summaryrefslogtreecommitdiff
path: root/src/tui_app.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-10 14:57:31 -0600
committert <t@tjp.lol>2026-06-10 15:28:16 -0600
commitcee08918509689adbdbd26b6384d5ef3a47e91a2 (patch)
treece2c6bd2269a086fa34edc682696bdf12d9e5b23 /src/tui_app.zig
parentea4bff482374fa8bce38636311fa582e3c87ad47 (diff)
fix: eliminate scrollback-clearing flash when streaming with expanded tool output
The root cause was a two-part bug: 1. Engine slot management (tui_engine.zig): The app rebuilds its full component list on every new transcript entry. The old approach drained every slot then re-added them all, resetting every component's render baseline. When earlier content had scrolled into native scrollback (viewport_top > 0), the engine's viewport-escape rule escalated any cut above the viewport top to a scrollback-clearing full redraw — the visible flash on every streamed block while a tool call was expanded. Fix: replace drain+rebuild with syncComponents(), which reconciles the live slot list in place by finding the longest common prefix and suffix (matched by component pointer). Prefix and suffix slots keep their baselines verbatim; only the changed region in the middle gets new empty slots. A pure insertion (the dominant case: a new transcript entry appended just before the pinned input+footer suffix) stays on the differential path with no clear. A structural change (slot replaced or removed) sets force_full for correctness. 2. RenderCache dirty signal (tui_component.zig): firstLineChanged() was returning the retained changed_from diff index even when the cache was clean. A first-paint store records changed_from == 0, so a clean but previously-rendered component was pegging the differential cut to line 0 on every subsequent frame, making unchanged slots above the insertion point drag the cut all the way up. Fix: when the cache is clean, firstLineChanged() returns null unconditionally. The recorded changed_from is now internal render bookkeeping only, not a live signal. Additional changes: - ToolUse.collapsed_tail_lines raised from 5 to 8 lines. - ToolUse collapsed-view test updated to use 12 output lines and collapsed_tail_lines symbolically so the numbers self-document. - tui_app.zig: override-slot test comment and assertion clarified to reflect that the override owns the slot pointer (not just renders it), and the SWAPPED-AT-DETAILS render check replaced with a pointer-equality check. - lua_event_bridge.zig: minor related fixes. - New tests: syncComponents middle-insertion no-clear, syncComponents structural removal forces full, syncComponents override swap; streaming expanded-tools no-clear regression test in tui_app.
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);
}