fix(run,terminal): merge-time fixes for the Instance count stepper and capture geometry

#454: the behaviour the PR adds had no test, so a regression test drives
runGrok() at tabCount 3 and asserts three quick-start POSTs with sequential
w<n>-<case> names (verified to fail against master's session-ui.js). Each
caller now reads the count BEFORE its opening banner and announces it there,
the way runClaude() already did, so a launch no longer prints two headers and
a launch with another session already active still says how many are starting.
runClaude() calls the shared _readTabCount() instead of its own copy of the
1..20 clamp, and that helper optional-chains the element read, since hoisting
it above each caller's try block would otherwise let a missing #tabCount throw
where the launch-error path cannot report it.

#435: sizeMovedUnderLoad derived from data.source alone. `mux-visible` is not
sufficient: a failed display-message cursor query makes capturePaneBuffer skip
the snapshot repaint and return the raw capture, which the route still labels
mux-visible, so a size that moved during such a load bought a full forced
reload to repair a frame that was never positioned. It now tests
Number.isFinite(data.captureRows) like its two siblings.

Plus the invariants and CLAUDE.md lines promised on #435: a visible capture
reports its geometry and omits it when nothing was positioned, the comparison
runs on mux-visible only, and the replay is capped at one attempt and latches
per session when it cannot converge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-19 12:18:39 +02:00
parent 2c3ccdf030
commit c376534a50
7 changed files with 134 additions and 64 deletions
@@ -4,44 +4,11 @@
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 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.
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.
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.
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.
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, 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.
Opening a session could draw a frame built for a pane bigger than your terminal. A
taller pane wrote its overflow rows onto the last line and lost the rows underneath
(against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew
the survivors twice), and a wider one wrapped every row and scrolled the whole frame
up by one. The terminal response now reports the geometry the capture was really
taken at, so the browser can see the mismatch and replay once at the size that stuck.
A pane that cannot be sized to fit is diagnosed once per session instead of on every
tab switch.
@@ -0,0 +1,11 @@
---
"aicodeman": patch
---
fix(run): make the Instance count stepper work for every non-Claude mode
The Instance count stepper next to the Run button only ever applied to Claude.
Setting it to 3 and launching OpenCode, Codex, Gemini, Antigravity, Pi, OMP, Grok or
DeepSeek started exactly one session, with no error and no hint that the control had
done nothing. All eight now launch the count you asked for, and the opening banner
says how many are starting.