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:
1 parent
7b8403607a
commit
3807857e3f
11 files changed
+582
-119
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user