Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1827 lines
56 KiB
TypeScript
1827 lines
56 KiB
TypeScript
import type { Terminal as XTerm } from "@xterm/xterm";
|
||
|
||
import { shouldSkipTerminalLineTimestamps } from "./terminalOutputPressure";
|
||
import { canRetainIncompleteTerminalControlSequence } from "./terminalControlSequenceLimits";
|
||
|
||
export type TerminalLineTimestampSegment =
|
||
| { kind: "data"; data: string }
|
||
| { kind: "timestamp"; label: string };
|
||
|
||
export type TerminalLineTimestampSegmenter = {
|
||
append: (data: string, timestampDate?: Date) => TerminalLineTimestampSegment[];
|
||
reset: () => void;
|
||
flushPendingEscapeSequence: () => string;
|
||
setAlternateScreenActive: (active: boolean) => void;
|
||
};
|
||
|
||
type TerminalLineTimestampSegmenterOptions = {
|
||
now?: () => Date;
|
||
/** Optional label factory (used to intern same-second strings). */
|
||
formatLabel?: (date: Date) => string;
|
||
};
|
||
|
||
type TimestampMarker = {
|
||
line: number;
|
||
isDisposed?: boolean;
|
||
dispose?: () => void;
|
||
onDispose?: (listener: () => void) => { dispose: () => void };
|
||
};
|
||
|
||
type TimestampEntry = {
|
||
marker: TimestampMarker;
|
||
label: string;
|
||
disposeListener?: { dispose: () => void };
|
||
};
|
||
|
||
/**
|
||
* Record/render separation: write path appends at most one ledger stamp per
|
||
* second. Gutter paint maps ledger → visible rows (fill-forward).
|
||
*
|
||
* Each stamp may hold a sparse xterm marker so reflow/scrollback trim keep
|
||
* `line` honest without per-row markers on every output line.
|
||
*/
|
||
type TimestampLedgerEntry = {
|
||
label: string;
|
||
/** Floor(unixMs/1000); at most one ledger stamp per second. */
|
||
secondKey: number;
|
||
/** Absolute buffer line (baseY + cursorY style) at stamp time. */
|
||
line: number;
|
||
/**
|
||
* Sparse reflow/scrollback anchor. xterm updates `marker.line` on reflow;
|
||
* disposed when the line is trimmed from scrollback. Prefer when live.
|
||
*/
|
||
marker?: TimestampMarker;
|
||
};
|
||
|
||
type TimestampStore = {
|
||
segmenter: TerminalLineTimestampSegmenter;
|
||
/**
|
||
* Legacy marker list (unused on the hot path). Kept so old helpers/tests that
|
||
* still touch markers do not crash; production paint reads `ledger` only.
|
||
*/
|
||
entries: TimestampEntry[];
|
||
/**
|
||
* Source of truth: per-second stamps with buffer line anchors.
|
||
*/
|
||
ledger: TimestampLedgerEntry[];
|
||
/** Last secondKey accepted (one stamp per second). */
|
||
lastStampSecondKey: number | null;
|
||
/**
|
||
* Last observed buffer.baseY / length. Used only to detect scrollback *trim*
|
||
* (circular drop from the top while buffer is full) — not ordinary baseY
|
||
* growth while the buffer is still growing.
|
||
*/
|
||
lastSeenBaseY: number | null;
|
||
lastSeenBufferLength: number | null;
|
||
/** Last seen cols; when cols change, reflow may have shifted bare line numbers. */
|
||
lastSeenCols: number | null;
|
||
/** @deprecated materialize path removed; always false. */
|
||
ledgerMaterialized: boolean;
|
||
/**
|
||
* Markers dropped from `entries` but not yet disposed. Drained with a per-pass
|
||
* budget so rewrite storms do not O(n²)-freeze on xterm marker list splices.
|
||
*/
|
||
orphanedMarkers: TimestampMarker[];
|
||
listeners: Set<() => void>;
|
||
timestampOnlyPrefix: string;
|
||
/** Amortize prune cost across many registerMarker calls. */
|
||
recordsSincePrune: number;
|
||
/** Disposed markers since last compact (drives write-path cleanup). */
|
||
disposedPendingCompact: number;
|
||
/** Intern HH:MM:SS for the current wall-clock second. */
|
||
labelCacheKey: number | null;
|
||
labelCacheValue: string;
|
||
/** Deferred fixed-size marker cleanup, one task at a time. */
|
||
orphanDrainTimer?: ReturnType<typeof setTimeout>;
|
||
};
|
||
|
||
type XTermWithUnicodeService = XTerm & {
|
||
_core?: {
|
||
unicodeService?: {
|
||
wcwidth?: (codePoint: number) => 0 | 1 | 2;
|
||
};
|
||
};
|
||
};
|
||
|
||
export type TerminalTimestampGutterEntry = {
|
||
marker: { line: number; isDisposed?: boolean };
|
||
label: string;
|
||
};
|
||
|
||
export type TerminalTimestampGutterRow = {
|
||
row: number;
|
||
label: string;
|
||
};
|
||
|
||
export type TerminalLineTimestampPerfStep =
|
||
| {
|
||
kind: "segment";
|
||
durationMs: number;
|
||
dataChars: number;
|
||
segmentCount: number;
|
||
dataSegmentCount: number;
|
||
timestampSegmentCount: number;
|
||
parsedChars: number;
|
||
}
|
||
| {
|
||
kind: "batched-write";
|
||
dataChars: number;
|
||
timestamps: number;
|
||
measureMs: number;
|
||
writeCallbackMs: number;
|
||
markerMs: number;
|
||
rowOffset: number;
|
||
columns: number;
|
||
}
|
||
| {
|
||
kind: "segmented-write";
|
||
dataChars: number;
|
||
timestamps: number;
|
||
writeCalls: number;
|
||
writeChars: number;
|
||
writeCallbackMs: number;
|
||
totalMs: number;
|
||
}
|
||
| {
|
||
kind: "fallback-write";
|
||
dataChars: number;
|
||
writeCallbackMs: number;
|
||
};
|
||
|
||
export type TerminalLineTimestampDiagnostics = {
|
||
onStep?: (step: TerminalLineTimestampPerfStep) => void;
|
||
};
|
||
|
||
export type TerminalLineTimestampWriteOptions = TerminalLineTimestampDiagnostics & {
|
||
/** Wall-clock arrival time for this batch, preserved across delayed writes. */
|
||
timestampDate?: Date;
|
||
/**
|
||
* Deprecated for recording: write path always maintains the per-second ledger
|
||
* (record/render separation). Kept for call-site compatibility; ignored.
|
||
*/
|
||
enabled?: boolean;
|
||
};
|
||
|
||
const stores = new WeakMap<XTerm, TimestampStore>();
|
||
/**
|
||
* Target ceiling for retained timestamp entries. xterm updates every marker
|
||
* whenever a scrollback row is trimmed, so matching a 100k-row scrollback makes
|
||
* steady output progressively more expensive. Keep enough recent timestamp
|
||
* history for normal navigation while bounding that per-line trim work.
|
||
*/
|
||
export const MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES = 4096;
|
||
/** Compact disposed holes at least this often during flood writes. */
|
||
const TIMESTAMP_PRUNE_EVERY_RECORDS = 256;
|
||
/** Max xterm marker.dispose() calls per prune/write pass (amortize O(n) splices). */
|
||
const TIMESTAMP_ORPHAN_DISPOSE_BUDGET = 64;
|
||
|
||
const pad2 = (value: number): string => value.toString().padStart(2, "0");
|
||
|
||
export const formatTerminalLineTimestamp = (date: Date): string => (
|
||
`${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
||
);
|
||
|
||
/**
|
||
* Resolve how many line timestamps to retain for a terminal.
|
||
* Prefer the live scrollback option so a smaller history trims timestamps too.
|
||
* Capacity follows scrollback + viewport (+slack), bounded by the live-marker
|
||
* target so very large histories do not make every trim progressively slower.
|
||
*/
|
||
export const resolveTerminalLineTimestampCapacity = (
|
||
term: XTerm,
|
||
fallback = MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
|
||
): number => {
|
||
const options = (term as XTerm & { options?: { scrollback?: number } }).options;
|
||
const scrollback = options?.scrollback;
|
||
const rows = Number.isFinite(term.rows) && term.rows > 0 ? term.rows : 24;
|
||
if (Number.isFinite(scrollback) && Number(scrollback) > 0) {
|
||
return Math.min(
|
||
MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
|
||
Math.floor(Number(scrollback)) + rows + 64,
|
||
);
|
||
}
|
||
return Math.min(MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES, fallback);
|
||
};
|
||
|
||
const isCsiFinalByte = (char: string): boolean => char >= "@" && char <= "~";
|
||
const STRING_TERMINATOR = "\u009c";
|
||
|
||
const readStringTerminatedSequence = (
|
||
data: string,
|
||
startIndex: number,
|
||
): { sequence: string; endIndex: number; complete: boolean } => {
|
||
for (let index = startIndex + 2; index < data.length; index += 1) {
|
||
if (data[index] === "\u0007" || data[index] === STRING_TERMINATOR) {
|
||
return {
|
||
sequence: data.slice(startIndex, index + 1),
|
||
endIndex: index,
|
||
complete: true,
|
||
};
|
||
}
|
||
if (data[index] === "\x1b" && data[index + 1] === "\\") {
|
||
return {
|
||
sequence: data.slice(startIndex, index + 2),
|
||
endIndex: index + 1,
|
||
complete: true,
|
||
};
|
||
}
|
||
}
|
||
return {
|
||
sequence: data.slice(startIndex),
|
||
endIndex: data.length - 1,
|
||
complete: false,
|
||
};
|
||
};
|
||
|
||
const readEscapeSequence = (
|
||
data: string,
|
||
startIndex: number,
|
||
): { sequence: string; endIndex: number; complete: boolean } | null => {
|
||
if (data[startIndex] !== "\x1b") return null;
|
||
const next = data[startIndex + 1];
|
||
if (!next) {
|
||
return { sequence: "\x1b", endIndex: startIndex, complete: false };
|
||
}
|
||
|
||
if (next === "[") {
|
||
for (let index = startIndex + 2; index < data.length; index += 1) {
|
||
if (isCsiFinalByte(data[index])) {
|
||
return {
|
||
sequence: data.slice(startIndex, index + 1),
|
||
endIndex: index,
|
||
complete: true,
|
||
};
|
||
}
|
||
}
|
||
return {
|
||
sequence: data.slice(startIndex),
|
||
endIndex: data.length - 1,
|
||
complete: false,
|
||
};
|
||
}
|
||
|
||
if (next === "]") {
|
||
return readStringTerminatedSequence(data, startIndex);
|
||
}
|
||
|
||
if (next === "P" || next === "^" || next === "_" || next === "X") {
|
||
return readStringTerminatedSequence(data, startIndex);
|
||
}
|
||
|
||
return {
|
||
sequence: data.slice(startIndex, startIndex + 2),
|
||
endIndex: startIndex + 1,
|
||
complete: true,
|
||
};
|
||
};
|
||
|
||
const getCsiFinal = (sequence: string): string | null => {
|
||
if (!sequence.startsWith("\x1b[") || sequence.length < 3) return null;
|
||
return sequence.at(-1) ?? null;
|
||
};
|
||
|
||
const getAlternateScreenAction = (sequence: string): "enter" | "leave" | null => {
|
||
const final = getCsiFinal(sequence);
|
||
if (final !== "h" && final !== "l") return null;
|
||
|
||
const params = sequence.slice(2, -1);
|
||
if (!params.startsWith("?")) return null;
|
||
|
||
const modes = params
|
||
.slice(1)
|
||
.split(";")
|
||
.map((part) => Number.parseInt(part, 10))
|
||
.filter(Number.isFinite);
|
||
|
||
if (!modes.some((mode) => mode === 47 || mode === 1047 || mode === 1049)) {
|
||
return null;
|
||
}
|
||
|
||
return final === "h" ? "enter" : "leave";
|
||
};
|
||
|
||
const getWraparoundAction = (sequence: string): boolean | null => {
|
||
const final = getCsiFinal(sequence);
|
||
if (final !== "h" && final !== "l") return null;
|
||
|
||
const params = sequence.slice(2, -1);
|
||
if (!params.startsWith("?")) return null;
|
||
|
||
const modes = params
|
||
.slice(1)
|
||
.split(";")
|
||
.map((part) => Number.parseInt(part, 10))
|
||
.filter(Number.isFinite);
|
||
|
||
return modes.includes(7) ? final === "h" : null;
|
||
};
|
||
|
||
const isSgrSequence = (sequence: string): boolean =>
|
||
getCsiFinal(sequence) === "m";
|
||
|
||
const isBulkMeasurableEscapeSequence = (sequence: string): boolean =>
|
||
getAlternateScreenAction(sequence) === null
|
||
&& (getWraparoundAction(sequence) !== null || isSgrSequence(sequence));
|
||
|
||
const isPotentialAlternateScreenSequence = (sequence: string): boolean => {
|
||
if (!sequence.startsWith("\x1b[?")) return false;
|
||
|
||
const params = sequence.slice(3).split(";");
|
||
const alternateScreenModes = ["47", "1047", "1049"];
|
||
return params.some((part) => (
|
||
part === ""
|
||
|| alternateScreenModes.some((mode) => mode.startsWith(part) || part.startsWith(mode))
|
||
));
|
||
};
|
||
|
||
const isPrintableOutput = (char: string): boolean => {
|
||
if (char === "\t") return true;
|
||
const code = char.codePointAt(0);
|
||
return code !== undefined
|
||
&& code >= 0x20
|
||
&& code !== 0x7f
|
||
&& (code < 0x80 || code > 0x9f);
|
||
};
|
||
|
||
const pushDataSegment = (
|
||
segments: TerminalLineTimestampSegment[],
|
||
data: string,
|
||
) => {
|
||
if (!data) return;
|
||
const previous = segments.at(-1);
|
||
if (previous?.kind === "data") {
|
||
previous.data += data;
|
||
return;
|
||
}
|
||
segments.push({ kind: "data", data });
|
||
};
|
||
|
||
/** Characters that can change segmenter state outside alternate screen. */
|
||
// eslint-disable-next-line no-control-regex
|
||
const SEGMENTER_BOUNDARY_SCAN = /[\u001b\n\r]/g;
|
||
|
||
/** Index of the next ESC/LF/CR at or after `from`, or `input.length`. */
|
||
const nextSegmenterBoundary = (input: string, from: number): number => {
|
||
SEGMENTER_BOUNDARY_SCAN.lastIndex = from;
|
||
const match = SEGMENTER_BOUNDARY_SCAN.exec(input);
|
||
return match === null ? input.length : match.index;
|
||
};
|
||
|
||
export const createTerminalLineTimestampSegmenter = (
|
||
options: TerminalLineTimestampSegmenterOptions = {},
|
||
): TerminalLineTimestampSegmenter => {
|
||
const now = options.now ?? (() => new Date());
|
||
const formatLabel = options.formatLabel ?? formatTerminalLineTimestamp;
|
||
let atLineStart = true;
|
||
let currentLineStamped = false;
|
||
let pendingEscapeSequence = "";
|
||
let suspendedForAlternateScreen = false;
|
||
|
||
const resetLineState = () => {
|
||
atLineStart = true;
|
||
currentLineStamped = false;
|
||
};
|
||
|
||
const pushTimestampIfNeeded = (
|
||
segments: TerminalLineTimestampSegment[],
|
||
timestampDate?: Date,
|
||
) => {
|
||
if (!atLineStart || currentLineStamped) return;
|
||
currentLineStamped = true;
|
||
atLineStart = false;
|
||
segments.push({
|
||
kind: "timestamp",
|
||
label: formatLabel(timestampDate ?? now()),
|
||
});
|
||
};
|
||
|
||
return {
|
||
append(data: string, timestampDate?: Date) {
|
||
const input = pendingEscapeSequence ? `${pendingEscapeSequence}${data}` : data;
|
||
pendingEscapeSequence = "";
|
||
const segments: TerminalLineTimestampSegment[] = [];
|
||
|
||
for (let index = 0; index < input.length;) {
|
||
const char = input[index];
|
||
|
||
if (char === "\x1b") {
|
||
const sequence = readEscapeSequence(input, index);
|
||
if (sequence) {
|
||
if (!sequence.complete) {
|
||
if (canRetainIncompleteTerminalControlSequence(sequence.sequence)) {
|
||
pendingEscapeSequence = sequence.sequence;
|
||
} else {
|
||
// Fail open: xterm still receives the original data, while the
|
||
// timestamp parser drops the malformed prefix and resumes fresh
|
||
// on the next chunk instead of retaining/scanning it forever.
|
||
pushDataSegment(segments, sequence.sequence);
|
||
}
|
||
break;
|
||
}
|
||
const alternateScreenAction = getAlternateScreenAction(sequence.sequence);
|
||
if (alternateScreenAction === "enter") {
|
||
suspendedForAlternateScreen = true;
|
||
resetLineState();
|
||
} else if (alternateScreenAction === "leave") {
|
||
suspendedForAlternateScreen = false;
|
||
resetLineState();
|
||
}
|
||
pushDataSegment(segments, sequence.sequence);
|
||
index = sequence.endIndex + 1;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (suspendedForAlternateScreen) {
|
||
// Nothing but an ESC sequence can change state while suspended;
|
||
// hop to the next ESC and append the span in one slice.
|
||
const nextEsc = input.indexOf("\x1b", index + 1);
|
||
const end = nextEsc === -1 ? input.length : nextEsc;
|
||
pushDataSegment(segments, input.slice(index, end));
|
||
index = end;
|
||
continue;
|
||
}
|
||
|
||
if (!isPrintableOutput(char)) {
|
||
// Single control character (e.g. \n, \r, BEL, backspace).
|
||
pushDataSegment(segments, char);
|
||
if (char === "\n") {
|
||
resetLineState();
|
||
} else if (char === "\r") {
|
||
atLineStart = true;
|
||
}
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
// Printable character: stamp the line if needed, then hop to the next
|
||
// state-changing character (ESC/LF/CR) and append the span in one
|
||
// slice. Control chars inside the span (BEL, backspace, DEL, C1)
|
||
// never change segmenter state, matching the per-char loop.
|
||
pushTimestampIfNeeded(segments, timestampDate);
|
||
atLineStart = false;
|
||
const end = nextSegmenterBoundary(input, index + 1);
|
||
pushDataSegment(segments, input.slice(index, end));
|
||
index = end;
|
||
}
|
||
|
||
return segments;
|
||
},
|
||
reset() {
|
||
resetLineState();
|
||
pendingEscapeSequence = "";
|
||
suspendedForAlternateScreen = false;
|
||
},
|
||
flushPendingEscapeSequence() {
|
||
const sequence = pendingEscapeSequence;
|
||
pendingEscapeSequence = "";
|
||
return sequence;
|
||
},
|
||
setAlternateScreenActive(active: boolean) {
|
||
suspendedForAlternateScreen = active;
|
||
if (active) {
|
||
resetLineState();
|
||
}
|
||
},
|
||
};
|
||
};
|
||
|
||
const notifyTimestampStore = (store: TimestampStore) => {
|
||
if (store.listeners.size === 0) return;
|
||
for (const listener of store.listeners) {
|
||
listener();
|
||
}
|
||
};
|
||
|
||
const getTimestampStore = (term: XTerm): TimestampStore => {
|
||
let store = stores.get(term);
|
||
if (!store) {
|
||
const created: TimestampStore = {
|
||
// Placeholder; replaced immediately with an interning segmenter below.
|
||
segmenter: createTerminalLineTimestampSegmenter(),
|
||
entries: [],
|
||
ledger: [],
|
||
lastStampSecondKey: null,
|
||
lastSeenBaseY: null,
|
||
lastSeenBufferLength: null,
|
||
lastSeenCols: null,
|
||
ledgerMaterialized: false,
|
||
orphanedMarkers: [],
|
||
listeners: new Set(),
|
||
timestampOnlyPrefix: "",
|
||
recordsSincePrune: 0,
|
||
disposedPendingCompact: 0,
|
||
labelCacheKey: null,
|
||
labelCacheValue: "",
|
||
};
|
||
created.segmenter = createTerminalLineTimestampSegmenter({
|
||
formatLabel: (date) => internTerminalLineTimestampLabel(created, date),
|
||
});
|
||
stores.set(term, created);
|
||
store = created;
|
||
}
|
||
return store;
|
||
};
|
||
|
||
const secondKeyFromDate = (date: Date): number => Math.floor(date.getTime() / 1000);
|
||
|
||
type BufferCursorState = {
|
||
absoluteLine: number;
|
||
column: number;
|
||
};
|
||
|
||
const readBufferCursorState = (
|
||
buffer: { baseY?: number; cursorY?: number; cursorX?: number } | null | undefined,
|
||
): BufferCursorState => {
|
||
const baseY = typeof buffer?.baseY === "number" ? buffer.baseY : 0;
|
||
const cursorY = typeof buffer?.cursorY === "number" ? buffer.cursorY : 0;
|
||
const cursorX = typeof buffer?.cursorX === "number" ? buffer.cursorX : 0;
|
||
return {
|
||
absoluteLine: Math.max(0, baseY + cursorY),
|
||
column: Math.max(0, cursorX),
|
||
};
|
||
};
|
||
|
||
const getAbsoluteCursorLine = (term: XTerm): number => {
|
||
try {
|
||
return readBufferCursorState(
|
||
term.buffer?.active as { baseY?: number; cursorY?: number; cursorX?: number } | undefined,
|
||
).absoluteLine;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Cursor on the normal (primary) buffer — used for stamp placement when the
|
||
* active buffer is the alternate screen. xterm keeps the pre-alt position on
|
||
* `buffer.normal` while `active` is alternate; reading active alone pins stamps
|
||
* to alt rows and drops them after leave.
|
||
*/
|
||
const getNormalBufferCursorState = (term: XTerm): BufferCursorState => {
|
||
try {
|
||
const buffers = term.buffer as {
|
||
normal?: { baseY?: number; cursorY?: number; cursorX?: number };
|
||
active?: { type?: string; baseY?: number; cursorY?: number; cursorX?: number };
|
||
} | undefined;
|
||
if (buffers?.normal) {
|
||
return readBufferCursorState(buffers.normal);
|
||
}
|
||
const active = buffers?.active;
|
||
if (active && active.type !== "alternate") {
|
||
return readBufferCursorState(active);
|
||
}
|
||
} catch {
|
||
// fall through
|
||
}
|
||
return { absoluteLine: 0, column: 0 };
|
||
};
|
||
|
||
/** Advance absolute line by hard newlines in a data span (fallback estimate). */
|
||
const advanceAbsoluteLineByData = (line: number, data: string): number => {
|
||
let next = line;
|
||
for (let index = 0; index < data.length; index += 1) {
|
||
if (data[index] === "\n") next += 1;
|
||
}
|
||
return next;
|
||
};
|
||
|
||
/**
|
||
* Soft-wrap–aware pre-write line advance for a span that is known to be on the
|
||
* normal buffer (not alternate screen).
|
||
*/
|
||
const advanceStampCursorByData = (
|
||
term: XTerm,
|
||
absoluteLine: number,
|
||
column: number,
|
||
data: string,
|
||
columns: number,
|
||
wraparoundMode: boolean,
|
||
): { absoluteLine: number; column: number; wraparoundMode: boolean } => {
|
||
if (!data) {
|
||
return { absoluteLine, column, wraparoundMode };
|
||
}
|
||
const measured = tryMeasureVisualRows(term, data, column, columns, wraparoundMode);
|
||
if (measured) {
|
||
return {
|
||
absoluteLine: absoluteLine + measured.rowOffset,
|
||
column: measured.column,
|
||
wraparoundMode: measured.wraparoundMode,
|
||
};
|
||
}
|
||
// Unmeasurable (cursor motion / partial CSI): hard newlines only.
|
||
return {
|
||
absoluteLine: advanceAbsoluteLineByData(absoluteLine, data),
|
||
column: 0,
|
||
wraparoundMode,
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Stamp line estimate in **normal-buffer** coordinates only.
|
||
* Seeded once per write (active cursor, or buffer.normal when already on alt).
|
||
* Enter freezes advancement; leave never rewinds line/col from the live term.
|
||
*/
|
||
export type StampCursorEstimate = {
|
||
absoluteLine: number;
|
||
column: number;
|
||
wraparoundMode: boolean;
|
||
/** True while DEC alt-screen is active — do not advance normal-buffer lines. */
|
||
altActive: boolean;
|
||
};
|
||
|
||
/**
|
||
* Pure alt-screen enter/leave for stamp estimates.
|
||
* Invariant: absoluteLine/column always mean normal-buffer coords and never
|
||
* change here — leave must not re-read term.buffer (spurious 1049l, same-chunk
|
||
* pre-enter advances, already-on-alt seed are all handled by seed + freeze).
|
||
*/
|
||
export const applyAltScreenAction = (
|
||
cursor: StampCursorEstimate,
|
||
action: "enter" | "leave",
|
||
): StampCursorEstimate => {
|
||
if (action === "enter") {
|
||
return { ...cursor, altActive: true };
|
||
}
|
||
return { ...cursor, altActive: false };
|
||
};
|
||
|
||
/**
|
||
* Walk a data segment for stamp line estimates: honor soft-wrap on the normal
|
||
* buffer, ignore visual rows while the alternate screen is active (vim/less),
|
||
* and toggle alt on enter/leave CSI so post-TUI stamps are not inflated.
|
||
*/
|
||
const advanceStampCursorThroughData = (
|
||
term: XTerm,
|
||
cursor: StampCursorEstimate,
|
||
data: string,
|
||
columns: number,
|
||
): StampCursorEstimate => {
|
||
let { absoluteLine, column, wraparoundMode, altActive } = cursor;
|
||
for (let index = 0; index < data.length;) {
|
||
if (data[index] === "\x1b") {
|
||
const sequence = readEscapeSequence(data, index);
|
||
if (!sequence) {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
if (!sequence.complete) {
|
||
// Incomplete ESC at chunk end — segmenter holds it; do not invent rows.
|
||
break;
|
||
}
|
||
const altAction = getAlternateScreenAction(sequence.sequence);
|
||
if (altAction) {
|
||
({ absoluteLine, column, wraparoundMode, altActive } = applyAltScreenAction(
|
||
{ absoluteLine, column, wraparoundMode, altActive },
|
||
altAction,
|
||
));
|
||
}
|
||
const wrapAction = getWraparoundAction(sequence.sequence);
|
||
if (wrapAction !== null && !altActive) {
|
||
wraparoundMode = wrapAction;
|
||
}
|
||
index = sequence.endIndex + 1;
|
||
continue;
|
||
}
|
||
|
||
if (altActive) {
|
||
const nextEsc = data.indexOf("\x1b", index + 1);
|
||
index = nextEsc === -1 ? data.length : nextEsc;
|
||
continue;
|
||
}
|
||
|
||
const nextEsc = data.indexOf("\x1b", index);
|
||
const end = nextEsc === -1 ? data.length : nextEsc;
|
||
if (end > index) {
|
||
({ absoluteLine, column, wraparoundMode } = advanceStampCursorByData(
|
||
term,
|
||
absoluteLine,
|
||
column,
|
||
data.slice(index, end),
|
||
columns,
|
||
wraparoundMode,
|
||
));
|
||
}
|
||
index = end;
|
||
}
|
||
return { absoluteLine, column, wraparoundMode, altActive };
|
||
};
|
||
|
||
const disposeLedgerMarker = (entry: TimestampLedgerEntry): void => {
|
||
const marker = entry.marker;
|
||
entry.marker = undefined;
|
||
if (!marker || marker.isDisposed) return;
|
||
marker.dispose?.();
|
||
};
|
||
|
||
const trimLedgerToCapacity = (
|
||
store: TimestampStore,
|
||
capacity: number,
|
||
): void => {
|
||
if (capacity > 0 && store.ledger.length > capacity) {
|
||
const drop = store.ledger.length - capacity;
|
||
for (let index = 0; index < drop; index += 1) {
|
||
disposeLedgerMarker(store.ledger[index]!);
|
||
}
|
||
store.ledger.splice(0, drop);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Accept at most one stamp per wall-clock second across ledger + markers.
|
||
* Returns false when this second was already stamped.
|
||
*/
|
||
const tryBeginSecondStamp = (
|
||
store: TimestampStore,
|
||
secondKey: number,
|
||
): boolean => {
|
||
if (store.lastStampSecondKey === secondKey) return false;
|
||
store.lastStampSecondKey = secondKey;
|
||
return true;
|
||
};
|
||
|
||
const pushLedgerStamp = (
|
||
store: TimestampStore,
|
||
label: string,
|
||
secondKey: number,
|
||
line: number,
|
||
capacity: number,
|
||
): TimestampLedgerEntry => {
|
||
const entry: TimestampLedgerEntry = {
|
||
label,
|
||
secondKey,
|
||
line: Math.max(0, line),
|
||
};
|
||
store.ledger.push(entry);
|
||
trimLedgerToCapacity(store, capacity);
|
||
return entry;
|
||
};
|
||
|
||
/**
|
||
* After term.write, pin new stamps to sparse xterm markers so reflow and
|
||
* scrollback trim keep line anchors accurate (still ≤1 marker/second).
|
||
*/
|
||
const attachLedgerAnchors = (
|
||
term: XTerm,
|
||
store: TimestampStore,
|
||
pending: readonly TimestampLedgerEntry[],
|
||
): void => {
|
||
if (pending.length === 0) return;
|
||
const registerMarker = (
|
||
term as XTerm & { registerMarker?: (offset: number) => TimestampMarker | undefined }
|
||
).registerMarker;
|
||
if (typeof registerMarker !== "function") return;
|
||
|
||
const stillInLedger = new Set(store.ledger);
|
||
const cursorLine = getAbsoluteCursorLine(term);
|
||
for (const entry of pending) {
|
||
if (!stillInLedger.has(entry)) continue;
|
||
if (entry.marker && !entry.marker.isDisposed) continue;
|
||
const offset = entry.line - cursorLine;
|
||
let marker: TimestampMarker | undefined;
|
||
try {
|
||
marker = registerMarker.call(term, offset);
|
||
} catch {
|
||
marker = undefined;
|
||
}
|
||
if (!marker || marker.isDisposed) continue;
|
||
entry.marker = marker;
|
||
if (typeof marker.line === "number" && Number.isFinite(marker.line)) {
|
||
entry.line = Math.max(0, marker.line);
|
||
}
|
||
}
|
||
};
|
||
|
||
/** Reattach sparse anchors after a display-only terminal buffer rebuild. */
|
||
export const restoreTerminalLineTimestampAnchors = (term: XTerm): void => {
|
||
const store = getTimestampStore(term);
|
||
for (const entry of store.ledger) {
|
||
if (!entry.marker?.isDisposed) continue;
|
||
entry.marker = undefined;
|
||
}
|
||
attachLedgerAnchors(term, store, store.ledger);
|
||
store.lastSeenBaseY = term.buffer.normal.baseY;
|
||
store.lastSeenBufferLength = term.buffer.normal.length;
|
||
store.lastSeenCols = _getTerminalColumnCount(term);
|
||
notifyTimestampStore(store);
|
||
};
|
||
|
||
export const snapshotTerminalLineTimestampLedger = (term: XTerm): Array<{
|
||
label: string;
|
||
secondKey: number;
|
||
line: number;
|
||
}> => getTimestampStore(term).ledger.map((entry) => ({
|
||
label: entry.label,
|
||
secondKey: entry.secondKey,
|
||
line: entry.marker && !entry.marker.isDisposed ? entry.marker.line : entry.line,
|
||
}));
|
||
|
||
export const restoreTerminalLineTimestampLedger = (
|
||
term: XTerm,
|
||
snapshot: readonly { label: string; secondKey: number; line: number }[],
|
||
): void => {
|
||
const store = getTimestampStore(term);
|
||
store.ledger = snapshot.map((entry) => ({ ...entry }));
|
||
store.lastStampSecondKey = store.ledger.at(-1)?.secondKey ?? null;
|
||
restoreTerminalLineTimestampAnchors(term);
|
||
};
|
||
|
||
/**
|
||
* Refresh ledger.line from live sparse anchors. Drops entries whose markers
|
||
* were disposed by scrollback trim. Bare (unanchored) lines keep their values
|
||
* unless a full-buffer baseY rebase applied earlier in the same pass.
|
||
*/
|
||
const syncLedgerFromAnchors = (store: TimestampStore): void => {
|
||
if (store.ledger.length === 0) return;
|
||
let write = 0;
|
||
for (let read = 0; read < store.ledger.length; read += 1) {
|
||
const entry = store.ledger[read]!;
|
||
const marker = entry.marker;
|
||
if (marker) {
|
||
if (marker.isDisposed) {
|
||
entry.marker = undefined;
|
||
// Trimmed away with its scrollback row — drop the stamp.
|
||
continue;
|
||
}
|
||
if (typeof marker.line === "number" && Number.isFinite(marker.line)) {
|
||
entry.line = Math.max(0, marker.line);
|
||
}
|
||
}
|
||
if (entry.line < 0) continue;
|
||
store.ledger[write] = entry;
|
||
write += 1;
|
||
}
|
||
store.ledger.length = write;
|
||
};
|
||
|
||
const resolveBufferMaxLines = (term: XTerm): number => {
|
||
const options = (term as XTerm & { options?: { scrollback?: number } }).options;
|
||
const scrollback = typeof options?.scrollback === "number" && options.scrollback > 0
|
||
? Math.floor(options.scrollback)
|
||
: 0;
|
||
const rows = Number.isFinite(term.rows) && term.rows > 0 ? term.rows : 24;
|
||
// xterm keeps scrollback + viewport rows in the active buffer.
|
||
return scrollback + rows;
|
||
};
|
||
|
||
/**
|
||
* Rebase / refresh ledger anchors after write, paint, or resize.
|
||
*
|
||
* - Sparse markers: xterm already moved them for scrollback trim + reflow;
|
||
* syncLedgerFromAnchors copies marker.line and drops disposed stamps.
|
||
* - Bare line numbers (marker attach failed): only subtract baseY when the
|
||
* buffer is actually full and trimming — never while still growing (MOTD
|
||
* vanishing on scroll).
|
||
* - Cols change: reflow invalidates bare numbers; drop unanchored stamps so we
|
||
* never paint wrong times on reshuffled history (anchored stamps survive).
|
||
*/
|
||
const rebaseLedgerForScrollback = (term: XTerm, store: TimestampStore): void => {
|
||
let baseY = 0;
|
||
let bufferLength = 0;
|
||
try {
|
||
const active = term.buffer?.active as
|
||
| { baseY?: number; length?: number }
|
||
| undefined;
|
||
baseY = typeof active?.baseY === "number" ? active.baseY : 0;
|
||
bufferLength = typeof active?.length === "number" ? active.length : 0;
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
const cols = _getTerminalColumnCount(term);
|
||
const colsChanged = store.lastSeenCols !== null
|
||
&& Number.isFinite(cols)
|
||
&& cols !== store.lastSeenCols;
|
||
|
||
const maxLines = resolveBufferMaxLines(term);
|
||
const bufferWasFull = store.lastSeenBufferLength !== null
|
||
&& store.lastSeenBufferLength >= maxLines;
|
||
const bufferIsFull = bufferLength >= maxLines;
|
||
|
||
// Only while full (or staying full) does baseY growth mean "N lines dropped
|
||
// from the top" for entries that still use bare line numbers.
|
||
if (
|
||
bufferWasFull
|
||
&& bufferIsFull
|
||
&& store.lastSeenBaseY !== null
|
||
&& baseY > store.lastSeenBaseY
|
||
) {
|
||
const delta = baseY - store.lastSeenBaseY;
|
||
for (const entry of store.ledger) {
|
||
if (entry.marker && !entry.marker.isDisposed) continue;
|
||
entry.line -= delta;
|
||
}
|
||
}
|
||
|
||
if (colsChanged) {
|
||
// Reflow: keep only stamps still pinned by a live marker.
|
||
for (const entry of store.ledger) {
|
||
if (entry.marker && !entry.marker.isDisposed) continue;
|
||
// Unanchored after reflow — line no longer trustworthy.
|
||
entry.line = -1;
|
||
}
|
||
}
|
||
|
||
store.lastSeenBaseY = baseY;
|
||
store.lastSeenBufferLength = bufferLength;
|
||
if (Number.isFinite(cols)) {
|
||
store.lastSeenCols = cols;
|
||
}
|
||
|
||
syncLedgerFromAnchors(store);
|
||
|
||
if (store.ledger.length === 0) return;
|
||
let write = 0;
|
||
for (let read = 0; read < store.ledger.length; read += 1) {
|
||
const entry = store.ledger[read]!;
|
||
if (entry.line < 0) {
|
||
disposeLedgerMarker(entry);
|
||
continue;
|
||
}
|
||
if (bufferLength > 0 && entry.line >= bufferLength) {
|
||
disposeLedgerMarker(entry);
|
||
continue;
|
||
}
|
||
store.ledger[write] = entry;
|
||
write += 1;
|
||
}
|
||
store.ledger.length = write;
|
||
};
|
||
|
||
/**
|
||
* Last buffer line that should show a gutter label: never past real content.
|
||
* Avoids painting empty viewport rows after the final content line.
|
||
*/
|
||
const resolveLastPaintBufferLine = (
|
||
term: XTerm,
|
||
store: TimestampStore,
|
||
bufferLength: number,
|
||
): number => {
|
||
let lastLedgerLine = -1;
|
||
for (const entry of store.ledger) {
|
||
const line = entry.marker && !entry.marker.isDisposed && typeof entry.marker.line === "number"
|
||
? entry.marker.line
|
||
: entry.line;
|
||
if (line > lastLedgerLine) lastLedgerLine = line;
|
||
}
|
||
|
||
let cursorAbs = 0;
|
||
let cursorX = 0;
|
||
try {
|
||
const active = term.buffer?.active as
|
||
| { baseY?: number; cursorY?: number; cursorX?: number }
|
||
| undefined;
|
||
const baseY = typeof active?.baseY === "number" ? active.baseY : 0;
|
||
const cursorY = typeof active?.cursorY === "number" ? active.cursorY : 0;
|
||
cursorX = typeof active?.cursorX === "number" ? active.cursorX : 0;
|
||
cursorAbs = Math.max(0, baseY + cursorY);
|
||
} catch {
|
||
cursorAbs = 0;
|
||
}
|
||
|
||
// After a trailing "\n", the cursor sits on an empty next line — that row
|
||
// should not force an extra painted gutter cell past content.
|
||
let contentEnd = cursorAbs;
|
||
try {
|
||
const line = term.buffer?.active?.getLine?.(cursorAbs) as
|
||
| { translateToString?: (trimRight?: boolean) => string }
|
||
| undefined;
|
||
const text = line?.translateToString?.(true) ?? "";
|
||
if (cursorX === 0 && text.length === 0 && cursorAbs > 0) {
|
||
contentEnd = cursorAbs - 1;
|
||
}
|
||
} catch {
|
||
// keep contentEnd
|
||
}
|
||
|
||
let lastPaint = Math.max(lastLedgerLine, contentEnd);
|
||
if (bufferLength > 0) {
|
||
lastPaint = Math.min(lastPaint, bufferLength - 1);
|
||
}
|
||
return Math.max(0, lastPaint);
|
||
};
|
||
|
||
/** Test helper: per-second ledger depth. */
|
||
export const getTerminalLineTimestampLedgerCount = (term: XTerm): number =>
|
||
getTimestampStore(term).ledger.length;
|
||
|
||
const internTerminalLineTimestampLabel = (
|
||
store: TimestampStore,
|
||
date: Date,
|
||
): string => {
|
||
// Same wall-clock second → identical label; avoid allocating 50万× "12:34:56".
|
||
const key = Math.floor(date.getTime() / 1000);
|
||
if (store.labelCacheKey === key) {
|
||
return store.labelCacheValue;
|
||
}
|
||
const label = formatTerminalLineTimestamp(date);
|
||
store.labelCacheKey = key;
|
||
store.labelCacheValue = label;
|
||
return label;
|
||
};
|
||
|
||
const enqueueOrphanedMarker = (
|
||
store: TimestampStore,
|
||
marker: TimestampMarker,
|
||
): void => {
|
||
if (marker.isDisposed) return;
|
||
store.orphanedMarkers.push(marker);
|
||
};
|
||
|
||
/**
|
||
* Dispose a bounded number of orphaned xterm markers. Full mass-dispose of
|
||
* thousands of markers freezes (xterm splice is O(markers) each); amortizing
|
||
* keeps rewrite/flood sessions responsive while still retiring superseded
|
||
* markers that scrollback will never trim (e.g. DECSTBM row rewrites).
|
||
*/
|
||
function scheduleOrphanMarkerDrain(store: TimestampStore): void {
|
||
if (store.orphanDrainTimer !== undefined || store.orphanedMarkers.length === 0) return;
|
||
const timer = setTimeout(() => {
|
||
if (store.orphanDrainTimer !== timer) return;
|
||
store.orphanDrainTimer = undefined;
|
||
drainOrphanedMarkers(store);
|
||
}, 0);
|
||
if (typeof timer === "object" && "unref" in timer && typeof timer.unref === "function") {
|
||
timer.unref();
|
||
}
|
||
store.orphanDrainTimer = timer;
|
||
}
|
||
|
||
function drainOrphanedMarkers(
|
||
store: TimestampStore,
|
||
budget = TIMESTAMP_ORPHAN_DISPOSE_BUDGET,
|
||
): void {
|
||
let remaining = Math.max(0, budget);
|
||
while (remaining > 0 && store.orphanedMarkers.length > 0) {
|
||
const marker = store.orphanedMarkers.pop();
|
||
remaining -= 1;
|
||
if (!marker || marker.isDisposed) continue;
|
||
marker.dispose?.();
|
||
}
|
||
scheduleOrphanMarkerDrain(store);
|
||
}
|
||
|
||
/** Cheap pass for paint/write paths: drop disposed holes only (no dedupe / capacity). */
|
||
|
||
/**
|
||
* Compact disposed markers, collapse rewritten lines to their latest label,
|
||
* and enforce a hard capacity in linear passes.
|
||
* Avoids the previous O(n) filter on *every* individual marker dispose during
|
||
* scrollback trim (which turned seq 1 500000 into ~O(n²) main-thread work).
|
||
*/
|
||
const pruneDisposedEntries = (
|
||
store: TimestampStore,
|
||
capacity = MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
|
||
): void => {
|
||
const entries = store.entries;
|
||
let write = 0;
|
||
for (let read = 0; read < entries.length; read += 1) {
|
||
const entry = entries[read];
|
||
if (entry.marker.isDisposed) continue;
|
||
entries[write] = entry;
|
||
write += 1;
|
||
}
|
||
entries.length = write;
|
||
|
||
// Cursor-up / reposition can append many live markers on the same buffer
|
||
// line. Keep only the latest label per line so the capacity budget tracks
|
||
// unique visible history instead of rewrite noise.
|
||
if (entries.length > 1) {
|
||
const kept: TimestampEntry[] = [];
|
||
const seenLines = new Set<number>();
|
||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||
const entry = entries[index];
|
||
const line = entry.marker.line;
|
||
if (seenLines.has(line)) {
|
||
enqueueOrphanedMarker(store, entry.marker);
|
||
continue;
|
||
}
|
||
seenLines.add(line);
|
||
kept.push(entry);
|
||
}
|
||
kept.reverse();
|
||
store.entries = kept;
|
||
}
|
||
|
||
const live = store.entries;
|
||
if (capacity > 0 && live.length > capacity) {
|
||
const drop = live.length - capacity;
|
||
for (let index = 0; index < drop; index += 1) {
|
||
enqueueOrphanedMarker(store, live[index].marker);
|
||
}
|
||
live.splice(0, drop);
|
||
}
|
||
drainOrphanedMarkers(store);
|
||
store.recordsSincePrune = 0;
|
||
};
|
||
|
||
const maybePruneTimestampEntries = (
|
||
term: XTerm,
|
||
store: TimestampStore,
|
||
force = false,
|
||
recordsAdded = 1,
|
||
): void => {
|
||
const capacity = resolveTerminalLineTimestampCapacity(term);
|
||
store.recordsSincePrune += Math.max(0, recordsAdded);
|
||
if (
|
||
force
|
||
|| store.recordsSincePrune >= TIMESTAMP_PRUNE_EVERY_RECORDS
|
||
|| store.entries.length > capacity * 1.25
|
||
) {
|
||
pruneDisposedEntries(store, capacity);
|
||
}
|
||
};
|
||
|
||
const resetTimestampStore = (store: TimestampStore) => {
|
||
if (store.orphanDrainTimer !== undefined) {
|
||
clearTimeout(store.orphanDrainTimer);
|
||
store.orphanDrainTimer = undefined;
|
||
}
|
||
for (const entry of store.entries) {
|
||
entry.disposeListener?.dispose();
|
||
entry.marker.dispose?.();
|
||
}
|
||
for (const marker of store.orphanedMarkers) {
|
||
if (!marker.isDisposed) marker.dispose?.();
|
||
}
|
||
for (const entry of store.ledger) {
|
||
disposeLedgerMarker(entry);
|
||
}
|
||
store.entries = [];
|
||
store.ledger = [];
|
||
store.lastStampSecondKey = null;
|
||
store.lastSeenBaseY = null;
|
||
store.lastSeenBufferLength = null;
|
||
store.lastSeenCols = null;
|
||
store.ledgerMaterialized = false;
|
||
store.orphanedMarkers = [];
|
||
store.segmenter.reset();
|
||
store.timestampOnlyPrefix = "";
|
||
store.recordsSincePrune = 0;
|
||
store.disposedPendingCompact = 0;
|
||
store.labelCacheKey = null;
|
||
store.labelCacheValue = "";
|
||
notifyTimestampStore(store);
|
||
};
|
||
|
||
const _recordTerminalLineTimestamp = (
|
||
term: XTerm,
|
||
store: TimestampStore,
|
||
label: string,
|
||
notify = true,
|
||
cursorYOffset = 0,
|
||
options: { skipPrune?: boolean } = {},
|
||
): boolean => {
|
||
// Gutter-on path: per-line markers for accurate paint. The gutter-off path
|
||
// uses a per-second ledger instead (see writeTerminalDataWithSecondLedgerOnly).
|
||
const registerMarker = (term as XTerm & { registerMarker?: (offset: number) => TimestampMarker | undefined }).registerMarker;
|
||
const marker = registerMarker?.call(term, cursorYOffset);
|
||
if (!marker) return false;
|
||
|
||
// Lightweight dispose signal only — never filter(entries) per dispose.
|
||
// Compaction happens in bulk on write/paint via compactDisposedMarkersOnly.
|
||
const entry: TimestampEntry = { marker, label };
|
||
entry.disposeListener = marker.onDispose?.(() => {
|
||
store.disposedPendingCompact += 1;
|
||
entry.disposeListener?.dispose();
|
||
entry.disposeListener = undefined;
|
||
});
|
||
store.entries.push(entry);
|
||
store.ledgerMaterialized = true;
|
||
if (!options.skipPrune) {
|
||
maybePruneTimestampEntries(term, store);
|
||
}
|
||
if (notify) {
|
||
notifyTimestampStore(store);
|
||
}
|
||
return true;
|
||
};
|
||
|
||
/** Test/diagnostics helper: live entry count after optional prune. */
|
||
export const getTerminalLineTimestampEntryCount = (
|
||
term: XTerm,
|
||
options: { prune?: boolean } = {},
|
||
): number => {
|
||
const store = getTimestampStore(term);
|
||
if (options.prune !== false) {
|
||
pruneDisposedEntries(store, resolveTerminalLineTimestampCapacity(term));
|
||
}
|
||
return store.entries.length;
|
||
};
|
||
|
||
const _countLineFeeds = (data: string): number => {
|
||
let count = 0;
|
||
for (const char of data) {
|
||
if (char === "\n") count += 1;
|
||
}
|
||
return count;
|
||
};
|
||
|
||
const _getTerminalColumnCount = (term: XTerm): number => {
|
||
const columns = (term as XTerm & { cols?: number }).cols;
|
||
return Number.isFinite(columns) && Number(columns) > 0
|
||
? Math.floor(Number(columns))
|
||
: Number.POSITIVE_INFINITY;
|
||
};
|
||
|
||
const _getTerminalCursorColumn = (term: XTerm): number => {
|
||
const cursorX = ((term.buffer?.active as { cursorX?: number } | undefined)?.cursorX);
|
||
return Number.isFinite(cursorX) && Number(cursorX) >= 0
|
||
? Math.floor(Number(cursorX))
|
||
: 0;
|
||
};
|
||
|
||
const _getTerminalWraparoundMode = (term: XTerm): boolean => (
|
||
((term as XTerm & { modes?: { wraparoundMode?: boolean } }).modes?.wraparoundMode) !== false
|
||
);
|
||
|
||
/**
|
||
* True when DECSTBM (or equivalent) leaves a partial scrolling region.
|
||
* Bulk row-offset measurement assumes full-buffer advancement; inside a
|
||
* region, newlines recycle rows and measured offsets would invent fake
|
||
* history and evict still-valid scrollback timestamps.
|
||
*/
|
||
export const hasPartialScrollingRegion = (term: XTerm): boolean => {
|
||
const core = (term as XTerm & {
|
||
_core?: { buffer?: { scrollTop?: number; scrollBottom?: number } };
|
||
})._core;
|
||
const scrollTop = core?.buffer?.scrollTop;
|
||
const scrollBottom = core?.buffer?.scrollBottom;
|
||
const rows = Number.isFinite(term.rows) && term.rows > 0 ? term.rows : 0;
|
||
if (
|
||
!Number.isFinite(scrollTop)
|
||
|| !Number.isFinite(scrollBottom)
|
||
|| rows <= 0
|
||
) {
|
||
return false;
|
||
}
|
||
return Number(scrollTop) > 0 || Number(scrollBottom) < rows - 1;
|
||
};
|
||
|
||
const isUnsafeGraphemeSequenceCodePoint = (codePoint: number): boolean => (
|
||
codePoint === 0x200d
|
||
|| codePoint === 0x20e3
|
||
|| (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff)
|
||
|| (codePoint >= 0x1f3fb && codePoint <= 0x1f3ff)
|
||
|| (codePoint >= 0xfe00 && codePoint <= 0xfe0f)
|
||
|| (codePoint >= 0xe0020 && codePoint <= 0xe007f)
|
||
|| (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
||
);
|
||
|
||
const isUnsafeFormatCodePoint = (codePoint: number): boolean => (
|
||
(codePoint >= 0x200b && codePoint <= 0x200f)
|
||
|| (codePoint >= 0x202a && codePoint <= 0x202e)
|
||
|| (codePoint >= 0x2060 && codePoint <= 0x206f)
|
||
|| codePoint === 0xfeff
|
||
|| (codePoint >= 0xfff9 && codePoint <= 0xfffb)
|
||
);
|
||
|
||
const unicodeMarkPattern = /\p{Mark}/u;
|
||
|
||
const isHangulJamoCodePoint = (codePoint: number): boolean => (
|
||
(codePoint >= 0x1100 && codePoint <= 0x11ff)
|
||
|| (codePoint >= 0xa960 && codePoint <= 0xa97f)
|
||
|| (codePoint >= 0xd7b0 && codePoint <= 0xd7ff)
|
||
);
|
||
|
||
const isContextSensitiveGraphemeCodePoint = (codePoint: number): boolean => (
|
||
unicodeMarkPattern.test(String.fromCodePoint(codePoint))
|
||
|| isHangulJamoCodePoint(codePoint)
|
||
);
|
||
|
||
const getCodePointCellWidth = (term: XTerm, codePoint: number): 0 | 1 | 2 | null => {
|
||
if (codePoint < 0x80) return 1;
|
||
const unicodeService = (term as XTermWithUnicodeService)._core?.unicodeService;
|
||
if (typeof unicodeService?.wcwidth !== "function") return null;
|
||
try {
|
||
const width = unicodeService.wcwidth(codePoint);
|
||
return width === 0 || width === 1 || width === 2 ? width : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
type MeasuredTerminalRows = {
|
||
rowOffset: number;
|
||
column: number;
|
||
wraparoundMode: boolean;
|
||
};
|
||
|
||
const advanceMeasuredColumns = (
|
||
column: number,
|
||
rowOffset: number,
|
||
columns: number,
|
||
width: number,
|
||
wraparoundMode: boolean,
|
||
): { column: number; rowOffset: number } => {
|
||
if (!Number.isFinite(columns)) {
|
||
return { column, rowOffset };
|
||
}
|
||
if (!wraparoundMode) {
|
||
return {
|
||
column: Math.min(columns, column + width),
|
||
rowOffset,
|
||
};
|
||
}
|
||
let nextRowOffset = rowOffset;
|
||
let nextColumn = column;
|
||
if (nextColumn + width > columns) {
|
||
nextRowOffset += 1;
|
||
nextColumn = 0;
|
||
}
|
||
nextColumn += width;
|
||
while (nextColumn > columns) {
|
||
nextRowOffset += 1;
|
||
nextColumn -= columns;
|
||
}
|
||
return { column: nextColumn, rowOffset: nextRowOffset };
|
||
};
|
||
|
||
const advanceMeasuredTab = (
|
||
column: number,
|
||
columns: number,
|
||
): number => {
|
||
if (!Number.isFinite(columns) || column >= columns) {
|
||
return column;
|
||
}
|
||
const tabStopWidth = 8;
|
||
const nextTabStop = column + (tabStopWidth - (column % tabStopWidth));
|
||
return Math.min(nextTabStop, columns - 1);
|
||
};
|
||
|
||
/**
|
||
* Fast gate for flood output like `seq`: printable ASCII + CR/LF/TAB/BS only.
|
||
* Avoids escape parsing and unicode width lookups entirely.
|
||
*/
|
||
export const isSimpleAsciiControlText = (data: string): boolean => {
|
||
for (let index = 0; index < data.length; index += 1) {
|
||
const code = data.charCodeAt(index);
|
||
if (code === 0x0a || code === 0x0d || code === 0x09 || code === 0x08) continue;
|
||
if (code >= 0x20 && code <= 0x7e) continue;
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const measureSimpleAsciiRows = (
|
||
data: string,
|
||
startColumn: number,
|
||
columns: number,
|
||
startWraparoundMode: boolean,
|
||
): MeasuredTerminalRows => {
|
||
let rowOffset = 0;
|
||
let column = startColumn;
|
||
const wraparoundMode = startWraparoundMode;
|
||
|
||
for (let index = 0; index < data.length; index += 1) {
|
||
const code = data.charCodeAt(index);
|
||
if (code === 0x0a) {
|
||
rowOffset += 1;
|
||
if (Number.isFinite(columns) && column >= columns) {
|
||
column = columns - 1;
|
||
}
|
||
continue;
|
||
}
|
||
if (code === 0x0d) {
|
||
column = 0;
|
||
continue;
|
||
}
|
||
if (code === 0x08) {
|
||
column = Math.max(0, column - 1);
|
||
continue;
|
||
}
|
||
if (code === 0x09) {
|
||
column = advanceMeasuredTab(column, columns);
|
||
continue;
|
||
}
|
||
// Printable ASCII is always width 1.
|
||
({ column, rowOffset } = advanceMeasuredColumns(
|
||
column,
|
||
rowOffset,
|
||
columns,
|
||
1,
|
||
wraparoundMode,
|
||
));
|
||
}
|
||
|
||
return { rowOffset, column, wraparoundMode };
|
||
};
|
||
|
||
/**
|
||
* Validate + measure visual rows in a single pass.
|
||
* Returns null when the chunk contains sequences we cannot bulk-measure safely
|
||
* (caller falls back to line-feed counting / segmented writes).
|
||
*/
|
||
/** Test-only: counts tryMeasureVisualRows invocations on the shipped path. */
|
||
let visualRowMeasureCountForTests = 0;
|
||
export const getVisualRowMeasureCountForTests = (): number => visualRowMeasureCountForTests;
|
||
export const resetVisualRowMeasureCountForTests = (): void => {
|
||
visualRowMeasureCountForTests = 0;
|
||
};
|
||
|
||
export const tryMeasureVisualRows = (
|
||
term: XTerm,
|
||
data: string,
|
||
startColumn: number,
|
||
columns: number,
|
||
startWraparoundMode: boolean,
|
||
): MeasuredTerminalRows | null => {
|
||
visualRowMeasureCountForTests += 1;
|
||
if (isSimpleAsciiControlText(data)) {
|
||
return measureSimpleAsciiRows(data, startColumn, columns, startWraparoundMode);
|
||
}
|
||
|
||
let rowOffset = 0;
|
||
let column = startColumn;
|
||
let wraparoundMode = startWraparoundMode;
|
||
|
||
for (let index = 0; index < data.length; index += 1) {
|
||
const char = data[index];
|
||
if (char === "\x1b") {
|
||
const sequence = readEscapeSequence(data, index);
|
||
if (!sequence?.complete || !isBulkMeasurableEscapeSequence(sequence.sequence)) {
|
||
return null;
|
||
}
|
||
wraparoundMode = getWraparoundAction(sequence.sequence) ?? wraparoundMode;
|
||
index = sequence.endIndex;
|
||
continue;
|
||
}
|
||
|
||
if (char === "\n") {
|
||
rowOffset += 1;
|
||
if (Number.isFinite(columns) && column >= columns) {
|
||
column = columns - 1;
|
||
}
|
||
continue;
|
||
}
|
||
if (char === "\r") {
|
||
column = 0;
|
||
continue;
|
||
}
|
||
if (char === "\b") {
|
||
column = Math.max(0, column - 1);
|
||
continue;
|
||
}
|
||
if (char === "\t") {
|
||
column = advanceMeasuredTab(column, columns);
|
||
continue;
|
||
}
|
||
|
||
const codePoint = data.codePointAt(index);
|
||
if (codePoint === undefined) return null;
|
||
if (
|
||
codePoint < 0x20
|
||
|| codePoint === 0x7f
|
||
|| (codePoint >= 0x80 && codePoint <= 0x9f)
|
||
|| isUnsafeGraphemeSequenceCodePoint(codePoint)
|
||
|| isUnsafeFormatCodePoint(codePoint)
|
||
|| isContextSensitiveGraphemeCodePoint(codePoint)
|
||
) {
|
||
return null;
|
||
}
|
||
const width = getCodePointCellWidth(term, codePoint);
|
||
if (width === null) return null;
|
||
({ column, rowOffset } = advanceMeasuredColumns(
|
||
column,
|
||
rowOffset,
|
||
columns,
|
||
width,
|
||
wraparoundMode,
|
||
));
|
||
if (codePoint > 0xffff) {
|
||
index += 1;
|
||
}
|
||
}
|
||
|
||
return { rowOffset, column, wraparoundMode };
|
||
};
|
||
|
||
|
||
export const resetTerminalLineTimestamps = (term: XTerm) => {
|
||
resetTimestampStore(getTimestampStore(term));
|
||
};
|
||
|
||
/**
|
||
* Reconnect-style reattach on a reused terminal: the xterm buffer (including
|
||
* scrollback preserved by the reconnect flow) survives the previous session,
|
||
* so keep ledger stamps anchored to that history and only reset per-session
|
||
* recording state (segmenter line tracking, same-second gate, pending prefix).
|
||
* A full reset here would leave preserved scrollback rows without timestamps.
|
||
*/
|
||
export const resetTerminalLineTimestampRecordingState = (term: XTerm): void => {
|
||
const store = getTimestampStore(term);
|
||
store.segmenter.reset();
|
||
store.timestampOnlyPrefix = "";
|
||
store.lastStampSecondKey = null;
|
||
store.recordsSincePrune = 0;
|
||
store.disposedPendingCompact = 0;
|
||
notifyTimestampStore(store);
|
||
};
|
||
|
||
export const onTerminalLineTimestampsChange = (
|
||
term: XTerm,
|
||
listener: () => void,
|
||
) => {
|
||
const store = getTimestampStore(term);
|
||
store.listeners.add(listener);
|
||
return () => {
|
||
store.listeners.delete(listener);
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Paint helper from marker entries (legacy). Prefer ledger-based paint.
|
||
*/
|
||
export const resolveTerminalTimestampGutterRows = ({
|
||
viewportY,
|
||
rows,
|
||
entries,
|
||
isWrappedLine,
|
||
}: {
|
||
viewportY: number;
|
||
rows: number;
|
||
entries: readonly TerminalTimestampGutterEntry[];
|
||
isWrappedLine?: (line: number) => boolean;
|
||
}): TerminalTimestampGutterRow[] => {
|
||
const viewportEnd = viewportY + rows - 1;
|
||
let firstRelevantLine = viewportY;
|
||
const wrappedSourceLineByRow = new Map<number, number>();
|
||
|
||
if (isWrappedLine) {
|
||
for (let row = 0; row < rows; row += 1) {
|
||
const line = viewportY + row;
|
||
if (!isWrappedLine(line)) continue;
|
||
let sourceLine = line;
|
||
while (sourceLine > 0 && isWrappedLine(sourceLine)) {
|
||
sourceLine -= 1;
|
||
}
|
||
wrappedSourceLineByRow.set(row, sourceLine);
|
||
firstRelevantLine = Math.min(firstRelevantLine, sourceLine);
|
||
}
|
||
}
|
||
|
||
const labelByLine = new Map<number, string>();
|
||
for (const entry of entries) {
|
||
if (entry.marker.isDisposed) continue;
|
||
const line = entry.marker.line;
|
||
if (line < firstRelevantLine || line > viewportEnd) continue;
|
||
labelByLine.set(line, entry.label);
|
||
}
|
||
|
||
const visible: TerminalTimestampGutterRow[] = [];
|
||
for (let row = 0; row < rows; row += 1) {
|
||
const line = viewportY + row;
|
||
const directLabel = labelByLine.get(line);
|
||
if (directLabel) {
|
||
visible.push({ row, label: directLabel });
|
||
continue;
|
||
}
|
||
|
||
const sourceLine = wrappedSourceLineByRow.get(row);
|
||
if (sourceLine === undefined) continue;
|
||
const wrappedLabel = labelByLine.get(sourceLine);
|
||
if (wrappedLabel) {
|
||
visible.push({ row, label: wrappedLabel });
|
||
}
|
||
}
|
||
|
||
return visible;
|
||
};
|
||
|
||
const effectiveLedgerLine = (entry: TimestampLedgerEntry): number => {
|
||
if (
|
||
entry.marker
|
||
&& !entry.marker.isDisposed
|
||
&& typeof entry.marker.line === "number"
|
||
&& Number.isFinite(entry.marker.line)
|
||
) {
|
||
return Math.max(0, entry.marker.line);
|
||
}
|
||
return entry.line;
|
||
};
|
||
|
||
/**
|
||
* Render gutter rows from the per-second ledger.
|
||
* Each visible line uses the latest stamp with stamp.line <= line so blank
|
||
* gaps between stamps (and soft-wrap continuations) fill with the previous time.
|
||
* Prefer live sparse marker lines when present (reflow-safe).
|
||
*/
|
||
export const resolveTerminalTimestampGutterRowsFromLedger = ({
|
||
viewportY,
|
||
rows,
|
||
ledger,
|
||
isWrappedLine,
|
||
/**
|
||
* Inclusive last buffer line that may receive a label. Caps paint at real
|
||
* content (not empty viewport rows past the last line).
|
||
*/
|
||
lastPaintLine,
|
||
}: {
|
||
viewportY: number;
|
||
rows: number;
|
||
ledger: readonly TimestampLedgerEntry[];
|
||
isWrappedLine?: (line: number) => boolean;
|
||
lastPaintLine?: number;
|
||
}): TerminalTimestampGutterRow[] => {
|
||
if (ledger.length === 0 || rows <= 0) return [];
|
||
|
||
const stamps = ledger
|
||
.map((entry) => ({ label: entry.label, secondKey: entry.secondKey, line: effectiveLedgerLine(entry) }))
|
||
.filter((entry) => entry.line >= 0)
|
||
.sort((left, right) => left.line - right.line || left.secondKey - right.secondKey);
|
||
|
||
const resolveSourceLine = (line: number): number => {
|
||
if (!isWrappedLine?.(line)) return line;
|
||
let sourceLine = line;
|
||
while (sourceLine > 0 && isWrappedLine(sourceLine)) {
|
||
sourceLine -= 1;
|
||
}
|
||
return sourceLine;
|
||
};
|
||
|
||
const maxLine = typeof lastPaintLine === "number" && Number.isFinite(lastPaintLine)
|
||
? lastPaintLine
|
||
: Number.POSITIVE_INFINITY;
|
||
|
||
const visible: TerminalTimestampGutterRow[] = [];
|
||
let stampIndex = -1;
|
||
let currentLabel: string | undefined;
|
||
for (let row = 0; row < rows; row += 1) {
|
||
const line = viewportY + row;
|
||
if (line > maxLine) break;
|
||
const sourceLine = resolveSourceLine(line);
|
||
while (stampIndex + 1 < stamps.length && stamps[stampIndex + 1].line <= sourceLine) {
|
||
stampIndex += 1;
|
||
currentLabel = stamps[stampIndex].label;
|
||
}
|
||
if (currentLabel) {
|
||
visible.push({ row, label: currentLabel });
|
||
}
|
||
}
|
||
return visible;
|
||
};
|
||
|
||
export const getVisibleTerminalLineTimestampRows = (
|
||
term: XTerm,
|
||
): TerminalTimestampGutterRow[] => {
|
||
if ((term.buffer.active as { type?: string }).type === "alternate") {
|
||
return [];
|
||
}
|
||
const store = getTimestampStore(term);
|
||
rebaseLedgerForScrollback(term, store);
|
||
let bufferLength = 0;
|
||
try {
|
||
const active = term.buffer?.active as { length?: number } | undefined;
|
||
bufferLength = typeof active?.length === "number" ? active.length : 0;
|
||
} catch {
|
||
bufferLength = 0;
|
||
}
|
||
const lastPaintLine = resolveLastPaintBufferLine(term, store, bufferLength);
|
||
return resolveTerminalTimestampGutterRowsFromLedger({
|
||
viewportY: term.buffer.active.viewportY,
|
||
rows: term.rows,
|
||
ledger: store.ledger,
|
||
isWrappedLine: (line) => term.buffer.active.getLine(line)?.isWrapped === true,
|
||
lastPaintLine,
|
||
});
|
||
};
|
||
|
||
/**
|
||
* Record path: one term.write + per-second ledger updates.
|
||
* Soft-wrap–aware pre-write line estimates; sparse markers after write for
|
||
* reflow/scrollback (still ≤1 marker per wall-clock second).
|
||
*/
|
||
const writeTerminalDataWithSecondLedger = (
|
||
term: XTerm,
|
||
data: string,
|
||
done: () => void,
|
||
options?: TerminalLineTimestampWriteOptions,
|
||
diagnostics?: TerminalLineTimestampDiagnostics,
|
||
shouldMeasureDiagnostics = false,
|
||
): void => {
|
||
const store = getTimestampStore(term);
|
||
|
||
store.segmenter.setAlternateScreenActive(
|
||
((term.buffer?.active as { type?: string } | undefined)?.type) === "alternate",
|
||
);
|
||
const timestampOnlyPrefix = store.timestampOnlyPrefix;
|
||
store.timestampOnlyPrefix = "";
|
||
const dataForTimestamps = `${timestampOnlyPrefix}${data}`;
|
||
const stampDate = options?.timestampDate ?? new Date();
|
||
const segments = store.segmenter.append(dataForTimestamps, stampDate);
|
||
|
||
const pendingEscapeSequence = store.segmenter.flushPendingEscapeSequence();
|
||
if (isPotentialAlternateScreenSequence(pendingEscapeSequence)) {
|
||
store.timestampOnlyPrefix = pendingEscapeSequence;
|
||
}
|
||
|
||
const startedOnAlt = (
|
||
(term.buffer?.active as { type?: string } | undefined)?.type
|
||
) === "alternate";
|
||
// If already on alt, seed from the saved normal buffer — not active (alt) cursor.
|
||
const seedCursor = startedOnAlt
|
||
? getNormalBufferCursorState(term)
|
||
: {
|
||
absoluteLine: getAbsoluteCursorLine(term),
|
||
column: _getTerminalCursorColumn(term),
|
||
};
|
||
let stampCursor: StampCursorEstimate = {
|
||
absoluteLine: seedCursor.absoluteLine,
|
||
column: seedCursor.column,
|
||
wraparoundMode: _getTerminalWraparoundMode(term),
|
||
altActive: startedOnAlt,
|
||
};
|
||
const columns = _getTerminalColumnCount(term);
|
||
const capacity = resolveTerminalLineTimestampCapacity(term);
|
||
let ledgerChanged = false;
|
||
const pendingAnchors: TimestampLedgerEntry[] = [];
|
||
for (const segment of segments) {
|
||
if (segment.kind === "timestamp") {
|
||
// Segmenter only emits timestamps off the alt screen; still guard line.
|
||
if (stampCursor.altActive) continue;
|
||
const secondKey = secondKeyFromDate(stampDate);
|
||
if (tryBeginSecondStamp(store, secondKey)) {
|
||
const entry = pushLedgerStamp(
|
||
store,
|
||
segment.label,
|
||
secondKey,
|
||
stampCursor.absoluteLine,
|
||
capacity,
|
||
);
|
||
pendingAnchors.push(entry);
|
||
ledgerChanged = true;
|
||
}
|
||
continue;
|
||
}
|
||
stampCursor = advanceStampCursorThroughData(
|
||
term,
|
||
stampCursor,
|
||
segment.data,
|
||
columns,
|
||
);
|
||
}
|
||
|
||
const writeStartedAt = shouldMeasureDiagnostics ? performance.now() : 0;
|
||
term.write(data, () => {
|
||
attachLedgerAnchors(term, store, pendingAnchors);
|
||
rebaseLedgerForScrollback(term, store);
|
||
if (ledgerChanged) {
|
||
notifyTimestampStore(store);
|
||
}
|
||
if (diagnostics) {
|
||
diagnostics.onStep?.({
|
||
kind: "fallback-write",
|
||
dataChars: data.length,
|
||
writeCallbackMs: performance.now() - writeStartedAt,
|
||
});
|
||
}
|
||
done();
|
||
});
|
||
};
|
||
|
||
export const writeTerminalDataWithLineTimestamps = (
|
||
term: XTerm,
|
||
data: string,
|
||
done: () => void,
|
||
options?: TerminalLineTimestampWriteOptions,
|
||
) => {
|
||
const diagnostics = options?.onStep ? options : undefined;
|
||
const shouldMeasureDiagnostics = Boolean(diagnostics);
|
||
const writeFallbackOnly = (fallbackData: string, onComplete: () => void): void => {
|
||
const writeStartedAt = shouldMeasureDiagnostics ? performance.now() : 0;
|
||
term.write(fallbackData, () => {
|
||
if (diagnostics) {
|
||
diagnostics.onStep?.({
|
||
kind: "fallback-write",
|
||
dataChars: fallbackData.length,
|
||
writeCallbackMs: performance.now() - writeStartedAt,
|
||
});
|
||
}
|
||
onComplete();
|
||
});
|
||
};
|
||
|
||
// True flood: skip ledger work entirely (same product rule as before).
|
||
if (shouldSkipTerminalLineTimestamps(term)) {
|
||
const store = getTimestampStore(term);
|
||
store.segmenter.setAlternateScreenActive(
|
||
((term.buffer?.active as { type?: string } | undefined)?.type) === "alternate",
|
||
);
|
||
store.segmenter.reset();
|
||
store.timestampOnlyPrefix = "";
|
||
store.lastStampSecondKey = null;
|
||
writeFallbackOnly(data, done);
|
||
return;
|
||
}
|
||
|
||
// Record/render separation: per-second ledger + sparse reflow anchors.
|
||
writeTerminalDataWithSecondLedger(
|
||
term,
|
||
data,
|
||
done,
|
||
options,
|
||
diagnostics,
|
||
shouldMeasureDiagnostics,
|
||
);
|
||
};
|