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.zig216
1 files changed, 202 insertions, 14 deletions
diff --git a/src/tui_app.zig b/src/tui_app.zig
index dbcf8f9..81894fa 100644
--- a/src/tui_app.zig
+++ b/src/tui_app.zig
@@ -458,17 +458,20 @@ pub const App = struct {
/// transcript layout changes (a layout change forces a full redraw inside
/// the engine, which is correct here).
fn rebuildEngineList(self: *App) !void {
- // Clear and re-add. `removeComponent` is O(n) per call, so clear by
- // re-initializing the slot list via repeated pops is awkward; instead
- // remove the pinned components, then append the new entry, then re-add
- // the pinned ones. To keep it simple and correct we drain & rebuild.
- while (self.engine.componentCount() > 0) {
- const first = self.engine.slots.items[0].comp;
- _ = self.engine.removeComponent(first);
- }
- for (self.transcript.items) |e| try self.engine.addComponent(e.comp());
- try self.engine.addComponent(self.input_box.comp());
- try self.engine.addComponent(self.footer.comp());
+ // Build the desired component list (transcript entries top-to-bottom,
+ // then the pinned input box and footer) and hand it to the engine to
+ // reconcile IN PLACE. `syncComponents` preserves the baseline of every
+ // unchanged slot, so the dominant case — appending a new transcript
+ // entry — stays on the differential path instead of forcing a full
+ // (and, for scrolled content, scrollback-clearing) redraw. See
+ // `Engine.syncComponents`.
+ var comps: std.ArrayList(Component) = .empty;
+ defer comps.deinit(self.alloc);
+ try comps.ensureTotalCapacity(self.alloc, self.transcript.items.len + 2);
+ for (self.transcript.items) |e| comps.appendAssumeCapacity(e.comp());
+ comps.appendAssumeCapacity(self.input_box.comp());
+ comps.appendAssumeCapacity(self.footer.comp());
+ try self.engine.syncComponents(comps.items);
}
/// Spawn a new assistant-text entry for the given block index and return
@@ -649,6 +652,69 @@ pub const App = struct {
return box;
}
+ /// Seed the transcript from a resumed `Conversation` (the `--resume` path).
+ /// Walks every stored message top-to-bottom and spawns the matching
+ /// transcript entry with its content set wholesale — the same components
+ /// the live event stream would have produced, but materialized in one shot
+ /// from history rather than streamed.
+ ///
+ /// Tool results correlate back to their `ToolUse` component by
+ /// `tool_use_id` via the router's id map (populated by `spawnTool` +
+ /// `putToolId`), exactly like the live `tool_dispatch_complete` path. A
+ /// user-role message that carries ONLY `ToolResult` blocks is the
+ /// tool-output carrier, not a user bubble, so it feeds the matching tool
+ /// boxes instead of rendering as user text. `System` blocks are not part of
+ /// the visible transcript and are skipped.
+ ///
+ /// Call once at startup, BEFORE the welcome banner / first paint, so the
+ /// engine's first frame already contains the full restored history.
+ pub fn seedFromConversation(self: *App, conv: *const panto.Conversation) !void {
+ for (conv.messages.items) |msg| {
+ for (msg.content.items) |block| {
+ switch (block) {
+ .Text => |tb| {
+ if (tb.items.len == 0) continue;
+ switch (msg.role) {
+ .assistant => {
+ const box = try self.spawnAssistant(0);
+ try box.setText(tb.items);
+ },
+ .user => try self.spawnUser(tb.items),
+ // System text never renders in the transcript.
+ .system => {},
+ }
+ },
+ .Thinking => |th| {
+ if (th.text.items.len == 0) continue;
+ const box = try self.spawnThinking(0);
+ try box.setText(th.text.items);
+ },
+ .ToolUse => |tu| {
+ const box = try self.spawnTool(0);
+ try box.setName(tu.name);
+ try box.setInput(tu.input.items);
+ try self.router.putToolId(tu.id, box);
+ },
+ .ToolResult => |tr| {
+ const box = self.router.getToolById(tr.tool_use_id) orelse continue;
+ var text: std.ArrayList(u8) = .empty;
+ defer text.deinit(self.alloc);
+ try tr.appendTextInto(self.alloc, &text);
+ try box.setOutput(text.items);
+ box.setResultOk(!tr.is_error);
+ },
+ .CompactionSummary => |cs| {
+ _ = try self.spawnCompaction(cs.text.items);
+ },
+ .System => {},
+ }
+ }
+ }
+ // The id map was a replay scratchpad; clear it so the first live turn
+ // starts clean (mirrors `beginTurn`).
+ self.router.reset();
+ }
+
/// 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.
@@ -1068,6 +1134,10 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void {
if (opts.version.len != 0) try welcome.setVersion(opts.version);
}
+ // On `--resume`, materialize the restored conversation into transcript
+ // components below the banner, so the first paint shows the full history.
+ try app.seedFromConversation(&opts.agent.conversation);
+
app.input_box.setFocused(true);
try app.rebuildEngineList();
try app.renderNow();
@@ -1665,6 +1735,118 @@ test "routeEvent: an unmatched tool_use_id is ignored, matched siblings still ro
try testing.expectEqualStrings("real", known.output.?.items);
}
+test "seedFromConversation materializes resumed history into transcript components" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ // Build a resumed conversation: system (skipped), user, assistant text +
+ // a tool call, the tool result (in a user-role carrier), and a final
+ // assistant reply.
+ var conv = panto.Conversation.init(alloc);
+ defer conv.deinit();
+ try conv.addSystemMessage("you are a helpful assistant"); // must NOT render
+ try conv.addUserMessage(&.{.{ .Text = try panto.textualBlockFromSlice(alloc, "hello there") }});
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try panto.textualBlockFromSlice(alloc, "on it") },
+ .{ .ToolUse = .{
+ .id = try alloc.dupe(u8, "call-1"),
+ .name = try alloc.dupe(u8, "bash"),
+ .input = try panto.textualBlockFromSlice(alloc, "{\"cmd\":\"ls\"}"),
+ } },
+ }, null);
+ {
+ var parts: std.ArrayList(panto.ResultPartStored) = .empty;
+ try parts.append(alloc, .{ .text = try panto.textualBlockFromSlice(alloc, "file-a\nfile-b") });
+ try conv.addUserMessage(&.{.{ .ToolResult = .{
+ .tool_use_id = try alloc.dupe(u8, "call-1"),
+ .parts = parts,
+ } }});
+ }
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try panto.textualBlockFromSlice(alloc, "done") },
+ }, null);
+
+ try h.app.seedFromConversation(&conv);
+
+ // Entries: user, assistant("on it"), tool, assistant("done"). The system
+ // message and the tool-result carrier produce no standalone entries.
+ try testing.expectEqual(@as(usize, 4), h.app.transcript.items.len);
+ try testing.expect(h.app.transcript.items[0].kind == .user);
+ try testing.expectEqualStrings("hello there", h.app.transcript.items[0].kind.user.buffer.items);
+ try testing.expect(h.app.transcript.items[1].kind == .assistant);
+ try testing.expectEqualStrings("on it", h.app.transcript.items[1].kind.assistant.buffer.items);
+ try testing.expect(h.app.transcript.items[2].kind == .tool);
+ const tool = h.app.transcript.items[2].kind.tool;
+ try testing.expectEqualStrings("bash", tool.name.?.items);
+ try testing.expect(std.mem.indexOf(u8, tool.input.items, "ls") != null);
+ // The tool result correlated by id and set the output + success state.
+ try testing.expect(tool.output != null);
+ try testing.expect(std.mem.indexOf(u8, tool.output.?.items, "file-b") != null);
+ try testing.expectEqual(@as(?bool, true), tool.result_ok);
+ try testing.expect(h.app.transcript.items[3].kind == .assistant);
+ try testing.expectEqualStrings("done", h.app.transcript.items[3].kind.assistant.buffer.items);
+
+ // The id map was a replay scratchpad and is cleared afterward.
+ try testing.expect(h.app.router.getToolById("call-1") == null);
+
+ // It renders through the real engine without error.
+ try h.app.rebuildEngineList();
+ try h.app.renderNow();
+ const out = h.buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, "hello there") != null);
+ try testing.expect(std.mem.indexOf(u8, out, "done") != null);
+}
+
+test "streaming a new block with expanded tools stays differential (no scrollback flash)" {
+ // Regression for the expanded-tool-call flash: with tall (scrolled)
+ // content, appending a new transcript entry must NOT trigger a
+ // scrollback-clearing full redraw. Before the syncComponents fix this
+ // emitted a full_clear on the first frame after each new block.
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+ // Short engine so content scrolls => viewport_top > 0 when expanded.
+ h.engine.resize(80, 12);
+ h.app.input_box.setFocused(true);
+ try h.app.rebuildEngineList();
+
+ // A tool call with a big multi-line output, expanded via ctrl+o.
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
+ const tool = h.app.router.get(0).?.tool;
+ try tool.setName("bash");
+ var big: std.ArrayList(u8) = .empty;
+ defer big.deinit(alloc);
+ var i: usize = 0;
+ while (i < 40) : (i += 1) {
+ var lb: [32]u8 = undefined;
+ try big.appendSlice(alloc, std.fmt.bufPrint(&lb, "output line {d}\n", .{i}) catch unreachable);
+ }
+ try tool.setOutput(big.items);
+ tool.setResultOk(true);
+ h.app.toggleToolCollapse();
+ try testing.expect(!tool.collapsed);
+ try h.app.renderNow();
+ try testing.expect(h.engine.viewport_top > 0);
+
+ // Open a new assistant text block and stream into it. No frame across the
+ // new-block boundary or the deltas may clear the scrollback.
+ var clears: usize = 0;
+ h.buf.clearRetainingCapacity();
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 1 } });
+ try h.app.renderNow();
+ if (std.mem.indexOf(u8, h.buf.written(), terminal_mod.seq.full_clear) != null) clears += 1;
+
+ const chunks = [_][]const u8{ "Lorem ", "ipsum ", "dolor ", "sit ", "amet " };
+ for (chunks) |c| {
+ h.buf.clearRetainingCapacity();
+ try h.app.routeEvent(delta(1, c));
+ try h.app.renderNow();
+ if (std.mem.indexOf(u8, h.buf.written(), terminal_mod.seq.full_clear) != null) clears += 1;
+ }
+ try testing.expectEqual(@as(usize, 0), clears);
+}
+
test "toggleToolCollapse flips every tool component globally" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
@@ -2141,12 +2323,18 @@ test "event wiring: mid-stream swap at tool_details takes over and keeps driving
// Args were driven into the default box too.
try testing.expect(std.mem.indexOf(u8, box.input.items, "path") != null);
- // The override still renders (not the default), even after the result
- // drove the default box.
+ // The override still owns the slot (not the default), even after the
+ // result drove the default box. Delivering the result dirtied the DEFAULT
+ // box, but the override is what renders that slot and it did NOT change,
+ // so the next differential frame must NOT repaint the default's content:
+ // the swapped-in marker stays on screen and "the-output" never appears.
+ // (Under the incremental model an unchanged slot is not re-emitted; we
+ // assert the slot is still the override and that the default content is
+ // absent from the frame.)
+ try testing.expect(h.app.transcript.items[0].override.?.ptr == marker.comp().ptr);
h.buf.clearRetainingCapacity();
try h.app.renderNow();
out = h.buf.written();
- try testing.expect(std.mem.indexOf(u8, out, "SWAPPED-AT-DETAILS") != null);
try testing.expect(std.mem.indexOf(u8, out, "the-output") == null);
}