diff --git a/application/i18n/locales/en/core.ts b/application/i18n/locales/en/core.ts
index fd504323ca..2a66d44d93 100644
--- a/application/i18n/locales/en/core.ts
+++ b/application/i18n/locales/en/core.ts
@@ -677,6 +677,8 @@ Highlight the focused split pane:
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
+ 'settings.terminal.rendering.allowTransparency': 'Allow transparency',
+ 'settings.terminal.rendering.allowTransparency.desc': 'Rasterise glyphs onto a transparent tile rather than onto their background colour. Costs a little quality on low-DPI displays, but lets one cached glyph serve every background it is drawn over — which keeps content that changes the background per cell (animated backgrounds, heatmaps, ANSI art) from re-rasterising every glyph on every frame. Takes effect on new terminal sessions only (xterm cannot change this after open).',
'settings.terminal.rendering.hibernateHiddenTabs': 'Hibernate hidden tabs',
'settings.terminal.rendering.hibernateHiddenTabs.desc': 'Dispose the terminal renderer for off-screen tabs to save memory while keeping the SSH session connected. Skipped during file transfers.',
'settings.terminal.rendering.hibernateHiddenTabsDelay': 'Hibernate delay',
diff --git a/application/syncPayload.ts b/application/syncPayload.ts
index e41b7d205b..3c89ae05fe 100644
--- a/application/syncPayload.ts
+++ b/application/syncPayload.ts
@@ -202,6 +202,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontSmoothing', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
+ 'allowTransparency',
'altAsMeta', 'optionArrowWordJump', 'shiftEnterNewlineEnabled', 'shiftEnterNewlineText',
'kittyKeyboardProtocolEnabled',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
diff --git a/components/settings/tabs/SettingsTerminalTab.tsx b/components/settings/tabs/SettingsTerminalTab.tsx
index 449bb8672f..47a0499b69 100644
--- a/components/settings/tabs/SettingsTerminalTab.tsx
+++ b/components/settings/tabs/SettingsTerminalTab.tsx
@@ -1049,6 +1049,15 @@ function SettingsTerminalTab(props: {
className="w-32"
/>
+
+ updateTerminalSetting("allowTransparency", v)}
+ />
+
{
test("isTerminalViewportScrolledUp is false when buffer is missing", () => {
assert.equal(isTerminalViewportScrolledUp({ rows: 24 } as never), false);
});
+
+/**
+ * Chunk boundaries that land mid-escape must hold back only the split
+ * sequence. Scanning longest-suffix-first held back everything from the
+ * chunk's *first* ESC, so escape-dense frames (60fps TUI, per-cell truecolor
+ * backgrounds) starved xterm for whole chunks at a time and released ~1MB
+ * bursts once a chunk happened to end cleanly.
+ */
+const escapeDenseFrame = (cells: number): string => {
+ let out = SYNC_START + "\x1b[1;1H";
+ for (let i = 0; i < cells; i += 1) {
+ out += `\x1b[0m\x1b[38;5;245m\x1b[48;2;${i % 256};128;200m#`;
+ }
+ return out + SYNC_END;
+};
+
+test("a mid-escape chunk boundary holds back only the split sequence", () => {
+ const state = createSyncBlockFilterState();
+ const frame = escapeDenseFrame(4000);
+ // Split inside the trailing `\x1b[48;2;...m` of some cell.
+ const cut = frame.lastIndexOf("\x1b[48;2;", frame.length - 40) + 6;
+ const emitted = filterSyncBlockClears(frame.slice(0, cut), state);
+
+ assert.ok(
+ state.pending.length < 32,
+ `pending should hold one partial sequence, held ${state.pending.length} bytes`,
+ );
+ assert.equal(emitted + state.pending, frame.slice(0, cut));
+});
+
+test("escape-dense frames stream through without withholding whole chunks", () => {
+ const state = createSyncBlockFilterState();
+ const stream = escapeDenseFrame(4000) + escapeDenseFrame(4000);
+ const CHUNK = 8192;
+ let emitted = "";
+ let emptyEmits = 0;
+
+ for (let i = 0; i < stream.length; i += CHUNK) {
+ const output = filterSyncBlockClears(stream.slice(i, i + CHUNK), state);
+ if (output.length === 0) emptyEmits += 1;
+ emitted += output;
+ assert.ok(
+ state.pending.length < CHUNK,
+ `pending must not accumulate across chunks, reached ${state.pending.length} bytes`,
+ );
+ }
+
+ assert.equal(emptyEmits, 0, "every chunk should release data to xterm");
+ assert.equal(emitted + state.pending, stream);
+});
diff --git a/components/terminal/runtime/filterSyncBlockClears.ts b/components/terminal/runtime/filterSyncBlockClears.ts
index 082fe23d59..915980bb59 100644
--- a/components/terminal/runtime/filterSyncBlockClears.ts
+++ b/components/terminal/runtime/filterSyncBlockClears.ts
@@ -152,7 +152,20 @@ const splitPendingMarkerSuffix = (input: string): { emit: string; pending: strin
// Only suffixes that start with ESC can qualify; skip other start positions
// with a charCode probe so no substring is allocated for them.
- for (let length = input.length; length > 0; length -= 1) {
+ //
+ // Scan SHORTEST suffix first (from the end of the chunk backwards).
+ // `isIncompleteEscapePrefix` walks forward across *complete* sequences and
+ // only reports the incomplete one it eventually reaches, so it also answers
+ // `true` for every longer suffix that merely contains the incomplete tail —
+ // including the one starting at the chunk's first ESC. Scanning longest-first
+ // therefore held back everything from that first ESC onwards instead of just
+ // the split sequence. On escape-dense output (a 60fps TUI painting per-cell
+ // truecolor backgrounds) the first ESC sits within a few bytes of the chunk
+ // start, so whole chunks were withheld and `state.pending` grew across chunks
+ // until one happened to end on a clean boundary — xterm then received nothing
+ // for hundreds of ms followed by a ~1MB burst. The last ESC in the chunk is
+ // the real split point: an incomplete CSI cannot contain a further ESC.
+ for (let length = 1; length <= input.length; length += 1) {
if (input.charCodeAt(input.length - length) !== 0x1b) {
continue;
}
diff --git a/components/terminal/runtime/syncFrameBoundary.test.ts b/components/terminal/runtime/syncFrameBoundary.test.ts
new file mode 100644
index 0000000000..2a97774dc0
--- /dev/null
+++ b/components/terminal/runtime/syncFrameBoundary.test.ts
@@ -0,0 +1,114 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { frameSafeSliceEnd, isInsideSyncBlockAt } from "./syncFrameBoundary";
+
+const H = "\x1b[?2026h";
+const L = "\x1b[?2026l";
+
+test("isInsideSyncBlockAt: open before close is inside", () => {
+ const data = `${H}frame body`;
+ assert.equal(isInsideSyncBlockAt(data, 0, data.length), true);
+});
+
+test("isInsideSyncBlockAt: closed block is not inside", () => {
+ const data = `${H}frame${L}`;
+ assert.equal(isInsideSyncBlockAt(data, 0, data.length), false);
+});
+
+test("isInsideSyncBlockAt: point between close and next open is not inside", () => {
+ const data = `${H}a${L}XX${H}b${L}`;
+ const between = `${H}a${L}X`.length;
+ assert.equal(isInsideSyncBlockAt(data, 0, between), false);
+});
+
+test("isInsideSyncBlockAt: a DECRQM query is not a frame boundary", () => {
+ const data = "\x1b[?2026$pplain text";
+ assert.equal(isInsideSyncBlockAt(data, 0, data.length), false);
+});
+
+test("isInsideSyncBlockAt: a cut mid-close still counts as inside", () => {
+ // startsWith matches whole SYNC_CLOSE in data even when `to` lands inside the
+ // marker. The scan must not flip to closed until the full closer is before `to`.
+ const frame = `${H}${"x".repeat(20)}${L}`;
+ const closeStart = frame.length - L.length;
+ for (let mid = 1; mid < L.length; mid++) {
+ const to = closeStart + mid;
+ assert.equal(
+ isInsideSyncBlockAt(frame, 0, to),
+ true,
+ `mid-close at +${mid} must still be inside the open block`,
+ );
+ }
+ assert.equal(isInsideSyncBlockAt(frame, 0, frame.length), false);
+});
+
+test("frameSafeSliceEnd: a cut inside a frame extends to past its close", () => {
+ const frame = `${H}${"x".repeat(100)}${L}`;
+ const data = `${frame}${frame}`;
+ // Desired cut lands inside the first frame.
+ const cut = H.length + 50;
+ const end = frameSafeSliceEnd(data, 0, cut);
+ assert.equal(end, frame.length, "must extend to the end of the open frame");
+ assert.equal(isInsideSyncBlockAt(data, 0, end), false);
+});
+
+test("frameSafeSliceEnd: a cut between frames is left untouched", () => {
+ const frame = `${H}${"x".repeat(100)}${L}`;
+ const data = `${frame}${frame}`;
+ const cut = frame.length; // exactly on the boundary
+ assert.equal(frameSafeSliceEnd(data, 0, cut), cut);
+});
+
+test("frameSafeSliceEnd: an unterminated frame is held to the end", () => {
+ const data = `${H}${"x".repeat(100)}`; // no close
+ const cut = H.length + 50;
+ assert.equal(frameSafeSliceEnd(data, 0, cut), data.length);
+});
+
+test("frameSafeSliceEnd: plain output is never adjusted", () => {
+ const data = "just some normal terminal output with no sync blocks";
+ assert.equal(frameSafeSliceEnd(data, 0, 20), 20);
+});
+
+test("frameSafeSliceEnd: end at data.length is returned as-is", () => {
+ const data = `${H}x${L}`;
+ assert.equal(frameSafeSliceEnd(data, 0, data.length), data.length);
+});
+
+test("frameSafeSliceEnd: never moves the end backwards", () => {
+ const frame = `${H}${"x".repeat(100)}${L}`;
+ const data = `${frame}tail`;
+ const cut = H.length + 10;
+ const end = frameSafeSliceEnd(data, 0, cut);
+ assert.ok(end >= cut, "the adjusted end must not precede the desired end");
+});
+
+test("frameSafeSliceEnd: a cut inside the close marker includes the full marker", () => {
+ const frame = `${H}${"x".repeat(20)}${L}`;
+ const data = `${frame}tail`;
+ // Land mid-close: after ESC[?2026 but before the final `l`.
+ const closeStart = frame.length - L.length;
+ for (let mid = 1; mid < L.length; mid++) {
+ const cut = closeStart + mid;
+ const end = frameSafeSliceEnd(data, 0, cut);
+ assert.equal(
+ end,
+ frame.length,
+ `mid-close cut at +${mid} must extend to full close marker`,
+ );
+ assert.equal(
+ data.slice(0, end).endsWith(L),
+ true,
+ "slice must end on a complete close marker",
+ );
+ }
+});
+
+test("frameSafeSliceEnd: a cut exactly at the close start still completes the frame", () => {
+ const frame = `${H}${"x".repeat(20)}${L}`;
+ const data = `${frame}next`;
+ const closeStart = frame.length - L.length;
+ // At the start of the close the block is still open, so extend past it.
+ assert.equal(frameSafeSliceEnd(data, 0, closeStart), frame.length);
+});
diff --git a/components/terminal/runtime/syncFrameBoundary.ts b/components/terminal/runtime/syncFrameBoundary.ts
new file mode 100644
index 0000000000..c3694e47f0
--- /dev/null
+++ b/components/terminal/runtime/syncFrameBoundary.ts
@@ -0,0 +1,147 @@
+/**
+ * DEC 2026 synchronized-output frame boundaries, for slicing terminal output
+ * without tearing a frame.
+ *
+ * A modern full-screen TUI (Tachikoma, and most others) enters the alternate
+ * screen once, then delimits every rendered frame with a DEC private mode 2026
+ * synchronized-output block: `\x1b[?2026h` … full frame … `\x1b[?2026l`. xterm
+ * buffers rendering while the block is open and paints once on close, so a
+ * frame is coherent as long as its whole block reaches xterm before xterm's
+ * 1000ms synchronized-output timeout expires (RenderService SyncOutputHandler).
+ *
+ * The write coalescer/slicer, however, is only aware of alt-screen DECSET
+ * toggles — it never sees 2026 — so it slices a continuous frame stream by
+ * byte size and hands the shards to xterm across `setTimeout` gaps. A shard
+ * boundary that lands inside an open block leaves xterm mid-frame; if the rest
+ * of the frame arrives after the 1000ms timeout, xterm force-flushes a partial
+ * frame and the display tears.
+ *
+ * These helpers let the slicer keep every 2026 block whole.
+ */
+
+const SYNC_OPEN = "\x1b[?2026h";
+const SYNC_CLOSE = "\x1b[?2026l";
+/** 8-bit C1 CSI form of the same markers (`CSI` = 0x9B). xterm accepts both. */
+const SYNC_OPEN_C1 = "\x9b?2026h";
+const SYNC_CLOSE_C1 = "\x9b?2026l";
+
+const indexOfSyncMarkerPrefix = (data: string, from: number): number => {
+ const a = data.indexOf("\x1b[?2026", from);
+ const b = data.indexOf("\x9b?2026", from);
+ if (a === -1) return b;
+ if (b === -1) return a;
+ return Math.min(a, b);
+};
+
+/**
+ * Sync-block nesting state at `to`, scanning `data` from `from`.
+ *
+ * DEC 2026 does not nest in practice (a frame is one open/close pair), so this
+ * tracks "open" as a boolean latched by the most recent marker rather than a
+ * depth count. Returns whether an open block is still unclosed at `to`.
+ * Recognizes both 7-bit ESC CSI and 8-bit C1 CSI forms (Codex P2).
+ */
+export function isInsideSyncBlockAt(data: string, from: number, to: number): boolean {
+ let open = false;
+ let i = indexOfSyncMarkerPrefix(data, from);
+ while (i !== -1 && i < to) {
+ if (data.startsWith(SYNC_OPEN, i) || data.startsWith(SYNC_OPEN_C1, i)) {
+ const len = data.startsWith(SYNC_OPEN, i) ? SYNC_OPEN.length : SYNC_OPEN_C1.length;
+ // Only latch open once the whole opener is before `to`. A cut mid-open
+ // leaves the block not yet entered for this scan.
+ if (i + len > to) break;
+ open = true;
+ i += len;
+ } else if (data.startsWith(SYNC_CLOSE, i) || data.startsWith(SYNC_CLOSE_C1, i)) {
+ const len = data.startsWith(SYNC_CLOSE, i) ? SYNC_CLOSE.length : SYNC_CLOSE_C1.length;
+ // Only latch closed once the whole closer is before `to`. `startsWith`
+ // matches against full `data`, so a mid-close cut would otherwise see
+ // the complete marker and report "outside" while the trailing `l` is
+ // still past `to` — splitting the close sequence itself.
+ if (i + len > to) break;
+ open = false;
+ i += len;
+ } else {
+ // A different `?2026` sequence (e.g. a DECRQM query `\x1b[?2026$p`) — not
+ // a frame boundary; step past this ESC/C1 and keep scanning.
+ i += 1;
+ }
+ i = indexOfSyncMarkerPrefix(data, i);
+ }
+ return open;
+}
+
+/**
+ * If `pos` lands strictly inside a DEC 2026 close marker (`ESC[?2026l`), return
+ * the index just past that full marker; otherwise return `pos` unchanged.
+ *
+ * Defence in depth with {@link isInsideSyncBlockAt}: that helper already keeps
+ * a mid-close cut "inside" so the open-block path extends past the closer, but
+ * this also catches mid-close cuts if a caller ever uses a looser inside check.
+ * Without either fix, a large-write slicer can split `\x1b[?2026` from the
+ * trailing `l` and leave xterm stuck in synchronized-output mode.
+ */
+function extendPastCloseMarkerIfSplit(
+ data: string,
+ offset: number,
+ pos: number,
+): number {
+ if (pos <= offset || pos >= data.length) return pos;
+ // pos is strictly inside SYNC_CLOSE when some candidateStart < pos and
+ // candidateStart + SYNC_CLOSE.length > pos, and data starts with SYNC_CLOSE
+ // there. Check each proper prefix length that ends at pos.
+ for (const closer of [SYNC_CLOSE, SYNC_CLOSE_C1]) {
+ for (let k = 1; k < closer.length; k++) {
+ const candidateStart = pos - k;
+ if (candidateStart < offset) break;
+ if (data.startsWith(closer, candidateStart)) {
+ return candidateStart + closer.length;
+ }
+ }
+ }
+ return pos;
+}
+
+/**
+ * A slice end at or after `desiredEnd` that never falls strictly inside an open
+ * DEC 2026 block, and never splits a close marker.
+ *
+ * If `desiredEnd` lands inside an open frame, it is pushed forward to just past
+ * that frame's `\x1b[?2026l`. If the frame never closes within `data`, the end
+ * is pushed to `data.length` so the incomplete frame is held for the next
+ * write rather than emitted in pieces. If `desiredEnd` lands mid-close marker
+ * after a completed block, the cut is extended to include the full marker.
+ *
+ * Never moves the end backwards, so it composes with the slicer's other
+ * boundary rules (which only ever shrink a slice).
+ */
+export function frameSafeSliceEnd(
+ data: string,
+ offset: number,
+ desiredEnd: number,
+): number {
+ if (desiredEnd >= data.length) return data.length;
+ // Never leave a half-written close marker at a slice boundary — even when
+ // the block is already considered closed at desiredEnd.
+ const end = extendPastCloseMarkerIfSplit(data, offset, desiredEnd);
+ if (!isInsideSyncBlockAt(data, offset, end)) return end;
+ const closeA = data.indexOf(SYNC_CLOSE, end);
+ const closeB = data.indexOf(SYNC_CLOSE_C1, end);
+ let close = -1;
+ let closeLen = SYNC_CLOSE.length;
+ if (closeA === -1) {
+ close = closeB;
+ closeLen = SYNC_CLOSE_C1.length;
+ } else if (closeB === -1) {
+ close = closeA;
+ closeLen = SYNC_CLOSE.length;
+ } else if (closeA <= closeB) {
+ close = closeA;
+ closeLen = SYNC_CLOSE.length;
+ } else {
+ close = closeB;
+ closeLen = SYNC_CLOSE_C1.length;
+ }
+ if (close === -1) return data.length;
+ return close + closeLen;
+}
diff --git a/components/terminal/runtime/terminalFlowConstants.ts b/components/terminal/runtime/terminalFlowConstants.ts
index fe43635704..de34450d39 100644
--- a/components/terminal/runtime/terminalFlowConstants.ts
+++ b/components/terminal/runtime/terminalFlowConstants.ts
@@ -8,6 +8,11 @@ import terminalFlowConstants from "../../../infrastructure/config/terminalFlowCo
*/
export const FLOW_HIGH_WATER_MARK = terminalFlowConstants.FLOW_HIGH_WATER_MARK;
export const FLOW_LOW_WATER_MARK = terminalFlowConstants.FLOW_LOW_WATER_MARK;
+// Relaxed watermarks for local shells, which have no network to overwhelm.
+export const LOCAL_FLOW_HIGH_WATER_MARK =
+ terminalFlowConstants.LOCAL_FLOW_HIGH_WATER_MARK;
+export const LOCAL_FLOW_LOW_WATER_MARK =
+ terminalFlowConstants.LOCAL_FLOW_LOW_WATER_MARK;
export const FLOW_CHAR_COUNT_ACK_SIZE = terminalFlowConstants.FLOW_CHAR_COUNT_ACK_SIZE;
export const MAX_PENDING_WRITE_COALESCE_BYTES =
terminalFlowConstants.MAX_PENDING_WRITE_COALESCE_BYTES;
diff --git a/components/terminal/runtime/terminalFrameGate.test.ts b/components/terminal/runtime/terminalFrameGate.test.ts
new file mode 100644
index 0000000000..8650f3a345
--- /dev/null
+++ b/components/terminal/runtime/terminalFrameGate.test.ts
@@ -0,0 +1,305 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ apportionFrameGateIngress,
+ collapseAndSplit,
+ endsWithSyncOpenerPrefix,
+ isDroppableVisualPayload,
+ makesFullRepaint,
+ payloadContainsSgr,
+ payloadMayAutowrapScroll,
+ viewportRepaintCoverage,
+} from "./terminalFrameGate.ts";
+
+const ON = "\x1b[?2026h";
+const OFF = "\x1b[?2026l";
+const HOME = "\x1b[1;1H";
+const frame = (paint: string) => `${ON}${HOME}${paint}${OFF}`;
+// Most collapse tests isolate the frame-boundary logic; a permissive predicate
+// treats every homing successor as a full repaint.
+const always = () => true;
+
+test("no frames: everything is complete, nothing held", () => {
+ assert.deepEqual(collapseAndSplit("plain text", always), {
+ complete: "plain text",
+ partial: "",
+ dropped: 0,
+ });
+});
+
+test("a trailing unterminated frame is held as partial", () => {
+ const a = frame("A");
+ const partialFrame = `${ON}${HOME}half`;
+ assert.deepEqual(collapseAndSplit(a + partialFrame, always), {
+ complete: a,
+ partial: partialFrame,
+ dropped: 0,
+ });
+});
+
+test("collapses a run of full-repaint frames to the last", () => {
+ const a = frame("A");
+ const b = frame("B");
+ const c = frame("C");
+ const r = collapseAndSplit(a + b + c, always);
+ assert.equal(r.complete, c);
+ assert.equal(r.dropped, a.length + b.length);
+});
+
+test("collapses complete frames but still holds a trailing partial", () => {
+ const a = frame("A");
+ const b = frame("B");
+ const partialFrame = `${ON}${HOME}new`;
+ const r = collapseAndSplit(a + b + partialFrame, always);
+ assert.equal(r.complete, b);
+ assert.equal(r.partial, partialFrame);
+ assert.equal(r.dropped, a.length);
+});
+
+test("does not drop across a non-empty gap between frames", () => {
+ const a = frame("A");
+ const b = frame("B");
+ const input = a + "\r\n" + b;
+ assert.deepEqual(collapseAndSplit(input, always), { complete: input, partial: "", dropped: 0 });
+});
+
+test("does not drop when the successor is not a full repaint", () => {
+ const a = frame("AAAA");
+ const b = frame("B");
+ const input = a + b;
+ // Predicate rejects the successor → nothing collapses.
+ assert.deepEqual(collapseAndSplit(input, () => false), { complete: input, partial: "", dropped: 0 });
+});
+
+test("does not drop a frame whose payload is not purely visual", () => {
+ const bell = `${ON}${HOME}ping\x07${OFF}`; // carries a BEL
+ const b = frame("B");
+ const input = bell + b;
+ assert.deepEqual(collapseAndSplit(input, always), { complete: input, partial: "", dropped: 0 });
+});
+
+// ---- isDroppableVisualPayload ---------------------------------------------
+
+test("droppable payload: cursor moves, SGR, erase and text are allowed", () => {
+ assert.equal(isDroppableVisualPayload("\x1b[1;1H\x1b[38;5;5mabc\x1b[K"), true);
+ assert.equal(isDroppableVisualPayload("\r\tplain text"), true);
+ assert.equal(isDroppableVisualPayload("\nrepaint"), false, "LF can scroll scrollback");
+ assert.equal(isDroppableVisualPayload("\r\n\tplain text"), false, "LF not droppable");
+ assert.equal(isDroppableVisualPayload("\x1b[2Jfull"), true);
+});
+
+test("un-droppable payload: side-effecting sequences are rejected", () => {
+ assert.equal(isDroppableVisualPayload("bell\x07"), false, "BEL");
+ assert.equal(isDroppableVisualPayload("\x1b]0;title\x07"), false, "OSC");
+ assert.equal(isDroppableVisualPayload("\x1b[6n"), false, "device status report query");
+ assert.equal(isDroppableVisualPayload("\x1b[?25l"), false, "private mode");
+ assert.equal(isDroppableVisualPayload("\x1b[?1049h"), false, "alt-screen");
+ assert.equal(isDroppableVisualPayload("\x1bP1;2q\x1b\\"), false, "DCS");
+ assert.equal(isDroppableVisualPayload("\x1b[c"), false, "device attributes");
+ assert.equal(isDroppableVisualPayload("\x1b[1S"), false, "SU scrolls buffer");
+ assert.equal(isDroppableVisualPayload("\x1b[1T"), false, "SD scrolls buffer");
+});
+
+// ---- viewportRepaintCoverage / makesFullRepaint ---------------------------
+
+test("coverage counts distinct written cells, with wrap", () => {
+ // Paints only count after an origin reset (CUP/home — not ED2 alone).
+ assert.equal(viewportRepaintCoverage(`${HOME}abcd`, 4, 1), 4);
+ assert.equal(viewportRepaintCoverage(`${HOME}abcd`, 2, 2), 4); // wraps to second row
+ assert.equal(viewportRepaintCoverage(`${HOME}ab`, 4, 1), 2);
+ assert.equal(viewportRepaintCoverage("abcd", 4, 1), 0, "no origin → no coverage");
+});
+
+test("coverage honors cursor positioning and erases", () => {
+ // ED2 is not coverage — may be stripped later when scrolled up.
+ assert.equal(viewportRepaintCoverage("\x1b[2J", 4, 2), 0, "ED2 grants no coverage credit");
+ // ED2 does not home the cursor; paints after ED2 without CUP stay uncounted.
+ assert.equal(
+ viewportRepaintCoverage(`\x1b[2J${"z".repeat(8)}`, 4, 2),
+ 0,
+ "ED2 alone is not a known origin",
+ );
+ assert.equal(viewportRepaintCoverage("\x1b[1;1H\x1b[K", 4, 2), 4, "erase-line covers one row");
+ // CUP to row 2 then one char covers a single cell there.
+ assert.equal(viewportRepaintCoverage("\x1b[2;3Hx", 4, 2), 1);
+});
+
+test("makesFullRepaint accepts a whole-screen paint and rejects a small update", () => {
+ const cols = 4;
+ const rows = 2;
+ const fullPaint = `${HOME}${"z".repeat(cols * rows)}`; // writes every cell
+ const smallPaint = `${HOME}z`; // one cell
+ assert.equal(makesFullRepaint(fullPaint, cols, rows), true);
+ assert.equal(makesFullRepaint(smallPaint, cols, rows), false);
+ // SGR-heavy small update must not pass on byte length alone.
+ const sgrHeavySmall = `${HOME}\x1b[38;2;1;2;3m\x1b[48;2;4;5;6mz`;
+ assert.equal(makesFullRepaint(sgrHeavySmall, cols, rows), false);
+});
+
+test("makesFullRepaint rejects partial paints even when well above 40% coverage", () => {
+ const cols = 10;
+ const rows = 10;
+ // 60% of cells — previously accepted under the 0.4 bar, must now fail.
+ const partial = `${HOME}${"z".repeat(Math.floor(cols * rows * 0.6))}`;
+ assert.equal(makesFullRepaint(partial, cols, rows), false);
+ // 98% is still short of the 0.99 bar (must not drop prior frames).
+ const almost = `${HOME}${"z".repeat(98)}`;
+ assert.equal(makesFullRepaint(almost, cols, rows), false);
+ // ED2 alone is not a full repaint (may be stripped when scrolled up).
+ assert.equal(makesFullRepaint("\x1b[2J", cols, rows), false);
+ // Near-full (>= 99%) is accepted: 99 of 100 cells.
+ const nearFull = `${HOME}${"z".repeat(99)}`;
+ assert.equal(makesFullRepaint(nearFull, cols, rows), true);
+ // Exact full coverage is accepted.
+ const full = `${HOME}${"z".repeat(100)}`;
+ assert.equal(makesFullRepaint(full, cols, rows), true);
+});
+
+test("collapse keeps SGR-bearing frames when the successor has no SGR", () => {
+ const sgrOnly = `${ON}${HOME}\x1b[31m${OFF}`;
+ // Full-viewport successor without SGR would otherwise drop the SGR frame and
+ // leave xterm on the previous color/style for the plain paint.
+ const plainFull = `${ON}${HOME}${"x".repeat(8)}${OFF}`;
+ const buffer = sgrOnly + plainFull;
+ const result = collapseAndSplit(buffer, (content) => makesFullRepaint(content, 4, 2));
+ assert.equal(result.dropped, 0, "must not drop SGR when successor omits SGR");
+ assert.ok(result.complete.includes("\x1b[31m"));
+ assert.equal(payloadContainsSgr("\x1b[1;1H\x1b[31mtext"), true);
+ assert.equal(payloadContainsSgr("\x1b[1;1Hplain"), false);
+});
+
+test("collapse drops only when coverage proves a full repaint", () => {
+ const cols = 4;
+ const rows = 2;
+ const pred = (c: string) => makesFullRepaint(c, cols, rows);
+ const stale = frame("AAAAAAAA");
+ const fullNext = frame("z".repeat(cols * rows));
+ const smallNext = frame("z");
+ // Full-repaint successor → predecessor dropped.
+ assert.equal(collapseAndSplit(stale + fullNext, pred).dropped, stale.length);
+ // Small successor → nothing dropped.
+ assert.equal(collapseAndSplit(stale + smallNext, pred).dropped, 0);
+ // Mid-coverage successor (~50%) must not drop the predecessor.
+ const halfNext = frame("z".repeat(Math.floor((cols * rows) / 2)));
+ assert.equal(collapseAndSplit(stale + halfNext, pred).dropped, 0);
+});
+
+// ---- ingress apportioning --------------------------------------------------
+
+test("ingress apportioning always sums back to the total", () => {
+ const cases: Array<[number, number, number, number, number]> = [
+ [1000, 1000, 400, 400, 200],
+ [1500, 1000, 400, 400, 200],
+ [700, 1000, 400, 400, 200],
+ [999, 1000, 333, 333, 334],
+ [0, 0, 0, 0, 0],
+ [500, 500, 0, 0, 500],
+ [500, 500, 500, 0, 0],
+ [500, 500, 0, 500, 0],
+ ];
+ for (const [total, totalChars, fwd, drop, held] of cases) {
+ const s = apportionFrameGateIngress(total, totalChars, fwd, drop, held);
+ assert.equal(s.forward + s.dropped + s.held, total);
+ assert.ok(s.forward >= 0 && s.dropped >= 0 && s.held >= 0);
+ }
+});
+
+test("ingress apportioning routes bytes to the right bucket in the exact case", () => {
+ assert.deepEqual(apportionFrameGateIngress(1000, 1000, 400, 400, 200), {
+ forward: 400,
+ dropped: 400,
+ held: 200,
+ });
+});
+
+test("ED3 (clear scrollback) makes a frame un-droppable", () => {
+ assert.equal(isDroppableVisualPayload("\x1b[1;1H\x1b[2Jrepaint"), true, "ED2 viewport clear is fine");
+ assert.equal(isDroppableVisualPayload("\x1b[1;1H\x1b[3Jrepaint"), false, "ED3 clears scrollback");
+});
+
+test("ED3 does not count as viewport coverage for full-repaint drops", () => {
+ const cols = 4;
+ const rows = 2;
+ // ED3 alone must not mark any viewport cells.
+ assert.equal(viewportRepaintCoverage("\x1b[3J", cols, rows), 0, "ED3 covers zero viewport cells");
+ assert.equal(makesFullRepaint("\x1b[3J", cols, rows), false, "ED3 is not a full viewport repaint");
+ // ED2 also grants no coverage (may be stripped when scrolled up).
+ assert.equal(viewportRepaintCoverage("\x1b[2J", cols, rows), 0, "ED2 grants no coverage credit");
+ // A successor that only does ED3 must not justify dropping a prior visual frame.
+ const stale = frame("AAAAAAAA");
+ const ed3Only = frame("\x1b[3J");
+ const pred = (c: string) => makesFullRepaint(c, cols, rows);
+ assert.equal(collapseAndSplit(stale + ed3Only, pred).dropped, 0, "ED3 successor must not drop prior frame");
+});
+
+test("8-bit C1 controls make a frame un-droppable", () => {
+ // C1 CSI (0x9B) and OSC (0x9D) would otherwise fall through as printable text.
+ assert.equal(isDroppableVisualPayload("\x9b1;1Hplain"), false, "C1 CSI");
+ assert.equal(isDroppableVisualPayload("\x9d0;title\x07"), false, "C1 OSC");
+ assert.equal(isDroppableVisualPayload("hello\x90data\x9c"), false, "C1 DCS");
+ // Unicode cell text above the C1 range remains droppable.
+ assert.equal(isDroppableVisualPayload("café中文"), true, "printable Unicode cell text");
+});
+
+test("coverage ignores OSC/DCS control-string payloads (not cell paints)", () => {
+ const cols = 4;
+ const rows = 2;
+ // Long OSC title would falsely fill the viewport if payload bytes were counted.
+ const oscBel = "\x1b]0;" + "T".repeat(cols * rows * 2) + "\x07";
+ const oscSt = "\x1b]0;" + "T".repeat(cols * rows * 2) + "\x1b\\";
+ assert.equal(viewportRepaintCoverage(oscBel, cols, rows), 0, "OSC … BEL covers zero cells");
+ assert.equal(viewportRepaintCoverage(oscSt, cols, rows), 0, "OSC … ST covers zero cells");
+ assert.equal(makesFullRepaint(oscBel, cols, rows), false, "OSC-only is not a full repaint");
+ // DCS / APC / PM / SOS payloads are likewise not paints.
+ assert.equal(viewportRepaintCoverage("\x1bP1$r\x1b\\", cols, rows), 0, "DCS");
+ assert.equal(viewportRepaintCoverage("\x1b_payload\x1b\\", cols, rows), 0, "APC");
+ assert.equal(viewportRepaintCoverage("\x1b^payload\x1b\\", cols, rows), 0, "PM");
+ assert.equal(viewportRepaintCoverage("\x1bXpayload\x1b\\", cols, rows), 0, "SOS");
+ // C1 forms: OSC (0x9D) + BEL, DCS (0x90) + C1 ST (0x9C).
+ assert.equal(viewportRepaintCoverage("\x9d0;title\x07", cols, rows), 0, "C1 OSC");
+ assert.equal(viewportRepaintCoverage("\x90data\x9c", cols, rows), 0, "C1 DCS");
+ // Real cell writes after origin + OSC still count; OSC itself does not inflate.
+ assert.equal(viewportRepaintCoverage(`${HOME}${oscBel}ab`, cols, rows), 2, "cells after OSC still count");
+ // A short paint padded with OSC must not pass the full-repaint bar.
+ const shortPlusOsc = `${HOME}z${oscBel}`;
+ assert.equal(makesFullRepaint(shortPlusOsc, cols, rows), false, "OSC must not inflate partial paint");
+ // Predecessor must not drop when successor is only OSC noise.
+ const stale = frame("AAAAAAAA");
+ const oscOnly = frame(oscBel);
+ const pred = (c: string) => makesFullRepaint(c, cols, rows);
+ assert.equal(collapseAndSplit(stale + oscOnly, pred).dropped, 0, "OSC successor must not drop prior frame");
+ // CSI H only after paints must not retroactively validate those paints.
+ assert.equal(
+ viewportRepaintCoverage(`ab${HOME}`, cols, rows),
+ 0,
+ "origin at end does not count preceding cells",
+ );
+});
+
+test("holds back a split sync opener as partial", () => {
+ // Buffer ends mid-opener (ESC[?20); it must be held, not forwarded.
+ const r = collapseAndSplit("hello\x1b[?20", always);
+ assert.equal(r.complete, "hello");
+ assert.equal(r.partial, "\x1b[?20");
+ assert.equal(r.dropped, 0);
+});
+
+test("endsWithSyncOpenerPrefix detects a split opener tail", () => {
+ assert.equal(endsWithSyncOpenerPrefix("abc\x1b[?2026"), true);
+ assert.equal(endsWithSyncOpenerPrefix("abc\x1b"), true);
+ assert.equal(endsWithSyncOpenerPrefix("abc\x1b[?2026h"), false, "complete opener is not a prefix hold");
+ assert.equal(endsWithSyncOpenerPrefix("plain"), false);
+});
+
+test("payloadMayAutowrapScroll detects wrap past bottom-right", () => {
+ const cols = 4;
+ const rows = 2;
+ // Exact fill of viewport from home: last cell sets wrap pending, no scroll yet.
+ const exact = `\x1b[H${"x".repeat(cols * rows)}`;
+ assert.equal(payloadMayAutowrapScroll(exact, cols, rows), false);
+ // One more cell after last: delayed autowrap scrolls.
+ const overflow = `\x1b[H${"x".repeat(cols * rows + 1)}`;
+ assert.equal(payloadMayAutowrapScroll(overflow, cols, rows), true);
+ assert.equal(payloadMayAutowrapScroll("x".repeat(cols * rows + 1), cols, rows), true);
+});
diff --git a/components/terminal/runtime/terminalFrameGate.ts b/components/terminal/runtime/terminalFrameGate.ts
new file mode 100644
index 0000000000..783f8d7e14
--- /dev/null
+++ b/components/terminal/runtime/terminalFrameGate.ts
@@ -0,0 +1,573 @@
+/**
+ * Frame-rate gate for full-screen animated TUIs.
+ *
+ * A TUI like TryIt.jl emits every frame as a DEC 2026 synchronized-output block
+ * that homes the cursor and repaints every cell:
+ *
+ * ESC[?2026h ESC[1;1H ESC[?2026l
+ *
+ * At ~60 fps each frame is ~140 KB. xterm.js can render that rate, but only if
+ * it is never more than a frame or two behind — otherwise frames queue up and
+ * the display (and the keyboard echo waiting behind it) runs up to a second
+ * late. The flow-control watermark bounds that backlog by *pausing* the source,
+ * which throttles the animation. This gate instead bounds it by *dropping*
+ * superseded frames: when a full-repaint frame is buffered behind another, only
+ * the last is visible (the next repaints every cell the previous drew), so the
+ * earlier one can be skipped. The source is never paused, so the animation keeps
+ * its full rate while the backlog — and the latency — stays small.
+ *
+ * Dropping is deliberately conservative, and proven rather than guessed:
+ * - the dropped frame must be a droppable *visual* payload (allowlist of cursor
+ * moves, SGR, erase and cell text — never a bell, device query, OSC, DCS/APC
+ * or private mode whose side effect would be lost); and
+ * - its successor must *demonstrably* repaint the whole viewport, measured by
+ * simulating the writes and counting covered cells — not by raw byte length,
+ * which SGR escapes inflate.
+ *
+ * This module is the pure buffer transform. It has no state and no side
+ * effects; the caller owns the per-terminal buffer, the accounting and the
+ * fail-open handling for frames that never complete.
+ */
+
+const SYNC_ON = "\x1b[?2026h";
+const SYNC_OFF = "\x1b[?2026l";
+const SYNC_ON_C1 = "\x9b?2026h";
+const SYNC_OFF_C1 = "\x9b?2026l";
+
+/** Length of a trailing run of `s` that is a proper (non-empty) prefix of the
+ * sync opener — 7-bit ESC CSI or 8-bit C1 CSI form. 0 when none. */
+const trailingSyncOpenerPrefixLen = (s: string): number => {
+ let best = 0;
+ for (const opener of [SYNC_ON, SYNC_ON_C1]) {
+ const max = Math.min(opener.length - 1, s.length);
+ for (let k = max; k >= 1; k--) {
+ if (s.endsWith(opener.slice(0, k))) {
+ best = Math.max(best, k);
+ break;
+ }
+ }
+ }
+ return best;
+};
+
+/** True when `s` ends with a split sync opener (a proper prefix of `ESC[?2026h`). */
+export const endsWithSyncOpenerPrefix = (s: string): boolean =>
+ trailingSyncOpenerPrefixLen(s) > 0;
+
+/** CSI final bytes that only move the cursor, set SGR, or erase. */
+const DROPPABLE_CSI_FINALS = new Set([
+ "A", "B", "C", "D", "E", "F", "G", "H", "f", // cursor moves / positioning
+ "d", "`", // line / column position (VPA / HPA)
+ "m", // SGR
+ "J", "K", // erase in display / line
+ // SU/SD (S/T) intentionally omitted: they scroll the buffer/region and can
+ // mutate history; a later full repaint restores cells but not lost scroll
+ // (Codex P2 on d2e6999e).
+]);
+/**
+ * C0 controls that only move the cursor without mutating scrollback.
+ * LF (`\n`) is intentionally excluded: on the normal buffer with the cursor on
+ * the bottom row, xterm scrolls and can add a history line. Dropping such a
+ * frame before xterm sees it loses scrollback that a later full repaint cannot
+ * restore (while the backend has already been acked).
+ */
+const DROPPABLE_C0 = new Set(["\r", "\t", "\b"]);
+
+/**
+ * True when `content` contains at least one CSI SGR (`…m`) sequence.
+ * Used so a frame that only changes rendition is not dropped unless its
+ * successor also re-establishes SGR (otherwise xterm keeps pre-drop colors).
+ */
+export const payloadContainsSgr = (content: string): boolean => {
+ let i = 0;
+ while (i < content.length) {
+ if (content[i] === "\x1b" && content[i + 1] === "[") {
+ let j = i + 2;
+ while (j < content.length) {
+ const c = content.charCodeAt(j);
+ if (c >= 0x30 && c <= 0x3f) {
+ j++;
+ continue;
+ }
+ if (c >= 0x20 && c <= 0x2f) {
+ j++;
+ continue;
+ }
+ if (content[j] === "m") return true;
+ i = j + 1;
+ break;
+ }
+ if (j >= content.length) return false;
+ continue;
+ }
+ i++;
+ }
+ return false;
+};
+
+/**
+ * True when every byte of `content` is a cursor move, SGR, erase, or cell text
+ * — i.e. dropping the frame loses nothing but pixels a full repaint overwrites.
+ * Anything else (BEL, device query/report, OSC, DCS/APC/PM/SOS, a private-mode
+ * or alternate-screen toggle, an intermediate-byte CSI) makes the frame
+ * un-droppable, since its side effect would never reach xterm.
+ */
+export const isDroppableVisualPayload = (content: string): boolean => {
+ let i = 0;
+ while (i < content.length) {
+ const ch = content[i];
+ const code = content.charCodeAt(i);
+ if (ch === "\x1b") {
+ if (content[i + 1] !== "[") return false; // only CSI; reject OSC/DCS/APC/single-char ESC
+ let j = i + 2;
+ let priv = false;
+ let params = "";
+ while (j < content.length) {
+ const c = content.charCodeAt(j);
+ if (c >= 0x30 && c <= 0x3f) {
+ if (c === 0x3c || c === 0x3d || c === 0x3e || c === 0x3f) priv = true; // < = > ?
+ else params += content[j];
+ j++;
+ } else {
+ break;
+ }
+ }
+ if (j < content.length && content.charCodeAt(j) >= 0x20 && content.charCodeAt(j) <= 0x2f) {
+ return false; // intermediate byte — uncommon, treat as un-droppable
+ }
+ const final = content[j];
+ if (final === undefined) return false; // incomplete CSI
+ if (priv || !DROPPABLE_CSI_FINALS.has(final)) return false;
+ // `CSI 3 J` (ED3) clears the saved scrollback, not just the viewport — a
+ // side effect a repaint does not restore, so a frame carrying it is not
+ // droppable.
+ if (final === "J" && params.split(";").includes("3")) return false;
+ i = j + 1;
+ continue;
+ }
+ if (code < 0x20) {
+ if (!DROPPABLE_C0.has(ch)) return false; // BEL and other C0 side effects
+ i++;
+ continue;
+ }
+ if (code === 0x7f) return false; // DEL
+ // 8-bit C1 controls (0x80–0x9F): CSI (0x9B), OSC (0x9D), DCS (0x90), …
+ // xterm accepts these as equivalents of the ESC-prefixed forms. Treat any
+ // C1 as un-droppable so title/mode/cursor side effects are never skipped
+ // when a frame is collapsed (C1 bytes would otherwise fall through as
+ // printable cell text).
+ if (code >= 0x80 && code <= 0x9f) return false;
+ i++; // printable / Unicode cell text
+ }
+ return true;
+};
+
+/**
+ * Count the distinct viewport cells `content` writes, by simulating cursor
+ * movement, cell output and erases against a `cols`×`rows` grid. Used to prove a
+ * frame repaints (nearly) the whole screen before an earlier frame is dropped.
+ */
+/**
+ * True when writing `content` from the default (0,0) cursor can trigger xterm
+ * delayed autowrap into a scroll (past the bottom-right cell). Such frames must
+ * not be dropped: the successor full-repaint restores cells but not scrollback
+ * history lines created by the wrap (Codex P2).
+ */
+export const payloadMayAutowrapScroll = (
+ content: string,
+ cols: number,
+ rows: number,
+): boolean => {
+ if (cols <= 0 || rows <= 0) return true;
+ let row = 0;
+ let col = 0;
+ let wrapPending = false;
+ const applyCsi = (paramStart: number): number => {
+ let j = paramStart;
+ let params = "";
+ while (j < content.length) {
+ const c = content.charCodeAt(j);
+ if (c >= 0x30 && c <= 0x3f) { params += content[j]; j++; } else break;
+ }
+ while (j < content.length && content.charCodeAt(j) >= 0x20 && content.charCodeAt(j) <= 0x2f) j++;
+ const final = content[j];
+ const nums = params.split(";").map((p) => (p === "" ? undefined : parseInt(p, 10)));
+ const n0 = nums[0];
+ if (final === "H" || final === "f") {
+ row = Math.max(0, Math.min(rows - 1, (n0 ?? 1) - 1));
+ col = Math.max(0, Math.min(cols - 1, (nums[1] ?? 1) - 1));
+ wrapPending = false;
+ } else if (final === "A") { row = Math.max(0, row - (n0 ?? 1)); wrapPending = false; }
+ else if (final === "B" || final === "E") { row = Math.min(rows - 1, row + (n0 ?? 1)); wrapPending = false; }
+ else if (final === "C") { col = Math.min(cols - 1, col + (n0 ?? 1)); wrapPending = false; }
+ else if (final === "D") { col = Math.max(0, col - (n0 ?? 1)); wrapPending = false; }
+ else if (final === "G" || final === "`") { col = Math.max(0, Math.min(cols - 1, (n0 ?? 1) - 1)); wrapPending = false; }
+ else if (final === "d") { row = Math.max(0, Math.min(rows - 1, (n0 ?? 1) - 1)); wrapPending = false; }
+ return final === undefined ? content.length : j + 1;
+ };
+ let i = 0;
+ while (i < content.length) {
+ const ch = content[i];
+ const code = content.charCodeAt(i);
+ if (ch === "\x1b") {
+ if (content[i + 1] === "[") {
+ i = applyCsi(i + 2);
+ continue;
+ }
+ i += content[i + 1] === undefined ? 1 : 2;
+ continue;
+ }
+ if (code === 0x9b) {
+ i = applyCsi(i + 1);
+ continue;
+ }
+ if (code < 0x20) {
+ if (ch === "\n") {
+ wrapPending = false;
+ row += 1;
+ if (row >= rows) return true;
+ col = 0;
+ } else if (ch === "\r") {
+ col = 0;
+ wrapPending = false;
+ } else if (ch === "\b") {
+ col = Math.max(0, col - 1);
+ wrapPending = false;
+ } else if (ch === "\t") {
+ col = Math.min(cols - 1, (Math.floor(col / 8) + 1) * 8);
+ wrapPending = false;
+ }
+ i++;
+ continue;
+ }
+ if (code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
+ i++;
+ continue;
+ }
+ if (wrapPending) {
+ wrapPending = false;
+ col = 0;
+ row += 1;
+ if (row >= rows) return true;
+ }
+ if (col >= cols - 1) {
+ wrapPending = true;
+ col = cols - 1;
+ } else {
+ col += 1;
+ }
+ i++;
+ }
+ return false;
+};
+
+export const viewportRepaintCoverage = (
+ content: string,
+ cols: number,
+ rows: number,
+): number => {
+ if (cols <= 0 || rows <= 0) return 0;
+ const covered = new Set();
+ let row = 0;
+ let col = 0;
+ // Only count cells painted after an explicit origin reset (CUP/home). A late
+ // CSI H at the end of a frame must not retroactively validate paints from
+ // the simulated (0,0) start. ED2 alone is not an origin (Codex P2).
+ let originKnown = false;
+ const clampRow = () => { row = row < 0 ? 0 : row >= rows ? rows - 1 : row; };
+ const clampCol = () => { col = col < 0 ? 0 : col >= cols ? cols - 1 : col; };
+ const mark = (r: number, c: number) => {
+ if (!originKnown) return;
+ if (r >= 0 && r < rows && c >= 0 && c < cols) covered.add(r * cols + c);
+ };
+ const markRange = (r: number, from: number, to: number) => {
+ for (let c = Math.max(0, from); c <= to && c < cols; c++) mark(r, c);
+ };
+ /** Advance past a control-string payload terminated by BEL, ST (ESC \), or C1 ST. */
+ const skipControlString = (from: number): number => {
+ let j = from;
+ while (j < content.length) {
+ const c = content.charCodeAt(j);
+ if (c === 0x07) return j + 1; // BEL
+ if (c === 0x9c) return j + 1; // C1 ST
+ if (content[j] === "\x1b" && content[j + 1] === "\\") return j + 2; // ESC \
+ j++;
+ }
+ return content.length; // incomplete — consume remainder, never count as cells
+ };
+ /** Apply CSI cursor/erase effects starting at the first parameter byte. */
+ const applyCsi = (paramStart: number): number => {
+ let j = paramStart;
+ let params = "";
+ while (j < content.length) {
+ const c = content.charCodeAt(j);
+ if (c >= 0x30 && c <= 0x3f) { params += content[j]; j++; } else break;
+ }
+ while (j < content.length && content.charCodeAt(j) >= 0x20 && content.charCodeAt(j) <= 0x2f) j++;
+ const final = content[j];
+ const nums = params.split(";").map((p) => (p === "" ? undefined : parseInt(p, 10)));
+ const n0 = nums[0];
+ if (final === "H" || final === "f") {
+ row = (n0 ?? 1) - 1;
+ col = (nums[1] ?? 1) - 1;
+ clampRow();
+ clampCol();
+ // CUP establishes a known origin for subsequent paints only.
+ originKnown = true;
+ } else if (final === "A") { row -= n0 ?? 1; clampRow(); }
+ else if (final === "B" || final === "E") { row += n0 ?? 1; clampRow(); }
+ else if (final === "C") { col += n0 ?? 1; clampCol(); }
+ else if (final === "D") { col -= n0 ?? 1; clampCol(); }
+ else if (final === "G" || final === "`") { col = (n0 ?? 1) - 1; clampCol(); }
+ else if (final === "d") { row = (n0 ?? 1) - 1; clampRow(); }
+ else if (final === "J") {
+ const p = n0 ?? 0;
+ // ED2 clears the display but does NOT move the cursor (xterm). Do not
+ // treat it as a known origin — only CUP/home does. ED2 also grants no
+ // coverage credit (scrolled-up filter may strip it later) (Codex P2).
+ if (p === 2) { /* no originKnown; no coverage */ }
+ // ED0/ED1 still mark the cells they clear (not stripped by that filter).
+ else if (p === 0) { markRange(row, col, cols - 1); for (let r = row + 1; r < rows; r++) markRange(r, 0, cols - 1); }
+ else if (p === 1) { for (let r = 0; r < row; r++) markRange(r, 0, cols - 1); markRange(row, 0, col); }
+ } else if (final === "K") {
+ const p = n0 ?? 0;
+ if (p === 0) markRange(row, col, cols - 1);
+ else if (p === 1) markRange(row, 0, col);
+ else if (p === 2) markRange(row, 0, cols - 1);
+ }
+ return final === undefined ? content.length : j + 1;
+ };
+ let i = 0;
+ while (i < content.length) {
+ const ch = content[i];
+ const code = content.charCodeAt(i);
+ if (ch === "\x1b") {
+ const intro = content[i + 1];
+ if (intro === "[") {
+ // CSI: parse cursor/erase; do not count parameter bytes as cell paints.
+ i = applyCsi(i + 2);
+ continue;
+ }
+ // OSC / DCS / SOS / PM / APC control strings — payloads run until ST or BEL.
+ // Counting their bytes as cell paints falsely inflates repaint coverage.
+ if (intro === "]" || intro === "P" || intro === "X" || intro === "^" || intro === "_") {
+ i = skipControlString(i + 2);
+ continue;
+ }
+ // Other ESC sequences (e.g. ESC 7, ESC (B): skip introducer + body; never
+ // mark intermediate/final bytes as viewport cells.
+ if (intro !== undefined) {
+ let j = i + 1;
+ while (j < content.length && content.charCodeAt(j) >= 0x20 && content.charCodeAt(j) <= 0x2f) j++;
+ if (j < content.length && content.charCodeAt(j) >= 0x30 && content.charCodeAt(j) <= 0x7e) {
+ i = j + 1;
+ } else {
+ i = content.length;
+ }
+ continue;
+ }
+ i++;
+ continue;
+ }
+ if (code < 0x20) {
+ if (ch === "\n") { row += 1; clampRow(); }
+ else if (ch === "\r") { col = 0; }
+ else if (ch === "\b") { col -= 1; clampCol(); }
+ else if (ch === "\t") { col = Math.min(cols - 1, (Math.floor(col / 8) + 1) * 8); }
+ i++;
+ continue;
+ }
+ if (code === 0x7f) { i++; continue; }
+ // 8-bit C1: CSI (0x9B) and control-string introducers — never cell paints.
+ if (code === 0x9b) {
+ i = applyCsi(i + 1);
+ continue;
+ }
+ if (code === 0x90 || code === 0x98 || code === 0x9d || code === 0x9e || code === 0x9f) {
+ i = skipControlString(i + 1);
+ continue;
+ }
+ if (code >= 0x80 && code <= 0x9f) { i++; continue; }
+ mark(row, col);
+ col += 1;
+ if (col >= cols) { col = 0; row += 1; clampRow(); }
+ i++;
+ }
+ return covered.size;
+};
+
+/**
+ * Fraction of the viewport a successor must repaint before its predecessor is
+ * dropped. Require near-full coverage so incremental successors (HOME + a few
+ * cells, or SGR-heavy partial paints) never justify dropping a prior frame.
+ *
+ * Historical note: a 0.4 threshold accepted ~40% paints and dropped prior
+ * frames with untouched cells still showing stale content. 0.99 keeps only a
+ * single-cell wrap/clamp off-by-one margin while still demanding essentially
+ * complete cell overwrite (or an ED2 clear).
+ */
+const FULL_REPAINT_COVERAGE = 0.99;
+
+/**
+ * True when the frame establishes a known cursor origin via CUP/home.
+ * ED2 clears the display but does not home the cursor in xterm, so it is not
+ * a known origin by itself (Codex P2).
+ */
+export const hasKnownCursorOrigin = (content: string): boolean => {
+ if (
+ content.includes("\x1b[H")
+ || content.includes("\x1b[f")
+ || content.includes("\x1b[1;1H")
+ || content.includes("\x1b[1;1f")
+ || content.includes("\x1b[;1H")
+ || content.includes("\x9bH")
+ || content.includes("\x9bf")
+ || content.includes("\x9b1;1H")
+ || content.includes("\x9b1;1f")
+ ) {
+ return true;
+ }
+ return false;
+};
+
+/** A successor frame that demonstrably repaints (almost) the whole viewport. */
+export const makesFullRepaint = (content: string, cols: number, rows: number): boolean => {
+ if (cols <= 0 || rows <= 0) return false;
+ // Coverage only counts cells after an in-order CUP/home origin reset.
+ const total = cols * rows;
+ const covered = viewportRepaintCoverage(content, cols, rows);
+ // Exact full coverage, or near-full (>= 99%) so tiny clamp/wrap off-by-ones
+ // do not block legitimate full repaints while partial paints still fail.
+ if (covered >= total) return true;
+ return covered >= Math.ceil(total * FULL_REPAINT_COVERAGE);
+};
+
+type Frame = { start: number; end: number; content: string };
+
+/**
+ * Result of {@link collapseAndSplit}:
+ * - `complete` — the leading, collapsed run of complete frames, ready to write.
+ * - `partial` — a trailing, not-yet-closed frame to keep buffering.
+ * - `dropped` — characters removed from `complete` by collapsing.
+ */
+export type FrameGateSplit = { complete: string; partial: string; dropped: number };
+
+/**
+ * Split `buffer` into its complete-frame prefix and a trailing incomplete
+ * frame, collapsing runs of superseded full-repaint frames in the prefix down
+ * to the last.
+ *
+ * A frame is dropped only when it is a droppable visual payload AND the frame
+ * directly after it, per `isFullRepaint`, demonstrably repaints the whole
+ * viewport (so it overwrites everything the dropped frame drew). Everything the
+ * transform is unsure about is preserved verbatim.
+ */
+export const collapseAndSplit = (
+ buffer: string,
+ isFullRepaint: (content: string) => boolean,
+ viewport?: { cols: number; rows: number },
+): FrameGateSplit => {
+ const frames: Frame[] = [];
+ let cursor = 0;
+ let partialStart = buffer.length;
+ const nextOpen = (from: number): { at: number; len: number } | null => {
+ const a = buffer.indexOf(SYNC_ON, from);
+ const b = buffer.indexOf(SYNC_ON_C1, from);
+ if (a < 0 && b < 0) return null;
+ if (a < 0) return { at: b, len: SYNC_ON_C1.length };
+ if (b < 0) return { at: a, len: SYNC_ON.length };
+ return a <= b ? { at: a, len: SYNC_ON.length } : { at: b, len: SYNC_ON_C1.length };
+ };
+ const nextClose = (from: number): { at: number; len: number } | null => {
+ const a = buffer.indexOf(SYNC_OFF, from);
+ const b = buffer.indexOf(SYNC_OFF_C1, from);
+ if (a < 0 && b < 0) return null;
+ if (a < 0) return { at: b, len: SYNC_OFF_C1.length };
+ if (b < 0) return { at: a, len: SYNC_OFF.length };
+ return a <= b ? { at: a, len: SYNC_OFF.length } : { at: b, len: SYNC_OFF_C1.length };
+ };
+ while (true) {
+ const open = nextOpen(cursor);
+ if (!open) break;
+ const contentStart = open.at + open.len;
+ const close = nextClose(contentStart);
+ if (!close) { partialStart = open.at; break; }
+ const end = close.at + close.len;
+ frames.push({ start: open.at, end, content: buffer.slice(contentStart, close.at) });
+ cursor = end;
+ }
+
+ // Hold back a trailing byte run that is a proper prefix of the sync opener, so
+ // an opener split across PTY chunks reunites with the next chunk instead of
+ // being forwarded and missed. Only when no unterminated frame already covers
+ // the tail.
+ if (partialStart === buffer.length) {
+ const holdLen = trailingSyncOpenerPrefixLen(buffer);
+ if (holdLen > 0) partialStart = buffer.length - holdLen;
+ }
+
+ const partial = buffer.slice(partialStart);
+ const completeRegion = buffer.slice(0, partialStart);
+ if (frames.length < 2) return { complete: completeRegion, partial, dropped: 0 };
+
+ const drop = new Array(frames.length).fill(false);
+ for (let i = 0; i < frames.length - 1; i++) {
+ const cur = frames[i];
+ const next = frames[i + 1];
+ // If the dropped frame carried SGR, the successor must re-establish
+ // rendition state (any CSI … m). Otherwise xterm keeps pre-drop colors
+ // when the successor omits redundant SGR (Codex P2 on dd606f39).
+ if (
+ next.start === cur.end
+ && isDroppableVisualPayload(cur.content)
+ && isFullRepaint(next.content)
+ && (!payloadContainsSgr(cur.content) || payloadContainsSgr(next.content))
+ // Reject predecessors that can autowrap-scroll (Codex P2).
+ && !(
+ viewport
+ && payloadMayAutowrapScroll(cur.content, viewport.cols, viewport.rows)
+ )
+ ) {
+ drop[i] = true;
+ }
+ }
+ if (!drop.some(Boolean)) return { complete: completeRegion, partial, dropped: 0 };
+
+ let complete = "";
+ let dropped = 0;
+ let pos = 0;
+ for (let i = 0; i < frames.length; i++) {
+ const f = frames[i];
+ complete += completeRegion.slice(pos, f.start);
+ if (drop[i]) dropped += f.end - f.start;
+ else complete += completeRegion.slice(f.start, f.end);
+ pos = f.end;
+ }
+ complete += completeRegion.slice(pos);
+ return { complete, partial, dropped };
+};
+
+/** Exact three-way split of buffered ingress bytes; parts always sum to `total`. */
+export type FrameGateIngressSplit = { forward: number; dropped: number; held: number };
+
+/**
+ * Apportion `total` flow-control ingress bytes across the forwarded, dropped and
+ * still-held parts of a buffer, by character share. Each share is the exact
+ * complement of the rounded parts before it, so the three always sum back to
+ * `total` regardless of rounding — the backend is never over- or
+ * under-acknowledged even when a chunk's ingress differs from its length.
+ */
+export const apportionFrameGateIngress = (
+ total: number,
+ totalChars: number,
+ forwardChars: number,
+ droppedChars: number,
+ heldChars: number,
+): FrameGateIngressSplit => {
+ const held = totalChars > 0 ? Math.round((total * heldChars) / totalChars) : 0;
+ const leaving = total - held;
+ const leavingChars = forwardChars + droppedChars;
+ const forward = leavingChars > 0 ? Math.round((leaving * forwardChars) / leavingChars) : 0;
+ const dropped = leaving - forward;
+ return { forward, dropped, held };
+};
diff --git a/components/terminal/runtime/terminalSessionAttachment.test.ts b/components/terminal/runtime/terminalSessionAttachment.test.ts
index 945f7147c5..6fe6ae483c 100644
--- a/components/terminal/runtime/terminalSessionAttachment.test.ts
+++ b/components/terminal/runtime/terminalSessionAttachment.test.ts
@@ -18,6 +18,7 @@ import {
resolveAttachSnapshot,
tryAttachSessionToTerminal,
writeSessionData,
+ writeTerminalLine,
} from "./terminalSessionAttachment.ts";
import { getVisibleTerminalLineTimestampRows } from "./terminalLineTimestamps.ts";
import { noteTerminalOutputPressureData } from "./terminalOutputPressure.ts";
@@ -39,7 +40,10 @@ import {
clearDeferredTerminalWriteAck,
getDeferredTerminalWriteAckBytes,
} from "./terminalWriteAckDeferral.ts";
-import { flushTerminalWriteQueueBypassingTimers } from "./terminalWriteQueue.ts";
+import {
+ flushTerminalWriteQueueBypassingTimers,
+ WRITE_QUEUE_STALL_TIMEOUT_MS,
+} from "./terminalWriteQueue.ts";
import { prioritizeTerminalInput } from "./terminalOutputPipeline";
import {
createPromptLineBreakState,
@@ -1733,6 +1737,84 @@ test("writeSessionData flushes deferred IPC acks before small output can leave t
clearTerminalSessionFlowAck("session-1");
});
+test("watchdog recovery flushes deferred IPC acks held for a stalled non-deferred write", async () => {
+ // Small writes batch IPC acks into the deferral buffer. A later large write
+ // used to clear that buffer before xterm's callback; if the callback is lost
+ // and the stall watchdog recovers, only the large write's dropBytes were
+ // acked — the cleared deferred total never reached main, leaving SSH paused.
+ clearTerminalSessionFlowAck("session-1");
+ let stallLarge = true;
+ const term = {
+ buffer: { active: { type: "normal" } },
+ write(data: string, callback?: () => void) {
+ if (stallLarge && data.length > XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES) {
+ // Lost xterm callback: accept the write, never invoke done.
+ return;
+ }
+ callback?.();
+ },
+ scrollToBottom() {},
+ } as unknown as XTerm;
+ let mainUnackedBytes = 0;
+ const ctx = {
+ ...createContext(false),
+ isVisibleRef: { current: true },
+ sessionRef: { current: "session-1" },
+ terminalBackend: {
+ ackSessionFlow: (_sessionId: string, bytes: number) => {
+ mainUnackedBytes = Math.max(0, mainUnackedBytes - bytes);
+ },
+ },
+ };
+ getFlowController(ctx as never, term);
+
+ const small = "s".repeat(64);
+ const smallCount = 3;
+ for (let i = 0; i < smallCount; i += 1) {
+ mainUnackedBytes += small.length;
+ writeSessionData(ctx as never, term, small);
+ }
+ flushTerminalWriteCoalescer(term);
+ // Drain small queue items so they complete (and defer IPC acks).
+ flushTerminalWriteQueueBypassingTimers(term);
+ await new Promise((resolve) => { setTimeout(resolve, 5); });
+ flushTerminalWriteQueueBypassingTimers(term);
+
+ const deferredBeforeLarge = getDeferredTerminalWriteAckBytes(term);
+ assert.equal(deferredBeforeLarge, small.length * smallCount);
+
+ const large = "L".repeat(XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES + 1);
+ mainUnackedBytes += large.length;
+ writeSessionData(ctx as never, term, large);
+ flushTerminalWriteCoalescer(term);
+ // Start the large write (still no callback).
+ flushTerminalWriteQueueBypassingTimers(term);
+
+ // Deferred bytes must still be present until someone owns the ack.
+ assert.equal(
+ getDeferredTerminalWriteAckBytes(term),
+ deferredBeforeLarge,
+ "non-deferred write must not clear deferred acks before owning the claim",
+ );
+
+ await new Promise((resolve) => {
+ setTimeout(resolve, WRITE_QUEUE_STALL_TIMEOUT_MS + 300);
+ });
+ stallLarge = false;
+
+ assert.equal(
+ getDeferredTerminalWriteAckBytes(term),
+ 0,
+ "watchdog recovery must flush deferred IPC acks",
+ );
+ assert.equal(
+ mainUnackedBytes,
+ 0,
+ "deferred + stalled write ingress must both reach main-process ACK",
+ );
+ clearTerminalSessionFlowAck("session-1");
+});
+
test("writeSessionData acks ingress bytes to match main-process trackEmitted", () => {
clearTerminalSessionFlowAck("session-1");
const { term } = createFakeTerm();
@@ -1936,6 +2018,32 @@ test("writeSessionData does not wipe scrollback for delayed sync clears after re
assert.equal(output.includes("\x1b[H\x1b[2Jframe"), true, output);
});
+test("writeTerminalLine flushes a held DEC 2026 partial before the lifecycle line", () => {
+ const { term, writes } = createFakeTerm();
+ const ctx = createContext(false);
+
+ // Open a synchronized frame without its closer — held by the frame gate.
+ writeSessionData(ctx as never, term, "\x1b[?2026h\x1b[1;1Hpartial-frame");
+ assert.equal(
+ writes.join(""),
+ "",
+ "incomplete frame must stay buffered until flush or fail-open",
+ );
+
+ // Session-exit style lifecycle line must not race a later fail-open release.
+ writeTerminalLine(ctx as never, term, "\r\n[session closed]");
+
+ const output = writes.join("");
+ const partialAt = output.indexOf("partial-frame");
+ const closedAt = output.indexOf("[session closed]");
+ assert.ok(partialAt >= 0, `expected held partial in output: ${JSON.stringify(output)}`);
+ assert.ok(closedAt >= 0, `expected exit line in output: ${JSON.stringify(output)}`);
+ assert.ok(
+ partialAt < closedAt,
+ `held frame must precede exit line (partial@${partialAt}, closed@${closedAt}): ${JSON.stringify(output)}`,
+ );
+});
+
test("writeSessionData always uses ledger recording regardless of gutter toggle", () => {
const { term, writes, markerLines } = createFakeTerm();
const ctx = createContext(false, { showLineTimestamps: false });
diff --git a/components/terminal/runtime/terminalSessionAttachment.ts b/components/terminal/runtime/terminalSessionAttachment.ts
index 8c2ee98826..eca92f76f6 100644
--- a/components/terminal/runtime/terminalSessionAttachment.ts
+++ b/components/terminal/runtime/terminalSessionAttachment.ts
@@ -43,10 +43,17 @@ import {
} from "./terminalSudoAutofill";
import {
filterTerminalSessionData,
+ flushTerminalSyncBlockFilterPending,
isTerminalSyncBlockOpen,
resetTerminalSyncBlockFilter,
} from "./terminalSyncBlockFilter";
import { appendEraseScrollbackAfterFullErases } from "../clearTerminalViewport";
+import {
+ apportionFrameGateIngress,
+ collapseAndSplit,
+ endsWithSyncOpenerPrefix,
+ makesFullRepaint,
+} from "./terminalFrameGate";
import {
type CoalescedTerminalWriteOptions,
enqueueCoalescedTerminalWrite,
@@ -68,6 +75,8 @@ import {
import {
FLOW_HIGH_WATER_MARK,
FLOW_LOW_WATER_MARK,
+ LOCAL_FLOW_HIGH_WATER_MARK,
+ LOCAL_FLOW_LOW_WATER_MARK,
XTERM_WRITE_CALLBACK_BATCH_BYTES,
XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES,
} from "./terminalFlowConstants";
@@ -88,6 +97,7 @@ import {
} from "./terminalOutputPipeline";
import {
hasPendingTerminalWrites,
+ registerFrameGateHibernateHooks,
maybeFlushTerminalWriteCoalescerWhenUnfocused,
scheduleTerminalRepaintWhenUnfocused,
shouldFlushTerminalWritesForBackgroundOutput,
@@ -373,9 +383,24 @@ export const getFlowController = (
): OutputFlowController => {
let controller = terminalFlowControllers.get(term);
if (!controller) {
+ // A local shell needs no output back-pressure: there is no network to
+ // overwhelm, and the source (a local process) blocks on its own write
+ // when the pipe fills. The 1 MB watermark meant for SSH otherwise pauses
+ // the PTY several times a second under a full-screen animated TUI, which
+ // throttles its frame loop far below what the renderer can paint. Give
+ // local sessions a much higher ceiling so the pause is rare; SSH keeps the
+ // tight default. The ceiling still bounds memory — it does not disable
+ // back-pressure, only relaxes it where a flood cannot originate.
+ const isLocal = (ctx.hostRef?.current ?? ctx.host)?.protocol === "local";
+ const highWaterMark = isLocal
+ ? Math.max(FLOW_HIGH_WATER_MARK, LOCAL_FLOW_HIGH_WATER_MARK)
+ : FLOW_HIGH_WATER_MARK;
+ const lowWaterMark = isLocal
+ ? Math.max(FLOW_LOW_WATER_MARK, LOCAL_FLOW_LOW_WATER_MARK)
+ : FLOW_LOW_WATER_MARK;
controller = createOutputFlowController({
- highWaterMark: FLOW_HIGH_WATER_MARK,
- lowWaterMark: FLOW_LOW_WATER_MARK,
+ highWaterMark,
+ lowWaterMark,
onPause: () => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, true);
@@ -387,11 +412,21 @@ export const getFlowController = (
});
terminalFlowControllers.set(term, controller);
setTerminalWriteQueueDropHandler(term, (bytes) => {
- if (bytes <= 0) return;
- controller?.written(bytes);
+ // Watchdog recovery only claims the active item's dropBytes. Small writes
+ // may have left ingress in the deferred IPC-ack buffer; a non-deferred
+ // write must not clear that buffer until it owns the ack, so a stall can
+ // still flush those earlier bytes here (without re-running flow.written —
+ // deferred writes already accounted them when they completed).
+ const deferredAck = clearDeferredTerminalWriteAck(term);
const sessionId = ctx.sessionRef.current;
- ackTerminalSessionFlow(ctx.terminalBackend, sessionId, bytes);
- if (sessionId) {
+ if (bytes > 0) {
+ controller?.written(bytes);
+ ackTerminalSessionFlow(ctx.terminalBackend, sessionId, bytes);
+ }
+ if (deferredAck > 0) {
+ ackTerminalSessionFlow(ctx.terminalBackend, sessionId, deferredAck);
+ }
+ if (sessionId && (bytes > 0 || deferredAck > 0)) {
flushTerminalSessionFlowAck(sessionId);
}
});
@@ -413,13 +448,21 @@ export const resetTerminalLineTimestampState = resetTerminalLineTimestamps;
export const acknowledgeDroppedTerminalDisplayBytes = (
ctx: TerminalSessionStartersContext,
bytes: number,
+ term?: XTerm,
): void => {
if (bytes <= 0) return;
const sessionId = ctx.sessionRef.current;
ackTerminalSessionFlow(ctx.terminalBackend, sessionId, bytes);
if (sessionId) {
flushTerminalSessionFlowAck(sessionId);
- ctx.terminalBackend.setSessionFlowPaused?.(sessionId, false);
+ // Dropped frames free backend ingress, but the renderer flow controller may
+ // still be above its high-water mark on forwarded writes. Only force-resume
+ // when it is not paused; otherwise the controller resumes when pending
+ // drains (Codex P2 on efe412a6).
+ const flow = term ? getFlowControllerForTerm(term) : undefined;
+ if (!flow?.isPaused?.()) {
+ ctx.terminalBackend.setSessionFlowPaused?.(sessionId, false);
+ }
}
};
@@ -435,22 +478,282 @@ export const writeTerminalLine = (
term: XTerm,
data: string,
) => {
+ // Flush held DEC 2026 frames before lifecycle lines. Without this, a process
+ // that exits mid-frame leaves a fail-open timer armed; the timer would
+ // forward the older partial *after* `[session closed]`, reversing output
+ // order and potentially overwriting the exit message.
+ resetFrameGate(term, (buffer, ingress) => {
+ forwardSessionData(ctx, term, buffer, ingress);
+ });
// Keep lifecycle/control lines ordered after all preceding PTY output.
flushPendingTerminalOutputNow(term);
const lineData = `${data}\r\n`;
+ // dropBytes: 0 — lifecycle banners never called flow.received(); if the
+ // stall watchdog recovered them with display-length dropBytes it would
+ // under-count real session backlog and resume SSH too early (Codex P2).
enqueueTerminalWrite(term, lineData.length, (done) => {
ctx.onTerminalLogData?.(lineData);
term.write(lineData, done);
- });
+ }, { dropBytes: 0 });
flushTerminalWritesForBackgroundOutput(term);
};
+/**
+ * Backlog (unacknowledged bytes awaiting xterm) below which the frame gate
+ * forwards frames straight through — a few frames deep, enough to keep xterm
+ * fed at full rate without starving. Above it the gate drops superseded frames
+ * instead of letting the backlog (and the latency riding behind it) grow.
+ */
+const FRAME_GATE_FORWARD_BACKLOG = 512 * 1024;
+/** Retry cadence for draining held *complete* output when the backlog clears. */
+const FRAME_GATE_FLUSH_MS = 8;
+/**
+ * Fail-open ceiling for the held buffer. Releasing at once past this keeps the
+ * gate from withholding output unboundedly and, kept below the SSH flow
+ * watermark, avoids deadlocking on a frame larger than that watermark (the
+ * backend would pause before the withheld closer could arrive).
+ */
+const FRAME_GATE_MAX_HELD_BYTES = 512 * 1024;
+/**
+ * How long a lone trailing partial (an opener with no closer) may be held before
+ * it is released to xterm. Long enough not to fire during normal frame assembly,
+ * short enough that a process killed mid-frame does not leave its prompt hidden.
+ */
+const FRAME_GATE_PARTIAL_FAILOPEN_MS = 200;
+
+type FrameGateState = {
+ buffer: string;
+ /**
+ * Flow-control ingress bytes attributable to `buffer`. Tracked separately
+ * from `buffer.length` because plugin processing can make a chunk's ingress
+ * differ from its rendered length; it is apportioned exactly (via complements)
+ * as bytes are forwarded, dropped or held so the backend is neither over- nor
+ * under-acknowledged.
+ */
+ ingress: number;
+ meta?: TerminalSessionDataMeta;
+ flushTimer?: ReturnType;
+};
+const frameGateStates = new WeakMap();
+
+const getFrameGateState = (term: XTerm): FrameGateState => {
+ let state = frameGateStates.get(term);
+ if (!state) {
+ state = { buffer: "", ingress: 0 };
+ frameGateStates.set(term, state);
+ }
+ return state;
+};
+
+export const resetFrameGate = (
+ term: XTerm,
+ onHeld?: (buffer: string, ingress: number) => void,
+): void => {
+ const state = frameGateStates.get(term);
+ if (!state) return;
+ if (state.flushTimer !== undefined) clearTimeout(state.flushTimer);
+ // Never silently drop held output before its state is deleted: a reset racing
+ // an incomplete frame (hibernation, detach) would otherwise lose the buffered
+ // bytes and leave their ingress unacknowledged to the backend. The caller
+ // decides how to release it — forward it where a write context exists,
+ // acknowledge its ingress where only the backend is available.
+ if (state.buffer) onHeld?.(state.buffer, state.ingress);
+ frameGateStates.delete(term);
+};
+
+/** Ingress flushed to xterm during hibernate before release can ACK it. */
+const frameGateHibernateFlushedIngress = new WeakMap();
+
+// Wire hibernate/close drains to write held DEC 2026 buffers into xterm before
+// serialization (avoids circular import with terminalUnfocusedRepaint).
+registerFrameGateHibernateHooks({
+ hasHeld: (term) => {
+ const state = frameGateStates.get(term);
+ return Boolean(state?.buffer);
+ },
+ flushToTerm: (term) => {
+ const state = frameGateStates.get(term);
+ if (!state) return;
+ if (state.flushTimer !== undefined) {
+ clearTimeout(state.flushTimer);
+ state.flushTimer = undefined;
+ }
+ if (!state.buffer) return;
+ const buffer = state.buffer;
+ const ingress = state.ingress;
+ // Clear held buffer so hasHeld is false, but keep gate state deleted so
+ // subsequent writes re-evaluate engagement cleanly.
+ frameGateStates.delete(term);
+ try {
+ // Route through the same scrollback-safe filter as live writes so a
+ // held HOME+CSI 2 J full redraw does not yank scrollback on hibernate
+ // (Codex P2 on e8c49563). Then force-release any filter-pending ESC
+ // suffix/cursor-home so snapshot drains do not drop them.
+ const filtered = filterTerminalSessionData(term, buffer);
+ const pending = flushTerminalSyncBlockFilterPending(term);
+ const toWrite = `${filtered || ""}${pending || ""}`;
+ if (toWrite) term.write(toWrite);
+ } catch {
+ try {
+ term.write(buffer);
+ } catch {
+ // ignore write failures during teardown
+ }
+ }
+ // Preserve ingress for releaseTerminalFlowBeforeHibernate; also try to ACK
+ // immediately via the session drop path if a flow controller is attached
+ // so non-hibernate flushPending callers do not leave an unacked floor.
+ if (ingress > 0) {
+ frameGateHibernateFlushedIngress.set(
+ term,
+ (frameGateHibernateFlushedIngress.get(term) ?? 0) + ingress,
+ );
+ try {
+ const flow = terminalFlowControllers.get(term);
+ if (flow) {
+ flow.written(ingress);
+ }
+ } catch {
+ // ignore
+ }
+ }
+ },
+});
+
+/**
+ * Drain as much of the gate's held buffer as the current backlog allows,
+ * collapsing superseded frames first. Held frames that cannot be forwarded yet
+ * stay buffered so the next arrival (or flush) collapses them against newer
+ * ones instead of letting them pile up.
+ */
+const drainFrameGate = (
+ ctx: TerminalSessionStartersContext,
+ term: XTerm,
+): void => {
+ const state = getFrameGateState(term);
+ if (state.flushTimer !== undefined) {
+ clearTimeout(state.flushTimer);
+ state.flushTimer = undefined;
+ }
+ if (!state.buffer) return;
+
+ // Drop a frame only when its successor demonstrably repaints the whole
+ // viewport (proven by simulating the writes and counting covered cells), never
+ // on raw payload length, which SGR escapes inflate.
+ const { complete, partial, dropped } = collapseAndSplit(
+ state.buffer,
+ (content) => makesFullRepaint(content, term.cols, term.rows),
+ { cols: term.cols, rows: term.rows },
+ );
+
+ // Apportion the buffered ingress across forwarded / dropped / held so the
+ // backend is acknowledged in its own units, not rendered-string lengths.
+ const {
+ forward: ingressComplete,
+ dropped: ingressDropped,
+ held: ingressPartial,
+ } = apportionFrameGateIngress(
+ state.ingress,
+ state.buffer.length,
+ complete.length,
+ dropped,
+ partial.length,
+ );
+
+ state.buffer = partial;
+ state.ingress = ingressPartial;
+ if (dropped > 0) acknowledgeDroppedTerminalDisplayBytes(ctx, ingressDropped, term);
+
+ let heldComplete = false;
+ if (complete) {
+ const backlog = getFlowControllerForTerm(term)?.pendingBytes() ?? 0;
+ if (backlog < FRAME_GATE_FORWARD_BACKLOG) {
+ forwardSessionData(ctx, term, complete, ingressComplete, state.meta);
+ } else {
+ // xterm is still behind: keep the collapsed complete run buffered ahead of
+ // the trailing partial so newer frames supersede it rather than queue up.
+ state.buffer = complete + state.buffer;
+ state.ingress += ingressComplete;
+ heldComplete = true;
+ }
+ }
+
+ if (!state.buffer) return;
+
+ // Fail-open: never withhold output indefinitely. A held buffer past the cap
+ // (e.g. a frame larger than the SSH flow watermark, which would otherwise
+ // deadlock) is released at once.
+ if (state.buffer.length >= FRAME_GATE_MAX_HELD_BYTES) {
+ forwardSessionData(ctx, term, state.buffer, state.ingress, state.meta);
+ state.buffer = "";
+ state.ingress = 0;
+ return;
+ }
+
+ // Held *complete* output is released by a clearing backlog, so poll quickly.
+ // A lone trailing partial can only be completed by new session data (which
+ // calls drainFrameGate itself); poll it only on a longer one-shot that
+ // fail-opens if it fires — so a process killed mid-frame never leaves its
+ // prompt hidden, yet a stalled session never busy-polls.
+ if (state.flushTimer === undefined) {
+ const delay = heldComplete ? FRAME_GATE_FLUSH_MS : FRAME_GATE_PARTIAL_FAILOPEN_MS;
+ state.flushTimer = setTimeout(() => {
+ state.flushTimer = undefined;
+ if (heldComplete) {
+ drainFrameGate(ctx, term);
+ return;
+ }
+ // Fired with no intervening drain: the partial is stuck — release it.
+ const stuck = getFrameGateState(term);
+ if (stuck.buffer) {
+ forwardSessionData(ctx, term, stuck.buffer, stuck.ingress, stuck.meta);
+ stuck.buffer = "";
+ stuck.ingress = 0;
+ }
+ }, delay);
+ }
+};
+
+/**
+ * Entry point for PTY output. When a full-screen animation is in flight (a DEC
+ * 2026 frame is present or already buffered) it runs through the frame gate,
+ * which caps the display/keyboard latency by dropping superseded frames. All
+ * other output takes the direct path unchanged.
+ */
export const writeSessionData = (
ctx: TerminalSessionStartersContext,
term: XTerm,
data: string,
ingressBytes: number = data.length,
meta?: TerminalSessionDataMeta,
+) => {
+ const state = frameGateStates.get(term);
+ // Engage on a complete opener, on already-buffered output, or on a trailing
+ // split opener (`ESC[?2026h` cut across PTY chunks) so an aligned stream can
+ // never bypass the gate by landing the opener on a chunk boundary.
+ // Include 8-bit C1 CSI opener (`\x9b?2026h`) so C1 animated streams enter the
+ // collapse/drop gate the same way as ESC CSI (Codex P2 on 3690e6e4).
+ const engaged = (state && state.buffer.length > 0)
+ || data.includes("\x1b[?2026h")
+ || data.includes("\x9b?2026h")
+ || endsWithSyncOpenerPrefix(data);
+ if (!engaged) {
+ forwardSessionData(ctx, term, data, ingressBytes, meta);
+ return;
+ }
+ const gate = getFrameGateState(term);
+ gate.buffer += data;
+ gate.ingress += ingressBytes;
+ gate.meta = meta;
+ drainFrameGate(ctx, term);
+};
+
+const forwardSessionData = (
+ ctx: TerminalSessionStartersContext,
+ term: XTerm,
+ data: string,
+ ingressBytes: number = data.length,
+ meta?: TerminalSessionDataMeta,
) => {
const flow = getFlowController(ctx, term);
const isPaneCurrentlyVisible = () => isTerminalPaneVisible(ctx);
@@ -546,7 +849,7 @@ const writeSessionDataImmediate = (
const displayBytes = data.length;
const bulkYieldAfter = shouldDegradeTerminalSideWork(term)
&& displayBytes >= XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES;
- enqueueTerminalWrite(term, displayBytes, (done) => {
+ enqueueTerminalWrite(term, displayBytes, (done, signal) => {
const shouldMeasurePerf = Boolean(writeOptions.perfTrace);
const queueItemStartedAt = shouldMeasurePerf ? performance.now() : 0;
const prepareStartedAt = shouldMeasurePerf ? performance.now() : 0;
@@ -705,10 +1008,21 @@ const writeSessionDataImmediate = (
);
};
+ /**
+ * Flow accounting must be exclusive with the stall watchdog's onDropped
+ * path. When the watchdog force-completes a lost/late callback it claims
+ * first; a subsequent real xterm callback must not re-ack the same bytes.
+ */
+ const commitFlowAckIfOwned = (): boolean => {
+ if (!signal.tryClaimFlowAck()) return false;
+ flow.written(ingressBytes);
+ return true;
+ };
+
if (deferFlowAck) {
writePreparedDisplayData(() => {
finishQueueItem();
- flow.written(ingressBytes);
+ if (!commitFlowAckIfOwned()) return;
const deferredTotal = accumulateDeferredTerminalWriteAck(term, ingressBytes);
if (deferredTotal >= XTERM_WRITE_CALLBACK_BATCH_BYTES) {
flushDeferredIpcAck();
@@ -719,16 +1033,15 @@ const writeSessionDataImmediate = (
return;
}
- const deferredBeforeCallback = clearDeferredTerminalWriteAck(term);
- const ackOnCallback = deferredBeforeCallback + ingressBytes;
+ // Do not clear deferred IPC acks until this callback owns flow-ack. Clearing
+ // early loses those bytes when the stall watchdog recovers the write: the
+ // write closure has already dropped them from the deferral buffer, and the
+ // late/no-op callback never flushes them — leaving the SSH channel paused.
writePreparedDisplayData(() => {
finishQueueItem();
- flow.written(ingressBytes);
- if (deferredBeforeCallback > 0) {
- flushIpcAck(ackOnCallback);
- } else {
- flushIpcAck(ackOnCallback);
- }
+ if (!commitFlowAckIfOwned()) return;
+ const deferredBeforeCallback = clearDeferredTerminalWriteAck(term);
+ flushIpcAck(deferredBeforeCallback + ingressBytes);
});
}, {
dropBytes: ingressBytes,
@@ -790,6 +1103,18 @@ export const releaseTerminalFlowBeforeHibernate = (
setTerminalWriteCoalescerFlushGate(term);
pendingTimestampSecondByTerm.delete(term);
resetDeferredTerminalWriteAck(term);
+ // Only the backend is in scope here; acknowledge held ingress so hibernation
+ // does not leave the source paused on bytes that will never be written.
+ // Also ACK any ingress already flushed to xterm by the hibernate drain hook.
+ let hibernateIngress = frameGateHibernateFlushedIngress.get(term) ?? 0;
+ frameGateHibernateFlushedIngress.delete(term);
+ resetFrameGate(term, (_buffer, ingress) => {
+ hibernateIngress += ingress;
+ });
+ if (hibernateIngress > 0) {
+ ackTerminalSessionFlow(backend, sessionId, hibernateIngress);
+ flushTerminalSessionFlowAck(sessionId);
+ }
terminalFlowControllers.delete(term);
};
@@ -838,6 +1163,11 @@ export const attachSessionToTerminal = (
teardownTerminalOutputPipeline(ctx, term, id, flow);
flushTerminalWriteCoalescer(term);
resetTerminalSyncBlockFilter(term);
+ // A write context exists here, so flush any held output to xterm rather than
+ // dropping it (forwardSessionData also acknowledges its ingress).
+ resetFrameGate(term, (buffer, ingress) => {
+ forwardSessionData(ctx, term, buffer, ingress);
+ });
resetTerminalLineTimestamps(term);
resetTerminalOutputPressure(term);
ctx.onSessionAttached?.(id);
diff --git a/components/terminal/runtime/terminalSyncBlockFilter.ts b/components/terminal/runtime/terminalSyncBlockFilter.ts
index 113da6ffc0..6e87efdad0 100644
--- a/components/terminal/runtime/terminalSyncBlockFilter.ts
+++ b/components/terminal/runtime/terminalSyncBlockFilter.ts
@@ -73,3 +73,22 @@ export const filterTerminalSessionData = (term: XTerm, data: string): string =>
return output;
};
+
+/**
+ * Force-release pending filter held bytes (split ESC sequences / cursor-home)
+ * so hibernate/snapshot drains do not lose output that was retained in filter
+ * state after the frame-gate buffer was written (Codex P2 on 741a911e).
+ */
+export const flushTerminalSyncBlockFilterPending = (term: XTerm): string => {
+ const state = getSyncBlockFilterState(term);
+ let released = "";
+ if (state.pending) {
+ released += state.pending;
+ state.pending = "";
+ }
+ if (state.pendingCursorHome) {
+ released += state.pendingCursorHome;
+ state.pendingCursorHome = null;
+ }
+ return released;
+};
diff --git a/components/terminal/runtime/terminalUnfocusedRepaint.ts b/components/terminal/runtime/terminalUnfocusedRepaint.ts
index fbddc5c7c1..5bbf9c1121 100644
--- a/components/terminal/runtime/terminalUnfocusedRepaint.ts
+++ b/components/terminal/runtime/terminalUnfocusedRepaint.ts
@@ -85,11 +85,27 @@ function getPendingTerminalWriteBufferBytes(term: XTerm): number {
return bytes;
}
+/**
+ * Optional frame-gate hooks registered from terminalSessionAttachment to avoid
+ * a circular import. Hibernate/close drains must write held DEC 2026 buffers
+ * into xterm before serialization, not only ACK their ingress.
+ */
+type FrameGateHibernateHooks = {
+ hasHeld: (term: XTerm) => boolean;
+ flushToTerm: (term: XTerm) => void;
+};
+let frameGateHibernateHooks: FrameGateHibernateHooks | null = null;
+
+export const registerFrameGateHibernateHooks = (hooks: FrameGateHibernateHooks): void => {
+ frameGateHibernateHooks = hooks;
+};
+
export function hasPendingTerminalWrites(term: XTerm): boolean {
return (
getTerminalWriteCoalescerPendingBytes(term) > 0
|| hasPendingTerminalWriteQueueWork(term)
|| getPendingTerminalWriteBufferBytes(term) > 0
+ || Boolean(frameGateHibernateHooks?.hasHeld(term))
);
}
@@ -181,6 +197,8 @@ export async function flushPendingTerminalWritesBeforeHibernate(
): Promise {
const deadline = Date.now() + Math.max(0, timeoutMs);
while (true) {
+ // Release held DEC 2026 frames into xterm before snapshotting (Codex P2).
+ frameGateHibernateHooks?.flushToTerm(term);
flushTerminalWriteCoalescer(term);
flushTerminalWriteQueueBypassingTimers(term);
@@ -193,6 +211,7 @@ export async function flushPendingTerminalWritesBeforeHibernate(
await waitForTerminalWriteCallbacks(Math.min(TERMINAL_WRITE_SETTLE_POLL_MS, remainingMs));
}
+ frameGateHibernateHooks?.flushToTerm(term);
flushTerminalWriteCoalescer(term);
flushTerminalWriteQueueBypassingTimers(term);
return !hasPendingTerminalWrites(term);
diff --git a/components/terminal/runtime/terminalWriteCoalescer.ts b/components/terminal/runtime/terminalWriteCoalescer.ts
index c71be7b2db..fa4b6abef4 100644
--- a/components/terminal/runtime/terminalWriteCoalescer.ts
+++ b/components/terminal/runtime/terminalWriteCoalescer.ts
@@ -8,6 +8,7 @@ import {
MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES,
} from "./terminalFlowConstants";
import { shouldDegradeTerminalSideWork } from "./terminalOutputPressure";
+import { frameSafeSliceEnd } from "./syncFrameBoundary";
import {
createWriteCoalescer,
type WriteCoalesceScheduleMode,
@@ -507,6 +508,13 @@ const writeLargeTerminalBatch = (
}
}
}
+ // Never cut inside a DEC 2026 synchronized-output frame: a shard that ends
+ // mid-frame is handed to xterm across a setTimeout gap, and if the frame's
+ // close arrives after xterm's sync timeout the display tears. Applied last,
+ // and only ever extends the end, so it does not fight the boundary rules
+ // above. Bounded: it extends at most to this frame's close (or, for an
+ // unterminated trailing frame, holds it whole for the next write).
+ end = frameSafeSliceEnd(data, offset, end);
const slice = data.slice(offset, end);
const sliceIngress = end >= data.length
? remainingIngressBytes
diff --git a/components/terminal/runtime/terminalWriteQueue.ts b/components/terminal/runtime/terminalWriteQueue.ts
index f1e8c9dad5..2ee8dc4804 100644
--- a/components/terminal/runtime/terminalWriteQueue.ts
+++ b/components/terminal/runtime/terminalWriteQueue.ts
@@ -12,6 +12,45 @@ export const MAX_WRITE_QUEUE_BYTES = 512 * 1024;
*/
export const WRITE_QUEUE_TURN_BUDGET_MS = 10;
+/**
+ * How long an active queue item may make no progress, with nothing scheduled to
+ * make any, before the stall watchdog considers recovering it.
+ *
+ * The completion of an item depends on its write closure invoking the callback
+ * it is handed, which in the real pipeline is wired to xterm's
+ * `term.write(data, cb)`. A lost `cb` — xterm accepting and parsing a write yet
+ * never firing its callback — would otherwise wedge the queue permanently.
+ *
+ * The watchdog is *not* time-only: it only force-completes when xterm's internal
+ * write buffer is idle (drained). A slow parse that still holds pending data
+ * re-arms the timer instead of treating the write as dropped. That keeps
+ * legitimate multi-hundred-ms callbacks from defeating backpressure.
+ *
+ * Set above any legitimate single-write callback latency once xterm is idle,
+ * yet short enough that a recovered stall reads as a brief hitch rather than a
+ * freeze. Aligned with {@link LARGE_WRITE_FLUSH_WATCHDOG_MS} in the session
+ * write path, which guards the same kind of stuck-write condition.
+ */
+export const WRITE_QUEUE_STALL_TIMEOUT_MS = 250;
+
+/**
+ * Signal handed to each write closure so flow-control acknowledgement is
+ * exclusive between the normal completion path and the stall-watchdog /
+ * drop-handler path. Callers that perform `flow.written` / IPC ack must claim
+ * via {@link TerminalWriteSignal.tryClaimFlowAck} before acknowledging.
+ */
+export type TerminalWriteSignal = {
+ /** True after the queue force-completed or aborted this item. */
+ isCancelled: () => boolean;
+ /**
+ * Claim exclusive ownership of flow-control ack for this step's dropBytes.
+ * Returns true only once — either the normal completion path or the
+ * watchdog/drop path may claim it. Prevents double-ack when a late xterm
+ * callback arrives after watchdog recovery.
+ */
+ tryClaimFlowAck: () => boolean;
+};
+
export type TerminalWriteQueueOptions = {
onDropped?: (bytes: number) => void;
dropBytes?: number;
@@ -33,8 +72,54 @@ type QueuedWrite = {
type QueuedWriteStep = {
bytes: number;
dropBytes: number;
- write: (done: () => void) => void;
+ write: (done: () => void, signal: TerminalWriteSignal) => void;
yieldAfter: boolean;
+ /** Set once when this step's dropBytes are acknowledged (normal or drop path). */
+ flowAckClaimed: boolean;
+};
+
+/** xterm.js private write-buffer shape used only to detect an idle drain. */
+type XTermWithPrivateWriteBuffer = XTerm & {
+ _core?: {
+ _writeBuffer?: {
+ _bufferOffset?: number;
+ _pendingData?: number;
+ _writeBuffer?: Array;
+ };
+ };
+};
+
+/**
+ * True when xterm has no queued input left to parse. A missing private API
+ * (tests / future xterm) is treated as idle so recovery still works; a
+ * positive pending count means a write is still in flight and must not be
+ * force-completed on elapsed time alone.
+ */
+export const isXtermWriteBufferIdle = (term: XTerm): boolean => {
+ const writeBuffer = (term as XTermWithPrivateWriteBuffer)._core?._writeBuffer;
+ if (!writeBuffer) return true;
+
+ if (
+ typeof writeBuffer._pendingData === "number"
+ && Number.isFinite(writeBuffer._pendingData)
+ && writeBuffer._pendingData > 0
+ ) {
+ return false;
+ }
+
+ const buffer = writeBuffer._writeBuffer;
+ if (!Array.isArray(buffer) || buffer.length === 0) return true;
+ const offset = typeof writeBuffer._bufferOffset === "number"
+ && Number.isFinite(writeBuffer._bufferOffset)
+ ? Math.max(0, writeBuffer._bufferOffset)
+ : 0;
+ return offset >= buffer.length;
+};
+
+const tryClaimStepFlowAck = (step: QueuedWriteStep): boolean => {
+ if (step.flowAckClaimed) return false;
+ step.flowAckClaimed = true;
+ return true;
};
type TerminalWriteQueue = {
@@ -49,6 +134,8 @@ type TerminalWriteQueue = {
drainTimer?: ReturnType;
stepTimer?: ReturnType;
stepContinuation?: () => void;
+ stallWatchdog?: ReturnType;
+ progressSeq: number;
};
const terminalWriteQueues = new WeakMap();
@@ -65,6 +152,7 @@ const getOrCreateQueue = (term: XTerm): TerminalWriteQueue => {
floodMode: false,
turnStartedAt: 0,
onDropped: terminalWriteQueueDropHandlers.get(term),
+ progressSeq: 0,
};
terminalWriteQueues.set(term, queue);
}
@@ -86,6 +174,107 @@ const isQueueTurnBudgetExceeded = (queue: TerminalWriteQueue): boolean => {
return performance.now() - queue.turnStartedAt >= WRITE_QUEUE_TURN_BUDGET_MS;
};
+const clearStallWatchdog = (queue: TerminalWriteQueue): void => {
+ if (queue.stallWatchdog !== undefined) {
+ clearTimeout(queue.stallWatchdog);
+ queue.stallWatchdog = undefined;
+ }
+};
+
+/**
+ * Claim dropBytes only for the currently dispatched step of a stalled item.
+ * Flood merges put many unstarted steps after `nextIndex`; those have never
+ * been written to xterm and must not be ACKed as dropped (Codex P1).
+ * `nextIndex` points one past the dispatched step, hence `nextIndex - 1`.
+ * Claiming here makes a late real callback's
+ * {@link TerminalWriteSignal.tryClaimFlowAck} return false.
+ */
+const claimDispatchedStepBytesOf = (item: QueuedWrite): number => {
+ const from = Math.max(0, item.nextIndex - 1);
+ const step = item.steps[from];
+ if (!step) {
+ return item.dropBytes;
+ }
+ return tryClaimStepFlowAck(step) ? step.dropBytes : 0;
+};
+
+/**
+ * Requeue steps that have not been written yet after a stall recovery on a
+ * flood-merged item, so remaining chunks still reach xterm.
+ */
+const requeueUnstartedTail = (queue: TerminalWriteQueue, item: QueuedWrite): void => {
+ const tail = item.steps.slice(item.nextIndex);
+ if (tail.length === 0) return;
+ let bytes = 0;
+ let dropBytes = 0;
+ for (const step of tail) {
+ bytes += step.bytes;
+ dropBytes += step.dropBytes;
+ }
+ queue.pending.unshift({
+ bytes,
+ dropBytes,
+ steps: tail,
+ nextIndex: 0,
+ cancelled: false,
+ yieldAfter: item.yieldAfter,
+ maxDrainBytes: item.maxDrainBytes,
+ });
+ queue.pendingBytes += bytes;
+};
+
+/**
+ * Arm a watchdog against a lost write callback wedging the queue. It fires only
+ * when the same item is still active, has made no progress since it was armed,
+ * has nothing scheduled to advance it, *and* xterm's write buffer is idle — a
+ * genuine lost-callback stall, never a slow-but-progressing parse. On fire it
+ * claims only the dispatched step's unacked bytes (so flow control resumes once,
+ * not twice when a late callback arrives), requeues any unstarted merged tail,
+ * and advances the queue.
+ */
+const armStallWatchdog = (
+ term: XTerm,
+ queue: TerminalWriteQueue,
+ item: QueuedWrite,
+): void => {
+ clearStallWatchdog(queue);
+ const progressAtArm = queue.progressSeq;
+ queue.stallWatchdog = setTimeout(() => {
+ queue.stallWatchdog = undefined;
+ if (terminalWriteQueues.get(term) !== queue) return;
+ if (
+ queue.active !== item
+ || item.cancelled
+ || queue.progressSeq !== progressAtArm
+ || queue.stepTimer !== undefined
+ || queue.stepContinuation !== undefined
+ || queue.drainTimer !== undefined
+ ) {
+ // Either the item advanced/completed, or something is scheduled to make
+ // it advance. Not a stall — leave it be.
+ return;
+ }
+ if (!isXtermWriteBufferIdle(term)) {
+ // xterm is still parsing this write. Elapsed time alone is not evidence of
+ // a lost callback — re-arm and wait for a real drain or real progress.
+ armStallWatchdog(term, queue, item);
+ return;
+ }
+ // Mark cancelled so a late real callback becomes a queue no-op, claim flow
+ // ack only for the dispatched step, requeue unstarted flood-merge tail, then
+ // advance.
+ item.cancelled = true;
+ const unacked = claimDispatchedStepBytesOf(item);
+ requeueUnstartedTail(queue, item);
+ queue.active = undefined;
+ queue.drainBytes = 0;
+ if (unacked > 0) {
+ queue.onDropped?.(unacked);
+ }
+ scheduleQueueDrain(term, queue, true);
+ }, WRITE_QUEUE_STALL_TIMEOUT_MS);
+};
+
const scheduleQueueDrain = (
term: XTerm,
queue: TerminalWriteQueue,
@@ -109,6 +298,8 @@ const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => {
queue.writing = false;
queue.drainBytes = 0;
queue.floodMode = false;
+ queue.active = undefined;
+ clearStallWatchdog(queue);
endQueueTurn(queue);
if (terminalWriteQueues.get(term) === queue) {
terminalWriteQueues.delete(term);
@@ -137,10 +328,12 @@ const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => {
if (queue.pendingBytes < 0) queue.pendingBytes = 0;
queue.writing = true;
queue.active = next;
+ armStallWatchdog(term, queue, next);
runQueuedWrite(next, () => {
if (queue.active !== next) {
return;
}
+ clearStallWatchdog(queue);
queue.drainBytes += next.bytes;
if (queue.active === next) {
queue.active = undefined;
@@ -166,6 +359,13 @@ const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => {
}, 0);
queue.stepTimer = timer;
queue.stepContinuation = continuation;
+ }, () => {
+ // Step progress: advance the sequence and re-arm the watchdog so a stall on
+ // a later step of a multi-step item is covered as well as the first.
+ if (queue.active === next) {
+ queue.progressSeq += 1;
+ armStallWatchdog(term, queue, next);
+ }
});
};
@@ -182,6 +382,7 @@ const runQueuedWrite = (
item: QueuedWrite,
done: () => void,
deferStep: (continuation: () => void) => void,
+ onStepProgress?: () => void,
): void => {
let index = 0;
let completed = false;
@@ -225,7 +426,15 @@ const runQueuedWrite = (
let callbackCalledSynchronously = false;
let insideWrite = true;
+ const signal: TerminalWriteSignal = {
+ isCancelled: () => item.cancelled,
+ tryClaimFlowAck: () => tryClaimStepFlowAck(step),
+ };
const continueAfterStep = (): void => {
+ // A completed step is progress: re-arm the stall watchdog against the new
+ // step so a lost callback on a *later* step of a multi-step item is
+ // covered too, not only the first.
+ onStepProgress?.();
currentDrainBytes += step.bytes;
// Inter-step yields honor per-step flags only. item.yieldAfter applies
// after the whole item finishes (see scheduleNextTerminalWrite), so that
@@ -255,7 +464,7 @@ const runQueuedWrite = (
return;
}
continueAfterStep();
- });
+ }, signal);
insideWrite = false;
if (callbackCalledSynchronously) {
continueAfterStep();
@@ -393,7 +602,7 @@ export const flushTerminalWriteQueueBypassingTimers = (term: XTerm): boolean =>
export const enqueueTerminalWrite = (
term: XTerm,
bytes: number,
- write: (done: () => void) => void,
+ write: (done: () => void, signal: TerminalWriteSignal) => void,
options: TerminalWriteQueueOptions = {},
): void => {
const queue = getOrCreateQueue(term);
@@ -411,7 +620,13 @@ export const enqueueTerminalWrite = (
queue.pending.push({
bytes,
dropBytes,
- steps: [{ bytes, dropBytes, write, yieldAfter: Boolean(options.yieldAfter) }],
+ steps: [{
+ bytes,
+ dropBytes,
+ write,
+ yieldAfter: Boolean(options.yieldAfter),
+ flowAckClaimed: false,
+ }],
nextIndex: 0,
cancelled: false,
yieldAfter: Boolean(options.yieldAfter),
@@ -462,6 +677,7 @@ export const abortTerminalWriteQueue = (
queue.stepTimer = undefined;
}
queue.stepContinuation = undefined;
+ clearStallWatchdog(queue);
terminalWriteQueues.delete(term);
if (droppedBytes > 0) {
diff --git a/components/terminal/runtime/terminalWriteQueueWatchdog.test.ts b/components/terminal/runtime/terminalWriteQueueWatchdog.test.ts
new file mode 100644
index 0000000000..88c5b4be35
--- /dev/null
+++ b/components/terminal/runtime/terminalWriteQueueWatchdog.test.ts
@@ -0,0 +1,227 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { Terminal as XTerm } from "@xterm/xterm";
+
+import {
+ enqueueTerminalWrite,
+ isXtermWriteBufferIdle,
+ setTerminalWriteQueueDropHandler,
+ WRITE_QUEUE_STALL_TIMEOUT_MS,
+ type TerminalWriteSignal,
+} from "./terminalWriteQueue";
+
+/**
+ * Recovery from a lost xterm write callback.
+ *
+ * A queue item completes only when its `write` closure calls the `done` it is
+ * handed, and that `done` is wired to xterm's `term.write(data, cb)` callback.
+ * If xterm accepts the write, parses it (its buffer drains to empty), yet never
+ * invokes `cb` — observed against a full-screen DEC 2026 TUI — the item never
+ * completes: `queue.active` stays set, `queue.writing` stays true, and every
+ * item behind it is stranded. Nothing reschedules it, because the queue is
+ * waiting on a callback that will never arrive.
+ *
+ * Downstream this is a permanent freeze: the completion callback is where
+ * `flow.written()` and the IPC ack live (terminalSessionAttachment), so the
+ * renderer backlog never drains, the main process pauses the PTY at the high
+ * watermark, and a TUI's own writes then block — its render loop stalls and its
+ * keyboard goes dead.
+ *
+ * The queue must not depend solely on xterm's callback. A stall watchdog
+ * force-completes an active item that has made no progress, has nothing
+ * scheduled, *and* whose xterm write buffer is idle — acknowledging its bytes
+ * once so flow control recovers without double-acking a late real callback.
+ */
+
+const settle = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+const makeTerm = (): XTerm => ({}) as XTerm;
+
+type PendingBufferTerm = XTerm & {
+ _core: {
+ _writeBuffer: {
+ _pendingData: number;
+ _writeBuffer: Array;
+ _bufferOffset: number;
+ };
+ };
+};
+
+const makeBusyTerm = (pendingData: number): PendingBufferTerm => ({
+ _core: {
+ _writeBuffer: {
+ _pendingData: pendingData,
+ _writeBuffer: [],
+ _bufferOffset: 0,
+ },
+ },
+}) as PendingBufferTerm;
+
+test("a write whose callback never fires does not wedge the queue forever", async () => {
+ const term = makeTerm();
+ const ran: number[] = [];
+ const dropped: number[] = [];
+ setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes));
+
+ // The first write simulates a lost xterm callback: it runs, but its `done`
+ // is never called. Term has no write buffer → treated as idle.
+ enqueueTerminalWrite(term, 4096, () => { ran.push(0); });
+
+ // Ordinary writes queued behind the stalled one.
+ for (let i = 1; i <= 5; i += 1) {
+ enqueueTerminalWrite(term, 4096, (done) => {
+ ran.push(i);
+ setTimeout(done, 0);
+ });
+ }
+
+ // Before the watchdog: only the stalled write has run; the rest are stuck.
+ await settle(50);
+ assert.deepEqual(ran, [0], "writes behind a stalled one must not run yet");
+
+ // After the watchdog fires: the queue recovers and drains the rest in order.
+ await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300);
+ assert.deepEqual(
+ ran, [0, 1, 2, 3, 4, 5],
+ "queue must recover and drain the stranded writes"
+ );
+ assert.ok(
+ dropped.reduce((a, b) => a + b, 0) > 0,
+ "the stalled item's bytes must be acknowledged so flow control resumes"
+ );
+});
+
+test("the watchdog leaves a healthy queue untouched", async () => {
+ const term = makeTerm();
+ const ran: number[] = [];
+ const dropped: number[] = [];
+ setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes));
+
+ // Every write completes normally, some slowly but well within the timeout.
+ for (let i = 0; i < 8; i += 1) {
+ enqueueTerminalWrite(term, 4096, (done) => {
+ ran.push(i);
+ setTimeout(done, 5);
+ }, { yieldAfter: i % 2 === 0 });
+ }
+
+ await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300);
+ assert.deepEqual(ran, [0, 1, 2, 3, 4, 5, 6, 7], "all writes ran");
+ assert.equal(
+ dropped.reduce((a, b) => a + b, 0), 0,
+ "nothing must be dropped when the queue is healthy"
+ );
+});
+
+test("a late callback after a watchdog recovery does not double-advance", async () => {
+ const term = makeTerm();
+ const ran: number[] = [];
+ let stalledDone: (() => void) | undefined;
+
+ // First write captures its done without calling it, then fires it LATE —
+ // after the watchdog has already force-completed the item.
+ enqueueTerminalWrite(term, 4096, (done) => { ran.push(0); stalledDone = done; });
+ for (let i = 1; i <= 3; i += 1) {
+ enqueueTerminalWrite(term, 4096, (done) => {
+ ran.push(i);
+ setTimeout(done, 0);
+ });
+ }
+
+ await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300);
+ const afterRecovery = [...ran];
+ // The real callback finally arrives; it must be a harmless no-op.
+ stalledDone?.();
+ await settle(100);
+
+ assert.deepEqual(afterRecovery, [0, 1, 2, 3], "queue recovered before the late callback");
+ assert.deepEqual(ran, [0, 1, 2, 3], "a late callback must not re-run or duplicate writes");
+});
+
+test("late callback after watchdog cannot re-claim flow ack (no double-ack)", async () => {
+ const term = makeTerm();
+ const dropped: number[] = [];
+ setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes));
+
+ let signal: TerminalWriteSignal | undefined;
+ let stalledDone: (() => void) | undefined;
+ enqueueTerminalWrite(term, 4096, (done, sig) => {
+ signal = sig;
+ stalledDone = done;
+ }, { dropBytes: 4096 });
+
+ enqueueTerminalWrite(term, 10, (done) => { done(); });
+
+ await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300);
+ assert.deepEqual(dropped, [4096], "watchdog claims and acks once");
+ assert.equal(signal?.isCancelled(), true);
+ assert.equal(
+ signal?.tryClaimFlowAck(), false,
+ "late path must not re-claim after watchdog"
+ );
+
+ // Late real callback still calls done; queue must ignore it.
+ stalledDone?.();
+ await settle(50);
+ assert.deepEqual(dropped, [4096], "no second onDropped from late done");
+});
+
+test("watchdog requeues unstarted flood-merged steps instead of ACKing them", async () => {
+ const term = makeTerm();
+ const dropped: number[] = [];
+ const ran: number[] = [];
+ setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes));
+
+ // Flood merge into one active multi-step item: stall on step 0, keep steps 1-2
+ // unwritten. Watchdog must ACK only step 0 and requeue the rest.
+ for (let i = 0; i < 3; i += 1) {
+ enqueueTerminalWrite(term, 100, (done) => {
+ ran.push(i);
+ if (i === 0) return; // lost callback on first step only
+ setTimeout(done, 0);
+ }, { dropBytes: 100 });
+ }
+
+ await settle(50);
+ assert.deepEqual(ran, [0], "only the first merged step has started");
+
+ await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300);
+ assert.deepEqual(dropped, [100], "only the dispatched step is flow-acked");
+ assert.deepEqual(ran, [0, 1, 2], "unstarted tail is requeued and still runs");
+});
+
+test("watchdog does not force-complete while xterm write buffer is still busy", async () => {
+ const term = makeBusyTerm(8192);
+ assert.equal(isXtermWriteBufferIdle(term), false);
+
+ const ran: number[] = [];
+ const dropped: number[] = [];
+ setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes));
+
+ let release: (() => void) | undefined;
+ enqueueTerminalWrite(term, 4096, (done) => {
+ ran.push(0);
+ release = done;
+ });
+ enqueueTerminalWrite(term, 10, (done) => {
+ ran.push(1);
+ done();
+ });
+
+ // Well past one stall timeout: still busy inside xterm → must not recover yet.
+ await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 100);
+ assert.deepEqual(ran, [0], "must not advance while xterm is still parsing");
+ assert.deepEqual(dropped, [], "must not drop/ack a still-in-flight write");
+
+ // Buffer drains (xterm finished parse) but callback still missing → now recover.
+ term._core._writeBuffer._pendingData = 0;
+ await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 200);
+ assert.deepEqual(ran, [0, 1], "recovers once xterm is idle with no callback");
+ assert.deepEqual(dropped, [4096]);
+
+ // Late callback after recovery is a no-op for the queue.
+ release?.();
+ await settle(20);
+ assert.deepEqual(ran, [0, 1]);
+});
diff --git a/components/terminal/useTerminalEffects.ts b/components/terminal/useTerminalEffects.ts
index 713a049105..b057979f36 100644
--- a/components/terminal/useTerminalEffects.ts
+++ b/components/terminal/useTerminalEffects.ts
@@ -959,6 +959,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
+ // allowTransparency is construction-time only (xterm: must be set before
+ // Terminal.open()). Do not assign here — the renderer/glyph atlas keeps
+ // the open-time mode until a new terminal session is created (Codex P2).
termRef.current.options.smoothScrollDuration =
terminalSettings.smoothScrolling
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
diff --git a/domain/models/terminal.ts b/domain/models/terminal.ts
index 39d01ac49e..5bdb39eb7f 100644
--- a/domain/models/terminal.ts
+++ b/domain/models/terminal.ts
@@ -60,6 +60,13 @@ export interface TerminalSettings {
// Accessibility
minimumContrastRatio: number; // Minimum contrast ratio (1-21)
+ // Rendering
+ // Rasterise glyphs onto a transparent tile instead of onto their background
+ // colour. The WebGL glyph cache is keyed on the background, so baking it in
+ // makes every glyph over a per-cell background (animated backgrounds,
+ // heatmaps, ANSI art) a cache miss and a fresh rasterisation on every frame.
+ allowTransparency: boolean;
+
// Keyboard
altAsMeta: boolean; // Use ⌥ as the Meta key
optionArrowWordJump: boolean; // macOS: Option+←/→ send Meta-b/f for word jump
@@ -368,6 +375,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
cursorShape: 'block',
cursorBlink: true,
minimumContrastRatio: 1,
+ allowTransparency: false,
altAsMeta: false,
optionArrowWordJump: false,
shiftEnterNewlineEnabled: true,
diff --git a/electron/bridges/terminalFlowAck.cjs b/electron/bridges/terminalFlowAck.cjs
index 8c06c4afbc..1a7c135baf 100644
--- a/electron/bridges/terminalFlowAck.cjs
+++ b/electron/bridges/terminalFlowAck.cjs
@@ -3,9 +3,25 @@
const {
FLOW_HIGH_WATER_MARK,
FLOW_LOW_WATER_MARK,
+ LOCAL_FLOW_HIGH_WATER_MARK,
+ LOCAL_FLOW_LOW_WATER_MARK,
} = require("../../infrastructure/config/terminalFlowConstants.cjs");
const { logTerminalOutputPerf } = require("./terminalPerformanceDiagnostics.cjs");
+// A local shell has no network to overwhelm and its source blocks on write when
+// the pipe fills, so the tight SSH watermark only serves to throttle it. Give
+// local sessions a much higher (still bounded) ceiling; every other kind keeps
+// the default. Matches the renderer-side gate in terminalSessionAttachment.
+function isLocalSession(session) {
+ return session?.protocol === "local" || session?.type === "local";
+}
+function highWaterFor(session) {
+ return isLocalSession(session) ? LOCAL_FLOW_HIGH_WATER_MARK : FLOW_HIGH_WATER_MARK;
+}
+function lowWaterFor(session) {
+ return isLocalSession(session) ? LOCAL_FLOW_LOW_WATER_MARK : FLOW_LOW_WATER_MARK;
+}
+
function getFlowTarget(session) {
return session?.stream || session?.proc || session?.socket || session?.serialPort || null;
}
@@ -36,7 +52,7 @@ function ensureFlowState(session) {
state.lastPerfAckLogAt = Number.isFinite(state.lastPerfAckLogAt) ? Math.max(0, state.lastPerfAckLogAt) : 0;
state.sessionId = typeof state.sessionId === "string" && state.sessionId ? state.sessionId : null;
if (typeof state.outputPaused !== "boolean") {
- state.outputPaused = state.appliedPause && (state.rendererPaused || state.unackedBytes >= FLOW_HIGH_WATER_MARK);
+ state.outputPaused = state.appliedPause && (state.rendererPaused || state.unackedBytes >= highWaterFor(session));
}
return session.flowState;
}
@@ -71,8 +87,8 @@ function getFlowPerfDetails(session, extra = {}) {
appliedPause: state.appliedPause,
unackedBytes: state.unackedBytes,
bufferedBytes: state.bufferedBytes,
- highWaterMark: FLOW_HIGH_WATER_MARK,
- lowWaterMark: FLOW_LOW_WATER_MARK,
+ highWaterMark: highWaterFor(session),
+ lowWaterMark: lowWaterFor(session),
...extra,
};
}
@@ -95,15 +111,17 @@ function reconcileSessionFlow(session) {
const target = getFlowTarget(session);
if (!target) return;
- if (!state.outputPaused && (state.rendererPaused || state.unackedBytes >= FLOW_HIGH_WATER_MARK)) {
+ const highWaterMark = highWaterFor(session);
+ const lowWaterMark = lowWaterFor(session);
+ if (!state.outputPaused && (state.rendererPaused || state.unackedBytes >= highWaterMark)) {
state.outputPaused = true;
- } else if (state.outputPaused && !state.rendererPaused && state.unackedBytes <= FLOW_LOW_WATER_MARK) {
+ } else if (state.outputPaused && !state.rendererPaused && state.unackedBytes <= lowWaterMark) {
state.outputPaused = false;
}
const pendingBytes = state.unackedBytes + state.bufferedBytes;
- const shouldPause = state.outputPaused || pendingBytes >= FLOW_HIGH_WATER_MARK;
- const shouldResume = !state.outputPaused && pendingBytes <= FLOW_LOW_WATER_MARK;
+ const shouldPause = state.outputPaused || pendingBytes >= highWaterMark;
+ const shouldResume = !state.outputPaused && pendingBytes <= lowWaterMark;
if (!state.appliedPause && shouldPause) {
logTerminalOutputPerf("backend-flow-pause", getFlowPerfDetails(session, { pendingBytes }));
diff --git a/infrastructure/config/terminalFlowConstants.json b/infrastructure/config/terminalFlowConstants.json
index 0004cdd1fd..2424d4edd1 100644
--- a/infrastructure/config/terminalFlowConstants.json
+++ b/infrastructure/config/terminalFlowConstants.json
@@ -10,5 +10,7 @@
"TERMINAL_LONG_LINE_PRESSURE_BYTES": 65536,
"TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS": 65536,
"XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES": 1024,
- "XTERM_WRITE_CALLBACK_BATCH_BYTES": 100000
-}
+ "XTERM_WRITE_CALLBACK_BATCH_BYTES": 100000,
+ "LOCAL_FLOW_HIGH_WATER_MARK": 4194304,
+ "LOCAL_FLOW_LOW_WATER_MARK": 2097152
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 9e4914ef0f..3ca6e73f49 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
- "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
+ "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs && node scripts/patch-xterm-sync-render.cjs",
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
diff --git a/scripts/patch-xterm-sync-render.cjs b/scripts/patch-xterm-sync-render.cjs
new file mode 100644
index 0000000000..e5d216b0be
--- /dev/null
+++ b/scripts/patch-xterm-sync-render.cjs
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+/* global process, console */
+/**
+ * Render a DEC 2026 synchronized-output frame the moment it closes, instead of
+ * on the next debounced tick.
+ *
+ * xterm's RenderService buffers rows while synchronized output is on and, on
+ * close, requests a refresh that is scheduled through the render debouncer
+ * (requestAnimationFrame). Under a continuous full-screen animation the next
+ * frame opens a new 2026 block before that rAF fires, and `_renderRows` skips
+ * while sync is on — so the debounced paint is dropped and the frame only
+ * appears when the 1000ms synchronized-output timeout expires. The display is
+ * then pinned at ~1fps however fast frames arrive.
+ *
+ * The fix renders synchronously when a synchronized-output buffer was just
+ * flushed. `refreshRows(...,sync,...)` normally does
+ * `sync ? _renderRows(...) : _renderDebouncer.refresh(...)`; we widen the
+ * condition to also render synchronously when the flush returned buffered rows
+ * (the local holding `_syncOutputHandler.flush()`). At that point the mode is
+ * already off, so the completed frame paints before the next can reopen it.
+ *
+ * Upstream: https://github.com/xtermjs/xterm.js (fix pending). Applied here as a
+ * string patch on the minified build, like patch-xterm-webgl-atlas.cjs, so a
+ * version bump that moves the target surfaces as an install failure rather than
+ * silently losing the fix.
+ *
+ * Idempotent.
+ */
+"use strict";
+const fs = require("node:fs");
+const path = require("node:path");
+
+const MARKER = "/*netcatty:sync-render*/";
+
+// The minified `sync ? _renderRows(a,b) : _renderDebouncer.refresh(a,b,c)`
+// ternary, and the `buffered` local to widen it with. Token names differ
+// between the CJS and ESM builds, so each target names its own.
+const TARGETS = [
+ {
+ file: "node_modules/@xterm/xterm/lib/xterm.js",
+ // const r = flush(); ... i ? _renderRows(e,t) : _renderDebouncer.refresh(e,t,this._rowCount)
+ from: "i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)",
+ to: "(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)",
+ },
+ {
+ file: "node_modules/@xterm/xterm/lib/xterm.mjs",
+ // let o = flush(); ... r ? _renderRows(e,t) : _renderDebouncer.refresh(e,t,this._rowCount)
+ from: "r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)",
+ to: "(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)",
+ },
+];
+
+let patched = 0;
+let already = 0;
+let missing = 0;
+
+for (const { file, from, to } of TARGETS) {
+ const abs = path.resolve(process.cwd(), file);
+ let src;
+ try {
+ src = fs.readFileSync(abs, "utf8");
+ } catch {
+ console.warn(`[patch-xterm-sync-render] skip (not found): ${file}`);
+ missing++;
+ continue;
+ }
+ const withMarker = to + MARKER;
+ if (src.includes(withMarker)) {
+ already++;
+ continue;
+ }
+ // Upstream (or a prior unmarked apply) already has the widened ternary —
+ // treat as already fixed so postinstall does not fail (Codex P3).
+ if (src.includes(to)) {
+ already++;
+ continue;
+ }
+ if (src.split(from).length - 1 === 1) {
+ fs.writeFileSync(abs, src.replace(from, withMarker), "utf8");
+ patched++;
+ } else {
+ console.warn(
+ `[patch-xterm-sync-render] ERROR: sync-render ternary not found (or ambiguous) in ${file}. ` +
+ "Refresh the minified target before upgrading @xterm/xterm.",
+ );
+ missing++;
+ }
+}
+
+console.log(
+ `[patch-xterm-sync-render] patched=${patched} already=${already} missing=${missing}`,
+);
+
+if (missing > 0) process.exitCode = 1;