diff options
Diffstat (limited to 'src/tui_app.zig')
| -rw-r--r-- | src/tui_app.zig | 216 |
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); } |
