Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/fix-replay-output-that-arrived-after-the-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"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.
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const BROWSER_TEST_GLOBS = [
'test/webgl-fallback.test.ts',
'test/terminal-copy-shortcut.test.ts',
'test/terminal-keycode229-recovery.browser.test.ts',
'test/capture-load-window.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
];

Expand Down
71 changes: 65 additions & 6 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1906,6 +1906,30 @@ class CodemanApp {
this._onSessionClearTerminal(data);
}

/**
* How a buffer load that just fetched `payload` must end.
*
* A tmux pane capture is a point-in-time frame, so nothing that reached the
* browser after the response headers can already be in it. Such a load
* replays exactly that tail; discarding it drops the CLI's output for the
* rest of the load window, and its next partial redraw then lands on a frame
* the terminal never received. A payload built from the server's accumulated
* byte history needs the opposite: that history is current up to the
* response, so replaying the queue on top of it would duplicate output.
*
* `headersReceivedAt` is the caller's own `performance.now()` reading from
* the moment the response arrived, compared only against other client-side
* readings, so there is no clock skew to worry about.
*
* @param {{source?: string}} payload - The parsed `data` of a terminal response.
* @param {number} headersReceivedAt - When that response reached this client.
* @returns {{flushQueued: boolean, since: number}} Options for `_finishBufferLoad`.
*/
_bufferLoadFinishOpts(payload, headersReceivedAt) {
const capturedFromMux = payload?.source === 'mux-visible' || payload?.source === 'mux-full-history';
return { flushQueued: capturedFromMux, since: headersReceivedAt };
}

