fix(terminal): keep the geometry replay to the pass that can converge

Three follow-ups to the source gate, each one measured rather than reasoned.

A pane already drawing at the size the client just requested is left alone. The
replay runs at `dimsAfterLoad`, so it can only change what is on screen if the
pane was drawing at some other size; when the reported geometry already IS that
size, the second pass captures the identical frame and pays a full reload to do
it, including a visible re-flash, a dropped and reopened WebSocket and a deleted
xterm snapshot. That equality is the signature of a clamp rather than a race:
`getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does not, so a
terminal narrower than 40 columns or shorter than 10 rows reports a pane
permanently bigger than itself and replayed on every tab switch without ever
converging. A race never produces the equality, since its premise is that the
pane was still at the size it was asked to leave. The declined-resize case does
not produce it either, so that one still costs the single capped attempt and
needs the pane-ownership question this does not touch.

The full-history re-arm is unreachable and now says so. A pass that consumed the
flag sent `full=1`, and the route answers `full=1` with `mux-full-history` or
`history`, never `mux-visible`, so the source gate already rules out every such
pass. The line stays for the invariant, but its comment no longer reads as if a
page load retries, and the suite pins that it does not.

The response no longer reports geometry for a body that carries no capture. The
full-history path writes `capturedGeometry` from the cursor query and then
returns '' for a pane holding nothing visible, which drops the source to
`history` with the geometry already recorded: a `full=1` request whose capture
reported 100x50 and returned nothing answered `source: "history"` with both
fields set. Nothing acted on it, because the client ignores geometry on any
other source, but the field said a frame had been drawn at a size when none had.

The browser stub now derives `source` from the request the way the route does,
rather than answering `full=1` with `mux-visible`, which the route cannot
produce. Each case reaches a visible-frame response the way production does, by
not being the first select of the page. Three cases pin the new behaviour and
each fails without its guard: the clamp case sees two fetches instead of one,
the scope case and the full-history case both see a replay the gate forbids, and
the width case sees one fetch instead of two.

The changeset now describes the change from 1.29.x rather than the difference
between the two commits on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-19 10:44:35 +02:00
co-authored by Claude Opus 5
parent 5cfb98fb8b
commit e0d4477edc
5 changed files with 219 additions and 56 deletions
@@ -5,36 +5,43 @@
fix(terminal): replay a pane capture at the geometry it was taken at
A visible-frame capture repaints each row at an absolute position, counting up
to the pane's height. A terminal shorter than that clamps every address past
its own height onto its last line, so the overflow rows overwrite one another
and the rows underneath are lost. Against a 50-row pane, a 30-row terminal
rendered 28 of a 45-line command and drew the surviving frame twice.
to the pane's height and out to the pane's width. A terminal shorter than that
clamps every address past its own height onto its last line, so the overflow
rows overwrite one another and the rows underneath are lost. Against a 50-row
pane, a 30-row terminal rendered 28 of a 45-line command and drew the surviving
frame twice. A narrower terminal damages the same frame a second way: each row
is painted out to the pane's own width, so the browser wraps every painted row,
and the wrap on the last one scrolls the whole frame up by a row.
A pane wider than the terminal damages the same frame a second way. Each row is
painted out to the pane's own width, so a narrower terminal wraps every painted
row, and the wrap on the last one scrolls the whole frame up by a row.
Nothing in the response said what geometry the frame was built for, so the
client could not detect either case. A capture now reports the geometry it was
really taken at through `capturedGeometry` on `PaneCaptureOptions`, and the
terminal response carries it as `captureCols` and `captureRows`. Both fields are
absent unless the response really carries a capture, since a body that was never
positioned has no geometry to describe. When a captured pane is taller or wider
than the terminal, or the size that produced the capture did not survive the
load, `selectSession` replays once at the size that stuck.
Nothing in the response said what geometry the frame was built for, so the client
could not detect either case. A capture now reports the geometry it was really
taken at through `capturedGeometry` on `PaneCaptureOptions`, and the terminal
response carries it as `captureCols` and `captureRows`. When the captured pane is
taller or wider than the terminal, or the size that produced the capture did not
survive the load, `selectSession` replays once at the size that stuck.
`resizeRetry` caps that at one attempt, so two competing fits cannot trade
replays forever.
That comparison runs on a visible-frame response only. A full-history response
is linear scrollback closed by a relative cursor move, and a byte-history
response carries no row alignment at all, so a size mismatch damages neither and
a replay repairs neither. The distinction matters because the first load of
every non-shell session per page takes the full-history path, where a replay
would capture the whole tmux scrollback a second time.
That comparison runs on a visible-frame response only. A full-history response is
linear scrollback closed by a relative cursor move, and a byte-history response
carries no row alignment at all, so a size mismatch damages neither and a replay
repairs neither. Gating on the source matters because the first load of every
non-shell session per page takes the full-history path, where an ungated
comparison would capture the whole tmux scrollback a second time. A response
whose capture reported no geometry now omits both fields rather than naming the
session's own PTY size, which describes no frame that was ever positioned.
Two guards keep the replay to the one pass that can converge. `resizeRetry` caps
it at a single attempt, so two competing fits cannot trade replays forever. A
pane already drawing at the size the client just requested is left alone, which
is the signature of a clamp rather than a race: `getTerminalDimensions()` floors
at 40x10 while `fitAddon.fit()` does not, so a terminal narrower than 40 columns
or shorter than 10 rows reports a pane permanently bigger than itself and would
otherwise replay on every tab switch without ever converging.
That repairs the case where a capture won a race against the resize meant to
precede it. It does not repair a capture whose pane was too tall because
One case is still reported rather than repaired. A pane can be too tall because
`Session.resize` declined the resize outright, which it does for a small
viewport while a desktop viewport's size claim is live: the retry re-sends the
same declined resize and captures the same pane. The reported geometry still
helps there, because the client can see the mismatch at all.
viewport while a desktop viewport's size claim is live. The retry re-sends the
same declined resize and captures the same pane, so it costs the one capped
attempt and the frame is shown as it is. Repairing it means deciding who owns
the pane size while a desktop claim is live, which is a policy question this
does not touch. The reported geometry still helps, because the client can see
the mismatch at all rather than being blind to it.