Skip to content

fix(tui): stop the scrollbar from blanking the transcript on resize - #775

Merged
ericleepi314 merged 1 commit into
mainfrom
fix/tui-resize-blank-transcript
Jul 31, 2026
Merged

fix(tui): stop the scrollbar from blanking the transcript on resize#775
ericleepi314 merged 1 commit into
mainfrom
fix/tui-resize-blank-transcript

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

Resizing the terminal blanked the whole transcript — composer and footer kept painting, the transcript region went empty, and it never came back. Reported in the VS Code integrated terminal.

Root cause

TranscriptScrollbar is a row sibling of the transcript ScrollBox and renders exactly viewportHeight rows tall — and that value is last frame's scrollViewportHeight, written by render-node-to-output at paint time.

A flex row stretches its children to the tallest one, so a stale-tall bar stretches the ScrollBox; its inner wrapper (flexGrow:1) fills the extra with blank rows; and the stretched height becomes the next frame's scrollViewportHeight, which re-renders the bar at that same height. A stable fixed point, not a transient — which is why it never recovers.

Widening re-wraps every row shorter, so the gap opens exactly then. Measured on a PTY at 60 → 110 columns: content fell 161 → 102 rows while the ScrollBox stayed at 161. Those ~59 blank rows push the transcript above the terminal viewport, where the resize repaint's ERASE_SCROLLBACK (log-update fullResetclearTerminal) wipes it from scrollback.

Fix

if (!vp)if (!vp || INLINE_MODE) in both scrollbars.

Inline mode is the default and has no constrained-height root, so the ScrollBox always fits its content and total <= vp holds — the bar only ever took its !scrollable branch and painted a column of invisible spaces, 40 rows of them. The 1-column gutter stays, so transcriptPanelWidth's reservation still matches what's drawn. Fullscreen pins the root to terminalRows, can't drift, and the bar is real UI there — untouched.

OverlayScrollbar (agentsOverlay.tsx) has the identical shape and the same bug, so it gets the same guard. Its minHeight={0} doesn't help: that permits shrinking below content, it doesn't stop cross-axis stretch.

Ruled out by experiment

Recorded so nobody retries them:

  • Recursively marking every Yoga node dirty before calculateLayout — so this is not a layout-cache bug.
  • minHeight={0} on the ScrollBox.
  • minHeight={0} on the ScrollBox plus both ancestor flex boxes.
  • Keying the ScrollBox on cols to force a remount. It fixes the symptom but orphans three useSyncExternalStore subscriptions on the dead handle and cold-starts the virtualizer. The three call sites now carry a comment explaining why their stable deps are only safe while the ScrollBox never remounts.

Verification

  • PTY + pyte against the real binary: blank rows after a 60 → 110 resize went 20-22/30 → 7-8/30, holding for a ~35-row transcript. Only widening exhibits the bug; narrowing grows content past the stale-short bar, so there's no gap to open.
  • Regression test asserts ROWS, not glyphs — the inline case paints spaces, so "draws no bar characters" passes against the bug. Self-calibrates against a bar-free render, and waits for a settled frame rather than a fixed timeout (which would fail open, since with the fix the bar paints nothing). Mutation-tested: reverting the guard fails 2 of its 3 cases.
  • Suite: 5 consecutive full runs at 8 failed / 1608 passed. main's baseline is 8 failed / 1605 passed — same 8 pre-existing failures, +3 from this PR, no flake.
  • tsc --noEmit clean, eslint clean on all changed files.

Follow-up, not in this PR

