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 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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); } }