diff --git a/components/terminal/runtime/terminalSessionAttachment.test.ts b/components/terminal/runtime/terminalSessionAttachment.test.ts index a8994998d9..5f59bf837e 100644 --- a/components/terminal/runtime/terminalSessionAttachment.test.ts +++ b/components/terminal/runtime/terminalSessionAttachment.test.ts @@ -14,6 +14,7 @@ import { import { attachSessionToTerminal, getFlowController, + isReplaceableDec2026FullFrame, notePendingOutputScrollIfEnabled, resolveAttachSnapshot, tryAttachSessionToTerminal, @@ -52,6 +53,25 @@ test("resolveAttachSnapshot keeps an authoritative empty final snapshot", () => assert.equal(resolveAttachSnapshot(undefined, "fallback"), "fallback"); }); +test("recognizes replaceable OpenCode DEC 2026 full frames conservatively", () => { + const frame = "\x1b[14t\x1b[?2026h\x1b[?25l\x1b[1;1Hframe\x1b[?25h\x1b[?2026l"; + + assert.equal(isReplaceableDec2026FullFrame(frame), true); + assert.equal( + isReplaceableDec2026FullFrame("\x1b[?2026h\x1b[Hframe\x1b[?2026l"), + true, + ); + assert.equal( + isReplaceableDec2026FullFrame("prompt\x1b[?2026h\x1b[1;1Hframe\x1b[?2026l"), + false, + ); + assert.equal( + isReplaceableDec2026FullFrame("\x1b[?2026h\x1b[5;1Hincremental\x1b[?2026l"), + false, + ); + assert.equal(isReplaceableDec2026FullFrame("\x1b[?2026h\x1b[1;1Hunfinished"), false); +}); + const createFakeTerm = (activeType = "normal") => { const writes: string[] = []; const markerLines: number[] = []; diff --git a/components/terminal/runtime/terminalSessionAttachment.ts b/components/terminal/runtime/terminalSessionAttachment.ts index 7d8f1fce7f..a08257ec94 100644 --- a/components/terminal/runtime/terminalSessionAttachment.ts +++ b/components/terminal/runtime/terminalSessionAttachment.ts @@ -511,6 +511,28 @@ export const writeSessionData = ( const isPlainTerminalDisplayData = (data: string): boolean => !data.includes("\x1b") && !data.includes("\x9b"); +const REPLACEABLE_DEC_2026_FRAME_KEY = "dec-2026-full-frame"; +const DEC_2026_SYNC_START = "\x1b[?2026h"; +const DEC_2026_SYNC_END = "\x1b[?2026l"; +const TERMINAL_QUERY_PREFIX_PATTERN = new RegExp( + `^(?:${String.fromCharCode(0x1b)}\\[[0-9;?]*[cnpt])+$`, + "u", +); + +/** + * Full TUI snapshots make older queued snapshots obsolete. OpenCode prefixes + * these with a small pixel-size query, so allow only CSI queries before the + * synchronized block; printable shell output must never become replaceable. + */ +export const isReplaceableDec2026FullFrame = (data: string): boolean => { + const syncStart = data.indexOf(DEC_2026_SYNC_START); + if (syncStart < 0 || !data.endsWith(DEC_2026_SYNC_END)) return false; + const prefix = data.slice(0, syncStart); + if (prefix && !TERMINAL_QUERY_PREFIX_PATTERN.test(prefix)) return false; + const frame = data.slice(syncStart + DEC_2026_SYNC_START.length, -DEC_2026_SYNC_END.length); + return frame.includes("\x1b[H") || frame.includes("\x1b[1;1H"); +}; + const writeSessionDataImmediate = ( ctx: TerminalSessionStartersContext, term: XTerm, @@ -523,6 +545,9 @@ const writeSessionDataImmediate = ( // event loop can paint/input between xterm parses (serial queue otherwise // chains the next write the moment the callback fires). const displayBytes = data.length; + const replacePendingKey = isReplaceableDec2026FullFrame(data) + ? REPLACEABLE_DEC_2026_FRAME_KEY + : undefined; const bulkYieldAfter = shouldDegradeTerminalSideWork(term) && displayBytes >= XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES; enqueueTerminalWrite(term, displayBytes, (done) => { @@ -729,6 +754,7 @@ const writeSessionDataImmediate = ( }); }, { dropBytes: ingressBytes, + replacePendingKey, deferStart: writeOptions.deferStart, // Intermediate plain shards set yieldAfter via writeLargeTerminalBatch; // bulk pressure also yields after sizable items (Tabby FlowControl intent). diff --git a/components/terminal/runtime/terminalWriteQueue.test.ts b/components/terminal/runtime/terminalWriteQueue.test.ts index 654058306d..cf401b67e8 100644 --- a/components/terminal/runtime/terminalWriteQueue.test.ts +++ b/components/terminal/runtime/terminalWriteQueue.test.ts @@ -407,3 +407,83 @@ test("abortTerminalWriteQueue reports ingress accounting instead of display sche assert.deepEqual(dropped, [80]); }); + +test("replacePendingKey keeps only the newest pending frame and reports ingress bytes", () => { + const term = createFakeTerm(); + const order: string[] = []; + const dropped: number[] = []; + let releaseActive: (() => void) | null = null; + + enqueueTerminalWrite(term, 1, (done) => { + order.push("active"); + releaseActive = done; + }); + enqueueTerminalWrite(term, 10, (done) => { + order.push("old-frame"); + done(); + }, { + dropBytes: 100, + replacePendingKey: "tui-frame", + onDropped: (bytes) => dropped.push(bytes), + }); + enqueueTerminalWrite(term, 2, (done) => { + order.push("ordinary-output"); + done(); + }); + enqueueTerminalWrite(term, 20, (done) => { + order.push("new-frame"); + done(); + }, { + dropBytes: 200, + replacePendingKey: "tui-frame", + }); + + assert.deepEqual(order, ["active"]); + assert.deepEqual(dropped, [100]); + releaseActive?.(); + assert.deepEqual(order, ["active", "ordinary-output", "new-frame"]); +}); + +test("replacePendingKey removes superseded steps from a merged flood item", async () => { + const term = createFakeTerm(); + const order: string[] = []; + const dropped: number[] = []; + let releaseActive: (() => void) | null = null; + + enqueueTerminalWrite(term, 1, (done) => { + releaseActive = done; + }); + enqueueTerminalWrite(term, 10, (done) => { + order.push("old-frame"); + done(); + }, { + dropBytes: 75, + replacePendingKey: "tui-frame", + onDropped: (bytes) => dropped.push(bytes), + }); + for (let index = 0; index < MAX_WRITE_QUEUE_ITEMS; index += 1) { + enqueueTerminalWrite(term, 1, (done) => { + order.push(`ordinary-${index}`); + done(); + }); + } + assert.equal(isTerminalWriteQueueInFloodMode(term), true); + assert.equal(getTerminalWriteQueueDepth(term), 1); + + enqueueTerminalWrite(term, 20, (done) => { + order.push("new-frame"); + done(); + }, { + dropBytes: 125, + replacePendingKey: "tui-frame", + }); + + assert.deepEqual(dropped, [75]); + releaseActive?.(); + for (let guard = 0; guard < 10 && order.length < MAX_WRITE_QUEUE_ITEMS + 1; guard += 1) { + await waitForQueuedWriteYield(); + } + assert.equal(order.includes("old-frame"), false); + assert.equal(order.filter((entry) => entry.startsWith("ordinary-")).length, MAX_WRITE_QUEUE_ITEMS); + assert.equal(order.at(-1), "new-frame"); +}); diff --git a/components/terminal/runtime/terminalWriteQueue.ts b/components/terminal/runtime/terminalWriteQueue.ts index f1e8c9dad5..b886687a6b 100644 --- a/components/terminal/runtime/terminalWriteQueue.ts +++ b/components/terminal/runtime/terminalWriteQueue.ts @@ -15,6 +15,8 @@ export const WRITE_QUEUE_TURN_BUDGET_MS = 10; export type TerminalWriteQueueOptions = { onDropped?: (bytes: number) => void; dropBytes?: number; + /** Replace older pending writes with the same key before enqueueing. */ + replacePendingKey?: string; deferStart?: boolean; yieldAfter?: boolean; maxDrainBytes?: number; @@ -33,6 +35,7 @@ type QueuedWrite = { type QueuedWriteStep = { bytes: number; dropBytes: number; + replacePendingKey?: string; write: (done: () => void) => void; yieldAfter: boolean; }; @@ -332,6 +335,41 @@ const updateFloodMode = ( } }; +const replacePendingWrites = ( + queue: TerminalWriteQueue, + replacePendingKey: string, +): void => { + let removedBytes = 0; + let droppedBytes = 0; + const retained: QueuedWrite[] = []; + + for (const item of queue.pending) { + const steps = item.steps.filter((step) => { + if (step.replacePendingKey !== replacePendingKey) return true; + removedBytes += step.bytes; + droppedBytes += step.dropBytes; + return false; + }); + if (steps.length === 0) continue; + + retained.push({ + ...item, + bytes: steps.reduce((sum, step) => sum + step.bytes, 0), + dropBytes: steps.reduce((sum, step) => sum + step.dropBytes, 0), + steps, + nextIndex: 0, + yieldAfter: steps.some((step) => step.yieldAfter), + }); + } + + if (removedBytes === 0) return; + queue.pending = retained; + queue.pendingBytes = Math.max(0, queue.pendingBytes - removedBytes); + if (droppedBytes > 0) { + queue.onDropped?.(droppedBytes); + } +}; + export const setTerminalWriteQueueDropHandler = ( term: XTerm, onDropped?: (bytes: number) => void, @@ -406,12 +444,22 @@ export const enqueueTerminalWrite = ( queue.onDropped = terminalWriteQueueDropHandlers.get(term); } + if (options.replacePendingKey) { + replacePendingWrites(queue, options.replacePendingKey); + } + updateFloodMode(queue, bytes); queue.pending.push({ bytes, dropBytes, - steps: [{ bytes, dropBytes, write, yieldAfter: Boolean(options.yieldAfter) }], + steps: [{ + bytes, + dropBytes, + replacePendingKey: options.replacePendingKey, + write, + yieldAfter: Boolean(options.yieldAfter), + }], nextIndex: 0, cancelled: false, yieldAfter: Boolean(options.yieldAfter),