From ff0495a265e0e30b050676dce8e10a84caa32193 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 17:05:42 +0100 Subject: [PATCH] feat: add basic terminal_view --- src/renderer/vaxis/renderer.zig | 2 +- src/shell.zig | 2 +- src/tui/mainview.zig | 35 +++++ src/tui/mode/overlay/task_palette.zig | 9 +- src/tui/terminal_view.zig | 198 ++++++++++++++++++++++++++ src/tui/tui.zig | 21 +++ 6 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 src/tui/terminal_view.zig diff --git a/src/renderer/vaxis/renderer.zig b/src/renderer/vaxis/renderer.zig index 3ed5f28..99df9a7 100644 --- a/src/renderer/vaxis/renderer.zig +++ b/src/renderer/vaxis/renderer.zig @@ -4,7 +4,7 @@ const cbor = @import("cbor"); const log = @import("log"); const Style = @import("theme").Style; const Color = @import("theme").Color; -const vaxis = @import("vaxis"); +pub const vaxis = @import("vaxis"); const input = @import("input"); const builtin = @import("builtin"); const RGB = @import("color").RGB; diff --git a/src/shell.zig b/src/shell.zig index 7e30390..b5ddfa7 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -304,7 +304,7 @@ const Process = struct { } }; -fn parse_arg0_to_argv(allocator: std.mem.Allocator, arg0: *[]const u8) !tp.message { +pub fn parse_arg0_to_argv(allocator: std.mem.Allocator, arg0: *[]const u8) !tp.message { // this is horribly simplistic // TODO: add quotes parsing and workspace variables, etc. var args: std.ArrayList([]const u8) = .empty; diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 178f7e5..e611785 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -34,6 +34,7 @@ const filelist_view = @import("filelist_view.zig"); const info_view = @import("info_view.zig"); const input_view = @import("inputview.zig"); const keybind_view = @import("keybindview.zig"); +const terminal_view = @import("terminal_view.zig"); const Self = @This(); const Commands = command.Collection(cmds); @@ -313,6 +314,28 @@ fn toggle_panel_view(self: *Self, view: anytype, mode: enum { toggle, enable, di tui.resize(); } +fn toggle_panel_view_with_args(self: *Self, view: anytype, mode: enum { toggle, enable, disable }, ctx: command.Context) !void { + if (self.panels) |panels| { + if (self.get_panel(@typeName(view))) |w| { + if (mode != .enable) { + panels.remove(w.*); + if (panels.empty()) { + self.widgets.remove(panels.widget()); + self.panels = null; + } + } + } else { + if (mode != .disable) + try panels.add(try view.create_with_args(self.allocator, self.widgets.plane, ctx)); + } + } else if (mode != .disable) { + const panels = try WidgetList.createH(self.allocator, self.widgets.plane, "panel", .{ .static = self.get_panel_height() }); + try self.widgets.add(panels.widget()); + try panels.add(try view.create_with_args(self.allocator, self.widgets.plane, ctx)); + self.panels = panels; + } + tui.resize(); +} fn get_panel(self: *Self, name_: []const u8) ?*Widget { if (self.panels) |panels| for (panels.widgets.items) |*w| @@ -911,6 +934,8 @@ const cmds = struct { try self.toggle_panel_view(keybind_view, .toggle) else if (self.is_panel_view_showing(input_view)) try self.toggle_panel_view(input_view, .toggle) + else if (self.is_panel_view_showing(terminal_view)) + try self.toggle_panel_view(terminal_view, .toggle) else try self.toggle_panel_view(logview, .toggle); } @@ -946,6 +971,16 @@ const cmds = struct { } pub const show_inspector_view_meta: Meta = .{}; + pub fn toggle_terminal_view(self: *Self, _: Ctx) Result { + try self.toggle_panel_view(terminal_view, .toggle); + } + pub const toggle_terminal_view_meta: Meta = .{ .description = "Toggle terminal" }; + + pub fn open_terminal(self: *Self, ctx: Ctx) Result { + try self.toggle_panel_view_with_args(terminal_view, .enable, ctx); + } + pub const open_terminal_meta: Meta = .{ .description = "Open terminal", .arguments = &.{.string} }; + pub fn close_find_in_files_results(self: *Self, _: Ctx) Result { if (self.file_list_type == .find_in_files) try self.toggle_panel_view(filelist_view, .disable); diff --git a/src/tui/mode/overlay/task_palette.zig b/src/tui/mode/overlay/task_palette.zig index 6dbaa1e..54d3ae2 100644 --- a/src/tui/mode/overlay/task_palette.zig +++ b/src/tui/mode/overlay/task_palette.zig @@ -38,6 +38,7 @@ pub fn load_entries(palette: *Type) !usize { var longest_hint: usize = 0; longest_hint = @max(longest_hint, try add_palette_command(palette, "add_task", hints)); longest_hint = @max(longest_hint, try add_palette_command(palette, "palette_menu_delete_item", hints)); + longest_hint = @max(longest_hint, try add_palette_command(palette, "run_task_in_terminal", hints)); return longest_hint - @min(longest_hint, longest) + 3; } @@ -129,13 +130,19 @@ fn select(menu: **Type.MenuType, button: *Type.ButtonType, _: Type.Pos) void { var entry: Entry = undefined; var iter = button.opts.label; if (!(cbor.matchValue(&iter, cbor.extract(&entry)) catch false)) return; + const activate = menu.*.opts.ctx.activate; + menu.*.opts.ctx.activate = .normal; if (entry.command) |command_name| { tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); tp.self_pid().send(.{ "cmd", command_name, .{} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); } else { tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); project_manager.add_task(entry.label) catch {}; - tp.self_pid().send(.{ "cmd", "run_task", .{entry.label} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); + const run_cmd = switch (activate) { + .normal => "run_task", + .alternate => "run_task_in_terminal", + }; + tp.self_pid().send(.{ "cmd", run_cmd, .{entry.label} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); } } diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig new file mode 100644 index 0000000..73150f4 --- /dev/null +++ b/src/tui/terminal_view.zig @@ -0,0 +1,198 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const tp = @import("thespian"); +const cbor = @import("cbor"); +const command = @import("command"); +const vaxis = @import("renderer").vaxis; +const shell = @import("shell"); + +const Plane = @import("renderer").Plane; +const Widget = @import("Widget.zig"); +const WidgetList = @import("WidgetList.zig"); +const MessageFilter = @import("MessageFilter.zig"); +const tui = @import("tui.zig"); + +pub const name = @typeName(Self); + +const Self = @This(); +const widget_type: Widget.Type = .panel; + +const Terminal = vaxis.widgets.Terminal; + +/// Poll interval in microseconds – how often we check the pty for new output. +/// 16 ms ≈ 60 Hz; Flow's render loop will coalesce multiple need_render calls. +const poll_interval_us: u64 = 16 * std.time.us_per_ms; + +allocator: Allocator, +plane: Plane, +vt: Terminal, +env: std.process.EnvMap, +write_buf: [4096]u8, +poll_timer: ?tp.Cancellable = null, + +pub fn create(allocator: Allocator, parent: Plane) !Widget { + return create_with_args(allocator, parent, .{}); +} + +pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Context) !Widget { + const self = try allocator.create(Self); + errdefer allocator.destroy(self); + + const container = try WidgetList.createHStyled( + allocator, + parent, + "panel_frame", + .dynamic, + widget_type, + ); + + var plane = try Plane.init(&(Widget.Box{}).opts(name), parent); + errdefer plane.deinit(); + + var env = try std.process.getEnvMap(allocator); + errdefer env.deinit(); + + var cmd_arg: []const u8 = ""; + const argv_msg: ?tp.message = if (ctx.args.match(.{tp.extract(&cmd_arg)}) catch false and cmd_arg.len > 0) + try shell.parse_arg0_to_argv(allocator, &cmd_arg) + else + null; + defer if (argv_msg) |msg| allocator.free(msg.buf); + + var argv_list: std.ArrayListUnmanaged([]const u8) = .empty; + defer argv_list.deinit(allocator); + if (argv_msg) |msg| { + var iter = msg.buf; + var len = try cbor.decodeArrayHeader(&iter); + while (len > 0) : (len -= 1) { + var arg: []const u8 = undefined; + if (try cbor.matchValue(&iter, cbor.extract(&arg))) + try argv_list.append(allocator, arg); + } + } else { + try argv_list.append(allocator, env.get("SHELL") orelse "bash"); + } + const argv: []const []const u8 = argv_list.items; + const home = env.get("HOME") orelse "/tmp"; + + // Use the current plane dimensions for the initial pty size. The plane + // starts at 0×0 before the first resize, so use a sensible fallback + // so the pty isn't created with a zero-cell screen. + const cols: u16 = @intCast(@max(80, plane.dim_x())); + const rows: u16 = @intCast(@max(24, plane.dim_y())); + + // write_buf must outlive the Terminal because the pty writer holds a + // pointer into it. It lives inside Self so the lifetimes match. + self.write_buf = undefined; + const vt = try Terminal.init( + allocator, + argv, + &env, + .{ + .winsize = .{ .rows = rows, .cols = cols, .x_pixel = 0, .y_pixel = 0 }, + .scrollback_size = 0, + .initial_working_directory = blk: { + const project = tp.env.get().str("project"); + break :blk if (project.len > 0) project else home; + }, + }, + &self.write_buf, + ); + + self.* = .{ + .allocator = allocator, + .plane = plane, + .vt = vt, + .env = env, + .write_buf = undefined, // managed via self.vt's pty_writer pointer + .poll_timer = null, + }; + + try self.vt.spawn(); + + try tui.message_filters().add(MessageFilter.bind(self, receive_filter)); + + container.ctx = self; + try container.add(Widget.to(self)); + + self.schedule_poll(); + + return container.widget(); +} + +pub fn deinit(self: *Self, allocator: Allocator) void { + tui.message_filters().remove_ptr(self); + if (self.poll_timer) |*t| { + t.cancel() catch {}; + t.deinit(); + } + self.vt.deinit(); + self.env.deinit(); + self.plane.deinit(); + allocator.destroy(self); +} + +pub fn render(self: *Self, _: *const Widget.Theme) bool { + // Drain the vt event queue. + while (self.vt.tryEvent()) |event| { + switch (event) { + .exited => { + tp.self_pid().send(.{ "cmd", "toggle_terminal_view" }) catch {}; + return false; + }, + .redraw, .bell, .title_change, .pwd_change => {}, + } + } + + // Blit the terminal's front screen into our vaxis.Window. + self.vt.draw(self.allocator, self.plane.window) catch |e| { + std.log.err("terminal_view: draw failed: {}", .{e}); + }; + + return false; +} + +pub fn handle_resize(self: *Self, pos: Widget.Box) void { + self.plane.move_yx(@intCast(pos.y), @intCast(pos.x)) catch return; + self.plane.resize_simple(@intCast(pos.h), @intCast(pos.w)) catch return; + + const cols: u16 = @intCast(@max(1, pos.w)); + const rows: u16 = @intCast(@max(1, pos.h)); + self.vt.resize(.{ + .rows = rows, + .cols = cols, + .x_pixel = 0, + .y_pixel = 0, + }) catch |e| { + std.log.err("terminal_view: resize failed: {}", .{e}); + }; +} + +// The pty read thread pushes output into vt asynchronously. We use a +// recurring thespian delay_send to wake up every ~16 ms and check whether +// new output has arrived, requesting a render frame when it has. +fn schedule_poll(self: *Self) void { + self.poll_timer = tp.self_pid().delay_send_cancellable( + self.allocator, + "terminal_view.poll", + poll_interval_us, + .{"TERMINAL_VIEW_POLL"}, + ) catch null; +} + +fn receive_filter(self: *Self, _: tp.pid_ref, m: tp.message) MessageFilter.Error!bool { + if (try cbor.match(m.buf, .{"TERMINAL_VIEW_POLL"})) { + if (self.poll_timer) |*t| { + t.deinit(); + self.poll_timer = null; + } + + if (self.vt.dirty) + tui.need_render(@src()); + + self.schedule_poll(); + return true; + } + return false; +} diff --git a/src/tui/tui.zig b/src/tui/tui.zig index fa425da..574b714 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -1483,6 +1483,27 @@ const cmds = struct { .arguments = &.{.string}, }; + pub fn run_task_in_terminal(self: *Self, ctx: Ctx) Result { + const expansion = @import("expansion.zig"); + var task: []const u8 = undefined; + if (!try ctx.args.match(.{tp.extract(&task)})) return; + const args = expansion.expand_cbor(self.allocator, ctx.args.buf) catch |e| switch (e) { + error.NotFound => return error.Stop, + else => |e_| return e_, + }; + defer self.allocator.free(args); + var cmd: []const u8 = undefined; + if (!try cbor.match(args, .{tp.extract(&cmd)})) + cmd = task; + call_add_task(task); + var buf: [tp.max_message_size]u8 = undefined; + try command.executeName("open_terminal", try command.fmtbuf(&buf, .{cmd})); + } + pub const run_task_in_terminal_meta: Meta = .{ + .description = "Run a task in terminal", + .arguments = &.{.string}, + }; + pub fn delete_task(_: *Self, ctx: Ctx) Result { var task: []const u8 = undefined; if (!try ctx.args.match(.{tp.extract(&task)}))