Skip to content
Draft
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
20 changes: 20 additions & 0 deletions components/terminal/runtime/terminalSessionAttachment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import {
attachSessionToTerminal,
getFlowController,
isReplaceableDec2026FullFrame,
notePendingOutputScrollIfEnabled,
resolveAttachSnapshot,
tryAttachSessionToTerminal,
Expand Down Expand Up @@ -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[] = [];
Expand Down
26 changes: 26 additions & 0 deletions components/terminal/runtime/terminalSessionAttachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) => {
Expand Down Expand Up @@ -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).
Expand Down
80 changes: 80 additions & 0 deletions components/terminal/runtime/terminalWriteQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
50 changes: 49 additions & 1 deletion components/terminal/runtime/terminalWriteQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,6 +35,7 @@ type QueuedWrite = {
type QueuedWriteStep = {
bytes: number;
dropBytes: number;
replacePendingKey?: string;
write: (done: () => void) => void;
yieldAfter: boolean;
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
Loading