_onSessionTerminal(data) {
if (data.id === this.activeSessionId) {
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
Expand All @@ -1915,7 +1939,7 @@ class CodemanApp {
// jump over the cap. Dropped data is recovered from the canonical buffer.
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
+ (this.flickerFilterBuffer?.length || 0)
+ (this._loadBufferQueue?.reduce((s, w) => s + w.length, 0) || 0)
+ (this._loadBufferQueue?.reduce((s, w) => s + w.data.length, 0) || 0)
+ (this._terminalWriteInFlightBytes || 0);
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
// Schedule a self-recovery once the
Expand Down Expand Up @@ -2498,9 +2522,11 @@ class CodemanApp {
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
);
let headersReceivedAt = performance.now();
let data = (await res.json())?.data ?? {};
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
headersReceivedAt = performance.now();
data = (await res.json())?.data ?? {};
}
// Bail on a tab switch mid-fetch: writing here would paint this session's
Expand All @@ -2516,7 +2542,12 @@ class CodemanApp {
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
this.terminal.clear();
this.terminal.reset();
await this.chunkedTerminalWrite(data.terminalBuffer);
await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
undefined,
this._bufferLoadFinishOpts(data, headersReceivedAt)
);
// A tail fetch can be partial, and the banner would otherwise keep
// describing the pre-refresh buffer (#258).
this._setHistoryTruncation(sessionId, data);
Expand Down Expand Up @@ -2552,6 +2583,7 @@ class CodemanApp {
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try {
const res = await fetch(`/api/sessions/${data.id}/terminal`);
const headersReceivedAt = performance.now();
const termData = (await res.json())?.data ?? {};

this.terminal.clear();
Expand All @@ -2561,7 +2593,12 @@ class CodemanApp {
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
const cleanBuffer = termData.terminalBuffer.replace(DEC_SYNC_STRIP_RE, '');
// Use chunked write to avoid UI freeze with large buffers (can be 1-2MB)
await this.chunkedTerminalWrite(cleanBuffer);
await this.chunkedTerminalWrite(
cleanBuffer,
TERMINAL_CHUNK_SIZE,
undefined,
this._bufferLoadFinishOpts(termData, headersReceivedAt)
);
}

// Fire-and-forget resize — don't block on it
Expand Down Expand Up @@ -5782,7 +5819,12 @@ class CodemanApp {
parsedAt,
bufferLength: parsedBufferLength,
completed,
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
} = await this.chunkedTerminalWrite(
buffer,
TERMINAL_CHUNK_SIZE,
sessionId,
this._bufferLoadFinishOpts(payload, headersReceivedAt)
);
timing.resetAndParseMs = parsedAt - replayStartedAt;
if (!completed || this.activeSessionId !== sessionId) return;
// Keep shell tab restores bounded too. A user-triggered full-history pull
Expand Down Expand Up @@ -6241,6 +6283,15 @@ class CodemanApp {
}
const data = (await res.json())?.data ?? {};
const bodyParsedAt = performance.now();
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
// point-in-time frame, so nothing that reached the browser after the
// response headers can already be in it. Replay exactly that tail;
// discarding it drops the CLI's output for the rest of the load window,
// and its next partial redraw then lands on a frame the terminal never
// received. `since` keeps the pre-capture events dropped, because the
// capture does hold those and replaying them would duplicate output.
const finishOpts = this._bufferLoadFinishOpts(data, headersReceivedAt);
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);

let freshResetAndParseMs = 0;
Expand All @@ -6267,7 +6318,8 @@ class CodemanApp {
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
bufferLoadOwner
bufferLoadOwner,
finishOpts
);
freshResetAndParseMs = freshParsedAt - replayStartedAt;
if (this._isStaleSelect(selectGen)) {
Expand Down Expand Up @@ -6317,7 +6369,14 @@ class CodemanApp {
// COD-144: when the load painted nothing, FLUSH the queued events instead of
// discarding — a new session's prompt arrives only as a queued SSE event.
if (this._isLoadingBuffer) {
this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty });
// Only reached when the write was skipped. COD-144 lives here: a new
// session's first prompt exists only as a queued event that predates the
// response, so an empty paint replays its queue WHOLE rather than from
// the header timestamp.
this._finishBufferLoad(
bufferLoadOwner,
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
);
}
// Drop the guard so user input clears state normally
this._restoringFlushedState = false;
Expand Down
57 changes: 44 additions & 13 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -3267,7 +3267,11 @@ Object.assign(CodemanApp.prototype, {
// to prevent interleaving historical buffer data with live SSE data.
// This is critical: interleaving causes cursor position chaos with Ink redraws.
if (this._isLoadingBuffer) {
if (this._loadBufferQueue) this._loadBufferQueue.push(data);
// Each entry records when it arrived. A flush of a tmux-capture load
// replays only what arrived after the capture; without the timestamp it
// would have to replay the whole queue, duplicating the events the
// capture already contains. See _finishBufferLoad's `since`.
if (this._loadBufferQueue) this._loadBufferQueue.push({ at: performance.now(), data });
return;
}

Expand Down Expand Up @@ -3747,9 +3751,14 @@ Object.assign(CodemanApp.prototype, {
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
* @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 32KB)
* @param {string} [loadOwner] - Load token to finish under
* @param {{ flushQueued?: boolean, since?: number }} [finishOpts] - Passed to
* `_finishBufferLoad`. This method ends the load for every non-empty buffer,
* so a caller that wants the queue replayed has to say so HERE; the call in
* `selectSession` only runs when the write was skipped entirely.
* @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner, finishOpts) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
// older writes abort instead of continuing to push stale data into the terminal.
const writeGen = ++this._chunkedWriteGen;
Expand All @@ -3762,7 +3771,7 @@ Object.assign(CodemanApp.prototype, {
completed,
});
if (!buffer || buffer.length === 0) {
this._finishBufferLoad(bufferLoadOwner);
this._finishBufferLoad(bufferLoadOwner, finishOpts);
resolve(parseSnapshot());
return;
}
Expand All @@ -3776,7 +3785,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.write(cleanBuffer, () => resolve(parseSnapshot()));
// The write is now ordered in xterm's queue. Release live output before
// parsing completes; subsequent writes stay behind it without being lost.
this._finishBufferLoad(bufferLoadOwner);
this._finishBufferLoad(bufferLoadOwner, finishOpts);
return;
}

Expand Down Expand Up @@ -3807,7 +3816,7 @@ Object.assign(CodemanApp.prototype, {
);
resolve(result);
});
this._finishBufferLoad(bufferLoadOwner);
this._finishBufferLoad(bufferLoadOwner, finishOpts);
return;
}

Expand All @@ -3826,10 +3835,20 @@ Object.assign(CodemanApp.prototype, {
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
*
* By default queued SSE events are DISCARDED, not flushed. For an established
* session the loaded buffer from the API is the source of truth up to the
* response timestamp; SSE events queued during the fetch+write overlap already
* appear in that buffer, so flushing them writes duplicate data (especially Ink
* cursor-up redraws), corrupting the terminal display.
* session whose buffer came from the server's accumulated byte history, that
* history is the source of truth up to the response timestamp; SSE events
* queued during the fetch+write overlap already appear in it, so flushing
* them writes duplicate data (especially Ink cursor-up redraws), corrupting
* the terminal display.
*
* A tmux PANE CAPTURE is the exception, and the reason `since` exists. A
* capture is a point-in-time frame taken part-way through the fetch, so it is
* the source of truth only up to CAPTURE time — not up to the response. Every
* event that arrives between the capture and the end of the chunked write is
* queued and, under a plain discard, lost outright: nothing re-fetches, and
* the CLI's next partial redraw lands on a frame the terminal never received.
* The caller passes the response's own arrival time as `since` so exactly
* that tail is replayed and the pre-capture events stay dropped.
*
* COD-144: a brand-new session is the exception. Its terminal fetch can resolve
* BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the
Expand All @@ -3843,14 +3862,22 @@ Object.assign(CodemanApp.prototype, {
* After unblocking, new SSE/WS events deliver subsequent output normally.
*
* @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op.
* @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events.
* @param {{ flushQueued?: boolean, since?: number }} [opts] When `flushQueued`
* is true, replay queued events whose arrival timestamp is at or after
* `since` (default 0, meaning the whole queue).
*/
_beginBufferLoad(owner) {
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
// opens it again under the SAME owner when it starts writing. Resetting the
// queue on that second call would throw away everything that arrived during
// the fetch, which on the capture path is output no buffer holds. Re-entering
// one load keeps its queue; a genuinely new load still starts empty.
const reentering = this._bufferLoadOwner === loadOwner && Array.isArray(this._loadBufferQueue);
this._bufferLoadOwner = loadOwner;
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
if (!reentering) this._loadBufferQueue = [];
return loadOwner;
},

Expand All @@ -3864,9 +3891,13 @@ Object.assign(CodemanApp.prototype, {
this._bufferLoadOwner = null;
// COD-144: replay (rather than discard) queued live events when the load
// painted nothing — the queued prompt is the only content a new session has.
// A tmux-capture load replays too, but only the tail: `since` cuts the queue
// at the moment the capture stopped being able to contain what arrived.
if (opts?.flushQueued && queued && queued.length) {
for (const data of queued) {
this.batchTerminalWrite(data);
const since = typeof opts.since === 'number' ? opts.since : 0;
for (const entry of queued) {
if (entry.at < since) continue;
this.batchTerminalWrite(entry.data);
}
}
return true;
Expand Down
Loading