Terminal: spend one reflow per layout switch, not several - #3
Open
thomwolf wants to merge 1 commit into
Open
Conversation
Switching the pane layout left broken history in the scrollback. PR #2 fixed the multi-device half of this (two clients fighting over one tmux window) but not this half, because the corruption doesn't need a second device: resizing a real agent TUI under tmux with NO client attached corrupts it just the same. Agent TUIs paint into the normal buffer, so on SIGWINCH they re-emit their frame with erase math computed at the OLD width and the rows they miss stay behind. Measured against a live `claude` (200x50 -> 70x50 -> 200x50 -> 70x50 -> 200x50): nonblank scrollback rows 10 -> 12, orphan border rows 1 -> 3, and after a few flips whole lines of real text are gone outright. Two measurements shaped the fix: - Only WIDTH costs history. Four height-only changes left the scrollback byte-identical, so the mobile keyboard, the key bar and vertical zoom were never implicated. - Damage scales with the NUMBER of width changes, not the size of the jump. Same start and end width, five steps vs one: +9 junk rows and 1->7 orphan borders, against +2 and 1->2. So one user-visible layout switch must cost exactly one width change. It was costing several, because resync() had neither debounce nor dedup: - fit() mutates the element the ResizeObserver watches, so it re-fires. - A tab regaining focus sent a resize unconditionally. - Every connect sized tmux twice: the URL's cols/rows, then an unconditional send in onopen. - Geist Mono is font-display:swap, so a cold load's first fit measured the FALLBACK font: the pane attached at the wrong width, and the fonts.ready handler then refit locally WITHOUT telling tmux, leaving xterm and tmux disagreeing about the width until something else happened to send one. Now every fit routes through one place that pushes the size which SETTLED, and only when it differs from what tmux already has. The first attach waits (capped) for real font metrics instead of correcting a glyph-width guess against a session that has been running all along, and takeBack() measures before reattaching so resuming on a resized device costs one reflow instead of two. A deliberate layout switch still costs its one reflow. Reaching zero means not reflowing at all -- pinning cols and scaling the font -- but at two panes across that turns 13px text into ~6px, so it is left alone. A clean break before the resize was tried too (tmux's ED(2) does push the screen into history losslessly), but Ctrl+L changed nothing measurable, so no keystroke injection. Verification: tsc --noEmit and vite build pass. The coalescing logic was exercised against a model of the new path -- one layout switch sends one resize; focus churn, the observer echo, and a box that wanders and returns send none; real settled changes and the handed-off guard still behave. Not exercised: the browser-side triggers themselves (ResizeObserver, focus, visibilitychange, the font swap) were verified by reading and by modelling, not by driving a real browser against a Space. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
The problem
Switching the pane layout still left broken history in the scrollback — orphaned frame fragments at mixed widths, and after a few flips whole lines of real text simply gone.
#2 fixed the multi-device half of this (two clients fighting over one tmux window). It could not fix this half, because the corruption doesn't need a second device. Resizing a live
claudeunder tmux with no client attached at all corrupts it exactly the same way:Agent TUIs paint into the normal buffer, so on SIGWINCH they re-emit their frame with erase math computed at the old width, and the rows they miss stay behind. The resize itself is the cost — #2 removed one source of resizes.
Two measurements shaped the fix
Only width costs history. Four height-only changes left the scrollback byte-identical:
So the mobile keyboard, the key bar and vertical zoom were never implicated.
Damage scales with the number of width changes, not the size of the jump. Same start and end width, different paths:
Why one layout switch was costing several width changes
resync()had neither debounce nor dedup:fit()mutates the element theResizeObserverwatches, so it re-fires — the fit echoes itself.cols/rows, then an unconditional send inonopen.Geist Monoisfont-display:swap, so a cold load's first fit measured the fallback font. The pane attached at the wrong width, and the existingfonts.readyhandler then refit locally without telling tmux — leaving xterm and tmux disagreeing about the width until something else happened to send one. That's the "wrong sizes" half of the report.The change
All of it in
TerminalPane.tsx:pushSize()/resync()— every fit routes through one place that pushes the size which settled (RESIZE_SETTLE_MS = 180), and only when it differs from what tmux already has. One layout switch → one resize; the observer echo, a tab focus, and a box that wanders and returns → none.onopen— records the size the attach already gave tmux instead of re-sending it, thenresync()corrects it once if the box settled elsewhere meanwhile.resync().takeBack()— measures before reattaching, so resuming on a device that changed size costs one reflow instead of two.What is deliberately left
A deliberate layout switch still costs its one reflow (+2 junk rows). Reaching zero means not reflowing at all — pinning cols and scaling the font — but at two panes across that turns 13px text into ~6px, so it is left alone.
A clean break before the resize was also tried: tmux's ED(2) does push the visible screen into history losslessly (verified), so clearing before the SIGWINCH should in principle give one clean copy and a clean repaint.
Ctrl+Lchanged nothing measurable, so there is no keystroke injection here.Verification
tsc --noEmitandvite buildpass.The coalescing logic was exercised against a model of the new path:
Not exercised: the browser-side triggers themselves (
ResizeObserver,focus,visibilitychange, the font swap) were verified by reading and by modelling, not by driving a real browser against a Space. Worth a manual pass on a phone and a desktop before merge.🤖 Generated with Claude Code