From c83afad92aa2789140ee728d9399ff681972dd67 Mon Sep 17 00:00:00 2001 From: Maxwell Legrand Date: Tue, 16 Dec 2025 18:56:09 -0500 Subject: [PATCH 1/6] Add capture pane functionality --- README.md | 16 +++++++++++++- src/action.zig | 2 ++ src/client.zig | 50 +++++++++++++++++++++++++++++++++++++++++++ src/lua/tiling.lua | 43 ++++++++++++++++++++++++++++++++++++- src/lua/types/pty.lua | 4 ++++ src/lua_event.zig | 18 ++++++++++++++++ src/server.zig | 27 +++++++++++++++++++++++ src/ui.zig | 3 ++- 8 files changed, 160 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8aca94c..4d463f4 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,16 @@ Available border styles: The focused pane uses `focused_color` (default: blue) to make it easy to identify the active terminal. +### Capture Pane + +Capture the full scrollback history of the active pane and open it in a new tab in your editor: + +The `capture_pane` action (default keybind: `x`) will: +1. Capture the entire scrollback history of the active pane +2. Write it to a temporary file in `/tmp` (named `pane_YYYYMMDD_HHMMSS.txt`) +3. Create a new tab and open the file in your configured `$EDITOR` (defaults to `vim`) +4. Close the tab automatically when you quit the editor + ### Default Keybinds The default leader key is `Super+k` (Cmd+k on macOS). After pressing the leader: @@ -200,9 +210,13 @@ The default leader key is `Super+k` (Cmd+k on macOS). After pressing the leader: | `c` | Close tab | | `n/p` | Next/previous tab | | `d` | Detach session | +| `x` | Capture pane | | `q` | Quit | -Press `Super+p` to open the command palette. +Additional keybinds: +- `Super+p` - Open command palette + +Press `Super+p` to open the command palette for other available commands. ### Custom Keybinds diff --git a/src/action.zig b/src/action.zig index f8b8042..0394c3b 100644 --- a/src/action.zig +++ b/src/action.zig @@ -58,6 +58,7 @@ pub const Action = union(enum) { // UI command_palette, + capture_pane, // Lua function reference (registry ref) lua_function: i32, @@ -126,6 +127,7 @@ pub const Action = union(enum) { .rename_session => "Rename Session", .quit => "Quit", .command_palette => "Command Palette", + .capture_pane => "Capture Pane", .lua_function => null, }; } diff --git a/src/client.zig b/src/client.zig index 44e20c0..bda805b 100644 --- a/src/client.zig +++ b/src/client.zig @@ -186,6 +186,7 @@ pub const ClientState = struct { detach, get_server_info, copy_selection, + capture_pane: struct { path: []const u8 }, }; pub fn init(allocator: std.mem.Allocator) ClientState { @@ -296,6 +297,7 @@ pub const ClientLogic = struct { .detach => .detached, .get_server_info => handleServerInfoResult(state, result), .copy_selection => handleCopySelectionResult(result), + .capture_pane => |capture_info| handleCapturePaneResult(result, capture_info.path), }; } return handleUnsolicitedResult(state, result); @@ -314,6 +316,25 @@ pub const ClientLogic = struct { return .none; } + fn handleCapturePaneResult(result: msgpack.Value, path: []const u8) ServerAction { + if (result == .string) { + log.info("handleCapturePaneResult: writing {} bytes to {s}", .{ result.string.len, path }); + const file = std.fs.cwd().createFile(path, .{}) catch |err| { + log.err("Failed to create file {s}: {}", .{ path, err }); + return .none; + }; + defer file.close(); + _ = file.writeAll(result.string) catch |err| { + log.err("Failed to write pane content to {s}: {}", .{ path, err }); + return .none; + }; + log.info("Successfully wrote pane content to {s}", .{path}); + return .none; + } + log.warn("handleCapturePaneResult: unexpected result type: {s}", .{@tagName(result)}); + return .none; + } + fn handleServerInfoResult(state: *ClientState, result: msgpack.Value) ServerAction { if (result != .map) return .none; @@ -2136,6 +2157,12 @@ pub const App = struct { try self.requestCopySelection(id); } }.appCopySelection, + .capture_pane_fn = struct { + fn appCapturePane(ctx: *anyopaque, id: u32, path: []const u8) anyerror!void { + const self: *App = @ptrCast(@alignCast(ctx)); + try self.requestCapturePane(id, path); + } + }.appCapturePane, .cell_size_fn = struct { fn appGetCellSize(ctx: *anyopaque) lua_event.CellSize { const self: *App = @ptrCast(@alignCast(ctx)); @@ -2424,6 +2451,23 @@ pub const App = struct { try self.sendDirect(msg); } + pub fn requestCapturePane(self: *App, pty_id: u32, path: []const u8) !void { + const msgid = self.state.next_msgid; + self.state.next_msgid += 1; + + const path_copy = try self.allocator.dupe(u8, path); + try self.state.pending_requests.put(msgid, .{ .capture_pane = .{ .path = path_copy } }); + + var params = try self.allocator.alloc(msgpack.Value, 1); + defer self.allocator.free(params); + params[0] = .{ .unsigned = pty_id }; + + const msg = try msgpack.encode(self.allocator, .{ 0, msgid, "capture_pane", msgpack.Value{ .array = params } }); + defer self.allocator.free(msg); + + try self.sendDirect(msg); + } + fn copyToClipboard(self: *App, text: []const u8) void { if (text.len == 0) { log.warn("copyToClipboard: empty text, nothing to copy", .{}); @@ -2969,6 +3013,12 @@ pub const App = struct { try app.requestCopySelection(pty_id); } }.copySelection, + .capture_pane_fn = struct { + fn capturePane(app_ctx: *anyopaque, pty_id: u32, path: []const u8) anyerror!void { + const app: *App = @ptrCast(@alignCast(app_ctx)); + try app.requestCapturePane(pty_id, path); + } + }.capturePane, .cell_size_fn = struct { fn getCellSize(app_ctx: *anyopaque) lua_event.CellSize { const app: *App = @ptrCast(@alignCast(app_ctx)); diff --git a/src/lua/tiling.lua b/src/lua/tiling.lua index 34817d0..0204c23 100644 --- a/src/lua/tiling.lua +++ b/src/lua/tiling.lua @@ -231,6 +231,7 @@ local config = { leader = "", keybinds = { [""] = "command_palette", + ["x"] = "capture_pane", ["v"] = "split_horizontal", ["s"] = "split_vertical", [""] = "split_auto", @@ -283,6 +284,7 @@ local state = { clock_timer = nil, pending_split = nil, pending_new_tab = false, + pending_capture_cmd = nil, next_split_id = 1, -- Command palette palette = { @@ -1563,6 +1565,15 @@ local commands = { return #state.tabs >= 10 end, }, + { + name = "Capture Pane", + action = function() + local handler = action_handlers["capture_pane"] + if handler then + handler() + end + end, + }, } -- Action handlers for keybind system @@ -1686,6 +1697,24 @@ action_handlers = { rename_session = function() open_rename() end, + capture_pane = function() + local pty = get_focused_pty() + if pty then + local timestamp = os.date("%Y%m%d_%H%M%S") + local filename = "/tmp/pane_" .. timestamp .. ".txt" + pty:capture_pane(filename) + prise.log.info("Pane captured to " .. filename) + + -- Defer opening editor to allow server to send capture content + prise.set_timeout(500, function() + -- Create new tab with shell that opens editor + local editor = os.getenv("EDITOR") or "vim" + state.pending_new_tab = true + state.pending_capture_cmd = editor .. ' "' .. filename .. '"' + prise.spawn({ cwd = pty:cwd() }) + end) + end + end, quit = function() detach_session() end, @@ -1834,6 +1863,14 @@ function M.update(event) } table.insert(state.tabs, new_tab) set_active_tab_index(#state.tabs) + + -- If there's a pending capture command, send it to the new PTY + if state.pending_capture_cmd then + local cmd = state.pending_capture_cmd + state.pending_capture_cmd = nil + -- Close tab when editor exits + pty:send_paste(cmd .. " && exit\n") + end elseif #state.tabs == 0 then -- First terminal - create first tab local tab_id = state.next_tab_id @@ -2040,7 +2077,7 @@ function M.update(event) if state.timer then state.timer:cancel() end - state.timer = prise.set_timeout(1000, function() + state.timer = prise.set_timeout(3000, function() if state.pending_command then state.pending_command = false state.timer = nil @@ -2089,6 +2126,10 @@ function M.update(event) end end elseif event.type == "key_release" then + -- Don't forward key releases to PTY if we're waiting for a keybind + if state.pending_command then + return + end -- Forward all key releases to focused PTY local root = get_active_root() if root and state.focused_id then diff --git a/src/lua/types/pty.lua b/src/lua/types/pty.lua index 945b543..f197b5c 100644 --- a/src/lua/types/pty.lua +++ b/src/lua/types/pty.lua @@ -68,3 +68,7 @@ function Pty:close() end ---Copy the current selection to clipboard function Pty:copy_selection() end + +---Capture the current pane content and write it to a file +---@param path string The file path to write pane content to +function Pty:capture_pane(path) end diff --git a/src/lua_event.zig b/src/lua_event.zig index 942abb5..d8bfb89 100644 --- a/src/lua_event.zig +++ b/src/lua_event.zig @@ -28,6 +28,7 @@ pub const PtyAttachInfo = struct { close_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cwd_fn: *const fn (app: *anyopaque, id: u32) ?[]const u8, copy_selection_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, + capture_pane_fn: *const fn (app: *anyopaque, id: u32, path: []const u8) anyerror!void, cell_size_fn: *const fn (app: *anyopaque) CellSize, }; @@ -137,6 +138,7 @@ fn pushPtyAttachEvent(lua: *ziglua.Lua, info: PtyAttachInfo) void { .close_fn = info.close_fn, .cwd_fn = info.cwd_fn, .copy_selection_fn = info.copy_selection_fn, + .capture_pane_fn = info.capture_pane_fn, .cell_size_fn = info.cell_size_fn, }; @@ -390,6 +392,7 @@ const PtyHandle = struct { close_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cwd_fn: *const fn (app: *anyopaque, id: u32) ?[]const u8, copy_selection_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, + capture_pane_fn: *const fn (app: *anyopaque, id: u32, path: []const u8) anyerror!void, cell_size_fn: *const fn (app: *anyopaque) CellSize, }; @@ -435,6 +438,10 @@ fn ptyIndex(lua: *ziglua.Lua) i32 { lua.pushFunction(ziglua.wrap(ptyCopySelection)); return 1; } + if (std.mem.eql(u8, key, "capture_pane")) { + lua.pushFunction(ziglua.wrap(ptyCapturePaneRequest)); + return 1; + } return 0; } @@ -484,6 +491,15 @@ fn ptyCopySelection(lua: *ziglua.Lua) i32 { return 0; } +fn ptyCapturePaneRequest(lua: *ziglua.Lua) i32 { + const pty = lua.checkUserdata(PtyHandle, 1, "PrisePty"); + const path = lua.checkString(2); + pty.capture_pane_fn(pty.app, pty.id, path) catch |err| { + log.err("Failed to capture pane: {}", .{err}); + }; + return 0; +} + fn ptySendKey(lua: *ziglua.Lua) i32 { const pty = lua.checkUserdata(PtyHandle, 1, "PrisePty"); lua.checkType(2, .table); @@ -736,6 +752,7 @@ pub fn pushPtyUserdata( close_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cwd_fn: *const fn (app: *anyopaque, id: u32) ?[]const u8, copy_selection_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, + capture_pane_fn: *const fn (app: *anyopaque, id: u32, path: []const u8) anyerror!void, cell_size_fn: *const fn (app: *anyopaque) CellSize, ) !void { const pty = lua.newUserdata(PtyHandle, @sizeOf(PtyHandle)); @@ -750,6 +767,7 @@ pub fn pushPtyUserdata( .close_fn = close_fn, .cwd_fn = cwd_fn, .copy_selection_fn = copy_selection_fn, + .capture_pane_fn = capture_pane_fn, .cell_size_fn = cell_size_fn, }; diff --git a/src/server.zig b/src/server.zig index 48f953a..c2bb1e7 100644 --- a/src/server.zig +++ b/src/server.zig @@ -2494,6 +2494,31 @@ const Server = struct { return msgpack.Value.nil; } + fn handleCapturePane(self: *Server, params: msgpack.Value) !msgpack.Value { + const pty_id = parsePtyId(params) catch { + return msgpack.Value{ .string = try self.allocator.dupe(u8, "invalid params") }; + }; + + const pty_instance = self.ptys.get(pty_id) orelse { + return msgpack.Value{ .string = try self.allocator.dupe(u8, "PTY not found") }; + }; + + pty_instance.terminal_mutex.lock(); + defer pty_instance.terminal_mutex.unlock(); + + const screen = pty_instance.terminal.screens.active; + + // Capture entire scrollback + active area in screen space (includes full history, not just viewport) + const result = screen.dumpStringAlloc(self.allocator, .{ + .screen = .{}, + }) catch |err| { + std.log.err("Failed to capture pane: {}", .{err}); + return msgpack.Value.nil; + }; + + return msgpack.Value{ .string = result }; + } + fn handleGetServerInfo(self: *Server) !msgpack.Value { const entries = try self.allocator.alloc(msgpack.Value.KeyValue, 2); entries[0] = .{ @@ -2576,6 +2601,8 @@ const Server = struct { return self.handleGetSelection(params); } else if (std.mem.eql(u8, method, "clear_selection")) { return self.handleClearSelection(params); + } else if (std.mem.eql(u8, method, "capture_pane")) { + return self.handleCapturePane(params); } else { return msgpack.Value{ .string = try self.allocator.dupe(u8, "unknown method") }; } diff --git a/src/ui.zig b/src/ui.zig index e988948..82a03d9 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -1070,6 +1070,7 @@ pub const UI = struct { close_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cwd_fn: *const fn (app: *anyopaque, id: u32) ?[]const u8, copy_selection_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, + capture_pane_fn: *const fn (app: *anyopaque, id: u32, path: []const u8) anyerror!void, cell_size_fn: *const fn (app: *anyopaque) lua_event.CellSize, }; @@ -1119,7 +1120,7 @@ pub const UI = struct { const result = lookup_ctx.lookup_fn(lookup_ctx.ctx, id); if (result) |r| { - lua_event.pushPtyUserdata(lua, id, r.surface, r.app, r.send_key_fn, r.send_mouse_fn, r.send_paste_fn, r.set_focus_fn, r.close_fn, r.cwd_fn, r.copy_selection_fn, r.cell_size_fn) catch { + lua_event.pushPtyUserdata(lua, id, r.surface, r.app, r.send_key_fn, r.send_mouse_fn, r.send_paste_fn, r.set_focus_fn, r.close_fn, r.cwd_fn, r.copy_selection_fn, r.capture_pane_fn, r.cell_size_fn) catch { lua.pushNil(); }; } else { From c84dba67897b9782d4131574ba6d4f43d3f87d02 Mon Sep 17 00:00:00 2001 From: Maxwell Legrand Date: Wed, 17 Dec 2025 10:27:44 -0500 Subject: [PATCH 2/6] Return to previous tab after closing capture pane tab --- src/lua/tiling.lua | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/lua/tiling.lua b/src/lua/tiling.lua index 0204c23..382dcdc 100644 --- a/src/lua/tiling.lua +++ b/src/lua/tiling.lua @@ -21,6 +21,7 @@ local utils = require("utils") ---@field title? string ---@field root? Node ---@field last_focused_id? number +---@field return_to_tab? integer Tab index to return to when this tab is closed ---@class PaletteRegion ---@field start_y number @@ -285,6 +286,7 @@ local state = { pending_split = nil, pending_new_tab = false, pending_capture_cmd = nil, + pending_capture_return_tab = nil, -- Tab index to return to when capture tab closes next_split_id = 1, -- Command palette palette = { @@ -812,13 +814,28 @@ local function close_tab(idx) end local old_focused = state.focused_id + local return_to = tab.return_to_tab table.remove(state.tabs, idx) -- Pick new active tab index - if idx > #state.tabs then - idx = #state.tabs + if return_to then + -- Tab has a preferred return destination (e.g., capture pane editor) + -- Adjust for removed tab if it was before the return target + if idx < return_to then + return_to = return_to - 1 + end + -- Clamp to valid range + if return_to > #state.tabs then + return_to = #state.tabs + elseif return_to < 1 then + return_to = 1 + end + state.active_tab = return_to + elseif idx > #state.tabs then + state.active_tab = #state.tabs + else + state.active_tab = idx > 0 and idx or 1 end - state.active_tab = idx > 0 and idx or 1 local new_tab = state.tabs[state.active_tab] if new_tab then @@ -1711,6 +1728,7 @@ action_handlers = { local editor = os.getenv("EDITOR") or "vim" state.pending_new_tab = true state.pending_capture_cmd = editor .. ' "' .. filename .. '"' + state.pending_capture_return_tab = state.active_tab prise.spawn({ cwd = pty:cwd() }) end) end @@ -1868,6 +1886,11 @@ function M.update(event) if state.pending_capture_cmd then local cmd = state.pending_capture_cmd state.pending_capture_cmd = nil + -- Store return tab index so we go back when this tab closes + if state.pending_capture_return_tab then + new_tab.return_to_tab = state.pending_capture_return_tab + state.pending_capture_return_tab = nil + end -- Close tab when editor exits pty:send_paste(cmd .. " && exit\n") end From bead9a9f7cf1ae7ed256c57882716fc12b69b341 Mon Sep 17 00:00:00 2001 From: Maxwell Legrand Date: Wed, 17 Dec 2025 23:04:41 -0500 Subject: [PATCH 3/6] Actually return to previous tab --- src/lua/tiling.lua | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/lua/tiling.lua b/src/lua/tiling.lua index 382dcdc..bb06bff 100644 --- a/src/lua/tiling.lua +++ b/src/lua/tiling.lua @@ -894,13 +894,30 @@ local function remove_pane_by_id(id) else local old_focused = state.focused_id table.remove(state.tabs, tab_idx) - - -- Adjust active_tab if needed - if state.active_tab > #state.tabs then - state.active_tab = #state.tabs - end - if state.active_tab < 1 then - state.active_tab = 1 + local return_to = tab.return_to_tab + + -- Pick new active tab index + if return_to then + -- Tab has a preferred return destination (e.g., capture pane editor) + -- Adjust for removed tab if it was before the return target + if tab_idx < return_to then + return_to = return_to - 1 + end + -- Clamp to valid range + if return_to > #state.tabs then + return_to = #state.tabs + elseif return_to < 1 then + return_to = 1 + end + state.active_tab = return_to + else + -- Adjust active_tab if needed + if state.active_tab > #state.tabs then + state.active_tab = #state.tabs + end + if state.active_tab < 1 then + state.active_tab = 1 + end end -- Update focus to new active tab From 967ff1c938716da2ab18f9a4fa606a01d80709fc Mon Sep 17 00:00:00 2001 From: Maxwell Legrand Date: Mon, 5 Jan 2026 16:04:31 -0500 Subject: [PATCH 4/6] Update capture pane to be event based --- src/client.zig | 65 ++++++++++++++++---------- src/lua/tiling.lua | 100 ++++++++++++++++++++++++++++------------ src/lua/types/prise.lua | 1 + src/lua/types/pty.lua | 5 +- src/lua_event.zig | 28 +++++++++-- src/server.zig | 34 +++++++++++++- src/ui.zig | 43 ++++++++++++++++- 7 files changed, 210 insertions(+), 66 deletions(-) diff --git a/src/client.zig b/src/client.zig index bda805b..7d1ad82 100644 --- a/src/client.zig +++ b/src/client.zig @@ -186,7 +186,7 @@ pub const ClientState = struct { detach, get_server_info, copy_selection, - capture_pane: struct { path: []const u8 }, + capture_pane: struct { pty_id: u32 }, }; pub fn init(allocator: std.mem.Allocator) ClientState { @@ -219,6 +219,7 @@ pub const ServerAction = union(enum) { color_query: ColorQueryTarget, server_info: struct { pty_validity: i64 }, copy_to_clipboard: []const u8, + capture_pane_complete: struct { pty_id: u32, content: []const u8 }, pub const ColorQueryTarget = struct { pty_id: u32, @@ -297,7 +298,7 @@ pub const ClientLogic = struct { .detach => .detached, .get_server_info => handleServerInfoResult(state, result), .copy_selection => handleCopySelectionResult(result), - .capture_pane => |capture_info| handleCapturePaneResult(result, capture_info.path), + .capture_pane => |capture_info| handleCapturePaneResult(result, capture_info.pty_id), }; } return handleUnsolicitedResult(state, result); @@ -316,20 +317,10 @@ pub const ClientLogic = struct { return .none; } - fn handleCapturePaneResult(result: msgpack.Value, path: []const u8) ServerAction { + fn handleCapturePaneResult(result: msgpack.Value, pty_id: u32) ServerAction { if (result == .string) { - log.info("handleCapturePaneResult: writing {} bytes to {s}", .{ result.string.len, path }); - const file = std.fs.cwd().createFile(path, .{}) catch |err| { - log.err("Failed to create file {s}: {}", .{ path, err }); - return .none; - }; - defer file.close(); - _ = file.writeAll(result.string) catch |err| { - log.err("Failed to write pane content to {s}: {}", .{ path, err }); - return .none; - }; - log.info("Successfully wrote pane content to {s}", .{path}); - return .none; + log.info("handleCapturePaneResult: received {} bytes for pty {}", .{ result.string.len, pty_id }); + return .{ .capture_pane_complete = .{ .pty_id = pty_id, .content = result.string } }; } log.warn("handleCapturePaneResult: unexpected result type: {s}", .{@tagName(result)}); return .none; @@ -2158,9 +2149,9 @@ pub const App = struct { } }.appCopySelection, .capture_pane_fn = struct { - fn appCapturePane(ctx: *anyopaque, id: u32, path: []const u8) anyerror!void { + fn appCapturePane(ctx: *anyopaque, id: u32) anyerror!void { const self: *App = @ptrCast(@alignCast(ctx)); - try self.requestCapturePane(id, path); + try self.requestCapturePane(id); } }.appCapturePane, .cell_size_fn = struct { @@ -2335,6 +2326,11 @@ pub const App = struct { .copy_to_clipboard => |text| { app.copyToClipboard(text); }, + .capture_pane_complete => |info| { + app.ui.update(.{ .capture_pane_complete = .{ .pty_id = info.pty_id, .content = info.content } }) catch |err| { + log.err("Failed to update UI with capture_pane_complete: {}", .{err}); + }; + }, .none => {}, } @@ -2411,7 +2407,23 @@ pub const App = struct { try env_array.append(self.allocator, .{ .string = env_str }); } - const num_params: usize = if (opts.cwd != null) 5 else 4; + // Build command array if specified + var cmd_array = std.ArrayList(msgpack.Value).empty; + defer cmd_array.deinit(self.allocator); + if (opts.command) |command| { + log.info("spawnPty: building command with {} args", .{command.len}); + for (command) |arg| { + log.info("spawnPty: arg = '{s}'", .{arg}); + try cmd_array.append(self.allocator, .{ .string = arg }); + } + } else { + log.info("spawnPty: no command in opts", .{}); + } + + var num_params: usize = 4; // rows, cols, attach, env + if (opts.cwd != null) num_params += 1; + if (opts.command != null) num_params += 1; + var map_items = try self.allocator.alloc(msgpack.Value.KeyValue, num_params); defer self.allocator.free(map_items); @@ -2419,8 +2431,14 @@ pub const App = struct { map_items[1] = .{ .key = .{ .string = "cols" }, .value = .{ .unsigned = opts.cols } }; map_items[2] = .{ .key = .{ .string = "attach" }, .value = .{ .boolean = opts.attach } }; map_items[3] = .{ .key = .{ .string = "env" }, .value = .{ .array = env_array.items } }; + + var idx: usize = 4; if (opts.cwd) |cwd| { - map_items[4] = .{ .key = .{ .string = "cwd" }, .value = .{ .string = cwd } }; + map_items[idx] = .{ .key = .{ .string = "cwd" }, .value = .{ .string = cwd } }; + idx += 1; + } + if (opts.command != null) { + map_items[idx] = .{ .key = .{ .string = "command" }, .value = .{ .array = cmd_array.items } }; } const params = msgpack.Value{ .map = map_items }; @@ -2451,12 +2469,11 @@ pub const App = struct { try self.sendDirect(msg); } - pub fn requestCapturePane(self: *App, pty_id: u32, path: []const u8) !void { + pub fn requestCapturePane(self: *App, pty_id: u32) !void { const msgid = self.state.next_msgid; self.state.next_msgid += 1; - const path_copy = try self.allocator.dupe(u8, path); - try self.state.pending_requests.put(msgid, .{ .capture_pane = .{ .path = path_copy } }); + try self.state.pending_requests.put(msgid, .{ .capture_pane = .{ .pty_id = pty_id } }); var params = try self.allocator.alloc(msgpack.Value, 1); defer self.allocator.free(params); @@ -3014,9 +3031,9 @@ pub const App = struct { } }.copySelection, .capture_pane_fn = struct { - fn capturePane(app_ctx: *anyopaque, pty_id: u32, path: []const u8) anyerror!void { + fn capturePane(app_ctx: *anyopaque, pty_id: u32) anyerror!void { const app: *App = @ptrCast(@alignCast(app_ctx)); - try app.requestCapturePane(pty_id, path); + try app.requestCapturePane(pty_id); } }.capturePane, .cell_size_fn = struct { diff --git a/src/lua/tiling.lua b/src/lua/tiling.lua index bb06bff..cea8309 100644 --- a/src/lua/tiling.lua +++ b/src/lua/tiling.lua @@ -118,7 +118,11 @@ local utils = require("utils") ---@field type "cwd_changed" ---@field data table ----@alias Event PtyAttachEvent|PtyExitedEvent|KeyPressEvent|KeyReleaseEvent|PasteEvent|MouseEvent|WinsizeEvent|FocusInEvent|FocusOutEvent|SplitResizeEvent|CwdChangedEvent +---@class CapturePaneCompleteEvent +---@field type "capture_pane_complete" +---@field data { pty_id: number, content: string } + +---@alias Event PtyAttachEvent|PtyExitedEvent|KeyPressEvent|KeyReleaseEvent|PasteEvent|MouseEvent|WinsizeEvent|FocusInEvent|FocusOutEvent|SplitResizeEvent|CwdChangedEvent|CapturePaneCompleteEvent -- Powerline symbols local POWERLINE_SYMBOLS = { @@ -272,6 +276,11 @@ local merge_config = utils.merge_config -- Convenience alias for theme access local THEME = config.theme +---@class PendingSpawnOpts +---@field new_tab? boolean Create in new tab instead of split +---@field return_to_tab? integer Tab index to return to when pane closes +---@field command? string[] Command to execute in the new pane + ---@type State local state = { tabs = {}, @@ -285,8 +294,8 @@ local state = { clock_timer = nil, pending_split = nil, pending_new_tab = false, - pending_capture_cmd = nil, - pending_capture_return_tab = nil, -- Tab index to return to when capture tab closes + ---@type PendingSpawnOpts? + pending_spawn_opts = nil, next_split_id = 1, -- Command palette palette = { @@ -1732,22 +1741,13 @@ action_handlers = { open_rename() end, capture_pane = function() + prise.log.info("capture_pane action triggered") local pty = get_focused_pty() if pty then - local timestamp = os.date("%Y%m%d_%H%M%S") - local filename = "/tmp/pane_" .. timestamp .. ".txt" - pty:capture_pane(filename) - prise.log.info("Pane captured to " .. filename) - - -- Defer opening editor to allow server to send capture content - prise.set_timeout(500, function() - -- Create new tab with shell that opens editor - local editor = os.getenv("EDITOR") or "vim" - state.pending_new_tab = true - state.pending_capture_cmd = editor .. ' "' .. filename .. '"' - state.pending_capture_return_tab = state.active_tab - prise.spawn({ cwd = pty:cwd() }) - end) + prise.log.info("Calling pty:capture_pane() on pty " .. pty:id()) + pty:capture_pane() + else + prise.log.warn("capture_pane: no focused PTY") end end, quit = function() @@ -1885,9 +1885,14 @@ function M.update(event) local new_pane = { type = "pane", pty = pty, id = pty:id() } local old_focused_id = state.focused_id - if state.pending_new_tab then + -- Check pending_spawn_opts for new_tab flag + local spawn_opts = state.pending_spawn_opts + state.pending_spawn_opts = nil + local create_new_tab = state.pending_new_tab or (spawn_opts and spawn_opts.new_tab) + state.pending_new_tab = false + + if create_new_tab then -- Create a new tab with this pane - state.pending_new_tab = false local tab_id = state.next_tab_id state.next_tab_id = tab_id + 1 ---@type Tab @@ -1895,21 +1900,20 @@ function M.update(event) id = tab_id, root = new_pane, last_focused_id = new_pane.id, + return_to_tab = spawn_opts and spawn_opts.return_to_tab, } table.insert(state.tabs, new_tab) set_active_tab_index(#state.tabs) - -- If there's a pending capture command, send it to the new PTY - if state.pending_capture_cmd then - local cmd = state.pending_capture_cmd - state.pending_capture_cmd = nil - -- Store return tab index so we go back when this tab closes - if state.pending_capture_return_tab then - new_tab.return_to_tab = state.pending_capture_return_tab - state.pending_capture_return_tab = nil - end - -- Close tab when editor exits - pty:send_paste(cmd .. " && exit\n") + -- If there's a pending command, send it to the new PTY + if spawn_opts and spawn_opts.command then + prise.log.info("Scheduling command for new PTY: " .. table.concat(spawn_opts.command, " ")) + local cmd_str = table.concat(spawn_opts.command, " ") + -- Delay sending the command to allow the shell to fully initialize + prise.set_timeout(100, function() + prise.log.info("Sending delayed command to PTY: " .. cmd_str) + pty:send_paste(cmd_str .. " && exit\n") + end) end elseif #state.tabs == 0 then -- First terminal - create first tab @@ -2380,6 +2384,16 @@ function M.update(event) update_cached_git_branch() prise.request_frame() prise.save() -- Auto-save on cwd change + elseif event.type == "capture_pane_complete" then + -- Pane content captured - emit to user's event handlers + -- User can handle this in their config to pipe to editor, fzf, clipboard, etc. + local pty_id = event.data.pty_id + local content = event.data.content + prise.log.info("Pane content captured for pty " .. pty_id .. " (" .. #content .. " bytes)") + -- Call user's on_capture_pane_complete handler if defined + if M.on_capture_pane_complete then + M.on_capture_pane_complete(pty_id, content) + end end end @@ -3119,6 +3133,32 @@ function M.set_state(saved, pty_lookup) prise.request_frame() end +---Spawn a new pane with the given command +---@param opts { command: string[], cwd?: string, new_tab?: boolean, return_to_tab?: boolean } +function M.spawn(opts) + prise.log.info("M.spawn called with opts: new_tab=" .. tostring(opts.new_tab) .. ", return_to_tab=" .. tostring(opts.return_to_tab) .. ", command=" .. (opts.command and table.concat(opts.command, " ") or "nil")) + opts = opts or {} + local pty = get_focused_pty() + local cwd = opts.cwd or (pty and pty:cwd()) + + -- Set pending spawn options + state.pending_spawn_opts = { + new_tab = opts.new_tab, + return_to_tab = opts.return_to_tab and state.active_tab or nil, + command = opts.command, + } + prise.log.info("M.spawn: set pending_spawn_opts with new_tab=" .. tostring(state.pending_spawn_opts.new_tab)) + + -- If not new_tab, set pending_split direction + if not opts.new_tab then + state.pending_split = { direction = get_auto_split_direction() } + end + + prise.log.info("M.spawn: calling prise.spawn with cwd=" .. (cwd or "nil") .. ", command=" .. (opts.command and table.concat(opts.command, " ") or "nil")) + prise.spawn({ cwd = cwd, command = opts.command }) + prise.log.info("M.spawn: prise.spawn completed") +end + -- Export internal functions for testing M._test = { is_pane = is_pane, diff --git a/src/lua/types/prise.lua b/src/lua/types/prise.lua index 6700741..8a6b20b 100644 --- a/src/lua/types/prise.lua +++ b/src/lua/types/prise.lua @@ -11,6 +11,7 @@ ---Spawn options for creating new PTYs ---@class SpawnOptions ---@field cwd? string Working directory for the new process +---@field command? string[] Command and arguments to run (defaults to shell) ---The prise module provides core functionality for the terminal multiplexer ---@class prise diff --git a/src/lua/types/pty.lua b/src/lua/types/pty.lua index f197b5c..bc19d60 100644 --- a/src/lua/types/pty.lua +++ b/src/lua/types/pty.lua @@ -69,6 +69,5 @@ function Pty:close() end ---Copy the current selection to clipboard function Pty:copy_selection() end ----Capture the current pane content and write it to a file ----@param path string The file path to write pane content to -function Pty:capture_pane(path) end +---Capture the current pane content (triggers capture_pane_complete event) +function Pty:capture_pane() end diff --git a/src/lua_event.zig b/src/lua_event.zig index d8bfb89..a6c8d96 100644 --- a/src/lua_event.zig +++ b/src/lua_event.zig @@ -28,7 +28,7 @@ pub const PtyAttachInfo = struct { close_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cwd_fn: *const fn (app: *anyopaque, id: u32) ?[]const u8, copy_selection_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, - capture_pane_fn: *const fn (app: *anyopaque, id: u32, path: []const u8) anyerror!void, + capture_pane_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cell_size_fn: *const fn (app: *anyopaque) CellSize, }; @@ -42,6 +42,11 @@ pub const CwdChangedInfo = struct { cwd: []const u8, }; +pub const CapturePaneCompleteInfo = struct { + pty_id: u32, + content: []const u8, +}; + pub const Event = union(enum) { vaxis: vaxis.Event, mouse: MouseEvent, @@ -50,6 +55,7 @@ pub const Event = union(enum) { pty_attach: PtyAttachInfo, pty_exited: PtyExitedInfo, cwd_changed: CwdChangedInfo, + capture_pane_complete: CapturePaneCompleteInfo, init: void, }; @@ -107,6 +113,7 @@ pub fn pushEvent(lua: *ziglua.Lua, event: Event) !void { .pty_attach => |info| pushPtyAttachEvent(lua, info), .pty_exited => |info| pushPtyExitedEvent(lua, info), .cwd_changed => |info| pushCwdChangedEvent(lua, info), + .capture_pane_complete => |info| pushCapturePaneCompleteEvent(lua, info), .paste => |data| pushPasteEvent(lua, data), .split_resize => |sr| pushSplitResizeEvent(lua, sr), .mouse => |m| pushMouseEvent(lua, m), @@ -175,6 +182,18 @@ fn pushCwdChangedEvent(lua: *ziglua.Lua, info: CwdChangedInfo) void { lua.setField(-2, "data"); } +fn pushCapturePaneCompleteEvent(lua: *ziglua.Lua, info: CapturePaneCompleteInfo) void { + _ = lua.pushString("capture_pane_complete"); + lua.setField(-2, "type"); + + lua.createTable(0, 2); + lua.pushInteger(@intCast(info.pty_id)); + lua.setField(-2, "pty_id"); + _ = lua.pushString(info.content); + lua.setField(-2, "content"); + lua.setField(-2, "data"); +} + fn pushPasteEvent(lua: *ziglua.Lua, data: []const u8) void { _ = lua.pushString("paste"); lua.setField(-2, "type"); @@ -392,7 +411,7 @@ const PtyHandle = struct { close_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cwd_fn: *const fn (app: *anyopaque, id: u32) ?[]const u8, copy_selection_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, - capture_pane_fn: *const fn (app: *anyopaque, id: u32, path: []const u8) anyerror!void, + capture_pane_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cell_size_fn: *const fn (app: *anyopaque) CellSize, }; @@ -493,8 +512,7 @@ fn ptyCopySelection(lua: *ziglua.Lua) i32 { fn ptyCapturePaneRequest(lua: *ziglua.Lua) i32 { const pty = lua.checkUserdata(PtyHandle, 1, "PrisePty"); - const path = lua.checkString(2); - pty.capture_pane_fn(pty.app, pty.id, path) catch |err| { + pty.capture_pane_fn(pty.app, pty.id) catch |err| { log.err("Failed to capture pane: {}", .{err}); }; return 0; @@ -752,7 +770,7 @@ pub fn pushPtyUserdata( close_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cwd_fn: *const fn (app: *anyopaque, id: u32) ?[]const u8, copy_selection_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, - capture_pane_fn: *const fn (app: *anyopaque, id: u32, path: []const u8) anyerror!void, + capture_pane_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cell_size_fn: *const fn (app: *anyopaque) CellSize, ) !void { const pty = lua.newUserdata(PtyHandle, @sizeOf(PtyHandle)); diff --git a/src/server.zig b/src/server.zig index c2bb1e7..4b61b1f 100644 --- a/src/server.zig +++ b/src/server.zig @@ -2032,12 +2032,13 @@ const Server = struct { /// Timestamp (ms since epoch) when server started - used to detect server restarts start_time_ms: i64 = 0, - fn parseSpawnPtyParams(params: msgpack.Value) struct { size: pty.Winsize, attach: bool, cwd: ?[]const u8, env: ?[]const msgpack.Value, macos_option_as_alt: key_encode.OptionAsAlt } { + fn parseSpawnPtyParams(params: msgpack.Value) struct { size: pty.Winsize, attach: bool, cwd: ?[]const u8, env: ?[]const msgpack.Value, command: ?[]const msgpack.Value, macos_option_as_alt: key_encode.OptionAsAlt } { var rows: u16 = 24; var cols: u16 = 80; var attach: bool = false; var cwd: ?[]const u8 = null; var env: ?[]const msgpack.Value = null; + var command: ?[]const msgpack.Value = null; var macos_option_as_alt: key_encode.OptionAsAlt = .false; if (params == .map) { @@ -2053,6 +2054,8 @@ const Server = struct { cwd = kv.value.string; } else if (std.mem.eql(u8, kv.key.string, "env") and kv.value == .array) { env = kv.value.array; + } else if (std.mem.eql(u8, kv.key.string, "command") and kv.value == .array) { + command = kv.value.array; } else if (std.mem.eql(u8, kv.key.string, "macos_option_as_alt")) { macos_option_as_alt = parseMacosOptionAsAlt(kv.value); } @@ -2069,6 +2072,7 @@ const Server = struct { .attach = attach, .cwd = cwd, .env = env, + .command = command, .macos_option_as_alt = macos_option_as_alt, }; } @@ -2208,7 +2212,33 @@ const Server = struct { } } - const process = try pty.Process.spawn(self.allocator, parsed.size, &.{shell}, @ptrCast(env_list.items), cwd); + // Build command array: use explicit command if provided, otherwise use shell + var cmd_list = std.ArrayList([]const u8).empty; + defer cmd_list.deinit(self.allocator); + + if (parsed.command) |command_arr| { + log.info("spawn_pty: received command array with {} elements", .{command_arr.len}); + for (command_arr) |val| { + if (val == .string) { + log.info("spawn_pty: command arg = '{s}'", .{val.string}); + try cmd_list.append(self.allocator, val.string); + } else { + log.warn("spawn_pty: non-string in command array", .{}); + } + } + } else { + log.info("spawn_pty: no command array in parsed params", .{}); + } + + // Fall back to shell if no command specified or command was empty + if (cmd_list.items.len == 0) { + log.info("spawn_pty: using shell fallback: {s}", .{shell}); + try cmd_list.append(self.allocator, shell); + } else { + log.info("spawn_pty: spawning with {} command args", .{cmd_list.items.len}); + } + + const process = try pty.Process.spawn(self.allocator, parsed.size, cmd_list.items, @ptrCast(env_list.items), cwd); const pty_id = self.next_pty_id; self.next_pty_id += 1; diff --git a/src/ui.zig b/src/ui.zig index 82a03d9..7b083b1 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -95,6 +95,7 @@ pub const UI = struct { cols: u16, attach: bool, cwd: ?[]const u8 = null, + command: ?[]const []const u8 = null, }; pub const InitError = struct { @@ -468,11 +469,49 @@ pub const UI = struct { _ = lua.getField(1, "cwd"); if (lua.isString(-1)) opts.cwd = lua.toString(-1) catch null; - lua.pop(1); + // Don't pop cwd yet - need to keep it valid for callback + + // Parse command array if present + // Keep strings on stack until after callback to keep pointers valid + var command_buf: [64][]const u8 = undefined; + var command_len: usize = 0; + var stack_items_to_pop: i32 = 1; // For cwd + + _ = lua.getField(1, "command"); + stack_items_to_pop += 1; // For command table + if (lua.typeOf(-1) == .table) { + const len = lua.rawLen(-1); + // Remember command table position (absolute index from bottom) + const cmd_table_idx = lua.getTop(); + var i: usize = 0; + while (i < len and i < command_buf.len) : (i += 1) { + // Use absolute index to command table since stack grows + _ = lua.rawGetIndex(cmd_table_idx, @intCast(i + 1)); + if (lua.isString(-1)) { + command_buf[i] = lua.toString(-1) catch ""; + command_len += 1; + stack_items_to_pop += 1; // Keep string on stack + } else { + lua.pop(1); // Pop non-string values immediately + } + } + } + + if (command_len > 0) { + opts.command = command_buf[0..command_len]; + log.info("spawn: command has {} args", .{command_len}); + for (command_buf[0..command_len]) |arg| { + log.info("spawn: arg = '{s}'", .{arg}); + } + } else { + log.info("spawn: no command provided", .{}); + } cb(ui.spawn_ctx, opts) catch |err| { + lua.pop(stack_items_to_pop); // Clean up stack before raising error lua.raiseErrorStr("Failed to spawn: %s", .{@errorName(err).ptr}); }; + lua.pop(stack_items_to_pop); // Clean up after successful callback } else { lua.raiseErrorStr("Spawn callback not configured", .{}); } @@ -1070,7 +1109,7 @@ pub const UI = struct { close_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cwd_fn: *const fn (app: *anyopaque, id: u32) ?[]const u8, copy_selection_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, - capture_pane_fn: *const fn (app: *anyopaque, id: u32, path: []const u8) anyerror!void, + capture_pane_fn: *const fn (app: *anyopaque, id: u32) anyerror!void, cell_size_fn: *const fn (app: *anyopaque) lua_event.CellSize, }; From 37a71d8183eef7e495a44f83faff58334e5121aa Mon Sep 17 00:00:00 2001 From: Maxwell Legrand Date: Tue, 6 Jan 2026 17:50:34 -0500 Subject: [PATCH 5/6] Fix formatting errors --- src/lua/tiling.lua | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lua/tiling.lua b/src/lua/tiling.lua index cea8309..c86f7fd 100644 --- a/src/lua/tiling.lua +++ b/src/lua/tiling.lua @@ -3136,7 +3136,14 @@ end ---Spawn a new pane with the given command ---@param opts { command: string[], cwd?: string, new_tab?: boolean, return_to_tab?: boolean } function M.spawn(opts) - prise.log.info("M.spawn called with opts: new_tab=" .. tostring(opts.new_tab) .. ", return_to_tab=" .. tostring(opts.return_to_tab) .. ", command=" .. (opts.command and table.concat(opts.command, " ") or "nil")) + prise.log.info( + "M.spawn called with opts: new_tab=" + .. tostring(opts.new_tab) + .. ", return_to_tab=" + .. tostring(opts.return_to_tab) + .. ", command=" + .. (opts.command and table.concat(opts.command, " ") or "nil") + ) opts = opts or {} local pty = get_focused_pty() local cwd = opts.cwd or (pty and pty:cwd()) @@ -3154,7 +3161,12 @@ function M.spawn(opts) state.pending_split = { direction = get_auto_split_direction() } end - prise.log.info("M.spawn: calling prise.spawn with cwd=" .. (cwd or "nil") .. ", command=" .. (opts.command and table.concat(opts.command, " ") or "nil")) + prise.log.info( + "M.spawn: calling prise.spawn with cwd=" + .. (cwd or "nil") + .. ", command=" + .. (opts.command and table.concat(opts.command, " ") or "nil") + ) prise.spawn({ cwd = cwd, command = opts.command }) prise.log.info("M.spawn: prise.spawn completed") end From b8ff03fc9ceaa1f9caf9aac33c4168989a6ae04a Mon Sep 17 00:00:00 2001 From: Maxwell Legrand Date: Tue, 6 Jan 2026 22:18:21 -0500 Subject: [PATCH 6/6] Fix capture callbacks --- src/lua/tiling.lua | 23 +++++++++++++++++++---- src/lua_event.zig | 1 + src/server.zig | 1 + 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/lua/tiling.lua b/src/lua/tiling.lua index c86f7fd..a81d0a3 100644 --- a/src/lua/tiling.lua +++ b/src/lua/tiling.lua @@ -297,6 +297,8 @@ local state = { ---@type PendingSpawnOpts? pending_spawn_opts = nil, next_split_id = 1, + ---@type PendingSpawnOpts[] + deferred_spawns = {}, -- Command palette palette = { visible = false, @@ -1877,6 +1879,15 @@ end ---@param event Event function M.update(event) + -- Process any deferred spawns from callbacks + if #state.deferred_spawns > 0 then + local spawns = state.deferred_spawns + state.deferred_spawns = {} + for _, spawn_opts in ipairs(spawns) do + M.spawn(spawn_opts) + end + end + if event.type == "pty_attach" then prise.log.info("Lua: pty_attach received") ---@type Pty @@ -1912,7 +1923,7 @@ function M.update(event) -- Delay sending the command to allow the shell to fully initialize prise.set_timeout(100, function() prise.log.info("Sending delayed command to PTY: " .. cmd_str) - pty:send_paste(cmd_str .. " && exit\n") + pty:send_paste(cmd_str .. "\n") end) end elseif #state.tabs == 0 then @@ -3133,6 +3144,12 @@ function M.set_state(saved, pty_lookup) prise.request_frame() end +---Queue a spawn for deferred execution (safe to call from event callbacks) +---@param opts { command: string[], cwd?: string, new_tab?: boolean, return_to_tab?: boolean } +function M.spawn_deferred(opts) + table.insert(state.deferred_spawns, opts) +end + ---Spawn a new pane with the given command ---@param opts { command: string[], cwd?: string, new_tab?: boolean, return_to_tab?: boolean } function M.spawn(opts) @@ -3164,10 +3181,8 @@ function M.spawn(opts) prise.log.info( "M.spawn: calling prise.spawn with cwd=" .. (cwd or "nil") - .. ", command=" - .. (opts.command and table.concat(opts.command, " ") or "nil") ) - prise.spawn({ cwd = cwd, command = opts.command }) + prise.spawn({ cwd = cwd }) prise.log.info("M.spawn: prise.spawn completed") end diff --git a/src/lua_event.zig b/src/lua_event.zig index a6c8d96..0a70d79 100644 --- a/src/lua_event.zig +++ b/src/lua_event.zig @@ -512,6 +512,7 @@ fn ptyCopySelection(lua: *ziglua.Lua) i32 { fn ptyCapturePaneRequest(lua: *ziglua.Lua) i32 { const pty = lua.checkUserdata(PtyHandle, 1, "PrisePty"); + log.info("ptyCapturePaneRequest: calling capture_pane_fn for pty {}", .{pty.id}); pty.capture_pane_fn(pty.app, pty.id) catch |err| { log.err("Failed to capture pane: {}", .{err}); }; diff --git a/src/server.zig b/src/server.zig index 4b61b1f..d32ee8d 100644 --- a/src/server.zig +++ b/src/server.zig @@ -2528,6 +2528,7 @@ const Server = struct { const pty_id = parsePtyId(params) catch { return msgpack.Value{ .string = try self.allocator.dupe(u8, "invalid params") }; }; + log.info("handleCapturePane: capturing pane {}", .{pty_id}); const pty_instance = self.ptys.get(pty_id) orelse { return msgpack.Value{ .string = try self.allocator.dupe(u8, "PTY not found") };