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/build.zig.zon b/build.zig.zon index 095e111d..c7b77d23 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#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/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/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; } } diff --git a/src/config.zig b/src/config.zig index 194a60d7..5cf6325f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -12,6 +12,8 @@ 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, +terminal_on_exit: TerminalOnExit = .hold_on_error, enable_sgr_pixel_mode_support: bool = true, enable_modal_dim: bool = true, highlight_current_line: bool = true, @@ -247,3 +249,9 @@ pub const AgeFormat = enum { short, long, }; + +pub const TerminalOnExit = enum { + hold_on_error, + close, + hold, +}; 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; diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 9ce0934d..c3c912d1 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -23,7 +23,9 @@ ["ctrl+6", "focus_split", 5], ["ctrl+7", "focus_split", 6], ["ctrl+8", "focus_split", 7], + ["ctrl+`", "open_terminal"], ["ctrl+j", "toggle_panel"], + ["ctrl+shift+j", "toggle_maximize_panel"], ["ctrl+q", "quit"], ["ctrl+w", "close_split"], ["ctrl+o", "open_file"], @@ -61,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"], @@ -349,6 +351,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"], @@ -517,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"], @@ -581,5 +585,30 @@ ["enter", "mini_mode_select"], ["backspace", "mini_mode_delete_backwards"] ] + }, + "terminal": { + "on_match_failure": "nothing", + "press": [ + ["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+`", "unfocus_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"], + ["alt+x", "open_command_palette"], + ["alt+!", "run_task"], + ["alt+f9", "panel_next_widget_style"], + ["ctrl+shift+q", "quit_without_saving"], + ["ctrl+alt+shift+r", "restart"] + ] } } diff --git a/src/keybind/keybind.zig b/src/keybind/keybind.zig index 632c049c..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 }; @@ -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()); @@ -786,6 +787,7 @@ const BindingSet = struct { else log_keyhints_message(), .ignore => log_keyhints_message(), + .nothing => {}, } globals.current_sequence.clearRetainingCapacity(); globals.current_sequence_egc.clearRetainingCapacity(); 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/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/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; 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/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); } 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/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 &.{}; diff --git a/src/tui/filelist_view.zig b/src/tui/filelist_view.zig index 8e2da8a7..f597885a 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/home.zig b/src/tui/home.zig index 9cdb16f0..478a8b2e 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 + \\open_terminal \\run_task \\add_task \\open_config @@ -52,6 +53,7 @@ const style = struct { \\open_recent_project \\find_in_files \\open_command_palette + \\open_terminal \\run_task \\add_task \\open_config 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 fa07775b..f6b18f66 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); @@ -58,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, @@ -124,6 +126,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); @@ -258,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); } @@ -279,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; @@ -290,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) { @@ -302,12 +316,12 @@ 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(); @@ -475,6 +489,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(); @@ -911,11 +926,29 @@ 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); + try open_terminal(self, .{}); } 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); } @@ -946,6 +979,53 @@ 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 { + const have_args = ctx.args.buf.len > 0 and try ctx.args.match(.{ tp.string, tp.more }); + + 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_with_args(terminal_view, .enable, ctx); + if (self.get_panel_view(terminal_view)) |vt| + vt.focus(); + } + } + 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)) |_| + 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) try self.toggle_panel_view(filelist_view, .disable); @@ -1522,6 +1602,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; @@ -1780,6 +1876,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/mode/overlay/task_palette.zig b/src/tui/mode/overlay/task_palette.zig index 6dbaa1e7..4f1a1604 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,18 @@ 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); + (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); } } diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig new file mode 100644 index 00000000..8b80de76 --- /dev/null +++ b/src/tui/terminal_view.zig @@ -0,0 +1,909 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("build_options"); +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 argv = @import("argv"); +const config = @import("config"); + +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"); +const input = @import("input"); +const keybind = @import("keybind"); +pub const Mode = keybind.Mode; +const RGB = @import("color").RGB; + +pub const name = @typeName(Self); + +const Self = @This(); +const widget_type: Widget.Type = .panel; + +const Terminal = vaxis.widgets.Terminal; +const TerminalOnExit = config.TerminalOnExit; + +allocator: Allocator, +plane: Plane, +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 { + 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(); + + const self = try allocator.create(Self); + errdefer allocator.destroy(self); + + self.* = .{ + .allocator = allocator, + .plane = plane, + .input_mode = try keybind.mode("terminal", allocator, .{ .insert_command = "do_nothing" }), + .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)); + + container.ctx = self; + try container.add(Widget.to(self)); + + 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()); + return true; + } else if (try m.match(.{ "H", tp.extract(&self.hover) })) { + tui.rdr().request_mouse_cursor_default(self.hover); + 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 }))) + 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 (event != input.event.press and event != input.event.repeat) return true; + const key: vaxis.Key = .{ + .codepoint = keypress, + .shifted_codepoint = if (keypress_shifted != keypress) keypress_shifted else null, + .mods = @bitCast(modifiers), + .text = if (text.len > 0) text else null, + }; + if (self.vt.process_exited) { + if (keypress == input.key.enter) { + self.re_run_cmd() 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| + std.log.err("terminal_view: input failed: {}", .{e}); + tui.need_render(@src()); + 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)); +} + +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.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; + }; + if (self.focused) tui.release_keyboard_focus(Widget.to(self)); + 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, theme: *const Widget.Theme) bool { + // Drain the vt event queue. + while (self.vt.vt.tryEvent()) |event| { + switch (event) { + .exited => |code| { + self.vt.process_exited = true; + self.handle_child_exit(code); + tui.need_render(@src()); + }, + .redraw, .bell => {}, + .pwd_change => |path| { + self.vt.cwd.clearRetainingCapacity(); + self.vt.cwd.appendSlice(self.allocator, path) catch {}; + }, + .title_change => |t| { + 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. + 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()); + } + }, + } + } + + // 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. + 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 and tui.terminal_has_focus(), software_cursor, focused_cursor_color, unfocused_cursor_color) catch |e| { + std.log.err("terminal_view: draw failed: {}", .{e}); + }; + + 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(); + 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("]") catch {}; + // Re-run prompt + const cmd_argv = self.vt.vt.cmd.argv; + if (cmd_argv.len > 0) { + w.writeAll(" Press enter to re-run '") catch {}; + _ = argv.write(w, cmd_argv) 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 {}; +} + +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; + self.vt.resize(pos); +} + +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 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, + write_buf: [4096]u8, + 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, + 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, on_exit: TerminalOnExit) !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, + .on_exit = on_exit, + }; + const self = &global_vt.?; + self.vt = try Terminal.init( + allocator, + cmd_argv, + &env, + .{ + .winsize = .{ .rows = rows, .cols = cols, .x_pixel = 0, .y_pixel = 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; + }, + }, + &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(.{"quit"}) catch {}; + pid.deinit(); + self.pty_pid = null; + } + self.vt.deinit(); + self.env.deinit(); + std.log.debug("terminal: vt destroyed", .{}); + } + + 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; + +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; + +const pty_posix = 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, + 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()); + 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 { + 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(); + 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| { + 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()); + }; + 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); + } + + fn pty_receive(self: *@This(), _: tp.pid_ref, m: tp.message) tp.result { + errdefer self.deinit(); + + if (try m.match(.{ "fd", "pty", "read_ready" })) { + self.read_and_process() catch |e| return switch (e) { + 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(.{ "fd", "pty", "read_error", tp.extract(&self.err_code), tp.more })) { + // 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 }); + 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(); + } else { + std.log.debug("terminal: pty exiting: unexpected message", .{}); + return tp.unexpected(m); + } + } + + fn read_and_process(self: *@This()) error{ Terminated, InputOutput, SendFailed, Unexpected }!void { + var buf: [4096]u8 = undefined; + + while (true) { + const n = std.posix.read(self.vt.ptyFd(), &buf) catch |e| switch (e) { + error.WouldBlock => { + // 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 }); + 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}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + 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.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; + } + + 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, + => { + 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 => {}, + } + } + + // 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 }); + return error.Unexpected; + }, + }; + } +}; + +/// Windows pty actor: reads ConPTY output pipe via tp.file_stream (IOCP overlapped I/O). +/// +/// 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, + stream: ?tp.file_stream = null, + 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()); + errdefer allocator.destroy(self); + self.* = .{ + .allocator = allocator, + .vt = vt, + .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", .{}); + if (self.wait_handle) |wh| { + _ = UnregisterWait(wh); + self.wait_handle = null; + } + if (self.stream) |s| s.deinit(); + self.parser.buf.deinit(); + self.parent.deinit(); + self.allocator.destroy(self); + } + + fn start(self: *@This()) tp.result { + errdefer self.deinit(); + 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()); + }; + + // 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(); + + var bytes: []const u8 = ""; + var err_code: i64 = 0; + var err_msg: []const u8 = ""; + + 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}); + return tp.exit_normal(); + }) { + .exited => { + std.log.debug("terminal: processOutput returned .exited", .{}); + return tp.exit_normal(); + }, + .running => {}, + } + 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", tp.extract(&err_code), tp.extract(&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 {}; + 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); + } + } + + // 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; +}; diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 3d9a1ad6..7908d4e7 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -53,8 +53,10 @@ 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, +terminal_focus: bool = true, last_hover_x: c_int = -1, last_hover_y: c_int = -1, commands: Commands = undefined, @@ -518,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; } @@ -833,22 +839,54 @@ 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 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(); + 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; + }; +} + +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(); 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; @@ -857,10 +895,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| { - _ = try w.send(from, m); - return; - } if (try self.update_hover(y, x)) |w| _ = try w.send(from, m); } @@ -868,10 +902,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| { - _ = try w.send(from, m); - return; - } _ = try self.update_hover(y, x); if (self.drag_source) |w| _ = try w.send(from, m); } @@ -924,10 +954,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() and self.terminal_focus; } 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 {}; @@ -1483,6 +1515,30 @@ const cmds = struct { .arguments = &.{.string}, }; + 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; + 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_, + }; + defer self.allocator.free(args); + var cmd: []const u8 = undefined; + if (!try cbor.match(args, .{tp.extract(&cmd)})) + cmd = task; + call_add_task(task); + 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", + .arguments = &.{.string}, + }; + pub fn delete_task(_: *Self, ctx: Ctx) Result { var task: []const u8 = undefined; if (!try ctx.args.match(.{tp.extract(&task)})) @@ -1507,6 +1563,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 = .{}; @@ -2659,3 +2717,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; +}