Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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.',
'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
69 changes: 69 additions & 0 deletions components/terminal/runtime/syncFrameBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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("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");
});
74 changes: 74 additions & 0 deletions components/terminal/runtime/syncFrameBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* 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";

/**
* 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`.
*/
export function isInsideSyncBlockAt(data: string, from: number, to: number): boolean {
let open = false;
let i = data.indexOf("\x1b[?2026", from);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize C1 DEC 2026 frame markers

This boundary scan only searches for the 7-bit \x1b[?2026 form, so a TUI that emits synchronized-output as 8-bit C1 CSI (\x9b?2026h / \x9b?2026l) is treated as ordinary output even though xterm accepts C1 CSI and this repo already handles it for other terminal controls. In that case writeLargeTerminalBatch can still slice a synchronized frame across timer yields, reintroducing the xterm sync-timeout tear/backlog behavior this change is meant to prevent; add the C1 opener/closer to the boundary and frame-gate detection helpers as well.

Useful? React with 👍 / 👎.

while (i !== -1 && i < to) {
if (data.startsWith(SYNC_OPEN, i)) {
open = true;
i += SYNC_OPEN.length;
} else if (data.startsWith(SYNC_CLOSE, i)) {
open = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not split DEC 2026 close markers

When desiredEnd lands inside the closing ESC[?2026l marker, this branch treats the close as already consumed because its start is before to, so frameSafeSliceEnd returns the original cut and the large-write slicer can split the close sequence itself. Under the same delayed setTimeout slicing this helper is meant to avoid, xterm remains in synchronized-output mode until the next shard arrives and can still hit the sync timeout/tear; only toggle open after the whole marker is before to, or explicitly extend cuts that fall inside the close marker.

Useful? React with 👍 / 👎.

i += SYNC_CLOSE.length;
} else {
// A different `?2026` sequence (e.g. a DECRQM query `\x1b[?2026$p`) — not
// a frame boundary; step past this ESC and keep scanning.
i += 1;
}
i = data.indexOf("\x1b[?2026", i);
}
return open;
}

/**
* A slice end at or after `desiredEnd` that never falls strictly inside an open
* DEC 2026 block.
*
* 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.
*
* 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;
if (!isInsideSyncBlockAt(data, offset, desiredEnd)) return desiredEnd;
const close = data.indexOf(SYNC_CLOSE, desiredEnd);
if (close === -1) return data.length;
return close + SYNC_CLOSE.length;
}
5 changes: 5 additions & 0 deletions components/terminal/runtime/terminalFlowConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading