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

Filter by extension

Filter by extension


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