Skip to content

fix(terminal): let a detached session's own window own its pane size - #397

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/detached-session-owns-its-pane-size
Sep 10, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/detached-session-owns-its-pane-size

Conversation

@irisitymichaelgrundberg

@irisitymichaelgrundberg irisitymichaelgrundberg commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Part of #398, which describes the symptom and the other causes found with it.

Popping a session out left both windows sizing the same pane. The dashboard
keeps the session active and keeps measuring it, and its terminal is narrower
than the popup because the session rail takes width the popup does not have.
One PTY cannot hold two sizes, so the CLI drew frames that fit neither window
and the popup showed a garbled frame.

sendResize and the debounced window-resize handler now stand aside for a
session this window has marked detached. A solo window is exempt, since it is
the owner. _maybeRefetchFullHistory already stood aside on exactly this
condition, so the rule is not a new one.

Sizing has to come back when the popup closes: while it owned the session the
dashboard sent no resizes, so the PTY still holds the popup's geometry.
_redock now re-asserts, with force, because the dimensions the dashboard
last sent are the ones it is about to send again.

Four defects the first revision of this PR had

The guard sat above the local fit, so it suppressed a reflow as well as the
server write. tab-rail-resize performs its single settle-time refit through
sendResize and its else fallback is unreachable for a truthy
activeSessionId, so dragging the rail stopped reflowing a detached session's
terminal in the dashboard. The mobile-keyboard guard fourteen lines below
already draws the line correctly — withhold the send, never the reflow — and the
guard now sits after the fit.

_lastResizeDims was left stale. It is one value for the whole window and
both guards skip updating it, so while a popup owns a session it no longer
describes the PTY. _redock repaired it only for the active session. Pop out A,
switch to B, close the popup: selecting A later found unchanged dimensions,
returned "unchanged", and selectSession skipped its 400ms redraw wait — while
the server, comparing against the real pane, did resize and did raise SIGWINCH,
so the fetch painted the pre-redraw frame. It is now cleared on every redock
path.

_redock could resize a session already gone. _onSessionDeleted redocks
before cleanup, so the id can be dead and the request is a guaranteed 404.

restoreTerminalSize — the header's redraw button and Ctrl+Shift+R —
silently did nothing for a detached session while still reporting success with
dimensions nothing was set to. It now says the session is sized by its own
window, where the same button works.

The force comment claimed a client-side dedupe that does not exist; the
deduplication is server-side against the real pane. Corrected to say what the
flag actually buys, and the _redock doc comment now records that the function
writes to the server and is not idempotent.

Verification

Reproduced with a dashboard and a popup on one session. Before: the pane sat at
315 columns while the popup rendered 289. After: both report the same size, and
comparing the popup's rendered rows against tmux capture-pane shows no
mismatched rows.

sendResize is covered for all three cases — the dashboard yielding, the solo
window still sizing, and an ordinary undetached session being sized — and now
also asserts the local fit still runs. _redock was the untested half and is
the half three of the defects above sit in; it now has coverage for clearing the
stale record on both the active and inactive paths, re-asserting only for the
session being shown, and staying silent for a deleted session. Every assertion
was checked by reverting the fix it covers.

npm test is green (6765 passing), along with typecheck, lint, format:check and
check:frontend-syntax.

Notes

One of three independent PRs for the same report; this one is the only
multi-window case and stands alone.

Popping a session out left both windows sizing the same pane. The dashboard
keeps the session active and keeps measuring it, and its terminal is
narrower than the popup because the session rail takes width the popup does
not have. One PTY cannot hold two sizes, so the CLI drew frames that fit
neither window and the popup showed a garbled frame.

sendResize and the debounced window-resize handler now stand aside for a
session this window has marked detached. A solo window is exempt, since it
is the owner. _maybeRefetchFullHistory already stood aside on exactly this
condition, so the rule is not a new one.

