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 01/70] 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 3ed5f280..99df9a73 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 7e303908..b5ddfa71 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 178f7e55..e6117858 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 6dbaa1e7..54d3ae27 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 00000000..73150f4c --- /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 fa425da4..574b7142 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)})) From f1a8efa318acbf74a195fffefa1c72363f732b95 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 17:30:13 +0100 Subject: [PATCH 02/70] feat: add {{project_name}} expansion variable --- src/tui/expansion.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/tui/expansion.zig b/src/tui/expansion.zig index c98ab393..d7050788 100644 --- a/src/tui/expansion.zig +++ b/src/tui/expansion.zig @@ -1,5 +1,6 @@ /// Expand variables in arg /// {{project}} - The path to the current project directory +/// {{project_name}} - The basename of the current project directory /// {{file}} - The path to the current file /// {{line}} - The line number of the primary cursor /// {{column}} - The column of the primary cursor @@ -76,6 +77,13 @@ const functions = struct { return try allocator.dupe(u8, tp.env.get().str("project")); } + pub fn project_name(allocator: Allocator) Error![]const u8 { + const project_ = tp.env.get().str("project"); + const basename_begin = std.mem.lastIndexOfScalar(u8, project_, std.fs.path.sep); + const basename = if (basename_begin) |begin| project_[begin + 1 ..] else project_; + return try allocator.dupe(u8, basename); + } + pub fn file(allocator: Allocator) Error![]const u8 { const mv = tui.mainview() orelse return &.{}; const ed = mv.get_active_editor() orelse return &.{}; From 9a68918adac72e18e605b0535e9a2573b36e9ec8 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 18:26:36 +0100 Subject: [PATCH 03/70] refactor: make Widget.focus/unfocus const --- src/tui/Widget.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/Widget.zig b/src/tui/Widget.zig index 6db55a94..6ee799ef 100644 --- a/src/tui/Widget.zig +++ b/src/tui/Widget.zig @@ -237,11 +237,11 @@ pub fn walk(self: *const Self, walk_ctx: *anyopaque, f: WalkFn) bool { return if (self.vtable.walk(self.ptr, walk_ctx, f)) true else f(walk_ctx, self.*); } -pub fn focus(self: *Self) void { +pub fn focus(self: *const Self) void { self.vtable.focus(self.ptr); } -pub fn unfocus(self: *Self) void { +pub fn unfocus(self: *const Self) void { self.vtable.unfocus(self.ptr); } From 367c532596cce7f6a79dd4296c08b0b2d3875646 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 19:12:59 +0100 Subject: [PATCH 04/70] refactor(terminal): route input to terminal_view when it is focused --- src/tui/mainview.zig | 8 ++++++++ src/tui/terminal_view.zig | 36 ++++++++++++++++++++++++++++++++++++ src/tui/tui.zig | 13 +++++++++++++ 3 files changed, 57 insertions(+) diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index e6117858..e4f7b946 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -981,6 +981,14 @@ const cmds = struct { } pub const open_terminal_meta: Meta = .{ .description = "Open terminal", .arguments = &.{.string} }; + pub fn focus_terminal(self: *Self, _: Ctx) Result { + if (self.get_panel_view(terminal_view)) |vt| + vt.focus() + else + try self.toggle_panel_view(terminal_view, .enable); + } + pub const focus_terminal_meta: Meta = .{ .description = "Focus terminal panel" }; + 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/terminal_view.zig b/src/tui/terminal_view.zig index 73150f4c..d16633eb 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -12,6 +12,7 @@ const Widget = @import("Widget.zig"); const WidgetList = @import("WidgetList.zig"); const MessageFilter = @import("MessageFilter.zig"); const tui = @import("tui.zig"); +const input = @import("input"); pub const name = @typeName(Self); @@ -30,6 +31,7 @@ vt: Terminal, env: std.process.EnvMap, write_buf: [4096]u8, poll_timer: ?tp.Cancellable = null, +focused: bool = false, pub fn create(allocator: Allocator, parent: Plane) !Widget { return create_with_args(allocator, parent, .{}); @@ -121,7 +123,41 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex return container.widget(); } +pub fn receive(self: *Self, _: tp.pid_ref, m: tp.message) error{Exit}!bool { + if (!self.focused) return false; + var evtype: u8 = 0; + var keycode: u21 = 0; + var shifted: u21 = 0; + var text: []const u8 = ""; + var mods: u8 = 0; + if (!(try m.match(.{ "I", tp.extract(&evtype), tp.extract(&keycode), tp.extract(&shifted), tp.extract(&text), tp.extract(&mods) }))) + return false; + // Only forward press and repeat events; ignore releases. + if (evtype != input.event.press and evtype != input.event.repeat) return true; + const key: vaxis.Key = .{ + .codepoint = keycode, + .shifted_codepoint = if (shifted != keycode) shifted else null, + .mods = @bitCast(mods), + .text = if (text.len > 0) text else null, + }; + self.vt.update(.{ .key_press = key }) catch |e| + std.log.err("terminal_view: input failed: {}", .{e}); + tui.need_render(@src()); + return true; +} + +pub fn focus(self: *Self) void { + self.focused = true; + tui.set_keyboard_focus(Widget.to(self)); +} + +pub fn unfocus(self: *Self) void { + self.focused = false; + tui.release_keyboard_focus(Widget.to(self)); +} + pub fn deinit(self: *Self, allocator: Allocator) void { + if (self.focused) tui.release_keyboard_focus(Widget.to(self)); tui.message_filters().remove_ptr(self); if (self.poll_timer) |*t| { t.cancel() catch {}; diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 574b7142..756ccfce 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -842,6 +842,19 @@ pub fn set_focus_by_mouse_event() FocusAction { return mv.focus_view_by_widget(self.hover_focus orelse return .notfound); } +pub fn set_keyboard_focus(w: Widget) void { + const self = current(); + if (self.keyboard_focus) |prev| prev.unfocus(); + self.keyboard_focus = w; +} + +pub fn release_keyboard_focus(w: Widget) void { + const self = current(); + if (self.keyboard_focus) |cur| if (cur.ptr == w.ptr) { + self.keyboard_focus = null; + }; +} + fn send_widgets(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { const frame = tracy.initZone(@src(), .{ .name = "tui widgets" }); defer frame.deinit(); From d423696e7edf3aff444e5e42751ce5da21b6cc9b Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 19:14:34 +0100 Subject: [PATCH 05/70] refactor(terminal): handle title_change and pwd_change events --- src/tui/terminal_view.zig | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index d16633eb..610663d7 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -32,6 +32,8 @@ env: std.process.EnvMap, write_buf: [4096]u8, poll_timer: ?tp.Cancellable = null, focused: bool = false, +cwd: std.ArrayListUnmanaged(u8) = .empty, +title: std.ArrayListUnmanaged(u8) = .empty, pub fn create(allocator: Allocator, parent: Plane) !Widget { return create_with_args(allocator, parent, .{}); @@ -158,6 +160,8 @@ pub fn unfocus(self: *Self) void { pub fn deinit(self: *Self, allocator: Allocator) void { if (self.focused) tui.release_keyboard_focus(Widget.to(self)); + self.cwd.deinit(self.allocator); + self.title.deinit(self.allocator); tui.message_filters().remove_ptr(self); if (self.poll_timer) |*t| { t.cancel() catch {}; @@ -177,7 +181,15 @@ pub fn render(self: *Self, _: *const Widget.Theme) bool { tp.self_pid().send(.{ "cmd", "toggle_terminal_view" }) catch {}; return false; }, - .redraw, .bell, .title_change, .pwd_change => {}, + .redraw, .bell => {}, + .pwd_change => |path| { + self.cwd.clearRetainingCapacity(); + self.cwd.appendSlice(self.allocator, path) catch {}; + }, + .title_change => |t| { + self.title.clearRetainingCapacity(); + self.title.appendSlice(self.allocator, t) catch {}; + }, } } From f8dd9f85b6da6fd368d60462e6bc016814fb29ed Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 21:14:08 +0100 Subject: [PATCH 06/70] refactor(terminal): move pty input processing to an actor --- src/tui/terminal_view.zig | 142 +++++++++++++++++++++++++++++--------- 1 file changed, 108 insertions(+), 34 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 610663d7..2e1bdbf2 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -21,16 +21,12 @@ 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, +pty_pid: ?tp.pid = null, focused: bool = false, cwd: std.ArrayListUnmanaged(u8) = .empty, title: std.ArrayListUnmanaged(u8) = .empty, @@ -110,7 +106,7 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex .vt = vt, .env = env, .write_buf = undefined, // managed via self.vt's pty_writer pointer - .poll_timer = null, + .pty_pid = null, }; try self.vt.spawn(); @@ -120,12 +116,16 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex container.ctx = self; try container.add(Widget.to(self)); - self.schedule_poll(); + self.pty_pid = try pty.spawn(allocator, &self.vt); return container.widget(); } pub fn receive(self: *Self, _: tp.pid_ref, m: tp.message) error{Exit}!bool { + if (try m.match(.{ "terminal_view", "output" })) { + tui.need_render(@src()); + return true; + } if (!self.focused) return false; var evtype: u8 = 0; var keycode: u21 = 0; @@ -162,10 +162,10 @@ pub fn deinit(self: *Self, allocator: Allocator) void { if (self.focused) tui.release_keyboard_focus(Widget.to(self)); self.cwd.deinit(self.allocator); self.title.deinit(self.allocator); - tui.message_filters().remove_ptr(self); - if (self.poll_timer) |*t| { - t.cancel() catch {}; - t.deinit(); + if (self.pty_pid) |pid| { + pid.send(.{ "pty_actor", "quit" }) catch {}; + pid.deinit(); + self.pty_pid = null; } self.vt.deinit(); self.env.deinit(); @@ -217,30 +217,104 @@ pub fn handle_resize(self: *Self, pos: Widget.Box) void { }; } -// 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(); +fn receive_filter(_: *Self, _: tp.pid_ref, m: tp.message) MessageFilter.Error!bool { + if (m.match(.{ "terminal_view", "output" }) catch false) { + tui.need_render(@src()); return true; } return false; } + +const pty = struct { + const Parser = Terminal.Parser; + + const Receiver = tp.Receiver(*@This()); + + allocator: std.mem.Allocator, + vt: *Terminal, + fd: tp.file_descriptor, + pty_fd: std.posix.fd_t, + parser: Parser, + receiver: Receiver, + parent: tp.pid, + + pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid { + const self = try allocator.create(@This()); + errdefer allocator.destroy(self); + self.* = .{ + .allocator = allocator, + .vt = vt, + .fd = undefined, + .pty_fd = vt.ptyFd(), + .parser = .{ .buf = try .initCapacity(allocator, 128) }, + .receiver = Receiver.init(pty_receive, self), + .parent = tp.self_pid().clone(), + }; + return tp.spawn_link(allocator, self, start, "pty_actor"); + } + + fn deinit(self: *@This()) void { + self.fd.deinit(); + self.parser.buf.deinit(); + self.parent.deinit(); + self.allocator.destroy(self); + } + + fn start(self: *@This()) tp.result { + errdefer self.deinit(); + self.fd = tp.file_descriptor.init("pty", self.pty_fd) catch |e| return tp.exit_error(e, @errorReturnTrace()); + self.fd.wait_read() catch |e| return tp.exit_error(e, @errorReturnTrace()); + tp.receive(&self.receiver); + } + + fn pty_receive(self: *@This(), _: tp.pid_ref, m: tp.message) tp.result { + errdefer self.deinit(); + + if (try m.match(.{ "fd", "pty", "read_ready" })) { + try self.read_and_process(); + return; + } + + if (try m.match(.{ "pty_actor", "quit" })) { + self.deinit(); + return; + } + } + + fn read_and_process(self: *@This()) tp.result { + var buf: [4096]u8 = undefined; + + while (true) { + const n = std.posix.read(self.vt.ptyFd(), &buf) catch |e| switch (e) { + error.WouldBlock => break, + error.InputOutput => { + self.vt.event_queue.push(.exited); + self.vt.cmd.wait(); + self.deinit(); + return; + }, + else => return tp.exit_error(e, @errorReturnTrace()), + }; + if (n == 0) { + self.vt.event_queue.push(.exited); + self.vt.cmd.wait(); + self.deinit(); + return; + } + + const exited = self.vt.processOutput(&self.parser, buf[0..n]) catch |e| + return tp.exit_error(e, @errorReturnTrace()); + if (exited) { + self.vt.cmd.wait(); + // Notify parent so it drains the .exited event on its next render. + self.parent.send(.{ "terminal_view", "output" }) catch {}; + self.deinit(); + return; + } + // Notify parent that new output is available. + self.parent.send(.{ "terminal_view", "output" }) catch {}; + } + + self.fd.wait_read() catch |e| return tp.exit_error(e, @errorReturnTrace()); + } +}; From 7de0d27a543b879cc86864f14d24476ad29c2e6b Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 23:09:15 +0100 Subject: [PATCH 07/70] refactor(terminal): update libvaxis for external pty read loop support --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index bd7db6ae..3330840e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#1f6c7222f59607bff0ee8d7c6a0637a05bceffcd", - .hash = "vaxis-0.5.1-BWNV_CNLCQDmr-D_UzqGRAngktQt7hiGTRf1gyozwxcG", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#b8ed67716720c4d07517d85577af2181b6c00f10", + .hash = "vaxis-0.5.1-BWNV_KRMCQAiYfM6aZa28DFk9SrsVVs0KuyMnzdMGHvb", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", From 3d1658541a3f86b5e52156795f2c0eee5b8caaac Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 23:13:52 +0100 Subject: [PATCH 08/70] refactor: allow tui.keyboard_focus widget to ignore input --- src/tui/tui.zig | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 756ccfce..aeab6c60 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -870,10 +870,9 @@ fn send_widgets(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { fn send_mouse(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.message) tp.result { tp.trace(tp.channel.input, m); _ = self.input_listeners_.send(from, m) catch {}; - if (self.keyboard_focus) |w| { - _ = try w.send(from, m); - return; - } + if (self.keyboard_focus) |w| + if (try w.send(from, m)) + return; if (try self.update_hover(y, x)) |w| _ = try w.send(from, m); } @@ -882,8 +881,8 @@ fn send_mouse_drag(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.mess tp.trace(tp.channel.input, m); _ = self.input_listeners_.send(from, m) catch {}; if (self.keyboard_focus) |w| { - _ = try w.send(from, m); - return; + if (try w.send(from, m)) + return; } _ = try self.update_hover(y, x); if (self.drag_source) |w| _ = try w.send(from, m); From 613b95c2af72330202f56c97bc09b10576e58643 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 23:14:38 +0100 Subject: [PATCH 09/70] refactor: make focus_termimal a toggle --- src/tui/mainview.zig | 9 ++++++--- src/tui/terminal_view.zig | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index e4f7b946..6bde6fce 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -982,10 +982,13 @@ const cmds = struct { pub const open_terminal_meta: Meta = .{ .description = "Open terminal", .arguments = &.{.string} }; pub fn focus_terminal(self: *Self, _: Ctx) Result { - if (self.get_panel_view(terminal_view)) |vt| - vt.focus() - else + if (self.get_panel_view(terminal_view)) |vt| { + vt.toggle_focus(); + } else { try self.toggle_panel_view(terminal_view, .enable); + if (self.get_panel_view(terminal_view)) |vt| + vt.focus(); + } } pub const focus_terminal_meta: Meta = .{ .description = "Focus terminal panel" }; diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 2e1bdbf2..e4d4d7a6 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -148,6 +148,10 @@ pub fn receive(self: *Self, _: tp.pid_ref, m: tp.message) error{Exit}!bool { return true; } +pub fn toggle_focus(self: *Self) void { + if (self.focused) self.unfocus() else self.focus(); +} + pub fn focus(self: *Self) void { self.focused = true; tui.set_keyboard_focus(Widget.to(self)); From 66433415741798e76db4ab2f1164eca96576de84 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 23:15:15 +0100 Subject: [PATCH 10/70] refactor: support direct calling of keybind.BindingSet --- src/keybind/keybind.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/keybind/keybind.zig b/src/keybind/keybind.zig index 632c049c..eb3df1a0 100644 --- a/src/keybind/keybind.zig +++ b/src/keybind/keybind.zig @@ -667,7 +667,7 @@ const BindingSet = struct { } } - fn receive(self: *const @This(), _: tp.pid_ref, m: tp.message) error{Exit}!bool { + pub fn receive(self: *const @This(), _: tp.pid_ref, m: tp.message) error{Exit}!bool { var event: input.Event = 0; var keypress: input.Key = 0; var keypress_shifted: input.Key = 0; @@ -696,6 +696,7 @@ const BindingSet = struct { } for (binding.commands) |*cmd| try cmd.execute(); + return true; } } else if (try m.match(.{"F"})) { self.flush() catch |e| return tp.exit_error(e, @errorReturnTrace()); From 3d81631679aa36a4ab0e8e650fe6c99cb3d3eba9 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 23:16:23 +0100 Subject: [PATCH 11/70] refactor: add binding set on_match_failure nothing mode --- src/keybind/keybind.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/keybind/keybind.zig b/src/keybind/keybind.zig index eb3df1a0..fd6b4441 100644 --- a/src/keybind/keybind.zig +++ b/src/keybind/keybind.zig @@ -485,7 +485,7 @@ const BindingSet = struct { deinit_command: ?Command = null, const KeySyntax = enum { flow, vim }; - const OnMatchFailure = enum { insert, ignore }; + const OnMatchFailure = enum { insert, ignore, nothing }; fn load(allocator: std.mem.Allocator, namespace_name: []const u8, config_section: []const u8, mode_bindings: std.json.Value, fallback: ?*const BindingSet, namespace: *Namespace) (error{ OutOfMemory, WriteFailed } || parse_flow.ParseError || parse_vim.ParseError || std.json.ParseFromValueError)!@This() { var self: @This() = .{ .name = undefined, .config_section = config_section, .selection_style = undefined }; @@ -787,6 +787,7 @@ const BindingSet = struct { else log_keyhints_message(), .ignore => log_keyhints_message(), + .nothing => {}, } globals.current_sequence.clearRetainingCapacity(); globals.current_sequence_egc.clearRetainingCapacity(); From 341c65233385b9d4db435104a8f7bc3fe583719a Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 23:17:21 +0100 Subject: [PATCH 12/70] refactor: process terminal mode keybindings --- src/tui/terminal_view.zig | 43 +++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index e4d4d7a6..5adc65f0 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -13,6 +13,8 @@ const WidgetList = @import("WidgetList.zig"); const MessageFilter = @import("MessageFilter.zig"); const tui = @import("tui.zig"); const input = @import("input"); +const keybind = @import("keybind"); +pub const Mode = keybind.Mode; pub const name = @typeName(Self); @@ -30,6 +32,7 @@ pty_pid: ?tp.pid = null, focused: bool = false, cwd: std.ArrayListUnmanaged(u8) = .empty, title: std.ArrayListUnmanaged(u8) = .empty, +input_mode: Mode, pub fn create(allocator: Allocator, parent: Plane) !Widget { return create_with_args(allocator, parent, .{}); @@ -107,6 +110,7 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex .env = env, .write_buf = undefined, // managed via self.vt's pty_writer pointer .pty_pid = null, + .input_mode = try keybind.mode("terminal", allocator, .{}), }; try self.vt.spawn(); @@ -121,25 +125,38 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex return container.widget(); } -pub fn receive(self: *Self, _: tp.pid_ref, m: tp.message) error{Exit}!bool { +pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { if (try m.match(.{ "terminal_view", "output" })) { tui.need_render(@src()); return true; - } - if (!self.focused) return false; - var evtype: u8 = 0; - var keycode: u21 = 0; - var shifted: u21 = 0; - var text: []const u8 = ""; - var mods: u8 = 0; - if (!(try m.match(.{ "I", tp.extract(&evtype), tp.extract(&keycode), tp.extract(&shifted), tp.extract(&text), tp.extract(&mods) }))) + } else if (!(try m.match(.{ "I", tp.more }) + // or + // try m.match(.{ "B", tp.more }) or + // try m.match(.{ "D", tp.more }) or + // try m.match(.{ "M", tp.more }) + )) return false; + + if (!self.focused) return false; + + if (try self.input_mode.bindings.receive(from, m)) + return true; + + var event: input.Event = 0; + var keypress: input.Key = 0; + var keypress_shifted: input.Key = 0; + var text: []const u8 = ""; + var modifiers: u8 = 0; + + if (!try m.match(.{ "I", tp.extract(&event), tp.extract(&keypress), tp.extract(&keypress_shifted), tp.extract(&text), tp.extract(&modifiers) })) + return false; + // Only forward press and repeat events; ignore releases. - if (evtype != input.event.press and evtype != input.event.repeat) return true; + if (event != input.event.press and event != input.event.repeat) return true; const key: vaxis.Key = .{ - .codepoint = keycode, - .shifted_codepoint = if (shifted != keycode) shifted else null, - .mods = @bitCast(mods), + .codepoint = keypress, + .shifted_codepoint = if (keypress_shifted != keypress) keypress_shifted else null, + .mods = @bitCast(modifiers), .text = if (text.len > 0) text else null, }; self.vt.update(.{ .key_press = key }) catch |e| From cc6c84be154f424331cd600ed9df0cde9ce3073f Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 23:18:58 +0100 Subject: [PATCH 13/70] refactor: add flow mode keybinds to focus_terminal --- src/keybind/builtin/flow.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 9ce0934d..3c2ad936 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -23,6 +23,7 @@ ["ctrl+6", "focus_split", 5], ["ctrl+7", "focus_split", 6], ["ctrl+8", "focus_split", 7], + ["ctrl+`", "focus_terminal"], ["ctrl+j", "toggle_panel"], ["ctrl+q", "quit"], ["ctrl+w", "close_split"], @@ -581,5 +582,11 @@ ["enter", "mini_mode_select"], ["backspace", "mini_mode_delete_backwards"] ] + }, + "terminal": { + "on_match_failure": "nothing", + "press": [ + ["ctrl+`", "focus_terminal"] + ] } } From 43b46d179f89ae2fa9d5cf35baed63705b64f665 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 24 Feb 2026 23:23:12 +0100 Subject: [PATCH 14/70] fix: don't insert when in terminal mode --- src/tui/terminal_view.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 5adc65f0..cd0b960a 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -110,7 +110,7 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex .env = env, .write_buf = undefined, // managed via self.vt's pty_writer pointer .pty_pid = null, - .input_mode = try keybind.mode("terminal", allocator, .{}), + .input_mode = try keybind.mode("terminal", allocator, .{ .insert_command = "do_nothing" }), }; try self.vt.spawn(); From 5c2ae846022b07e38429d9f5e2b85db95e862e6d Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 09:36:22 +0100 Subject: [PATCH 15/70] refactor(terminal): render terminal unfocused state --- build.zig.zon | 4 ++-- src/tui/terminal_view.zig | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 3330840e..0105f0c9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#b8ed67716720c4d07517d85577af2181b6c00f10", - .hash = "vaxis-0.5.1-BWNV_KRMCQAiYfM6aZa28DFk9SrsVVs0KuyMnzdMGHvb", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#525cd36ed3e52e5a4787092672bcad728e03487c", + .hash = "vaxis-0.5.1-BWNV_EJOCQCbXYSOktimDKBUitbfuwLJtUOAIP2hD6Xg", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index cd0b960a..3f4bbdae 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -215,7 +215,7 @@ pub fn render(self: *Self, _: *const Widget.Theme) bool { } // Blit the terminal's front screen into our vaxis.Window. - self.vt.draw(self.allocator, self.plane.window) catch |e| { + self.vt.draw(self.allocator, self.plane.window, self.focused) catch |e| { std.log.err("terminal_view: draw failed: {}", .{e}); }; From 558c59368bd08668eb094c076645736c9899d4a9 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 10:54:46 +0100 Subject: [PATCH 16/70] refactor(terminal): report child exit status --- build.zig.zon | 4 ++-- src/tui/terminal_view.zig | 45 ++++++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 0105f0c9..3f133683 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#525cd36ed3e52e5a4787092672bcad728e03487c", - .hash = "vaxis-0.5.1-BWNV_EJOCQCbXYSOktimDKBUitbfuwLJtUOAIP2hD6Xg", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#c3897fa1b939e5d7a28c5e596d2a77cecc2d4841", + .hash = "vaxis-0.5.1-BWNV_DNOCQDusSCszNwnYmi7HmVWLa1IQJj3TH5oaifJ", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 3f4bbdae..a33c4733 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -198,9 +198,9 @@ 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; + .exited => |code| { + self.show_exit_message(code); + tui.need_render(@src()); }, .redraw, .bell => {}, .pwd_change => |path| { @@ -222,6 +222,21 @@ pub fn render(self: *Self, _: *const Widget.Theme) bool { return false; } +fn show_exit_message(self: *Self, code: u8) void { + var msg: std.Io.Writer.Allocating = .init(self.allocator); + defer msg.deinit(); + const w = &msg.writer; + w.writeAll("\r\n") catch {}; + w.writeAll("\x1b[0m\x1b[2m") catch {}; + w.writeAll("[process exited") catch {}; + if (code != 0) + w.print(" with code {d}", .{code}) catch {}; + w.writeAll("]\x1b[0m\r\n") catch {}; + var parser: pty.Parser = .{ .buf = .init(self.allocator) }; + defer parser.buf.deinit(); + _ = self.vt.processOutput(&parser, msg.written()) catch {}; +} + 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; @@ -297,8 +312,7 @@ const pty = struct { } if (try m.match(.{ "pty_actor", "quit" })) { - self.deinit(); - return; + return tp.exit_normal(); } } @@ -309,28 +323,25 @@ const pty = struct { const n = std.posix.read(self.vt.ptyFd(), &buf) catch |e| switch (e) { error.WouldBlock => break, error.InputOutput => { - self.vt.event_queue.push(.exited); - self.vt.cmd.wait(); - self.deinit(); - return; + const code = self.vt.cmd.wait(); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); }, else => return tp.exit_error(e, @errorReturnTrace()), }; if (n == 0) { - self.vt.event_queue.push(.exited); - self.vt.cmd.wait(); - self.deinit(); - return; + const code = self.vt.cmd.wait(); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); } const exited = self.vt.processOutput(&self.parser, buf[0..n]) catch |e| return tp.exit_error(e, @errorReturnTrace()); if (exited) { - self.vt.cmd.wait(); - // Notify parent so it drains the .exited event on its next render. self.parent.send(.{ "terminal_view", "output" }) catch {}; - self.deinit(); - return; + return tp.exit_normal(); } // Notify parent that new output is available. self.parent.send(.{ "terminal_view", "output" }) catch {}; From 7d51b09aac98b74dec8450342eae5ecb1ebff37d Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 10:56:45 +0100 Subject: [PATCH 17/70] refactor(terminal): add click-to-focus handling for terminal --- src/tui/terminal_view.zig | 16 +++++++++++++++- src/tui/tui.zig | 18 +++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index a33c4733..87a39667 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -33,6 +33,7 @@ focused: bool = false, cwd: std.ArrayListUnmanaged(u8) = .empty, title: std.ArrayListUnmanaged(u8) = .empty, input_mode: Mode, +hover: bool = false, pub fn create(allocator: Allocator, parent: Plane) !Widget { return create_with_args(allocator, parent, .{}); @@ -129,7 +130,20 @@ pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { if (try m.match(.{ "terminal_view", "output" })) { tui.need_render(@src()); return true; - } else if (!(try m.match(.{ "I", tp.more }) + } else if (try m.match(.{ "H", tp.extract(&self.hover) })) { + tui.rdr().request_mouse_cursor_default(self.hover); + tui.need_render(@src()); + return true; + } + if (try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON1), tp.more }) or + try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON2), tp.more }) or + try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON3), tp.more })) + switch (tui.set_focus_by_mouse_event()) { + .changed => return true, + .same, .notfound => {}, + }; + + if (!(try m.match(.{ "I", tp.more }) // or // try m.match(.{ "B", tp.more }) or // try m.match(.{ "D", tp.more }) or diff --git a/src/tui/tui.zig b/src/tui/tui.zig index aeab6c60..836be973 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -833,13 +833,23 @@ pub const FocusAction = enum { same, changed, notfound }; pub fn set_focus_by_widget(w: Widget) FocusAction { const mv = mainview() orelse return .notfound; + clear_keyboard_focus(); return mv.focus_view_by_widget(w); } pub fn set_focus_by_mouse_event() FocusAction { const self = current(); const mv = mainview() orelse return .notfound; - return mv.focus_view_by_widget(self.hover_focus orelse return .notfound); + const hover_focus = self.hover_focus orelse return .notfound; + const keyboard_focus = if (self.keyboard_focus) |prev| prev.ptr else null; + if (hover_focus.ptr == keyboard_focus) return .same; + clear_keyboard_focus(); + switch (mv.focus_view_by_widget(hover_focus)) { + .notfound => {}, + else => |action| return action, + } + hover_focus.focus(); + return .changed; } pub fn set_keyboard_focus(w: Widget) void { @@ -855,6 +865,12 @@ pub fn release_keyboard_focus(w: Widget) void { }; } +pub fn clear_keyboard_focus() void { + const self = current(); + if (self.keyboard_focus) |prev| prev.unfocus(); + self.keyboard_focus = null; +} + fn send_widgets(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { const frame = tracy.initZone(@src(), .{ .name = "tui widgets" }); defer frame.deinit(); From aff2a7919b2c7ab185a483a9a2dec0378835594c Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 10:58:17 +0100 Subject: [PATCH 18/70] fix: don't dispatch mouse and widget events to keyboard_focus widget --- src/tui/tui.zig | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 836be973..38911201 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -875,9 +875,7 @@ fn send_widgets(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { const frame = tracy.initZone(@src(), .{ .name = "tui widgets" }); defer frame.deinit(); tp.trace(tp.channel.widget, m); - return if (self.keyboard_focus) |w| - w.send(from, m) - else if (self.mainview_) |mv| + return if (self.mainview_) |mv| mv.send(from, m) else false; @@ -886,9 +884,6 @@ fn send_widgets(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { fn send_mouse(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.message) tp.result { tp.trace(tp.channel.input, m); _ = self.input_listeners_.send(from, m) catch {}; - if (self.keyboard_focus) |w| - if (try w.send(from, m)) - return; if (try self.update_hover(y, x)) |w| _ = try w.send(from, m); } @@ -896,10 +891,6 @@ fn send_mouse(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.message) fn send_mouse_drag(self: *Self, y: c_int, x: c_int, from: tp.pid_ref, m: tp.message) tp.result { tp.trace(tp.channel.input, m); _ = self.input_listeners_.send(from, m) catch {}; - if (self.keyboard_focus) |w| { - if (try w.send(from, m)) - return; - } _ = try self.update_hover(y, x); if (self.drag_source) |w| _ = try w.send(from, m); } From 330d2b1f66e9564460d5b08e5cca8d376445975e Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 11:19:23 +0100 Subject: [PATCH 19/70] fix(terminal): focus switching --- src/tui/editor.zig | 2 +- src/tui/mainview.zig | 1 + src/tui/tui.zig | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index bdac85ac..2c4fbe32 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -7479,7 +7479,7 @@ pub const EditorWidget = struct { fn mouse_click_event(self: *Self, event: input.Event, btn: input.Mouse, y: c_int, x: c_int, ypx: c_int, xpx: c_int) Result { if (event != input.event.press) return; - if (!self.focused) switch (btn) { + if (!self.focused or tui.is_keyboard_focused()) switch (btn) { input.mouse.BUTTON1, input.mouse.BUTTON2, input.mouse.BUTTON3 => _ = tui.set_focus_by_mouse_event(), else => {}, }; diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 6bde6fce..91772e41 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1825,6 +1825,7 @@ pub fn focus_view_by_widget(self: *Self, w: Widget) tui.FocusAction { } pub fn focus_view(self: *Self, n: usize) !void { + tui.clear_keyboard_focus(); if (n == self.active_view) return; if (n > self.views.widgets.items.len) return; if (n == self.views.widgets.items.len) diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 38911201..7243e037 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -852,6 +852,11 @@ pub fn set_focus_by_mouse_event() FocusAction { return .changed; } +pub fn is_keyboard_focused() bool { + const self = current(); + return self.keyboard_focus != null; +} + pub fn set_keyboard_focus(w: Widget) void { const self = current(); if (self.keyboard_focus) |prev| prev.unfocus(); From ee7a3ed2ced9b8bfc4ed46b9daf185f21cd6657e Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 11:20:03 +0100 Subject: [PATCH 20/70] refactor(terminal): add more terminal mode keybinds --- src/keybind/builtin/flow.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 3c2ad936..6cd5363a 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -586,7 +586,17 @@ "terminal": { "on_match_failure": "nothing", "press": [ - ["ctrl+`", "focus_terminal"] + ["ctrl+1", "focus_split", 0], + ["ctrl+2", "focus_split", 1], + ["ctrl+3", "focus_split", 2], + ["ctrl+4", "focus_split", 3], + ["ctrl+5", "focus_split", 4], + ["ctrl+6", "focus_split", 5], + ["ctrl+7", "focus_split", 6], + ["ctrl+8", "focus_split", 7], + ["ctrl+`", "focus_terminal"], + ["ctrl+j", "toggle_panel"], + ["alt+f9", "panel_next_widget_style"] ] } } From 7e01eae3892ba20d9e70fb16c6453f438703b1dc Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 11:40:14 +0100 Subject: [PATCH 21/70] refactor(terminal): add palette keybindings to terminal mode --- src/keybind/builtin/flow.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 6cd5363a..de0d4f2f 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -596,6 +596,9 @@ ["ctrl+8", "focus_split", 7], ["ctrl+`", "focus_terminal"], ["ctrl+j", "toggle_panel"], + ["ctrl+shift+p", "open_command_palette"], + ["alt+shift+p", "open_command_palette"], + ["alt+x", "open_command_palette"], ["alt+f9", "panel_next_widget_style"] ] } From f17ceb282a90b7444717ec499ce11b26f5b40444 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 11:55:20 +0100 Subject: [PATCH 22/70] refactor(terminal): add run_task keybind to terminal mode --- src/keybind/builtin/flow.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index de0d4f2f..37993966 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -599,6 +599,7 @@ ["ctrl+shift+p", "open_command_palette"], ["alt+shift+p", "open_command_palette"], ["alt+x", "open_command_palette"], + ["alt+!", "run_task"], ["alt+f9", "panel_next_widget_style"] ] } From 45de943d84bbdba8b228cccbd3634c5182bfea87 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 11:59:10 +0100 Subject: [PATCH 23/70] refactor(terminal): store/restore keyboard_focus when entering/exiting overlay modes --- src/tui/tui.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 7243e037..0420e15b 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -53,6 +53,7 @@ delayed_init_input_mode: ?Mode = null, input_mode_outer_: ?Mode = null, input_listeners_: EventHandler.List, keyboard_focus: ?Widget = null, +keyboard_focus_outer: ?Widget = null, mini_mode_: ?MiniMode = null, hover_focus: ?Widget = null, last_hover_x: c_int = -1, @@ -948,10 +949,12 @@ pub fn save_config() (root.ConfigDirError || root.ConfigWriteError)!void { pub fn is_mainview_focused() bool { const self = current(); - return self.mini_mode_ == null and self.input_mode_outer_ == null; + return self.mini_mode_ == null and self.input_mode_outer_ == null and !is_keyboard_focused(); } fn enter_overlay_mode(self: *Self, mode: type) command.Result { + self.keyboard_focus_outer = self.keyboard_focus; + clear_keyboard_focus(); command.executeName("disable_fast_scroll", .{}) catch {}; command.executeName("disable_alt_scroll", .{}) catch {}; command.executeName("disable_jump_mode", .{}) catch {}; @@ -1552,6 +1555,8 @@ const cmds = struct { if (self.input_mode_) |*mode| mode.deinit(); self.input_mode_ = self.input_mode_outer_; self.input_mode_outer_ = null; + if (self.keyboard_focus_outer) |widget| if (self.is_live_widget_ptr(widget)) + widget.focus(); refresh_hover(@src()); } pub const exit_overlay_mode_meta: Meta = .{}; From 582d3d1066b4e65f76aad04ad7dfa64e209fae09 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 12:31:39 +0100 Subject: [PATCH 24/70] refactor(terminal): reduce terminal logging in release builds --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 3f133683..5ea83ccd 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#c3897fa1b939e5d7a28c5e596d2a77cecc2d4841", - .hash = "vaxis-0.5.1-BWNV_DNOCQDusSCszNwnYmi7HmVWLa1IQJj3TH5oaifJ", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#03fe1b123afdaf879829da507f54e42780d4ec65", + .hash = "vaxis-0.5.1-BWNV_EJOCQCOD5tzfVqOgDUSYfvBLrokxE0fr9MSb05q", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", From 316b65a0f779329b7469ee3315f8a65031c0761d Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 14:57:48 +0100 Subject: [PATCH 25/70] refactor: add support for dotnet test output file links --- src/file_link.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/file_link.zig b/src/file_link.zig index bde12b11..7f9409c2 100644 --- a/src/file_link.zig +++ b/src/file_link.zig @@ -43,6 +43,11 @@ pub fn parse(link: []const u8) error{InvalidFileLink}!Dest { file.path = link; break :blk null; }; + } else if (line_.len > 5 and std.mem.eql(u8, "line ", line_[0..5])) { + file.line = std.fmt.parseInt(usize, line_[5..], 10) catch blk: { + file.path = link; + break :blk null; + }; } else { file.line = std.fmt.parseInt(usize, line_, 10) catch blk: { file.path = link; From 61a509cf2fef82fb164a426c8a3e4668d7f549f7 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 16:31:29 +0100 Subject: [PATCH 26/70] refactor(terminal): persist terminal state across terminal view/show operations --- src/tui/terminal_view.zig | 147 +++++++++++++++++++++----------------- 1 file changed, 83 insertions(+), 64 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 87a39667..d9d123b3 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -25,24 +25,16 @@ const Terminal = vaxis.widgets.Terminal; allocator: Allocator, plane: Plane, -vt: Terminal, -env: std.process.EnvMap, -write_buf: [4096]u8, -pty_pid: ?tp.pid = null, focused: bool = false, -cwd: std.ArrayListUnmanaged(u8) = .empty, -title: std.ArrayListUnmanaged(u8) = .empty, input_mode: Mode, hover: bool = false, +vt: *Vt, 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, @@ -77,8 +69,6 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex } 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 @@ -86,43 +76,23 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex 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, - ); + if (global_vt == null) try Vt.init(allocator, argv_list.items, env, rows, cols); + + const self = try allocator.create(Self); + errdefer allocator.destroy(self); self.* = .{ .allocator = allocator, .plane = plane, - .vt = vt, - .env = env, - .write_buf = undefined, // managed via self.vt's pty_writer pointer - .pty_pid = null, .input_mode = try keybind.mode("terminal", allocator, .{ .insert_command = "do_nothing" }), + .vt = &global_vt.?, }; - try self.vt.spawn(); - try tui.message_filters().add(MessageFilter.bind(self, receive_filter)); container.ctx = self; try container.add(Widget.to(self)); - self.pty_pid = try pty.spawn(allocator, &self.vt); - return container.widget(); } @@ -173,7 +143,7 @@ pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { .mods = @bitCast(modifiers), .text = if (text.len > 0) text else null, }; - self.vt.update(.{ .key_press = key }) catch |e| + self.vt.vt.update(.{ .key_press = key }) catch |e| std.log.err("terminal_view: input failed: {}", .{e}); tui.need_render(@src()); return true; @@ -195,22 +165,17 @@ pub fn unfocus(self: *Self) void { pub fn deinit(self: *Self, allocator: Allocator) void { if (self.focused) tui.release_keyboard_focus(Widget.to(self)); - self.cwd.deinit(self.allocator); - self.title.deinit(self.allocator); - if (self.pty_pid) |pid| { - pid.send(.{ "pty_actor", "quit" }) catch {}; - pid.deinit(); - self.pty_pid = null; - } - self.vt.deinit(); - self.env.deinit(); + // if (state) |*p| { + // p.deinit(self.allocator); + // state = null; + // } 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| { + while (self.vt.vt.tryEvent()) |event| { switch (event) { .exited => |code| { self.show_exit_message(code); @@ -218,18 +183,18 @@ pub fn render(self: *Self, _: *const Widget.Theme) bool { }, .redraw, .bell => {}, .pwd_change => |path| { - self.cwd.clearRetainingCapacity(); - self.cwd.appendSlice(self.allocator, path) catch {}; + self.vt.cwd.clearRetainingCapacity(); + self.vt.cwd.appendSlice(self.allocator, path) catch {}; }, .title_change => |t| { - self.title.clearRetainingCapacity(); - self.title.appendSlice(self.allocator, t) catch {}; + self.vt.title.clearRetainingCapacity(); + self.vt.title.appendSlice(self.allocator, t) catch {}; }, } } // Blit the terminal's front screen into our vaxis.Window. - self.vt.draw(self.allocator, self.plane.window, self.focused) catch |e| { + self.vt.vt.draw(self.allocator, self.plane.window, self.focused) catch |e| { std.log.err("terminal_view: draw failed: {}", .{e}); }; @@ -248,23 +213,13 @@ fn show_exit_message(self: *Self, code: u8) void { w.writeAll("]\x1b[0m\r\n") catch {}; var parser: pty.Parser = .{ .buf = .init(self.allocator) }; defer parser.buf.deinit(); - _ = self.vt.processOutput(&parser, msg.written()) catch {}; + _ = self.vt.vt.processOutput(&parser, msg.written()) catch {}; } 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}); - }; + self.vt.resize(pos); } fn receive_filter(_: *Self, _: tp.pid_ref, m: tp.message) MessageFilter.Error!bool { @@ -275,6 +230,70 @@ fn receive_filter(_: *Self, _: tp.pid_ref, m: tp.message) MessageFilter.Error!bo return false; } +const Vt = struct { + vt: Terminal, + env: std.process.EnvMap, + write_buf: [4096]u8, + pty_pid: ?tp.pid = null, + cwd: std.ArrayListUnmanaged(u8) = .empty, + title: std.ArrayListUnmanaged(u8) = .empty, + + fn init(allocator: std.mem.Allocator, argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16) !void { + const home = env.get("HOME") orelse "/tmp"; + + global_vt = .{ + .vt = undefined, + .env = env, + .write_buf = undefined, // managed via self.vt's pty_writer pointer + .pty_pid = null, + }; + const self = &global_vt.?; + self.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, + ); + + try self.vt.spawn(); + self.pty_pid = try pty.spawn(allocator, &self.vt); + } + + fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + self.cwd.deinit(allocator); + self.title.deinit(allocator); + if (self.pty_pid) |pid| { + pid.send(.{ "pty_actor", "quit" }) catch {}; + pid.deinit(); + self.pty_pid = null; + } + self.vt.deinit(); + self.env.deinit(); + } + + pub fn resize(self: *@This(), pos: Widget.Box) void { + 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: resize failed: {}", .{e}); + }; + } +}; +var global_vt: ?Vt = null; + const pty = struct { const Parser = Terminal.Parser; From 69b0885f4b27db30d217c44d05c7fe53afb93528 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 19:12:49 +0100 Subject: [PATCH 27/70] fix(terminal): properly catch child EOF And be much more explicit about error handling. --- build.zig.zon | 4 +-- src/tui/terminal_view.zig | 70 +++++++++++++++++++++++++++++---------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 5ea83ccd..09d3c13a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#03fe1b123afdaf879829da507f54e42780d4ec65", - .hash = "vaxis-0.5.1-BWNV_EJOCQCOD5tzfVqOgDUSYfvBLrokxE0fr9MSb05q", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#8fba2b2b14174f73a957b3b4b0e1c30f25a78577", + .hash = "vaxis-0.5.1-BWNV_HJQCQAVq-jmKeSh0Ur4DLbkc3DUP-amXI3k22TB", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index d9d123b3..bbc27467 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -271,7 +271,7 @@ const Vt = struct { self.cwd.deinit(allocator); self.title.deinit(allocator); if (self.pty_pid) |pid| { - pid.send(.{ "pty_actor", "quit" }) catch {}; + pid.send(.{"quit"}) catch {}; pid.deinit(); self.pty_pid = null; } @@ -340,16 +340,20 @@ const pty = struct { errdefer self.deinit(); if (try m.match(.{ "fd", "pty", "read_ready" })) { - try self.read_and_process(); - return; - } - - if (try m.match(.{ "pty_actor", "quit" })) { + self.read_and_process() catch |e| return switch (e) { + error.Terminated => tp.exit_normal(), + error.InputOutput => tp.exit_normal(), + error.SendFailed => tp.exit_normal(), + error.Unexpected => tp.exit_normal(), + }; + } else if (try m.match(.{"quit"})) { return tp.exit_normal(); + } else { + return tp.unexpected(m); } } - fn read_and_process(self: *@This()) tp.result { + fn read_and_process(self: *@This()) error{ Terminated, InputOutput, SendFailed, Unexpected }!void { var buf: [4096]u8 = undefined; while (true) { @@ -359,27 +363,57 @@ const pty = struct { const code = self.vt.cmd.wait(); self.vt.event_queue.push(.{ .exited = code }); self.parent.send(.{ "terminal_view", "output" }) catch {}; - return tp.exit_normal(); + return error.InputOutput; + }, + error.SystemResources, + error.IsDir, + error.OperationAborted, + error.BrokenPipe, + error.ConnectionResetByPeer, + error.ConnectionTimedOut, + error.NotOpenForReading, + error.SocketNotConnected, + error.Canceled, + error.AccessDenied, + error.ProcessNotFound, + error.LockViolation, + error.Unexpected, + => { + std.log.err("terminal_view: read unexpected: {}", .{e}); + return error.Unexpected; }, - else => return tp.exit_error(e, @errorReturnTrace()), }; if (n == 0) { const code = self.vt.cmd.wait(); self.vt.event_queue.push(.{ .exited = code }); self.parent.send(.{ "terminal_view", "output" }) catch {}; - return tp.exit_normal(); + return error.Terminated; } - const exited = self.vt.processOutput(&self.parser, buf[0..n]) catch |e| - return tp.exit_error(e, @errorReturnTrace()); - if (exited) { - self.parent.send(.{ "terminal_view", "output" }) catch {}; - return tp.exit_normal(); + defer self.parent.send(.{ "terminal_view", "output" }) catch {}; + + switch (self.vt.processOutput(&self.parser, buf[0..n]) catch |e| switch (e) { + error.WriteFailed, + error.ReadFailed, + error.OutOfMemory, + error.Utf8InvalidStartByte, + => { + std.log.err("terminal_view: processOutput unexpected: {}", .{e}); + return error.Unexpected; + }, + }) { + .exited => { + return error.Terminated; + }, + .running => {}, } - // Notify parent that new output is available. - self.parent.send(.{ "terminal_view", "output" }) catch {}; } - self.fd.wait_read() catch |e| return tp.exit_error(e, @errorReturnTrace()); + self.fd.wait_read() catch |e| switch (e) { + error.ThespianFileDescriptorWaitReadFailed => { + std.log.err("terminal_view: wait_read unexpected: {}", .{e}); + return error.Unexpected; + }, + }; } }; From 3e265dade5d9b03d1d78d736ac85408a8ac2f43b Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 20:33:14 +0100 Subject: [PATCH 28/70] feat(terminal): add scrollback support --- build.zig.zon | 4 ++-- src/tui/terminal_view.zig | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 09d3c13a..83fc7493 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#8fba2b2b14174f73a957b3b4b0e1c30f25a78577", - .hash = "vaxis-0.5.1-BWNV_HJQCQAVq-jmKeSh0Ur4DLbkc3DUP-amXI3k22TB", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#8561409c5cd0e1799118d188764ac3d6edae6445", + .hash = "vaxis-0.5.1-BWNV_MlsCQBt3YUcMDQoQIhq4I8rS2ElBX9rRjpktJBQ", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index bbc27467..ba658876 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -113,6 +113,15 @@ pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { .same, .notfound => {}, }; + if (try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON4), tp.more })) { + if (self.vt.vt.scroll(3)) tui.need_render(@src()); + return true; + } + if (try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON5), tp.more })) { + if (self.vt.vt.scroll(-3)) tui.need_render(@src()); + return true; + } + if (!(try m.match(.{ "I", tp.more }) // or // try m.match(.{ "B", tp.more }) or @@ -143,6 +152,7 @@ pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { .mods = @bitCast(modifiers), .text = if (text.len > 0) text else null, }; + self.vt.vt.scrollToBottom(); self.vt.vt.update(.{ .key_press = key }) catch |e| std.log.err("terminal_view: input failed: {}", .{e}); tui.need_render(@src()); From f88f7794105b513756313ab1adafbec201613d97 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 20:33:41 +0100 Subject: [PATCH 29/70] refactor(terminal): add scrollback size configuration option --- src/config.zig | 1 + src/tui/terminal_view.zig | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.zig b/src/config.zig index 194a60d7..c4572b00 100644 --- a/src/config.zig +++ b/src/config.zig @@ -12,6 +12,7 @@ gutter_width_minimum: usize = 4, gutter_width_maximum: usize = 8, enable_terminal_cursor: bool = true, enable_terminal_color_scheme: bool = false, +terminal_scrollback_size: u16 = 500, enable_sgr_pixel_mode_support: bool = true, enable_modal_dim: bool = true, highlight_current_line: bool = true, diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index ba658876..1aa8eb73 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -264,7 +264,7 @@ const Vt = struct { &env, .{ .winsize = .{ .rows = rows, .cols = cols, .x_pixel = 0, .y_pixel = 0 }, - .scrollback_size = 0, + .scrollback_size = tui.config().terminal_scrollback_size, .initial_working_directory = blk: { const project = tp.env.get().str("project"); break :blk if (project.len > 0) project else home; From 41576388920ec2bb4ce6310caa30f0a91a49e42b Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 20:34:13 +0100 Subject: [PATCH 30/70] refactor(terminal): add force quit keybinding to terminal mode --- src/keybind/builtin/flow.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 37993966..818a41ae 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -600,7 +600,8 @@ ["alt+shift+p", "open_command_palette"], ["alt+x", "open_command_palette"], ["alt+!", "run_task"], - ["alt+f9", "panel_next_widget_style"] + ["alt+f9", "panel_next_widget_style"], + ["ctrl+shift+q", "quit_without_saving"] ] } } From 4affdf56882d5227a39af44a1b40e4ae29bf4f98 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 20:59:22 +0100 Subject: [PATCH 31/70] refactor(terminal): add keyboard scrolling keybinds --- src/keybind/builtin/flow.json | 2 ++ src/tui/terminal_view.zig | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 818a41ae..b27c000e 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -39,6 +39,8 @@ ["ctrl+shift+f", "find_in_files"], ["ctrl+shift+l", "toggle_panel"], ["alt+shift+p", "open_command_palette"], + ["ctrl+shift+page_up", "terminal_scroll_up"], + ["ctrl+shift+page_down", "terminal_scroll_down"], ["alt+n", "goto_next_file_or_diagnostic"], ["alt+p", "goto_prev_file_or_diagnostic"], ["alt+l", "toggle_panel"], diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 1aa8eb73..389751af 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -29,6 +29,7 @@ focused: bool = false, input_mode: Mode, hover: bool = false, vt: *Vt, +commands: Commands = undefined, pub fn create(allocator: Allocator, parent: Plane) !Widget { return create_with_args(allocator, parent, .{}); @@ -88,6 +89,7 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex .vt = &global_vt.?, }; + try self.commands.init(self); try tui.message_filters().add(MessageFilter.bind(self, receive_filter)); container.ctx = self; @@ -179,6 +181,7 @@ pub fn deinit(self: *Self, allocator: Allocator) void { // p.deinit(self.allocator); // state = null; // } + self.commands.unregister(); self.plane.deinit(); allocator.destroy(self); } @@ -240,6 +243,29 @@ fn receive_filter(_: *Self, _: tp.pid_ref, m: tp.message) MessageFilter.Error!bo return false; } +const Commands = command.Collection(cmds); + +const cmds = struct { + pub const Target = Self; + const Ctx = command.Context; + const Meta = command.Metadata; + const Result = command.Result; + + pub fn terminal_scroll_up(self: *Self, _: Ctx) Result { + const half_page = @max(1, self.vt.vt.front_screen.height / 2); + if (self.vt.vt.scroll(@intCast(half_page))) + tui.need_render(@src()); + } + pub const terminal_scroll_up_meta: Meta = .{ .description = "Terminal: Scroll up" }; + + pub fn terminal_scroll_down(self: *Self, _: Ctx) Result { + const half_page = @max(1, self.vt.vt.front_screen.height / 2); + if (self.vt.vt.scroll(-@as(i32, @intCast(half_page)))) + tui.need_render(@src()); + } + pub const terminal_scroll_down_meta: Meta = .{ .description = "Terminal: Scroll down" }; +}; + const Vt = struct { vt: Terminal, env: std.process.EnvMap, From 35ef58d0e12bd997a3e6fc55d15942b378a0cca4 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 21:12:35 +0100 Subject: [PATCH 32/70] refactor(terminal): fix vt cursor during scrollback --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 83fc7493..cecde0f5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#8561409c5cd0e1799118d188764ac3d6edae6445", - .hash = "vaxis-0.5.1-BWNV_MlsCQBt3YUcMDQoQIhq4I8rS2ElBX9rRjpktJBQ", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#855da7eb7e0991b360e1dcb630691465b725c761", + .hash = "vaxis-0.5.1-BWNV_OxtCQD40bC4JBVvCa4GjVRU4ESeFmYOUPcsuROG", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", From 3ad37b3b7034834eb33ba53e7455aa2cb344fc2f Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 21:18:58 +0100 Subject: [PATCH 33/70] refactor(terminal): shutdown terminal on exit or project switch --- src/tui/mainview.zig | 2 ++ src/tui/terminal_view.zig | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 91772e41..e3463aad 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -125,6 +125,7 @@ pub fn create(allocator: std.mem.Allocator) CreateError!Widget { pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { self.close_all_panel_views(); + terminal_view.shutdown(allocator); self.commands.deinit(); self.widgets.deinit(allocator); self.symbols.deinit(allocator); @@ -498,6 +499,7 @@ const cmds = struct { { self.closing_project = true; defer self.closing_project = false; + terminal_view.shutdown(self.allocator); try close_splits(self, .{}); try self.close_all_editors(); self.delete_all_buffers(); diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 389751af..89c6a2fa 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -177,15 +177,18 @@ pub fn unfocus(self: *Self) void { pub fn deinit(self: *Self, allocator: Allocator) void { if (self.focused) tui.release_keyboard_focus(Widget.to(self)); - // if (state) |*p| { - // p.deinit(self.allocator); - // state = null; - // } self.commands.unregister(); self.plane.deinit(); allocator.destroy(self); } +pub fn shutdown(allocator: Allocator) void { + if (global_vt) |*vt| { + vt.deinit(allocator); + global_vt = null; + } +} + pub fn render(self: *Self, _: *const Widget.Theme) bool { // Drain the vt event queue. while (self.vt.vt.tryEvent()) |event| { From 598c2a58aa1a6cb00697c06bec7ebe370c12bad5 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 21:48:49 +0100 Subject: [PATCH 34/70] refactor(terminal): add some debug logs for pty lifetime tracking --- src/tui/terminal_view.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 89c6a2fa..a6fc4129 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -316,6 +316,7 @@ const Vt = struct { } self.vt.deinit(); self.env.deinit(); + std.log.debug("terminal: vt destroyed", .{}); } pub fn resize(self: *@This(), pos: Widget.Box) void { @@ -366,6 +367,7 @@ const pty = struct { self.parser.buf.deinit(); self.parent.deinit(); self.allocator.destroy(self); + std.log.debug("terminal: pty destroyed", .{}); } fn start(self: *@This()) tp.result { From ec8379ce5123341e0cdf35ad4f7a2477d0466092 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Feb 2026 21:48:58 +0100 Subject: [PATCH 35/70] refactor(terminal): add restart keybind to terminal mode --- src/keybind/builtin/flow.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index b27c000e..d9462d42 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -603,7 +603,8 @@ ["alt+x", "open_command_palette"], ["alt+!", "run_task"], ["alt+f9", "panel_next_widget_style"], - ["ctrl+shift+q", "quit_without_saving"] + ["ctrl+shift+q", "quit_without_saving"], + ["ctrl+alt+shift+r", "restart"] ] } } From 05cba52397084ac2f2c53b25b8da31b51c77fd5c Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 20:29:36 +0100 Subject: [PATCH 36/70] fix: crash in View when panel is maximized --- src/buffer/View.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/buffer/View.zig b/src/buffer/View.zig index 1d5a89be..379785fc 100644 --- a/src/buffer/View.zig +++ b/src/buffer/View.zig @@ -109,8 +109,8 @@ fn clamp_row(self: *Self, cursor: *const Cursor, abs: bool, bottom_offset: usize } if (cursor.row < self.row) { self.row = 0; - } else if (cursor.row > self.row + self.rows - bottom_min_border_distance) { - self.row = cursor.row + bottom_min_border_distance - self.rows; + } else if (cursor.row > self.row + self.rows -| bottom_min_border_distance) { + self.row = cursor.row + bottom_min_border_distance -| self.rows; } } From 871d40f906be3b8d06b101b26019771d12174ef2 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 20:30:37 +0100 Subject: [PATCH 37/70] refactor: add toggle_panel_maximize command --- src/tui/mainview.zig | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index e3463aad..19cfde38 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -59,6 +59,7 @@ buffer_manager: Buffer.Manager, find_in_files_state: enum { init, adding, done } = .done, file_list_type: FileListType = .find_in_files, panel_height: ?usize = null, +panel_maximized: bool = false, symbols: std.ArrayListUnmanaged(u8) = .empty, symbols_complete: bool = true, closing_project: bool = false, @@ -260,6 +261,10 @@ pub fn handle_resize(self: *Self, pos: Box) void { if (self.panel_height) |h| if (h >= self.box().h) { self.panel_height = null; }; + if (self.panel_maximized) { + if (self.panels) |panels| + panels.layout_ = .{ .static = self.box().h -| 1 }; + } self.widgets.handle_resize(pos); self.floating_views.resize(pos); } @@ -281,6 +286,7 @@ fn bottom_bar_primary_drag(self: *Self, y: usize) tp.result { }; const h = self.plane.dim_y(); self.panel_height = @max(1, h - @min(h, y + 1)); + self.panel_maximized = false; panels.layout_ = .{ .static = self.panel_height.? }; if (self.panel_height == 1) { self.panel_height = null; @@ -943,6 +949,22 @@ const cmds = struct { } pub const toggle_panel_meta: Meta = .{ .description = "Toggle panel" }; + pub fn toggle_maximize_panel(self: *Self, _: Ctx) Result { + const panels = self.panels orelse return; + const max_h = self.box().h -| 1; + if (self.panel_maximized) { + // Restore previous height + self.panel_maximized = false; + panels.layout_ = .{ .static = self.get_panel_height() }; + } else { + // Maximize: fill screen minus status bar + self.panel_maximized = true; + panels.layout_ = .{ .static = max_h }; + } + tui.resize(); + } + pub const toggle_maximize_panel_meta: Meta = .{ .description = "Toggle maximize panel" }; + pub fn toggle_logview(self: *Self, _: Ctx) Result { try self.toggle_panel_view(logview, .toggle); } From 770fa884cd814fad3ef8848dd519331bbe7ce102 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 20:30:56 +0100 Subject: [PATCH 38/70] feat: add keybinds for toggle_maximize_panel --- src/keybind/builtin/flow.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index d9462d42..0415135b 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -25,6 +25,7 @@ ["ctrl+8", "focus_split", 7], ["ctrl+`", "focus_terminal"], ["ctrl+j", "toggle_panel"], + ["ctrl+shift+j", "toggle_maximize_panel"], ["ctrl+q", "quit"], ["ctrl+w", "close_split"], ["ctrl+o", "open_file"], @@ -602,6 +603,7 @@ ["alt+shift+p", "open_command_palette"], ["alt+x", "open_command_palette"], ["alt+!", "run_task"], + ["ctrl+shift+j", "toggle_maximize_panel"], ["alt+f9", "panel_next_widget_style"], ["ctrl+shift+q", "quit_without_saving"], ["ctrl+alt+shift+r", "restart"] From f68102e44878721c27dc0bfc19261ae482bdac53 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 20:31:16 +0100 Subject: [PATCH 39/70] feat: open terminal as default panel --- src/tui/mainview.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 19cfde38..985819c6 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -945,7 +945,7 @@ const cmds = struct { 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); + try focus_terminal(self, .{}); } pub const toggle_panel_meta: Meta = .{ .description = "Toggle panel" }; From 519d8dd886c605995248619c10e4ae7628c81c6c Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 20:52:06 +0100 Subject: [PATCH 40/70] feat(terminal): support OSC 10/11 query terminal fg/bg color --- src/tui/terminal_view.zig | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index a6fc4129..d62daf50 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -189,7 +189,7 @@ pub fn shutdown(allocator: Allocator) void { } } -pub fn render(self: *Self, _: *const Widget.Theme) bool { +pub fn render(self: *Self, theme: *const Widget.Theme) bool { // Drain the vt event queue. while (self.vt.vt.tryEvent()) |event| { switch (event) { @@ -209,6 +209,17 @@ pub fn render(self: *Self, _: *const Widget.Theme) bool { } } + // Update the terminal's fg/bg color cache from the current theme so that + // OSC 10/11 colour queries return accurate values. + if (theme.editor.fg) |fg| { + const c = fg.color; + self.vt.vt.fg_color = .{ @truncate(c >> 16), @truncate(c >> 8), @truncate(c) }; + } + if (theme.editor.bg) |bg| { + const c = bg.color; + self.vt.vt.bg_color = .{ @truncate(c >> 16), @truncate(c >> 8), @truncate(c) }; + } + // Blit the terminal's front screen into our vaxis.Window. self.vt.vt.draw(self.allocator, self.plane.window, self.focused) catch |e| { std.log.err("terminal_view: draw failed: {}", .{e}); From 424fd3efc3f80678333b4a561c72219d7dd2c9d1 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 20:54:06 +0100 Subject: [PATCH 41/70] refactor(terminal): add terminal to home screen menu --- src/keybind/builtin/flow.json | 3 ++- src/tui/home.zig | 2 ++ src/tui/mainview.zig | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 0415135b..a481efc8 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -353,6 +353,7 @@ ["alt+f9", "overlay_next_widget_style"], ["alt+!", "add_task"], ["ctrl+j", "toggle_panel"], + ["ctrl+shift+j", "toggle_maximize_panel"], ["ctrl+q", "quit"], ["ctrl+w", "close_file"], ["ctrl+shift+f", "find_in_files"], @@ -599,11 +600,11 @@ ["ctrl+8", "focus_split", 7], ["ctrl+`", "focus_terminal"], ["ctrl+j", "toggle_panel"], + ["ctrl+shift+j", "toggle_maximize_panel"], ["ctrl+shift+p", "open_command_palette"], ["alt+shift+p", "open_command_palette"], ["alt+x", "open_command_palette"], ["alt+!", "run_task"], - ["ctrl+shift+j", "toggle_maximize_panel"], ["alt+f9", "panel_next_widget_style"], ["ctrl+shift+q", "quit_without_saving"], ["ctrl+alt+shift+r", "restart"] diff --git a/src/tui/home.zig b/src/tui/home.zig index 9cdb16f0..40b41c03 100644 --- a/src/tui/home.zig +++ b/src/tui/home.zig @@ -34,6 +34,7 @@ const style = struct { \\open_recent_project \\find_in_files \\open_command_palette + \\focus_terminal \\run_task \\add_task \\open_config @@ -52,6 +53,7 @@ const style = struct { \\open_recent_project \\find_in_files \\open_command_palette + \\focus_terminal \\run_task \\add_task \\open_config diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 985819c6..4fe76b6b 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1014,7 +1014,7 @@ const cmds = struct { vt.focus(); } } - pub const focus_terminal_meta: Meta = .{ .description = "Focus terminal panel" }; + pub const focus_terminal_meta: Meta = .{ .description = "Terminal" }; pub fn close_find_in_files_results(self: *Self, _: Ctx) Result { if (self.file_list_type == .find_in_files) From 8a3cd776e944eb5da947cc129878aea5447c1b87 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 20:57:03 +0100 Subject: [PATCH 42/70] refactor(terminal): update libvaxis for Terminal --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index cecde0f5..54168675 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#855da7eb7e0991b360e1dcb630691465b725c761", - .hash = "vaxis-0.5.1-BWNV_OxtCQD40bC4JBVvCa4GjVRU4ESeFmYOUPcsuROG", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#e9527fc87f02b51577ac400e59580430a1580818", + .hash = "vaxis-0.5.1-BWNV_JaGCQBZcOkwVusASSBKQ9LYJcnBZ_vwtay8MAJ0", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", From 885c9682eb70875b9a96433174176d9b4001869d Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 21:18:28 +0100 Subject: [PATCH 43/70] refactor(terminal): add merged move_tab_next/prev_or_scroll_terminal_down/up commands --- src/keybind/builtin/flow.json | 8 ++++---- src/tui/mainview.zig | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index a481efc8..4e6b6dbe 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -40,8 +40,6 @@ ["ctrl+shift+f", "find_in_files"], ["ctrl+shift+l", "toggle_panel"], ["alt+shift+p", "open_command_palette"], - ["ctrl+shift+page_up", "terminal_scroll_up"], - ["ctrl+shift+page_down", "terminal_scroll_down"], ["alt+n", "goto_next_file_or_diagnostic"], ["alt+p", "goto_prev_file_or_diagnostic"], ["alt+l", "toggle_panel"], @@ -65,8 +63,8 @@ ["ctrl+shift+tab", "previous_tab"], ["ctrl+page_down", "next_tab"], ["ctrl+page_up", "previous_tab"], - ["ctrl+shift+page_down", "move_tab_next"], - ["ctrl+shift+page_up", "move_tab_previous"], + ["ctrl+shift+page_down", "move_tab_next_or_scroll_terminal_down"], + ["ctrl+shift+page_up", "move_tab_previous_or_scroll_terminal_up"], ["ctrl+k e", "switch_buffers"], ["alt+shift+v", "clipboard_history"], ["ctrl+0", "reset_fontsize"], @@ -600,6 +598,8 @@ ["ctrl+8", "focus_split", 7], ["ctrl+`", "focus_terminal"], ["ctrl+j", "toggle_panel"], + ["ctrl+shift+page_down", "terminal_scroll_down"], + ["ctrl+shift+page_up", "terminal_scroll_up"], ["ctrl+shift+j", "toggle_maximize_panel"], ["ctrl+shift+p", "open_command_palette"], ["alt+shift+p", "open_command_palette"], diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 4fe76b6b..76ad8461 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1591,6 +1591,22 @@ const cmds = struct { } pub const move_tab_previous_meta: Meta = .{ .description = "Move tab to previous position" }; + pub fn move_tab_next_or_scroll_terminal_down(self: *Self, _: Ctx) Result { + if (self.is_panel_view_showing(terminal_view)) + try command.executeName("terminal_scroll_down", .{}) + else + _ = try self.widgets_widget.msg(.{"move_tab_next"}); + } + pub const move_tab_next_or_scroll_terminal_down_meta: Meta = .{ .description = "Move tab next or scroll terminal down" }; + + pub fn move_tab_previous_or_scroll_terminal_up(self: *Self, _: Ctx) Result { + if (self.is_panel_view_showing(terminal_view)) + try command.executeName("terminal_scroll_up", .{}) + else + _ = try self.widgets_widget.msg(.{"move_tab_previous"}); + } + pub const move_tab_previous_or_scroll_terminal_up_meta: Meta = .{ .description = "Move tab previous or scroll terminal up" }; + pub fn place_next_tab(self: *Self, ctx: Ctx) Result { var pos: enum { before, after } = undefined; var buffer_ref: Buffer.Ref = undefined; From 4bba8d9715ada5829a553285a7dfc7041f5bb3ba Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 21:30:40 +0100 Subject: [PATCH 44/70] feat(terminal): handle OSC 52 clipboard requests --- src/tui/terminal_view.zig | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index d62daf50..e108e7ef 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -206,6 +206,29 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { self.vt.title.clearRetainingCapacity(); self.vt.title.appendSlice(self.allocator, t) catch {}; }, + .osc_copy => |text| { + // Terminal app wrote to clipboard via OSC 52. + // Add to flow clipboard history and forward to system clipboard. + const owned = tui.clipboard_allocator().dupe(u8, text) catch break; + tui.clipboard_clear_all(); + tui.clipboard_start_group(); + tui.clipboard_add_chunk(owned); + tui.clipboard_send_to_system() catch {}; + }, + .osc_paste_request => { + // Terminal app requested clipboard contents via OSC 52. + // Assemble from flow clipboard history and respond. + if (tui.clipboard_get_history()) |history| { + var buf: std.Io.Writer.Allocating = .init(self.allocator); + defer buf.deinit(); + var first = true; + for (history) |chunk| { + if (first) first = false else buf.writer.writeByte('\n') catch break; + buf.writer.writeAll(chunk.text) catch break; + } + self.vt.vt.respondOsc52Paste(buf.written()); + } + }, } } From 7e7cb511a8a0b776bf31c1025a68050d83b7b9aa Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 22:12:47 +0100 Subject: [PATCH 45/70] refactor(terminal): handle color_change events --- src/tui/terminal_view.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index e108e7ef..0936ea2e 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -206,6 +206,11 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { self.vt.title.clearRetainingCapacity(); self.vt.title.appendSlice(self.allocator, t) catch {}; }, + .color_change => |cc| { + self.vt.app_fg = cc.fg; + self.vt.app_bg = cc.bg; + self.vt.app_cursor = cc.cursor; + }, .osc_copy => |text| { // Terminal app wrote to clipboard via OSC 52. // Add to flow clipboard history and forward to system clipboard. @@ -310,6 +315,10 @@ const Vt = struct { pty_pid: ?tp.pid = null, cwd: std.ArrayListUnmanaged(u8) = .empty, title: std.ArrayListUnmanaged(u8) = .empty, + /// App-specified override colours (from OSC 10/11/12). null = use theme. + app_fg: ?[3]u8 = null, + app_bg: ?[3]u8 = null, + app_cursor: ?[3]u8 = null, fn init(allocator: std.mem.Allocator, argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16) !void { const home = env.get("HOME") orelse "/tmp"; From 49d4cda7efd1d24afcc761407a10c41825008791 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 22:18:28 +0100 Subject: [PATCH 46/70] refactor(terminal): add detailed exit debug logging --- src/tui/terminal_view.zig | 43 ++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 0936ea2e..0b84ae35 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -406,17 +406,23 @@ const pty = struct { } fn deinit(self: *@This()) void { + std.log.debug("terminal: pty actor deinit (pid={?})", .{self.vt.cmd.pid}); self.fd.deinit(); self.parser.buf.deinit(); self.parent.deinit(); self.allocator.destroy(self); - std.log.debug("terminal: pty destroyed", .{}); } fn start(self: *@This()) tp.result { errdefer self.deinit(); - self.fd = tp.file_descriptor.init("pty", self.pty_fd) catch |e| return tp.exit_error(e, @errorReturnTrace()); - self.fd.wait_read() catch |e| return tp.exit_error(e, @errorReturnTrace()); + self.fd = tp.file_descriptor.init("pty", self.pty_fd) catch |e| { + std.log.debug("terminal: pty fd init failed: {}", .{e}); + return tp.exit_error(e, @errorReturnTrace()); + }; + self.fd.wait_read() catch |e| { + std.log.debug("terminal: pty initial wait_read failed: {}", .{e}); + return tp.exit_error(e, @errorReturnTrace()); + }; tp.receive(&self.receiver); } @@ -425,14 +431,28 @@ const pty = struct { if (try m.match(.{ "fd", "pty", "read_ready" })) { self.read_and_process() catch |e| return switch (e) { - error.Terminated => tp.exit_normal(), - error.InputOutput => tp.exit_normal(), - error.SendFailed => tp.exit_normal(), - error.Unexpected => tp.exit_normal(), + error.Terminated => { + std.log.debug("terminal: pty exiting: read loop terminated (process exited)", .{}); + return tp.exit_normal(); + }, + error.InputOutput => { + std.log.debug("terminal: pty exiting: EIO on read (process exited)", .{}); + return tp.exit_normal(); + }, + error.SendFailed => { + std.log.debug("terminal: pty exiting: send to parent failed", .{}); + return tp.exit_normal(); + }, + error.Unexpected => { + std.log.debug("terminal: pty exiting: unexpected error (see preceding log)", .{}); + return tp.exit_normal(); + }, }; } else if (try m.match(.{"quit"})) { + std.log.debug("terminal: pty exiting: received quit", .{}); return tp.exit_normal(); } else { + std.log.debug("terminal: pty exiting: unexpected message", .{}); return tp.unexpected(m); } } @@ -445,6 +465,7 @@ const pty = struct { error.WouldBlock => break, error.InputOutput => { const code = self.vt.cmd.wait(); + std.log.debug("terminal: read EIO, process exited with code={d}", .{code}); self.vt.event_queue.push(.{ .exited = code }); self.parent.send(.{ "terminal_view", "output" }) catch {}; return error.InputOutput; @@ -463,12 +484,13 @@ const pty = struct { error.LockViolation, error.Unexpected, => { - std.log.err("terminal_view: read unexpected: {}", .{e}); + std.log.debug("terminal: read unexpected error: {} (pid={?})", .{ e, self.vt.cmd.pid }); return error.Unexpected; }, }; if (n == 0) { const code = self.vt.cmd.wait(); + std.log.debug("terminal: read returned 0 bytes (EOF), process exited with code={d}", .{code}); self.vt.event_queue.push(.{ .exited = code }); self.parent.send(.{ "terminal_view", "output" }) catch {}; return error.Terminated; @@ -482,11 +504,12 @@ const pty = struct { error.OutOfMemory, error.Utf8InvalidStartByte, => { - std.log.err("terminal_view: processOutput unexpected: {}", .{e}); + std.log.debug("terminal: processOutput error: {} (pid={?})", .{ e, self.vt.cmd.pid }); return error.Unexpected; }, }) { .exited => { + std.log.debug("terminal: processOutput returned .exited (process EOF)", .{}); return error.Terminated; }, .running => {}, @@ -495,7 +518,7 @@ const pty = struct { self.fd.wait_read() catch |e| switch (e) { error.ThespianFileDescriptorWaitReadFailed => { - std.log.err("terminal_view: wait_read unexpected: {}", .{e}); + std.log.debug("terminal: wait_read failed: {} (pid={?})", .{ e, self.vt.cmd.pid }); return error.Unexpected; }, }; From d98a40ab9e45ae4fd9c529a44f8e6a3860e7ebd5 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 22:21:09 +0100 Subject: [PATCH 47/70] refactor(terminal): update libvaxis for various terminal features and fixes --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 54168675..15896194 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#e9527fc87f02b51577ac400e59580430a1580818", - .hash = "vaxis-0.5.1-BWNV_JaGCQBZcOkwVusASSBKQ9LYJcnBZ_vwtay8MAJ0", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#1f16837cc6444f9323ac21a7988860f6a424b9d0", + .hash = "vaxis-0.5.1-BWNV_BazCQACrQg5CxqJoXx6A0SusCFlc8Rr-vgePzLr", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", From bd507d48e25a81f7ef33be8b1946595fc5d4b3d1 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 22:26:18 +0100 Subject: [PATCH 48/70] fix(terminal): prevent terminal disconnect on invalid UTF-8 --- build.zig.zon | 4 ++-- src/tui/terminal_view.zig | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 15896194..86597a15 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#1f16837cc6444f9323ac21a7988860f6a424b9d0", - .hash = "vaxis-0.5.1-BWNV_BazCQACrQg5CxqJoXx6A0SusCFlc8Rr-vgePzLr", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#db8eff78967d2414a678f93362fc88d624138d79", + .hash = "vaxis-0.5.1-BWNV_DKzCQBccS_dYazIT7jjvx1lNiFLng2jm3sdLjka", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 0b84ae35..39aa3719 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -502,7 +502,6 @@ const pty = struct { error.WriteFailed, error.ReadFailed, error.OutOfMemory, - error.Utf8InvalidStartByte, => { std.log.debug("terminal: processOutput error: {} (pid={?})", .{ e, self.vt.cmd.pid }); return error.Unexpected; From 737236db015614605d9c2408449f744f659defed Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 22:31:41 +0100 Subject: [PATCH 49/70] fix(terminal): avoid leaking ESC \ --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 86597a15..590a0639 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#db8eff78967d2414a678f93362fc88d624138d79", - .hash = "vaxis-0.5.1-BWNV_DKzCQBccS_dYazIT7jjvx1lNiFLng2jm3sdLjka", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#e83e3f871786fab577bb26e2e4800dd7e9bf4390", + .hash = "vaxis-0.5.1-BWNV_Ke0CQCnpGN5qbzPOFD1V_oBgcIWd1O0PrBYmTMa", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", From 632a7c445350d15ba43c16c6a83d28981020df3b Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 11:49:12 +0100 Subject: [PATCH 50/70] refactor(terminal): add pty read_error handler --- src/tui/terminal_view.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 39aa3719..0e0a3cd0 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -389,6 +389,7 @@ const pty = struct { parser: Parser, receiver: Receiver, parent: tp.pid, + err_code: i64 = 0, pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid { const self = try allocator.create(@This()); @@ -448,6 +449,12 @@ const pty = struct { return tp.exit_normal(); }, }; + } else if (try m.match(.{ "fd", "pty", "read_error", tp.extract(&self.err_code), tp.more })) { + const code = self.vt.cmd.wait(); + std.log.debug("terminal: read_error from fd (err={d}), process exited with code={d}", .{ self.err_code, code }); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); } else if (try m.match(.{"quit"})) { std.log.debug("terminal: pty exiting: received quit", .{}); return tp.exit_normal(); From 94f6b342fab7bdf9e9ed1d84c93a7aba54863d61 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 12:57:43 +0100 Subject: [PATCH 51/70] fix(terminal): setup SIGCHLD handler to catch exits with no writes --- build.zig.zon | 4 ++-- src/tui/terminal_view.zig | 43 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 590a0639..492fd798 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#e83e3f871786fab577bb26e2e4800dd7e9bf4390", - .hash = "vaxis-0.5.1-BWNV_Ke0CQCnpGN5qbzPOFD1V_oBgcIWd1O0PrBYmTMa", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#5727534c75c7490e3eab1ec65b6998fecf228165", + .hash = "vaxis-0.5.1-BWNV_Aq3CQCFVUB6Ie_kp0ftM2brpixkT_mOERADRiVD", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 0e0a3cd0..e619e536 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -390,6 +390,7 @@ const pty = struct { receiver: Receiver, parent: tp.pid, err_code: i64 = 0, + sigchld: ?tp.signal = null, pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid { const self = try allocator.create(@This()); @@ -408,6 +409,7 @@ const pty = struct { fn deinit(self: *@This()) void { std.log.debug("terminal: pty actor deinit (pid={?})", .{self.vt.cmd.pid}); + if (self.sigchld) |s| s.deinit(); self.fd.deinit(); self.parser.buf.deinit(); self.parent.deinit(); @@ -424,6 +426,10 @@ const pty = struct { std.log.debug("terminal: pty initial wait_read failed: {}", .{e}); return tp.exit_error(e, @errorReturnTrace()); }; + self.sigchld = tp.signal.init(std.posix.SIG.CHLD, tp.message.fmt(.{"sigchld"})) catch |e| { + std.log.debug("terminal: SIGCHLD signal init failed: {}", .{e}); + return tp.exit_error(e, @errorReturnTrace()); + }; tp.receive(&self.receiver); } @@ -450,11 +456,24 @@ const pty = struct { }, }; } else if (try m.match(.{ "fd", "pty", "read_error", tp.extract(&self.err_code), tp.more })) { + // thespian fires read_error with EPOLLHUP when the child exits cleanly. + // Treat it the same as EIO: reap the child and signal exit. const code = self.vt.cmd.wait(); std.log.debug("terminal: read_error from fd (err={d}), process exited with code={d}", .{ self.err_code, code }); self.vt.event_queue.push(.{ .exited = code }); self.parent.send(.{ "terminal_view", "output" }) catch {}; return tp.exit_normal(); + } else if (try m.match(.{"sigchld"})) { + // SIGCHLD fires when any child exits. Check if it's our child. + if (self.vt.cmd.try_wait()) |code| { + std.log.debug("terminal: child exited (SIGCHLD) with code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); + } + // Not our child (or already reaped) - re-arm the signal and continue. + if (self.sigchld) |s| s.deinit(); + self.sigchld = tp.signal.init(std.posix.SIG.CHLD, tp.message.fmt(.{"sigchld"})) catch null; } else if (try m.match(.{"quit"})) { std.log.debug("terminal: pty exiting: received quit", .{}); return tp.exit_normal(); @@ -469,7 +488,19 @@ const pty = struct { while (true) { const n = std.posix.read(self.vt.ptyFd(), &buf) catch |e| switch (e) { - error.WouldBlock => break, + error.WouldBlock => { + // No more data right now. Check if the child already exited - + // on Linux a clean exit may not make the pty fd readable again + // (no EPOLLIN), it just starts returning EIO on the next read. + // Polling here catches that case before we arm wait_read again. + if (self.vt.cmd.try_wait()) |code| { + std.log.debug("terminal: child exited (detected via try_wait) with code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return error.InputOutput; + } + break; + }, error.InputOutput => { const code = self.vt.cmd.wait(); std.log.debug("terminal: read EIO, process exited with code={d}", .{code}); @@ -522,6 +553,16 @@ const pty = struct { } } + // Check for child exit once more before sleeping in wait_read. + // A clean exit with no final output will never make the pty fd readable, + // so we must detect it here rather than waiting forever. + if (self.vt.cmd.try_wait()) |code| { + std.log.debug("terminal: child exited (pre-wait_read check) with code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return error.InputOutput; + } + self.fd.wait_read() catch |e| switch (e) { error.ThespianFileDescriptorWaitReadFailed => { std.log.debug("terminal: wait_read failed: {} (pid={?})", .{ e, self.vt.cmd.pid }); From a35edeaa9b27ec776cac452ecde1360a439bf6b0 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 13:00:08 +0100 Subject: [PATCH 52/70] refactor(terminal): add re-run command message --- build.zig.zon | 4 ++-- src/tui/terminal_view.zig | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 492fd798..84e13bba 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#5727534c75c7490e3eab1ec65b6998fecf228165", - .hash = "vaxis-0.5.1-BWNV_Aq3CQCFVUB6Ie_kp0ftM2brpixkT_mOERADRiVD", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#9c8e0a7d61c75ea6aee3b69761acfb36899709e6", + .hash = "vaxis-0.5.1-BWNV_LC4CQDtTTjkRwC90yjbqAtv2AnDiRpU0e8c_BtF", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index e619e536..44ebe0da 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -154,6 +154,13 @@ pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { .mods = @bitCast(modifiers), .text = if (text.len > 0) text else null, }; + if (self.vt.process_exited and (keypress == input.key.enter or keypress == '\r')) { + self.vt.process_exited = false; + self.restart() catch |e| + std.log.err("terminal_view: restart failed: {}", .{e}); + tui.need_render(@src()); + return true; + } self.vt.vt.scrollToBottom(); self.vt.vt.update(.{ .key_press = key }) catch |e| std.log.err("terminal_view: input failed: {}", .{e}); @@ -194,6 +201,7 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { while (self.vt.vt.tryEvent()) |event| { switch (event) { .exited => |code| { + self.vt.process_exited = true; self.show_exit_message(code); tui.need_render(@src()); }, @@ -266,11 +274,37 @@ fn show_exit_message(self: *Self, code: u8) void { if (code != 0) w.print(" with code {d}", .{code}) catch {}; w.writeAll("]\x1b[0m\r\n") catch {}; + // Build display command string from argv for the re-run prompt + const argv = self.vt.vt.cmd.argv; + if (argv.len > 0) { + w.writeAll("\x1b[0m\x1b[2mPress enter to re-run '") catch {}; + for (argv, 0..) |arg, i| { + if (i > 0) w.writeByte(' ') catch {}; + // Quote args that contain spaces + const needs_quote = std.mem.indexOfScalar(u8, arg, ' ') != null; + if (needs_quote) w.writeByte('"') catch {}; + w.writeAll(arg) catch {}; + if (needs_quote) w.writeByte('"') catch {}; + } + w.writeAll("'\x1b[0m\r\n") catch {}; + } var parser: pty.Parser = .{ .buf = .init(self.allocator) }; defer parser.buf.deinit(); _ = self.vt.vt.processOutput(&parser, msg.written()) catch {}; } +fn restart(self: *Self) !void { + // Kill the old pty actor if still alive + if (self.vt.pty_pid) |pid| { + pid.send(.{"quit"}) catch {}; + pid.deinit(); + self.vt.pty_pid = null; + } + // Re-spawn the child process and a fresh pty actor + try self.vt.vt.spawn(); + self.vt.pty_pid = try pty.spawn(self.allocator, &self.vt.vt); +} + 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; @@ -319,6 +353,7 @@ const Vt = struct { app_fg: ?[3]u8 = null, app_bg: ?[3]u8 = null, app_cursor: ?[3]u8 = null, + process_exited: bool = false, fn init(allocator: std.mem.Allocator, argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16) !void { const home = env.get("HOME") orelse "/tmp"; From fc78e8cf028398f6467c8bb13c8d3d2e374e9aaf Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 14:07:06 +0100 Subject: [PATCH 53/70] refactor: add argv module with helper functions --- build.zig | 10 +++++++ src/argv.zig | 33 +++++++++++++++++++++ src/list_languages.zig | 62 +++++++++++++++------------------------ src/tui/terminal_view.zig | 20 +++++-------- 4 files changed, 73 insertions(+), 52 deletions(-) create mode 100644 src/argv.zig diff --git a/build.zig b/build.zig index 8d3c126e..3168c030 100644 --- a/build.zig +++ b/build.zig @@ -392,6 +392,13 @@ pub fn build_exe( }, }); + const argv_mod = b.createModule(.{ + .root_source_file = b.path("src/argv.zig"), + .imports = &.{ + .{ .name = "cbor", .module = cbor_mod }, + }, + }); + const lsp_config_mod = b.createModule(.{ .root_source_file = b.path("src/lsp_config.zig"), .imports = &.{ @@ -660,6 +667,7 @@ pub fn build_exe( .{ .name = "project_manager", .module = project_manager_mod }, .{ .name = "syntax", .module = syntax_mod }, .{ .name = "text_manip", .module = text_manip_mod }, + .{ .name = "argv", .module = argv_mod }, .{ .name = "Buffer", .module = Buffer_mod }, .{ .name = "keybind", .module = keybind_mod }, .{ .name = "shell", .module = shell_mod }, @@ -709,6 +717,7 @@ pub fn build_exe( exe.root_module.addImport("cbor", cbor_mod); exe.root_module.addImport("config", config_mod); exe.root_module.addImport("text_manip", text_manip_mod); + exe.root_module.addImport("argv", argv_mod); exe.root_module.addImport("Buffer", Buffer_mod); exe.root_module.addImport("tui", tui_mod); exe.root_module.addImport("thespian", thespian_mod); @@ -759,6 +768,7 @@ pub fn build_exe( check_exe.root_module.addImport("cbor", cbor_mod); check_exe.root_module.addImport("config", config_mod); check_exe.root_module.addImport("text_manip", text_manip_mod); + check_exe.root_module.addImport("argv", argv_mod); check_exe.root_module.addImport("Buffer", Buffer_mod); check_exe.root_module.addImport("tui", tui_mod); check_exe.root_module.addImport("thespian", thespian_mod); diff --git a/src/argv.zig b/src/argv.zig new file mode 100644 index 00000000..3e6d5ec9 --- /dev/null +++ b/src/argv.zig @@ -0,0 +1,33 @@ +const std = @import("std"); + +/// Write a `[]const []const u8` argv array as a space-separated command string. +/// Args that contain spaces are wrapped in double-quotes. +/// Writes nothing if argv is null or empty. +pub fn write(writer: *std.Io.Writer, argv: ?[]const []const u8) error{WriteFailed}!usize { + const args = argv orelse return 0; + var count: usize = 0; + for (args, 0..) |arg, i| { + if (i > 0) { + try writer.writeByte(' '); + count += 1; + } + const needs_quote = std.mem.indexOfScalar(u8, arg, ' ') != null; + if (needs_quote) { + try writer.writeByte('"'); + count += 1; + } + try writer.writeAll(arg); + count += arg.len; + if (needs_quote) { + try writer.writeByte('"'); + count += 1; + } + } + return count; +} + +/// Return the display length of an argv array rendered by write_argv. +pub fn len(argv: ?[]const []const u8) usize { + var discard: std.Io.Writer.Discarding = .init(&.{}); + return write(&discard.writer, argv) catch return 0; +} diff --git a/src/list_languages.zig b/src/list_languages.zig index 8296e87f..669c7863 100644 --- a/src/list_languages.zig +++ b/src/list_languages.zig @@ -3,6 +3,7 @@ const file_type_config = @import("file_type_config"); const text_manip = @import("text_manip"); const write_string = text_manip.write_string; const write_padding = text_manip.write_padding; +const argv = @import("argv"); const builtin = @import("builtin"); const RGB = @import("color").RGB; @@ -22,9 +23,9 @@ pub fn list(allocator: std.mem.Allocator, writer: *std.io.Writer, tty_config: st for (file_type_config.get_all_names()) |file_type_name| { const file_type = try file_type_config.get(file_type_name) orelse unreachable; max_language_len = @max(max_language_len, file_type.name.len); - max_langserver_len = @max(max_langserver_len, args_string_length(file_type.language_server)); - max_formatter_len = @max(max_formatter_len, args_string_length(file_type.formatter)); - max_extensions_len = @max(max_extensions_len, args_string_length(file_type.extensions)); + max_langserver_len = @max(max_langserver_len, argv.len(file_type.language_server)); + max_formatter_len = @max(max_formatter_len, argv.len(file_type.formatter)); + max_extensions_len = @max(max_extensions_len, argv.len(file_type.extensions)); } try tty_config.setColor(writer, .yellow); @@ -43,59 +44,42 @@ pub fn list(allocator: std.mem.Allocator, writer: *std.io.Writer, tty_config: st try tty_config.setColor(writer, .reset); try writer.writeAll(" "); try write_string(writer, file_type.name, max_language_len + 1); - try write_segmented(writer, file_type.extensions, ",", max_extensions_len + 1, tty_config); + { + const exts = file_type.extensions orelse &.{}; + var ext_len: usize = 0; + for (exts, 0..) |ext, i| { + if (i > 0) { + try writer.writeByte(','); + ext_len += 1; + } + try writer.writeAll(ext); + ext_len += ext.len; + } + try tty_config.setColor(writer, .reset); + try write_padding(writer, ext_len, max_extensions_len + 1); + } if (file_type.language_server) |language_server| try write_checkmark(writer, bin_path.can_execute(allocator, language_server[0]), tty_config); - try write_segmented(writer, file_type.language_server, " ", max_langserver_len + 1, tty_config); + const len = try argv.write(writer, file_type.language_server); + try tty_config.setColor(writer, .reset); + try write_padding(writer, len, max_langserver_len + 1); if (file_type.formatter) |formatter| try write_checkmark(writer, bin_path.can_execute(allocator, formatter[0]), tty_config); - try write_segmented(writer, file_type.formatter, " ", null, tty_config); + _ = try argv.write(writer, file_type.formatter); + try tty_config.setColor(writer, .reset); try writer.writeAll("\n"); } } -fn args_string_length(args_: ?[]const []const u8) usize { - const args = args_ orelse return 0; - var len: usize = 0; - var first: bool = true; - for (args) |arg| { - if (first) first = false else len += 1; - len += arg.len; - } - return len; -} - fn write_checkmark(writer: anytype, success: bool, tty_config: std.io.tty.Config) !void { try tty_config.setColor(writer, if (success) .green else .red); if (success) try writer.writeAll(success_mark) else try writer.writeAll(fail_mark); } -fn write_segmented( - writer: anytype, - args_: ?[]const []const u8, - sep: []const u8, - pad: ?usize, - tty_config: std.io.tty.Config, -) !void { - const args = args_ orelse return; - var len: usize = 0; - var first: bool = true; - for (args) |arg| { - if (first) first = false else { - len += 1; - try writer.writeAll(sep); - } - len += arg.len; - try writer.writeAll(arg); - } - try tty_config.setColor(writer, .reset); - if (pad) |pad_| try write_padding(writer, len, pad_); -} - fn setColorRgb(writer: anytype, color: u24) !void { const fg_rgb_legacy = "\x1b[38;2;{d};{d};{d}m"; const rgb = RGB.from_u24(color); diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 44ebe0da..0eb6c1d6 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -6,6 +6,7 @@ const cbor = @import("cbor"); const command = @import("command"); const vaxis = @import("renderer").vaxis; const shell = @import("shell"); +const argv = @import("argv"); const Plane = @import("renderer").Plane; const Widget = @import("Widget.zig"); @@ -274,18 +275,11 @@ fn show_exit_message(self: *Self, code: u8) void { if (code != 0) w.print(" with code {d}", .{code}) catch {}; w.writeAll("]\x1b[0m\r\n") catch {}; - // Build display command string from argv for the re-run prompt - const argv = self.vt.vt.cmd.argv; - if (argv.len > 0) { + // Re-run prompt + const cmd_argv = self.vt.vt.cmd.argv; + if (cmd_argv.len > 0) { w.writeAll("\x1b[0m\x1b[2mPress enter to re-run '") catch {}; - for (argv, 0..) |arg, i| { - if (i > 0) w.writeByte(' ') catch {}; - // Quote args that contain spaces - const needs_quote = std.mem.indexOfScalar(u8, arg, ' ') != null; - if (needs_quote) w.writeByte('"') catch {}; - w.writeAll(arg) catch {}; - if (needs_quote) w.writeByte('"') catch {}; - } + _ = argv.write(w, cmd_argv) catch {}; w.writeAll("'\x1b[0m\r\n") catch {}; } var parser: pty.Parser = .{ .buf = .init(self.allocator) }; @@ -355,7 +349,7 @@ const Vt = struct { app_cursor: ?[3]u8 = null, process_exited: bool = false, - fn init(allocator: std.mem.Allocator, argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16) !void { + fn init(allocator: std.mem.Allocator, cmd_argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16) !void { const home = env.get("HOME") orelse "/tmp"; global_vt = .{ @@ -367,7 +361,7 @@ const Vt = struct { const self = &global_vt.?; self.vt = try Terminal.init( allocator, - argv, + cmd_argv, &env, .{ .winsize = .{ .rows = rows, .cols = cols, .x_pixel = 0, .y_pixel = 0 }, From 57aae0d45c709fdf48c8057817a542049b546ed8 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 16:05:40 +0100 Subject: [PATCH 54/70] feat(terminal): add close_terminal command --- src/tui/mainview.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 76ad8461..fe4507a5 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1014,7 +1014,13 @@ const cmds = struct { vt.focus(); } } - pub const focus_terminal_meta: Meta = .{ .description = "Terminal" }; + pub const focus_terminal_meta: Meta = .{ .description = "Open terminal" }; + + pub fn close_terminal(self: *Self, _: Ctx) Result { + if (self.get_panel_view(terminal_view)) |_| + try self.toggle_panel_view(terminal_view, .disable); + } + pub const close_terminal_meta: Meta = .{ .description = "Close terminal" }; pub fn close_find_in_files_results(self: *Self, _: Ctx) Result { if (self.file_list_type == .find_in_files) From 0a37c2b05bc5a0f42bb8b4fc4c30993eced24cd2 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 16:06:23 +0100 Subject: [PATCH 55/70] refactor(terminal): close terminal on escape keypress if exited --- src/tui/terminal_view.zig | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 0eb6c1d6..925ae8ad 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -155,12 +155,18 @@ pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { .mods = @bitCast(modifiers), .text = if (text.len > 0) text else null, }; - if (self.vt.process_exited and (keypress == input.key.enter or keypress == '\r')) { - self.vt.process_exited = false; - self.restart() catch |e| - std.log.err("terminal_view: restart failed: {}", .{e}); - tui.need_render(@src()); - return true; + if (self.vt.process_exited) { + if (keypress == input.key.enter) { + self.vt.process_exited = false; + self.restart() catch |e| + std.log.err("terminal_view: restart failed: {}", .{e}); + tui.need_render(@src()); + return true; + } + if (keypress == input.key.escape) { + tp.self_pid().send(.{ "cmd", "close_terminal", .{} }) catch {}; + return true; + } } self.vt.vt.scrollToBottom(); self.vt.vt.update(.{ .key_press = key }) catch |e| From 29c34249136cd2fa3da826fa50ed9ab68baaf3cd Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 16:07:01 +0100 Subject: [PATCH 56/70] fix(terminal): reset terminal if closed when exited --- src/tui/terminal_view.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 925ae8ad..7858c8d5 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -190,6 +190,10 @@ pub fn unfocus(self: *Self) void { } pub fn deinit(self: *Self, allocator: Allocator) void { + if (global_vt) |*vt| if (vt.process_exited) { + vt.deinit(allocator); + global_vt = null; + }; if (self.focused) tui.release_keyboard_focus(Widget.to(self)); self.commands.unregister(); self.plane.deinit(); From 21b7995393ee93733ac18acb6dd826f090c98a75 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 16:29:18 +0100 Subject: [PATCH 57/70] feat(terminal): add terminal_on_exit config option --- src/config.zig | 7 +++++++ src/tui/terminal_view.zig | 24 +++++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/config.zig b/src/config.zig index c4572b00..5cf6325f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -13,6 +13,7 @@ gutter_width_maximum: usize = 8, enable_terminal_cursor: bool = true, enable_terminal_color_scheme: bool = false, terminal_scrollback_size: u16 = 500, +terminal_on_exit: TerminalOnExit = .hold_on_error, enable_sgr_pixel_mode_support: bool = true, enable_modal_dim: bool = true, highlight_current_line: bool = true, @@ -248,3 +249,9 @@ pub const AgeFormat = enum { short, long, }; + +pub const TerminalOnExit = enum { + hold_on_error, + close, + hold, +}; diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 7858c8d5..806ca261 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -7,6 +7,7 @@ const command = @import("command"); const vaxis = @import("renderer").vaxis; const shell = @import("shell"); const argv = @import("argv"); +const config = @import("config"); const Plane = @import("renderer").Plane; const Widget = @import("Widget.zig"); @@ -23,6 +24,7 @@ const Self = @This(); const widget_type: Widget.Type = .panel; const Terminal = vaxis.widgets.Terminal; +const TerminalOnExit = config.TerminalOnExit; allocator: Allocator, plane: Plane, @@ -52,8 +54,11 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex errdefer env.deinit(); var cmd_arg: []const u8 = ""; + var on_exit: TerminalOnExit = tui.config().terminal_on_exit; 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 if (ctx.args.match(.{ tp.extract(&cmd_arg), tp.extract(&on_exit) }) 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); @@ -78,7 +83,7 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex const cols: u16 = @intCast(@max(80, plane.dim_x())); const rows: u16 = @intCast(@max(24, plane.dim_y())); - if (global_vt == null) try Vt.init(allocator, argv_list.items, env, rows, cols); + if (global_vt == null) try Vt.init(allocator, argv_list.items, env, rows, cols, on_exit); const self = try allocator.create(Self); errdefer allocator.destroy(self); @@ -213,7 +218,7 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { switch (event) { .exited => |code| { self.vt.process_exited = true; - self.show_exit_message(code); + self.handle_child_exit(code); tui.need_render(@src()); }, .redraw, .bell => {}, @@ -275,6 +280,17 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { return false; } +fn handle_child_exit(self: *Self, code: u8) void { + switch (self.vt.on_exit) { + .hold => self.show_exit_message(code), + .hold_on_error => if (code == 0) + tp.self_pid().send(.{ "cmd", "close_terminal", .{} }) catch {} + else + self.show_exit_message(code), + .close => tp.self_pid().send(.{ "cmd", "close_terminal", .{} }) catch {}, + } +} + fn show_exit_message(self: *Self, code: u8) void { var msg: std.Io.Writer.Allocating = .init(self.allocator); defer msg.deinit(); @@ -358,8 +374,9 @@ const Vt = struct { app_bg: ?[3]u8 = null, app_cursor: ?[3]u8 = null, process_exited: bool = false, + on_exit: TerminalOnExit, - fn init(allocator: std.mem.Allocator, cmd_argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16) !void { + fn init(allocator: std.mem.Allocator, cmd_argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16, on_exit: TerminalOnExit) !void { const home = env.get("HOME") orelse "/tmp"; global_vt = .{ @@ -367,6 +384,7 @@ const Vt = struct { .env = env, .write_buf = undefined, // managed via self.vt's pty_writer pointer .pty_pid = null, + .on_exit = on_exit, }; const self = &global_vt.?; self.vt = try Terminal.init( From 5f9b7b7c13b28e32b90d123120146d37cd0b0035 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 20:11:00 +0100 Subject: [PATCH 58/70] fix(terminal): run posix shell if no command specified and no SHELL found --- src/tui/terminal_view.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 806ca261..86f765fc 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -74,7 +74,7 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex try argv_list.append(allocator, arg); } } else { - try argv_list.append(allocator, env.get("SHELL") orelse "bash"); + try argv_list.append(allocator, env.get("SHELL") orelse "/bin/sh"); } // Use the current plane dimensions for the initial pty size. The plane From df5c426383ee00f1a1528cf21273223c7ae5ef51 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 20:21:04 +0100 Subject: [PATCH 59/70] fix(terminal): set terminal hold when running tasks in terminal --- src/tui/mode/overlay/task_palette.zig | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/tui/mode/overlay/task_palette.zig b/src/tui/mode/overlay/task_palette.zig index 54d3ae27..4f1a1604 100644 --- a/src/tui/mode/overlay/task_palette.zig +++ b/src/tui/mode/overlay/task_palette.zig @@ -138,11 +138,10 @@ fn select(menu: **Type.MenuType, button: *Type.ButtonType, _: Type.Pos) void { } 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 {}; - 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); + (switch (activate) { + .normal => tp.self_pid().send(.{ "cmd", "run_task", .{entry.label} }), + .alternate => tp.self_pid().send(.{ "cmd", "run_task_in_terminal", .{ entry.label, "hold" } }), + }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); } } From b1e13f036d49ce24df0252a113f3d31f1a7f9103 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 27 Feb 2026 23:13:02 +0100 Subject: [PATCH 60/70] feat(terminal): report mouse events to terminal applications --- build.zig.zon | 4 +- src/tui/terminal_view.zig | 111 ++++++++++++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 23 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 84e13bba..5789f967 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#9c8e0a7d61c75ea6aee3b69761acfb36899709e6", - .hash = "vaxis-0.5.1-BWNV_LC4CQDtTTjkRwC90yjbqAtv2AnDiRpU0e8c_BtF", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#db8eee5fc5e5d1b942e57b7645b2b1e39a3e37f1", + .hash = "vaxis-0.5.1-BWNV_LHUCQBX3VsvEzYYeHuShpG6o1br7Vc4h-wxQjJZ", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 86f765fc..c6a97747 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -113,29 +113,98 @@ pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { tui.need_render(@src()); return true; } - if (try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON1), tp.more }) or - try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON2), tp.more }) or - try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON3), tp.more })) - switch (tui.set_focus_by_mouse_event()) { - .changed => return true, - .same, .notfound => {}, - }; - - if (try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON4), tp.more })) { - if (self.vt.vt.scroll(3)) tui.need_render(@src()); - return true; - } - if (try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON5), tp.more })) { - if (self.vt.vt.scroll(-3)) tui.need_render(@src()); - return true; + // Mouse button press - set focus first, then forward to terminal if reporting is on + { + var btn: i64 = 0; + var col: i64 = 0; + var row: i64 = 0; + var xoffset: i64 = 0; + var yoffset: i64 = 0; + if (try m.match(.{ "B", input.event.press, tp.extract(&btn), tp.any, tp.extract(&col), tp.extract(&row), tp.extract(&xoffset), tp.extract(&yoffset) }) or + try m.match(.{ "B", input.event.release, tp.extract(&btn), tp.any, tp.extract(&col), tp.extract(&row), tp.extract(&xoffset), tp.extract(&yoffset) })) + { + const button: vaxis.Mouse.Button = @enumFromInt(btn); + const is_press = try m.match(.{ "B", input.event.press, tp.more }); + // Set focus on left/middle/right button press + if (is_press) switch (button) { + .left, .middle, .right => switch (tui.set_focus_by_mouse_event()) { + .changed => return true, + .same, .notfound => {}, + }, + // Scroll wheel: forward to vt if reporting active, else scroll scrollback + .wheel_up => { + if (self.vt.vt.mode.mouse == .none) { + if (self.vt.vt.scroll(3)) tui.need_render(@src()); + return true; + } + }, + .wheel_down => { + if (self.vt.vt.mode.mouse == .none) { + if (self.vt.vt.scroll(-3)) tui.need_render(@src()); + return true; + } + }, + else => {}, + }; + // Forward to vt if terminal mouse reporting is active + if (self.focused and self.vt.vt.mode.mouse != .none) { + const rel = self.plane.abs_yx_to_rel(@intCast(row), @intCast(col)); + const mouse_event: vaxis.Mouse = .{ + .col = @intCast(rel[1]), + .row = @intCast(rel[0]), + .xoffset = @intCast(xoffset), + .yoffset = @intCast(yoffset), + .button = button, + .mods = .{}, + .type = if (is_press) .press else .release, + }; + self.vt.vt.update(.{ .mouse = mouse_event }) catch {}; + tui.need_render(@src()); + return true; + } + return false; + } + // Mouse drag + if (try m.match(.{ "D", input.event.press, tp.extract(&btn), tp.any, tp.extract(&col), tp.extract(&row), tp.extract(&xoffset), tp.extract(&yoffset) })) { + if (self.focused and self.vt.vt.mode.mouse != .none) { + const rel = self.plane.abs_yx_to_rel(@intCast(row), @intCast(col)); + const mouse_event: vaxis.Mouse = .{ + .col = @intCast(rel[1]), + .row = @intCast(rel[0]), + .xoffset = @intCast(xoffset), + .yoffset = @intCast(yoffset), + .button = @enumFromInt(btn), + .mods = .{}, + .type = .drag, + }; + self.vt.vt.update(.{ .mouse = mouse_event }) catch {}; + tui.need_render(@src()); + return true; + } + return false; + } + // Mouse motion (no button held) + if (try m.match(.{ "M", tp.extract(&col), tp.extract(&row), tp.extract(&xoffset), tp.extract(&yoffset) })) { + if (self.focused and self.vt.vt.mode.mouse == .any_event) { + const rel = self.plane.abs_yx_to_rel(@intCast(row), @intCast(col)); + const mouse_event: vaxis.Mouse = .{ + .col = @intCast(rel[1]), + .row = @intCast(rel[0]), + .xoffset = @intCast(xoffset), + .yoffset = @intCast(yoffset), + .button = .none, + .mods = .{}, + .type = .motion, + }; + self.vt.vt.update(.{ .mouse = mouse_event }) catch {}; + tui.need_render(@src()); + return true; + } + return false; + } } - if (!(try m.match(.{ "I", tp.more }) - // or - // try m.match(.{ "B", tp.more }) or - // try m.match(.{ "D", tp.more }) or - // try m.match(.{ "M", tp.more }) - )) + if (!(try m.match(.{ "I", tp.more }))) return false; if (!self.focused) return false; From 646db3b374020ff8ee90201d6b9740e66b750013 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sat, 28 Feb 2026 20:40:54 +0100 Subject: [PATCH 61/70] fix(terminal): build terminal on macos and freebsd --- build.zig.zon | 4 ++-- src/tui/terminal_view.zig | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 5789f967..7966bf31 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#db8eee5fc5e5d1b942e57b7645b2b1e39a3e37f1", - .hash = "vaxis-0.5.1-BWNV_LHUCQBX3VsvEzYYeHuShpG6o1br7Vc4h-wxQjJZ", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#31e54f7d16c16b3f1a4aaf99966dadb9e5ca7a0f", + .hash = "vaxis-0.5.1-BWNV_DzhCQDZI3Vt_DRwcmmEd_jrTyNLGPKpnOPoHQ3-", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index c6a97747..460eafc8 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -582,7 +582,7 @@ const pty = struct { }, }; } else if (try m.match(.{ "fd", "pty", "read_error", tp.extract(&self.err_code), tp.more })) { - // thespian fires read_error with EPOLLHUP when the child exits cleanly. + // thespian fires read_error when the pty fd signals an error condition // Treat it the same as EIO: reap the child and signal exit. const code = self.vt.cmd.wait(); std.log.debug("terminal: read_error from fd (err={d}), process exited with code={d}", .{ self.err_code, code }); @@ -615,10 +615,11 @@ const pty = struct { while (true) { const n = std.posix.read(self.vt.ptyFd(), &buf) catch |e| switch (e) { error.WouldBlock => { - // No more data right now. Check if the child already exited - - // on Linux a clean exit may not make the pty fd readable again - // (no EPOLLIN), it just starts returning EIO on the next read. - // Polling here catches that case before we arm wait_read again. + // No more data right now. On Linux, a clean child exit may not + // generate a readable event on the pty master - it just starts + // returning EIO. Poll for exit here before sleeping in wait_read. + // On macOS/FreeBSD the pty master raises EIO directly, so the + // try_wait check here is just an extra safety net. if (self.vt.cmd.try_wait()) |code| { std.log.debug("terminal: child exited (detected via try_wait) with code={d}", .{code}); self.vt.event_queue.push(.{ .exited = code }); From 97f8d024c630465ff4247cdb69de65ea971493ec Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sat, 28 Feb 2026 21:49:56 +0100 Subject: [PATCH 62/70] feat(terminal): initial version of conpty windows support --- build.zig.zon | 4 +- src/tui/terminal_view.zig | 117 +++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 7966bf31..e68bf0d5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#31e54f7d16c16b3f1a4aaf99966dadb9e5ca7a0f", - .hash = "vaxis-0.5.1-BWNV_DzhCQDZI3Vt_DRwcmmEd_jrTyNLGPKpnOPoHQ3-", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#398f890e9015576673a9767d6d44877c1da34cfc", + .hash = "vaxis-0.5.1-BWNV_PQdCgBo6zTDEPmwNeTk9TM_qY0FjHWOx60YRYUB", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 460eafc8..026ee22e 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const tp = @import("thespian"); @@ -74,7 +75,11 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex try argv_list.append(allocator, arg); } } else { - try argv_list.append(allocator, env.get("SHELL") orelse "/bin/sh"); + const default_shell = if (builtin.os.tag == .windows) + env.get("COMSPEC") orelse "cmd.exe" + else + env.get("SHELL") orelse "/bin/sh"; + try argv_list.append(allocator, default_shell); } // Use the current plane dimensions for the initial pty size. The plane @@ -503,7 +508,11 @@ const Vt = struct { }; var global_vt: ?Vt = null; -const pty = struct { +// Platform-specific pty actor: POSIX uses tp.file_descriptor + SIGCHLD, +// Windows uses tp.file_stream with IOCP overlapped reads on the ConPTY output pipe. +const pty = if (builtin.os.tag == .windows) pty_windows else pty_posix; + +const pty_posix = struct { const Parser = Terminal.Parser; const Receiver = tp.Receiver(*@This()); @@ -698,3 +707,107 @@ const pty = struct { }; } }; + +/// Windows pty actor: reads ConPTY output pipe via tp.file_stream (IOCP overlapped I/O). +/// Exit detection relies on error 109 (ERROR_BROKEN_PIPE) from the read stream, +/// which fires when the child process exits and the ConPTY closes the pipe. +const pty_windows = struct { + const Parser = Terminal.Parser; + const Receiver = tp.Receiver(*@This()); + + allocator: std.mem.Allocator, + vt: *Terminal, + stream: tp.file_stream, + parser: Parser, + receiver: Receiver, + parent: tp.pid, + + pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid { + const self = try allocator.create(@This()); + errdefer allocator.destroy(self); + // tp.file_stream.init takes a *anyopaque (Win32 HANDLE) + const stream = try tp.file_stream.init("pty_out", vt.ptyOutputHandle()); + self.* = .{ + .allocator = allocator, + .vt = vt, + .stream = stream, + .parser = .{ .buf = try .initCapacity(allocator, 128) }, + .receiver = Receiver.init(pty_receive, self), + .parent = tp.self_pid().clone(), + }; + return tp.spawn_link(allocator, self, start, "pty_actor"); + } + + fn deinit(self: *@This()) void { + std.log.debug("terminal: pty actor (windows) deinit", .{}); + self.stream.deinit(); + self.parser.buf.deinit(); + self.parent.deinit(); + self.allocator.destroy(self); + } + + fn start(self: *@This()) tp.result { + errdefer self.deinit(); + self.stream.start_read() catch |e| { + std.log.debug("terminal: pty stream start_read failed: {}", .{e}); + return tp.exit_error(e, @errorReturnTrace()); + }; + tp.receive(&self.receiver); + } + + fn pty_receive(self: *@This(), _: tp.pid_ref, m: tp.message) tp.result { + errdefer self.deinit(); + + var bytes: []const u8 = ""; + var err_code: i64 = 0; + var err_msg: []const u8 = ""; + + if (try m.match(.{ "stream", "pty_out", "read_complete", tp.extract(&bytes) })) { + // Got output data from the child - process it, then arm next read. + if (bytes.len == 0) { + // Zero bytes = EOF on the pipe = child exited + const code = self.vt.cmd.wait(); + std.log.debug("terminal: ConPTY pipe EOF, process exited with code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); + } + defer self.parent.send(.{ "terminal_view", "output" }) catch {}; + switch (self.vt.processOutput(&self.parser, bytes) catch |e| { + std.log.debug("terminal: processOutput error: {}", .{e}); + return tp.exit_normal(); + }) { + .exited => { + std.log.debug("terminal: processOutput returned .exited", .{}); + return tp.exit_normal(); + }, + .running => {}, + } + // Re-arm the read for next chunk + self.stream.start_read() catch |e| { + std.log.debug("terminal: pty stream re-arm failed: {}", .{e}); + return tp.exit_normal(); + }; + } else if (try m.match(.{ "stream", "pty_out", "read_error", 109, tp.extract(&err_msg) })) { + // ERROR_BROKEN_PIPE (109) = child process has exited and ConPTY closed the pipe. + const code = self.vt.cmd.wait(); + std.log.debug("terminal: ConPTY pipe broken (child exited), code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); + } else if (try m.match(.{ "stream", "pty_out", "read_error", tp.extract(&err_code), tp.extract(&err_msg) })) { + // Other read error - treat as unexpected exit + std.log.debug("terminal: ConPTY read error: {d} {s}", .{ err_code, err_msg }); + const code = self.vt.cmd.wait(); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); + } else if (try m.match(.{"quit"})) { + std.log.debug("terminal: pty actor (windows) received quit", .{}); + return tp.exit_normal(); + } else { + std.log.debug("terminal: pty actor (windows) unexpected message", .{}); + return tp.unexpected(m); + } + } +}; From a21b1318ed3fb8c089424cf7e8e99655be397392 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 1 Mar 2026 18:13:55 +0100 Subject: [PATCH 63/70] fix(terminal): file_stream.init call for pty_out should be in the pty actor --- src/tui/terminal_view.zig | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 026ee22e..0a38f598 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -717,7 +717,7 @@ const pty_windows = struct { allocator: std.mem.Allocator, vt: *Terminal, - stream: tp.file_stream, + stream: ?tp.file_stream = null, parser: Parser, receiver: Receiver, parent: tp.pid, @@ -725,12 +725,9 @@ const pty_windows = struct { pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid { const self = try allocator.create(@This()); errdefer allocator.destroy(self); - // tp.file_stream.init takes a *anyopaque (Win32 HANDLE) - const stream = try tp.file_stream.init("pty_out", vt.ptyOutputHandle()); self.* = .{ .allocator = allocator, .vt = vt, - .stream = stream, .parser = .{ .buf = try .initCapacity(allocator, 128) }, .receiver = Receiver.init(pty_receive, self), .parent = tp.self_pid().clone(), @@ -740,7 +737,7 @@ const pty_windows = struct { fn deinit(self: *@This()) void { std.log.debug("terminal: pty actor (windows) deinit", .{}); - self.stream.deinit(); + if (self.stream) |s| s.deinit(); self.parser.buf.deinit(); self.parent.deinit(); self.allocator.destroy(self); @@ -748,7 +745,11 @@ const pty_windows = struct { fn start(self: *@This()) tp.result { errdefer self.deinit(); - self.stream.start_read() catch |e| { + self.stream = tp.file_stream.init("pty_out", self.vt.ptyOutputHandle()) catch |e| { + std.log.debug("terminal: pty stream init failed: {}", .{e}); + return tp.exit_error(e, @errorReturnTrace()); + }; + self.stream.?.start_read() catch |e| { std.log.debug("terminal: pty stream start_read failed: {}", .{e}); return tp.exit_error(e, @errorReturnTrace()); }; @@ -784,7 +785,7 @@ const pty_windows = struct { .running => {}, } // Re-arm the read for next chunk - self.stream.start_read() catch |e| { + self.stream.?.start_read() catch |e| { std.log.debug("terminal: pty stream re-arm failed: {}", .{e}); return tp.exit_normal(); }; From 8027096f3e1b769c56e4cb5cd9db2045c939a381 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 1 Mar 2026 19:26:35 +0100 Subject: [PATCH 64/70] fix(vt): detect windows pty child exit via registerWaitForSingleObject --- build.zig.zon | 4 +- src/tui/terminal_view.zig | 102 ++++++++++++++++++++++++++++++-------- 2 files changed, 82 insertions(+), 24 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index e68bf0d5..7662f652 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#398f890e9015576673a9767d6d44877c1da34cfc", - .hash = "vaxis-0.5.1-BWNV_PQdCgBo6zTDEPmwNeTk9TM_qY0FjHWOx60YRYUB", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#e6801b9c81fab5313bc35b349b294ba4b0a060ad", + .hash = "vaxis-0.5.1-BWNV_L0eCgA4TNGahogJfmfebvJ-0sQXhOJAKn5WZmc6", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 0a38f598..42f543aa 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -709,11 +709,23 @@ const pty_posix = struct { }; /// Windows pty actor: reads ConPTY output pipe via tp.file_stream (IOCP overlapped I/O). -/// Exit detection relies on error 109 (ERROR_BROKEN_PIPE) from the read stream, -/// which fires when the child process exits and the ConPTY closes the pipe. +/// +/// Exit detection: ConPTY does NOT close the output pipe when the child process exits - +/// it keeps it open until ClosePseudoConsole is called. So a pending async read would +/// block forever. Instead we use RegisterWaitForSingleObject on the process handle; +/// when it fires the threadpool callback posts "child_exited" to this actor, which +/// cancels the stream and tears down cleanly. const pty_windows = struct { const Parser = Terminal.Parser; const Receiver = tp.Receiver(*@This()); + const windows = std.os.windows; + + // Context struct allocated on the heap and passed to the wait callback. + // Heap-allocated so its lifetime is independent of the actor. + const WaitCtx = struct { + self_pid: tp.pid, + allocator: std.mem.Allocator, + }; allocator: std.mem.Allocator, vt: *Terminal, @@ -721,6 +733,7 @@ const pty_windows = struct { parser: Parser, receiver: Receiver, parent: tp.pid, + wait_handle: ?windows.HANDLE = null, pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid { const self = try allocator.create(@This()); @@ -737,6 +750,10 @@ const pty_windows = struct { fn deinit(self: *@This()) void { std.log.debug("terminal: pty actor (windows) deinit", .{}); + if (self.wait_handle) |wh| { + _ = UnregisterWait(wh); + self.wait_handle = null; + } if (self.stream) |s| s.deinit(); self.parser.buf.deinit(); self.parent.deinit(); @@ -753,9 +770,46 @@ const pty_windows = struct { std.log.debug("terminal: pty stream start_read failed: {}", .{e}); return tp.exit_error(e, @errorReturnTrace()); }; + + // Register a one-shot wait on the process handle. When the child exits + // the threadpool fires on_child_exit, which sends "child_exited" to us. + // This is the only reliable way to detect ConPTY child exit without polling, + // since ConPTY keeps the output pipe open until ClosePseudoConsole. + const process_handle = self.vt.cmd.process_handle orelse { + std.log.debug("terminal: pty actor: no process handle to wait on", .{}); + return tp.exit_error(error.NoProcessHandle, @errorReturnTrace()); + }; + const ctx = self.allocator.create(WaitCtx) catch |e| + return tp.exit_error(e, @errorReturnTrace()); + ctx.* = .{ + .self_pid = tp.self_pid().clone(), + .allocator = self.allocator, + }; + var wh: windows.HANDLE = undefined; + // WT_EXECUTEONLYONCE: callback fires once then the wait is auto-unregistered. + const WT_EXECUTEONLYONCE: windows.ULONG = 0x00000008; + if (RegisterWaitForSingleObject(&wh, process_handle, on_child_exit, ctx, windows.INFINITE, WT_EXECUTEONLYONCE) == windows.FALSE) { + ctx.self_pid.deinit(); + self.allocator.destroy(ctx); + std.log.debug("terminal: RegisterWaitForSingleObject failed", .{}); + return tp.exit_error(error.RegisterWaitFailed, @errorReturnTrace()); + } + self.wait_handle = wh; + tp.receive(&self.receiver); } + /// Threadpool callback - called when the process handle becomes signaled. + /// Must be fast and non-blocking. Sends "child_exited" to the pty actor. + fn on_child_exit(ctx_ptr: ?*anyopaque, _: windows.BOOLEAN) callconv(.winapi) void { + const ctx: *WaitCtx = @ptrCast(@alignCast(ctx_ptr orelse return)); + defer { + ctx.self_pid.deinit(); + ctx.allocator.destroy(ctx); + } + ctx.self_pid.send(.{"child_exited"}) catch {}; + } + fn pty_receive(self: *@This(), _: tp.pid_ref, m: tp.message) tp.result { errdefer self.deinit(); @@ -763,16 +817,15 @@ const pty_windows = struct { var err_code: i64 = 0; var err_msg: []const u8 = ""; - if (try m.match(.{ "stream", "pty_out", "read_complete", tp.extract(&bytes) })) { - // Got output data from the child - process it, then arm next read. - if (bytes.len == 0) { - // Zero bytes = EOF on the pipe = child exited - const code = self.vt.cmd.wait(); - std.log.debug("terminal: ConPTY pipe EOF, process exited with code={d}", .{code}); - self.vt.event_queue.push(.{ .exited = code }); - self.parent.send(.{ "terminal_view", "output" }) catch {}; - return tp.exit_normal(); - } + if (try m.match(.{"child_exited"})) { + self.wait_handle = null; + if (self.stream) |s| s.cancel() catch {}; + const code = self.vt.cmd.wait(); + std.log.debug("terminal: child exited (process wait), code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); + } else if (try m.match(.{ "stream", "pty_out", "read_complete", tp.extract(&bytes) })) { defer self.parent.send(.{ "terminal_view", "output" }) catch {}; switch (self.vt.processOutput(&self.parser, bytes) catch |e| { std.log.debug("terminal: processOutput error: {}", .{e}); @@ -784,21 +837,12 @@ const pty_windows = struct { }, .running => {}, } - // Re-arm the read for next chunk self.stream.?.start_read() catch |e| { std.log.debug("terminal: pty stream re-arm failed: {}", .{e}); return tp.exit_normal(); }; - } else if (try m.match(.{ "stream", "pty_out", "read_error", 109, tp.extract(&err_msg) })) { - // ERROR_BROKEN_PIPE (109) = child process has exited and ConPTY closed the pipe. - const code = self.vt.cmd.wait(); - std.log.debug("terminal: ConPTY pipe broken (child exited), code={d}", .{code}); - self.vt.event_queue.push(.{ .exited = code }); - self.parent.send(.{ "terminal_view", "output" }) catch {}; - return tp.exit_normal(); } else if (try m.match(.{ "stream", "pty_out", "read_error", tp.extract(&err_code), tp.extract(&err_msg) })) { - // Other read error - treat as unexpected exit - std.log.debug("terminal: ConPTY read error: {d} {s}", .{ err_code, err_msg }); + std.log.debug("terminal: ConPTY stream error: {d} {s}", .{ err_code, err_msg }); const code = self.vt.cmd.wait(); self.vt.event_queue.push(.{ .exited = code }); self.parent.send(.{ "terminal_view", "output" }) catch {}; @@ -811,4 +855,18 @@ const pty_windows = struct { return tp.unexpected(m); } } + + // Win32 extern declarations + extern "kernel32" fn RegisterWaitForSingleObject( + phNewWaitObject: *windows.HANDLE, + hObject: windows.HANDLE, + Callback: *const fn (?*anyopaque, windows.BOOLEAN) callconv(.winapi) void, + Context: ?*anyopaque, + dwMilliseconds: windows.DWORD, + dwFlags: windows.ULONG, + ) callconv(.winapi) windows.BOOL; + + extern "kernel32" fn UnregisterWait( + WaitHandle: windows.HANDLE, + ) callconv(.winapi) windows.BOOL; }; From ce240c534c88af832a1b74940ef09cb79a454586 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 1 Mar 2026 19:34:33 +0100 Subject: [PATCH 65/70] fix(tv): fix windows gui build --- src/renderer/win32/renderer.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/win32/renderer.zig b/src/renderer/win32/renderer.zig index dcf27528..94827b6e 100644 --- a/src/renderer/win32/renderer.zig +++ b/src/renderer/win32/renderer.zig @@ -3,7 +3,7 @@ pub const log_name = "renderer"; const std = @import("std"); const cbor = @import("cbor"); -const vaxis = @import("vaxis"); +pub const vaxis = @import("vaxis"); const Style = @import("theme").Style; const Color = @import("theme").Color; pub const CursorShape = vaxis.Cell.CursorShape; From 581bbdb2105a1d220baff91bd61f45105849dc9a Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 1 Mar 2026 19:58:48 +0100 Subject: [PATCH 66/70] fix(terminal): render software cursor in terminal if enable_terminal_cursor is false --- build.zig.zon | 4 ++-- src/tui/terminal_view.zig | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 7662f652..e7e91a94 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#e6801b9c81fab5313bc35b349b294ba4b0a060ad", - .hash = "vaxis-0.5.1-BWNV_L0eCgA4TNGahogJfmfebvJ-0sQXhOJAKn5WZmc6", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#cecc97d9ff8da9df13499da0d0b19c5cd18742c3", + .hash = "vaxis-0.5.1-BWNV_BcgCgDG3wpSPxCHxaRAZukEfnnKrBa-52zjnjex", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 42f543aa..fb6de666 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -1,5 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); +const build_options = @import("build_options"); const Allocator = std.mem.Allocator; const tp = @import("thespian"); @@ -18,6 +19,7 @@ const tui = @import("tui.zig"); const input = @import("input"); const keybind = @import("keybind"); pub const Mode = keybind.Mode; +const RGB = @import("color").RGB; pub const name = @typeName(Self); @@ -347,7 +349,10 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { } // Blit the terminal's front screen into our vaxis.Window. - self.vt.vt.draw(self.allocator, self.plane.window, self.focused) catch |e| { + const software_cursor = build_options.gui or !tui.config().enable_terminal_cursor; + const focused_cursor_color: ?[3]u8 = if (theme.editor_cursor.bg) |bg| RGB.to_u8s(RGB.from_u24(bg.color)) else null; + const unfocused_cursor_color: ?[3]u8 = if (theme.editor_cursor_secondary.bg) |bg| RGB.to_u8s(RGB.from_u24(bg.color)) else focused_cursor_color; + self.vt.vt.draw(self.allocator, self.plane.window, self.focused, software_cursor, focused_cursor_color, unfocused_cursor_color) catch |e| { std.log.err("terminal_view: draw failed: {}", .{e}); }; From c4f6b6c945bcb8de334af7f813343cbb4b602071 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 1 Mar 2026 21:20:54 +0100 Subject: [PATCH 67/70] refactor(terminal): render terminal panel as unfocused if outer terminal looses focus --- src/tui/terminal_view.zig | 2 +- src/tui/tui.zig | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index fb6de666..6fac6a25 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -352,7 +352,7 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { const software_cursor = build_options.gui or !tui.config().enable_terminal_cursor; const focused_cursor_color: ?[3]u8 = if (theme.editor_cursor.bg) |bg| RGB.to_u8s(RGB.from_u24(bg.color)) else null; const unfocused_cursor_color: ?[3]u8 = if (theme.editor_cursor_secondary.bg) |bg| RGB.to_u8s(RGB.from_u24(bg.color)) else focused_cursor_color; - self.vt.vt.draw(self.allocator, self.plane.window, self.focused, software_cursor, focused_cursor_color, unfocused_cursor_color) catch |e| { + self.vt.vt.draw(self.allocator, self.plane.window, self.focused and tui.terminal_has_focus(), software_cursor, focused_cursor_color, unfocused_cursor_color) catch |e| { std.log.err("terminal_view: draw failed: {}", .{e}); }; diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 0420e15b..9622bb8d 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -56,6 +56,7 @@ keyboard_focus: ?Widget = null, keyboard_focus_outer: ?Widget = null, mini_mode_: ?MiniMode = null, hover_focus: ?Widget = null, +terminal_focus: bool = true, last_hover_x: c_int = -1, last_hover_y: c_int = -1, commands: Commands = undefined, @@ -519,15 +520,19 @@ fn receive_safe(self: *Self, from: tp.pid_ref, m: tp.message) !void { return; if (try m.match(.{"focus_in"})) { + self.terminal_focus = true; std.log.debug("focus_in", .{}); + need_render(@src()); return; } if (try m.match(.{"focus_out"})) { + self.terminal_focus = false; std.log.debug("focus_out", .{}); self.clear_hover_focus(@src()) catch {}; self.last_hover_x = -1; self.last_hover_y = -1; + need_render(@src()); return; } @@ -949,7 +954,7 @@ pub fn save_config() (root.ConfigDirError || root.ConfigWriteError)!void { pub fn is_mainview_focused() bool { const self = current(); - return self.mini_mode_ == null and self.input_mode_outer_ == null and !is_keyboard_focused(); + return self.mini_mode_ == null and self.input_mode_outer_ == null and !is_keyboard_focused() and self.terminal_focus; } fn enter_overlay_mode(self: *Self, mode: type) command.Result { @@ -2705,3 +2710,8 @@ pub fn jump_mode() bool { const self = current(); return self.jump_mode_; } + +pub fn terminal_has_focus() bool { + const self = current(); + return self.terminal_focus; +} From 2f5d4ded3c74db7c16b45db3f6d14636847e8a03 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 1 Mar 2026 21:50:37 +0100 Subject: [PATCH 68/70] refactor: deduplicate toggle_panel_view By allowing any panel to accept arguments. --- src/tui/filelist_view.zig | 2 +- src/tui/info_view.zig | 3 ++- src/tui/inputview.zig | 3 ++- src/tui/inspector_view.zig | 3 ++- src/tui/keybindview.zig | 3 ++- src/tui/logview.zig | 3 ++- src/tui/mainview.zig | 34 +++++++++------------------------- src/tui/terminal_view.zig | 6 +----- 8 files changed, 21 insertions(+), 36 deletions(-) diff --git a/src/tui/filelist_view.zig b/src/tui/filelist_view.zig index ff820e13..9001b071 100644 --- a/src/tui/filelist_view.zig +++ b/src/tui/filelist_view.zig @@ -57,7 +57,7 @@ const Entry = struct { pos_type: editor.PosType, }; -pub fn create(allocator: Allocator, parent: Plane) !Widget { +pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget { const self = try allocator.create(Self); errdefer allocator.destroy(self); self.* = .{ diff --git a/src/tui/info_view.zig b/src/tui/info_view.zig index 1bc72dc3..9628df0f 100644 --- a/src/tui/info_view.zig +++ b/src/tui/info_view.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = @import("std").mem.Allocator; const Plane = @import("renderer").Plane; +const command = @import("command"); const Widget = @import("Widget.zig"); const WidgetList = @import("WidgetList.zig"); const reflow = @import("Buffer").reflow; @@ -19,7 +20,7 @@ widget_type: Widget.Type, const default_widget_type: Widget.Type = .panel; -pub fn create(allocator: Allocator, parent: Plane) !Widget { +pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget { return create_widget_type(allocator, parent, default_widget_type); } diff --git a/src/tui/inputview.zig b/src/tui/inputview.zig index 00e2b553..b1e732c0 100644 --- a/src/tui/inputview.zig +++ b/src/tui/inputview.zig @@ -10,6 +10,7 @@ const cbor = @import("cbor"); const Plane = @import("renderer").Plane; const EventHandler = @import("EventHandler"); const input = @import("input"); +const command = @import("command"); const tui = @import("tui.zig"); const Widget = @import("Widget.zig"); @@ -33,7 +34,7 @@ const Entry = struct { }; const Buffer = ArrayList(Entry); -pub fn create(allocator: Allocator, parent: Plane) !Widget { +pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget { var n = try Plane.init(&(Widget.Box{}).opts_vscroll(@typeName(Self)), parent); errdefer n.deinit(); const container = try WidgetList.createHStyled(allocator, parent, "panel_frame", .dynamic, widget_type); diff --git a/src/tui/inspector_view.zig b/src/tui/inspector_view.zig index a7c26a0c..69bf41b7 100644 --- a/src/tui/inspector_view.zig +++ b/src/tui/inspector_view.zig @@ -9,6 +9,7 @@ const Plane = @import("renderer").Plane; const style = @import("renderer").style; const styles = @import("renderer").styles; const EventHandler = @import("EventHandler"); +const command = @import("command"); const tui = @import("tui.zig"); const Widget = @import("Widget.zig"); @@ -25,7 +26,7 @@ last_node: usize = 0, const Self = @This(); const widget_type: Widget.Type = .panel; -pub fn create(allocator: Allocator, parent: Plane) !Widget { +pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget { const editor = tui.get_active_editor() orelse return error.NotFound; const self = try allocator.create(Self); errdefer allocator.destroy(self); diff --git a/src/tui/keybindview.zig b/src/tui/keybindview.zig index 339d7c24..40645778 100644 --- a/src/tui/keybindview.zig +++ b/src/tui/keybindview.zig @@ -10,6 +10,7 @@ const cbor = @import("cbor"); const Plane = @import("renderer").Plane; const input = @import("input"); +const command = @import("command"); const tui = @import("tui.zig"); const Widget = @import("Widget.zig"); @@ -33,7 +34,7 @@ const Entry = struct { }; const Buffer = ArrayList(Entry); -pub fn create(allocator: Allocator, parent: Plane) !Widget { +pub fn create(allocator: Allocator, parent: Plane, _: command.Context) !Widget { var n = try Plane.init(&(Widget.Box{}).opts_vscroll(@typeName(Self)), parent); errdefer n.deinit(); const container = try WidgetList.createHStyled(allocator, parent, "panel_frame", .dynamic, widget_type); diff --git a/src/tui/logview.zig b/src/tui/logview.zig index 92a756a4..04ceedc9 100644 --- a/src/tui/logview.zig +++ b/src/tui/logview.zig @@ -6,6 +6,7 @@ const array_list = @import("std").array_list; const tp = @import("thespian"); const cbor = @import("cbor"); +const command = @import("command"); const Plane = @import("renderer").Plane; @@ -39,7 +40,7 @@ const Level = enum { err, }; -pub fn create(allocator: Allocator, parent: Plane) !Widget { +pub fn create(allocator: Allocator, parent: Plane, _: 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); diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index fe4507a5..4633709d 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -298,7 +298,13 @@ pub fn get_panel_height(self: *Self) usize { return self.panel_height orelse self.box().h / 5; } -fn toggle_panel_view(self: *Self, view: anytype, mode: enum { toggle, enable, disable }) !void { +pub const PanelToggleMode = enum { toggle, enable, disable }; + +fn toggle_panel_view(self: *Self, view: anytype, mode: PanelToggleMode) !void { + return self.toggle_panel_view_with_args(view, mode, .{}); +} + +fn toggle_panel_view_with_args(self: *Self, view: anytype, mode: PanelToggleMode, ctx: command.Context) !void { if (self.panels) |panels| { if (self.get_panel(@typeName(view))) |w| { if (mode != .enable) { @@ -310,39 +316,17 @@ fn toggle_panel_view(self: *Self, view: anytype, mode: enum { toggle, enable, di } } else { if (mode != .disable) - try panels.add(try view.create(self.allocator, self.widgets.plane)); + try panels.add(try view.create(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(self.allocator, self.widgets.plane)); + try panels.add(try view.create(self.allocator, self.widgets.plane, ctx)); self.panels = panels; } 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| diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 6fac6a25..d84f8b22 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -37,11 +37,7 @@ hover: bool = false, vt: *Vt, commands: Commands = undefined, -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 { +pub fn create(allocator: Allocator, parent: Plane, ctx: command.Context) !Widget { const container = try WidgetList.createHStyled( allocator, parent, From 3553fbf0d2c18e987f31cfc440b9fe1981f6a09a Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 2 Mar 2026 10:49:00 +0100 Subject: [PATCH 69/70] refactor(terminal): merge focus_terminal and open_terminal commands --- src/keybind/builtin/flow.json | 4 +- src/tui/home.zig | 4 +- src/tui/mainview.zig | 36 ++++++-- src/tui/terminal_view.zig | 150 +++++++++++++++++++++------------- src/tui/tui.zig | 9 +- 5 files changed, 131 insertions(+), 72 deletions(-) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 4e6b6dbe..269ce510 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -23,7 +23,7 @@ ["ctrl+6", "focus_split", 5], ["ctrl+7", "focus_split", 6], ["ctrl+8", "focus_split", 7], - ["ctrl+`", "focus_terminal"], + ["ctrl+`", "open_terminal"], ["ctrl+j", "toggle_panel"], ["ctrl+shift+j", "toggle_maximize_panel"], ["ctrl+q", "quit"], @@ -596,7 +596,7 @@ ["ctrl+6", "focus_split", 5], ["ctrl+7", "focus_split", 6], ["ctrl+8", "focus_split", 7], - ["ctrl+`", "focus_terminal"], + ["ctrl+`", "unfocus_terminal"], ["ctrl+j", "toggle_panel"], ["ctrl+shift+page_down", "terminal_scroll_down"], ["ctrl+shift+page_up", "terminal_scroll_up"], diff --git a/src/tui/home.zig b/src/tui/home.zig index 40b41c03..478a8b2e 100644 --- a/src/tui/home.zig +++ b/src/tui/home.zig @@ -34,7 +34,7 @@ const style = struct { \\open_recent_project \\find_in_files \\open_command_palette - \\focus_terminal + \\open_terminal \\run_task \\add_task \\open_config @@ -53,7 +53,7 @@ const style = struct { \\open_recent_project \\find_in_files \\open_command_palette - \\focus_terminal + \\open_terminal \\run_task \\add_task \\open_config diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 4633709d..d15b2539 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -929,7 +929,7 @@ const cmds = struct { else if (self.is_panel_view_showing(terminal_view)) try self.toggle_panel_view(terminal_view, .toggle) else - try focus_terminal(self, .{}); + try open_terminal(self, .{}); } pub const toggle_panel_meta: Meta = .{ .description = "Toggle panel" }; @@ -985,20 +985,40 @@ const cmds = struct { 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} }; + const have_args = ctx.args.buf.len > 0 and try ctx.args.match(.{ tp.string, tp.more }); - pub fn focus_terminal(self: *Self, _: Ctx) Result { - if (self.get_panel_view(terminal_view)) |vt| { + if (have_args and terminal_view.is_vt_running()) { + var msg: std.Io.Writer.Allocating = .init(self.allocator); + defer msg.deinit(); + try msg.writer.writeAll("terminal is already running '"); + try terminal_view.get_running_cmd(&msg.writer); + try msg.writer.writeAll("'"); + return tp.exit(msg.written()); + } + + if (terminal_view.is_vt_running()) if (self.get_panel_view(terminal_view)) |vt| { + std.log.debug("open_terminal: toggle_focus", .{}); vt.toggle_focus(); + return; + }; + + var buf: [tp.max_message_size]u8 = undefined; + std.log.debug("open_terminal: {s}", .{if (ctx.args.buf.len > 0) ctx.args.to_json(&buf) catch "(error)" else "(none)"}); + if (self.get_panel_view(terminal_view)) |vt| { + try vt.run_cmd(ctx); } else { - try self.toggle_panel_view(terminal_view, .enable); + try self.toggle_panel_view_with_args(terminal_view, .enable, ctx); if (self.get_panel_view(terminal_view)) |vt| vt.focus(); } } - pub const focus_terminal_meta: Meta = .{ .description = "Open terminal" }; + pub const open_terminal_meta: Meta = .{ .description = "Open terminal" }; + + pub fn unfocus_terminal(self: *Self, _: Ctx) Result { + if (self.get_panel_view(terminal_view)) |vt| + vt.toggle_focus(); + } + pub const unfocus_terminal_meta: Meta = .{}; pub fn close_terminal(self: *Self, _: Ctx) Result { if (self.get_panel_view(terminal_view)) |_| diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index d84f8b22..8b80de76 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -35,6 +35,7 @@ focused: bool = false, input_mode: Mode, hover: bool = false, vt: *Vt, +last_cmd: ?[]const u8, commands: Commands = undefined, pub fn create(allocator: Allocator, parent: Plane, ctx: command.Context) !Widget { @@ -49,45 +50,6 @@ pub fn create(allocator: Allocator, parent: Plane, ctx: command.Context) !Widget 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 = ""; - var on_exit: TerminalOnExit = tui.config().terminal_on_exit; - 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 if (ctx.args.match(.{ tp.extract(&cmd_arg), tp.extract(&on_exit) }) 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 { - const default_shell = if (builtin.os.tag == .windows) - env.get("COMSPEC") orelse "cmd.exe" - else - env.get("SHELL") orelse "/bin/sh"; - try argv_list.append(allocator, default_shell); - } - - // 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())); - - if (global_vt == null) try Vt.init(allocator, argv_list.items, env, rows, cols, on_exit); - const self = try allocator.create(Self); errdefer allocator.destroy(self); @@ -95,8 +57,10 @@ pub fn create(allocator: Allocator, parent: Plane, ctx: command.Context) !Widget .allocator = allocator, .plane = plane, .input_mode = try keybind.mode("terminal", allocator, .{ .insert_command = "do_nothing" }), - .vt = &global_vt.?, + .vt = undefined, + .last_cmd = null, }; + try self.run_cmd(ctx); try self.commands.init(self); try tui.message_filters().add(MessageFilter.bind(self, receive_filter)); @@ -107,6 +71,73 @@ pub fn create(allocator: Allocator, parent: Plane, ctx: command.Context) !Widget return container.widget(); } +pub fn run_cmd(self: *Self, ctx: command.Context) !void { + var env = try std.process.getEnvMap(self.allocator); + errdefer env.deinit(); + + var cmd_arg: []const u8 = ""; + var on_exit: TerminalOnExit = tui.config().terminal_on_exit; + 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(self.allocator, &cmd_arg) + else if (ctx.args.match(.{ tp.extract(&cmd_arg), tp.extract(&on_exit) }) catch false and cmd_arg.len > 0) + try shell.parse_arg0_to_argv(self.allocator, &cmd_arg) + else + null; + defer if (argv_msg) |msg| self.allocator.free(msg.buf); + + var argv_list: std.ArrayListUnmanaged([]const u8) = .empty; + defer argv_list.deinit(self.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(self.allocator, arg); + } + } else { + const default_shell = if (builtin.os.tag == .windows) + env.get("COMSPEC") orelse "cmd.exe" + else + env.get("SHELL") orelse "/bin/sh"; + try argv_list.append(self.allocator, default_shell); + } + + // 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, self.plane.dim_x())); + const rows: u16 = @intCast(@max(24, self.plane.dim_y())); + + if (global_vt) |*vt| { + if (!vt.process_exited) { + var msg: std.Io.Writer.Allocating = .init(self.allocator); + defer msg.deinit(); + try msg.writer.writeAll("terminal is already running '"); + try get_running_cmd(&msg.writer); + try msg.writer.writeAll("'"); + return tp.exit(msg.written()); + } + vt.deinit(self.allocator); + global_vt = null; + } + try Vt.init(self.allocator, argv_list.items, env, rows, cols, on_exit); + self.vt = &global_vt.?; + + if (self.last_cmd) |cmd| { + self.allocator.free(cmd); + self.last_cmd = null; + } + self.last_cmd = try self.allocator.dupe(u8, ctx.args.buf); +} + +fn re_run_cmd(self: *Self) !void { + return if (self.last_cmd) |cmd| + self.run_cmd(.{ .args = .{ .buf = cmd } }) + else + tp.exit("no command to re-run"); +} + pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { if (try m.match(.{ "terminal_view", "output" })) { tui.need_render(@src()); @@ -234,8 +265,7 @@ pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool { }; if (self.vt.process_exited) { if (keypress == input.key.enter) { - self.vt.process_exited = false; - self.restart() catch |e| + self.re_run_cmd() catch |e| std.log.err("terminal_view: restart failed: {}", .{e}); tui.need_render(@src()); return true; @@ -267,6 +297,10 @@ pub fn unfocus(self: *Self) void { } pub fn deinit(self: *Self, allocator: Allocator) void { + if (self.last_cmd) |cmd| { + self.allocator.free(cmd); + self.last_cmd = null; + } if (global_vt) |*vt| if (vt.process_exited) { vt.deinit(allocator); global_vt = null; @@ -375,31 +409,22 @@ fn show_exit_message(self: *Self, code: u8) void { w.writeAll("[process exited") catch {}; if (code != 0) w.print(" with code {d}", .{code}) catch {}; - w.writeAll("]\x1b[0m\r\n") catch {}; + w.writeAll("]") catch {}; // Re-run prompt const cmd_argv = self.vt.vt.cmd.argv; if (cmd_argv.len > 0) { - w.writeAll("\x1b[0m\x1b[2mPress enter to re-run '") catch {}; + w.writeAll(" Press enter to re-run '") catch {}; _ = argv.write(w, cmd_argv) catch {}; - w.writeAll("'\x1b[0m\r\n") catch {}; + w.writeAll("' or escape to close") catch {}; + } else { + w.writeAll(" Press esc to close") catch {}; } + w.writeAll("\x1b[0m\r\n") catch {}; var parser: pty.Parser = .{ .buf = .init(self.allocator) }; defer parser.buf.deinit(); _ = self.vt.vt.processOutput(&parser, msg.written()) catch {}; } -fn restart(self: *Self) !void { - // Kill the old pty actor if still alive - if (self.vt.pty_pid) |pid| { - pid.send(.{"quit"}) catch {}; - pid.deinit(); - self.vt.pty_pid = null; - } - // Re-spawn the child process and a fresh pty actor - try self.vt.vt.spawn(); - self.vt.pty_pid = try pty.spawn(self.allocator, &self.vt.vt); -} - 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; @@ -509,6 +534,17 @@ const Vt = struct { }; var global_vt: ?Vt = null; +pub fn is_vt_running() bool { + return if (global_vt) |vt| !vt.process_exited else false; +} + +pub fn get_running_cmd(writer: *std.Io.Writer) std.Io.Writer.Error!void { + const cmd_argv = if (global_vt) |vt| vt.vt.cmd.argv else &.{}; + if (cmd_argv.len > 0) { + _ = argv.write(writer, cmd_argv) catch {}; + } +} + // Platform-specific pty actor: POSIX uses tp.file_descriptor + SIGCHLD, // Windows uses tp.file_stream with IOCP overlapped reads on the ConPTY output pipe. const pty = if (builtin.os.tag == .windows) pty_windows else pty_posix; diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 9622bb8d..951ab6d6 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -1516,9 +1516,13 @@ const cmds = struct { }; pub fn run_task_in_terminal(self: *Self, ctx: Ctx) Result { + var buf: [tp.max_message_size]u8 = undefined; + std.log.debug("run_task_in_terminal: {s}", .{if (ctx.args.buf.len > 0) ctx.args.to_json(&buf) catch "(error)" else "(none)"}); const expansion = @import("expansion.zig"); var task: []const u8 = undefined; - if (!try ctx.args.match(.{tp.extract(&task)})) return; + var on_exit: @import("config").TerminalOnExit = self.config_.terminal_on_exit; + if (!(try ctx.args.match(.{tp.extract(&task)}) or + try ctx.args.match(.{ tp.extract(&task), tp.extract(&on_exit) }))) return; const args = expansion.expand_cbor(self.allocator, ctx.args.buf) catch |e| switch (e) { error.NotFound => return error.Stop, else => |e_| return e_, @@ -1528,8 +1532,7 @@ const cmds = struct { 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})); + try command.executeName("open_terminal", try command.fmtbuf(&buf, .{ cmd, on_exit })); } pub const run_task_in_terminal_meta: Meta = .{ .description = "Run a task in terminal", From 2fd907345af0d96599f2eb55416b0b02b29af717 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 29 Mar 2026 19:27:48 +0200 Subject: [PATCH 70/70] refactor: add toggle_maximize_panel binding to file_in_files mode --- src/keybind/builtin/flow.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 269ce510..c3c912d1 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -520,6 +520,7 @@ ["shift+f1", "scroll_keybind_hints"], ["ctrl+alt+?", "scroll_keybind_hints"], ["alt+f9", "panel_next_widget_style"], + ["ctrl+shift+j", "toggle_maximize_panel"], ["ctrl+q", "quit"], ["ctrl+v", "system_paste"], ["ctrl+u", "mini_mode_reset"],