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
459 lines
13 KiB
TypeScript
459 lines
13 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
import type { RefObject } from "react";
|
|
import type { Terminal as XTerm } from "@xterm/xterm";
|
|
|
|
import {
|
|
getVisibleTerminalLineTimestampRows,
|
|
onTerminalLineTimestampsChange,
|
|
} from "./runtime/terminalLineTimestamps";
|
|
import type { TerminalTimestampGutterRow } from "./runtime/terminalLineTimestamps";
|
|
import { getTerminalOutputPressure } from "./runtime/terminalOutputPressure";
|
|
|
|
export const TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH = 56;
|
|
export const TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING = 16;
|
|
export const TERMINAL_TIMESTAMP_SAMPLE_LABEL = "88:88:88";
|
|
/** Cap gutter paint rate while large-output pressure is active (rAF still coalesces). */
|
|
export const TERMINAL_TIMESTAMP_GUTTER_FLOOD_MIN_INTERVAL_MS = 100;
|
|
const GUTTER_ROW_CLASS =
|
|
"absolute left-0 right-0 px-2 text-right tabular-nums whitespace-nowrap";
|
|
|
|
type TerminalTimestampGutterProps = {
|
|
termRef: RefObject<XTerm | null>;
|
|
containerRef: RefObject<HTMLDivElement | null>;
|
|
enabled: boolean;
|
|
top: string;
|
|
left?: number;
|
|
bottom?: number;
|
|
sessionId: string;
|
|
color: string;
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
fontWeight: string | number;
|
|
width: number;
|
|
onWidthChange?: (width: number) => void;
|
|
};
|
|
|
|
type DisposableLike = {
|
|
dispose: () => void;
|
|
};
|
|
|
|
type TerminalTimestampTypography = {
|
|
fontFamily?: string;
|
|
fontSize?: number;
|
|
fontWeight?: string | number;
|
|
};
|
|
|
|
const getTerminalScreen = (container: HTMLElement): HTMLElement => (
|
|
container.querySelector<HTMLElement>(".xterm-screen") ?? container
|
|
);
|
|
|
|
const clearElement = (element: HTMLElement) => {
|
|
while (element.firstChild) {
|
|
element.removeChild(element.firstChild);
|
|
}
|
|
};
|
|
|
|
const applyGutterRowStyles = (
|
|
item: HTMLElement,
|
|
{
|
|
row,
|
|
label,
|
|
screenTop,
|
|
cellHeight,
|
|
color,
|
|
fontFamily,
|
|
fontSize,
|
|
fontWeight,
|
|
}: {
|
|
row: number;
|
|
label: string;
|
|
screenTop: number;
|
|
cellHeight: number;
|
|
color: string;
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
fontWeight: string | number;
|
|
},
|
|
) => {
|
|
if (item.textContent !== label) {
|
|
item.textContent = label;
|
|
}
|
|
item.style.top = `${screenTop + row * cellHeight}px`;
|
|
item.style.height = `${cellHeight}px`;
|
|
item.style.lineHeight = `${cellHeight}px`;
|
|
item.style.color = color;
|
|
item.style.fontFamily = fontFamily;
|
|
item.style.fontSize = `${fontSize}px`;
|
|
item.style.fontWeight = String(fontWeight);
|
|
item.style.fontVariantNumeric = "tabular-nums";
|
|
item.style.display = "";
|
|
};
|
|
|
|
/**
|
|
* Reuse a fixed pool of row divs instead of clear+create on every paint.
|
|
* Returns the number of visible nodes kept after the update.
|
|
*/
|
|
export const syncTerminalTimestampGutterRows = (
|
|
gutter: HTMLElement,
|
|
rows: readonly TerminalTimestampGutterRow[],
|
|
layout: {
|
|
screenTop: number;
|
|
cellHeight: number;
|
|
color: string;
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
fontWeight: string | number;
|
|
},
|
|
): number => {
|
|
const existing = gutter.children;
|
|
let index = 0;
|
|
for (; index < rows.length; index += 1) {
|
|
const { row, label } = rows[index];
|
|
let item = existing[index] as HTMLElement | undefined;
|
|
if (!item) {
|
|
item = document.createElement("div");
|
|
item.className = GUTTER_ROW_CLASS;
|
|
gutter.appendChild(item);
|
|
}
|
|
applyGutterRowStyles(item, {
|
|
row,
|
|
label,
|
|
screenTop: layout.screenTop,
|
|
cellHeight: layout.cellHeight,
|
|
color: layout.color,
|
|
fontFamily: layout.fontFamily,
|
|
fontSize: layout.fontSize,
|
|
fontWeight: layout.fontWeight,
|
|
});
|
|
}
|
|
// Hide surplus pooled nodes (keep them for the next paint).
|
|
for (; index < existing.length; index += 1) {
|
|
(existing[index] as HTMLElement).style.display = "none";
|
|
}
|
|
return rows.length;
|
|
};
|
|
|
|
export const resolveTerminalTimestampGutterColor = (
|
|
colors: Partial<Record<"brightCyan" | "brightYellow" | "brightMagenta" | "foreground", string>>,
|
|
): string => (
|
|
colors.brightCyan
|
|
|| colors.brightYellow
|
|
|| colors.brightMagenta
|
|
|| colors.foreground
|
|
|| "currentColor"
|
|
);
|
|
|
|
const normalizeTerminalTimestampFontSize = (fontSize?: number): number => (
|
|
Number.isFinite(fontSize) && fontSize && fontSize > 0 ? fontSize : 12
|
|
);
|
|
|
|
export const getTerminalTimestampTypography = ({
|
|
fontFamily,
|
|
fontSize,
|
|
fontWeight,
|
|
}: TerminalTimestampTypography) => ({
|
|
fontFamily: fontFamily || "monospace",
|
|
fontSize: normalizeTerminalTimestampFontSize(fontSize),
|
|
fontWeight: fontWeight ?? 400,
|
|
});
|
|
|
|
const estimateTerminalTimestampTextWidth = (
|
|
fontSize: number,
|
|
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
|
|
): number => (
|
|
normalizeTerminalTimestampFontSize(fontSize) * label.length * 0.62
|
|
);
|
|
|
|
export const resolveTerminalTimestampGutterWidth = ({
|
|
measuredTextWidth,
|
|
fontSize,
|
|
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
|
|
}: {
|
|
measuredTextWidth?: number;
|
|
fontSize?: number;
|
|
label?: string;
|
|
}): number => {
|
|
const textWidth =
|
|
Number.isFinite(measuredTextWidth) && measuredTextWidth !== undefined && measuredTextWidth > 0
|
|
? measuredTextWidth
|
|
: estimateTerminalTimestampTextWidth(normalizeTerminalTimestampFontSize(fontSize), label);
|
|
return Math.ceil(Math.max(
|
|
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
|
|
textWidth + TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
|
|
));
|
|
};
|
|
|
|
export const resolveTerminalTimestampGutterRenderSignature = ({
|
|
screenTop,
|
|
cellHeight,
|
|
color,
|
|
fontFamily,
|
|
fontSize,
|
|
fontWeight,
|
|
rows,
|
|
}: {
|
|
screenTop: number;
|
|
cellHeight: number;
|
|
color: string;
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
fontWeight: string | number;
|
|
rows: readonly TerminalTimestampGutterRow[];
|
|
}): string => {
|
|
let signature = `${screenTop}|${cellHeight}|${color}|${fontFamily}|${fontSize}|${fontWeight}`;
|
|
for (const { row, label } of rows) {
|
|
signature += `|${row}:${label}`;
|
|
}
|
|
return signature;
|
|
};
|
|
|
|
export function TerminalTimestampGutter({
|
|
termRef,
|
|
containerRef,
|
|
enabled,
|
|
top,
|
|
left = 0,
|
|
bottom = 0,
|
|
sessionId,
|
|
color,
|
|
fontFamily,
|
|
fontSize,
|
|
fontWeight,
|
|
width,
|
|
onWidthChange,
|
|
}: TerminalTimestampGutterProps) {
|
|
const gutterRef = useRef<HTMLDivElement>(null);
|
|
const typography = getTerminalTimestampTypography({ fontFamily, fontSize, fontWeight });
|
|
|
|
useLayoutEffect(() => {
|
|
if (!enabled || !onWidthChange) return;
|
|
const gutter = gutterRef.current;
|
|
if (!gutter) return;
|
|
|
|
let disposed = false;
|
|
|
|
const measure = () => {
|
|
if (disposed) return;
|
|
const probe = document.createElement("span");
|
|
probe.textContent = TERMINAL_TIMESTAMP_SAMPLE_LABEL;
|
|
probe.style.position = "absolute";
|
|
probe.style.visibility = "hidden";
|
|
probe.style.pointerEvents = "none";
|
|
probe.style.whiteSpace = "nowrap";
|
|
probe.style.fontFamily = typography.fontFamily;
|
|
probe.style.fontSize = `${typography.fontSize}px`;
|
|
probe.style.fontWeight = String(typography.fontWeight);
|
|
probe.style.fontVariantNumeric = "tabular-nums";
|
|
gutter.appendChild(probe);
|
|
const measuredTextWidth = probe.getBoundingClientRect().width;
|
|
probe.remove();
|
|
onWidthChange(resolveTerminalTimestampGutterWidth({
|
|
measuredTextWidth,
|
|
fontSize: typography.fontSize,
|
|
}));
|
|
};
|
|
|
|
measure();
|
|
const fonts = (document as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
|
|
void fonts?.ready?.then(measure);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
};
|
|
}, [enabled, onWidthChange, sessionId, typography.fontFamily, typography.fontSize, typography.fontWeight]);
|
|
|
|
useEffect(() => {
|
|
const gutter = gutterRef.current;
|
|
if (!gutter) return;
|
|
|
|
let disposed = false;
|
|
let rafId: number | null = null;
|
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let floodThrottleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let disposables: DisposableLike[] = [];
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
let lastRenderSignature = "";
|
|
let lastFloodRenderAt = 0;
|
|
|
|
const clearGutter = () => {
|
|
lastRenderSignature = "";
|
|
clearElement(gutter);
|
|
};
|
|
|
|
const render = () => {
|
|
rafId = null;
|
|
// Always advance the flood clock when a render attempt runs — even if the
|
|
// signature is unchanged. Otherwise same-second labels during sustained
|
|
// output leave lastFloodRenderAt stale and the throttle stops limiting work.
|
|
lastFloodRenderAt = performance.now();
|
|
const term = termRef.current;
|
|
const container = containerRef.current;
|
|
if (!enabled || !term || !container) {
|
|
clearGutter();
|
|
return;
|
|
}
|
|
|
|
const screen = getTerminalScreen(container);
|
|
const rows = Math.max(1, term.rows || 1);
|
|
const cellHeight = screen.clientHeight / rows;
|
|
if (!Number.isFinite(cellHeight) || cellHeight <= 0) {
|
|
clearGutter();
|
|
return;
|
|
}
|
|
|
|
const screenRect = screen.getBoundingClientRect();
|
|
const gutterRect = gutter.getBoundingClientRect();
|
|
const screenTop = screenRect.top - gutterRect.top;
|
|
const visibleRows = getVisibleTerminalLineTimestampRows(term);
|
|
const signature = resolveTerminalTimestampGutterRenderSignature({
|
|
screenTop,
|
|
cellHeight,
|
|
color,
|
|
fontFamily: typography.fontFamily,
|
|
fontSize: typography.fontSize,
|
|
fontWeight: typography.fontWeight,
|
|
rows: visibleRows,
|
|
});
|
|
if (signature === lastRenderSignature) return;
|
|
lastRenderSignature = signature;
|
|
|
|
syncTerminalTimestampGutterRows(gutter, visibleRows, {
|
|
screenTop,
|
|
cellHeight,
|
|
color,
|
|
fontFamily: typography.fontFamily,
|
|
fontSize: typography.fontSize,
|
|
fontWeight: typography.fontWeight,
|
|
});
|
|
};
|
|
|
|
const queueRafRender = () => {
|
|
if (disposed || rafId !== null) return;
|
|
if (typeof requestAnimationFrame === "function") {
|
|
rafId = requestAnimationFrame(render);
|
|
} else {
|
|
render();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* immediate: user scroll/resize — paint ASAP.
|
|
* normal: output/render pressure — throttle while flood pressure is active.
|
|
*/
|
|
const scheduleRender = (priority: "immediate" | "normal" = "normal") => {
|
|
if (disposed) return;
|
|
if (priority === "normal") {
|
|
const term = termRef.current;
|
|
if (term) {
|
|
const pressure = getTerminalOutputPressure(term);
|
|
if (pressure.largeOutput || pressure.longLine) {
|
|
const now = performance.now();
|
|
const elapsed = now - lastFloodRenderAt;
|
|
if (elapsed < TERMINAL_TIMESTAMP_GUTTER_FLOOD_MIN_INTERVAL_MS) {
|
|
if (floodThrottleTimer === null) {
|
|
floodThrottleTimer = setTimeout(() => {
|
|
floodThrottleTimer = null;
|
|
queueRafRender();
|
|
}, TERMINAL_TIMESTAMP_GUTTER_FLOOD_MIN_INTERVAL_MS - elapsed);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} else if (floodThrottleTimer !== null) {
|
|
clearTimeout(floodThrottleTimer);
|
|
floodThrottleTimer = null;
|
|
}
|
|
queueRafRender();
|
|
};
|
|
|
|
const attach = () => {
|
|
if (disposed) return;
|
|
const term = termRef.current;
|
|
const container = containerRef.current;
|
|
if (!enabled || !term || !container) {
|
|
clearGutter();
|
|
if (enabled) {
|
|
retryTimer = setTimeout(attach, 50);
|
|
}
|
|
return;
|
|
}
|
|
|
|
disposables = [
|
|
// xterm fires onScroll for output-driven scrolling too (not only user
|
|
// wheel/trackpad). Throttle only while large-output pressure is active
|
|
// (time-bounded). Do not use longLine here — it sticks until the next
|
|
// data chunk and would lag genuine user scrolling after output stops.
|
|
term.onScroll?.(() => {
|
|
const pressure = getTerminalOutputPressure(term);
|
|
scheduleRender(pressure.largeOutput ? "normal" : "immediate");
|
|
}),
|
|
term.onRender?.(() => scheduleRender("normal")),
|
|
term.onResize?.(() => scheduleRender("immediate")),
|
|
].filter(Boolean) as DisposableLike[];
|
|
disposables.push({
|
|
dispose: onTerminalLineTimestampsChange(term, () => scheduleRender("normal")),
|
|
});
|
|
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
resizeObserver = new ResizeObserver(() => scheduleRender("immediate"));
|
|
resizeObserver.observe(container);
|
|
resizeObserver.observe(getTerminalScreen(container));
|
|
}
|
|
|
|
scheduleRender("immediate");
|
|
};
|
|
|
|
attach();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
if (rafId !== null && typeof cancelAnimationFrame === "function") {
|
|
cancelAnimationFrame(rafId);
|
|
}
|
|
if (retryTimer) {
|
|
clearTimeout(retryTimer);
|
|
}
|
|
if (floodThrottleTimer !== null) {
|
|
clearTimeout(floodThrottleTimer);
|
|
}
|
|
for (const disposable of disposables) {
|
|
disposable.dispose();
|
|
}
|
|
resizeObserver?.disconnect();
|
|
clearElement(gutter);
|
|
};
|
|
}, [
|
|
color,
|
|
containerRef,
|
|
enabled,
|
|
bottom,
|
|
left,
|
|
sessionId,
|
|
termRef,
|
|
top,
|
|
typography.fontFamily,
|
|
typography.fontSize,
|
|
typography.fontWeight,
|
|
]);
|
|
|
|
if (!enabled) return null;
|
|
|
|
return (
|
|
<div
|
|
ref={gutterRef}
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute z-[1] overflow-hidden select-none text-[color:var(--terminal-ui-fg)]"
|
|
style={{
|
|
top,
|
|
bottom,
|
|
left,
|
|
width,
|
|
backgroundColor: "var(--terminal-ui-bg)",
|
|
boxShadow: "inset -0.5px 0 0 color-mix(in srgb, var(--terminal-ui-fg) 8%, transparent)",
|
|
}}
|
|
data-section="terminal-timestamp-gutter"
|
|
/>
|
|
);
|
|
}
|