diff options
| author | t <t@tjp.lol> | 2026-06-08 14:48:33 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-08 19:56:47 -0600 |
| commit | 104001d25c9c8cb5ec45ced1678f7c7b70888808 (patch) | |
| tree | e0fd9d5c89e953dd9db9253b87be8426e56d63f1 /src/tui_app.zig | |
| parent | b5eb3f1776a540a55d5675f786a4421c49a6283d (diff) | |
keybinding fixes
Diffstat (limited to 'src/tui_app.zig')
| -rw-r--r-- | src/tui_app.zig | 628 |
1 files changed, 580 insertions, 48 deletions
diff --git a/src/tui_app.zig b/src/tui_app.zig index 788595a..a9d9a42 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -28,17 +28,28 @@ //! (`firstLineChanged` near the tail via the render cache + the line-diff //! backstop) repaints only the dirty tail. stdout is never written directly. //! -//! ## Thinking / tool deltas in P1 (deferred display, non-crashing) +//! ## Thinking / tool / compaction display (P2) //! -//! P1's minimal component set is user/assistant/input/footer. There is no -//! dedicated thinking or collapsible tool-use component yet (P2). To avoid -//! crashing on those blocks while keeping the loop honest: -//! - a Thinking block streams its deltas into a DIM status line (one -//! `AssistantText`-style component styled dim), and -//! - a ToolUse block renders a single dim `tool: <name>` status line -//! (name resolved at `tool_details` / `block_complete`). -//! The full thinking component and the collapsible tool-use component are -//! deferred to P2; this is the documented minimal stand-in. +//! The full built-in component set is wired here: +//! - a Thinking block streams its deltas into a dedicated dim `Thinking` +//! component, +//! - a ToolUse block drives a `ToolUse` component (one per call) through its +//! `tool (?)…` -> `tool (<name>) <input json>` -> `+ <output>` progression; +//! the component is collapsible via a GLOBAL ctrl+o toggle (default +//! collapsed, showing the last 5 output lines), +//! - a CompactionSummary block (or a compaction provider-retry) renders a +//! `CompactionSummary` component. +//! +//! ## Tool-result correlation (no "active component") +//! +//! ToolResult blocks do NOT arrive via `block_start`/`block_complete`; the +//! agent assembles them and delivers them together in the +//! `tool_dispatch_complete` event's user `Message`. Each `ToolResultBlock` +//! carries a `tool_use_id` linking back to its `ToolUseBlock.id`. The router +//! therefore keeps a SECOND map, tool-call id -> *ToolUse component, populated +//! when the tool name/id resolve; on `tool_dispatch_complete` we walk the +//! result blocks and feed each one's text to the matching component by id. +//! Nothing is keyed by a single "current" component (plan §6 invariant). const std = @import("std"); const posix = std.posix; @@ -60,6 +71,10 @@ const AssistantText = components.AssistantText; const UserText = components.UserText; const InputBox = components.InputBox; const Footer = components.Footer; +const Welcome = components.Welcome; +const Thinking = components.Thinking; +const CompactionSummary = components.CompactionSummary; +const ToolUse = components.ToolUse; const Component = component.Component; const Event = panto.Event; @@ -103,20 +118,47 @@ const Entry = union(enum) { user: *UserText, /// Assistant message body (streaming text block). assistant: *AssistantText, - /// A dim status/thinking/tool/retry line (minimal P1 stand-in; not a full - /// component — see module docs). + /// A dim status/retry line (provider retries, command output, errors). status: *AssistantText, + /// Session-start banner. + welcome: *Welcome, + /// Streaming thinking block (dim). + thinking: *Thinking, + /// A tool call + result (collapsible). + tool: *ToolUse, + /// A compaction summary. + compaction: *CompactionSummary, fn comp(self: Entry) Component { return switch (self) { .user => |p| p.comp(), .assistant => |p| p.comp(), .status => |p| p.comp(), + .welcome => |p| p.comp(), + .thinking => |p| p.comp(), + .tool => |p| p.comp(), + .compaction => |p| p.comp(), }; } fn deinit(self: Entry, alloc: std.mem.Allocator) void { switch (self) { + .welcome => |p| { + p.deinit(); + alloc.destroy(p); + }, + .thinking => |p| { + p.deinit(); + alloc.destroy(p); + }, + .tool => |p| { + p.deinit(); + alloc.destroy(p); + }, + .compaction => |p| { + p.deinit(); + alloc.destroy(p); + }, .user => |p| { p.deinit(); alloc.destroy(p); @@ -154,6 +196,11 @@ pub const App = struct { /// Per-turn block routing. Cleared at each turn boundary. router: TurnRouter, + /// Global tool-use collapse state (ctrl+o). Applies to EVERY tool-use + /// component at once (plan: collapse is a global toggle). Default true: + /// tool output starts collapsed to its last few lines. + tools_collapsed: bool = true, + /// Optional sink flusher. The real terminal's engine writer is a buffered /// file writer that must be flushed after each frame for output to reach /// the tty; tests inject an in-memory writer and leave this null. @@ -179,6 +226,7 @@ pub const App = struct { .input_box = input_box, .footer = footer, .router = TurnRouter.init(alloc), + .tools_collapsed = true, }; } @@ -263,6 +311,53 @@ pub const App = struct { try self.pushEntry(.{ .user = box }); } + /// Spawn the session-start welcome banner. Returns it so the caller can set + /// its fields (version / cwd / model). + fn spawnWelcome(self: *App) !*Welcome { + const box = try self.alloc.create(Welcome); + box.* = Welcome.init(self.alloc); + try self.pushEntry(.{ .welcome = box }); + return box; + } + + /// Spawn a streaming thinking entry. Keyed by block index in the router. + fn spawnThinking(self: *App) !*Thinking { + const box = try self.alloc.create(Thinking); + box.* = Thinking.init(self.alloc); + try self.pushEntry(.{ .thinking = box }); + return box; + } + + /// Spawn a tool-use entry. Inherits the app's current global collapse state + /// so a tool opened while everything is collapsed starts collapsed too. + fn spawnTool(self: *App) !*ToolUse { + const box = try self.alloc.create(ToolUse); + box.* = ToolUse.init(self.alloc); + box.setCollapsed(self.tools_collapsed); + try self.pushEntry(.{ .tool = box }); + return box; + } + + /// Spawn a compaction-summary entry seeded with `summary`. + fn spawnCompaction(self: *App, summary: []const u8) !*CompactionSummary { + const box = try self.alloc.create(CompactionSummary); + box.* = CompactionSummary.init(self.alloc); + try box.setSummary(summary); + try self.pushEntry(.{ .compaction = box }); + return box; + } + + /// Toggle the global tool-use collapse state (ctrl+o) and apply it to every + /// tool-use component in the transcript. No "active component": we iterate + /// the whole list and flip each one. Requests a render. + pub fn toggleToolCollapse(self: *App) void { + self.tools_collapsed = !self.tools_collapsed; + for (self.transcript.items) |e| { + if (e == .tool) e.tool.setCollapsed(self.tools_collapsed); + } + self.scheduler.requestRender(); + } + // -- the render pump ---------------------------------------------------- /// Render a frame if one is pending, feeding the footer the measured @@ -312,15 +407,13 @@ pub const App = struct { try self.router.put(b.index, .{ .assistant = box }); }, .Thinking => { - // Minimal P1 stand-in: a dim streaming status line. - const box = try self.spawnStatus("[thinking] "); + const box = try self.spawnThinking(); try self.router.put(b.index, .{ .thinking = box }); }, .ToolUse => { - // Minimal P1 stand-in: a dim one-line tool status. The - // name is unknown at start (streamed); fill it in at - // tool_details / block_complete. - const box = try self.spawnStatus("tool: …"); + // The name is unknown at start (streamed); the component + // renders `tool (?)…` until `tool_details` resolves it. + const box = try self.spawnTool(); try self.router.put(b.index, .{ .tool = box }); }, .ToolResult => {}, @@ -330,10 +423,11 @@ pub const App = struct { .tool_details => |d| { if (self.router.get(d.index)) |ref| switch (ref) { .tool => |box| { - const dim = theme.default.fg(.dim); - const line = try std.fmt.allocPrint(self.alloc, "{s}tool: {s}{s}", .{ dim.open(), d.name, dim.close() }); - defer self.alloc.free(line); - try box.setText(line); + try box.setName(d.name); + // Register the id -> component mapping so a later + // ToolResult (out-of-band, keyed by tool_use_id) finds + // this exact component. + try self.router.putToolId(d.id, box); self.scheduler.requestRender(); }, else => {}, @@ -346,15 +440,15 @@ pub const App = struct { self.scheduler.requestRender(); }, .thinking => |box| { - // Append thinking deltas (still dim — the seed kept the - // dim run open; we append raw text, which renders plain - // assistant style. Acceptable P1 stand-in). try box.appendDelta(d.delta); self.scheduler.requestRender(); }, - // Tool args stream as deltas too; P1 doesn't display the - // streamed JSON args (deferred to the P2 tool component). - .tool => {}, + .tool => |box| { + // Tool args stream as deltas — they ARE the verbatim + // JSON input. Accumulate them into the component. + try box.appendInput(d.delta); + self.scheduler.requestRender(); + }, }; }, .block_complete => |b| { @@ -362,22 +456,37 @@ pub const App = struct { .ToolUse => |tu| { if (self.router.get(b.index)) |ref| switch (ref) { .tool => |box| { - const dim = theme.default.fg(.dim); - const line = try std.fmt.allocPrint(self.alloc, "{s}tool: {s}{s}", .{ dim.open(), tu.name, dim.close() }); - defer self.alloc.free(line); - try box.setText(line); + // Final authoritative name + input from the + // completed block (covers the case where + // tool_details never fired and replaces any + // partial streamed args with the final bytes). + try box.setName(tu.name); + try box.setInput(tu.input.items); + try self.router.putToolId(tu.id, box); self.scheduler.requestRender(); }, else => {}, }; }, + .CompactionSummary => |cs| { + _ = try self.spawnCompaction(cs.text.items); + self.scheduler.requestRender(); + }, else => {}, } }, - .message_complete => {}, + .message_complete => |mc| { + // Update the footer's context-window token count with the + // LATEST usage (plan §6): input + cache_read + cache_write + // (output/reasoning excluded — not "in the window"). Latest + // value wins; not accumulated. + if (mc.usage) |u| { + const ctx = u.input + u.cache_read + u.cache_write; + self.footer.setContextTokens(ctx); + self.scheduler.requestRender(); + } + }, .provider_retry => |info| { - // Preserve the existing dim retry messaging meaning as a status - // line in the transcript. if (info.compaction) { _ = try self.spawnStatus("context overflow: compacting and retrying"); } else { @@ -392,8 +501,37 @@ pub const App = struct { } self.scheduler.requestRender(); }, - .tool_dispatch_start, .tool_dispatch_complete, .turn_complete => {}, + .tool_dispatch_complete => |info| { + // ToolResult blocks are delivered together here as the content + // of the appended user message. Correlate each back to its + // ToolUse component by tool_use_id and feed it the result text. + try self.routeToolResults(info.message); + }, + .tool_dispatch_start, .turn_complete => {}, + } + } + + /// Walk a tool-dispatch-complete user message and feed each `ToolResult` + /// block's text to the `ToolUse` component that issued the matching call + /// (looked up by `tool_use_id`). Honors the no-active-component invariant: + /// the correlation is purely by id. + fn routeToolResults(self: *App, message: panto.Message) !void { + var any = false; + for (message.content.items) |block| { + switch (block) { + .ToolResult => |tr| { + const box = self.router.getToolById(tr.tool_use_id) orelse continue; + // Concatenate the textual parts of the result. + var text: std.ArrayList(u8) = .empty; + defer text.deinit(self.alloc); + try tr.appendTextInto(self.alloc, &text); + try box.setOutput(text.items); + any = true; + }, + else => {}, + } } + if (any) self.scheduler.requestRender(); } /// Reset per-turn routing state. The transcript entries persist (they are @@ -421,25 +559,45 @@ pub const App = struct { /// is never a single mutable "current" component. pub const BlockRef = union(enum) { assistant: *AssistantText, - /// Thinking block (dim status stand-in for P1). - thinking: *AssistantText, - /// Tool-use block (one-line status stand-in for P1). - tool: *AssistantText, + /// Streaming thinking block. + thinking: *Thinking, + /// Tool-use block (drives its own ToolUse component). + tool: *ToolUse, }; +/// Block-index -> component routing, plus a SECOND map from tool-call id -> +/// the owning `ToolUse` component. The id map is what correlates a later +/// `ToolResult` (delivered out-of-band in `tool_dispatch_complete`, keyed by +/// `tool_use_id`) back to the component that issued the call — without any +/// "active component" (plan §6). +/// +/// The id map borrows transcript-owned `*ToolUse` pointers; both maps are +/// cleared at each turn boundary (the transcript entries themselves persist as +/// history). String keys are duped into an arena so they outlive the borrowed +/// libpanto event slices. pub const TurnRouter = struct { map: std.AutoHashMap(usize, BlockRef), + tool_by_id: std.StringHashMap(*ToolUse), + id_arena: std.heap.ArenaAllocator, pub fn init(alloc: std.mem.Allocator) TurnRouter { - return .{ .map = std.AutoHashMap(usize, BlockRef).init(alloc) }; + return .{ + .map = std.AutoHashMap(usize, BlockRef).init(alloc), + .tool_by_id = std.StringHashMap(*ToolUse).init(alloc), + .id_arena = std.heap.ArenaAllocator.init(alloc), + }; } pub fn deinit(self: *TurnRouter) void { self.map.deinit(); + self.tool_by_id.deinit(); + self.id_arena.deinit(); } pub fn reset(self: *TurnRouter) void { self.map.clearRetainingCapacity(); + self.tool_by_id.clearRetainingCapacity(); + _ = self.id_arena.reset(.retain_capacity); } pub fn put(self: *TurnRouter, index: usize, ref: BlockRef) !void { @@ -449,6 +607,19 @@ pub const TurnRouter = struct { pub fn get(self: *TurnRouter, index: usize) ?BlockRef { return self.map.get(index); } + + /// Register a tool-call id -> its `ToolUse` component for result + /// correlation. The id is duped into the router arena (the libpanto slice + /// is borrowed and transient). + pub fn putToolId(self: *TurnRouter, id: []const u8, box: *ToolUse) !void { + const key = try self.id_arena.allocator().dupe(u8, id); + try self.tool_by_id.put(key, box); + } + + /// Look up the `ToolUse` component that issued the call with this id. + pub fn getToolById(self: *TurnRouter, id: []const u8) ?*ToolUse { + return self.tool_by_id.get(id); + } }; // =========================================================================== @@ -467,6 +638,15 @@ pub const RunOptions = struct { /// status line, then cleared. See `runLoop` for the rationale. cmd_capture: *std.Io.Writer.Allocating, model_label: []const u8, + /// Working directory shown in the welcome banner. Borrowed for the loop. + cwd: []const u8, + /// panto version string for the welcome banner (empty = omit). + version: []const u8 = "", + /// The std.Io used to spawn `$EDITOR` for the Ctrl+G round-trip. + io: std.Io, + /// Process environment, used to resolve `$EDITOR` (and `$VISUAL`) for the + /// Ctrl+G round-trip. Borrowed for the loop's lifetime. + environ: *const std.process.Environ.Map, }; /// Run the interactive chat loop against a real terminal until EOF / Ctrl+D / @@ -507,6 +687,17 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { defer term.showCursor(); try app.footer.setModel(opts.model_label); + + // Session-start welcome banner as the first transcript entry. cwd is read + // from the process; the model label comes from the run options. (Version + // is not threaded through the run options yet; the banner omits it.) + { + const welcome = try app.spawnWelcome(); + try welcome.setModel(opts.model_label); + if (opts.cwd.len != 0) try welcome.setCwd(opts.cwd); + if (opts.version.len != 0) try welcome.setVersion(opts.version); + } + app.input_box.setFocused(true); try app.rebuildEngineList(); try app.renderNow(); @@ -568,6 +759,29 @@ fn handleBytes(app: *App, term: *Terminal, hs: *Handshake, bytes: []const u8, op // terminal's deinit in main. Signal EOF by closing the loop. return error.UserExit; } + if (k.isCtrl('o')) { + // Global collapse/expand of all tool-use components. Consume + // the key (do NOT feed it to the input box) and request a + // render. + app.toggleToolCollapse(); + off += step.consumed; + continue; + } + if (k.isCtrl('g')) { + // Punt the editor buffer out to $EDITOR (markdown tempfile), + // then read it back. Consume the key; never feed it to the + // box. + editInExternalEditor(app, term, opts.io, opts.environ) catch |err| { + if (std.fmt.allocPrint(app.alloc, "[$EDITOR failed: {s}]", .{@errorName(err)})) |msg| { + defer app.alloc.free(msg); + _ = app.spawnStatus(msg) catch {}; + } else |_| { + _ = app.spawnStatus("[$EDITOR failed]") catch {}; + } + }; + off += step.consumed; + continue; + } // Feed the key to the focused input box. app.input_box.comp().handleInput(bytes[off .. off + step.consumed]); }, @@ -622,6 +836,96 @@ fn handleNegotiation(term: *Terminal, hs: *Handshake, neg: input_mod.Negotiation } } +/// Punt the input box's buffer to the user's `$EDITOR` (Ctrl+G), then read it +/// back. Mirrors pi's editor escape hatch. +/// +/// Flow: write the buffer to a `.md` tempfile -> drop the terminal to cooked +/// mode + show the cursor -> spawn `$EDITOR <file>` inheriting our stdio and +/// wait -> re-enter raw mode + hide the cursor -> read the file back into the +/// box (trimming a single trailing newline most editors add) -> delete the +/// tempfile -> force a full engine redraw (the child scribbled all over the +/// screen, so the differential baseline is stale). +/// +/// The terminal's signal/panic restore record stays armed with the ORIGINAL +/// (cooked) termios throughout (`suspendRawMode` does not clear it), so a crash +/// or signal while the editor is open still leaves a sane terminal. We re-enter +/// raw mode on every return path via `defer`. +fn editInExternalEditor( + app: *App, + term: *Terminal, + io: std.Io, + environ: *const std.process.Environ.Map, +) !void { + const editor = environ.get("VISUAL") orelse environ.get("EDITOR") orelse "vi"; + + // Build a tempfile path: $TMPDIR (or /tmp) + a pid/nanotime-unique name. + const tmp_dir = environ.get("TMPDIR") orelse "/tmp"; + const pid = std.c.getpid(); + const nanos = std.Io.Clock.now(.awake, io).nanoseconds; + const path = try std.fmt.allocPrint(app.alloc, "{s}/panto-edit-{d}-{d}.md", .{ + std.mem.trimEnd(u8, tmp_dir, "/"), + pid, + nanos, + }); + defer app.alloc.free(path); + + // Write the current buffer out. + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = app.input_box.buffer() }); + defer std.Io.Dir.cwd().deleteFile(io, path) catch {}; + + // Split `$EDITOR` on spaces so commands with flags (e.g. "code -w") work, + // then append the file path as the final argv entry. + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(app.alloc); + try splitEditorArgv(app.alloc, editor, path, &argv); + + // Drop to cooked mode for the child; always re-enter raw mode + force a + // full redraw afterward. + term.suspendRawMode(); + app.flushSink(); + defer { + term.resumeRawMode() catch {}; + app.engine.forceFullRedraw(); + app.renderNow() catch {}; + } + + var child = try std.process.spawn(io, .{ + .argv = argv.items, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }); + _ = try child.wait(io); + + // Read the edited file back. Cap the read so a pathological file can't OOM + // us; 16 MiB is far past any reasonable prompt. + const edited = std.Io.Dir.cwd().readFileAlloc(io, path, app.alloc, .limited(16 * 1024 * 1024)) catch |err| switch (err) { + else => return err, + }; + defer app.alloc.free(edited); + + // Trim a single trailing newline (the convention most editors add on save). + const trimmed = if (std.mem.endsWith(u8, edited, "\n")) edited[0 .. edited.len - 1] else edited; + try app.input_box.setBuffer(trimmed); +} + +/// Build the argv for the `$EDITOR` spawn: split `editor` on spaces (so +/// commands with flags like `"code -w"` work), fall back to `vi` when empty, +/// then append `path` as the final argument. Split out as a pure helper so the +/// arg-splitting seam is unit-testable without a PTY (the spawn + raw-mode +/// round-trip itself is interactive-only). +fn splitEditorArgv( + alloc: std.mem.Allocator, + editor: []const u8, + path: []const u8, + argv: *std.ArrayList([]const u8), +) !void { + var it = std.mem.tokenizeScalar(u8, editor, ' '); + while (it.next()) |part| try argv.append(alloc, part); + if (argv.items.len == 0) try argv.append(alloc, "vi"); + try argv.append(alloc, path); +} + /// Handle a submitted input line: slash command vs. model turn. fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void { if (line.len == 0) return; @@ -776,33 +1080,38 @@ test "routeEvent: two text blocks key by index, no active-component clobber" { try testing.expectEqualStrings("B", h.app.router.get(1).?.assistant.buffer.items); } -test "routeEvent: thinking deltas do not crash and stream to a status line" { +test "routeEvent: thinking deltas stream into a dedicated Thinking component" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 0 } }); - try h.app.routeEvent(delta(0, "reasoning")); + try h.app.routeEvent(delta(0, "reason")); + try h.app.routeEvent(delta(0, "ing")); const ref = h.app.router.get(0).?; try testing.expect(ref == .thinking); - // The status line buffer contains the seed + appended delta. - try testing.expect(std.mem.indexOf(u8, ref.thinking.buffer.items, "reasoning") != null); + try testing.expectEqualStrings("reasoning", ref.thinking.buffer.items); } -test "routeEvent: tool block renders a minimal tool: <name> status (no crash)" { +test "routeEvent: tool block accumulates verbatim args and resolves its name" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); - // Tool args streaming as deltas must be dropped silently, not crash. + // Tool args stream as deltas and accumulate verbatim into the component. try h.app.routeEvent(delta(0, "{\"path\":")); + try h.app.routeEvent(delta(0, "\"x\"}")); try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "t1", .name = "read" } }); const ref = h.app.router.get(0).?; try testing.expect(ref == .tool); - try testing.expect(std.mem.indexOf(u8, ref.tool.buffer.items, "tool: read") != null); + try testing.expect(ref.tool.name != null); + try testing.expectEqualStrings("read", ref.tool.name.?.items); + try testing.expectEqualStrings("{\"path\":\"x\"}", ref.tool.input.items); + // The id was registered for result correlation. + try testing.expect(h.app.router.getToolById("t1") == ref.tool); } test "routeEvent: provider_retry adds a dim status line" { @@ -874,3 +1183,226 @@ test "maybeRender feeds the footer a frame time and respects coalescing" { // Footer received a frame time (>= 0). try testing.expect(h.app.footer.frame_ms != null); } + +test "routeEvent: tool result correlates to its ToolUse component by id" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Open a tool call, resolve its id/name, accumulate args. + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + try h.app.routeEvent(delta(0, "{\"q\":1}")); + try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "call-1", .name = "search" } }); + + // Build a tool_dispatch_complete user message carrying a ToolResult for + // call-1 (the out-of-band delivery path). + var msg: panto.Message = .{ .role = .user }; + defer msg.deinit(alloc); + var parts: std.ArrayList(panto.ResultPartStored) = .empty; + var text: panto.TextualBlock = .empty; + try text.appendSlice(alloc, "the result body"); + try parts.append(alloc, .{ .text = text }); + const id = try alloc.dupe(u8, "call-1"); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = id, .parts = parts } }); + + try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); + + // The matching component received the output. + const box = h.app.router.getToolById("call-1").?; + try testing.expect(box.output != null); + try testing.expectEqualStrings("the result body", box.output.?.items); +} + +test "routeEvent: two concurrent tool calls route results to their OWN component by id" { + // The highest-risk no-active-component case (plan §6): with MULTIPLE tool + // calls in flight, each ToolResult must land on the component that issued + // the matching id — never "the" tool component. We deliberately deliver the + // results in the REVERSE order of the calls and assert no cross-talk. + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Open two tool calls at distinct block indices; resolve distinct ids. + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + try h.app.routeEvent(delta(0, "{\"a\":1}")); + try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "call-A", .name = "read" } }); + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); + try h.app.routeEvent(delta(1, "{\"b\":2}")); + try h.app.routeEvent(.{ .tool_details = .{ .index = 1, .id = "call-B", .name = "write" } }); + + const box_a = h.app.router.getToolById("call-A").?; + const box_b = h.app.router.getToolById("call-B").?; + try testing.expect(box_a != box_b); + + // Deliver BOTH results in ONE tool_dispatch_complete user message, in the + // reverse order (B before A), each carrying its own tool_use_id. + var msg: panto.Message = .{ .role = .user }; + defer msg.deinit(alloc); + { + var parts_b: std.ArrayList(panto.ResultPartStored) = .empty; + var text_b: panto.TextualBlock = .empty; + try text_b.appendSlice(alloc, "result for B"); + try parts_b.append(alloc, .{ .text = text_b }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "call-B"), .parts = parts_b } }); + + var parts_a: std.ArrayList(panto.ResultPartStored) = .empty; + var text_a: panto.TextualBlock = .empty; + try text_a.appendSlice(alloc, "result for A"); + try parts_a.append(alloc, .{ .text = text_a }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "call-A"), .parts = parts_a } }); + } + try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); + + // Each result landed on its OWN component — no clobber, no cross-talk. + try testing.expect(box_a.output != null); + try testing.expect(box_b.output != null); + try testing.expectEqualStrings("result for A", box_a.output.?.items); + try testing.expectEqualStrings("result for B", box_b.output.?.items); + // And the inputs were never crossed either. + try testing.expectEqualStrings("{\"a\":1}", box_a.input.items); + try testing.expectEqualStrings("{\"b\":2}", box_b.input.items); +} + +test "routeEvent: an unmatched tool_use_id is ignored, matched siblings still route" { + // A result whose id has no live ToolUse must be skipped (orelse continue), + // never crash or smear onto another component. + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "known", .name = "read" } }); + const known = h.app.router.getToolById("known").?; + + var msg: panto.Message = .{ .role = .user }; + defer msg.deinit(alloc); + { + var p_unknown: std.ArrayList(panto.ResultPartStored) = .empty; + var t_unknown: panto.TextualBlock = .empty; + try t_unknown.appendSlice(alloc, "orphan"); + try p_unknown.append(alloc, .{ .text = t_unknown }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "ghost"), .parts = p_unknown } }); + + var p_known: std.ArrayList(panto.ResultPartStored) = .empty; + var t_known: panto.TextualBlock = .empty; + try t_known.appendSlice(alloc, "real"); + try p_known.append(alloc, .{ .text = t_known }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "known"), .parts = p_known } }); + } + try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); + + try testing.expect(known.output != null); + try testing.expectEqualStrings("real", known.output.?.items); +} + +test "toggleToolCollapse flips every tool component globally" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Two tool calls. Default collapsed == true. + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); + const a = h.app.router.get(0).?.tool; + const b = h.app.router.get(1).?.tool; + try testing.expect(a.collapsed and b.collapsed); + + // ctrl+o equivalent: expand all. + h.app.toggleToolCollapse(); + try testing.expect(!a.collapsed and !b.collapsed); + try testing.expect(!h.app.tools_collapsed); + + // Toggle again: collapse all. + h.app.toggleToolCollapse(); + try testing.expect(a.collapsed and b.collapsed); +} + +test "toggleToolCollapse: a tool spawned AFTER the toggle inherits the global state" { + // ctrl+o is a GLOBAL mode, not a per-component flip: a tool call that opens + // later must adopt whatever the current global collapse state is, so the + // whole transcript stays consistent. + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Default is collapsed; flip the global mode to EXPANDED before any tool. + h.app.toggleToolCollapse(); + try testing.expect(!h.app.tools_collapsed); + + // A tool that opens now must be expanded to match the global mode. + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + const late = h.app.router.get(0).?.tool; + try testing.expect(!late.collapsed); + + // Flip back to collapsed; a still-later tool must open collapsed. + h.app.toggleToolCollapse(); + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); + const later = h.app.router.get(1).?.tool; + try testing.expect(later.collapsed); + // And the earlier one flipped along with the global toggle. + try testing.expect(late.collapsed); +} + +test "spawnWelcome shows a session-start banner entry" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + const w = try h.app.spawnWelcome(); + try w.setModel("m"); + try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); + try testing.expect(h.app.transcript.items[0] == .welcome); +} + +test "routeEvent: compaction summary block spawns a compaction entry" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + var cs: panto.TextualBlock = .empty; + defer cs.deinit(alloc); + try cs.appendSlice(alloc, "old turns summarized"); + try h.app.routeEvent(.{ .block_complete = .{ + .index = 0, + .block = .{ .CompactionSummary = .{ .text = cs } }, + } }); + + try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); + try testing.expect(h.app.transcript.items[0] == .compaction); +} + +test "splitEditorArgv: splits flags, appends the path, and falls back to vi" { + const alloc = testing.allocator; + + // Bare editor name: [editor, path]. + { + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(alloc); + try splitEditorArgv(alloc, "nvim", "/tmp/panto-edit-1.md", &argv); + try testing.expectEqual(@as(usize, 2), argv.items.len); + try testing.expectEqualStrings("nvim", argv.items[0]); + try testing.expectEqualStrings("/tmp/panto-edit-1.md", argv.items[1]); + } + + // Editor with flags: each space-delimited token is its own argv entry, + // then the path is last (e.g. "code -w" -> [code, -w, path]). + { + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(alloc); + try splitEditorArgv(alloc, "code -w", "/tmp/x.md", &argv); + try testing.expectEqual(@as(usize, 3), argv.items.len); + try testing.expectEqualStrings("code", argv.items[0]); + try testing.expectEqualStrings("-w", argv.items[1]); + try testing.expectEqualStrings("/tmp/x.md", argv.items[2]); + } + + // Empty editor string: falls back to vi, then the path. + { + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(alloc); + try splitEditorArgv(alloc, "", "/tmp/y.md", &argv); + try testing.expectEqual(@as(usize, 2), argv.items.len); + try testing.expectEqualStrings("vi", argv.items[0]); + try testing.expectEqualStrings("/tmp/y.md", argv.items[1]); + } +} |
