summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lua_event_bridge.zig15
-rw-r--r--src/tui_app.zig216
-rw-r--r--src/tui_component.zig52
-rw-r--r--src/tui_components.zig64
-rw-r--r--src/tui_engine.zig176
5 files changed, 464 insertions, 59 deletions
diff --git a/src/lua_event_bridge.zig b/src/lua_event_bridge.zig
index f6d67e0..c4acb1d 100644
--- a/src/lua_event_bridge.zig
+++ b/src/lua_event_bridge.zig
@@ -797,9 +797,11 @@ test "Lua-defined component renders lines through the bridged vtable" {
try testing.expectEqual(@as(usize, 2), lines.len);
try testing.expectEqualStrings("hello", lines[0]);
try testing.expectEqualStrings("world", lines[1]);
- // firstLineChanged is cache-derived: first render dirties from 0.
- try testing.expectEqual(@as(?usize, 0), chosen.?.firstLineChanged());
- // A second identical render reports no change.
+ // firstLineChanged is cache-derived and is the LIVE signal: after a render
+ // the cache is clean, so it reports null (the first-render diff index 0 is
+ // retained internally as bookkeeping only).
+ try testing.expectEqual(@as(?usize, null), chosen.?.firstLineChanged());
+ // A second identical render still reports no change.
_ = try chosen.?.render(80, testing.allocator);
try testing.expectEqual(@as(?usize, null), chosen.?.firstLineChanged());
}
@@ -1258,10 +1260,11 @@ test "bridged firstLineChanged is cache-derived: append stays near the tail" {
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
- // First render: 2 lines, dirty from 0.
+ // First render: 2 lines. After the render the cache is clean, so the live
+ // signal is null (the first-render diff index 0 is internal bookkeeping).
_ = try chosen.render(80, testing.allocator);
- try testing.expectEqual(@as(?usize, 0), chosen.firstLineChanged());
- // No change: null.
+ try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged());
+ // No change: still null.
_ = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged());
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);
}
diff --git a/src/tui_component.zig b/src/tui_component.zig
index a2b0ea4..b213e3f 100644
--- a/src/tui_component.zig
+++ b/src/tui_component.zig
@@ -119,7 +119,16 @@ pub const Focusable = struct {
/// 2. records the lowest differing index as the "changed-from" value,
/// 3. replaces the cache with a copy of the new lines,
/// 4. marks the cache clean.
-/// - After a clean render with no diff, `firstLineChanged()` returns null.
+/// - Once the cache is CLEAN, `firstLineChanged()` returns null regardless
+/// of the diff `store` recorded. `firstLineChanged()` is the LIVE
+/// pre-render dirty signal (the engine reads it once per slot BEFORE
+/// `render` to decide whether to re-render and where to cut); a clean
+/// component is already up to date on screen and has nothing pending, so
+/// it must report null. The recorded diff index is retained separately in
+/// `changed_from` as render bookkeeping and is NOT a live signal — exposing
+/// it while clean would peg the differential cut to a stale line on every
+/// later frame (a first-paint store records `changed_from == 0`, which
+/// would otherwise force the cut to line 0 forever).
///
/// Because `firstLineChanged` is computed purely from cache state, it cannot
/// drift from a hand-managed field. Components embed this struct and route
@@ -132,8 +141,11 @@ pub const RenderCache = struct {
/// Owned copies of the last rendered lines, or null when never rendered or
/// invalidated.
lines: ?[][]u8 = null,
- /// Lowest line index that changed at the last `store`, or 0 when dirty
- /// with no prior cache. Null only when clean with no change.
+ /// Lowest line index that changed at the last `store` (or the pending
+ /// dirty line while dirty; 0 when dirty with no prior cache). This is
+ /// render bookkeeping, NOT the live signal: when the cache is clean,
+ /// `firstLineChanged()` returns null even though this still holds the last
+ /// diff index. Null only when a clean store found no change.
changed_from: ?usize = null,
dirty: bool = true,
@@ -202,8 +214,17 @@ pub const RenderCache = struct {
/// Derived dirty signal. Returns the lowest changed line index, or null
/// when the cache is clean and nothing changed at the last store.
pub fn firstLineChanged(self: *const RenderCache) ?usize {
+ // The engine reads this as the PRE-render dirty signal (once per slot,
+ // before `render`). A clean cache has no pending change to repaint, so
+ // it must report null — even though the last `store` recorded a
+ // `changed_from` diff index. Returning that retained index while clean
+ // makes an unchanged component spuriously cut the differential repaint
+ // at its stale diff line on EVERY later frame (notably a first-paint
+ // store leaves `changed_from == 0`, which would peg the cut to line 0
+ // forever). The retained `changed_from` is internal render bookkeeping,
+ // not a live signal; only the dirty state drives a repaint.
if (self.dirty) return self.changed_from orelse 0;
- return self.changed_from;
+ return null;
}
/// Record a successful render. Diffs `new_lines` against the cache,
@@ -264,12 +285,15 @@ test "RenderCache: store cleans, no-change render returns null" {
const a = [_][]const u8{ "one", "two" };
try c.store(&a);
- // First store after empty cache => changed from 0.
- try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged());
+ // store cleans the cache, so the live signal is null (clean => null).
+ // The recorded diff (first store after empty => 0) lives in changed_from.
+ try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
+ try std.testing.expectEqual(@as(?usize, 0), c.changed_from);
- // Re-store identical => no change.
+ // Re-store identical => no change, still clean.
try c.store(&a);
try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
+ try std.testing.expectEqual(@as(?usize, null), c.changed_from);
}
test "RenderCache: store reports lowest differing line" {
@@ -280,7 +304,10 @@ test "RenderCache: store reports lowest differing line" {
try c.store(&a);
const b = [_][]const u8{ "one", "TWO", "three" };
try c.store(&b);
- try std.testing.expectEqual(@as(?usize, 1), c.firstLineChanged());
+ // The diff store computed is recorded in changed_from; the live signal is
+ // null because the cache is now clean (already rendered on screen).
+ try std.testing.expectEqual(@as(?usize, 1), c.changed_from);
+ try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
}
test "RenderCache: line-count change reports the boundary" {
@@ -291,7 +318,8 @@ test "RenderCache: line-count change reports the boundary" {
try c.store(&a);
const b = [_][]const u8{ "one", "two", "three" };
try c.store(&b);
- try std.testing.expectEqual(@as(?usize, 2), c.firstLineChanged());
+ try std.testing.expectEqual(@as(?usize, 2), c.changed_from);
+ try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
}
test "RenderCache: markDirtyAppend keeps baseline and reports a tail hint, diff recovers exact change" {
@@ -307,10 +335,12 @@ test "RenderCache: markDirtyAppend keeps baseline and reports a tail hint, diff
try std.testing.expect(c.lines != null);
// A render that only adds a tail line: diff recovers the exact boundary (4),
- // NOT 0 — the streaming-tail property.
+ // NOT 0 — the streaming-tail property. The cache is clean after store, so
+ // the recorded diff lives in changed_from and the live signal is null.
const b = [_][]const u8{ "l0", "l1", "l2", "l3", "l4" };
try c.store(&b);
- try std.testing.expectEqual(@as(?usize, 4), c.firstLineChanged());
+ try std.testing.expectEqual(@as(?usize, 4), c.changed_from);
+ try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
}
test "RenderCache: markDirtyAppend with no baseline reports 0" {
diff --git a/src/tui_components.zig b/src/tui_components.zig
index a9648ee..1ce5b56 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -1580,7 +1580,7 @@ pub const CompactionSummary = struct {
/// component holds no global state.
pub const ToolUse = struct {
/// Number of trailing output lines shown when collapsed.
- pub const collapsed_tail_lines: usize = 5;
+ pub const collapsed_tail_lines: usize = 8;
alloc: std.mem.Allocator,
cache: RenderCache,
@@ -1854,8 +1854,9 @@ test "AssistantText: renders wrapped text within width" {
// renderBlock adds 2 margin lines: 3 content + 2 = 5.
try testing.expectEqual(@as(usize, 5), lines.len);
for (lines) |l| try testing.expect(vw(l) <= 7);
- // First render after empty cache => changed from 0.
- try testing.expectEqual(@as(?usize, 0), at.comp().firstLineChanged());
+ // After a render the cache is clean, so the live signal is null. (The
+ // first-render diff index 0 is retained internally in changed_from.)
+ try testing.expectEqual(@as(?usize, null), at.comp().firstLineChanged());
}
test "AssistantText: streaming keeps firstLineChanged near the tail, not 0" {
@@ -2530,24 +2531,33 @@ test "ToolUse: stage 2 shows name + verbatim json + placeholder" {
for (lines) |l| try testing.expect(vw(l) <= 60);
}
-test "ToolUse: collapsed shows only the last 5 output lines (default)" {
+test "ToolUse: collapsed shows only the last collapsed_tail_lines output lines (default)" {
var t = ToolUse.init(testing.allocator);
defer t.deinit();
try t.setName("read");
try t.setInput("{}");
- // 8 short output lines -> collapsed shows the marker + last 5.
- try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8");
+ // 12 short output lines -> collapsed shows the marker + the last
+ // collapsed_tail_lines (8) of them, eliding l1..l4.
+ try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12");
const collapsed = try t.comp().render(40, testing.allocator);
- // margin+pad+header+sep+marker+l4..l8+pad+margin = 1+1+1+1+1+5+1+1 = 12
- // Last output line (l8) is at lines[len-3] (before bottom pad + margin).
- try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 3], "l8") != null);
- try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 7], "l4") != null);
- // The earliest output lines are elided when collapsed.
- var has_l1 = false;
+ // margin+pad+header+sep+marker+(tail lines)+pad+margin
+ // = 1+1+1+1+1+collapsed_tail_lines+1+1 = 7 + collapsed_tail_lines
+ try testing.expectEqual(@as(usize, 7 + ToolUse.collapsed_tail_lines), collapsed.len);
+ // Last output line (l12) is at lines[len-3] (before bottom pad + margin);
+ // the first SHOWN tail line is at lines[len-3-(tail-1)].
+ // Tokens are rendered as " l<n>" followed by right-padding, so match on a
+ // trailing space to avoid "l1" spuriously matching inside "l10".."l12".
+ try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 3], "l12 ") != null);
+ try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 3 - (ToolUse.collapsed_tail_lines - 1)], "l5 ") != null);
+ // The earliest output lines (l1..l4) are elided when collapsed.
+ var has_elided = false;
for (collapsed) |l| {
- if (std.mem.indexOf(u8, l, "l1") != null) has_l1 = true;
+ if (std.mem.indexOf(u8, l, "l1 ") != null or
+ std.mem.indexOf(u8, l, "l2 ") != null or
+ std.mem.indexOf(u8, l, "l3 ") != null or
+ std.mem.indexOf(u8, l, "l4 ") != null) has_elided = true;
}
- try testing.expect(!has_l1);
+ try testing.expect(!has_elided);
// Expanding shows everything.
t.setCollapsed(false);
@@ -2555,7 +2565,7 @@ test "ToolUse: collapsed shows only the last 5 output lines (default)" {
try testing.expect(expanded.len > collapsed.len);
var has_l1_exp = false;
for (expanded) |l| {
- if (std.mem.indexOf(u8, l, "l1") != null) has_l1_exp = true;
+ if (std.mem.indexOf(u8, l, "l1 ") != null) has_l1_exp = true;
}
try testing.expect(has_l1_exp);
}
@@ -2620,33 +2630,31 @@ test "ToolUse: collapse/expand is a length change with a cache-derived firstLine
defer t.deinit();
try t.setName("read");
try t.setInput("{}");
- try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8");
+ // 12 output lines, so collapsing (tail = collapsed_tail_lines = 8) truncates.
+ try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12");
// Default collapsed:
- // margin+pad+header+sep+truncmark+5lines+pad+margin = 1+1+1+1+1+5+1+1 = 12
+ // margin+pad+header+sep+truncmark+8lines+pad+margin = 1+1+1+1+1+8+1+1 = 15
const collapsed = try t.comp().render(40, testing.allocator);
- try testing.expectEqual(@as(usize, 12), collapsed.len);
- // A stable re-render is clean (cache-derived, no drift).
+ try testing.expectEqual(@as(usize, 15), collapsed.len);
+ // A stable re-render is clean: the live signal is null.
_ = try t.comp().render(40, testing.allocator);
try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged());
- // Expand: margin+pad+header+sep+8lines+pad+margin = 1+1+1+1+8+1+1 = 14
+ // Expand: margin+pad+header+sep+12lines+pad+margin = 1+1+1+1+12+1+1 = 18
t.setCollapsed(false);
// While dirty (full drop), the signal is the cache-derived 0.
try testing.expectEqual(@as(?usize, 0), t.comp().firstLineChanged());
const expanded = try t.comp().render(40, testing.allocator);
- try testing.expectEqual(@as(usize, 14), expanded.len);
- // After the render the baseline was dropped on the toggle, so the diff
- // reports from 0 — cache-derived, not a hand-managed value.
- try testing.expectEqual(@as(?usize, 0), t.comp().firstLineChanged());
- // Stable re-render is clean again.
- _ = try t.comp().render(40, testing.allocator);
+ try testing.expectEqual(@as(usize, 18), expanded.len);
+ // After the render the cache is clean, so the live signal is null again
+ // (the toggle's drop-from-0 diff is internal bookkeeping, not a live cut).
try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged());
- // Collapse again: shrink back to 12 rows.
+ // Collapse again: shrink back to 15 rows.
t.setCollapsed(true);
const recollapsed = try t.comp().render(40, testing.allocator);
- try testing.expectEqual(@as(usize, 12), recollapsed.len);
+ try testing.expectEqual(@as(usize, 15), recollapsed.len);
}
test "ToolUse: args are rendered VERBATIM (no pretty-print) and within width" {
diff --git a/src/tui_engine.zig b/src/tui_engine.zig
index 59bce06..6c5b372 100644
--- a/src/tui_engine.zig
+++ b/src/tui_engine.zig
@@ -350,6 +350,89 @@ pub const Engine = struct {
return self.slots.items.len;
}
+ /// Reconcile the live slot list against `comps` IN PLACE, preserving the
+ /// render baseline of every slot whose component pointer is unchanged.
+ ///
+ /// This is the structure-aware replacement for "drain every slot + re-add
+ /// all" (which reset EVERY baseline and forced a full redraw on every
+ /// transcript mutation). The app rebuilds its component list on each new
+ /// entry, but the overwhelmingly common shape is an APPEND at the end
+ /// (a new transcript entry) with all earlier slots unchanged. Resetting
+ /// the unchanged slots' baselines turned that append into a full redraw,
+ /// and when earlier content had scrolled into native scrollback
+ /// (`viewport_top > 0`) the viewport-escape rule escalated it to a
+ /// SCROLLBACK-CLEARING redraw — the visible "flash" on every streamed
+ /// block while tool output was expanded.
+ ///
+ /// Reconciliation anchors on the longest common PREFIX and the longest
+ /// common SUFFIX (both matched by `comp.ptr`); everything between them is
+ /// the changed region.
+ /// - Prefix and suffix slots keep their baseline verbatim (no re-render,
+ /// no forced full): unchanged components above/below the change point
+ /// stay clean and reprint from cache only if they sit below the cut.
+ /// - PURE INSERTION (prefix + suffix account for every OLD slot; the new
+ /// list only ADDS items in the middle): the inserted slots get EMPTY
+ /// baselines, so they first-render at their own offset and the cut
+ /// lands at the insertion point. This covers the dominant case — a new
+ /// transcript entry appended just before the pinned input box + footer
+ /// suffix. The frame stays on the differential path with NO clear, and
+ /// `force_full` is deliberately NOT set; that is the whole point.
+ /// - STRUCTURAL CHANGE (a slot in the changed region is REPLACED or
+ /// REMOVED — an override swap or a transcript removal): `force_full` is
+ /// set. Correctness for scrolled content is still guaranteed by the
+ /// viewport-escape rule in `render` (a cut above `viewport_top`
+ /// escalates to a clearing redraw); only the rare mid-list mutation
+ /// pays that cost, not every append.
+ pub fn syncComponents(self: *Engine, comps: []const Component) !void {
+ const old = self.slots.items;
+
+ // Longest common prefix by component pointer.
+ var pre: usize = 0;
+ const pre_max = @min(old.len, comps.len);
+ while (pre < pre_max and old[pre].comp.ptr == comps[pre].ptr) pre += 1;
+
+ // Longest common suffix, not overlapping the prefix on either side.
+ var suf: usize = 0;
+ const suf_max = @min(old.len - pre, comps.len - pre);
+ while (suf < suf_max and
+ old[old.len - 1 - suf].comp.ptr == comps[comps.len - 1 - suf].ptr) suf += 1;
+
+ // OLD slots in [pre, old.len - suf) are replaced/removed; NEW comps in
+ // [pre, comps.len - suf) are inserted. A pure insertion replaces zero
+ // old slots; anything else is a structural change.
+ const old_changed = old.len - suf - pre; // old slots dropped
+ const structural_change = old_changed != 0;
+
+ // Detach the preserved suffix slots (with their baselines) before we
+ // mutate the middle, then re-attach them after the inserted slots.
+ var suffix_slots: std.ArrayList(Slot) = .empty;
+ defer suffix_slots.deinit(self.alloc);
+ try suffix_slots.ensureTotalCapacity(self.alloc, suf);
+ {
+ var k: usize = 0;
+ while (k < suf) : (k += 1) suffix_slots.appendAssumeCapacity(old[old.len - suf + k]);
+ }
+
+ // Pop everything from the divergence point down to the prefix. The
+ // top `suf` entries are the preserved suffix (copied out above — pop
+ // WITHOUT freeing their lines, they will be re-attached). The next
+ // `old_changed` entries are genuinely replaced/removed — free them.
+ var popped: usize = 0;
+ while (self.slots.items.len > pre) : (popped += 1) {
+ var removed = self.slots.pop().?;
+ if (popped >= suf) self.freeSlotLines(&removed);
+ }
+
+ // Insert the new middle components with empty baselines, then restore
+ // the preserved suffix slots.
+ for (comps[pre .. comps.len - suf]) |comp| {
+ try self.slots.append(self.alloc, .{ .comp = comp });
+ }
+ for (suffix_slots.items) |slot| try self.slots.append(self.alloc, slot);
+
+ if (structural_change) self.force_full = true;
+ }
+
// -- resize ------------------------------------------------------------
/// Apply a new terminal size. A width change (wrapping changes) or height
@@ -1399,6 +1482,99 @@ test "change above viewport_top forces a full redraw" {
try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null);
}
+test "syncComponents: middle insertion before a pinned suffix does NOT clear scrollback (the expanded-tools flash fix)" {
+ // The regression: the app rebuilds its component list on every new
+ // transcript entry. The desired list is [..transcript.., input, footer],
+ // so a new entry is INSERTED just before the pinned input+footer suffix.
+ // The old drain+rebuild reset every baseline and forced a full redraw;
+ // with scrolled content (viewport_top > 0) that escalated to a
+ // scrollback-CLEARING redraw — the visible flash. syncComponents must keep
+ // the unchanged prefix/suffix baselines and stay on the differential path.
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ // Viewport tall enough that the insertion point (transcript tail, just
+ // above the 2-line pinned suffix) stays visible, but short enough that the
+ // tall tool body has scrolled some lines into native scrollback
+ // (viewport_top > 0). This mirrors the real app: the new entry lands at
+ // the bottom, below viewport_top, so it MUST stay on the differential path.
+ var eng = makeEngine(&buf, 80, 6);
+ defer eng.deinit();
+
+ // A tall "tool" body, plus pinned input + footer (the suffix). Each
+ // unchanged component reports firstLineChanged 0 on first paint, then null
+ // (clean) on later frames — the real RenderCache contract. Preserved slots
+ // must therefore contribute NO cut, so the insert stays at the tail.
+ var tool = FakeComponent{ .scripts = &.{ &.{ "t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7" }, &.{ "t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7" } }, .first_changed = &.{ 0, null } };
+ var input = FakeComponent{ .scripts = &.{ &.{"input"}, &.{"input"} }, .first_changed = &.{ 0, null } };
+ var footer = FakeComponent{ .scripts = &.{ &.{"f0"}, &.{"f0"} }, .first_changed = &.{ 0, null } };
+ try eng.syncComponents(&.{ tool.comp(), input.comp(), footer.comp() });
+ try eng.render(); // first paint
+ try testing.expect(eng.viewport_top > 0);
+
+ // Advance the preserved components to their clean (null) signal.
+ tool.advance();
+ input.advance();
+ footer.advance();
+
+ // Insert a new entry between the tool body and the pinned input/footer.
+ var entry = FakeComponent{ .scripts = &.{&.{ "new0", "new1" }}, .first_changed = &.{0} };
+ buf.clearRetainingCapacity();
+ try eng.syncComponents(&.{ tool.comp(), entry.comp(), input.comp(), footer.comp() });
+ try eng.render();
+
+ const out = buf.written();
+ // The inserted content is drawn, but WITHOUT a scrollback wipe.
+ try testing.expect(std.mem.indexOf(u8, out, "new0") != null);
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+ // The unchanged tool body was NOT re-rendered (its baseline survived):
+ // only the first paint rendered it.
+ try testing.expectEqual(@as(usize, 1), tool.render_calls);
+}
+
+test "syncComponents: removal in the middle forces a full redraw (structural change)" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100); // tall: nothing scrolls
+ defer eng.deinit();
+
+ var a = FakeComponent{ .scripts = &.{&.{"a"}}, .first_changed = &.{0} };
+ var b = FakeComponent{ .scripts = &.{&.{"b"}}, .first_changed = &.{0} };
+ var c = FakeComponent{ .scripts = &.{&.{"c"}}, .first_changed = &.{0} };
+ try eng.syncComponents(&.{ a.comp(), b.comp(), c.comp() });
+ try eng.render();
+ try testing.expectEqual(@as(usize, 3), eng.componentCount());
+
+ // Remove the middle component => structural change => force_full.
+ buf.clearRetainingCapacity();
+ try eng.syncComponents(&.{ a.comp(), c.comp() });
+ try testing.expect(eng.force_full);
+ try eng.render();
+ try testing.expectEqual(@as(usize, 2), eng.componentCount());
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, "b") == null);
+}
+
+test "syncComponents: replacing a slot (override swap) resets only that baseline" {
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100);
+ defer eng.deinit();
+
+ var a = FakeComponent{ .scripts = &.{&.{"a"}}, .first_changed = &.{0} };
+ var b = FakeComponent{ .scripts = &.{&.{"b"}}, .first_changed = &.{0} };
+ var b2 = FakeComponent{ .scripts = &.{&.{"b2"}}, .first_changed = &.{0} };
+ try eng.syncComponents(&.{ a.comp(), b.comp() });
+ try eng.render();
+
+ // Swap b -> b2 at the same position: structural change (pointer differs).
+ buf.clearRetainingCapacity();
+ try eng.syncComponents(&.{ a.comp(), b2.comp() });
+ try testing.expect(eng.force_full);
+ try eng.render();
+ const out = buf.written();
+ try testing.expect(std.mem.indexOf(u8, out, "b2") != null);
+}
+
test "width overflow is a hard error" {
var buf = std.Io.Writer.Allocating.init(testing.allocator);
defer buf.deinit();