summaryrefslogtreecommitdiff
path: root/src/tui_app.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_app.zig')
-rw-r--r--src/tui_app.zig628
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]);
+ }
+}