Repository navigation
Conversation
… viewport In regular mode the terminal owns scrolling, but a frame that changed a row above the viewport and also changed the document length replayed the whole document: the 2J/H/3J clear sequence erases scrollback, so a reader who had scrolled up was thrown back to the top as soon as generation settled. The settled-message frame is exactly that shape. With hideThinkingBlock enabled renderThinkingRows inserts a summary row and a blank row above the message, and the same frame usually turns the dock's status line into "Done" and updates the footer usage. Slide the window instead of replaying it. A length change above the viewport leaves the rows below it aligned: - no visible row changed: the frame writes nothing and only catches the bookkeeping up, moving the hardware cursor's document row with the window; - a few visible rows changed (the dock lines): the frame rewrites exactly those rows with absolute cursor moves, so nothing scrolls and scrollback survives; - the first visible row changed, the window moved as a whole, or rows entered or left it: the frame still replays the document, because the rows that left cannot be put into scrollback in order without writing them again. The in-place repair is bounded by IN_PLACE_WINDOW_EDIT_LIMIT and refuses a changed first row, which is how a slide looks; a misjudged frame therefore falls back to a replay instead of dropping rows. The full replay now runs the same over-wide line guard as the differential writer, so an over-wide frame fails loudly with tui-crash.log instead of silently wrapping. Trade-offs are documented in packages/tui/README.md and docs/tui-rendering-pipeline.md: rows changed above the window never reach terminal history, and a document that shrinks leaves its removed rows in scrollback. Tests cover the settle shape with a dock status change, a window that really moves, growth beyond one screen, a shrink below one screen, and the over-wide line. The regression test flushes the terminal before scrolling it, since scrollLines() used to move a buffer that did not hold the document yet.
GJ0727
requested review from
MelodyVAR,
Uking-xxx,
ZouR-Ma and
liuedcson
as code owners
October 22, 2026 03:05
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.
Problem
In the default regular TUI mode a user who scrolls the terminal viewport up to read history while a long response streams gets thrown back to the very top of the transcript when generation completes.
Root cause: TuiMainScreen.doRender treated "a changed row above the previous viewport and a document-length change" as a reason to run fullRender(true). That path writes \x1b[2J\x1b[H\x1b[3J — it erases terminal scrollback as well as the screen — and replays the whole document. The renderer cannot observe the terminal's own scroll offset, so clearing scrollback destroys the reader's position and clamps the viewport to row 0 of the replayed buffer.
The settle frame of an assistant message is that shape: with hideThinkingBlock enabled, renderThinkingRows inserts a summary row plus a blank row above the message, and the same frame usually also updates the dock (status line turning into "Done", footer usage). Streaming frames before it only append, which is why the jump appears only "when generation finishes".
In regular mode the terminal owns scrolling and there is no app-level scroll state to restore, so packages/tui/README.md's promise that TuiMainScreen "preserves terminal scrollback" is the only contract that protects the reader — and this path violated it.
Changes
packages/tui/src/tui-main-screen.ts: the above-viewport branch now separates a length change that lands entirely above the window from one that moves the window itself.
Every visible row kept its content: the frame writes nothing and catches the bookkeeping up — previousLines, previousViewportTop, maxLinesRendered, the cursor row, and hardwareCursorRow, which moves with the window so the physical cursor keeps its row while its document line changes.
Only a few visible rows changed (inPlaceWindowEdits, bounded by IN_PLACE_WINDOW_EDIT_LIMIT): the rows below the change still line up, so the frame rewrites exactly those rows with absolute cursor moves (repaintVisibleRows). Nothing scrolls and scrollback survives.
Anything else — the window moved as a whole, or rows were added to or removed from it — replays the document. The rows that left the screen cannot be put into scrollback in order without writing them again, and guessing drops or duplicates them.
The full replay now runs the same over-wide-line guard as the differential writer (assertLineFitsWidth), so an over-wide first frame writes tui-crash.log and throws instead of silently wrapping.
packages/tui/test/main-screen-offscreen-change.test.ts: the regression test flushes the terminal before scrolling it (without that flush scrollLines() moved a buffer that did not hold the document yet, so "the reader scrolled up" was never exercised). It now asserts the scroll took effect, drives the settle shape with a dock status change, and inspects the renderer's mirror directly so a stale previousLines/previousViewportTop is caught instead of being papered over by the next replay. Other tests cover replaying a window that moved, a whole-screen growth, a shrink below one screen, and an over-wide line.
packages/tui/test/virtual-terminal.ts: adds scrollLines() and getViewportTop() so tests can simulate a reader scrolling the terminal's viewport (xterm headless API).
packages/tui/test/tui-render.test.ts: unchanged from main — shrinking still replays the document, as before this change.
packages/tui/README.md, docs/tui-rendering-pipeline.md: describe the strategy and its trade-offs, and re-anchor the file:line references that moved.
apps/cli/src/ui/view/chrome/step-logo.ts: the comment referenced scrollback-guard.ts, which does not exist in this tree; it now describes the mechanism that actually exists in the renderer.
Behavior and compatibility
No public interface, configuration key, persisted state, prompt, or tool contract changes. TuiMainScreen bytes change only for frames that used to clear scrollback.
Trade-offs, inherent to a terminal buffer the app cannot rewrite retroactively:
rows changed above the window never reach terminal history; a reader scrolling up sees the old copy, and rows inserted there are not in history at all;
a document that shrinks keeps its removed rows in scrollback, so a reader scrolling up may see a short duplicate stretch;
clearOnShrink (off by default) and the "all changes are in deleted lines" fallback still clear scrollback, as before.
The in-place repair only engages when the visible window still lines up and at most IN_PLACE_WINDOW_EDIT_LIMIT rows differ; a moved window exceeds that budget, so a misclassification fails towards a replay rather than towards lost rows.
fullscreen mode (TuiAltScreen, ScrollView) is untouched.
Validation
Platform: macOS (darwin arm64), Node.js v24.19.0, pnpm 9.15.9.
pnpm install --frozen-lockfile --ignore-scripts — clean install.
cd packages/tui && node --test test/*.test.ts — 941 tests, 941 passed, 0 failed.
cd apps/cli && ./node_modules/.bin/vitest --run test/edit-tool-no-full-redraw.test.ts test/tui-acceptance-interactions.test.ts test/interactive-mode-status.test.ts — 59 passed.
pnpm run check — passed (biome, tsgo --noEmit, layer/UI/registry/public-boundary/browser-smoke and the remaining boundary checks).
Regression tests do fail on main ("must not replay the document") and are not vacuously green: dropping the bookkeeping update in the skip branch, or leaving previousViewportTop stale, each fails a test (verified by mutating the source in a worktree).
Repro with TuiMainScreen + VirtualTerminal(60, 20), a 62-row document, reader scrolled up (viewportY 27), then one row rewritten above the viewport plus a one-line append: before this change \x1b[3J was written and viewportY went 27 → 0; now no \x1b[3J, viewportY stays 27, and the viewport is byte-identical.
./test.sh — did not finish green on this machine; both attempts failed at a different environment-only packages/coding-agent test that passes standalone. Details in Limitations.
Limitations
./test.sh did not produce a fully green run on this machine across two attempts, and each attempt failed at a different packages/coding-agent test that passes when run on its own:
attempt 1: test/subagent-invocation.test.ts — EINVAL: listen on a tsx IPC pipe under test.sh's isolated TMPDIR; passes standalone (4/4).
attempt 2 (short TMPDIR): test/footer-data-provider.test.ts > does not notify listeners when reftable updates keep the same branch — "Timed out waiting for condition" under the isolated git environment (GIT_CONFIG_GLOBAL=/dev/null); passes standalone twice (8/8). Everything else in those runs passed: packages/coding-agent 4,856 passed / 59 skipped / 1 failed, and the packages/tui suite when run directly. The failing files and the process/git machinery they exercise are untouched by this change; CI (Ubuntu + Node 22) remains the authority for that gate.
Manual TUI verification in a real terminal (./step-test.sh --no-env with a long reasoning session) was not performed. The settle shape is covered by the virtual-terminal regression and by an out-of-tree probe using the real StepAssistantMessageComponent; real terminals may still differ in how they treat a user's scroll offset when control sequences arrive.
Only regular mode was reproduced. fullscreen was not exercised manually, though its tests pass.
The in-place budget (IN_PLACE_WINDOW_EDIT_LIMIT = 3) is a heuristic: it separates a few edited rows from a moved window, and no terminal state can prove the difference. A differing first visible row is treated as a slide (the row that left cannot be recovered), so such frames replay; frames that exceed the budget replay too. Measured against the real StepAssistantMessageComponent with the dock, a settle frame changes two visible rows, so the budget has one row of headroom.
One known frame still replays: with a status tip open, a status indicator shrinking from two rows to one inside the window moves the whole window, so the scrollback is cleared as before. Whether message_end and agent_end really land in one render frame was not observed in a live session.