mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
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 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 #395, #396 and #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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c03714eb74
commit
c9515b1d4c
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -3762,7 +3771,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
completed,
|
||||
});
|
||||
if (!buffer || buffer.length === 0) {
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
resolve(parseSnapshot());
|
||||
return;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3807,7 +3816,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
resolve(result);
|
||||
});
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user