Files
Codeman/.changeset/fix-report-the-captured-pane-geometry.md
T
Michael GrundbergandClaude Opus 5 5cfb98fb8b 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>
2026-09-19 10:44:35 +02:00

2.3 KiB

aicodeman
aicodeman
patch

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.

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