Skip to content

fix(tui): keep scrollback when the document grows above the viewport - #292

Open
xdimtech wants to merge 1 commit into
stepfun-ai:mainfrom
xdimtech:fix/tui-streaming-scrollback
Open

xdimtech wants to merge 1 commit into
stepfun-ai:mainfrom
xdimtech:fix/tui-streaming-scrollback

Conversation

@xdimtech

@xdimtech xdimtech commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

While a response streams in regular (main-screen) mode, terminal history disappears and only comes back after the window is resized. It reproduces reliably in iTerm2 in a tall native-fullscreen window.

Root cause: in TuiMainScreen.doRender, the viewport clamp handles "the first changed row is above the previous viewport". When the document length also changed in that frame, it escalated to fullRender(true). That clears the screen and scrollback (\x1b[2J\x1b[H\x1b[3J) and replays the whole document.

During streaming that condition holds on almost every frame. A running tool's row has scrolled above the viewport, and its spinner/elapsed clock ticks every 200 ms (STEP_SPINNER_INTERVAL_MS) while tokens are appended below. With the headless VirtualTerminal, 120 streamed frames with a 5 Hz row above the viewport caused 5 scrollback wipes. A long message whose first row also changes caused a wipe on every frame (29 of 30). In iTerm2 the user sees history vanish while each replay is written. \x1b[3J also deletes the shell output from before Step started, and that part never comes back.

Changes

packages/tui/src/tui-main-screen.ts: the clamp now escalates to a full redraw only when the document shrank (or a Kitty image block straddles the clamp point, as before). It no longer escalates on any length change.

Growth keeps the viewport mapping intact. The tail moves down by the appended line count, and rewriting the visible window from the clamp point sends exactly that many line feeds through the bottom row. The terminal therefore scrolls as far as a plain append would. A shrink still needs the redraw, because rows left the document and the terminal cannot scroll back.

fullRender itself is untouched. Resizes, clearOnShrink, Ctrl+L and image fallbacks behave exactly as before.

Tests (packages/tui/test/main-screen-offscreen-change.test.ts):

  • The old "length changes above the viewport → full redraw" case now asserts that growth stays on the differential path, with no \x1b[3J.
  • New: streaming shape. A row above the viewport changes every frame while the document grows for 40 frames. Asserts no full redraws, no \x1b[3J, history still in scrollback, and the footer on the last row.
  • New: a shrink above the viewport still falls back to a full redraw.

docs/tui-rendering-pipeline.md: section 5, step 8 now describes the new clamp rule.

Behavior and compatibility

  • The bytes written for every viewport are unchanged. I compared old and new renderers frame by frame with randomized streaming (spinner row above the viewport, growth by small and multi-screen jumps, random shrinks, edits to visible rows) at 80×24, 40×10, 120×50 and 30×5. That is 4,000 frames, and every viewport was identical.
  • Final scrollback is identical except for the row above the viewport that kept changing. It now keeps the content it had when it scrolled off, e.g. an older spinner frame instead of the latest one. That is the same trade-off the existing same-length clamp already makes. Without an animated row above the viewport, the scrollback is byte-identical.
  • In the same comparison, full redraws while streaming with an animated row above the viewport dropped from 302–376 to 34–58 per scenario. The remaining ones are shrinks, which this change doesn't touch.
  • No settings, public interfaces, persisted data or fullscreen (TuiAltScreen) behavior change.

Validation

All of the following ran on Linux, on top of stepfun-ai/Step-Code@dff413f:

  • pnpm run check: passed, and Biome left the working tree unchanged.
  • node --test test/*.test.ts in packages/tui: 942 passed, 0 failed.
  • npx vitest run test/edit-tool-no-full-redraw.test.ts in apps/cli: 3 passed.
  • pnpm test: passed (exit 0). Script tests 32/32; packages/tui 942/942; apps/cli 746/746; packages/coding-agent 4900 passed / 55 skipped; packages/agent-core 539 passed / 1 skipped; packages/providers 461 passed / 6 skipped; contracts, config, telemetry and lark-adapter all passed.
  • Built step with scripts/build-binaries.sh --platform linux-x64 --offline-model-data, installed it with infra/release/install.sh, and ran it in a 120×40 tmux pane. The test streamed 300 lines through ! shell output, then resized the pane height (40 → 28 → 45). The welcome block and the streamed rows stayed in history throughout.

Limitations

  • I couldn't run iTerm2 itself in this environment. The fix targets the escape sequences that caused the problem (the per-frame \x1b[2J…\x1b[3J replay), and those were verified gone in xterm.js and tmux. Confirmation in iTerm2 native fullscreen is still pending.
  • Unchanged by design: a resize or clearOnShrink still does a full redraw, which clears scrollback as before.

🤖 Generated with Claude Code

When a row above the viewport changed in the same frame that the document
grew, the main-screen renderer escalated to a full redraw, which clears the
screen and scrollback (CSI 2J + CSI 3J) and replays the whole document.
While a response streams, a running tool's spinner row sits above the
viewport and ticks every 200 ms as tokens are appended, so this ran several
times a second. In iTerm2, especially in a tall native-fullscreen window,
history disappeared during streaming and came back only after a resize.

Growth does not break the viewport mapping: the tail moves down by the
appended line count, and rewriting the visible window from the clamp point
sends that many line feeds through the bottom row, so the terminal scrolls
as far as a plain append would. Only a shrink still needs the full redraw,
because rows left the document and the terminal cannot scroll back.

The per-frame viewport output is unchanged. As with the existing same-length
clamp, rows that already scrolled into scrollback keep the content they had
when they left the screen.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant