diff options
| author | t <t@tjp.lol> | 2026-06-10 14:57:31 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-10 15:28:16 -0600 |
| commit | cee08918509689adbdbd26b6384d5ef3a47e91a2 (patch) | |
| tree | ce2c6bd2269a086fa34edc682696bdf12d9e5b23 /src/tui_components.zig | |
| parent | ea4bff482374fa8bce38636311fa582e3c87ad47 (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_components.zig')
| -rw-r--r-- | src/tui_components.zig | 64 |
1 files changed, 36 insertions, 28 deletions
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" { |