Sizing has to come back when the popup closes: while it owned the session
the dashboard sent no resizes, so the PTY still holds the popup's geometry.
_redock now re-asserts, with force, because the dimensions the dashboard
last sent are the ones it is about to send again.

Reproduced with a dashboard and a popup on one session: before, the pane
sat at 315 columns while the popup rendered 289. After, both report the
same size and the popup's frame matches the pane exactly.
@irisitymichaelgrundberg
irisitymichaelgrundberg force-pushed the fix/detached-session-owns-its-pane-size branch from d5efd21 to 5ac516d Compare September 9, 2026 12:21
…dock

Review of the previous commit found four defects in it.

The guard sat above the local fit, so it suppressed a reflow as well as the
server write. tab-rail-resize performs its single settle-time refit through
sendResize and has no fallback for a truthy activeSessionId, so dragging the
rail stopped reflowing a detached session's terminal in the dashboard. The
mobile-keyboard guard fourteen lines below already draws the line correctly —
withhold the send, never the reflow — and the guard now sits after the fit.

_lastResizeDims is one value for the whole window, and both guards skip
updating it, so while a popup owns a session that value no longer describes
the PTY. _redock repaired it only for the active session. Pop out A, switch to
B, close the popup: selecting A later found unchanged dimensions, returned
"unchanged", and selectSession skipped its 400ms redraw wait — while the
server, comparing against the real pane, did resize and did raise SIGWINCH, so
the fetch painted the pre-redraw frame. _redock now clears the record on every
path, active or not.

_redock could also fire a resize for a session already gone: _onSessionDeleted
redocks before cleanup, so the id can be dead and the request is a guaranteed
404. It now checks the session still exists.

restoreTerminalSize — the header's redraw button and Ctrl+Shift+R — silently
did nothing for a detached session while still reporting success with
dimensions nothing was set to. It now says the session is sized by its own
window, where the same button works.

The `force` comment claimed a client-side dedupe that does not exist; the
deduplication is server-side against the real pane. Corrected to say what the
flag actually buys. The _redock doc comment now records that the function
writes to the server and is not idempotent.

Tests: _redock was the untested half and is the half three of these defects
sit in. It now has coverage for clearing the stale record on both the active
and inactive paths, re-asserting only for the session being shown, and staying
silent for a deleted session. The existing sendResize test now asserts the
local fit still runs.
@Ark0N

Ark0N commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Merged and shipped in v1.26.2, together with #395 and #396. Thank you.

The four defects you listed against your own first revision are the reason this went in without a review round. Two of them are ones I would not have caught by reading the diff: tab-rail-resize performing its settle-time refit through sendResize with an unreachable else fallback, and _lastResizeDims being one value for the whole window, so the pop-out-A, switch-to-B, close sequence leaves a false "unchanged" that makes selectSession skip its redraw wait. Finding those in your own work and writing them down is worth more than the fix itself.

Putting the guard after the local fit is the right line, and matching it to the mobile-keyboard guard fourteen lines below (withhold the send, never the reflow) means the file now states that rule twice in the same shape rather than in two different ones.

Since #395, #396 and #397 all touch the same two frontend files in places, I ran the full gate locally after merging all three rather than trusting three separate green CI runs, because a semantic conflict between PRs merges green on GitHub. 6803 tests passed, so the three compose cleanly.

#398 is closed. Thank you for splitting one symptom into four independent causes instead of filing one report and one patch: that is what made it possible to land them as reviewable pieces.

irisitymichaelgrundberg added a commit to irisitymichaelgrundberg/Codeman that referenced this pull request Sep 15, 2026
Live terminal events are queued while a buffer load runs, and the load discards
that queue when it ends. That is right when the loaded buffer is the server's
accumulated byte history. The history is current up to the response, so the
queued events already appear in it and replaying them would duplicate output,
most visibly Ink's cursor-up redraws.

