From cee08918509689adbdbd26b6384d5ef3a47e91a2 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 10 Jun 2026 14:57:31 -0600 Subject: fix: eliminate scrollback-clearing flash when streaming with expanded tool output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lua_event_bridge.zig | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) (limited to 'src/lua_event_bridge.zig') 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()); -- cgit v1.3