Files
NetMesh/components/terminal/runtime/terminalLineTimestamps.ts

1827 lines
56 KiB
TypeScript
Raw Permalink Normal View History

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-wrapaware 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-wrapaware 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,
);
};