A tmux pane capture is current only up to CAPTURE time, which is part-way
through the fetch. Everything arriving between the capture and the end of the
chunked write was dropped, and nothing scheduled a re-fetch to recover it:
`_onSessionNeedsRefresh` is wired only to the 128KB overflow path. The CLI's
next partial redraw then landed on a frame the terminal never received. The
window covers the whole load, not a sliver of it — a 400ms redraw settle after a
real resize, the fetch, and the chunked write after that.

Queue entries now carry their arrival time, and `_finishBufferLoad` takes a
`since` cutoff, so a capture load replays exactly the tail that arrived after
the response headers. The pre-capture events stay dropped, because the capture
does hold those.

Two further things had to change for that tail to still exist when the load
ends, and a browser test is what found both. `chunkedTerminalWrite` is what ends
the load for every non-empty buffer, so the flush policy travels to its own
finish calls; the call in `selectSession` runs only when the write was skipped.
`_beginBufferLoad` no longer empties the queue when one load re-enters it, which
it does on every write, because that reset discarded the whole fetch window
before anything could replay it.

The response already distinguishes the two cases. `source` reads `mux-visible`
or `mux-full-history` for a capture and `history` for the byte stream.

Follows Ark0N#395, Ark0N#396 and Ark0N#397, which fixed the ways the replayed frame itself
could disagree with the terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
irisitymichaelgrundberg added a commit to irisitymichaelgrundberg/Codeman that referenced this pull request Sep 15, 2026
Live terminal events are queued while a buffer load runs, and the load discards
that queue when it ends. That is right when the loaded buffer is the server's
accumulated byte history. The route appends to that history right up to the
moment it serializes the response, so a queued event already appears in it and
replaying it would duplicate output, most visibly Ink's cursor-up redraws.

A tmux pane capture is a photograph, current only as of the instant
`capture-pane` ran. Output printed afterwards was queued and then dropped, and
nothing scheduled a re-fetch to recover it: `_onSessionNeedsRefresh` is wired
only to the 128KB overflow path. The CLI's next partial redraw then landed on a
frame the terminal never received.

How much went missing depended on which capture the route served. A `?full=1`
load returns the capture alone, with no history in front of it, so it lost
everything from the capture to the end of the chunked write. A `?tail=` load
returns history, a clear, and then the capture, and the route reads that history
after the capture, so it lost everything from the response to the end of that
write. The chunked write dominates either way. An agent CLI hides the loss on
its next full redraw; a shell session does not, because its output is linear and
nothing repaints it.

Queue entries now carry their arrival time, and `_finishBufferLoad` takes a
`since` cutoff, so a capture load replays exactly the tail that arrived after
the response headers. The earlier events stay dropped, because a payload that
carries history does hold those.

Two further things had to change for that tail to still exist when the load
ends, and a browser test is what found both. `chunkedTerminalWrite` is what ends
the load for every non-empty buffer, so the flush policy travels to its own
finish calls; the call in `selectSession` runs only when the write was skipped.
`_beginBufferLoad` no longer empties the queue when one load re-enters it, which
it does on every write, because that reset discarded the whole fetch window
before anything could replay it.

The response already distinguishes the sources. `source` reads `mux-visible` or
`mux-full-history` for a capture and `history` for the byte stream.

Follows Ark0N#395, Ark0N#396 and Ark0N#397, which fixed the ways the replayed frame itself
could disagree with the terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
irisitymichaelgrundberg added a commit to irisitymichaelgrundberg/Codeman that referenced this pull request Sep 15, 2026
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. The overflow rows then overwrite one another, and
the rows underneath are lost. Replaying a real 50-row capture into a 30-row
terminal rendered 28 lines of a 45-line command and drew the 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.

The retry re-arms the full-history flag only when the pass that ran had consumed
it. A tab switch takes the bounded tail, so its retry takes the tail too:
clearing the flag unconditionally would upgrade that switch into a fresh
scrollback capture the user never asked for, which the route's own comments put
at tens of megabytes.