ERASE_SCROLLBACK (ESC[3J) fires on every resize (log-update.ts:188clearTerminal.ts:63). In inline mode a full repaint needs only ESC[2J + cursor home; the ESC[3J additionally destroys the user's pre-TUI shell scrollback and every transcript row that scrolled off, on every window drag. It survives this fix — it's what upgraded "scrolled off screen" to "unrecoverable".

🤖 Generated with Claude Code

Resizing the terminal blanked the whole transcript: composer and footer kept
painting, the transcript region went empty, and it never came back.

TranscriptScrollbar is a ROW SIBLING of the transcript ScrollBox and renders
exactly `viewportHeight` rows tall — where that value is LAST frame's
scrollViewportHeight, written by render-node-to-output at paint time. A flex row
stretches its children to the tallest one, so a stale-tall bar stretches the
ScrollBox; its inner wrapper (flexGrow:1) fills the extra with blank rows; and
the stretched height becomes the next frame's scrollViewportHeight, which
re-renders the bar at that same height. A stable fixed point, not a transient.

Widening re-wraps every row shorter, so the gap opens exactly then. Measured on
a PTY at 60 -> 110 columns: content fell 161 -> 102 rows while the ScrollBox
stayed at 161. Those ~59 blank rows push the transcript above the terminal
viewport, where the resize repaint's ERASE_SCROLLBACK (log-update fullReset ->
clearTerminal) wipes it from scrollback — so it is gone, not merely scrolled off.

Inline mode is the default and has no constrained-height root, so the ScrollBox
always fits its content and `total <= vp` holds: the bar only ever took its
!scrollable branch and painted a column of invisible SPACES. Suppress it there.
The 1-column gutter stays so transcriptPanelWidth's reservation still matches
what is drawn. Fullscreen pins the root to terminalRows and cannot drift, and
the bar is real UI there, so it is untouched.

OverlayScrollbar in agentsOverlay.tsx has the identical shape and the same bug;
it gets the same guard. Its minHeight={0} does not help — that permits shrinking
below content, it does not stop cross-axis stretch.

Ruled out by experiment, recorded so nobody retries them: recursively marking
every Yoga node dirty before calculateLayout (so this is NOT a layout-cache
bug), minHeight={0} on the ScrollBox, and minHeight={0} on the ScrollBox plus
both ancestor flex boxes.

The regression test asserts ROWS, not glyphs — the inline case paints spaces, so
"draws no bar characters" passes against the bug. It self-calibrates against a
bar-free render and waits for a settled frame rather than a fixed timeout, which
would fail open. Mutation-tested: reverting the guard fails 2 of its 3 cases.

Also comments the three useSyncExternalStore call sites whose stable deps are
only safe because the ScrollBox never remounts — remounting one orphans every
subscription on the dead handle, which is why keying it on `cols` is not a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Test Results

    1 files      1 suites   7m 59s ⏱️
9 376 tests 9 370 ✅ 6 💤 0 ❌
9 622 runs  9 616 ✅ 6 💤 0 ❌

Results for commit df7f823.

@ericleepi314
ericleepi314 merged commit 7d9ce61 into main Jul 31, 2026
3 checks passed
ericleepi314 added a commit that referenced this pull request Aug 3, 2026
An earlier attempt at the resize-blanking bug keyed the transcript ScrollBox on
`cols` to force a fresh Yoga node. It was abandoned once the real cause turned
out to be TranscriptScrollbar stretching the row from a stale viewportHeight
(fixed in #775), but it had already been swept into #774 and reached main.

It is not merely redundant now, it is harmful. Remounting the ScrollBox builds a
new listenersRef Set behind useImperativeHandle(..., []), while the three
useSyncExternalStore subscribers (useVirtualHistory, useViewportSnapshot,
useScrollbarSnapshot) have stable deps and never resubscribe — so every
subscription is orphaned on the dead handle, on every resize. It also resets the
handle, sending useVirtualHistory down its `vp <= 0` cold-start branch, which on
a long transcript mounts only the last 30 items behind a large blank topSpacer.

Its comment was also wrong about the mechanism, which is worse than no comment:
it told the next reader the vendored Yoga port fails to re-measure and that
marking the tree dirty is insufficient. Neither is true.

Verified the resize fix still holds without it: PTY at 60 -> 110 columns, blank
rows 7/30 before and after, matching #775's numbers. Suite unchanged from main's
baseline at 8 failed / 1681 passed across three runs.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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