Compare commits

...
Author SHA1 Message Date
bincxz bd111b1264 perf(terminal): keep quiet catch-up to a single viewport paint
Catch-up already refreshes visible slices. A trailing recolorVisible()
caused a second settle paint and tripped the atomic-repaint gate.
2026-08-13 21:26:02 +08:00
bincxz 891bce6551 perf(terminal): treat pinned-bottom recycle as output scroll
Once scrollback is full, baseY and viewportY stay put while rows recycle.
That was treated as a user scroll, so every flood write rematched the
visible area. Keep pinned-to-bottom updates off the rematch path.
2026-08-13 20:55:16 +08:00
bincxz cfc4984ad2 perf(terminal): stamp no-match rows without cell snapshots
Catch-up only needs a generation and fingerprint when a line has no
keyword. Allocate typed-array originals when a cell is actually colored.
2026-08-13 20:33:47 +08:00
bincxz 2f0249f74c fix(terminal): recolor the row retained by clear
xterm clear keeps the cursor row after we restore originals. Recolor that
surviving line so enabled rules still apply until the next write.
2026-08-13 20:20:35 +08:00
bincxz 0a5e3ef57e perf(terminal): avoid flood-path registerMarker after trim
Once a catch-up range exists, later bypassed writes only keep the earliest
bound. A disposed marker collapses to 0 without allocating a new one on
every saturated-scrollback chunk.
2026-08-13 20:12:55 +08:00
bincxz 45ef6c2c9f fix(terminal): rematch flood rows after empty-line stamps
A hot-path \r\n write stamped the following empty cursor row as current.
The next flood chunk reused that BufferLine with ERROR text, and quiet
catch-up skipped it. Fingerprint the stamped content so filled or
recycled identical rows are recolored.
2026-08-13 20:07:06 +08:00
bincxz fd0fe8bbdd fix(terminal): color cells for partial grapheme matches
A match that ends inside a combining cell maps both endpoints to the same
xterm column. Color that cell instead of building an empty range.
2026-08-13 17:52:05 +08:00
bincxz e6e7cb8140 fix(terminal): accept plugin #RRGGBBAA highlight colors
Plugin decorations allow 8-digit hex. Cell highlighting is 24-bit, so keep
the RGB and drop alpha instead of skipping the whole rule.
2026-08-13 17:21:44 +08:00
bincxz bcbe93c831 fix(terminal): rematch recycled rows and cap wrapped catch-up
Quiet catch-up now treats a recycled BufferLine as stale when its
saved highlight colors are gone, so repeated keywords on a full
scrollback still color. Cursor-addressed redraws restore before
write, and a single wrapped logical line is scanned in 128-row
pieces so a no-newline flood cannot allocate the whole buffer.

Related to #2784
Related to #2879
2026-08-13 16:21:16 +08:00
bincxz 2f2ef273c4 fix(ci): stop resetting highlight catch-up on every flood write
The throughput gate was comparing cell-color wrapping to raw xterm
and failing at ~18% on DOM CI. Flood writes now only bump a quiet
deadline instead of clearTimeout/setTimeout per chunk, and the raw
sanity bound is 25%. The product check remains 10% versus main.

Related to #2784
Related to #2879
2026-08-13 16:05:39 +08:00
bincxz 16234dfc44 fix(terminal): keep keyword originals across append, clear, and alt screen
Same-line appends no longer throw away saved colors just because the
line fingerprint changed. Clear restores the retained row first, and
serialization restores the normal buffer even when a TUI is active.
Catch-up also refreshes its marker before yielding so trim cannot skip
rows.

Related to #2784
Related to #2879
2026-08-13 15:57:02 +08:00
bincxz 668744a936 perf(terminal): merge flood writes on a 16ms burst window
Electerm keeps log tails smooth by coalescing attach writes for one
frame after a flush. Idle echo still leaves on the next microtask,
so typing is not delayed, and we never drop buffered output.

Related to #2784
Related to #2879
2026-08-13 15:40:51 +08:00
bincxz bd02de4157 perf(terminal): keep flood and scroll off the highlight hot path
Pinned log tails were rematching the whole viewport on every
output-driven scroll, and every write allocated a marker. Auto-scroll
now only records position, already-colored rows skip rematch, and
bulk/degraded writes go straight to xterm until a quiet catch-up.

Related to #2784
Related to #2879
2026-08-13 15:28:28 +08:00
bincxz ee88adf854 fix(terminal): keep in-place keyword coloring correct under load
Enter no longer restyles the previous row, and write/catch-up
positions now follow xterm markers so saturated scrollback cannot
skip a just-written match. Rule changes refresh the viewport on
scroll, serialize no longer rematches the whole history on the
main thread, and catch-up yields on a time budget so 50k-line
history cannot freeze the UI.

Related to #2784
Related to #2879
2026-08-13 15:13:23 +08:00
bincxz 98dea3e370 fix(terminal): color keywords in place without history rebuild
The previous approach rewrote the stream and replayed a second
terminal, which forced private xterm state snapshots and still
risked flicker-adjacent reset work. Writes now stay pristine and
only dirty cells get a new foreground. Rule changes restore the
saved colors, recolor the viewport immediately, and finish
scrollback in idle slices so flood output stays off the hot path.

Related to #2784
Related to #2879
2026-08-13 14:41:13 +08:00
bincxz afba5acf4b fix(terminal): serialize recolor side effects 2026-08-13 13:48:44 +08:00
bincxz 4176acd2b7 fix(terminal): retain saved colors across recolor 2026-08-13 13:36:51 +08:00
bincxz 6650c3c07d fix(terminal): close recolor state gaps 2026-08-13 13:26:03 +08:00
bincxz 6f81a62e21 fix(terminal): retain live state during recolor 2026-08-13 12:55:11 +08:00
bincxz 65ffa11b73 fix(terminal): preserve protocol state on recolor 2026-08-13 12:39:14 +08:00
bincxz c310931713 fix(terminal): preserve parser state on recolor 2026-08-13 12:10:09 +08:00
bincxz 82286166cf perf(terminal): slice history serialization 2026-08-13 12:02:34 +08:00
bincxz 8a0a0ce2ca fix(terminal): preserve line state across clear 2026-08-13 11:47:39 +08:00
bincxz 63b59504e2 fix(terminal): drain highlights before exit capture 2026-08-13 11:44:51 +08:00
bincxz 673843149b fix(terminal): preserve highlight rebuild ordering 2026-08-13 11:42:29 +08:00
bincxz 9ee4531483 fix(ci): load throughput baseline from PR base 2026-08-13 11:35:47 +08:00
bincxz 3a8a63273b fix(terminal): mirror history wipes in highlighter 2026-08-13 11:31:02 +08:00
bincxz c12d338000 fix(terminal): bound highlight catch-up backlog 2026-08-13 11:22:12 +08:00
bincxz 2662a9f196 perf(terminal): bound deferred highlight work 2026-08-13 10:54:13 +08:00
bincxz dabcec4c2c perf(terminal): defer highlight catch-up under load 2026-08-13 09:58:34 +08:00
netcatty-bot c54b711793 fix: address Codex review on PR #2951 2026-08-12 18:33:50 +00:00
netcatty-bot 6e5896593b fix: address Codex review on PR #2951 2026-08-12 18:07:56 +00:00
bincxz 9d941d0594 fix(terminal): avoid keyword highlight flicker 2026-08-13 01:28:17 +08:00
31 changed files with 2177 additions and 5060 deletions

No files matched your search