What this repairs is a capture that 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, and `resizeRetry` then stops
it. Repairing that means changing who owns the pane size, which is a policy
question this does not touch. The reported geometry still helps there, because
the client can see the mismatch at all rather than being blind to it.

Follows Ark0N#395, Ark0N#396 and Ark0N#397, which fixed the other ways the replayed frame and
the terminal could disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
irisitymichaelgrundberg added a commit to irisitymichaelgrundberg/Codeman that referenced this pull request Sep 15, 2026
Live terminal events are queued while a buffer load runs, and the load discards
that queue when it ends. That is right when the loaded buffer is the server's
accumulated byte history. The route appends to that history right up to the
moment it serializes the response, so a queued event already appears in it and
replaying it would duplicate output, most visibly Ink's cursor-up redraws.

A tmux pane capture is a photograph, current only as of the instant
`capture-pane` ran. Output printed afterwards was queued and then dropped, and
nothing scheduled a re-fetch to recover it: `_onSessionNeedsRefresh` is wired
only to the 128KB overflow path. The CLI's next partial redraw then landed on a
frame the terminal never received.

How much went missing depended on which capture the route served. A `?full=1`
load returns the capture alone, with no history in front of it, so it lost
everything from the capture to the end of the chunked write. A `?tail=` load
returns history, a clear, and then the capture, and the route reads that history
after the capture, so it lost everything from the response to the end of that
write. The chunked write dominates either way. An agent CLI hides the loss on
its next full redraw; a shell session does not, because its output is linear and
nothing repaints it.

Queue entries now carry their arrival time, and `_finishBufferLoad` takes a
`since` cutoff, so a capture load replays exactly the tail that arrived after
the response headers. The earlier events stay dropped, because a payload that
carries history does hold those.

All four paths that fetch a terminal buffer and write it now decide this the
same way, through one `_bufferLoadFinishOpts` helper, so they cannot drift
apart: `selectSession`, `_onSessionNeedsRefresh`, `_onSessionClearTerminal` and
`_maybeRefetchFullHistory`. The second of those is the one that stings. It
exists to restore output the client already dropped once under backpressure, and
it was dropping more output while performing that recovery. The cache-hit write
inside `selectSession` stays on discard deliberately: it runs before the fetch,
so its queue holds only events the capture that follows already contains.

Two further things had to change for that tail to still exist when the load
ends, and a browser test is what found both. `chunkedTerminalWrite` is what ends
the load for every non-empty buffer, so the flush policy travels to its own
finish calls; the call in `selectSession` runs only when the write was skipped.
`_beginBufferLoad` no longer empties the queue when one load re-enters it, which
it does on every write, because that reset discarded the whole fetch window
before anything could replay it.

The response already distinguishes the sources. `source` reads `mux-visible` or
`mux-full-history` for a capture and `history` for the byte stream.

Follows Ark0N#395, Ark0N#396 and Ark0N#397, which fixed the ways the replayed frame itself
could disagree with the terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
irisitymichaelgrundberg added a commit to irisitymichaelgrundberg/Codeman that referenced this pull request Sep 19, 2026
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. The overflow rows then overwrite one another, and
the rows underneath are lost. Replaying a real 50-row capture into a 30-row
terminal rendered 28 lines of a 45-line command and drew the 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.

The retry re-arms the full-history flag only when the pass that ran had consumed
it. A tab switch takes the bounded tail, so its retry takes the tail too:
clearing the flag unconditionally would upgrade that switch into a fresh
scrollback capture the user never asked for, which the route's own comments put
at tens of megabytes.

What this repairs is a capture that 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, and `resizeRetry` then stops
it. Repairing that means changing who owns the pane size, which is a policy
question this does not touch. The reported geometry still helps there, because
the client can see the mismatch at all rather than being blind to it.

Follows Ark0N#395, Ark0N#396 and Ark0N#397, which fixed the other ways the replayed frame and
the terminal could disagree.

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.

2 participants