Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e6e17a7
fix(terminal): recover the write queue from a lost xterm write callback
s-celles Jul 24, 2026
2358d28
feat(terminal): expose allowTransparency as a setting
s-celles Jul 24, 2026
f05615f
fix(terminal): hold back only the split escape at a chunk boundary
s-celles Jul 24, 2026
0ff11a9
fix(terminal): keep DEC 2026 frames whole when slicing coalesced output
s-celles Jul 24, 2026
7a16b73
build(terminal): render DEC 2026 frames at full rate via an xterm patch
s-celles Jul 25, 2026
5a6a6d5
perf(terminal): relax output back-pressure for local shells
s-celles Jul 25, 2026
b30766c
perf(terminal): drop superseded animation frames to cap latency
s-celles Jul 25, 2026
44a74df
fix(terminal): harden the animation frame gate (review follow-ups)
s-celles Jul 25, 2026
c34c6e2
fix(terminal): prove full repaint by cell coverage, and fail open
s-celles Jul 25, 2026
651c447
fix(terminal): frame-gate review follow-ups (ED3, reset flush, split …
s-celles Jul 26, 2026
bfca22f
fix(terminal): close remaining fluid-terminal-v2 review findings
binaricat Aug 5, 2026
75ce231
fix(terminal): do not treat ED3/C1 as droppable full repaints
binaricat Aug 5, 2026
91c47c9
fix(terminal): keep DEC 2026 close markers whole; require near-full r…
binaricat Aug 5, 2026
507304d
fix(terminal): harden DEC 2026 mid-close slice and 0.99 repaint bar
binaricat Aug 5, 2026
bdc7dbd
fix(terminal): ignore OSC/DCS payloads in repaint coverage
binaricat Aug 5, 2026
7bc6a0d
fix(terminal): flush deferred IPC acks on write-queue watchdog recovery
binaricat Aug 5, 2026
dd606f3
fix(terminal): never drop frames that contain line feeds
binaricat Aug 5, 2026
fcd6bbf
fix(terminal): keep SGR frames unless successor reestablishes SGR
binaricat Aug 5, 2026
a2eaefb
fix(terminal): do not credit ED2 as frame-gate repaint coverage
binaricat Aug 5, 2026
efe412a
test(terminal): update ED2 coverage expectations after gate fix
binaricat Aug 5, 2026
d2e6999
fix(terminal): scope drop ACKs and lifecycle write dropBytes
binaricat Aug 5, 2026
506b0d3
fix(terminal): exclude SU/SD from droppable frame payloads
binaricat Aug 5, 2026
4dc189c
fix(terminal): flush held DEC 2026 frames before hibernate snapshot
binaricat Aug 5, 2026
e8c4956
fix(terminal): ACK frame-gate ingress flushed during hibernate
binaricat Aug 5, 2026
3b9f068
fix(terminal): filter hibernate frame-gate flush like live writes
binaricat Aug 5, 2026
81692b0
fix(terminal): requeue unstarted flood-merge steps on watchdog recovery
binaricat Aug 5, 2026
3690e6e
fix(terminal): recognize C1 CSI forms of DEC 2026 frame markers
binaricat Aug 5, 2026
1628318
fix(terminal): engage frame gate on C1 DEC 2026 openers
binaricat Aug 5, 2026
741a911
fix(terminal): find C1 closers in frameSafeSliceEnd; refine gate flush
binaricat Aug 5, 2026
6e92729
fix(terminal): flush sync-filter pending bytes on hibernate drain
binaricat Aug 5, 2026
6b54464
fix(terminal): require known cursor origin for full-repaint drops
binaricat Aug 5, 2026
8d6c89e
fix(terminal): count repaint coverage only after in-order origin reset
binaricat Aug 5, 2026
775c246
fix(terminal): treat allowTransparency as new-session only
binaricat Aug 5, 2026
e837aa1
fix(terminal): do not treat ED2 as a known cursor origin
binaricat Aug 5, 2026
9ba81f4
fix(terminal): reject autowrap-scroll frames; tolerate unmarked xterm…
binaricat Aug 5, 2026
f6e359e
fix(terminal): use frame-gate state buffer when draining
binaricat Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions application/i18n/locales/en/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions application/syncPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions components/settings/tabs/SettingsTerminalTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,15 @@ function SettingsTerminalTab(props: {
className="w-32"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.rendering.allowTransparency")}
description={t("settings.terminal.rendering.allowTransparency.desc")}
>
<Toggle
checked={terminalSettings.allowTransparency}
onChange={(v) => updateTerminalSetting("allowTransparency", v)}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.rendering.hibernateHiddenTabs")}
description={t("settings.terminal.rendering.hibernateHiddenTabs.desc")}
Expand Down
5 changes: 5 additions & 0 deletions components/terminal/runtime/createXTermRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const fontWeightBold = settings?.fontWeightBold ?? 700;
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
const allowTransparency =
settings?.allowTransparency ?? performanceConfig.options.allowTransparency;
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
const smoothScrollDuration = settings?.smoothScrolling
? performanceConfig.options.smoothScrollDuration
Expand Down Expand Up @@ -454,6 +456,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
// Rescale glyphs that would visually overlap into the next cell (CJK compliance)
rescaleOverlappingGlyphs: true,
// After the performanceConfig spread so the user setting wins over the
// platform default.
allowTransparency,
fontSize: effectiveFontSize,
fontFamily,
fontWeight: fontWeight as
Expand Down
50 changes: 50 additions & 0 deletions components/terminal/runtime/filterSyncBlockClears.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,53 @@ test("isTerminalViewportScrolledUp is false on alternate screen", () => {
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);
});
15 changes: 14 additions & 1 deletion components/terminal/runtime/filterSyncBlockClears.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
114 changes: 114 additions & 0 deletions components/terminal/runtime/syncFrameBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
147 changes: 147 additions & 0 deletions components/terminal/runtime/syncFrameBoundary.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading