fix(terminal): compare capture geometry only on a visible-frame response

Only a visible-frame capture positions its rows absolutely, so only that frame
can be damaged by a terminal of the wrong size. A `full=1` body is linear
scrollback closed by a relative cursor move, which is relative precisely so the
browser's row count need not match the pane's, and a `history` body is the byte
stream, which carries no row alignment to protect. The geometry comparison ran
on all three, so it fired most often on the one response it cannot help:
`_fullHistoryLoaded` is empty on the first select of every non-shell session per
page, and a session whose pane a desktop tab holds too tall to ever fit then
paid a second whole-scrollback capture, reset and replay on every page load and
every first tab switch.

`framePositionsRowsAbsolutely` gates both the captured-geometry comparison and
`sizeMovedUnderLoad`. A size that moved under a byte-stream or scrollback replay
is healed by xterm's own reflow plus the SIGWINCH the trailing `sendResize`
already sends.

A pane WIDER than the terminal damages the same frame a second way, so
`captureCols` is now compared rather than only logged. `formatPaneSnapshot`
paints each row out to the pane's own width, so a narrower browser wraps every
painted row, and the wrap on the last one scrolls the whole frame up by a row.

The terminal response no longer falls back to `session.ptyCols`/`ptyRows` when
the capture reported no geometry. The cursor query is what produces the absolute
addressing in the first place, so a capture that lost it returned a raw frame
that was never positioned, and a byte-history response was never positioned
either. Naming the session's own PTY size there described a frame that does not
exist and invited a repair for damage that is not present. `_ptyCols` is also
written only by `resize()` while the PTY is spawned at the size queried from
tmux, so it can be wrong on its own terms. Both fields are now absent instead,
and the `Session` getters added for that fallback go with it.

Two browser cases cover the new behaviour and each fails without its fix: a
`mux-full-history` response with both dimensions mismatched asserts one fetch
(two without the gate), and a `mux-visible` response wider than the terminal
but short enough to fit asserts two (one without the width comparison).

Corrects a claim in the comment above `capturedGeometry` in tmux-manager.ts.
Both replay paths do not address rows absolutely; the full-history one ends in a
relative move, which is the whole reason the gate is right.

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 3edf9aae2f
commit 5cfb98fb8b
8 changed files with 165 additions and 63 deletions
@@ -10,13 +10,27 @@ 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.
Nothing in the response said what height the frame was built for, so the client
could not detect this. 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
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.
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`. 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. 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.
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