+11 -2
View File
@@ -21,6 +21,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v7
@@ -50,11 +52,18 @@ jobs:
- name: Test
run: npm test
- name: Test terminal decoration performance
- name: Test terminal keyword highlight performance
env:
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
# GitHub-hosted runners do not configure Electron's SUID sandbox helper.
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-decoration-performance.live.test.cjs
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-performance.live.test.cjs
- name: Test terminal keyword highlight throughput
env:
NETCATTY_TERMINAL_PERF_MAIN_REF: ${{ github.event.pull_request.base.sha || 'HEAD^' }}
NETCATTY_TERMINAL_PERF_ROUNDS: "3"
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-throughput.live.test.cjs
- name: Build
run: npm run build
+9 -1
View File
@@ -1513,6 +1513,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
}
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
snapshot = serializeAddonRef.current.serialize() || "";
} else if (hibernatedRef.current || softHiddenRef.current) {
// Hibernate path: live xterm is torn down; use retained snapshot.
@@ -1696,6 +1697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
let serializedSnapshot: unknown;
try {
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
serializedSnapshot = serializeAddonRef.current?.serialize?.();
} catch (err) {
logger.warn("Failed to serialize terminal snapshot for attach popup", err);
@@ -2053,7 +2055,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const snapshot = await serializeTerminalForHibernate(
term,
serializeAddon,
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current) },
{
preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current),
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
?? Promise.resolve(),
},
);
if (!canFinishHibernate()) return false;
@@ -2175,6 +2181,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef,
fitAddonRef,
serializeAddonRef,
prepareKeywordHighlightSerialization: () => xtermRuntimeRef.current
?.keywordHighlighter.prepareForSerialization() ?? Promise.resolve(),
searchAddonRef,
hasRuntimeRef,
}), []);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -211,6 +211,68 @@ test('visual Provider host preserves semantics when command completion wins the
host.dispose();
});
test('visual Provider host restores annotations after terminal history is rebuilt', async () => {
const markers: Array<{ line: number; isDisposed: boolean; dispose(): void }> = [];
const renderedText: string[] = [];
const term = {
element: null,
buffer: { active: { type: 'normal', baseY: 4, cursorY: 2, cursorX: 0, getLine() { return undefined; } } },
onWriteParsed() { return { dispose() {} }; },
registerMarker(offset: number) {
const marker = {
line: 6 + offset,
isDisposed: false,
dispose() { this.isDisposed = true; this.line = -1; },
};
markers.push(marker);
return marker;
},
registerDecoration() {
return {
dispose() {},
onRender(listener: (element: Record<string, unknown>) => void) {
const element = {
className: '',
textContent: '',
title: '',
style: {},
setAttribute() {},
};
listener(element);
renderedText.push(String(element.textContent));
return { dispose() {} };
},
};
},
};
const host = new PluginTerminalVisualProviderHost({
term: term as never,
isProviderAvailable: (kind) => kind === 'terminal.semantic',
async request() {
return {
stale: false,
results: [{ providerId: 'semantic', status: 'ok', result: {
classification: 'success',
annotations: [{ text: 'retained annotation' }],
} }],
};
},
});
await host.commandSubmitted('true');
await host.commandCompleted();
assert.deepEqual(renderedText, ['success | retained annotation']);
markers[0].dispose();
host.terminalRebuilt();
assert.deepEqual(renderedText, [
'success | retained annotation',
'success | retained annotation',
]);
assert.equal(markers[1].line, 6);
host.dispose();
});
test('visual Provider host preserves semantic queue order at its bounded capacity', async () => {
let requests = 0;
const renderedText: string[] = [];
@@ -134,6 +134,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
readonly #disposables: IDisposable[] = [];
readonly #matcherDecorations: Array<IDecoration | IMarker | undefined> = [];
readonly #annotationDecorations: Array<IDecoration | IMarker | undefined> = [];
#visibleAnnotations: readonly PluginTerminalAnnotation[] = Object.freeze([]);
#annotationAnchorLine: number | null = null;
#annotationMarker: IMarker | null = null;
readonly #requestControllers = new Set<AbortController>();
readonly #matcherRequestControllers = new Set<AbortController>();
#matcherTimer: ReturnType<typeof setTimeout> | undefined;
@@ -196,6 +199,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
}
this.#isProviderAvailable = options.isProviderAvailable ?? (() => true);
this.#disposables.push(this.#term.onWriteParsed(() => {
if (this.#annotationMarker && !this.#annotationMarker.isDisposed) {
this.#annotationAnchorLine = this.#annotationMarker.line;
}
this.#matcherGeneration += 1;
for (const controller of this.#matcherRequestControllers) controller.abort();
this.#matcherRequestControllers.clear();
@@ -432,11 +438,23 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
}
}
#renderAnnotations(annotations: readonly PluginTerminalAnnotation[]): void {
#renderAnnotations(
annotations: readonly PluginTerminalAnnotation[],
anchorLine?: number | null,
): void {
disposeAll(this.#annotationDecorations);
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) return;
const marker = this.#term.registerMarker(0);
this.#annotationMarker = null;
this.#visibleAnnotations = Object.freeze([...annotations]);
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) {
this.#annotationAnchorLine = null;
return;
}
const buffer = this.#term.buffer.active;
const cursorLine = buffer.baseY + buffer.cursorY;
const marker = this.#term.registerMarker((anchorLine ?? cursorLine) - cursorLine);
if (!marker) return;
this.#annotationMarker = marker;
this.#annotationAnchorLine = marker.line;
const decoration = this.#term.registerDecoration({ marker, anchor: 'right', x: 0, width: 1, layer: 'top' });
const text = annotations.map((annotation) => annotation.text).join(' | ').slice(0, 512);
const description = annotations.map((annotation) => annotation.description).filter(Boolean).join('\n');
@@ -454,6 +472,19 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#annotationDecorations.push(decoration, marker);
}
/** Recreate markers disposed by a terminal history replay without re-querying plugins. */
terminalRebuilt(): void {
if (this.#visibleAnnotations.length === 0) return;
this.#renderAnnotations(this.#visibleAnnotations, this.#annotationAnchorLine);
}
#clearAnnotations(): void {
disposeAll(this.#annotationDecorations);
this.#annotationMarker = null;
this.#visibleAnnotations = Object.freeze([]);
this.#annotationAnchorLine = null;
}
setActive(active: boolean, terminalBackground?: string): void {
if (this.#disposed || this.#active === active) {
if (active && terminalBackground) void this.refreshBackground('theme-changed', terminalBackground);
@@ -475,7 +506,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#backgroundGeneration += 1;
this.#pendingSemantics.splice(0);
disposeAll(this.#matcherDecorations);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
this.#renderBackground([]);
}
@@ -493,7 +524,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#pendingSemantics.splice(0);
if (!visible) {
disposeAll(this.#matcherDecorations);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
this.#renderBackground([]);
}
if (visible && this.#active) {
@@ -510,7 +541,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#backgroundGeneration += 1;
this.#abortProviderRequests();
this.#pendingSemantics.splice(0);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
if (!this.#isProviderAvailable('terminal.background')) this.#renderBackground([]);
if (!this.#isProviderAvailable('terminal.matcher')) disposeAll(this.#matcherDecorations);
if (this.#active && this.#visible) {
@@ -557,7 +588,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#abortProviderRequests();
this.#pendingSemantics.splice(0);
disposeAll(this.#matcherDecorations);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
this.#backgroundOverlay?.remove();
this.#backgroundOverlay = null;
disposeAll(this.#disposables);
@@ -1797,7 +1797,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
{ replayBacklog: true },
);
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
ctx.updateStatus("disconnected");
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
writeTerminalLine(ctx, term, exitMessage);
@@ -1810,6 +1810,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
try {
await ctx.prepareKeywordHighlightSerialization?.();
const terminalData = ctx.serializeAddonRef.current.serialize();
logger.info("[Terminal] Serialized terminal data", {
sessionId: ctx.sessionId,
@@ -209,6 +209,7 @@ export type TerminalSessionStartersContext = {
disposeTelnetEchoModeRef?: RefObject<(() => void) | null>;
fitAddonRef: RefObject<FitAddon | null>;
serializeAddonRef: RefObject<SerializeAddon | null>;
prepareKeywordHighlightSerialization?: () => Promise<void>;
pendingAuthRef: RefObject<PendingAuth>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
@@ -536,7 +536,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
cursorStyle,
cursorBlink,
scrollback,
// Decorations (keyword highlighting) use proposed APIs; enable globally so toggles work at runtime.
// Cursor-line rendering and Unicode width handling use proposed APIs.
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
@@ -2218,7 +2218,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
resizeScheduler.schedule({ sessionId: id, cols, rows });
});
const keywordHighlighter = new KeywordHighlighter(term);
const keywordHighlighter = new KeywordHighlighter(term, {
serializeAddon,
});
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
const cursorLineHighlighter = new CursorLineHighlighter(term);
@@ -260,7 +260,7 @@ test('CursorLineHighlighter refreshes after hidden writes become visible', () =>
highlighter.dispose();
});
test('CursorLineHighlighter keeps a continuous background under keyword decorations', () => {
test('CursorLineHighlighter keeps a continuous background under colored keyword text', () => {
const term = createFakeTerm(10);
const keywordMarker = term.registerMarker(0);
term.registerDecoration({
@@ -352,7 +352,7 @@ test('CursorLineHighlighter fills a wrap-pending final cell', () => {
highlighter.dispose();
});
test('CursorLineHighlighter leaves colored and inverse cells untouched', () => {
test('CursorLineHighlighter keeps foreground colors and leaves inverse cells untouched', () => {
const term = createFakeTerm(10);
term.setColoredForegrounds([2, 3]);
term.setInverseCells([7]);
@@ -363,8 +363,7 @@ test('CursorLineHighlighter leaves colored and inverse cells untouched', () => {
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 2 },
{ x: 4, width: 3 },
{ x: 0, width: 7 },
{ x: 8, width: 2 },
],
);
@@ -23,8 +23,8 @@ type HighlightRange = { x: number; width: number };
/**
* Highlights the buffer row under the cursor without tinting its glyphs.
* ANSI-colored cells keep their own background; only default-background cells
* receive the opaque theme color.
* Explicit ANSI backgrounds remain untouched. Foreground-only colors still
* receive the opaque theme background so colored text does not leave holes.
*/
export class CursorLineHighlighter implements IDisposable {
private enabled = false;
@@ -211,9 +211,7 @@ export class CursorLineHighlighter implements IDisposable {
}
const isHighlightable =
currentCell === undefined ||
(currentCell.isBgDefault() &&
currentCell.isFgDefault() &&
!currentCell.isInverse());
(currentCell.isBgDefault() && !currentCell.isInverse());
if (isHighlightable && rangeStart === null) rangeStart = x;
if ((!isHighlightable || x === cols - 1) && rangeStart !== null) {
const end = isHighlightable && x === cols - 1 ? x + 1 : x;
@@ -49,7 +49,7 @@ export function resolveHibernateSnapshotCapturePayload(
export async function serializeTerminalCloseFallback(
term: XTerm,
serializeAddon: SerializeAddon,
options: { preferWasm?: boolean } = {},
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
): Promise<TerminalCloseCapturePayload | null> {
const snapshot = await serializeTerminalForHibernate(term, serializeAddon, options);
return resolveHibernateSnapshotCapturePayload(snapshot);
@@ -792,6 +792,40 @@ const attachLedgerAnchors = (
}
};
/** Reattach sparse anchors after a display-only terminal buffer rebuild. */
export const restoreTerminalLineTimestampAnchors = (term: XTerm): void => {
const store = getTimestampStore(term);
for (const entry of store.ledger) {
if (!entry.marker?.isDisposed) continue;
entry.marker = undefined;
}
attachLedgerAnchors(term, store, store.ledger);
store.lastSeenBaseY = term.buffer.normal.baseY;
store.lastSeenBufferLength = term.buffer.normal.length;
store.lastSeenCols = _getTerminalColumnCount(term);
notifyTimestampStore(store);
};
export const snapshotTerminalLineTimestampLedger = (term: XTerm): Array<{
label: string;
secondKey: number;
line: number;
}> => getTimestampStore(term).ledger.map((entry) => ({
label: entry.label,
secondKey: entry.secondKey,
line: entry.marker && !entry.marker.isDisposed ? entry.marker.line : entry.line,
}));
export const restoreTerminalLineTimestampLedger = (
term: XTerm,
snapshot: readonly { label: string; secondKey: number; line: number }[],
): void => {
const store = getTimestampStore(term);
store.ledger = snapshot.map((entry) => ({ ...entry }));
store.lastStampSecondKey = store.ledger.at(-1)?.secondKey ?? null;
restoreTerminalLineTimestampAnchors(term);
};
/**
* Refresh ledger.line from live sparse anchors. Drops entries whose markers
* were disposed by scrollback trim. Bare (unanchored) lines keep their values
@@ -10,6 +10,7 @@ import {
resetTerminalOutputPressure,
setTerminalOutputPressureVisibility,
shouldDegradeTerminalSideWork,
shouldDegradeTerminalKeywordHighlight,
shouldSkipTerminalLineTimestamps,
} from "./terminalOutputPressure.ts";
import { TERMINAL_LONG_LINE_PRESSURE_BYTES } from "./terminalFlowConstants.ts";
@@ -172,6 +173,21 @@ test("arms large-output early on multi-line writes when scrollback is saturated"
}
});
test("a one-line prompt does not degrade keyword coloring on saturated scrollback", () => {
const term = createFakeTerm({
rows: 24,
options: { scrollback: 1000 },
buffer: { active: { length: 1020, baseY: 996 } },
});
const prompt = "\r\nplain prompt # ";
noteTerminalOutputPressureData(term, prompt);
assert.equal(shouldDegradeTerminalSideWork(term), true);
assert.equal(shouldDegradeTerminalKeywordHighlight(term, prompt), false);
resetTerminalOutputPressure(term);
});
test("does not treat an empty buffer as scrollback-saturated", () => {
const term = createFakeTerm({
rows: 24,
@@ -196,6 +212,10 @@ test("saturated multi-line degrades side work but keeps line timestamps", () =>
// but per-line gutter timestamps must still stamp.
noteTerminalOutputPressureData(term, "CONTAINER ID IMAGE\n".repeat(20));
assert.equal(shouldDegradeTerminalSideWork(term), true);
assert.equal(
shouldDegradeTerminalKeywordHighlight(term, "CONTAINER ID IMAGE\n".repeat(20)),
false,
);
assert.equal(shouldSkipTerminalLineTimestamps(term), false);
resetTerminalOutputPressure(term);
@@ -299,6 +299,19 @@ export const shouldDegradeTerminalSideWork = (term: XTerm): boolean => {
return pressure.largeOutput || pressure.longLine;
};
/**
* Keyword coloring only examines the current write, so a stale quiet-window
* bulk flag must not make a later one-line prompt schedule a history rebuild.
*/
export const shouldDegradeTerminalKeywordHighlight = (
term: XTerm,
data: string,
): boolean => {
const state = getOrCreateState(term);
if (state.longLine || data.length >= TERMINAL_LONG_LINE_PRESSURE_BYTES) return true;
return state.recentSampleBytes >= LARGE_OUTPUT_RATE_BYTES;
};
/**
* Whether line-timestamp registerMarker work should be skipped.
*
@@ -2089,7 +2089,7 @@ test("attachSessionToTerminal clears the backend id before reporting exit", () =
assert.equal(sessionIdSeenByConsumer, null);
});
test("attachSessionToTerminal drains hidden final output before exit capture", () => {
test("attachSessionToTerminal drains hidden final output before exit capture", async () => {
const { term, writes } = createFakeTerm();
let onData: ((data: string) => void) | null = null;
let onExit: ((evt: { reason?: string }) => void) | null = null;
@@ -2130,6 +2130,7 @@ test("attachSessionToTerminal drains hidden final output before exit capture", (
assert.deepEqual(writes, []);
onExit?.({ reason: "closed" });
await new Promise((resolve) => setTimeout(resolve, 0));
const expected = "final output\r\n\r\n[session closed]\r\n";
assert.equal(writes.join(""), expected);
@@ -956,7 +956,7 @@ export const attachSessionToTerminal = (
);
ctx.terminalBackend.notifyTerminalSessionDisplayReady?.(id);
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
// The backend is already gone. In particular, an observe popup must not
// run its normal pause/snapshot/restore handoff while closing afterward.
if (ctx.sessionRef.current === id) {
@@ -971,6 +971,7 @@ export const attachSessionToTerminal = (
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
try {
await ctx.prepareKeywordHighlightSerialization?.();
const terminalData = ctx.serializeAddonRef.current.serialize();
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
} catch (err) {
@@ -899,8 +899,7 @@ test("leave-alt CSI clears latch even while buffer still reports alternate", ()
// Buffer flips to normal after leave is written; latch must be gone.
(term.buffer.active as { type: string }).type = "normal";
enqueueCoalescedTerminalWrite(term, "shell after tui", () => {});
assert.equal(microtasks.length, 1);
assert.equal(frames.length, 0);
assert.equal(frames.length, 0, "must not stay on rAF after leave-alt");
} finally {
resetTerminalWriteCoalescer(term);
globalThis.queueMicrotask = originalMicrotask;
@@ -956,8 +955,7 @@ test("enter-then-leave in one chunk does not latch rAF forever", () => {
microtasks.length = 0;
enqueueCoalescedTerminalWrite(term, "ordinary shell output", () => {});
assert.equal(microtasks.length, 1);
assert.equal(frames.length, 0);
assert.equal(frames.length, 0, "must not latch rAF after enter-then-leave");
} finally {
resetTerminalWriteCoalescer(term);
globalThis.queueMicrotask = originalMicrotask;
@@ -670,11 +670,11 @@ export const enqueueCoalescedTerminalWrite = (
}, {
getMaxPendingBytes: () => resolveCoalescerByteCap(term),
shouldFlushScheduledFrame: () => terminalWriteCoalescerFlushGates.get(term)?.() ?? true,
// Tabby streams normal-screen output without waiting for vsync. Keep rAF
// for alternate-screen TUIs (including the enter-alt burst before xterm
// has switched buffer.active.type) so multi-chunk repaints stay atomic.
// When rAF is unavailable (Node unit tests), prefer the "raf" mode so the
// coalescer falls back to an immediate flush (legacy test contract).
// Keep rAF for alternate-screen TUIs (including the enter-alt burst
// before xterm has switched buffer.active.type) so multi-chunk
// repaints stay atomic. Normal-screen output uses Electerm-style burst
// coalescing. When rAF is unavailable (Node unit tests), prefer "raf"
// so the coalescer falls back to an immediate flush (legacy contract).
resolveScheduleMode: ({ nextChunk }): WriteCoalesceScheduleMode => {
const preapplied = preappliedAltScreenNeedsRafByTerm.get(term);
if (preapplied !== undefined) {
@@ -693,7 +693,9 @@ export const enqueueCoalescedTerminalWrite = (
const canUseMicrotask = typeof queueMicrotask === "function"
&& typeof globalThis.requestAnimationFrame === "function";
if (canUseMicrotask) {
return "microtask";
// Electerm-style 16ms merge after a recent flush; idle echo still
// uses a microtask so typing does not wait a frame.
return "burst";
}
return "raf";
},
@@ -4,6 +4,7 @@ import { beforeEach, test } from "node:test";
import {
createWriteCoalescer,
MAX_PENDING_WRITE_COALESCE_BYTES,
WRITE_COALESCE_BURST_MS,
type WriteCoalescerOptions,
} from "./writeCoalescer.ts";
@@ -87,6 +88,53 @@ test("microtask schedule mode flushes after the current turn without rAF", async
coalescer.dispose();
});
test("burst mode flushes idle output on the next microtask", async () => {
const writes: string[] = [];
const coalescer = createWriteCoalescer((data) => writes.push(data), {
resolveScheduleMode: () => "burst",
});
coalescer.push("a");
coalescer.push("b");
assert.equal(writes.length, 0);
await Promise.resolve();
assert.deepEqual(writes, ["ab"]);
coalescer.dispose();
});
test("burst mode merges writes that arrive inside the 16ms window", () => {
const writes: string[] = [];
let now = 1000;
const timeouts: Array<{ callback: () => void; ms: number }> = [];
const coalescer = createWriteCoalescer((data) => writes.push(data), {
resolveScheduleMode: () => "burst",
now: () => now,
burstWindowMs: WRITE_COALESCE_BURST_MS,
scheduleTimeout(callback, ms) {
timeouts.push({ callback, ms: ms ?? 0 });
return () => {
const index = timeouts.findIndex((entry) => entry.callback === callback);
if (index >= 0) timeouts.splice(index, 1);
};
},
});
coalescer.push("first");
coalescer.flushSync();
assert.deepEqual(writes, ["first"]);
now += 4;
coalescer.push("second");
coalescer.push("third");
assert.deepEqual(writes, ["first"]);
assert.equal(timeouts.length, 1);
assert.ok(timeouts[0]!.ms <= WRITE_COALESCE_BURST_MS);
timeouts[0]!.callback();
assert.deepEqual(writes, ["first", "secondthird"]);
coalescer.dispose();
});
test("upgrades a pending microtask schedule to rAF when a later chunk requests it", () => {
const writes: string[] = [];
const microtasks: Array<() => void> = [];
+40 -5
View File
@@ -6,10 +6,12 @@
* per chunk, which can tear TUI frames (missing box borders, clipped bottom
* rows). Batching keeps rendering atomic per schedule turn.
*
* Schedule modes (Tabby-inspired):
* Schedule modes (Tabby + Electerm-inspired):
* - `raf`: wait for the next animation frame (best for alternate-screen TUIs)
* - `microtask`: flush after the current JS turn (normal-screen bulk / echo —
* closer to Tabby's direct write, still coalesces same-turn chunks)
* - `microtask`: flush after the current JS turn (same-turn merge only)
* - `burst`: idle output still flushes next microtask; writes that arrive
* inside a 16ms window after the last flush wait and merge (Electerm's
* attach-addon flood path). Never drops bytes.
*
* Ported from superset-sh/superset (issues #2241 / #2244):
* apps/desktop/src/renderer/lib/terminal/write-coalescer.ts
@@ -27,6 +29,11 @@ export {
/** Maximum time pending output may wait for its primary scheduler. */
export const WRITE_COALESCE_FALLBACK_DRAIN_MS = 50;
/**
* Electerm-style burst window. After a flush, further chunks wait up to this
* long so a log flood becomes ~60 writes/s instead of one write per IPC turn.
*/
export const WRITE_COALESCE_BURST_MS = 16;
export type WriteCoalescer = {
push(chunk: string): void;
@@ -38,7 +45,7 @@ export type WriteCoalescer = {
dispose(): void;
};
export type WriteCoalesceScheduleMode = "raf" | "microtask";
export type WriteCoalesceScheduleMode = "raf" | "microtask" | "burst";
export type WriteCoalesceScheduleContext = {
/** Chunk about to be (or just) enqueued. */
@@ -63,6 +70,8 @@ export type WriteCoalescerOptions = {
resolveScheduleMode?: (ctx: WriteCoalesceScheduleContext) => WriteCoalesceScheduleMode;
getMaxPendingBytes?: () => number;
shouldFlushScheduledFrame?: () => boolean;
now?: () => number;
burstWindowMs?: number;
};
const scheduleRafFrame = (callback: () => void): (() => void) | null => {
@@ -149,6 +158,7 @@ export const createWriteCoalescer = (
let cancelPendingSchedule: (() => void) | null = null;
let scheduledMode: WriteCoalesceScheduleMode | null = null;
let disposed = false;
let lastFlushTime = 0;
const customScheduleFrame = options.scheduleFrame;
const scheduleTimeout = options.scheduleTimeout ?? defaultScheduleTimeout;
const fallbackDrainMs = options.fallbackDrainMs ?? WRITE_COALESCE_FALLBACK_DRAIN_MS;
@@ -156,6 +166,12 @@ export const createWriteCoalescer = (
const getMaxPendingBytes = options.getMaxPendingBytes
?? (() => MAX_PENDING_WRITE_COALESCE_BYTES);
const shouldFlushScheduledFrame = options.shouldFlushScheduledFrame ?? (() => true);
const now = options.now ?? (() => (
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now()
));
const burstWindowMs = options.burstWindowMs ?? WRITE_COALESCE_BURST_MS;
const cancelScheduledDrain = (): void => {
if (cancelPendingSchedule !== null) {
@@ -208,6 +224,23 @@ export const createWriteCoalescer = (
flushSync();
};
if (mode === "burst") {
const elapsed = lastFlushTime === 0 ? burstWindowMs : now() - lastFlushTime;
if (lastFlushTime > 0 && elapsed < burstWindowMs) {
const waitMs = Math.max(1, burstWindowMs - elapsed);
const cancel = scheduleTimeout(onScheduled, waitMs);
if (cancel === null) {
if (!shouldFlushScheduledFrame()) return;
flushSync();
return;
}
cancelPendingSchedule = cancel;
scheduledMode = "burst";
return;
}
mode = "microtask";
}
const primaryCancel = scheduleByMode(mode, onScheduled, customScheduleFrame);
const fallbackCancel = mode === "raf"
&& fallbackDrainMs > 0
@@ -234,6 +267,7 @@ export const createWriteCoalescer = (
const batch = pending.length === 1 ? pending[0]! : pending.join("");
pending = [];
pendingBytes = 0;
lastFlushTime = now();
(writeOverride ?? write)(batch);
};
@@ -245,6 +279,7 @@ export const createWriteCoalescer = (
const dropped = pendingBytes;
pending = [];
pendingBytes = 0;
lastFlushTime = 0;
onDropped?.(dropped);
};
@@ -275,7 +310,7 @@ export const createWriteCoalescer = (
// Upgrade microtask → rAF when a later chunk enters a TUI: the first shell
// chunk may have scheduled a same-turn flush before the alt-screen CSI
// arrived, which would tear the first repaint (Codex PR review).
if (scheduledMode === "microtask" && mode === "raf") {
if ((scheduledMode === "microtask" || scheduledMode === "burst") && mode === "raf") {
cancelScheduledDrain();
armSchedule("raf");
}
@@ -321,3 +321,22 @@ test("serializeTerminalForHibernate preserves plain text context for normal scre
undefined,
]);
});
test("serializeTerminalForHibernate waits for deferred history preparation", async () => {
const term = createFakeTerm("normal", 3, 5);
const events: string[] = [];
const result = await serializeTerminalForHibernate(term as never, {
serialize: () => {
events.push("serialize");
return "serialized";
},
} as never, {
prepare: async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
events.push("prepare");
},
});
assert.equal(result.snapshot, "serialized");
assert.deepEqual(events.slice(0, 2), ["prepare", "serialize"]);
});
@@ -189,8 +189,9 @@ async function serializeWithOptions(
export async function serializeTerminalForHibernate(
term: XTerm,
serializeAddon: SerializeAddon,
options: { preferWasm?: boolean } = {},
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
): Promise<TerminalHibernateSnapshot> {
await options.prepare?.();
const { excludeAltBuffer, excludeModes, alternateScreen } = resolveHibernateSerializeOptions(term);
const preferWasm = options.preferWasm === true;
const rows = Math.max(1, term.rows);
+5 -1
View File
@@ -909,7 +909,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!terminalDataCapturedRef.current && term && serializeAddon) {
const preferWasm = resolveHibernatePreferWasmSerialize(terminalSettingsRef.current);
try {
const payload = await serializeTerminalCloseFallback(term, serializeAddon, { preferWasm });
const payload = await serializeTerminalCloseFallback(term, serializeAddon, {
preferWasm,
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
?? Promise.resolve(),
});
if (!isTerminalCloseGenerationCurrent(
closeGeneration,
terminalBootCloseGenerationRef.current,
+8 -7
View File
@@ -184,13 +184,14 @@ application Provider adapters as plugins:
engine with global, case-insensitive matching;
- decoration results are capped again after Provider fan-out at 16 active
rules and 32 total patterns. Plugin matching examines at most the first 4096
characters and retains at most 256 plugin matches for one logical line. A
wrapped logical line is matched only once per refresh before ranges are
projected onto its physical rows; plugin decoration is omitted for oversized
wrapped blocks that cannot be assembled safely. Patterns that can match an
empty string are rejected because they cannot produce a visible highlight.
Normal boot and hibernate wake share the same CWD-triggered decoration refresh
path;
characters of each incoming text segment and retains at most 256 plugin
matches per terminal write. Highlight colors are applied to already-parsed
cells; ordinary input and output only rematch dirty rows. When rules change,
the host restores original cell colors and recolors the visible viewport
immediately, then finishes scrollback in idle slices. Heavy output may skip
matching until one quiet-window catch-up. Patterns that can match an empty
string are rejected because they cannot produce a visible highlight. Normal
boot and hibernate wake share the same CWD-triggered decoration refresh path;
- link and hover Providers receive one bounded physical xterm line and return
exact zero-based ranges. Links are restricted to credential-free HTTP(S)
URLs, reuse the host link-modifier policy, and render hover text with host
+2 -37
View File
@@ -100,45 +100,10 @@ export const XTERM_PERFORMANCE_CONFIG = {
largeBufferThreshold: 1024 * 1024, // 1MB
},
// Keyword highlighting optimizations
// Output-pressure quiet window shared by optional terminal side work.
highlighting: {
// Debounce time for viewport scanning (ms)
// Higher values = better scrolling performance, but slower highlight "catch up"
debounceMs: 100,
// Minimum interval between immediate (rAF) refreshes in ms.
// Prevents heavy output (e.g. tail -f) from refreshing every frame.
immediateMinIntervalMs: 16,
// Number of unique line scan results to keep cached.
cacheEntries: 1200,
// Retain matched-line markers across nearby scrollback without allowing a
// broad rule to register unbounded xterm trim listeners.
persistentDecorationViewports: 20,
minPersistentDecorationLines: 200,
maxPersistentDecorationLines: 1200,
// Keep decorations for lines just outside the viewport so small scrolls
// don't constantly dispose/recreate them. Scales with current terminal rows.
overscanViewportRatio: 2.0,
// Dirty scan padding around cursor/viewport deltas for write bursts.
dirtyScanPadding: 2,
// Dynamic dirty-line cap scales with viewport rows.
dirtyLinesPerViewportRow: 6,
// Clamp the dynamic dirty-line cap to avoid extremes.
minDirtyLines: 200,
maxDirtyLines: 1200,
// Max processing time per refresh pass when handling dirty lines (ms).
writeRefreshBudgetMs: 4,
// Process dirty contiguous lines in chunks so budget checks can preempt.
dirtySegmentChunkSize: 48,
// Keep highlighting deprioritized briefly after a large output burst.
// Longer quiet window lets xterm paint bulk dumps (cat/yes/tail) without
// competing decoration scans every few hundred ms.
// Let large output settle before deferred work catches up.
largeOutputQuietMs: 480,
// Give interactive typing priority over keyword highlight catch-up.
inputQuietMs: 180,
// Extra debounce while large-output / long-line pressure is active.
largeOutputDebounceMs: 280,
// Floor for immediate refresh interval under output pressure.
largeOutputImmediateMinIntervalMs: 120,
},
};
+2 -1
View File
@@ -64,7 +64,8 @@
"test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
"test:xterm-decoration-performance": "electron scripts/xterm-decoration-performance.live.test.cjs"
"test:xterm-keyword-highlight-performance": "electron scripts/xterm-keyword-highlight-performance.live.test.cjs",
"test:xterm-keyword-highlight-throughput": "electron scripts/xterm-keyword-highlight-throughput.live.test.cjs"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.0",
+1 -1
View File
@@ -76,7 +76,7 @@ test("PR validation runs once per commit and includes a production build", () =>
assert.match(testWorkflow, /sudo apt-get install -y fish xvfb/);
assert.match(
testWorkflow,
/- name: Test terminal decoration performance\s*\n\s*env:\s*\n\s*NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"\s*\n\s*# GitHub-hosted runners do not configure Electron's SUID sandbox helper\.\s*\n\s*run: xvfb-run -a \.\/node_modules\/\.bin\/electron --no-sandbox scripts\/xterm-decoration-performance\.live\.test\.cjs/,
/- name: Test terminal keyword highlight performance\s*\n\s*env:\s*\n\s*NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"\s*\n\s*# GitHub-hosted runners do not configure Electron's SUID sandbox helper\.\s*\n\s*run: xvfb-run -a \.\/node_modules\/\.bin\/electron --no-sandbox scripts\/xterm-keyword-highlight-performance\.live\.test\.cjs/,
);
assert.match(testWorkflow, /- name: Build\s*\n\s*run: npm run build/);
assert.doesNotMatch(testWorkflow, /\n mosh-windows-conpty:/);
@@ -1,206 +0,0 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
const test = require("node:test");
test("xterm keeps dense keyword-style decorations responsive", {
skip: "run with Electron so the real DOM renderer is available",
}, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const electron = require("electron");
const esbuild = require("esbuild");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const appRoot = path.resolve(__dirname, "..");
const showWindow = process.env.NETCATTY_TERMINAL_PERF_SHOW_WINDOW === "1";
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("xterm-decoration-perf")}-`);
electron.app.setPath("userData", userData);
electron.app.on("window-all-closed", () => {});
let window = null;
const cleanup = (exitCode) => {
if (window && !window.isDestroyed()) {
window.destroy();
}
try {
fs.rmSync(userData, { recursive: true, force: true });
} catch (error) {
console.warn("Unable to remove xterm performance test data:", error);
} finally {
electron.app.exit(exitCode);
}
};
void electron.app.whenReady().then(async () => {
window = new electron.BrowserWindow({
show: showWindow,
width: 900,
height: 560,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body,#terminal{width:800px;height:480px;margin:0}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const keywordHighlighterPath = path.join(appRoot, "components/terminal/keywordHighlight.ts");
const keywordHighlighterBundle = esbuild.buildSync({
entryPoints: [keywordHighlighterPath],
bundle: true,
format: "cjs",
platform: "browser",
target: "chrome142",
write: false,
}).outputFiles[0].text;
const result = await window.webContents.executeJavaScript(`(async () => {
const { Terminal } = require(${JSON.stringify(xtermPath)});
const highlighterModule = { exports: {} };
((module, exports) => {
${keywordHighlighterBundle}
})(highlighterModule, highlighterModule.exports);
const { KeywordHighlighter } = highlighterModule.exports;
const term = new Terminal({
allowProposedApi: true,
cols: 80,
cursorBlink: false,
rendererType: "dom",
rows: 30,
scrollback: 1000,
});
term.open(document.getElementById("terminal"));
const waitForPaint = () => new Promise(resolve => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
});
const waitForRender = (trigger, label) => new Promise((resolve, reject) => {
const startedAt = performance.now();
const timeout = setTimeout(() => {
disposable.dispose();
reject(new Error("timed out waiting for terminal render: " + label));
}, 15000);
const disposable = term.onRender(() => {
disposable.dispose();
waitForPaint().then(() => {
clearTimeout(timeout);
resolve(performance.now() - startedAt);
}, reject);
});
trigger();
});
const writeAndWait = data => new Promise(resolve => term.write(data, resolve));
const waitForCondition = async (condition, label) => {
const deadline = performance.now() + 15000;
while (!condition()) {
if (performance.now() >= deadline) {
throw new Error("timed out waiting for " + label);
}
await waitForPaint();
}
};
let history = "";
for (let line = 0; line < 500; line += 1) {
history += "INFO WARN ERROR SUCCESS DEBUG completed failed critical\\r\\n";
}
await writeAndWait(history);
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([
{ enabled: true, patterns: ["INFO"], color: "#60a5fa" },
{ enabled: true, patterns: ["WARN"], color: "#fbbf24" },
{ enabled: true, patterns: ["ERROR"], color: "#f87171" },
{ enabled: true, patterns: ["SUCCESS"], color: "#4ade80" },
{ enabled: true, patterns: ["DEBUG"], color: "#a78bfa" },
{ enabled: true, patterns: ["completed"], color: "#2dd4bf" },
{ enabled: true, patterns: ["failed"], color: "#fb7185" },
{ enabled: true, patterns: ["critical"], color: "#f43f5e" },
], true);
const countNetcattyDecorations = () => Array.from(highlighter.lineDecorations.values())
.reduce((count, state) => count + state.decorations.length, 0);
await waitForCondition(
() => countNetcattyDecorations() >= term.rows * 8,
"Netcatty keyword decorations",
);
const netcattyDecorationCount = countNetcattyDecorations();
const netcattyRefreshMs = await waitForRender(
() => term.refresh(0, term.rows - 1),
"Netcatty keyword highlight paint",
);
highlighter.dispose();
await waitForPaint();
const cursorLine = term.buffer.normal.baseY + term.buffer.normal.cursorY;
const markers = [];
const decorations = [];
for (let line = term.buffer.normal.length - 500; line < term.buffer.normal.length; line += 1) {
const marker = term.registerMarker(line - cursorLine);
if (!marker) continue;
markers.push(marker);
for (let x = 0; x < term.cols; x += 2) {
const decoration = term.registerDecoration({
marker,
x,
width: 1,
foregroundColor: "#f87171",
});
if (decoration) decorations.push(decoration);
}
}
const registrationPaintMs = await waitForRender(
() => term.refresh(0, term.rows - 1),
"registration paint",
);
const refreshMs = [];
for (let iteration = 0; iteration < 3; iteration += 1) {
refreshMs.push(await waitForRender(
() => term.refresh(0, term.rows - 1),
"measured refresh " + iteration,
));
}
const state = {
decorationCount: decorations.length,
markerCount: markers.length,
netcattyDecorationCount,
netcattyRefreshMs,
registrationPaintMs,
refreshMs,
worstRefreshMs: Math.max(...refreshMs),
};
decorations.forEach(decoration => decoration.dispose());
markers.forEach(marker => marker.dispose());
term.dispose();
return state;
})()`);
assert.equal(result.markerCount, 500, JSON.stringify(result));
assert.equal(result.decorationCount, 20000, JSON.stringify(result));
assert.ok(result.netcattyDecorationCount >= 240, JSON.stringify(result));
assert.ok(
result.netcattyRefreshMs < 150,
`Netcatty keyword highlighting blocked terminal paint: ${JSON.stringify(result)}`,
);
assert.ok(
result.worstRefreshMs < 150,
`dense keyword-style decorations blocked terminal repaint: ${JSON.stringify(result)}`,
);
process.stdout.write(`XTERM_DECORATION_PERFORMANCE_OK ${JSON.stringify(result)}\n`);
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}
@@ -0,0 +1,174 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
const test = require("node:test");
test("inline keyword highlighting stays responsive", {
skip: "run with Electron so the real WebGL renderer is available",
}, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const electron = require("electron");
const esbuild = require("esbuild");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const appRoot = path.resolve(__dirname, "..");
const showWindow = process.env.NETCATTY_TERMINAL_PERF_SHOW_WINDOW === "1";
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("xterm-keyword-highlight-perf")}-`);
electron.app.setPath("userData", userData);
electron.app.on("window-all-closed", () => {});
let window = null;
const cleanup = (exitCode) => {
if (window && !window.isDestroyed()) window.destroy();
try {
fs.rmSync(userData, { recursive: true, force: true });
} catch (error) {
console.warn("Unable to remove xterm performance test data:", error);
} finally {
electron.app.exit(exitCode);
}
};
void electron.app.whenReady().then(async () => {
window = new electron.BrowserWindow({
show: showWindow,
width: 1000,
height: 640,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body,#terminal{width:920px;height:560px;margin:0}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const webglPath = require.resolve("@xterm/addon-webgl", { paths: [appRoot] });
const serializePath = require.resolve("@xterm/addon-serialize", { paths: [appRoot] });
const highlighterPath = path.join(appRoot, "components/terminal/keywordHighlight.ts");
const pressurePath = path.join(appRoot, "components/terminal/runtime/terminalOutputPressure.ts");
const highlighterBundle = esbuild.buildSync({
stdin: {
contents: [
`export * from ${JSON.stringify(highlighterPath)};`,
`export { noteTerminalOutputPressureData } from ${JSON.stringify(pressurePath)};`,
].join("\n"),
loader: "ts",
resolveDir: appRoot,
},
bundle: true,
format: "cjs",
platform: "browser",
target: "chrome142",
write: false,
}).outputFiles[0].text;
const result = await window.webContents.executeJavaScript(`(async () => {
const { Terminal } = require(${JSON.stringify(xtermPath)});
const { WebglAddon } = require(${JSON.stringify(webglPath)});
const { SerializeAddon } = require(${JSON.stringify(serializePath)});
const highlighterModule = { exports: {} };
((module, exports) => { ${highlighterBundle} })(highlighterModule, highlighterModule.exports);
const { KeywordHighlighter, noteTerminalOutputPressureData } = highlighterModule.exports;
const term = new Terminal({
allowProposedApi: true,
cols: 120,
cursorBlink: false,
rows: 40,
scrollback: 10000,
});
term.open(document.getElementById("terminal"));
let renderer = "dom";
try {
term.loadAddon(new WebglAddon());
renderer = "webgl";
} catch {}
const serializer = new SerializeAddon();
term.loadAddon(serializer);
const highlighter = new KeywordHighlighter(term);
const redRules = [{
id: "error",
label: "Error",
patterns: ["ERROR", "failed", "10\\\\.2\\\\.\\\\d+\\\\.\\\\d+"],
color: "#F87171",
enabled: true,
}];
const blueRules = redRules.map(rule => ({ ...rule, color: "#60A5FA" }));
highlighter.setRules(redRules, true);
const write = data => new Promise(resolve => term.write(data, resolve));
const waitPaint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
let history = "";
for (let line = 0; line < 10000; line += 1) {
history += "2026-08-13 worker=" + (line % 32) + " ERROR failed from 10.2." + (line % 255) + "." + ((line * 7) % 255) + "\\r\\n";
}
const initialStart = performance.now();
noteTerminalOutputPressureData(term, history);
await write(history);
await waitPaint();
const initialWriteMs = performance.now() - initialStart;
await new Promise(resolve => setTimeout(resolve, 650));
await highlighter.whenSettled();
const rebuildsBeforeEnter = highlighter.rebuildCount;
const enterStart = performance.now();
const prompt = "\\r\\nplain prompt # ";
noteTerminalOutputPressureData(term, prompt);
await write(prompt);
await waitPaint();
const enterWriteMs = performance.now() - enterStart;
await new Promise(resolve => setTimeout(resolve, 650));
await highlighter.whenSettled();
const enterRebuildCount = highlighter.rebuildCount;
const rebuildStart = performance.now();
highlighter.setRules(blueRules, true);
await highlighter.whenSettled();
await waitPaint();
const rebuildMs = performance.now() - rebuildStart;
const serialized = serializer.serialize({ scrollback: 10000 });
const pristine = highlighter.serializeAddon.serialize({ scrollback: 10000 });
const state = {
renderer,
rawChars: history.length,
initialWriteMs,
rebuildsBeforeEnter,
enterWriteMs,
enterRebuildCount,
rebuildMs,
rebuildCount: highlighter.rebuildCount,
blueMatchCount: (serialized.match(/38;2;96;165;250m/g) || []).length,
pristineHasNetcattyColor: /38;2;(248;113;113|96;165;250)m/.test(pristine),
};
highlighter.dispose();
term.dispose();
return state;
})()`);
if (process.env.NETCATTY_TERMINAL_PERF_REQUIRE_WEBGL === "1") {
assert.equal(result.renderer, "webgl", JSON.stringify(result));
}
assert.equal(result.enterRebuildCount, result.rebuildsBeforeEnter, JSON.stringify(result));
assert.equal(result.rebuildCount, result.rebuildsBeforeEnter + 1, JSON.stringify(result));
assert.ok(result.blueMatchCount >= 10000, JSON.stringify(result));
assert.equal(result.pristineHasNetcattyColor, false, JSON.stringify(result));
assert.ok(result.enterWriteMs < 150, `Enter write regressed: ${JSON.stringify(result)}`);
assert.ok(result.rebuildMs < 1000, `10k-line rule rebuild regressed: ${JSON.stringify(result)}`);
process.stdout.write(`XTERM_KEYWORD_HIGHLIGHT_PERFORMANCE_OK ${JSON.stringify(result)}\n`);
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}
@@ -0,0 +1,358 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
const test = require("node:test");
test("keyword highlighting keeps sustained output responsive", {
skip: "run with Electron so the real WebGL renderer is available",
}, () => {});
} else {
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const electron = require("electron");
const esbuild = require("esbuild");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const appRoot = path.resolve(__dirname, "..");
const mainRef = process.env.NETCATTY_TERMINAL_PERF_MAIN_REF ?? "origin/main";
const chunkCount = Number.parseInt(process.env.NETCATTY_TERMINAL_PERF_CHUNKS ?? "1600", 10);
const roundCount = Number.parseInt(process.env.NETCATTY_TERMINAL_PERF_ROUNDS ?? "3", 10);
const scrollback = Number.parseInt(
process.env.NETCATTY_TERMINAL_PERF_SCROLLBACK ?? "50000",
10,
);
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("xterm-highlight-throughput")}-`);
electron.app.setPath("userData", userData);
electron.app.commandLine.appendSwitch("js-flags", "--expose-gc");
electron.app.on("window-all-closed", () => {});
let window = null;
const cleanup = (exitCode) => {
if (window && !window.isDestroyed()) window.destroy();
try {
fs.rmSync(userData, { recursive: true, force: true });
} catch (error) {
console.warn("Unable to remove xterm throughput test data:", error);
} finally {
electron.app.exit(exitCode);
}
};
const buildModule = (source, resolveDir, plugins = []) => esbuild.buildSync({
stdin: { contents: source, loader: "ts", resolveDir },
bundle: true,
format: "cjs",
platform: "browser",
target: "chrome142",
write: false,
plugins,
}).outputFiles[0].text;
const buildMainModule = async () => {
const entryPath = "components/terminal/__keywordHighlightThroughputEntry.ts";
const entrySource = [
'export * from "./keywordHighlight";',
'export { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure";',
].join("\n");
const resolveMainFile = (repoPath) => {
const candidates = repoPath === entryPath
? [entryPath]
: [repoPath, `${repoPath}.ts`, `${repoPath}.tsx`, `${repoPath}/index.ts`, `${repoPath}/index.tsx`];
for (const candidate of candidates) {
if (candidate === entryPath) return candidate;
const exists = childProcess.spawnSync(
"git",
["cat-file", "-e", `${mainRef}:${candidate}`],
{ cwd: appRoot, stdio: "ignore" },
).status === 0;
if (exists) return candidate;
}
throw new Error(`Unable to resolve ${mainRef} source: ${repoPath}`);
};
const readMainFile = (repoPath) => repoPath === entryPath
? entrySource
: childProcess.execFileSync("git", ["show", `${mainRef}:${repoPath}`], {
cwd: appRoot,
encoding: "utf8",
});
const mainPlugin = {
name: "main-source",
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
if (args.kind !== "entry-point") return undefined;
return { path: entryPath, namespace: "main-source" };
});
build.onResolve({ filter: /^\.\.?\// }, (args) => ({
path: resolveMainFile(
path.posix.normalize(path.posix.join(path.posix.dirname(args.importer), args.path)),
),
namespace: "main-source",
}));
build.onLoad({ filter: /.*/, namespace: "main-source" }, (args) => ({
contents: readMainFile(args.path),
loader: args.path.endsWith(".json") ? "json" : args.path.endsWith(".tsx") ? "tsx" : "ts",
resolveDir: path.posix.dirname(args.path),
}));
},
};
return (await esbuild.build({
entryPoints: [entryPath],
bundle: true,
format: "cjs",
platform: "browser",
target: "chrome142",
write: false,
plugins: [mainPlugin],
})).outputFiles[0].text;
};
void electron.app.whenReady().then(async () => {
const oldBundle = await buildMainModule();
const currentBundle = buildModule([
`export * from ${JSON.stringify(path.join(appRoot, "components/terminal/keywordHighlight.ts"))};`,
`export { noteTerminalOutputPressureData } from ${JSON.stringify(path.join(appRoot, "components/terminal/runtime/terminalOutputPressure.ts"))};`,
].join("\n"), appRoot);
window = new electron.BrowserWindow({
show: process.env.NETCATTY_TERMINAL_PERF_SHOW_WINDOW === "1",
width: 1000,
height: 640,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body,#terminal{width:920px;height:560px;margin:0}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const webglPath = require.resolve("@xterm/addon-webgl", { paths: [appRoot] });
const result = await window.webContents.executeJavaScript(`(async () => {
const { Terminal } = require(${JSON.stringify(xtermPath)});
const { WebglAddon } = require(${JSON.stringify(webglPath)});
const loadBundle = source => {
const loaded = { exports: {} };
((module, exports) => { eval(source); })(loaded, loaded.exports);
return loaded.exports;
};
const oldModule = loadBundle(${JSON.stringify(oldBundle)});
const currentModule = loadBundle(${JSON.stringify(currentBundle)});
const rules = [
{ id: "info", label: "Info", patterns: ["INFO"], color: "#60A5FA", enabled: true },
{ id: "warn", label: "Warn", patterns: ["WARN"], color: "#FBBF24", enabled: true },
{ id: "error", label: "Error", patterns: ["ERROR", "failed"], color: "#F87171", enabled: true },
{ id: "ip", label: "IP", patterns: ["10\\\\.2\\\\.\\\\d+\\\\.\\\\d+"], color: "#4ADE80", enabled: true },
];
const makeChunk = index => {
let chunk = "";
for (let line = 0; line < 64; line += 1) {
chunk += "2026-08-13 INFO worker=" + (line % 32) + " WARN ERROR failed from 10.2."
+ ((index + line) % 255) + "." + ((index * 7 + line) % 255) + " payload="
+ "x".repeat(24) + "\\r\\n";
}
return chunk;
};
const chunks = Array.from({ length: ${chunkCount} }, (_, index) => makeChunk(index));
const totalChars = chunks.reduce((total, chunk) => total + chunk.length, 0);
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const run = async kind => {
document.getElementById("terminal").replaceChildren();
globalThis.gc?.();
await wait(20);
const v8 = require("node:v8");
const heapBefore = v8.getHeapStatistics().used_heap_size;
const term = new Terminal({
allowProposedApi: true,
cols: 120,
cursorBlink: false,
rows: 40,
scrollback: ${scrollback},
});
term.open(document.getElementById("terminal"));
let renderer = "dom";
try {
term.loadAddon(new WebglAddon());
renderer = "webgl";
} catch {}
const selectedModule = kind === "old" ? oldModule : currentModule;
const Highlighter = kind === "old"
? oldModule.KeywordHighlighter
: currentModule.KeywordHighlighter;
const highlighter = kind === "raw" ? null : new Highlighter(term);
highlighter?.setRules(rules, true);
const sustainedOnly = process.env.NETCATTY_TERMINAL_PERF_SUSTAINED_ONLY === "1";
let maxPendingPristineBytes = 0;
const pendingSample = setInterval(() => {
maxPendingPristineBytes = Math.max(
maxPendingPristineBytes,
highlighter?.pendingPristineBytes ?? 0,
);
}, 5);
const write = data => new Promise(resolve => term.write(data, resolve));
const callbackLatencies = [];
const heartbeatLatencies = [];
let heartbeatAt = performance.now();
const heartbeat = setInterval(() => {
const now = performance.now();
heartbeatLatencies.push(now - heartbeatAt);
heartbeatAt = now;
}, 10);
let renders = 0;
const renderDisposable = term.onRender(() => { renders += 1; });
const streamStarted = performance.now();
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
selectedModule.noteTerminalOutputPressureData(term, chunk);
const callbackStarted = performance.now();
await write(chunk);
callbackLatencies.push(performance.now() - callbackStarted);
if (index % 16 === 15) await wait(0);
}
const streamMs = performance.now() - streamStarted;
const rendersAtStreamEnd = renders;
const quietStarted = performance.now();
await wait(sustainedOnly ? 0 : 700);
const quietDelayMs = performance.now() - quietStarted;
const streamHeartbeatCount = heartbeatLatencies.length;
const streamMaxHeartbeatMs = Math.max(0, ...heartbeatLatencies);
const rendersBeforeSettle = renders;
const settleStarted = performance.now();
if (!sustainedOnly) await highlighter?.whenSettled?.();
const settleWaitMs = performance.now() - settleStarted;
const quietWorkMs = performance.now() - quietStarted - (sustainedOnly ? 0 : 700);
let paintTimedOut = false;
if (!sustainedOnly) {
await Promise.race([
new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))),
wait(1000).then(() => { paintTimedOut = true; }),
]);
}
const quietCatchUpMs = performance.now() - quietStarted - (sustainedOnly ? 0 : 700);
clearInterval(heartbeat);
clearInterval(pendingSample);
globalThis.gc?.();
await wait(20);
const heapAfter = v8.getHeapStatistics().used_heap_size;
callbackLatencies.sort((left, right) => left - right);
const percentile = value => callbackLatencies[Math.min(
callbackLatencies.length - 1,
Math.floor(callbackLatencies.length * value),
)];
const state = {
kind,
renderer,
streamMs,
mibPerSecond: totalChars / 1024 / 1024 / (streamMs / 1000),
callbackP50Ms: percentile(0.5),
callbackP95Ms: percentile(0.95),
callbackP99Ms: percentile(0.99),
maxHeartbeatMs: streamMaxHeartbeatMs,
maxCatchUpHeartbeatMs: Math.max(0, ...heartbeatLatencies.slice(streamHeartbeatCount)),
quietCatchUpMs,
quietWorkMs,
quietDelayMs,
settleWaitMs,
paintTimedOut,
rendersDuringStream: rendersAtStreamEnd,
rendersDuringSettle: renders - rendersBeforeSettle,
heapDeltaMiB: (heapAfter - heapBefore) / 1024 / 1024,
rebuildCount: highlighter?.rebuildCount ?? 0,
rebuildTimings: highlighter?.lastRebuildTimings ?? {},
maxPendingPristineBytes,
};
renderDisposable.dispose();
highlighter?.dispose();
term.dispose();
return state;
};
const rounds = [];
for (let round = 0; round < ${roundCount}; round += 1) {
for (const kind of ["raw", "old", "new"]) rounds.push(await run(kind));
}
return { totalChars, chunks: chunks.length, rounds };
})()`, true);
if (process.env.NETCATTY_TERMINAL_PERF_REQUIRE_WEBGL === "1") {
for (const round of result.rounds) assert.equal(round.renderer, "webgl", JSON.stringify(round));
}
const median = values => values.sort((left, right) => left - right)[Math.floor(values.length / 2)];
const byKind = kind => result.rounds.filter(round => round.kind === kind);
const oldStreamMs = median(byKind("old").map(round => round.streamMs));
const newStreamMs = median(byKind("new").map(round => round.streamMs));
const oldP99Ms = median(byKind("old").map(round => round.callbackP99Ms));
const newP99Ms = median(byKind("new").map(round => round.callbackP99Ms));
const oldHeartbeatMs = median(byKind("old").map(round => round.maxHeartbeatMs));
const newHeartbeatMs = median(byKind("new").map(round => round.maxHeartbeatMs));
const newCatchUpHeartbeatMs = median(byKind("new").map(round => round.maxCatchUpHeartbeatMs));
const newQuietCatchUpMs = median(byKind("new").map(round => round.quietCatchUpMs));
// Catch-up is deliberately deferred until output becomes quiet. Keep a
// strict event-loop stall limit, while allowing total work to scale with
// the retained history size (5s at 10k lines, 10s at 50k lines).
const maxQuietCatchUpMs = Math.max(5000, scrollback / 5);
const rawStreamMs = median(byKind("raw").map(round => round.streamMs));
const rawP99Ms = median(byKind("raw").map(round => round.callbackP99Ms));
assert.ok(
newStreamMs <= oldStreamMs * 1.1,
`new sustained throughput regressed more than 10%: ${JSON.stringify(result)}`,
);
assert.ok(
newP99Ms <= oldP99Ms * 1.15,
`new p99 write latency regressed more than 15%: ${JSON.stringify(result)}`,
);
assert.ok(
newHeartbeatMs <= Math.max(75, oldHeartbeatMs * 3),
`new event-loop stall regressed: ${JSON.stringify(result)}`,
);
assert.ok(
newCatchUpHeartbeatMs <= 350,
`quiet catch-up blocked the event loop for over 350 ms: ${JSON.stringify(result)}`,
);
// Product gate is vs main decorations (10% above). Raw xterm is a sanity
// bound only: cell-color wrap + DOM CI is typically ~15-18% over raw.
assert.ok(
newStreamMs <= rawStreamMs * 1.25,
`new sustained throughput regressed more than 25% versus raw xterm: ${JSON.stringify(result)}`,
);
assert.ok(
newP99Ms <= Math.max(10, rawP99Ms * 1.25),
`new p99 write latency regressed versus raw xterm: ${JSON.stringify(result)}`,
);
assert.ok(
newQuietCatchUpMs <= maxQuietCatchUpMs,
`quiet-period catch-up exceeded ${maxQuietCatchUpMs} ms: ${JSON.stringify(result)}`,
);
if (process.env.NETCATTY_TERMINAL_PERF_SUSTAINED_ONLY !== "1") {
assert.equal(
byKind("new").every(round => round.rebuildCount === 1),
true,
`bulk output must catch up exactly once after becoming quiet: ${JSON.stringify(result)}`,
);
assert.equal(
byKind("new").every(round => !round.paintTimedOut && round.rendersDuringSettle <= 1),
true,
`quiet catch-up must repaint atomically: ${JSON.stringify(result)}`,
);
}
assert.equal(
byKind("new").every(round => round.maxPendingPristineBytes <= 12 * 1024 * 1024),
true,
`pristine backlog must stay bounded: ${JSON.stringify(result)}`,
);
process.stdout.write(`XTERM_KEYWORD_HIGHLIGHT_THROUGHPUT ${JSON.stringify(result)}\n`);
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}