perf(terminal): implement output pressure tracking and enhance logical line handling

- Introduced `TerminalOutputPressureTracker` to monitor output pressure and manage performance modes based on backlog and burst conditions.
- Added logic to handle long lines and wrapped lines efficiently, preventing excessive processing.
- Updated `XTerminal` and related components to integrate output pressure tracking, ensuring smoother terminal performance under load.
- Added tests for new functionality to validate output pressure detection and logical line processing.
This commit is contained in:
Kang committed 2026-09-28 16:35:28 +08:00
1 parent 7b8403607a
commit 3807857e3f
11 files changed
+582 -119

No files matched your search

+40 -11
View File
@@ -32,9 +32,18 @@ interface GutterLayout {
const DEFAULT_TIMESTAMP_FORMAT = "[HH:mm:ss]";
const MAX_TIMESTAMP_FORMAT_LENGTH = 64;
const TIMESTAMP_WIDTH_SAMPLE_MS = new Date(2099, 11, 28, 23, 59, 59, 999).getTime();
const TIMESTAMP_WIDTH_SAMPLE_MS = new Date(
2099,
11,
28,
23,
59,
59,
999,
).getTime();
const GUTTER_COLUMN_GAP = 12;
const GUTTER_RIGHT_PADDING = 8;
const MAX_TIMESTAMP_LOOKBACK_ROWS = 512;
function normalizeTimestampFormat(format: string | undefined): string {
if (!format || format.trim().length === 0) {
@@ -148,7 +157,8 @@ export default function TerminalGutter({
const viewport = el.querySelector(".xterm-viewport") as HTMLElement | null;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const screenHeight = viewport?.clientHeight ?? screen?.clientHeight ?? el.clientHeight;
const screenHeight =
viewport?.clientHeight ?? screen?.clientHeight ?? el.clientHeight;
const topPadding = screen?.offsetTop ?? 0;
const core = (terminal as Terminal & XTermCoreWithRenderDimensions)._core;
@@ -161,8 +171,13 @@ export default function TerminalGutter({
: 18;
const fontSize = Number(terminal.options.fontSize ?? 12);
const cellWidth =
measuredCell?.width && measuredCell.width > 0 ? measuredCell.width : fontSize * 0.62;
const viewportY = Math.max(0, Math.min(buf.baseY, viewportYRef.current || buf.viewportY));
measuredCell?.width && measuredCell.width > 0
? measuredCell.width
: fontSize * 0.62;
const viewportY = Math.max(
0,
Math.min(buf.baseY, viewportYRef.current || buf.viewportY),
);
const rows = terminal.rows;
const cursorAbsoluteY = buf.baseY + buf.cursorY;
const lineOffset = buf.type === "alternate" ? 0 : getLineOffset();
@@ -170,8 +185,11 @@ export default function TerminalGutter({
const resolveTimestamp = (bufferLine: number): number | undefined => {
let y = bufferLine;
while (y >= 0) {
const ts = buf.type === "alternate" ? undefined : lineTimestamps.get(lineOffset + y);
while (y >= 0 && bufferLine - y < MAX_TIMESTAMP_LOOKBACK_ROWS) {
const ts =
buf.type === "alternate"
? undefined
: lineTimestamps.get(lineOffset + y);
if (ts) return ts;
const line = buf.getLine(y);
@@ -189,7 +207,7 @@ export default function TerminalGutter({
const line = buf.getLine(bufferLine);
const isWrapped = line?.isWrapped ?? false;
const hasRenderedRow = bufferLine <= cursorAbsoluteY;
const ts = resolveTimestamp(bufferLine);
const ts = showTimestamps ? resolveTimestamp(bufferLine) : undefined;
const logicalLine = lineOffset + bufferLine;
nextLines.push({
@@ -198,7 +216,10 @@ export default function TerminalGutter({
showTimestamps && hasRenderedRow && !isWrapped && ts
? formatTimestamp(ts, timestampFormat)
: "",
lineNumber: showLineNumbers && hasRenderedRow && !isWrapped ? String(logicalLine + 1) : "",
lineNumber:
showLineNumbers && hasRenderedRow && !isWrapped
? String(logicalLine + 1)
: "",
});
}
@@ -302,7 +323,10 @@ export default function TerminalGutter({
d.dispose();
});
if (handleExternalRefresh) {
window.removeEventListener("nyaterm:refresh-gutter", handleExternalRefresh);
window.removeEventListener(
"nyaterm:refresh-gutter",
handleExternalRefresh,
);
}
};
}, [suspended, terminalRef, scheduleUpdate, sessionId]);
@@ -319,8 +343,13 @@ export default function TerminalGutter({
const lineNumWidth = showLineNumbers
? Math.max(Math.ceil(layout.cellWidth * layout.maxLineNumberDigits) + 2, 24)
: 0;
const timestampTemplate = formatTimestamp(TIMESTAMP_WIDTH_SAMPLE_MS, timestampFormat);
const tsWidth = showTimestamps ? Math.ceil(layout.cellWidth * timestampTemplate.length) + 2 : 0;
const timestampTemplate = formatTimestamp(
TIMESTAMP_WIDTH_SAMPLE_MS,
timestampFormat,
);
const tsWidth = showTimestamps
? Math.ceil(layout.cellWidth * timestampTemplate.length) + 2
: 0;
const columnGap = showLineNumbers && showTimestamps ? GUTTER_COLUMN_GAP : 0;
const gutterWidth = tsWidth + lineNumWidth + columnGap + GUTTER_RIGHT_PADDING;
+65 -30
View File
@@ -175,6 +175,8 @@ type SearchAddonWithLifecycle = SearchAddon & {
* xterm.js terminal for a session. Handles OSC 133 shell integration (or fallback prompt
* detection), fuzzy command history suggestions, and resize/fit. Key props: sessionId, active.
*/
const MAX_WRAPPED_LINE_LOOKBACK_ROWS = 512;
export default function XTerminal({
sessionId,
sessionName,
@@ -512,9 +514,12 @@ export default function XTerminal({
}),
);
unlistenBag.add(
listen<SerialModemEventPayload>(`serial-modem-event-${sessionId}`, (event) => {
wake({ type: "serialModem", payload: event.payload });
}),
listen<SerialModemEventPayload>(
`serial-modem-event-${sessionId}`,
(event) => {
wake({ type: "serialModem", payload: event.payload });
},
),
);
unlistenBag.add(
listen<AiCaptureEvent>(`ai-capture-${sessionId}`, (event) => {
@@ -1190,11 +1195,11 @@ export default function XTerminal({
origin,
}).catch(() => {});
}
return sendSessionInput(sessionId, data, {
preview: null,
registerSubmission: command,
return sendSessionInput(sessionId, data, {
preview: null,
registerSubmission: command,
origin,
}).catch(() => {});
}).catch(() => {});
};
const canReconnectDisconnectedSession = () =>
@@ -1218,7 +1223,8 @@ export default function XTerminal({
};
const assertTemporaryConfigMatchesSessionType = () => {
if (!temporaryConfigRef.current || temporaryConfigMatchesSessionType()) return;
if (!temporaryConfigRef.current || temporaryConfigMatchesSessionType())
return;
throw new Error("Temporary session config protocol mismatch");
};
@@ -1273,7 +1279,9 @@ export default function XTerminal({
if (connectionId) {
return invoke<string>("create_ssh_session", {
connectionId,
startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand),
startupCommand: buildStartupCommandPayload(
restoreCwdStartupCommand,
),
});
}
assertTemporaryConfigMatchesSessionType();
@@ -1281,12 +1289,16 @@ export default function XTerminal({
const { protocol: _protocol, ...sshConfig } = temporaryConfig;
return invoke<string>("create_temporary_ssh_session", {
config: sshConfig,
startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand),
startupCommand: buildStartupCommandPayload(
restoreCwdStartupCommand,
),
});
}
return invoke<string>("create_ssh_session", {
connectionId,
startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand),
startupCommand: buildStartupCommandPayload(
restoreCwdStartupCommand,
),
});
}
};
@@ -1924,7 +1936,10 @@ export default function XTerminal({
const map = lineTimestampsRef.current;
let startLine = cursorLine;
while (startLine > 0) {
while (
startLine > 0 &&
cursorLine - startLine < MAX_WRAPPED_LINE_LOOKBACK_ROWS
) {
const line = buf.getLine(startLine);
if (line && !line.isWrapped) break;
startLine -= 1;
@@ -1945,6 +1960,8 @@ export default function XTerminal({
outputDrain,
frameGate,
noteSkippedOutput,
noteOutputPressure,
disposeOutputPressure,
maybeRecoverPerformanceMode,
refreshOutputPressureMode,
updateOutputDrainMode,
@@ -2104,7 +2121,10 @@ export default function XTerminal({
case "serialModem":
if (event.payload.type === "progress") {
zmodemActiveRef.current = true;
} else if (event.payload.type === "complete" || event.payload.type === "failed") {
} else if (
event.payload.type === "complete" ||
event.payload.type === "failed"
) {
zmodemActiveRef.current = false;
}
serialModemHandler.handle(event.payload);
@@ -2195,6 +2215,7 @@ export default function XTerminal({
enterDisconnectedState,
enterDisconnectedStateIfAttachSessionMissing,
noteSkippedOutput,
noteOutputPressure,
noteOutputActivity,
updateCredentialPromptInputMode,
feedCredentialOutput,
@@ -2344,7 +2365,11 @@ export default function XTerminal({
}
if (data === "\x04" && fallbackInteractive) {
fallbackInteractive = nextFallbackInteractiveState(fallbackInteractive, data, "");
fallbackInteractive = nextFallbackInteractiveState(
fallbackInteractive,
data,
"",
);
inputStateRef.current = createTerminalInputState();
}
@@ -2432,19 +2457,30 @@ export default function XTerminal({
disconnected: disconnectedRef.current,
aiCapturing: aiCapturingRef.current,
credentialPrompt: isCredentialPromptInputMode(),
interactive: fallbackInteractive || commandSuggestionSuppressedRef.current,
interactive:
fallbackInteractive || commandSuggestionSuppressedRef.current,
})
) {
const buffer = terminal.buffer.active;
const cursorLine = buffer.baseY + buffer.cursorY;
let startLine = cursorLine;
while (startLine > 0 && buffer.getLine(startLine)?.isWrapped)
while (
startLine > 0 &&
cursorLine - startLine < MAX_WRAPPED_LINE_LOOKBACK_ROWS &&
buffer.getLine(startLine)?.isWrapped
)
startLine--;
const marker = terminal.registerMarker(startLine - cursorLine);
if (marker) commandNavigation.add(marker);
if (!buffer.getLine(startLine)?.isWrapped) {
const marker = terminal.registerMarker(startLine - cursorLine);
if (marker) commandNavigation.add(marker);
}
}
if (data === "\r" && command && !shellIntegrationRef.current.enabled) {
fallbackInteractive = nextFallbackInteractiveState(fallbackInteractive, data, command);
fallbackInteractive = nextFallbackInteractiveState(
fallbackInteractive,
data,
command,
);
}
inputStateRef.current = applyTerminalInputData(
inputStateRef.current,
@@ -2508,7 +2544,11 @@ export default function XTerminal({
const handleTerminalPointerDown = () => {
commandNavigation.reset();
};
containerEl.addEventListener("pointerdown", handleTerminalPointerDown, true);
containerEl.addEventListener(
"pointerdown",
handleTerminalPointerDown,
true,
);
const commandSelectionChangeDisposable = terminal.onSelectionChange(() => {
if (!terminal.hasSelection()) {
@@ -2665,6 +2705,7 @@ export default function XTerminal({
fitSchedulerRef.current = null;
}
sessionEvents.dispose();
disposeOutputPressure();
zmodemHandler.dispose();
serialModemHandler.dispose();
frameGate.dispose({ ackRemaining: true, reason: "terminal_cleanup" });
@@ -2746,16 +2787,10 @@ export default function XTerminal({
terminalReady,
performanceMode,
});
useKeywordHighlighter(
terminalInstance,
terminalSettings,
sessionId,
isDark,
{
suspended: keywordHighlighterSuspended,
releaseCachesAfterDelay: !visible || hibernated,
},
);
useKeywordHighlighter(terminalInstance, terminalSettings, sessionId, isDark, {
suspended: keywordHighlighterSuspended,
releaseCachesAfterDelay: !visible || hibernated,
});
const { tooltipState, menuState, closeMenu } = useActionLinks(
terminalInstance,
@@ -0,0 +1,32 @@
import type { Terminal } from "@xterm/xterm";
import { describe, expect, it, vi } from "vitest";
import { createTerminalInputState } from "@/lib/terminalInputTracker";
import { getInputIndexAtBufferPosition } from "./terminalInputSelection";
describe("Smart Cursor logical line budget", () => {
it("falls back before translating a line wrapped across thousands of rows", () => {
const translate = vi.fn(() => "x".repeat(80));
const terminal = {
cols: 80,
buffer: {
active: {
type: "normal",
length: 10_000,
baseY: 9_999,
cursorY: 0,
cursorX: 1,
getLine: (y: number) =>
y >= 0 && y < 10_000
? { isWrapped: y > 0, length: 80, translateToString: translate }
: undefined,
},
},
} as unknown as Terminal;
const state = { ...createTerminalInputState(), value: "x", cursor: 1 };
expect(
getInputIndexAtBufferPosition(terminal, state, { x: 1, y: 9_999 }),
).toBeNull();
expect(translate).not.toHaveBeenCalled();
});
});
@@ -96,6 +96,8 @@ interface XTermCoreWithRenderDimensions {
const MAX_GEOMETRY_INPUT_CHARS = 64 * 1024;
const MAX_GEOMETRY_INPUT_LINES = 512;
const MAX_INPUT_LOGICAL_LINE_ROWS = 512;
const MAX_INPUT_LOGICAL_LINE_CHARS = 64 * 1024;
const inputSpanCache = new WeakMap<Terminal, CachedInputSpan>();
function buildLineStringToCellMap(
@@ -139,11 +141,13 @@ function readLogicalLineSnapshotAt(
const buffer = terminal.buffer.active;
let startY = anchorY;
while (startY > 0 && buffer.getLine(startY)?.isWrapped) {
if (anchorY - startY + 2 > MAX_INPUT_LOGICAL_LINE_ROWS) return null;
startY -= 1;
}
let endY = anchorY;
while (endY + 1 < buffer.length && buffer.getLine(endY + 1)?.isWrapped) {
if (endY - startY + 2 > MAX_INPUT_LOGICAL_LINE_ROWS) return null;
endY += 1;
}
@@ -151,6 +155,7 @@ function readLogicalLineSnapshotAt(
const parts: string[] = [];
const stringIndexToCellOffset: number[] = [];
let lastCellOffset = 0;
let totalChars = 0;
for (let y = startY; y <= endY; y += 1) {
const line = buffer.getLine(y);
@@ -159,7 +164,14 @@ function readLogicalLineSnapshotAt(
const rowOffset = (y - startY) * terminal.cols;
const maxCols = Math.min(line.length, terminal.cols);
const text = line.translateToString(false, 0, maxCols);
const lineMap = buildLineStringToCellMap(line, text.length, maxCols, scratchCell);
totalChars += text.length;
if (totalChars > MAX_INPUT_LOGICAL_LINE_CHARS) return null;
const lineMap = buildLineStringToCellMap(
line,
text.length,
maxCols,
scratchCell,
);
for (let i = 0; i < text.length; i += 1) {
stringIndexToCellOffset.push(rowOffset + (lineMap[i] ?? i));
@@ -173,7 +185,9 @@ function readLogicalLineSnapshotAt(
return { startY, endY, text: parts.join(""), stringIndexToCellOffset };
}
function readLogicalLineSnapshot(terminal: Terminal): LogicalInputLineSnapshot | null {
function readLogicalLineSnapshot(
terminal: Terminal,
): LogicalInputLineSnapshot | null {
const buffer = terminal.buffer.active;
return readLogicalLineSnapshotAt(terminal, buffer.baseY + buffer.cursorY);
}
@@ -209,7 +223,11 @@ function findTrackedInputCellSpans(
return spans;
}
function toGlobalCellOffset(snapshot: LogicalInputLineSnapshot, cellOffset: number, cols: number) {
function toGlobalCellOffset(
snapshot: LogicalInputLineSnapshot,
cellOffset: number,
cols: number,
) {
return snapshot.startY * cols + cellOffset;
}
@@ -222,7 +240,8 @@ function inputCellSpanToInputSpan(
const indexToCellOffset: number[] = [];
for (let i = 0; i <= valueLength; i += 1) {
const cellOffset = snapshot.stringIndexToCellOffset[span.startStringIndex + i];
const cellOffset =
snapshot.stringIndexToCellOffset[span.startStringIndex + i];
if (cellOffset === undefined) return null;
indexToCellOffset.push(toGlobalCellOffset(snapshot, cellOffset, cols));
}
@@ -242,7 +261,9 @@ function findSingleLineInputSpans(
cols: number,
): InputSpan[] {
return findTrackedInputCellSpans(snapshot, state)
.map((span) => inputCellSpanToInputSpan(snapshot, span, state.value.length, cols))
.map((span) =>
inputCellSpanToInputSpan(snapshot, span, state.value.length, cols),
)
.filter((span): span is InputSpan => span !== null);
}
@@ -254,7 +275,10 @@ function findLineSegmentIndex(lineText: string, segment: string): number {
return lineText.lastIndexOf(segment);
}
function cellOffsetToInputIndex(indexToCellOffset: number[], cellOffset: number): number {
function cellOffsetToInputIndex(
indexToCellOffset: number[],
cellOffset: number,
): number {
for (let i = 0; i < indexToCellOffset.length; i += 1) {
if ((indexToCellOffset[i] ?? 0) >= cellOffset) {
return i;
@@ -288,7 +312,11 @@ function getSegmentIndexAtCursor(offsets: number[], cursor: number): number {
function canResolveEnhancedGeometry(state: TerminalInputState): boolean {
if (!state.value) return false;
if (state.value.length > MAX_GEOMETRY_INPUT_CHARS) return false;
if (state.multiline && state.value.split("\n").length > MAX_GEOMETRY_INPUT_LINES) return false;
if (
state.multiline &&
state.value.split("\n").length > MAX_GEOMETRY_INPUT_LINES
)
return false;
return true;
}
@@ -309,7 +337,10 @@ function getNextLogicalLineSnapshot(
return readLogicalLineSnapshotAt(terminal, current.endY + 1);
}
function findSegmentStartIndex(lineText: string, segment: string): number | null {
function findSegmentStartIndex(
lineText: string,
segment: string,
): number | null {
if (!segment) {
return lineText.length;
}
@@ -318,7 +349,10 @@ function findSegmentStartIndex(lineText: string, segment: string): number | null
return index >= 0 ? index : null;
}
function buildMultilineInputSpan(terminal: Terminal, state: TerminalInputState): InputSpan | null {
function buildMultilineInputSpan(
terminal: Terminal,
state: TerminalInputState,
): InputSpan | null {
const segments = state.value.split("\n");
if (segments.length <= 1) return null;
@@ -326,10 +360,13 @@ function buildMultilineInputSpan(terminal: Terminal, state: TerminalInputState):
if (!cursorSnapshot) return null;
const segmentOffsets = getMultilineSegmentOffsets(state.value);
const cursorSegmentIndex = getSegmentIndexAtCursor(segmentOffsets, state.cursor);
const segmentSnapshots: Array<LogicalInputLineSnapshot | null> = new Array(segments.length).fill(
null,
const cursorSegmentIndex = getSegmentIndexAtCursor(
segmentOffsets,
state.cursor,
);
const segmentSnapshots: Array<LogicalInputLineSnapshot | null> = new Array(
segments.length,
).fill(null);
segmentSnapshots[cursorSegmentIndex] = cursorSnapshot;
for (let i = cursorSegmentIndex - 1; i >= 0; i -= 1) {
@@ -346,7 +383,11 @@ function buildMultilineInputSpan(terminal: Terminal, state: TerminalInputState):
const indexToCellOffset: number[] = [];
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
for (
let segmentIndex = 0;
segmentIndex < segments.length;
segmentIndex += 1
) {
const segment = segments[segmentIndex] ?? "";
const snapshot = segmentSnapshots[segmentIndex];
const valueOffset = segmentOffsets[segmentIndex] ?? 0;
@@ -358,7 +399,11 @@ function buildMultilineInputSpan(terminal: Terminal, state: TerminalInputState):
for (let i = 0; i <= segment.length; i += 1) {
const cellOffset = snapshot.stringIndexToCellOffset[matchIndex + i];
if (cellOffset === undefined) return null;
indexToCellOffset[valueOffset + i] = toGlobalCellOffset(snapshot, cellOffset, terminal.cols);
indexToCellOffset[valueOffset + i] = toGlobalCellOffset(
snapshot,
cellOffset,
terminal.cols,
);
}
}
@@ -386,7 +431,10 @@ function buildMultilineInputSpan(terminal: Terminal, state: TerminalInputState):
};
}
function buildInputSpanCacheKey(terminal: Terminal, state: TerminalInputState): string {
function buildInputSpanCacheKey(
terminal: Terminal,
state: TerminalInputState,
): string {
const buffer = terminal.buffer.active;
return [
state.value,
@@ -398,7 +446,10 @@ function buildInputSpanCacheKey(terminal: Terminal, state: TerminalInputState):
].join("\u0000");
}
function getCachedInputSpan(terminal: Terminal, state: TerminalInputState): InputSpan | null {
function getCachedInputSpan(
terminal: Terminal,
state: TerminalInputState,
): InputSpan | null {
const key = buildInputSpanCacheKey(terminal, state);
const cached = inputSpanCache.get(terminal);
if (cached?.key === key) {
@@ -410,18 +461,26 @@ function getCachedInputSpan(terminal: Terminal, state: TerminalInputState): Inpu
return span;
}
function resolveTrackedInputSpan(terminal: Terminal, state: TerminalInputState): InputSpan | null {
function resolveTrackedInputSpan(
terminal: Terminal,
state: TerminalInputState,
): InputSpan | null {
if (!canResolveEnhancedGeometry(state)) return null;
const buffer = terminal.buffer.active;
const cursorCellOffset = (buffer.baseY + buffer.cursorY) * terminal.cols + buffer.cursorX;
const cursorCellOffset =
(buffer.baseY + buffer.cursorY) * terminal.cols + buffer.cursorX;
if (!state.multiline) {
const snapshot = readLogicalLineSnapshot(terminal);
if (!snapshot) return null;
const spans = findSingleLineInputSpans(snapshot, state, terminal.cols);
return spans.find((span) => span.indexToCellOffset[state.cursor] === cursorCellOffset) ?? null;
return (
spans.find(
(span) => span.indexToCellOffset[state.cursor] === cursorCellOffset,
) ?? null
);
}
const span = buildMultilineInputSpan(terminal, state);
@@ -456,7 +515,10 @@ export function getSelectedInputRange(
return null;
}
const start = cellOffsetToInputIndex(inputSpan.indexToCellOffset, selectionStart);
const start = cellOffsetToInputIndex(
inputSpan.indexToCellOffset,
selectionStart,
);
const end = cellOffsetToInputIndex(inputSpan.indexToCellOffset, selectionEnd);
if (start < 0 || end > state.value.length || end <= start) {
@@ -470,7 +532,9 @@ export function getMouseBufferPosition(
terminal: Terminal,
event: MouseEvent,
): InputClickPosition | null {
const screenEl = terminal.element?.querySelector(".xterm-screen") as HTMLElement | null;
const screenEl = terminal.element?.querySelector(
".xterm-screen",
) as HTMLElement | null;
const core = (terminal as Terminal & XTermCoreWithRenderDimensions)._core;
const cellWidth = core?._renderService?.dimensions?.css?.cell?.width ?? 0;
const cellHeight = core?._renderService?.dimensions?.css?.cell?.height ?? 0;
@@ -511,7 +575,10 @@ export function getInputIndexAtBufferPosition(
return position.y === inputSpan.endY ? state.value.length : null;
}
const inputIndex = cellOffsetToInputIndex(inputSpan.indexToCellOffset, clickedCellOffset);
const inputIndex = cellOffsetToInputIndex(
inputSpan.indexToCellOffset,
clickedCellOffset,
);
if (inputIndex < 0 || inputIndex > state.value.length) return null;
return inputIndex;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { TerminalOutputPressureTracker } from "./terminalOutputPressure";
describe("TerminalOutputPressureTracker", () => {
it("detects a burst before backlog accumulates and recovers after quiet", () => {
const tracker = new TerminalOutputPressureTracker();
tracker.noteOutput("a".repeat(20_000), 20_000, 0);
tracker.noteOutput("b".repeat(24_000), 24_000, 20);
tracker.noteOutput("c".repeat(30_000), 30_000, 40);
expect(tracker.getReasons(0, 40)).toContain("burst");
expect(tracker.isStrained(0, 200)).toBe(true);
expect(tracker.isStrained(0, 440)).toBe(false);
});
it("tracks unbroken lines across chunks and resets on CR or LF", () => {
const tracker = new TerminalOutputPressureTracker();
tracker.noteOutput("x".repeat(40_000), 40_000, 0);
tracker.noteOutput("y".repeat(30_000), 30_000, 200);
expect(tracker.getReasons(0, 200)).toContain("long-line");
tracker.noteOutput("\rshort\nnext", 11, 700);
expect(tracker.getReasons(0, 700)).toContain("long-line");
expect(tracker.isStrained(0, 700)).toBe(true);
expect(tracker.getReasons(0, 1100)).not.toContain("long-line");
expect(tracker.isStrained(0, 1100)).toBe(false);
});
it("detects a long line that ends within one chunk", () => {
const tracker = new TerminalOutputPressureTracker();
tracker.noteOutput(`${"x".repeat(70_000)}\n`, 70_001, 0);
expect(tracker.isStrained(0, 0)).toBe(true);
expect(tracker.isStrained(0, 400)).toBe(false);
});
it("keeps backlog pressure until the queue drains", () => {
const tracker = new TerminalOutputPressureTracker();
expect(tracker.isStrained(128 * 1024, 0)).toBe(true);
expect(tracker.isStrained(128 * 1024, 1000)).toBe(true);
expect(tracker.isStrained(0, 1200)).toBe(true);
expect(tracker.isStrained(0, 1400)).toBe(false);
});
});
@@ -0,0 +1,87 @@
import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance";
export type TerminalOutputPressureReason = "backlog" | "burst" | "long-line";
const config = XTERM_PERFORMANCE_CONFIG.output;
export class TerminalOutputPressureTracker {
private bursts: Array<{ at: number; bytes: number }> = [];
private burstHead = 0;
private burstBytes = 0;
private unbrokenChars = 0;
private lastPressureAt = -Infinity;
private lastLongLineAt = -Infinity;
noteOutput(data: string, bytes: number, now = Date.now()): void {
this.expireBursts(now);
this.bursts.push({ at: now, bytes });
this.burstBytes += bytes;
let runStart = 0;
const breaks = /[\n\r]/g;
let match = breaks.exec(data);
let longLineSeen = false;
while (match) {
if (
this.unbrokenChars + match.index - runStart >=
config.pressure.longLineChars
) {
longLineSeen = true;
}
this.unbrokenChars = 0;
runStart = match.index + 1;
match = breaks.exec(data);
}
this.unbrokenChars += data.length - runStart;
if (this.unbrokenChars >= config.pressure.longLineChars || longLineSeen) {
this.lastLongLineAt = now;
this.lastPressureAt = now;
}
if (this.burstBytes >= config.pressure.burstBytes) {
this.lastPressureAt = now;
}
}
getReasons(
backlogBytes: number,
now = Date.now(),
): TerminalOutputPressureReason[] {
this.expireBursts(now);
const reasons: TerminalOutputPressureReason[] = [];
if (backlogBytes >= config.strainedBacklogBytes) {
reasons.push("backlog");
this.lastPressureAt = now;
}
if (this.burstBytes >= config.pressure.burstBytes) reasons.push("burst");
if (now - this.lastLongLineAt < config.pressure.quietMs) {
reasons.push("long-line");
}
return reasons;
}
isStrained(backlogBytes: number, now = Date.now()): boolean {
if (this.getReasons(backlogBytes, now).length > 0) {
return true;
}
return now - this.lastPressureAt < config.pressure.quietMs;
}
getRecoveryDelayMs(now = Date.now()): number {
return Math.max(0, this.lastPressureAt + config.pressure.quietMs - now);
}
private expireBursts(now: number): void {
while (
this.burstHead < this.bursts.length &&
now - this.bursts[this.burstHead].at >= config.pressure.burstWindowMs
) {
this.burstBytes -= this.bursts[this.burstHead].bytes;
this.burstHead += 1;
}
if (this.burstHead > 1024 && this.burstHead * 2 >= this.bursts.length) {
this.bursts = this.bursts.slice(this.burstHead);
this.burstHead = 0;
}
}
}
@@ -11,6 +11,7 @@ import {
type TerminalOutputDrainMode,
} from "./terminalOutputDrain";
import { TerminalOutputScheduler } from "./terminalOutputScheduling";
import { TerminalOutputPressureTracker } from "./terminalOutputPressure";
import type { HibernationPhase } from "./xterminalInternalTypes";
import type { PerformanceMode } from "./xterminalTypes";
@@ -66,6 +67,8 @@ export function createXTerminalOutputController({
sessionId,
terminalGeneration,
);
const pressureTracker = new TerminalOutputPressureTracker();
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
const isAlternateScreenActive = () =>
terminal.buffer.active.type === "alternate" ||
alternateScreenTrackerRef.current.isAlternateScreenActive();
@@ -89,14 +92,33 @@ export function createXTerminalOutputController({
(frameGateRef.current?.getHeldBytes() ?? 0);
const getNonOverloadedPressureMode = (): PerformanceMode =>
getPendingOutputBytes() >=
XTERM_PERFORMANCE_CONFIG.output.strainedBacklogBytes
? "strained"
: "normal";
pressureTracker.isStrained(getPendingOutputBytes()) ? "strained" : "normal";
const refreshOutputPressureMode = () => {
if (performanceModeRef.current === "overloaded") return;
setOutputPressureMode(getNonOverloadedPressureMode());
const mode = getNonOverloadedPressureMode();
setOutputPressureMode(mode);
if (recoveryTimer !== null) clearTimeout(recoveryTimer);
recoveryTimer = null;
if (mode === "strained") {
const delay = pressureTracker.getRecoveryDelayMs();
if (delay > 0) {
recoveryTimer = setTimeout(
refreshOutputPressureMode,
Math.ceil(delay) + 1,
);
}
}
};
const noteOutputPressure = (data: string, bytes: number) => {
pressureTracker.noteOutput(data, bytes);
refreshOutputPressureMode();
};
const disposeOutputPressure = () => {
if (recoveryTimer !== null) clearTimeout(recoveryTimer);
recoveryTimer = null;
};
const noteSkippedOutput = (count: number) => {
@@ -322,6 +344,8 @@ export function createXTerminalOutputController({
frameGate,
isAlternateScreenActive,
noteSkippedOutput,
noteOutputPressure,
disposeOutputPressure,
maybeRecoverPerformanceMode,
refreshOutputPressureMode,
updateOutputDrainMode,
@@ -72,6 +72,7 @@ interface CreateXTerminalSessionEventsParams {
}) => void;
enterDisconnectedStateIfAttachSessionMissing: (error: unknown) => boolean;
noteSkippedOutput: (count: number) => void;
noteOutputPressure: (data: string, bytes: number) => void;
noteOutputActivity: () => void;
updateCredentialPromptInputMode: (payload: string) => void;
feedCredentialOutput: (payload: string) => void;
@@ -117,6 +118,7 @@ export function createXTerminalSessionEvents({
enterDisconnectedState,
enterDisconnectedStateIfAttachSessionMissing,
noteSkippedOutput,
noteOutputPressure,
noteOutputActivity,
updateCredentialPromptInputMode,
feedCredentialOutput,
@@ -166,11 +168,10 @@ export function createXTerminalSessionEvents({
return;
}
noteOutputActivity();
noteOutputPressure(payload.data, payload.bytes);
const recentPayload =
payload.data.length > 4096
? payload.data.slice(-4096)
: payload.data;
payload.data.length > 4096 ? payload.data.slice(-4096) : payload.data;
alternateScreenTrackerRef.current.ingest(payload.data);
updateCredentialPromptInputMode(recentPayload);
feedCredentialOutput(recentPayload);
@@ -318,7 +319,10 @@ export function createXTerminalSessionEvents({
requestWake("serial_modem");
if (event.payload.type === "progress") {
zmodemActiveRef.current = true;
} else if (event.payload.type === "complete" || event.payload.type === "failed") {
} else if (
event.payload.type === "complete" ||
event.payload.type === "failed"
) {
zmodemActiveRef.current = false;
}
serialModemHandler.handle(event.payload);
@@ -365,7 +369,8 @@ export function createXTerminalSessionEvents({
{ reason: "terminal_ready", backend_attached: backendAttached },
error,
);
const sessionMissing = enterDisconnectedStateIfAttachSessionMissing(error);
const sessionMissing =
enterDisconnectedStateIfAttachSessionMissing(error);
if (!backendAttached && !sessionMissing) {
// The backend is still detached, so let setup retry without publishing
// a title from an unsettled renderer.
+70
View File
@@ -0,0 +1,70 @@
import type { Terminal } from "@xterm/xterm";
import { describe, expect, it, vi } from "vitest";
import { ActionLinksAddon, type ActionMatcher } from "./actionLinksAddon";
function createTerminal(rowCount: number, rowText: string) {
const translate = vi.fn(() => rowText);
const terminal = {
cols: rowText.length,
buffer: {
active: {
type: "normal",
viewportY: 0,
length: rowCount,
getLine: (y: number) =>
y >= 0 && y < rowCount
? {
isWrapped: y > 0,
length: rowText.length,
translateToString: translate,
}
: undefined,
getNullCell: () => ({}),
},
},
registerLinkProvider: () => ({ dispose: () => {} }),
onWriteParsed: () => ({ dispose: () => {} }),
onResize: () => ({ dispose: () => {} }),
onRender: () => ({ dispose: () => {} }),
} as unknown as Terminal;
return { terminal, translate };
}
describe("ActionLinksAddon logical line budget", () => {
const matcher: ActionMatcher = {
id: "test",
label: "Test",
match: vi.fn(() => []),
getActions: () => [],
};
it("stops before translating an excessively wrapped line", () => {
const { terminal, translate } = createTerminal(10_000, "x".repeat(80));
const addon = new ActionLinksAddon([matcher]);
addon.activate(terminal);
expect(addon.computeLinksForLine(5_000)).toEqual([]);
expect(translate).not.toHaveBeenCalled();
addon.dispose();
});
it("stops assembling after the character limit", () => {
const { terminal, translate } = createTerminal(200, "x".repeat(1_000));
const addon = new ActionLinksAddon([matcher]);
addon.activate(terminal);
expect(addon.computeLinksForLine(0)).toEqual([]);
expect(translate.mock.calls.length).toBeLessThan(20);
addon.dispose();
});
it("passes the complete text of a normal wrapped line to matchers", () => {
const { terminal } = createTerminal(3, "abc");
const match = vi.fn(() => []);
const addon = new ActionLinksAddon([{ ...matcher, match }]);
addon.activate(terminal);
expect(addon.computeLinksForLine(1)).toEqual([]);
expect(match).toHaveBeenCalledWith(
expect.objectContaining({ text: "abcabcabc" }),
);
addon.dispose();
});
});
+108 -45
View File
@@ -15,7 +15,13 @@ import type {
/* Types */
/* -------------------------------------------------------------------------- */
export type EntityKind = "url" | "ip" | "hostPort" | "archive" | "file" | "custom";
export type EntityKind =
| "url"
| "ip"
| "hostPort"
| "archive"
| "file"
| "custom";
export type ExecutionTrigger =
| "plainClick"
@@ -149,8 +155,16 @@ export interface ExecutionPolicy {
ctx: ActionContext,
trigger: ExecutionTrigger,
) => boolean | Promise<boolean>;
transformCommand?: (command: string, ctx: ActionContext, trigger: ExecutionTrigger) => string;
resolveAliasCommand?: (command: string, ctx: ActionContext, trigger: ExecutionTrigger) => string;
transformCommand?: (
command: string,
ctx: ActionContext,
trigger: ExecutionTrigger,
) => string;
resolveAliasCommand?: (
command: string,
ctx: ActionContext,
trigger: ExecutionTrigger,
) => string;
onExecutionError?: (
error: unknown,
action: ResolvedAction,
@@ -205,7 +219,10 @@ export interface HostPortMatcherOptions extends CommonMatcherOptions {
/* Helpers */
/* -------------------------------------------------------------------------- */
function resolveActions(matcher: ActionMatcher, ctx: ActionContext): ResolvedAction[] {
function resolveActions(
matcher: ActionMatcher,
ctx: ActionContext,
): ResolvedAction[] {
const defs = matcher.getActions(ctx);
return defs
.filter((d) => !d.when || d.when(ctx))
@@ -265,24 +282,29 @@ interface ParsedLine {
endY: number;
}
function readLogicalLineAtAbsoluteY(terminal: Terminal, absY: number): ParsedLine | null {
const MAX_LOGICAL_LINE_SCAN_CHARS = 16 * 1024;
const MAX_LOGICAL_LINE_ROWS = 256;
function readLogicalLineAtAbsoluteY(
terminal: Terminal,
absY: number,
): ParsedLine | null {
const buffer = terminal.buffer.active;
const line = buffer.getLine(absY);
if (!line) return null;
// Walk backwards to find the start of a wrapped group
let startY = absY;
while (startY > 0) {
const prev = buffer.getLine(startY - 1);
if (!prev?.isWrapped) break;
startY--;
while (startY > 0 && buffer.getLine(startY)?.isWrapped) {
if (absY - startY + 2 > MAX_LOGICAL_LINE_ROWS) return null;
startY -= 1;
}
let endY = absY;
while (endY + 1 < buffer.length) {
const next = buffer.getLine(endY + 1);
if (!next?.isWrapped) break;
endY++;
if (endY - startY + 2 > MAX_LOGICAL_LINE_ROWS) return null;
endY += 1;
}
// Rebuild the full logical line from startY up through wrapped continuations
@@ -298,6 +320,7 @@ function readLogicalLineAtAbsoluteY(terminal: Terminal, absY: number): ParsedLin
const maxCols = Math.min(l.length, terminal.cols);
const text = l.translateToString(y === endY, 0, maxCols);
if (full.length + text.length > MAX_LOGICAL_LINE_SCAN_CHARS) return null;
if (y < absY) {
offsetChars += text.length;
@@ -331,7 +354,10 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
private _cache = new Map<string, ActionLink[]>();
// Decoration layer: key → {deco, marker}, line → keys, scanned line set
private _decoCache = new Map<string, { deco: IDecoration; marker: IMarker }>();
private _decoCache = new Map<
string,
{ deco: IDecoration; marker: IMarker }
>();
private _lineToDecoKeys = new Map<number, string[]>();
private _scannedAbsLines = new Set<number>();
private _writeDecoTimer: ReturnType<typeof setTimeout> | null = null;
@@ -346,10 +372,14 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
private static readonly DECORATION_DEBOUNCE_MS = 50;
private static readonly DECORATION_THROTTLE_MS = 80;
private static readonly MAX_CACHE_ENTRIES = 2000;
private static readonly MAX_LOGICAL_LINE_SCAN_CHARS = 16 * 1024;
constructor(matchers: ActionMatcher[] = [], options: ActionLinksAddonOptions = {}) {
this._matchers = [...matchers].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
constructor(
matchers: ActionMatcher[] = [],
options: ActionLinksAddonOptions = {},
) {
this._matchers = [...matchers].sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
);
this._options = {
allowCtrlOrMetaClickExecute: true,
allowAltClickMenu: true,
@@ -406,7 +436,9 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
}
setMatchers(matchers: ActionMatcher[]): void {
this._matchers = [...matchers].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
this._matchers = [...matchers].sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
);
this._cache.clear();
this._clearAllDecorations();
this._scheduleWriteDecoRefresh();
@@ -439,7 +471,10 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
}
/* ILinkProvider */
provideLinks(bufferLineNumber: number, callback: (links: ILink[] | undefined) => void): void {
provideLinks(
bufferLineNumber: number,
callback: (links: ILink[] | undefined) => void,
): void {
const terminal = this._terminal;
if (
!terminal ||
@@ -456,12 +491,11 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
callback(undefined);
return;
}
if (parsed.full.length > ActionLinksAddon.MAX_LOGICAL_LINE_SCAN_CHARS) {
callback(undefined);
return;
}
const actionLinks = this._getCachedActionLinks(terminal, parsed.full, bufferLineNumber);
const actionLinks = this._getCachedActionLinks(
terminal,
parsed.full,
bufferLineNumber,
);
const ilinks: ILink[] = [];
for (const al of actionLinks) {
@@ -545,7 +579,12 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
}
private _refreshDecorations(terminal: Terminal): void {
if (this._suspended || !terminal.buffer?.active || this._matchers.length === 0) return;
if (
this._suspended ||
!terminal.buffer?.active ||
this._matchers.length === 0
)
return;
if (terminal.buffer.active.type === "alternate") {
this._clearAllDecorations();
return;
@@ -576,7 +615,8 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
// Reuse memoized result for immutable scrollback lines
if (this._scannedAbsLines.has(absLineY)) {
for (const k of this._lineToDecoKeys.get(absLineY) ?? []) requiredKeys.add(k);
for (const k of this._lineToDecoKeys.get(absLineY) ?? [])
requiredKeys.add(k);
continue;
}
@@ -587,22 +627,23 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
}
continue;
}
if (parsed.full.length > ActionLinksAddon.MAX_LOGICAL_LINE_SCAN_CHARS) {
if (parsed.endY < buffer.baseY) {
this._scannedAbsLines.add(absLineY);
this._lineToDecoKeys.delete(absLineY);
}
continue;
}
const actionLinks = this._getCachedActionLinks(terminal, parsed.full, absLineY + 1);
const actionLinks = this._getCachedActionLinks(
terminal,
parsed.full,
absLineY + 1,
);
const lineKeys: string[] = [];
for (const al of actionLinks) {
const span = this._getActionLinkCellSpan(al, parsed);
if (!span) continue;
const key = this._ensureDecoration(absLineY, span.cellStart, span.cellWidth, cursorAbsY);
const key = this._ensureDecoration(
absLineY,
span.cellStart,
span.cellWidth,
cursorAbsY,
);
if (key) {
lineKeys.push(key);
requiredKeys.add(key);
@@ -741,7 +782,11 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
const cached = this._cache.get(logicalText);
if (cached) return cached;
const actionLinks = this._computeActionLinks(terminal, logicalText, bufferLineNumber);
const actionLinks = this._computeActionLinks(
terminal,
logicalText,
bufferLineNumber,
);
this._cache.set(logicalText, actionLinks);
if (this._cache.size > ActionLinksAddon.MAX_CACHE_ENTRIES) {
@@ -759,7 +804,8 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
logicalText: string,
bufferLineNumber: number,
): ActionLink[] {
const viewportLine = bufferLineNumber - terminal.buffer.active.viewportY - 1;
const viewportLine =
bufferLineNumber - terminal.buffer.active.viewportY - 1;
const input: MatchInput = {
text: logicalText,
startLineIndex: 0,
@@ -770,14 +816,18 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
const results: ActionLink[] = [];
const covered: Array<[number, number]> = [];
const sorted = [...this._matchers].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
const sorted = [...this._matchers].sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
);
for (const matcher of sorted) {
if (matcher.prefilter && !matcher.prefilter(input)) continue;
const matches = matcher.match(input);
for (const m of matches) {
const overlaps = covered.some(([s, e]) => m.startIndex < e && m.endIndex > s);
const overlaps = covered.some(
([s, e]) => m.startIndex < e && m.endIndex > s,
);
if (overlaps) continue;
covered.push([m.startIndex, m.endIndex]);
@@ -831,13 +881,18 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
if (endIndex <= lineStart || startIndex >= lineEnd) return null;
const segmentStart = Math.max(startIndex - parsed.offset, 0);
const segmentEnd = Math.min(endIndex - parsed.offset, parsed.lineText.length);
const segmentEnd = Math.min(
endIndex - parsed.offset,
parsed.lineText.length,
);
if (segmentEnd <= segmentStart) return null;
const cellStart = parsed.cellMap
? (parsed.cellMap[segmentStart] ?? segmentStart)
: segmentStart;
const cellEnd = parsed.cellMap ? (parsed.cellMap[segmentEnd] ?? segmentEnd) : segmentEnd;
const cellEnd = parsed.cellMap
? (parsed.cellMap[segmentEnd] ?? segmentEnd)
: segmentEnd;
const cellWidth = cellEnd - cellStart;
if (cellWidth <= 0) return null;
@@ -882,7 +937,10 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
showTooltip({ event, text: al.text, range: al.range, link: al });
}
private async _handleActivate(event: MouseEvent, al: ActionLink): Promise<void> {
private async _handleActivate(
event: MouseEvent,
al: ActionLink,
): Promise<void> {
const {
allowCtrlOrMetaClickExecute,
allowAltClickMenu,
@@ -922,7 +980,10 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
}
}
private async _executeDefault(al: ActionLink, trigger: ExecutionTrigger): Promise<void> {
private async _executeDefault(
al: ActionLink,
trigger: ExecutionTrigger,
): Promise<void> {
const defaultAction = al.actions.find((a) => a.isDefault) ?? al.actions[0];
if (defaultAction) {
await this._executeById(al, defaultAction.id, trigger);
@@ -946,8 +1007,10 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
}
let cmd = action.command;
if (policy?.resolveAliasCommand) cmd = policy.resolveAliasCommand(cmd, al.ctx, trigger);
if (policy?.transformCommand) cmd = policy.transformCommand(cmd, al.ctx, trigger);
if (policy?.resolveAliasCommand)
cmd = policy.resolveAliasCommand(cmd, al.ctx, trigger);
if (policy?.transformCommand)
cmd = policy.transformCommand(cmd, al.ctx, trigger);
if (executeCommand) {
await executeCommand(cmd, action, al.ctx, trigger);
+9 -1
View File
@@ -28,6 +28,12 @@ export const XTERM_PERFORMANCE_CONFIG = {
maxCachedMatchLines: 3_000,
},
output: {
pressure: {
burstWindowMs: 100,
burstBytes: 64 * 1024,
longLineChars: 64 * 1024,
quietMs: 400,
},
/** Backlog threshold where terminal side work should start yielding to rendering/input. */
strainedBacklogBytes: 128 * 1024,
/** Backlog threshold for using microtask low-latency writes on normal shell output. */
@@ -89,6 +95,8 @@ export function getKeywordHighlightPerformanceConfig() {
const highlighting = XTERM_PERFORMANCE_CONFIG.highlighting;
return {
...highlighting,
resolvedOverscanLines: isMacOS ? highlighting.macosOverscanLines : highlighting.overscanLines,
resolvedOverscanLines: isMacOS
? highlighting.macosOverscanLines
: highlighting.overscanLines,
};
}