Files
NetMesh/components/terminal/runtime/terminalLineTimestamps.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1827 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
};