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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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/82] 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 561124e667d362083631ef7fd402fb07eb79dba6 Mon Sep 17 00:00:00 2001 From: JailBird Date: Tue, 24 Mar 2026 19:06:38 -0700 Subject: [PATCH 70/82] Add formatter for YAML and LSP for YAML and RPM .spec --- src/file_type_lsp.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/file_type_lsp.zig b/src/file_type_lsp.zig index d7bf03d3..83f0bf3b 100644 --- a/src/file_type_lsp.zig +++ b/src/file_type_lsp.zig @@ -183,7 +183,9 @@ pub const python = .{ pub const regex = .{}; -pub const rpmspec = .{}; +pub const rpmspec = .{ + .language_server = .{ "python3", "-mrpm_spec_language_server", "--stdio" }, +}; pub const rst = .{ .language_server = .{"esbonio"}, @@ -233,7 +235,10 @@ pub const xml = .{ .formatter = .{ "xmllint", "--format", "-" }, }; -pub const yaml = .{}; +pub const yaml = .{ + .language_server = .{ "yaml-language-server", "--stdio" }, + .formatter = .{ "prettier", "--parser", "yaml" }, +}; pub const zig = .{ .language_server = .{"zls"}, From 84a45810a6ccdc9dd27c3dcec2fab797a35dd9a8 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 25 Mar 2026 12:28:18 +0100 Subject: [PATCH 71/82] fix(lsp): fallback to sorting completions by label for LSPs that don't return unique sortText --- src/tui/mode/overlay/completion_dropdown.zig | 5 +++-- src/tui/mode/overlay/completion_palette.zig | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/tui/mode/overlay/completion_dropdown.zig b/src/tui/mode/overlay/completion_dropdown.zig index ceb7a340..a9a20786 100644 --- a/src/tui/mode/overlay/completion_dropdown.zig +++ b/src/tui/mode/overlay/completion_dropdown.zig @@ -87,8 +87,9 @@ pub fn load_entries(self: *Type) !usize { const less_fn = struct { fn less_fn(_: void, lhs: Entry, rhs: Entry) bool { - const lhs_str = if (lhs.sort_text.len > 0) lhs.sort_text else lhs.label; - const rhs_str = if (rhs.sort_text.len > 0) rhs.sort_text else rhs.label; + const sort_text_equal = std.mem.eql(u8, lhs.sort_text, rhs.sort_text); + const lhs_str = if (!sort_text_equal and lhs.sort_text.len > 0) lhs.sort_text else lhs.label; + const rhs_str = if (!sort_text_equal and rhs.sort_text.len > 0) rhs.sort_text else rhs.label; return std.mem.order(u8, lhs_str, rhs_str) == .lt; } }.less_fn; diff --git a/src/tui/mode/overlay/completion_palette.zig b/src/tui/mode/overlay/completion_palette.zig index ae8f9d26..c6e6723a 100644 --- a/src/tui/mode/overlay/completion_palette.zig +++ b/src/tui/mode/overlay/completion_palette.zig @@ -69,8 +69,9 @@ pub fn load_entries(palette: *Type) !usize { const less_fn = struct { fn less_fn(_: void, lhs: Entry, rhs: Entry) bool { - const lhs_str = if (lhs.sort_text.len > 0) lhs.sort_text else lhs.label; - const rhs_str = if (rhs.sort_text.len > 0) rhs.sort_text else rhs.label; + const sort_text_equal = std.mem.eql(u8, lhs.sort_text, rhs.sort_text); + const lhs_str = if (!sort_text_equal and lhs.sort_text.len > 0) lhs.sort_text else lhs.label; + const rhs_str = if (!sort_text_equal and rhs.sort_text.len > 0) rhs.sort_text else rhs.label; return std.mem.order(u8, lhs_str, rhs_str) == .lt; } }.less_fn; From 7f07f544c95e8ed97b295d488b786c6149175aac Mon Sep 17 00:00:00 2001 From: Volodia Kraplich Date: Tue, 24 Mar 2026 18:06:02 +0200 Subject: [PATCH 72/82] feat(lsp): support toml --- src/file_type_lsp.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/file_type_lsp.zig b/src/file_type_lsp.zig index 83f0bf3b..5b216187 100644 --- a/src/file_type_lsp.zig +++ b/src/file_type_lsp.zig @@ -216,7 +216,10 @@ pub const verilog = .{ .formatter = .{ "verible-verilog-format", "-" }, }; -pub const toml = .{}; +pub const toml = .{ + .language_server = .{ "tombi", "lsp" }, + .formatter = .{ "tombi", "format" }, +}; pub const typescript = .{ .language_server = .{ "typescript-language-server", "--stdio" }, From e930effa0ca98b679cd84259626dd92a74c77095 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Mar 2026 09:38:28 +0100 Subject: [PATCH 73/82] feat: add {{reflow_width}} expansion --- src/tui/expansion.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/tui/expansion.zig b/src/tui/expansion.zig index c98ab393..427a78f1 100644 --- a/src/tui/expansion.zig +++ b/src/tui/expansion.zig @@ -9,6 +9,7 @@ /// {{selections*}} - All current selections expanded to multiple quoted arguments /// {{indent_mode}} - The current indent mode ("tabs" or "spaces") /// {{indent_size}} - The current indent size (in columns) +/// {{reflow_width}} - The current reflow width (in columns) /// {{blame_commit}} - The blame commit ID at the line number of the primary cursor pub fn expand(allocator: Allocator, arg: []const u8) Error![]const u8 { var result: std.Io.Writer.Allocating = .init(allocator); @@ -162,6 +163,15 @@ const functions = struct { return stream.toOwnedSlice(); } + /// {{reflow_width}} - The current reflow width (in columns) + pub fn reflow_width(allocator: Allocator) Error![]const u8 { + const mv = tui.mainview() orelse return &.{}; + const ed = mv.get_active_editor() orelse return &.{}; + var stream: std.Io.Writer.Allocating = .init(allocator); + try stream.writer.print("{d}", .{ed.reflow_width orelse tui.config().reflow_width}); + return stream.toOwnedSlice(); + } + /// {{blame_commit}} - The blame commit ID at the line number of the primary cursor pub fn blame_commit(allocator: Allocator) Error![]const u8 { const mv = tui.mainview() orelse return &.{}; From f61c716c27b1327c3846aa688673e32263d897c2 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Mar 2026 09:39:06 +0100 Subject: [PATCH 74/82] feat: configure default markdown formatter to reflow at reflow_width --- src/file_type_lsp.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/file_type_lsp.zig b/src/file_type_lsp.zig index 5b216187..9064fc3c 100644 --- a/src/file_type_lsp.zig +++ b/src/file_type_lsp.zig @@ -116,7 +116,7 @@ pub const make = .{}; pub const markdown = .{ .language_server = .{ "marksman", "server" }, - .formatter = .{ "prettier", "--parser", "markdown" }, + .formatter = .{ "prettier", "--parser", "markdown", "--prose-wrap", "always", "--print-width", "{{reflow_width}}" }, }; pub const @"markdown-inline" = .{}; From a782bfb690aec321fc3c6a955953895a150333b1 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sat, 28 Mar 2026 14:48:07 +0100 Subject: [PATCH 75/82] fix: don't filter super, hyper and meta modifiers for no reason This filter was introduced to ignore caps lock, scroll lock and num lock modifier bits. super, hyper and meta might actually be useful though. --- src/renderer/vaxis/renderer.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/vaxis/renderer.zig b/src/renderer/vaxis/renderer.zig index 3ed5f280..384d0d7f 100644 --- a/src/renderer/vaxis/renderer.zig +++ b/src/renderer/vaxis/renderer.zig @@ -627,6 +627,9 @@ fn filter_mods(key_: vaxis.Key) vaxis.Key { .shift = key_.mods.shift, .alt = key_.mods.alt, .ctrl = key_.mods.ctrl, + .super = key_.mods.super, + .hyper = key_.mods.hyper, + .meta = key_.mods.meta, }; return key__; } 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 76/82] 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"], From a8437d61391479c1f56ae2ced5238d6f6973788b Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 31 Mar 2026 09:52:31 +0200 Subject: [PATCH 77/82] feat(themes): add Kanso theme (zen, ink, mist, pearl variants) --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 095e111d..48e0c7d0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -22,8 +22,8 @@ .hash = "thespian-0.0.1-owFOjlgiBgC8w4XqkCOegxz5vMy6kNErcssWQWf2QHeE", }, .themes = .{ - .url = "https://github.com/neurocyte/flow-themes/releases/download/master-c6c7f18cfb2e3945cd0b71dab24271465074dbc3/flow-themes.tar.gz", - .hash = "N-V-__8AAOKzJACguNxU76WX9M7RIhOYGuLnlasJ1-GDdhqT", + .url = "https://github.com/neurocyte/flow-themes/releases/download/master-750400d02ea8cacaabc869cd4d34dcebf04a53c8/flow-themes.tar.gz", + .hash = "N-V-__8AAEWxJQAyUV_rvRIWHB8EhIBxpQXqCB68SpilIjEt", }, .fuzzig = .{ .url = "https://github.com/fjebaker/fuzzig/archive/4251fe4230d38e721514394a485db62ee1667ff3.tar.gz", From ba840b72e0d48e4ea5dc6ab24ffa5fd3b336cf38 Mon Sep 17 00:00:00 2001 From: Paul Graydon Date: Sat, 14 Feb 2026 23:09:57 +0100 Subject: [PATCH 78/82] feat: [vim] Add word textobject actions --- src/keybind/builtin/vim.json | 12 ++++ src/tui/editor.zig | 12 +++- src/tui/mode/vim.zig | 128 ++++++++++++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src/keybind/builtin/vim.json b/src/keybind/builtin/vim.json index a844be61..587f148f 100644 --- a/src/keybind/builtin/vim.json +++ b/src/keybind/builtin/vim.json @@ -81,14 +81,23 @@ ["dgg", "cut_buffer_begin"], ["\"_dd", "delete_line"], + ["diw", "cut_inside_word"], + ["daw", "cut_around_word"], + ["cc", ["enter_mode", "insert"], ["cut_internal_vim"]], ["C", ["enter_mode", "insert"], ["cut_to_end_vim"]], ["D", "cut_to_end_vim"], ["cw", ["enter_mode", "insert"], ["cut_word_right_vim"]], ["cb", ["enter_mode", "insert"], ["cut_word_left_vim"]], + ["ciw", ["enter_mode", "insert"], ["cut_inside_word"]], + ["caw", ["enter_mode", "insert"], ["cut_around_word"]], + ["yy", ["copy_line_internal_vim"], ["cancel"]], + ["yiw", ["copy_inside_word"], ["cancel"]], + ["yaw", ["copy_around_word"], ["cancel"]], + ["", "move_scroll_half_page_up_vim"], ["", "move_scroll_half_page_down_vim"], @@ -159,6 +168,9 @@ ["B", "select_word_left"], ["e", "select_word_right_end_vim"], + ["iw", "select_inside_word"], + ["aw", "select_around_word"], + ["^", "smart_move_begin"], ["$", "select_end"], [":", "open_command_palette"], diff --git a/src/tui/editor.zig b/src/tui/editor.zig index bdac85ac..e65dd6d5 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -2628,7 +2628,15 @@ pub const Editor = struct { return cursor.test_at(root, is_whitespace, metrics); } - fn is_non_whitespace_at_cursor(root: Buffer.Root, cursor: *const Cursor, metrics: Buffer.Metrics) bool { + pub fn is_whitespace_or_eol_at_cursor(root: Buffer.Root, cursor: *const Cursor, metrics: Buffer.Metrics) bool { + return cursor.test_at(root, is_whitespace_or_eol, metrics); + } + + pub fn is_non_whitespace_at_cursor(root: Buffer.Root, cursor: *const Cursor, metrics: Buffer.Metrics) bool { + return !cursor.test_at(root, is_whitespace, metrics); + } + + pub fn is_non_whitespace_or_eol_at_cursor(root: Buffer.Root, cursor: *const Cursor, metrics: Buffer.Metrics) bool { return !cursor.test_at(root, is_whitespace_or_eol, metrics); } @@ -3745,7 +3753,7 @@ pub const Editor = struct { } pub fn move_cursor_right_until_non_whitespace(root: Buffer.Root, cursor: *Cursor, metrics: Buffer.Metrics) error{Stop}!void { - move_cursor_right_until(root, cursor, is_non_whitespace_at_cursor, metrics); + move_cursor_right_until(root, cursor, is_non_whitespace_or_eol_at_cursor, metrics); } pub fn move_word_left(self: *Self, ctx: Context) Result { diff --git a/src/tui/mode/vim.zig b/src/tui/mode/vim.zig index 38eacbee..f7067e50 100644 --- a/src/tui/mode/vim.zig +++ b/src/tui/mode/vim.zig @@ -2,6 +2,13 @@ const std = @import("std"); const command = @import("command"); const cmd = command.executeName; +const tui = @import("../tui.zig"); + +const Buffer = @import("Buffer"); +const Cursor = Buffer.Cursor; +const CurSel = @import("../editor.zig").CurSel; +const Editor = @import("../editor.zig").Editor; + var commands: Commands = undefined; pub fn init() !void { @@ -138,6 +145,125 @@ const cmds_ = struct { //TODO return undefined; } - pub const copy_line_meta: Meta = .{ .description = "Copies the current line" }; + + pub fn select_inside_word(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_word_textobject, ed.metrics); + } + pub const select_inside_word_meta: Meta = .{ .description = "Select inside word" }; + + pub fn select_around_word(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_word_textobject, ed.metrics); + } + pub const select_around_word_meta: Meta = .{ .description = "Select around word" }; + + pub fn cut_inside_word(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_word_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_inside_word_meta: Meta = .{ .description = "Cut inside word" }; + + pub fn cut_around_word(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_word_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_around_word_meta: Meta = .{ .description = "Cut around word" }; + + pub fn copy_inside_word(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_word_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_inside_word_meta: Meta = .{ .description = "Copy inside word" }; + + pub fn copy_around_word(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_word_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_around_word_meta: Meta = .{ .description = "Copy around word" }; }; + +fn is_tab_or_space(c: []const u8) bool { + return (c[0] == ' ') or (c[0] == '\t'); +} + +fn is_tab_or_space_at_cursor(root: Buffer.Root, cursor: *const Cursor, metrics: Buffer.Metrics) bool { + return cursor.test_at(root, is_tab_or_space, metrics); +} +fn is_not_tab_or_space_at_cursor(root: Buffer.Root, cursor: *const Cursor, metrics: Buffer.Metrics) bool { + return !cursor.test_at(root, is_tab_or_space, metrics); +} + +fn select_inside_word_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_word_textobject(root, cursel, metrics, .inside); +} + +fn select_around_word_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_word_textobject(root, cursel, metrics, .around); +} + +fn select_word_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics, scope: enum { inside, around }) !void { + var prev = cursel.cursor; + var next = cursel.cursor; + + if (cursel.cursor.test_at(root, Editor.is_non_word_char, metrics)) { + if (cursel.cursor.test_at(root, Editor.is_whitespace_or_eol, metrics)) { + Editor.move_cursor_left_until(root, &prev, Editor.is_non_whitespace_at_cursor, metrics); + Editor.move_cursor_right_until(root, &next, Editor.is_non_whitespace_at_cursor, metrics); + } else { + Editor.move_cursor_left_until(root, &prev, Editor.is_whitespace_or_eol_at_cursor, metrics); + Editor.move_cursor_right_until(root, &next, Editor.is_whitespace_or_eol_at_cursor, metrics); + } + prev.move_right(root, metrics) catch {}; + } else { + Editor.move_cursor_left_until(root, &prev, Editor.is_word_boundary_left_vim, metrics); + Editor.move_cursor_right_until(root, &next, Editor.is_word_boundary_right_vim, metrics); + next.move_right(root, metrics) catch {}; + } + + if (scope == .around) { + const inside_prev = prev; + const inside_next = next; + + if (next.test_at(root, is_tab_or_space, metrics)) { + Editor.move_cursor_right_until(root, &next, is_not_tab_or_space_at_cursor, metrics); + } else { + next = inside_next; + prev.move_left(root, metrics) catch {}; + if (prev.test_at(root, is_tab_or_space, metrics)) { + Editor.move_cursor_left_until(root, &prev, is_not_tab_or_space_at_cursor, metrics); + prev.move_right(root, metrics) catch {}; + } else { + prev = inside_prev; + } + } + } + + const sel = cursel.enable_selection(root, metrics); + sel.begin = prev; + sel.end = next; + cursel.*.cursor = next; +} From ce7cc48a722fc5410ae653dab31b3a56216f9113 Mon Sep 17 00:00:00 2001 From: Paul Graydon Date: Sun, 8 Mar 2026 16:23:21 +0100 Subject: [PATCH 79/82] feat: [vim] Add bracket textobject actions --- src/keybind/builtin/vim.json | 52 +++++++ src/tui/editor.zig | 2 +- src/tui/mode/vim.zig | 274 +++++++++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+), 1 deletion(-) diff --git a/src/keybind/builtin/vim.json b/src/keybind/builtin/vim.json index 587f148f..07d82099 100644 --- a/src/keybind/builtin/vim.json +++ b/src/keybind/builtin/vim.json @@ -82,7 +82,20 @@ ["\"_dd", "delete_line"], ["diw", "cut_inside_word"], + ["di(", "cut_inside_parentheses"], + ["di)", "cut_inside_parentheses"], + ["di[", "cut_inside_square_brackets"], + ["di]", "cut_inside_square_brackets"], + ["di{", "cut_inside_braces"], + ["di}", "cut_inside_braces"], + ["daw", "cut_around_word"], + ["da(", "cut_around_parentheses"], + ["da)", "cut_around_parentheses"], + ["da[", "cut_around_square_brackets"], + ["da]", "cut_around_square_brackets"], + ["da{", "cut_around_braces"], + ["da}", "cut_around_braces"], ["cc", ["enter_mode", "insert"], ["cut_internal_vim"]], ["C", ["enter_mode", "insert"], ["cut_to_end_vim"]], @@ -91,12 +104,38 @@ ["cb", ["enter_mode", "insert"], ["cut_word_left_vim"]], ["ciw", ["enter_mode", "insert"], ["cut_inside_word"]], + ["ci(", ["enter_mode", "insert"], ["cut_inside_parentheses"]], + ["ci)", ["enter_mode", "insert"], ["cut_inside_parentheses"]], + ["ci[", ["enter_mode", "insert"], ["cut_inside_square_brackets"]], + ["ci]", ["enter_mode", "insert"], ["cut_inside_square_brackets"]], + ["ci{", ["enter_mode", "insert"], ["cut_inside_braces"]], + ["ci}", ["enter_mode", "insert"], ["cut_inside_braces"]], + ["caw", ["enter_mode", "insert"], ["cut_around_word"]], + ["ca(", ["enter_mode", "insert"], ["cut_around_parentheses"]], + ["ca)", ["enter_mode", "insert"], ["cut_around_parentheses"]], + ["ca[", ["enter_mode", "insert"], ["cut_around_square_brackets"]], + ["ca]", ["enter_mode", "insert"], ["cut_around_square_brackets"]], + ["ca{", ["enter_mode", "insert"], ["cut_around_braces"]], + ["ca}", ["enter_mode", "insert"], ["cut_around_braces"]], ["yy", ["copy_line_internal_vim"], ["cancel"]], ["yiw", ["copy_inside_word"], ["cancel"]], + ["yi(", ["copy_inside_parentheses"], ["cancel"]], + ["yi)", ["copy_inside_parentheses"], ["cancel"]], + ["yi[", ["copy_inside_square_brackets"], ["cancel"]], + ["yi]", ["copy_inside_square_brackets"], ["cancel"]], + ["yi{", ["copy_inside_braces"], ["cancel"]], + ["yi}", ["copy_inside_braces"], ["cancel"]], + ["yaw", ["copy_around_word"], ["cancel"]], + ["ya(", ["copy_around_parentheses"], ["cancel"]], + ["ya)", ["copy_around_parentheses"], ["cancel"]], + ["ya[", ["copy_around_square_brackets"], ["cancel"]], + ["ya]", ["copy_around_square_brackets"], ["cancel"]], + ["ya{", ["copy_around_braces"], ["cancel"]], + ["ya}", ["copy_around_braces"], ["cancel"]], ["", "move_scroll_half_page_up_vim"], ["", "move_scroll_half_page_down_vim"], @@ -169,7 +208,20 @@ ["e", "select_word_right_end_vim"], ["iw", "select_inside_word"], + ["i(", "select_inside_parentheses"], + ["i)", "select_inside_parentheses"], + ["i[", "select_inside_square_brackets"], + ["i]", "select_inside_square_brackets"], + ["i{", "select_inside_braces"], + ["i}", "select_inside_braces"], + ["aw", "select_around_word"], + ["a(", "select_around_parentheses"], + ["a)", "select_around_parentheses"], + ["a[", "select_around_square_brackets"], + ["a]", "select_around_square_brackets"], + ["a{", "select_around_braces"], + ["a}", "select_around_braces"], ["^", "smart_move_begin"], ["$", "select_end"], diff --git a/src/tui/editor.zig b/src/tui/editor.zig index e65dd6d5..74a99168 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -41,7 +41,7 @@ const double_click_time_ms = 350; const syntax_full_reparse_time_limit = 0; // ms (0 = always use incremental) const syntax_full_reparse_error_threshold = 3; // number of tree-sitter errors that trigger a full reparse -const bracket_search_radius = if (builtin.mode == std.builtin.OptimizeMode.Debug) 8_192 else 65_536; +pub const bracket_search_radius = if (builtin.mode == std.builtin.OptimizeMode.Debug) 8_192 else 65_536; pub const max_matches = if (builtin.mode == std.builtin.OptimizeMode.Debug) 10_000 else 100_000; pub const max_match_lines = 15; diff --git a/src/tui/mode/vim.zig b/src/tui/mode/vim.zig index f7067e50..043ed35a 100644 --- a/src/tui/mode/vim.zig +++ b/src/tui/mode/vim.zig @@ -8,6 +8,7 @@ const Buffer = @import("Buffer"); const Cursor = Buffer.Cursor; const CurSel = @import("../editor.zig").CurSel; const Editor = @import("../editor.zig").Editor; +const bracket_search_radius = @import("../editor.zig").bracket_search_radius; var commands: Commands = undefined; @@ -165,6 +166,60 @@ const cmds_ = struct { } pub const select_around_word_meta: Meta = .{ .description = "Select around word" }; + pub fn select_inside_parentheses(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_parentheses_textobject, ed.metrics); + } + pub const select_inside_parentheses_meta: Meta = .{ .description = "Select inside ()" }; + + pub fn select_around_parentheses(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_parentheses_textobject, ed.metrics); + } + pub const select_around_parentheses_meta: Meta = .{ .description = "Select around ()" }; + + pub fn select_inside_square_brackets(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_square_brackets_textobject, ed.metrics); + } + pub const select_inside_square_brackets_meta: Meta = .{ .description = "Select inside []" }; + + pub fn select_around_square_brackets(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_square_brackets_textobject, ed.metrics); + } + pub const select_around_square_brackets_meta: Meta = .{ .description = "Select around []" }; + + pub fn select_inside_braces(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_braces_textobject, ed.metrics); + } + pub const select_inside_braces_meta: Meta = .{ .description = "Select inside {}" }; + + pub fn select_around_braces(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_braces_textobject, ed.metrics); + } + pub const select_around_braces_meta: Meta = .{ .description = "Select around {}" }; + pub fn cut_inside_word(_: *void, ctx: Ctx) Result { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; @@ -185,6 +240,66 @@ const cmds_ = struct { } pub const cut_around_word_meta: Meta = .{ .description = "Cut around word" }; + pub fn cut_inside_parentheses(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_parentheses_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_inside_parentheses_meta: Meta = .{ .description = "Cut inside ()" }; + + pub fn cut_around_parentheses(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_parentheses_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_around_parentheses_meta: Meta = .{ .description = "Cut around ()" }; + + pub fn cut_inside_square_brackets(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_square_brackets_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_inside_square_brackets_meta: Meta = .{ .description = "Cut inside []" }; + + pub fn cut_around_square_brackets(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_square_brackets_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_around_square_brackets_meta: Meta = .{ .description = "Cut around []" }; + + pub fn cut_inside_braces(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_braces_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_inside_braces_meta: Meta = .{ .description = "Cut inside {}" }; + + pub fn cut_around_braces(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_braces_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_around_braces_meta: Meta = .{ .description = "Cut around {}" }; + pub fn copy_inside_word(_: *void, ctx: Ctx) Result { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; @@ -204,6 +319,66 @@ const cmds_ = struct { try ed.copy_internal_vim(ctx); } pub const copy_around_word_meta: Meta = .{ .description = "Copy around word" }; + + pub fn copy_inside_parentheses(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_parentheses_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_inside_parentheses_meta: Meta = .{ .description = "Copy inside ()" }; + + pub fn copy_around_parentheses(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_parentheses_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_around_parentheses_meta: Meta = .{ .description = "Copy around ()" }; + + pub fn copy_inside_square_brackets(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_square_brackets_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_inside_square_brackets_meta: Meta = .{ .description = "Copy inside []" }; + + pub fn copy_around_square_brackets(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_square_brackets_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_around_square_brackets_meta: Meta = .{ .description = "Copy around []" }; + + pub fn copy_inside_braces(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_braces_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_inside_braces_meta: Meta = .{ .description = "Copy inside {}" }; + + pub fn copy_around_braces(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_braces_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_around_braces_meta: Meta = .{ .description = "Copy around {}" }; }; fn is_tab_or_space(c: []const u8) bool { @@ -267,3 +442,102 @@ fn select_word_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Me sel.end = next; cursel.*.cursor = next; } + +fn select_inside_parentheses_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_bracket_textobject(root, cursel, metrics, "(", ")", .inside); +} + +fn select_around_parentheses_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_bracket_textobject(root, cursel, metrics, "(", ")", .around); +} + +fn select_inside_square_brackets_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_bracket_textobject(root, cursel, metrics, "[", "]", .inside); +} + +fn select_around_square_brackets_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_bracket_textobject(root, cursel, metrics, "[", "]", .around); +} + +fn select_inside_braces_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_bracket_textobject(root, cursel, metrics, "{", "}", .inside); +} + +fn select_around_braces_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_bracket_textobject(root, cursel, metrics, "{", "}", .around); +} + +fn select_bracket_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics, opening_char: []const u8, closing_char: []const u8, scope: enum { inside, around }) !void { + const current = cursel.cursor; + var prev = cursel.cursor; + var next = cursel.cursor; + + const bracket_egc, _, _ = root.egc_at(current.row, current.col, metrics) catch { + return error.Stop; + }; + if (std.mem.eql(u8, bracket_egc, opening_char)) { + const closing_row, const closing_col = try Editor.match_bracket(root, current, metrics); + + prev = current; + next.row = closing_row; + next.col = closing_col; + } else if (std.mem.eql(u8, bracket_egc, closing_char)) { + const opening_row, const opening_col = try Editor.match_bracket(root, current, metrics); + + prev.row = opening_row; + prev.col = opening_col; + next = current; + } else { + const opening_pos, const closing_pos = find_bracket_pair(root, cursel, metrics, .left, opening_char) catch try find_bracket_pair(root, cursel, metrics, .right, opening_char); + + prev.row = opening_pos[0]; + prev.col = opening_pos[1]; + next.row = closing_pos[0]; + next.col = closing_pos[1]; + } + + prev.move_right(root, metrics) catch {}; + + if (scope == .around) { + prev.move_left(root, metrics) catch {}; + next.move_right(root, metrics) catch {}; + } + + const sel = cursel.enable_selection(root, metrics); + sel.begin = prev; + sel.end = next; + cursel.*.cursor = next; +} + +fn find_bracket_pair(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics, direction: enum { left, right }, char: []const u8) error{Stop}!struct { struct { usize, usize }, struct { usize, usize } } { + const start = cursel.cursor; + var moving_cursor = cursel.cursor; + + var i: usize = 0; + while (i < bracket_search_radius) : (i += 1) { + switch (direction) { + .left => try moving_cursor.move_left(root, metrics), + .right => try moving_cursor.move_right(root, metrics), + } + + const curr_egc, _, _ = root.egc_at(moving_cursor.row, moving_cursor.col, metrics) catch { + return error.Stop; + }; + if (std.mem.eql(u8, char, curr_egc)) { + const closing_row, const closing_col = try Editor.match_bracket(root, moving_cursor, metrics); + + switch (direction) { + .left => if (closing_row > start.row or (closing_row == start.row and closing_col > start.col)) { + return .{ .{ moving_cursor.row, moving_cursor.col }, .{ closing_row, closing_col } }; + } else { + continue; + }, + .right => { + return .{ .{ moving_cursor.row, moving_cursor.col }, .{ closing_row, closing_col } }; + }, + } + } + } + + return error.Stop; +} From d53d155c6d94bd6caec6655c88e0744f4092a95e Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 31 Mar 2026 14:01:22 +0200 Subject: [PATCH 80/82] feat: add V language support closes #509 --- build.zig.zon | 4 ++-- src/file_type_lsp.zig | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 48e0c7d0..71a537a6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -6,8 +6,8 @@ .dependencies = .{ .syntax = .{ - .url = "git+https://github.com/neurocyte/flow-syntax?ref=master#56929f0c523b59153e17919be2cd09d8bef32cd0", - .hash = "flow_syntax-0.7.2-X8jOoeFTAQBeP2Tn08Tw1jsMdifLEDBgPLqPqNelAupy", + .url = "git+https://github.com/neurocyte/flow-syntax?ref=master#7b1fd3a97f00aba3a95cc65b95f34162347ed1ea", + .hash = "flow_syntax-0.7.2-X8jOoQhWAQBPt1rBRmttAGI0Z2QC-hCSZuoBZoZgr6Vv", }, .flags = .{ .url = "git+https://github.com/neurocyte/flags?ref=main#984b27948da3e4e40a253f76c85b51ec1a9ada11", diff --git a/src/file_type_lsp.zig b/src/file_type_lsp.zig index 9064fc3c..a359e8a7 100644 --- a/src/file_type_lsp.zig +++ b/src/file_type_lsp.zig @@ -232,6 +232,11 @@ pub const typst = .{ pub const uxntal = .{}; +pub const v = .{ + .language_server = .{"v-analyzer"}, + .formatter = .{ "v", "fmt", "-" }, +}; + pub const vim = .{}; pub const xml = .{ From 310221bb266ae6a0252541daf6ebc2dacedbf5b7 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 31 Mar 2026 20:58:00 +0200 Subject: [PATCH 81/82] feat: support adding entirely new themes via the config --- src/main.zig | 6 +- src/soft_root.zig | 5 ++ src/tui/Widget.zig | 110 ++++++++++++++++++++++++- src/tui/mode/overlay/theme_palette.zig | 3 +- src/tui/tui.zig | 73 ++-------------- 5 files changed, 126 insertions(+), 71 deletions(-) diff --git a/src/main.zig b/src/main.zig index b191faf5..a8de7100 100644 --- a/src/main.zig +++ b/src/main.zig @@ -895,15 +895,15 @@ pub fn write_theme(theme_name: []const u8, content: []const u8) !void { pub fn list_themes(allocator: std.mem.Allocator) ![]const []const u8 { var dir = try std.fs.openDirAbsolute(try get_theme_directory(), .{ .iterate = true }); defer dir.close(); - var result = std.ArrayList([]const u8).init(allocator); + var result: std.ArrayList([]const u8) = .empty; var iter = dir.iterateAssumeFirstIteration(); while (try iter.next()) |entry| { switch (entry.kind) { - .file, .sym_link => try result.append(try allocator.dupe(u8, std.fs.path.stem(entry.name))), + .file, .sym_link => try result.append(allocator, try allocator.dupe(u8, std.fs.path.stem(entry.name))), else => continue, } } - return result.toOwnedSlice(); + return result.toOwnedSlice(allocator); } pub fn get_config_dir() ConfigDirError![]const u8 { diff --git a/src/soft_root.zig b/src/soft_root.zig index a792e730..d076e942 100644 --- a/src/soft_root.zig +++ b/src/soft_root.zig @@ -29,6 +29,7 @@ pub const root = struct { pub const read_theme = if (@hasDecl(hard_root, "read_theme")) hard_root.read_theme else dummy.read_theme; pub const write_theme = if (@hasDecl(hard_root, "write_theme")) hard_root.write_theme else dummy.write_theme; + pub const list_themes = if (@hasDecl(hard_root, "list_themes")) hard_root.list_themes else dummy.list_themes; pub const get_theme_file_name = if (@hasDecl(hard_root, "get_theme_file_name")) hard_root.get_theme_file_name else dummy.get_theme_file_name; pub const exit = if (@hasDecl(hard_root, "exit")) hard_root.exit else dummy.exit; @@ -109,6 +110,10 @@ const dummy = struct { pub fn write_theme(_: []const u8, _: []const u8) !void { @panic("dummy write_theme call"); } + pub fn list_themes(_: std.mem.Allocator) ![]const []const u8 { + @panic("dummy list_themes call"); + } + pub fn get_theme_file_name(_: []const u8) ![]const u8 { @panic("dummy get_theme_file_name call"); } diff --git a/src/tui/Widget.zig b/src/tui/Widget.zig index 6db55a94..9a473ecf 100644 --- a/src/tui/Widget.zig +++ b/src/tui/Widget.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const tp = @import("thespian"); +const root = @import("soft_root").root; const Plane = @import("renderer").Plane; const EventHandler = @import("EventHandler"); @@ -9,7 +10,6 @@ const tui = @import("tui.zig"); pub const Box = @import("Box.zig"); pub const Pos = struct { y: i32 = 0, x: i32 = 0 }; pub const Theme = @import("theme"); -pub const themes = @import("themes").themes; pub const scopes = @import("themes").scopes; pub const Type = @import("config").WidgetType; pub const StyleTag = @import("config").WidgetStyle; @@ -42,6 +42,114 @@ pub const Layout = union(enum) { } }; +pub const ThemeInfo = struct { + name: []const u8, + storage: ?std.json.Parsed(Theme) = null, + + pub fn get(self: *@This(), allocator: std.mem.Allocator) ?Theme { + if (load_theme_file(allocator, self.name) catch null) |parsed_theme| { + self.storage = parsed_theme; + return self.storage.?.value; + } + + for (static_themes) |theme_| { + if (std.mem.eql(u8, theme_.name, self.name)) + return theme_; + } + return null; + } + + fn load_theme_file(allocator: std.mem.Allocator, theme_name: []const u8) !?std.json.Parsed(Theme) { + return load_theme_file_internal(allocator, theme_name) catch |e| { + std.log.err("Error loading theme '{s}' from file: {t}", .{ theme_name, e }); + return e; + }; + } + fn load_theme_file_internal(allocator: std.mem.Allocator, theme_name: []const u8) !?std.json.Parsed(Theme) { + const json_str = root.read_theme(allocator, theme_name) orelse return null; + defer allocator.free(json_str); + return try std.json.parseFromSlice(Theme, allocator, json_str, .{ .allocate = .alloc_always }); + } +}; + +var themes_: ?std.StringHashMap(*ThemeInfo) = null; +var theme_names_: ?[]const []const u8 = null; +const static_themes = @import("themes").themes; + +fn get_themes(allocator: std.mem.Allocator) *std.StringHashMap(*ThemeInfo) { + if (themes_) |*themes__| return themes__; + + const theme_files = root.list_themes(allocator) catch @panic("OOM get_themes"); + var themes: std.StringHashMap(*ThemeInfo) = .init(allocator); + defer allocator.free(theme_files); + for (theme_files) |file| { + const theme_info = allocator.create(ThemeInfo) catch @panic("OOM get_themes"); + theme_info.* = .{ + .name = file, + }; + themes.put(theme_info.name, theme_info) catch @panic("OOM get_themes"); + } + + for (static_themes) |theme_| if (!themes.contains(theme_.name)) { + const theme_info = allocator.create(ThemeInfo) catch @panic("OOM get_themes"); + theme_info.* = .{ + .name = theme_.name, + }; + themes.put(theme_info.name, theme_info) catch @panic("OOM get_themes"); + }; + themes_ = themes; + return &themes_.?; +} + +fn get_theme_names() []const []const u8 { + if (theme_names_) |names_| return names_; + const themes = themes_ orelse return &.{}; + var i = get_themes(themes.allocator).iterator(); + var names: std.ArrayList([]const u8) = .empty; + while (i.next()) |theme_| names.append(themes.allocator, theme_.value_ptr.*.name) catch @panic("OOM get_theme_names"); + std.mem.sort([]const u8, names.items, {}, struct { + fn cmp(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.mem.order(u8, lhs, rhs) == .lt; + } + }.cmp); + theme_names_ = names.toOwnedSlice(themes.allocator) catch @panic("OOM get_theme_names"); + return theme_names_.?; +} + +pub fn get_theme_by_name(allocator: std.mem.Allocator, name_: []const u8) ?Theme { + const themes = get_themes(allocator); + const theme = themes.get(name_) orelse return null; + return theme.get(allocator); +} + +pub fn get_next_theme_by_name(name_: []const u8) []const u8 { + const theme_names = get_theme_names(); + var next = false; + for (theme_names) |theme_name| { + if (next) + return theme_name; + if (std.mem.eql(u8, theme_name, name_)) + next = true; + } + return theme_names[0]; +} + +pub fn get_prev_theme_by_name(name_: []const u8) []const u8 { + const theme_names = get_theme_names(); + const last = theme_names[theme_names.len - 1]; + var prev: ?[]const u8 = null; + for (theme_names) |theme_name| { + if (std.mem.eql(u8, theme_name, name_)) + return prev orelse last; + prev = theme_name; + } + return last; +} + +pub fn list_themes() []const []const u8 { + return get_theme_names(); +} + pub const VTable = struct { deinit: *const fn (ctx: *anyopaque, allocator: Allocator) void, send: *const fn (ctx: *anyopaque, from: tp.pid_ref, m: tp.message) error{Exit}!bool, diff --git a/src/tui/mode/overlay/theme_palette.zig b/src/tui/mode/overlay/theme_palette.zig index a58e9844..1f9396df 100644 --- a/src/tui/mode/overlay/theme_palette.zig +++ b/src/tui/mode/overlay/theme_palette.zig @@ -33,7 +33,8 @@ pub fn load_entries(palette: *Type) !usize { var longest_hint: usize = 0; var idx: usize = 0; try set_previous_theme(palette, tui.theme().name); - for (Widget.themes) |theme| { + for (Widget.list_themes()) |theme_name_| { + const theme = Widget.get_theme_by_name(palette.allocator, theme_name_) orelse continue; idx += 1; (try palette.entries.addOne(palette.allocator)).* = .{ .label = theme.description, diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 3d9a1ad6..6b04a30c 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -62,9 +62,7 @@ logger: log.Logger, drag_source: ?Widget = null, drag_button: input.MouseType = 0, dark_theme: Widget.Theme, -dark_parsed_theme: ?std.json.Parsed(Widget.Theme), light_theme: Widget.Theme, -light_parsed_theme: ?std.json.Parsed(Widget.Theme), idle_frame_count: usize = 0, unrendered_input_events_count: usize = 0, init_timer: ?tp.timeout, @@ -160,9 +158,9 @@ fn init(allocator: Allocator) InitError!*Self { if (@hasDecl(renderer, "install_crash_handler") and conf.start_debugger_on_crash) renderer.jit_debugger_enabled = true; - const dark_theme, const dark_parsed_theme = get_theme_by_name(allocator, conf.theme) orelse get_theme_by_name(allocator, "dark_modern") orelse return error.UnknownTheme; + const dark_theme = Widget.get_theme_by_name(allocator, conf.theme) orelse Widget.get_theme_by_name(allocator, "dark_modern") orelse return error.UnknownTheme; conf.theme = dark_theme.name; - const light_theme, const light_parsed_theme = get_theme_by_name(allocator, conf.light_theme) orelse get_theme_by_name(allocator, "default-light") orelse return error.UnknownTheme; + const light_theme = Widget.get_theme_by_name(allocator, conf.light_theme) orelse Widget.get_theme_by_name(allocator, "default-light") orelse return error.UnknownTheme; conf.light_theme = light_theme.name; if (build_options.gui) conf.enable_terminal_cursor = false; @@ -203,8 +201,6 @@ fn init(allocator: Allocator) InitError!*Self { .query_cache_ = try syntax.QueryCache.create(allocator, .{}), .dark_theme = dark_theme, .light_theme = light_theme, - .dark_parsed_theme = dark_parsed_theme, - .light_parsed_theme = light_parsed_theme, }; instance_ = self; defer instance_ = null; @@ -987,21 +983,13 @@ fn refresh_input_mode(self: *Self) command.Result { } fn set_theme_by_name(self: *Self, name: []const u8, action: enum { none, store }) !void { - const theme_, const parsed_theme = get_theme_by_name(self.allocator, name) orelse { + const theme_ = Widget.get_theme_by_name(self.allocator, name) orelse { self.logger.print("theme not found: {s}", .{name}); return; }; switch (self.color_scheme) { - .dark => { - if (self.dark_parsed_theme) |p| p.deinit(); - self.dark_parsed_theme = parsed_theme; - self.dark_theme = theme_; - }, - .light => { - if (self.light_parsed_theme) |p| p.deinit(); - self.light_parsed_theme = parsed_theme; - self.light_theme = theme_; - }, + .dark => self.dark_theme = theme_, + .light => self.light_theme = theme_, } self.set_terminal_style(&theme_); self.logger.print("theme: {s}", .{theme_.description}); @@ -1141,13 +1129,13 @@ const cmds = struct { pub const set_theme_meta: Meta = .{ .arguments = &.{.string} }; pub fn theme_next(self: *Self, _: Ctx) Result { - const name = get_next_theme_by_name(self.current_theme().name); + const name = Widget.get_next_theme_by_name(self.current_theme().name); return self.set_theme_by_name(name, .store); } pub const theme_next_meta: Meta = .{ .description = "Next color theme" }; pub fn theme_prev(self: *Self, _: Ctx) Result { - const name = get_prev_theme_by_name(self.current_theme().name); + const name = Widget.get_prev_theme_by_name(self.current_theme().name); return self.set_theme_by_name(name, .store); } pub const theme_prev_meta: Meta = .{ .description = "Previous color theme" }; @@ -2020,40 +2008,6 @@ pub fn theme() *const Widget.Theme { return current().current_theme(); } -pub fn get_theme_by_name(allocator: std.mem.Allocator, name: []const u8) ?struct { Widget.Theme, ?std.json.Parsed(Widget.Theme) } { - if (load_theme_file(allocator, name) catch null) |parsed_theme| { - std.log.info("loaded theme from file: {s}", .{name}); - return .{ parsed_theme.value, parsed_theme }; - } - - for (Widget.themes) |theme_| { - if (std.mem.eql(u8, theme_.name, name)) - return .{ theme_, null }; - } - return null; -} - -fn get_next_theme_by_name(name: []const u8) []const u8 { - var next = false; - for (Widget.themes) |theme_| { - if (next) - return theme_.name; - if (std.mem.eql(u8, theme_.name, name)) - next = true; - } - return Widget.themes[0].name; -} - -fn get_prev_theme_by_name(name: []const u8) []const u8 { - var prev: ?Widget.Theme = null; - for (Widget.themes) |theme_| { - if (std.mem.eql(u8, theme_.name, name)) - return (prev orelse Widget.themes[Widget.themes.len - 1]).name; - prev = theme_; - } - return Widget.themes[Widget.themes.len - 1].name; -} - pub fn find_scope_style(theme_: *const Widget.Theme, scope: []const u8) ?Widget.Theme.Token { return if (find_scope_fallback(scope)) |tm_scope| scope_to_theme_token(theme_, tm_scope) orelse @@ -2442,19 +2396,6 @@ fn get_or_create_theme_file(self: *Self, allocator: std.mem.Allocator) ![]const return try root.get_theme_file_name(theme_name); } -fn load_theme_file(allocator: std.mem.Allocator, theme_name: []const u8) !?std.json.Parsed(Widget.Theme) { - return load_theme_file_internal(allocator, theme_name) catch |e| { - std.log.err("loaded theme from file failed: {}", .{e}); - return e; - }; -} -fn load_theme_file_internal(allocator: std.mem.Allocator, theme_name: []const u8) !?std.json.Parsed(Widget.Theme) { - _ = std.json.Scanner; - const json_str = root.read_theme(allocator, theme_name) orelse return null; - defer allocator.free(json_str); - return try std.json.parseFromSlice(Widget.Theme, allocator, json_str, .{ .allocate = .alloc_always }); -} - pub const WidgetType = @import("config").WidgetType; pub const ConfigWidgetStyle = @import("config").WidgetStyle; pub const WidgetStyle = @import("WidgetStyle.zig"); From cf7fc6af5455646a84c3b571cd6c8de85ccf4af1 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 31 Mar 2026 20:58:54 +0200 Subject: [PATCH 82/82] fix: allow loading custom theme files up to 512Kb in size closes #544 --- src/main.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index a8de7100..054340ca 100644 --- a/src/main.zig +++ b/src/main.zig @@ -882,7 +882,10 @@ pub fn read_theme(allocator: std.mem.Allocator, theme_name: []const u8) ?[]const const file_name = get_theme_file_name(theme_name) catch return null; var file = std.fs.openFileAbsolute(file_name, .{ .mode = .read_only }) catch return null; defer file.close(); - return file.readToEndAlloc(allocator, 64 * 1024) catch null; + return file.readToEndAlloc(allocator, 512 * 1024) catch |e| { + std.log.err("Error reading theme file: {t}", .{e}); + return null; + }; } pub fn write_theme(theme_name: []const u8, content: []const u8) !void {