fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws - #1139
Merged
Conversation
…ing redraws (#1135) Off-screen transcript entries live in terminal scrollback, which is immutable. Five event paths mutated their rendered lines in place, forcing pi-tui's differential renderer into a full redraw (ESC[2J ESC[H ESC[3J) that clears pre-Maka scrollback and resets the scroll position: - refreshRunningShellRunElapsed: 1s ticker rewrote off-screen running cards every second - shell-run settle/ownership: applied status/result changes to off-screen Bash cards - setThinking: late thinking_complete replaced streamed text in place - tool_result poll-card splice: removed an off-screen entry, shifting subsequent line numbers - tool_progress/tool_output_delta: appended to expanded off-screen cards The fix introduces a single render-layer freeze: when an entry's first line sits above the live viewport (already recorded by #1130's renderGeometry), renderTranscriptEntryMemoized serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen, so session switch, resume, and re-expand stay correct. The splice path gets a separate entryInLiveViewport guard: splicing an off-screen entry shifts line numbers above the viewport, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch. The ticker skips off-screen running cards entirely, so refreshRunningShellRunElapsed returns false when no visible card needs an update and the 1s interval stops.
Codex review found three issues in the initial freeze approach: P1: Freezing an entry whose first line is in scrollback but whose tail is still visible stopped streaming text from appearing. The invariant is that scrollback lines are immutable, not that the entire entry is frozen. Now only entries sitting entirely above the viewport (firstLine + height <= viewportTop) are frozen; straddling entries re-render normally — append-only changes fall inside the viewport, so pi-tui's firstChanged never touches scrollback. P2: The ticker skip stopped the 1s interval when all running cards were off-screen, but a resize making them visible again never restarted it. Removed the skip: the render freeze already prevents visual changes for off-screen cards, and the extra render cycle is negligible. P2: The splice guard left off-screen poll cards as stale 'running' entries. A width-change full redraw would re-render them as duplicate running cards. Now the guard also updates the entry's status to 'done' with the folded result, so a future full redraw renders it correctly. Added a streaming-past-viewport test that verifies a long assistant reply keeps appending visible content after crossing the viewport boundary, with no ESC[3J.
…review Two findings from the second Codex review: 1. Zero-height cache gap: a blank thinking_delta renders zero lines; when pushed off-screen and then thinking_complete writes non-empty text, the entryHeight=0 check prevented the freeze, inserting new lines into scrollback. Now entries whose first line is above the viewport are frozen regardless of cached height. 2. Poll card duplication: off-screen Read/StopBackgroundTask poll cards that were kept in place (to avoid line-number shifts) but marked done would re-render as duplicate cards on a width-change full redraw. Now they get a hidden flag that makes them contribute zero lines, so a future full redraw skips them entirely. They are cleaned on the next session switch.
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #1135.
Five transcript event paths mutated the rendered lines of entries that had scrolled above pi-tui's live viewport. Any such change made pi-tui's differential renderer hit its
firstChanged < viewportToppath and emitESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the scroll position.The invariant #1130 established for toggles should own all of these: the rendered prefix of off-screen entries must stay frozen.
The fix is a single render-layer freeze point plus two targeted guards:
Render freeze —
renderTranscriptEntryMemoizedchecks whether the entry's first line sits above the live viewport (using fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback #1130'srenderGeometry). If so, it serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen. This covers the ticker, settle/ownership updates,thinking_completereplacement, andtool_progress/tool_output_deltaappends.Splice guard — poll-card removal (
tool_resultfold for Read/StopBackgroundTask) gets anentryInLiveViewportcheck: splicing an off-screen entry shifts subsequent line numbers, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch.Ticker skip —
refreshRunningShellRunElapsedskips off-screen running cards, so it returnsfalsewhen no visible card needs an update and the 1s interval stops automatically.Verification
packages/cli:tsc --noEmitclean,npm test465/465 (3 new), rootnpm run lintclean.ESC[3Jfor: off-screen running-Bash ticker updates (2.5s wait past two ticks), off-screen shell-run settle, and off-screenthinking_complete.pi-tui-layout.test.ts: the "in-place edit above the top" sub-case now expectsviewportTopto stay put (the freeze prevents the rendered change from reaching the shadow diff), matching the new invariant.