Skip to content

Terminal: spend one reflow per layout switch, not several - #3

Open
thomwolf wants to merge 1 commit into
huggingface:mainfrom
thomwolf:terminal/coalesce-resizes
Open

Terminal: spend one reflow per layout switch, not several#3
thomwolf wants to merge 1 commit into
huggingface:mainfrom
thomwolf:terminal/coalesce-resizes

Conversation

@thomwolf

Copy link
Copy Markdown
Member

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 claude under tmux with no client attached at all corrupts it exactly the same way:

200x50 → 70x50 → 200x50 → 70x50 → 200x50
nonblank scrollback rows   10 → 12
orphan border rows          1 → 3

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:

nonblank rows orphan borders
rows-only, 50→24→50→24→50 10 → 10 1
cols-only, 200→70→200→70→200 10 → 12 1 → 3

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:

Path to the same final width junk rows orphan borders
5 width changes +9 1 → 7
1 width change +2 1 → 2

Why one layout switch was costing several width changes

resync() had neither debounce nor dedup:

  • fit() mutates the element the ResizeObserver watches, so it re-fires — the fit echoes itself.
  • 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 existing fonts.ready handler 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, then resync() corrects it once if the box settled elsewhere meanwhile.
  • First attach — waits (capped at 400ms, warm loads pass straight through) for real font metrics, rather than spending a width change to correct a glyph-width guess against a session that has been running all along. A late font still reconciles through 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+L changed nothing measurable, so there is no keystroke injection here.

Verification

tsc --noEmit and vite build pass.

The coalescing logic was exercised against a model of the new path:

PASS  one layout switch = one resize
PASS  focus + echo churn on same size = no resize
PASS  transient box change that returns = no resize
PASS  real settled changes still sent
PASS  handed off = no resize sent

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

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>
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