diff --git a/src/components/terminal/TerminalGutter.tsx b/src/components/terminal/TerminalGutter.tsx index 82a09be7..093b53e4 100644 --- a/src/components/terminal/TerminalGutter.tsx +++ b/src/components/terminal/TerminalGutter.tsx @@ -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; diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index cb178a9b..40f77dd7 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -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(`serial-modem-event-${sessionId}`, (event) => { - wake({ type: "serialModem", payload: event.payload }); - }), + listen( + `serial-modem-event-${sessionId}`, + (event) => { + wake({ type: "serialModem", payload: event.payload }); + }, + ), ); unlistenBag.add( listen(`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("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("create_temporary_ssh_session", { config: sshConfig, - startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand), + startupCommand: buildStartupCommandPayload( + restoreCwdStartupCommand, + ), }); } return invoke("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, diff --git a/src/components/terminal/terminalInputSelection.test.ts b/src/components/terminal/terminalInputSelection.test.ts new file mode 100644 index 00000000..e5cef955 --- /dev/null +++ b/src/components/terminal/terminalInputSelection.test.ts @@ -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(); + }); +}); diff --git a/src/components/terminal/terminalInputSelection.ts b/src/components/terminal/terminalInputSelection.ts index 5e337d49..dfc76882 100644 --- a/src/components/terminal/terminalInputSelection.ts +++ b/src/components/terminal/terminalInputSelection.ts @@ -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(); 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 = new Array(segments.length).fill( - null, + const cursorSegmentIndex = getSegmentIndexAtCursor( + segmentOffsets, + state.cursor, ); + const segmentSnapshots: Array = 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; } diff --git a/src/components/terminal/terminalOutputPressure.test.ts b/src/components/terminal/terminalOutputPressure.test.ts new file mode 100644 index 00000000..77fcc3e6 --- /dev/null +++ b/src/components/terminal/terminalOutputPressure.test.ts @@ -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); + }); +}); diff --git a/src/components/terminal/terminalOutputPressure.ts b/src/components/terminal/terminalOutputPressure.ts new file mode 100644 index 00000000..61654b90 --- /dev/null +++ b/src/components/terminal/terminalOutputPressure.ts @@ -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; + } + } +} diff --git a/src/components/terminal/xterminalOutputController.ts b/src/components/terminal/xterminalOutputController.ts index f013ba1e..5ef3c859 100644 --- a/src/components/terminal/xterminalOutputController.ts +++ b/src/components/terminal/xterminalOutputController.ts @@ -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 | 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, diff --git a/src/components/terminal/xterminalSessionEvents.ts b/src/components/terminal/xterminalSessionEvents.ts index f87e4f00..61242826 100644 --- a/src/components/terminal/xterminalSessionEvents.ts +++ b/src/components/terminal/xterminalSessionEvents.ts @@ -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. diff --git a/src/lib/actionLinksAddon.test.ts b/src/lib/actionLinksAddon.test.ts new file mode 100644 index 00000000..4487d0f1 --- /dev/null +++ b/src/lib/actionLinksAddon.test.ts @@ -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(); + }); +}); diff --git a/src/lib/actionLinksAddon.ts b/src/lib/actionLinksAddon.ts index 461d0249..b7e461dd 100644 --- a/src/lib/actionLinksAddon.ts +++ b/src/lib/actionLinksAddon.ts @@ -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; - 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(); // Decoration layer: key → {deco, marker}, line → keys, scanned line set - private _decoCache = new Map(); + private _decoCache = new Map< + string, + { deco: IDecoration; marker: IMarker } + >(); private _lineToDecoKeys = new Map(); private _scannedAbsLines = new Set(); private _writeDecoTimer: ReturnType | 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 { + private async _handleActivate( + event: MouseEvent, + al: ActionLink, + ): Promise { const { allowCtrlOrMetaClickExecute, allowAltClickMenu, @@ -922,7 +980,10 @@ export class ActionLinksAddon implements ITerminalAddon, ILinkProvider { } } - private async _executeDefault(al: ActionLink, trigger: ExecutionTrigger): Promise { + private async _executeDefault( + al: ActionLink, + trigger: ExecutionTrigger, + ): Promise { 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); diff --git a/src/lib/xtermPerformance.ts b/src/lib/xtermPerformance.ts index fba30971..9ad31595 100644 --- a/src/lib/xtermPerformance.ts +++ b/src/lib/xtermPerformance.ts @@ -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, }; }