Files
Codeman/.changeset/fix-replay-output-that-arrived-after-the-capture.md
T
Michael GrundbergandClaude Opus 5 75a028e825 fix(terminal): re-take the sticky-scroll baseline after a replay
A capture load now replays its queued tail, and that replay runs through
`batchTerminalWrite`, which samples `_wasAtBottomBeforeWrite` before it queues.
It runs inside `chunkedTerminalWrite`, before that promise resolves, with the
terminal freshly reset and rewritten — so the sample is always true. The caller
then restored the reader's position and the next `flushPendingWrites` scrolled
straight back to the bottom off the latched flag, undoing it. The only thing in
the way was `_hasRecentUserScrollUp()`, a 1500ms window a server-triggered
refresh is usually past.

`_syncStickyScrollBaseline()` re-takes the flag from wherever the viewport now
sits, and the two paths that restore a position call it right after doing so:
`_onSessionNeedsRefresh` and `_maybeRefetchFullHistory`. Those are the paths
#259 and #205 exist for, and they are also where a non-empty queue is most
likely, since a needsRefresh fires when output is flooding. Re-taking rather
than suppressing the sampling: suppressing leaves whatever stale value the flag
held from before the load, which on the full-history re-pull has no reason to
be false. `selectSession` and `_onSessionClearTerminal` deliberately end at the
bottom, so the sampled true is already the truth there and they do not call it.

`_bufferLoadFinishOpts` gains the coverage the CI gate can see: both mux
sources flush, `history` does not, and a payload naming no source does not.
Its only coverage was the browser suite, which CI does not run.

The JSDoc and the changeset now record the one duplicate window this cutoff
cannot close. The server appends output to the byte buffer in the same tick it
emits, but broadcasts on a batch timer — 8ms over WebSocket, 16 to 50ms over
SSE — so a batch pending when `capture-pane` ran leaves the server after the
reply and is replayed although the capture holds it. It is one batch interval
wide against a recovery window spanning the whole chunked write, and closing it
means flushing that batch server side before the capture.

The second browser test asserts its session was created, so a failed create
fails it instead of passing with zero hits.

docs/architecture-invariants.md no longer claims the replay leaves the
queued-event discard window alone. That clause now describes what decides how a
load ends, the baseline rule, the batch window, and the three covering tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:43:04 +02:00

2.9 KiB

aicodeman
aicodeman
patch

fix(terminal): keep the output a pane capture could not contain

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 the queued events already appear in it and replaying them would duplicate output.

A tmux pane capture is a photograph, current only as of the instant capture-pane ran. Output printed afterwards was queued and then dropped, with nothing scheduling a re-fetch, and the CLI's next partial redraw landed on a frame the terminal never received. A ?full=1 load returns the capture alone, so it lost everything from the capture to the end of the chunked write. A ?tail= load carries the byte history in front of the capture, so it lost everything from the response to the end of that write. A shell session shows this most plainly, 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. All four paths that fetch a terminal buffer and write it use the same rule, through one shared _bufferLoadFinishOpts helper: selecting a session, the backpressure refresh, the clear-terminal reload, and the full-history re-pull. The backpressure refresh matters most, because it exists to restore output the client already dropped once and could drop more while doing it.

Two things had to change for that tail to still exist when the load ends. chunkedTerminalWrite is what ends the load for any non-empty buffer, so it takes the flush policy and applies it at its own finish sites. _beginBufferLoad no longer empties the queue when the same load re-enters it, which it does on every write, because that reset discarded the fetch window before anything could replay it.

A path that replays its queue and then restores a scroll position re-takes the sticky-scroll baseline (_syncStickyScrollBaseline). The replay runs with the terminal freshly reset, so it reads as sitting at the bottom, and the next flush would scroll there and undo the restore. The backpressure refresh and the full-history re-pull are the two paths that restore a position, and both are ones a reader reaches while scrolled up.

One duplicate window stays open and is not closable from the browser. The server appends output to the byte buffer in the same tick it emits, but broadcasts on a batch timer, 8ms over WebSocket and 16 to 50ms over SSE. A batch already pending when capture-pane ran therefore leaves the server after the reply and is replayed although the capture holds it. It is one batch interval wide, against a recovery window that spans the whole chunked write, and closing it means flushing that session's pending batch before taking the capture.