Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
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;
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