` elements positioned on an exact grid
+ * matching xterm.js's canvas renderer. This avoids sub-pixel drift that
+ * occurs with normal DOM text flow.
+ */
+export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
+ const { lines, startCol, totalCols, cellW, cellH, promptRow, font, showCursor, cursorColor } = params;
+
+ // Position container at prompt row
+ container.style.left = '0px';
+ container.style.top = (promptRow * cellH) + 'px';
+
+ // Clear and rebuild (typically 1-3 line divs, negligible cost)
+ container.innerHTML = '';
+ const fullWidthPx = totalCols * cellW;
+
+ for (let i = 0; i < lines.length; i++) {
+ const leftPx = i === 0 ? startCol * cellW : 0;
+ const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx;
+ const topPx = i * cellH;
+ const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, font);
+ container.appendChild(lineEl);
+ }
+
+ // Block cursor at end of last line
+ if (showCursor) {
+ const lastLine = lines[lines.length - 1];
+ const lastLineLeft = lines.length === 1 ? startCol : 0;
+ const cursorCol = lastLineLeft + lastLine.length;
+ if (cursorCol < totalCols) {
+ const cursor = document.createElement('span');
+ cursor.style.cssText = 'position:absolute;display:inline-block';
+ cursor.style.left = (cursorCol * cellW) + 'px';
+ cursor.style.top = ((lines.length - 1) * cellH) + 'px';
+ cursor.style.width = cellW + 'px';
+ cursor.style.height = cellH + 'px';
+ cursor.style.backgroundColor = cursorColor;
+ container.appendChild(cursor);
+ }
+ }
+
+ container.style.display = '';
+}
+
+/**
+ * Create a styled line `` with per-character grid positioning.
+ *
+ * Each character gets its own `` placed at `i * cellW` pixels.
+ * This matches xterm's canvas renderer where each glyph occupies exactly
+ * one cell width, regardless of the actual glyph metrics.
+ */
+function makeLine(
+ text: string,
+ leftPx: number,
+ topPx: number,
+ widthPx: number,
+ cellH: number,
+ cellW: number,
+ font: FontStyle,
+): HTMLDivElement {
+ const el = document.createElement('div');
+ el.style.cssText = 'position:absolute;pointer-events:none';
+ el.style.backgroundColor = font.backgroundColor;
+ el.style.left = leftPx + 'px';
+ el.style.top = topPx + 'px';
+ el.style.width = widthPx + 'px';
+ el.style.height = (cellH + 1) + 'px';
+ el.style.lineHeight = cellH + 'px';
+
+ for (let i = 0; i < text.length; i++) {
+ const span = document.createElement('span');
+ // Match xterm.js canvas text rendering:
+ // - antialiased smoothing (canvas uses grayscale, not LCD subpixel)
+ // - geometricPrecision for consistent glyph sizing
+ // - no ligatures (canvas renders each glyph independently)
+ span.style.cssText =
+ 'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
+ '-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;' +
+ "text-rendering:geometricPrecision;font-feature-settings:'liga' 0,'calt' 0";
+ span.style.left = (i * cellW) + 'px';
+ span.style.width = cellW + 'px';
+ span.style.fontFamily = font.fontFamily;
+ span.style.fontSize = font.fontSize;
+ span.style.fontWeight = font.fontWeight;
+ span.style.color = font.color;
+ if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
+ span.textContent = text[i];
+ el.appendChild(span);
+ }
+
+ return el;
+}
diff --git a/packages/xterm-zerolag-input/src/prompt-finder.ts b/packages/xterm-zerolag-input/src/prompt-finder.ts
new file mode 100644
index 00000000..736f415f
--- /dev/null
+++ b/packages/xterm-zerolag-input/src/prompt-finder.ts
@@ -0,0 +1,77 @@
+import type { XtermTerminal, PromptFinder, PromptPosition } from './types.js';
+
+/**
+ * Find the prompt in the terminal buffer using the configured strategy.
+ * Scans bottom-up through the viewport to find the most recent prompt.
+ *
+ * @returns The prompt position (viewport-relative), or `null` if not found.
+ */
+export function findPrompt(
+ terminal: XtermTerminal,
+ finder: PromptFinder,
+): PromptPosition | null {
+ try {
+ const buffer = terminal.buffer.active;
+ const viewportTop = buffer.viewportY;
+
+ switch (finder.type) {
+ case 'character': {
+ for (let row = terminal.rows - 1; row >= 0; row--) {
+ const line = buffer.getLine(viewportTop + row);
+ if (!line) continue;
+ const text = line.translateToString(true);
+ const idx = text.lastIndexOf(finder.char);
+ if (idx >= 0) return { row, col: idx };
+ }
+ return null;
+ }
+
+ case 'regex': {
+ for (let row = terminal.rows - 1; row >= 0; row--) {
+ const line = buffer.getLine(viewportTop + row);
+ if (!line) continue;
+ const text = line.translateToString(true);
+ const match = text.match(finder.pattern);
+ if (match) {
+ const col = match.index ?? 0;
+ return { row, col };
+ }
+ }
+ return null;
+ }
+
+ case 'custom':
+ return finder.find(terminal);
+
+ default:
+ return null;
+ }
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Read text after the prompt position on the same line.
+ *
+ * @param terminal - The xterm.js terminal instance
+ * @param prompt - The prompt position in the viewport
+ * @param offset - Characters to skip after the prompt marker (e.g., 2 for "> ")
+ * @returns The text after the prompt, trimmed. Empty string if nothing found.
+ */
+export function readTextAfterPrompt(
+ terminal: XtermTerminal,
+ prompt: PromptPosition,
+ offset: number,
+): string {
+ try {
+ const buffer = terminal.buffer.active;
+ const absRow = buffer.viewportY + prompt.row;
+ const line = buffer.getLine(absRow);
+ if (!line) return '';
+ const lineText = line.translateToString(true);
+ return lineText.slice(prompt.col + offset).trimEnd();
+ } catch {
+ return '';
+ }
+}
diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts
new file mode 100644
index 00000000..294bd2d2
--- /dev/null
+++ b/packages/xterm-zerolag-input/src/types.ts
@@ -0,0 +1,163 @@
+/**
+ * Minimal terminal interface required by the addon.
+ *
+ * Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+).
+ * Consumers pass their real Terminal instance — we only use these properties.
+ */
+export interface XtermTerminal {
+ readonly element: HTMLElement | undefined;
+ readonly cols: number;
+ readonly rows: number;
+ readonly options: {
+ fontFamily?: string;
+ fontSize?: number;
+ fontWeight?: string | number;
+ theme?: {
+ background?: string;
+ foreground?: string;
+ cursor?: string;
+ };
+ };
+ readonly buffer: {
+ readonly active: {
+ readonly viewportY: number;
+ readonly baseY: number;
+ getLine(y: number): {
+ translateToString(trimRight?: boolean): string;
+ } | undefined;
+ };
+ };
+}
+
+/**
+ * Minimal addon interface matching xterm.js ITerminalAddon.
+ *
+ * The consumer calls `terminal.loadAddon(addon)` which invokes `activate()`.
+ */
+export interface XtermAddon {
+ activate(terminal: XtermTerminal): void;
+ dispose(): void;
+}
+
+/**
+ * Position of the prompt in the terminal viewport.
+ */
+export interface PromptPosition {
+ /** Viewport-relative row (0 = top of viewport) */
+ row: number;
+ /** Column of the prompt marker character */
+ col: number;
+}
+
+/**
+ * Prompt detection strategy.
+ *
+ * The overlay needs to know where user input starts on the terminal line.
+ * Three strategies are supported:
+ *
+ * - `character`: Scan bottom-up for a specific character (e.g., `$`, `>`, `❯`)
+ * - `regex`: Scan each line with a regex pattern
+ * - `custom`: Full escape hatch — provide your own finder function
+ */
+export type PromptFinder =
+ | { type: 'character'; char: string; offset?: number }
+ | { type: 'regex'; pattern: RegExp; offset?: number }
+ | { type: 'custom'; find: (terminal: XtermTerminal) => PromptPosition | null; offset?: number };
+
+/**
+ * Configuration options for ZerolagInputAddon.
+ */
+export interface ZerolagInputOptions {
+ /**
+ * How to find the prompt in the terminal buffer.
+ *
+ * The `offset` controls how many characters after the prompt marker
+ * the user input begins (e.g., `"> "` = offset 2).
+ *
+ * @default { type: 'character', char: '>', offset: 2 }
+ */
+ prompt?: PromptFinder;
+
+ /**
+ * Z-index for the overlay element.
+ * @default 7
+ */
+ zIndex?: number;
+
+ /**
+ * Background color for the overlay.
+ * Set to `'transparent'` to disable the opaque background.
+ * @default Read from terminal.options.theme.background
+ */
+ backgroundColor?: string;
+
+ /**
+ * Foreground color for overlay text.
+ * @default Read from terminal.options.theme.foreground
+ */
+ foregroundColor?: string;
+
+ /**
+ * Whether to show a block cursor at the end of the overlay text.
+ * @default true
+ */
+ showCursor?: boolean;
+
+ /**
+ * Cursor color (block cursor at end of text).
+ * @default Read from terminal.options.theme.cursor
+ */
+ cursorColor?: string;
+
+ /**
+ * Scroll debounce time in ms for re-rendering when user scrolls
+ * back to the bottom of the terminal.
+ * @default 50
+ */
+ scrollDebounceMs?: number;
+}
+
+/**
+ * Read-only state snapshot of the overlay.
+ */
+export interface ZerolagInputState {
+ /** Characters typed but not yet acknowledged by the server */
+ pendingText: string;
+ /** Number of characters flushed to PTY but echo not yet received */
+ flushedLength: number;
+ /** Text content of the flushed portion */
+ flushedText: string;
+ /** Whether the overlay is currently visible */
+ visible: boolean;
+ /** Last detected prompt position, if any */
+ promptPosition: PromptPosition | null;
+}
+
+/** Cell dimensions in CSS pixels. */
+export interface CellDimensions {
+ width: number;
+ height: number;
+}
+
+/** Parameters for the overlay renderer. */
+export interface RenderParams {
+ lines: string[];
+ startCol: number;
+ totalCols: number;
+ cellW: number;
+ cellH: number;
+ promptRow: number;
+ font: FontStyle;
+ showCursor: boolean;
+ cursorColor: string;
+}
+
+/** Cached font style properties for overlay rendering. */
+export interface FontStyle {
+ fontFamily: string;
+ fontSize: string;
+ fontWeight: string;
+ color: string;
+ backgroundColor: string;
+ letterSpacing: string;
+}
diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
new file mode 100644
index 00000000..6b7bab43
--- /dev/null
+++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
@@ -0,0 +1,544 @@
+import type {
+ XtermTerminal,
+ XtermAddon,
+ ZerolagInputOptions,
+ ZerolagInputState,
+ PromptPosition,
+ PromptFinder,
+ FontStyle,
+} from './types.js';
+import { getCellDimensions } from './cell-dimensions.js';
+import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
+import { renderOverlay } from './overlay-renderer.js';
+
+const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
+const DEFAULT_Z_INDEX = 7;
+const DEFAULT_SCROLL_DEBOUNCE_MS = 50;
+const DEFAULT_BG = '#0d0d0d';
+const DEFAULT_FG = '#eeeeee';
+const DEFAULT_CURSOR = '#e0e0e0';
+
+/**
+ * xterm.js addon that provides instant keystroke feedback via a DOM overlay.
+ *
+ * Eliminates perceived input latency over high-RTT connections (SSH, remote
+ * terminals, mobile) by rendering typed characters immediately as a DOM
+ * overlay, without waiting for the PTY round-trip.
+ *
+ * The addon does NOT hook `terminal.onData` — the consumer wires their
+ * own input handler and calls `addChar()`, `removeChar()`, `clear()`, etc.
+ *
+ * Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+).
+ *
+ * @example
+ * ```typescript
+ * import { Terminal } from '@xterm/xterm';
+ * import { ZerolagInputAddon } from 'xterm-zerolag-input';
+ *
+ * const terminal = new Terminal();
+ * const zerolag = new ZerolagInputAddon({
+ * prompt: { type: 'character', char: '$', offset: 2 },
+ * });
+ * terminal.open(document.getElementById('terminal')!);
+ * terminal.loadAddon(zerolag);
+ *
+ * terminal.onData((data) => {
+ * if (data === '\r') {
+ * const text = zerolag.pendingText;
+ * zerolag.clear();
+ * ws.send(text + '\r');
+ * } else if (data === '\x7f') {
+ * if (zerolag.removeChar()) ws.send(data);
+ * } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
+ * zerolag.addChar(data);
+ * }
+ * });
+ * ```
+ */
+export class ZerolagInputAddon implements XtermAddon {
+ private _terminal: XtermTerminal | null = null;
+ private _overlay: HTMLDivElement | null = null;
+ private _options: Required<
+ Pick
+ > & ZerolagInputOptions;
+
+ // Text state
+ private _pendingText = '';
+ private _flushedOffset = 0;
+ private _flushedText = '';
+ private _bufferDetectDone = false;
+
+ // Render cache
+ private _lastRenderKey = '';
+ private _lastPromptPos: PromptPosition | null = null;
+
+ // Font cache
+ private _font: FontStyle = {
+ fontFamily: 'monospace',
+ fontSize: '14px',
+ fontWeight: 'normal',
+ color: DEFAULT_FG,
+ backgroundColor: DEFAULT_BG,
+ letterSpacing: '',
+ };
+
+ // Scroll handling
+ private _scrollTimer: ReturnType | null = null;
+ private _scrollHandler: (() => void) | null = null;
+ private _scrollViewport: Element | null = null;
+
+ constructor(options?: ZerolagInputOptions) {
+ this._options = {
+ prompt: options?.prompt ?? DEFAULT_PROMPT,
+ zIndex: options?.zIndex ?? DEFAULT_Z_INDEX,
+ showCursor: options?.showCursor ?? true,
+ scrollDebounceMs: options?.scrollDebounceMs ?? DEFAULT_SCROLL_DEBOUNCE_MS,
+ backgroundColor: options?.backgroundColor,
+ foregroundColor: options?.foregroundColor,
+ cursorColor: options?.cursorColor,
+ };
+ }
+
+ // ─── Lifecycle ────────────────────────────────────────────────────
+
+ /**
+ * Called by `terminal.loadAddon()`. Do not call directly.
+ */
+ activate(terminal: XtermTerminal): void {
+ this._terminal = terminal;
+
+ // Create overlay container
+ this._overlay = document.createElement('div');
+ this._overlay.style.cssText =
+ `position:absolute;z-index:${this._options.zIndex};pointer-events:none;display:none`;
+
+ // Insert into xterm DOM
+ const screen = terminal.element?.querySelector('.xterm-screen');
+ if (screen) {
+ screen.appendChild(this._overlay);
+ }
+
+ // Cache font properties
+ this._cacheFont();
+
+ // Scroll detection: hide overlay when scrolled away from bottom
+ this._scrollHandler = () => {
+ try {
+ const buf = this._terminal!.buffer.active;
+ if (buf.viewportY !== buf.baseY) {
+ this._overlay!.style.display = 'none';
+ if (this._scrollTimer) {
+ clearTimeout(this._scrollTimer);
+ this._scrollTimer = null;
+ }
+ } else if (this._pendingText || this._flushedOffset > 0) {
+ if (this._scrollTimer) clearTimeout(this._scrollTimer);
+ this._scrollTimer = setTimeout(() => {
+ this._scrollTimer = null;
+ this._lastRenderKey = '';
+ this._render();
+ }, this._options.scrollDebounceMs);
+ }
+ } catch { /* ignore */ }
+ };
+
+ const viewport = terminal.element?.querySelector('.xterm-viewport');
+ if (viewport) {
+ viewport.addEventListener('scroll', this._scrollHandler, { passive: true });
+ this._scrollViewport = viewport;
+ }
+ }
+
+ /**
+ * Remove the overlay, clean up listeners.
+ */
+ dispose(): void {
+ this.clear();
+ if (this._scrollTimer) {
+ clearTimeout(this._scrollTimer);
+ this._scrollTimer = null;
+ }
+ if (this._scrollViewport && this._scrollHandler) {
+ this._scrollViewport.removeEventListener('scroll', this._scrollHandler);
+ }
+ this._overlay?.remove();
+ this._overlay = null;
+ this._scrollViewport = null;
+ this._scrollHandler = null;
+ this._terminal = null;
+ }
+
+ // ─── Input methods ────────────────────────────────────────────────
+
+ /**
+ * Add a single printable character to the overlay.
+ * Call this when the user types a character (charCode >= 32, length === 1).
+ */
+ addChar(char: string): void {
+ if (!this._pendingText && !this._flushedOffset) this._detectBufferText();
+ this._pendingText += char;
+ this._render();
+ }
+
+ /**
+ * Append multiple characters at once (e.g., paste).
+ */
+ appendText(text: string): void {
+ if (!text) return;
+ if (!this._pendingText && !this._flushedOffset) this._detectBufferText();
+ this._pendingText += text;
+ this._render();
+ }
+
+ /**
+ * Remove the last character from the overlay.
+ *
+ * Cascade order:
+ * 1. Remove from `pendingText` if non-empty
+ * 2. Decrement `flushedOffset` if pending is empty but flushed exists
+ * 3. Try `detectBufferText()` if both are empty
+ *
+ * @returns `true` if a character was removed, `false` if nothing to remove.
+ * When `false`, the consumer should NOT send backspace to the PTY.
+ */
+ removeChar(): boolean {
+ if (this._pendingText.length > 0) {
+ this._pendingText = this._pendingText.slice(0, -1);
+ if (this._pendingText.length > 0 || this._flushedOffset > 0) {
+ this._render();
+ } else {
+ this._hide();
+ }
+ return true;
+ }
+
+ if (this._flushedOffset > 0) {
+ this._flushedOffset--;
+ this._flushedText = this._flushedText.slice(0, -1);
+ if (this._flushedOffset > 0) {
+ this._render();
+ } else {
+ this._hide();
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Clear all overlay state (pending + flushed). Hides the overlay.
+ * Call on Enter, Ctrl+C, or any action that submits/cancels input.
+ */
+ clear(): void {
+ this._pendingText = '';
+ this._flushedOffset = 0;
+ this._flushedText = '';
+ this._bufferDetectDone = false;
+ this._lastRenderKey = '';
+ this._lastPromptPos = null;
+ this._hide();
+ }
+
+ // ─── Flushed text tracking ────────────────────────────────────────
+
+ /**
+ * Mark characters as "flushed" — sent to PTY but echo not yet received.
+ *
+ * The overlay renders flushed text (from the stored string) with an opaque
+ * background to cover the terminal's canvas text, preventing a visible
+ * font mismatch between canvas and DOM rendering.
+ *
+ * @param count - Number of characters flushed
+ * @param text - The actual flushed text (avoids reading stale terminal buffer)
+ */
+ setFlushed(count: number, text: string): void {
+ this._flushedOffset = count;
+ this._flushedText = text;
+ this._render();
+ }
+
+ /**
+ * Get current flushed state.
+ */
+ getFlushed(): { count: number; text: string } {
+ return { count: this._flushedOffset, text: this._flushedText };
+ }
+
+ /**
+ * Clear flushed state. Call when server echo has arrived and the terminal
+ * buffer now contains the flushed text.
+ */
+ clearFlushed(): void {
+ this._flushedOffset = 0;
+ this._flushedText = '';
+ if (this._pendingText) {
+ this._render();
+ } else {
+ this._hide();
+ }
+ }
+
+ // ─── Rendering control ────────────────────────────────────────────
+
+ /**
+ * Force a re-render of the overlay at the current prompt position.
+ * Call after terminal resets, buffer reloads, or full-screen redraws
+ * that move the prompt.
+ */
+ rerender(): void {
+ if (this._pendingText || this._flushedOffset > 0) {
+ this._lastRenderKey = '';
+ this._render();
+ }
+ }
+
+ /**
+ * Re-read font properties from the terminal and re-render.
+ * Call after font size changes, theme changes, etc.
+ */
+ refreshFont(): void {
+ this._cacheFont();
+ this._lastRenderKey = '';
+ if (this._pendingText || this._flushedOffset > 0) this._render();
+ }
+
+ // ─── Buffer detection ─────────────────────────────────────────────
+
+ /**
+ * Scan the terminal buffer for text after the prompt marker.
+ * If found, sets it as flushed text in the overlay.
+ *
+ * Use case: Tab completion filled text on the prompt that the overlay
+ * doesn't know about. Call this to sync overlay state with the buffer.
+ *
+ * @returns The detected text, or `null` if no prompt or no text found.
+ */
+ detectBufferText(): string | null {
+ return this._detectBufferText();
+ }
+
+ /**
+ * Reset the buffer detection guard. After `clear()`, detection is
+ * automatically re-enabled. Call this manually if you need to force
+ * re-detection (e.g., after a tab completion response arrives).
+ */
+ resetBufferDetection(): void {
+ this._bufferDetectDone = false;
+ }
+
+ // ─── Prompt utilities ─────────────────────────────────────────────
+
+ /**
+ * Find the prompt in the terminal buffer using the configured strategy.
+ * @returns The position or `null` if not found.
+ */
+ findPrompt(): PromptPosition | null {
+ if (!this._terminal) return null;
+ return findPrompt(this._terminal, this._options.prompt ?? DEFAULT_PROMPT);
+ }
+
+ /**
+ * Read text after the prompt marker on the prompt line.
+ * Convenience method for consumers that need to snapshot prompt content.
+ */
+ readPromptText(): string | null {
+ if (!this._terminal) return null;
+ const prompt = this.findPrompt();
+ if (!prompt) return null;
+ const offset = this._getPromptOffset();
+ const text = readTextAfterPrompt(this._terminal, prompt, offset);
+ return text || null;
+ }
+
+ // ─── Public state ─────────────────────────────────────────────────
+
+ /** Current pending (unacknowledged) text. */
+ get pendingText(): string {
+ return this._pendingText;
+ }
+
+ /** Whether there is any overlay content (pending or flushed). */
+ get hasPending(): boolean {
+ return this._pendingText.length > 0 || this._flushedOffset > 0;
+ }
+
+ /** Read-only state snapshot. */
+ get state(): ZerolagInputState {
+ return {
+ pendingText: this._pendingText,
+ flushedLength: this._flushedOffset,
+ flushedText: this._flushedText,
+ visible: this._overlay?.style.display !== 'none',
+ promptPosition: this._lastPromptPos ? { ...this._lastPromptPos } : null,
+ };
+ }
+
+ // ─── Private methods ──────────────────────────────────────────────
+
+ private _getPromptOffset(): number {
+ const prompt = this._options.prompt ?? DEFAULT_PROMPT;
+ return prompt.offset ?? 2;
+ }
+
+ private _detectBufferText(): string | null {
+ if (this._bufferDetectDone) return null;
+ if (!this._terminal) return null;
+
+ try {
+ const prompt = this.findPrompt();
+ if (!prompt) return null;
+
+ const offset = this._getPromptOffset();
+ const afterPrompt = readTextAfterPrompt(this._terminal, prompt, offset);
+
+ if (afterPrompt.length > 0) {
+ this._flushedOffset = afterPrompt.length;
+ this._flushedText = afterPrompt;
+ this._lastPromptPos = prompt;
+ this._bufferDetectDone = true;
+ return afterPrompt;
+ }
+ } catch { /* ignore */ }
+
+ return null;
+ }
+
+ private _cacheFont(): void {
+ if (!this._terminal) return;
+
+ const t = this._terminal;
+ this._font.fontFamily = t.options.fontFamily || 'monospace';
+ this._font.fontSize = (t.options.fontSize || 14) + 'px';
+ this._font.fontWeight = String(t.options.fontWeight || 'normal');
+ this._font.backgroundColor =
+ this._options.backgroundColor ??
+ t.options.theme?.background ??
+ DEFAULT_BG;
+ this._font.color =
+ this._options.foregroundColor ??
+ t.options.theme?.foreground ??
+ DEFAULT_FG;
+ this._font.letterSpacing = '';
+
+ // Prefer computed styles from rendered rows (matches actual rendering)
+ const rows = t.element?.querySelector('.xterm-rows');
+ if (rows) {
+ const cs = getComputedStyle(rows);
+ this._font.letterSpacing = cs.letterSpacing;
+ if (!this._options.foregroundColor && cs.color) {
+ this._font.color = cs.color;
+ }
+ }
+ }
+
+ private _hide(): void {
+ if (!this._overlay) return;
+ this._lastRenderKey = '';
+ this._lastPromptPos = null;
+ this._overlay.innerHTML = '';
+ this._overlay.style.display = 'none';
+ }
+
+ private _render(): void {
+ if (!this._terminal || !this._overlay) return;
+ if (!this._pendingText && !(this._flushedOffset > 0)) {
+ this._overlay.style.display = 'none';
+ return;
+ }
+
+ try {
+ const buf = this._terminal.buffer.active;
+
+ // Hide overlay when scrolled up — prompt is at bottom, not in viewport
+ if (buf.viewportY !== buf.baseY) {
+ this._overlay.style.display = 'none';
+ return;
+ }
+
+ // Re-scan for prompt on every render (full-screen redraws can move it)
+ const prompt = this.findPrompt();
+ if (prompt) {
+ // When flushed text exists, lock column to prevent jitter from
+ // redraws that temporarily shift the prompt marker. Allow row changes.
+ if (this._lastPromptPos && this._flushedOffset > 0) {
+ this._lastPromptPos = { row: prompt.row, col: this._lastPromptPos.col };
+ } else {
+ this._lastPromptPos = prompt;
+ }
+ } else if (!this._lastPromptPos) {
+ this._overlay.style.display = 'none';
+ return;
+ }
+ const activePrompt = this._lastPromptPos!;
+
+ const dims = getCellDimensions(this._terminal);
+ if (!dims) {
+ this._overlay.style.display = 'none';
+ return;
+ }
+
+ const { width: cellW, height: cellH } = dims;
+ const totalCols = this._terminal.cols;
+ const offset = this._getPromptOffset();
+ const startCol = activePrompt.col + offset;
+
+ // Build display text: flushed chars + pending chars
+ let displayText = this._pendingText;
+ if (this._flushedOffset > 0) {
+ if (this._flushedText && this._flushedText.length === this._flushedOffset) {
+ displayText = this._flushedText + this._pendingText;
+ } else {
+ // Fallback: read flushed chars from terminal buffer
+ const absRow = buf.viewportY + activePrompt.row;
+ const line = buf.getLine(absRow);
+ if (line) {
+ const lineText = line.translateToString(true);
+ const flushedChars = lineText.slice(startCol, startCol + this._flushedOffset);
+ displayText = flushedChars + this._pendingText;
+ }
+ }
+ }
+
+ // Skip redundant re-renders
+ const renderKey = `${displayText.length}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`;
+ if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
+ this._lastRenderKey = renderKey;
+
+ // Split text into visual lines matching terminal character-wrap
+ const firstLineCols = Math.max(1, totalCols - startCol);
+ const lines: string[] = [];
+ let remaining = displayText;
+ lines.push(remaining.slice(0, firstLineCols));
+ remaining = remaining.slice(firstLineCols);
+ while (remaining.length > 0) {
+ lines.push(remaining.slice(0, totalCols));
+ remaining = remaining.slice(totalCols);
+ }
+
+ const cursorColor =
+ this._options.cursorColor ??
+ this._terminal.options.theme?.cursor ??
+ DEFAULT_CURSOR;
+
+ renderOverlay(this._overlay, {
+ lines,
+ startCol,
+ totalCols,
+ cellW,
+ cellH,
+ promptRow: activePrompt.row,
+ font: this._font,
+ showCursor: this._options.showCursor,
+ cursorColor,
+ });
+ } catch {
+ // Hide on render error but preserve pendingText —
+ // next rerender() will retry when terminal is ready.
+ if (this._overlay) {
+ this._overlay.innerHTML = '';
+ this._overlay.style.display = 'none';
+ }
+ }
+ }
+}
diff --git a/packages/xterm-zerolag-input/test/helpers.ts b/packages/xterm-zerolag-input/test/helpers.ts
new file mode 100644
index 00000000..9fd3c0c8
--- /dev/null
+++ b/packages/xterm-zerolag-input/test/helpers.ts
@@ -0,0 +1,121 @@
+/**
+ * Mock terminal factory for unit tests.
+ *
+ * Creates a minimal Terminal-like object that satisfies the addon's
+ * requirements without needing a real xterm.js instance or DOM renderer.
+ */
+
+interface MockLine {
+ translateToString(_trimRight?: boolean): string;
+}
+
+interface MockBufferOptions {
+ lines: string[];
+ viewportY?: number;
+ baseY?: number;
+ cursorX?: number;
+ cursorY?: number;
+}
+
+interface MockTerminalOptions {
+ buffer?: MockBufferOptions;
+ cols?: number;
+ rows?: number;
+ fontFamily?: string;
+ fontSize?: number;
+ fontWeight?: string | number;
+ theme?: {
+ background?: string;
+ foreground?: string;
+ cursor?: string;
+ };
+ cellWidth?: number;
+ cellHeight?: number;
+}
+
+export function createMockTerminal(opts: MockTerminalOptions = {}) {
+ const bufOpts = opts.buffer ?? { lines: ['$ '] };
+ const lines = bufOpts.lines;
+ const viewportY = bufOpts.viewportY ?? 0;
+ const baseY = bufOpts.baseY ?? viewportY;
+ const cols = opts.cols ?? 80;
+ const rows = opts.rows ?? Math.max(lines.length, 24);
+ const cellW = opts.cellWidth ?? 8.4;
+ const cellH = opts.cellHeight ?? 17;
+
+ const mockLines: MockLine[] = lines.map((text) => ({
+ translateToString: () => text,
+ }));
+
+ // Create minimal DOM structure
+ const element = document.createElement('div');
+ element.className = 'terminal xterm';
+
+ const viewport = document.createElement('div');
+ viewport.className = 'xterm-viewport';
+
+ const screen = document.createElement('div');
+ screen.className = 'xterm-screen';
+ screen.style.position = 'relative';
+
+ const xtermRows = document.createElement('div');
+ xtermRows.className = 'xterm-rows';
+
+ element.appendChild(viewport);
+ element.appendChild(screen);
+ screen.appendChild(xtermRows);
+
+ // Append to document so getComputedStyle works
+ document.body.appendChild(element);
+
+ const terminal = {
+ element,
+ cols,
+ rows,
+ options: {
+ fontFamily: opts.fontFamily ?? 'monospace',
+ fontSize: opts.fontSize ?? 14,
+ fontWeight: opts.fontWeight ?? 'normal',
+ theme: opts.theme ?? {},
+ },
+ buffer: {
+ active: {
+ viewportY,
+ baseY,
+ cursorX: bufOpts.cursorX ?? 0,
+ cursorY: bufOpts.cursorY ?? 0,
+ getLine: (absRow: number): MockLine | undefined => {
+ return mockLines[absRow - viewportY];
+ },
+ },
+ },
+ _core: {
+ _renderService: {
+ dimensions: {
+ css: {
+ cell: { width: cellW, height: cellH },
+ },
+ },
+ },
+ },
+ // Simulate loadAddon
+ loadAddon(addon: { activate: (t: unknown) => void }) {
+ addon.activate(this);
+ },
+ };
+
+ return {
+ terminal,
+ /** Update buffer lines for subsequent calls */
+ setLines(newLines: string[]) {
+ mockLines.length = 0;
+ for (const text of newLines) {
+ mockLines.push({ translateToString: () => text });
+ }
+ },
+ /** Clean up DOM */
+ cleanup() {
+ element.remove();
+ },
+ };
+}
diff --git a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts
new file mode 100644
index 00000000..0944afbc
--- /dev/null
+++ b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts
@@ -0,0 +1,169 @@
+import { describe, it, expect } from 'vitest';
+import { renderOverlay } from '../src/overlay-renderer.js';
+import type { RenderParams, FontStyle } from '../src/types.js';
+
+const FONT: FontStyle = {
+ fontFamily: 'monospace',
+ fontSize: '14px',
+ fontWeight: 'normal',
+ color: '#eeeeee',
+ backgroundColor: '#0d0d0d',
+ letterSpacing: '',
+};
+
+function makeParams(overrides: Partial = {}): RenderParams {
+ return {
+ lines: ['hello'],
+ startCol: 2,
+ totalCols: 80,
+ cellW: 8.4,
+ cellH: 17,
+ promptRow: 10,
+ font: FONT,
+ showCursor: true,
+ cursorColor: '#e0e0e0',
+ ...overrides,
+ };
+}
+
+describe('renderOverlay', () => {
+ it('positions container at prompt row', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ promptRow: 5 }));
+ expect(container.style.top).toBe((5 * 17) + 'px');
+ expect(container.style.left).toBe('0px');
+ });
+
+ it('creates per-character spans in a line div', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['abc'] }));
+
+ // Line div + cursor span
+ expect(container.children.length).toBe(2);
+
+ const lineDiv = container.children[0] as HTMLDivElement;
+ expect(lineDiv.children.length).toBe(3); // a, b, c
+
+ const spanA = lineDiv.children[0] as HTMLSpanElement;
+ expect(spanA.textContent).toBe('a');
+ expect(spanA.style.left).toBe('0px');
+
+ const spanB = lineDiv.children[1] as HTMLSpanElement;
+ expect(spanB.textContent).toBe('b');
+ expect(spanB.style.left).toBe('8.4px');
+
+ const spanC = lineDiv.children[2] as HTMLSpanElement;
+ expect(spanC.textContent).toBe('c');
+ expect(spanC.style.left).toBe('16.8px');
+ });
+
+ it('sets span width to cellW', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['x'], cellW: 9.5 }));
+ const lineDiv = container.children[0] as HTMLDivElement;
+ const span = lineDiv.children[0] as HTMLSpanElement;
+ expect(span.style.width).toBe('9.5px');
+ });
+
+ it('applies font styles to spans', () => {
+ const font: FontStyle = {
+ fontFamily: 'Fira Code',
+ fontSize: '16px',
+ fontWeight: 'bold',
+ color: '#ff0000',
+ backgroundColor: '#000000',
+ letterSpacing: '0.5px',
+ };
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['A'], font }));
+
+ const lineDiv = container.children[0] as HTMLDivElement;
+ // jsdom normalizes hex to rgb()
+ expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
+
+ const span = lineDiv.children[0] as HTMLSpanElement;
+ expect(span.style.fontFamily).toBe('Fira Code');
+ expect(span.style.fontSize).toBe('16px');
+ expect(span.style.fontWeight).toBe('bold');
+ expect(span.style.color).toBe('rgb(255, 0, 0)');
+ expect(span.style.letterSpacing).toBe('0.5px');
+ });
+
+ it('offsets first line by startCol', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['hi'], startCol: 5, cellW: 10 }));
+ const lineDiv = container.children[0] as HTMLDivElement;
+ // First line left = startCol * cellW
+ expect(lineDiv.style.left).toBe('50px');
+ });
+
+ it('renders cursor at end of text', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({
+ lines: ['ab'],
+ startCol: 3,
+ cellW: 10,
+ cellH: 20,
+ showCursor: true,
+ cursorColor: '#ff00ff',
+ }));
+
+ // Last child is cursor (after line div)
+ const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
+ // cursorCol = startCol(3) + text.length(2) = 5
+ expect(cursor.style.left).toBe('50px');
+ expect(cursor.style.width).toBe('10px');
+ expect(cursor.style.height).toBe('20px');
+ // jsdom normalizes hex to rgb()
+ expect(cursor.style.backgroundColor).toBe('rgb(255, 0, 255)');
+ });
+
+ it('does not render cursor when showCursor is false', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
+ // Only line div, no cursor
+ expect(container.children.length).toBe(1);
+ });
+
+ it('renders multi-line text', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({
+ lines: ['first', 'second'],
+ startCol: 5,
+ cellW: 10,
+ cellH: 20,
+ }));
+
+ // 2 line divs + cursor
+ expect(container.children.length).toBe(3);
+
+ const line1 = container.children[0] as HTMLDivElement;
+ expect(line1.style.left).toBe('50px'); // startCol * cellW
+ expect(line1.style.top).toBe('0px');
+ expect(line1.children.length).toBe(5); // 'first'
+
+ const line2 = container.children[1] as HTMLDivElement;
+ expect(line2.style.left).toBe('0px'); // wrapped lines start at col 0
+ expect(line2.style.top).toBe('20px'); // second row
+ expect(line2.children.length).toBe(6); // 'second'
+ });
+
+ it('clears previous content on re-render', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['abc'] }));
+ expect(container.children.length).toBe(2); // line + cursor
+
+ renderOverlay(container, makeParams({ lines: ['xy'] }));
+ expect(container.children.length).toBe(2); // line + cursor (rebuilt)
+
+ const lineDiv = container.children[0] as HTMLDivElement;
+ expect(lineDiv.children.length).toBe(2); // x, y
+ });
+
+ it('shows container (display not none)', () => {
+ const container = document.createElement('div');
+ container.style.display = 'none';
+ renderOverlay(container, makeParams());
+ expect(container.style.display).toBe('');
+ });
+});
diff --git a/packages/xterm-zerolag-input/test/prompt-finder.test.ts b/packages/xterm-zerolag-input/test/prompt-finder.test.ts
new file mode 100644
index 00000000..08ea8133
--- /dev/null
+++ b/packages/xterm-zerolag-input/test/prompt-finder.test.ts
@@ -0,0 +1,150 @@
+import { describe, it, expect } from 'vitest';
+import { createMockTerminal } from './helpers.js';
+import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
+import type { XtermTerminal, PromptFinder } from '../src/types.js';
+
+function term(lines: string[]) {
+ return createMockTerminal({ buffer: { lines } });
+}
+
+describe('findPrompt', () => {
+ describe('character strategy', () => {
+ it('finds $ prompt at column 0', () => {
+ const { terminal, cleanup } = term(['output line', '$ ls -la']);
+ const finder: PromptFinder = { type: 'character', char: '$' };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).toEqual({ row: 1, col: 0 });
+ cleanup();
+ });
+
+ it('finds > prompt', () => {
+ const { terminal, cleanup } = term(['> hello']);
+ const finder: PromptFinder = { type: 'character', char: '>' };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).toEqual({ row: 0, col: 0 });
+ cleanup();
+ });
+
+ it('finds prompt with prefix (user@host)', () => {
+ const { terminal, cleanup } = term(['user@host:~$ command']);
+ const finder: PromptFinder = { type: 'character', char: '$' };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).toEqual({ row: 0, col: 11 });
+ cleanup();
+ });
+
+ it('scans bottom-up and returns lowest match', () => {
+ const { terminal, cleanup } = term([
+ '$ old prompt',
+ 'output',
+ '$ current prompt',
+ ]);
+ const finder: PromptFinder = { type: 'character', char: '$' };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).toEqual({ row: 2, col: 0 });
+ cleanup();
+ });
+
+ it('returns null when no prompt found', () => {
+ const { terminal, cleanup } = term(['no prompt here', 'or here']);
+ const finder: PromptFinder = { type: 'character', char: '$' };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).toBeNull();
+ cleanup();
+ });
+
+ it('finds Unicode prompt character', () => {
+ const { terminal, cleanup } = term(['\u276f hello']);
+ const finder: PromptFinder = { type: 'character', char: '\u276f' };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).toEqual({ row: 0, col: 0 });
+ cleanup();
+ });
+ });
+
+ describe('regex strategy', () => {
+ it('finds regex prompt', () => {
+ const { terminal, cleanup } = term(['user@host:~/dir$ ls']);
+ const finder: PromptFinder = { type: 'regex', pattern: /\$/ };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).not.toBeNull();
+ expect(pos!.col).toBe(15);
+ cleanup();
+ });
+
+ it('matches complex PS1 patterns', () => {
+ const { terminal, cleanup } = term(['(venv) user % cmd']);
+ const finder: PromptFinder = { type: 'regex', pattern: /%/ };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).not.toBeNull();
+ expect(pos!.col).toBe(12);
+ cleanup();
+ });
+
+ it('returns null on no match', () => {
+ const { terminal, cleanup } = term(['just output']);
+ const finder: PromptFinder = { type: 'regex', pattern: /\$\s*$/ };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).toBeNull();
+ cleanup();
+ });
+ });
+
+ describe('custom strategy', () => {
+ it('uses custom finder function', () => {
+ const { terminal, cleanup } = term(['anything']);
+ const finder: PromptFinder = {
+ type: 'custom',
+ find: () => ({ row: 5, col: 10 }),
+ };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).toEqual({ row: 5, col: 10 });
+ cleanup();
+ });
+
+ it('handles null from custom finder', () => {
+ const { terminal, cleanup } = term(['anything']);
+ const finder: PromptFinder = {
+ type: 'custom',
+ find: () => null,
+ };
+ const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
+ expect(pos).toBeNull();
+ cleanup();
+ });
+ });
+});
+
+describe('readTextAfterPrompt', () => {
+ it('reads text after prompt with offset', () => {
+ const { terminal, cleanup } = term(['$ hello world']);
+ const prompt = { row: 0, col: 0 };
+ const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
+ expect(text).toBe('hello world');
+ cleanup();
+ });
+
+ it('returns empty string for empty prompt line', () => {
+ const { terminal, cleanup } = term(['$ ']);
+ const prompt = { row: 0, col: 0 };
+ const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
+ expect(text).toBe('');
+ cleanup();
+ });
+
+ it('trims trailing whitespace', () => {
+ const { terminal, cleanup } = term(['$ hello ']);
+ const prompt = { row: 0, col: 0 };
+ const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
+ expect(text).toBe('hello');
+ cleanup();
+ });
+
+ it('handles offset for complex prompts', () => {
+ const { terminal, cleanup } = term(['user@host:~$ ls -la']);
+ const prompt = { row: 0, col: 11 };
+ const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
+ expect(text).toBe('ls -la');
+ cleanup();
+ });
+});
diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
new file mode 100644
index 00000000..43fb9a14
--- /dev/null
+++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
@@ -0,0 +1,297 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { createMockTerminal } from './helpers.js';
+import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
+import type { Terminal } from '../src/types.js';
+
+function setup(lines: string[] = ['$ '], promptChar = '$') {
+ const mock = createMockTerminal({ buffer: { lines } });
+ const addon = new ZerolagInputAddon({
+ prompt: { type: 'character', char: promptChar, offset: 2 },
+ });
+ mock.terminal.loadAddon(addon);
+ return { addon, mock };
+}
+
+let cleanups: (() => void)[] = [];
+
+afterEach(() => {
+ for (const fn of cleanups) fn();
+ cleanups = [];
+});
+
+function tracked(lines?: string[], promptChar?: string) {
+ const result = setup(lines, promptChar);
+ cleanups.push(() => {
+ result.addon.dispose();
+ result.mock.cleanup();
+ });
+ return result;
+}
+
+describe('ZerolagInputAddon', () => {
+ describe('lifecycle', () => {
+ it('creates overlay element in .xterm-screen', () => {
+ const { addon, mock } = tracked();
+ const screen = mock.terminal.element.querySelector('.xterm-screen');
+ expect(screen!.children.length).toBeGreaterThan(0);
+ const overlay = screen!.lastElementChild as HTMLDivElement;
+ expect(overlay.style.zIndex).toBe('7');
+ expect(overlay.style.display).toBe('none');
+ addon.dispose();
+ });
+
+ it('dispose removes overlay from DOM', () => {
+ const { addon, mock } = tracked();
+ const screen = mock.terminal.element.querySelector('.xterm-screen')!;
+ const before = screen.children.length;
+ addon.dispose();
+ expect(screen.children.length).toBe(before - 1);
+ });
+ });
+
+ describe('addChar / pendingText', () => {
+ it('adds characters to pendingText', () => {
+ const { addon } = tracked();
+ addon.addChar('a');
+ addon.addChar('b');
+ addon.addChar('c');
+ expect(addon.pendingText).toBe('abc');
+ });
+
+ it('hasPending is true when text exists', () => {
+ const { addon } = tracked();
+ expect(addon.hasPending).toBe(false);
+ addon.addChar('x');
+ expect(addon.hasPending).toBe(true);
+ });
+ });
+
+ describe('appendText', () => {
+ it('appends multiple characters (paste)', () => {
+ const { addon } = tracked();
+ addon.addChar('h');
+ addon.appendText('ello');
+ expect(addon.pendingText).toBe('hello');
+ });
+
+ it('ignores empty string', () => {
+ const { addon } = tracked();
+ addon.appendText('');
+ expect(addon.pendingText).toBe('');
+ expect(addon.hasPending).toBe(false);
+ });
+ });
+
+ describe('removeChar', () => {
+ it('removes last character from pendingText', () => {
+ const { addon } = tracked();
+ addon.addChar('a');
+ addon.addChar('b');
+ const removed = addon.removeChar();
+ expect(removed).toBe(true);
+ expect(addon.pendingText).toBe('a');
+ });
+
+ it('returns false when nothing to remove', () => {
+ const { addon } = tracked();
+ expect(addon.removeChar()).toBe(false);
+ });
+
+ it('decrements flushed when pending is empty', () => {
+ const { addon } = tracked();
+ addon.setFlushed(3, 'abc');
+ expect(addon.removeChar()).toBe(true);
+ expect(addon.getFlushed().count).toBe(2);
+ expect(addon.getFlushed().text).toBe('ab');
+ });
+
+ it('removes pending before flushed', () => {
+ const { addon } = tracked();
+ addon.setFlushed(2, 'ab');
+ addon.addChar('c');
+ expect(addon.removeChar()).toBe(true);
+ expect(addon.pendingText).toBe('');
+ expect(addon.getFlushed().count).toBe(2); // flushed unchanged
+ });
+
+ it('hides overlay when both pending and flushed become empty', () => {
+ const { addon } = tracked();
+ addon.addChar('x');
+ addon.removeChar();
+ expect(addon.hasPending).toBe(false);
+ });
+ });
+
+ describe('clear', () => {
+ it('resets all state', () => {
+ const { addon } = tracked();
+ addon.setFlushed(3, 'abc');
+ addon.addChar('d');
+ addon.clear();
+
+ expect(addon.pendingText).toBe('');
+ expect(addon.getFlushed().count).toBe(0);
+ expect(addon.getFlushed().text).toBe('');
+ expect(addon.hasPending).toBe(false);
+ });
+ });
+
+ describe('flushed text', () => {
+ it('setFlushed stores count and text', () => {
+ const { addon } = tracked();
+ addon.setFlushed(5, 'hello');
+ expect(addon.getFlushed()).toEqual({ count: 5, text: 'hello' });
+ expect(addon.hasPending).toBe(true);
+ });
+
+ it('clearFlushed resets flushed state', () => {
+ const { addon } = tracked();
+ addon.setFlushed(3, 'abc');
+ addon.clearFlushed();
+ expect(addon.getFlushed()).toEqual({ count: 0, text: '' });
+ });
+
+ it('clearFlushed preserves pending text', () => {
+ const { addon } = tracked();
+ addon.setFlushed(3, 'abc');
+ addon.addChar('d');
+ addon.clearFlushed();
+ expect(addon.pendingText).toBe('d');
+ expect(addon.hasPending).toBe(true);
+ });
+ });
+
+ describe('state snapshot', () => {
+ it('returns current state', () => {
+ const { addon } = tracked();
+ addon.setFlushed(2, 'hi');
+ addon.addChar('!');
+
+ const state = addon.state;
+ expect(state.pendingText).toBe('!');
+ expect(state.flushedLength).toBe(2);
+ expect(state.flushedText).toBe('hi');
+ });
+
+ it('state is read-only copy', () => {
+ const { addon } = tracked();
+ addon.addChar('a');
+ const s1 = addon.state;
+ addon.addChar('b');
+ const s2 = addon.state;
+ expect(s1.pendingText).toBe('a');
+ expect(s2.pendingText).toBe('ab');
+ });
+ });
+
+ describe('prompt detection', () => {
+ it('findPrompt returns position for character prompt', () => {
+ const { addon } = tracked(['$ hello world']);
+ const pos = addon.findPrompt();
+ expect(pos).toEqual({ row: 0, col: 0 });
+ });
+
+ it('findPrompt returns null when no prompt', () => {
+ const { addon } = tracked(['no prompt here']);
+ const pos = addon.findPrompt();
+ expect(pos).toBeNull();
+ });
+
+ it('readPromptText reads text after prompt', () => {
+ const { addon } = tracked(['$ hello world']);
+ const text = addon.readPromptText();
+ expect(text).toBe('hello world');
+ });
+
+ it('readPromptText returns null when no prompt', () => {
+ const { addon } = tracked(['no prompt']);
+ const text = addon.readPromptText();
+ expect(text).toBeNull();
+ });
+ });
+
+ describe('buffer detection', () => {
+ it('detectBufferText picks up existing text after prompt', () => {
+ const { addon } = tracked(['$ existing text']);
+ const text = addon.detectBufferText();
+ expect(text).toBe('existing text');
+ expect(addon.getFlushed().count).toBe(13);
+ expect(addon.getFlushed().text).toBe('existing text');
+ });
+
+ it('detectBufferText returns null for empty prompt', () => {
+ const { addon } = tracked(['$ ']);
+ const text = addon.detectBufferText();
+ expect(text).toBeNull();
+ });
+
+ it('detectBufferText is guarded (only runs once)', () => {
+ const { addon } = tracked(['$ text']);
+ addon.detectBufferText();
+ addon.clearFlushed(); // clear what was detected
+
+ // Should not detect again (guard is set)
+ const text = addon.detectBufferText();
+ expect(text).toBeNull();
+ });
+
+ it('resetBufferDetection allows re-detection', () => {
+ const { addon } = tracked(['$ text']);
+ addon.detectBufferText();
+ addon.clearFlushed();
+ addon.resetBufferDetection();
+
+ const text = addon.detectBufferText();
+ expect(text).toBe('text');
+ });
+
+ it('clear resets buffer detection guard', () => {
+ const { addon } = tracked(['$ text']);
+ addon.detectBufferText();
+ addon.clear();
+
+ // After clear, detection should work again
+ const text = addon.detectBufferText();
+ expect(text).toBe('text');
+ });
+ });
+
+ describe('custom prompt configurations', () => {
+ it('works with > prompt character', () => {
+ const { addon } = tracked(['> hello'], '>');
+ const text = addon.readPromptText();
+ expect(text).toBe('hello');
+ });
+
+ it('works with Unicode prompt', () => {
+ const mock = createMockTerminal({ buffer: { lines: ['\u276f hello'] } });
+ const addon = new ZerolagInputAddon({
+ prompt: { type: 'character', char: '\u276f', offset: 2 },
+ });
+ mock.terminal.loadAddon(addon);
+ cleanups.push(() => { addon.dispose(); mock.cleanup(); });
+
+ const text = addon.readPromptText();
+ expect(text).toBe('hello');
+ });
+ });
+
+ describe('rerender / refreshFont', () => {
+ it('rerender does not crash when no text', () => {
+ const { addon } = tracked();
+ expect(() => addon.rerender()).not.toThrow();
+ });
+
+ it('refreshFont does not crash', () => {
+ const { addon } = tracked();
+ expect(() => addon.refreshFont()).not.toThrow();
+ });
+
+ it('rerender re-renders when hasPending', () => {
+ const { addon } = tracked();
+ addon.addChar('x');
+ expect(() => addon.rerender()).not.toThrow();
+ expect(addon.hasPending).toBe(true);
+ });
+ });
+});
diff --git a/packages/xterm-zerolag-input/tsconfig.json b/packages/xterm-zerolag-input/tsconfig.json
new file mode 100644
index 00000000..a4253f48
--- /dev/null
+++ b/packages/xterm-zerolag-input/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist", "test", "examples"]
+}
diff --git a/packages/xterm-zerolag-input/vitest.config.ts b/packages/xterm-zerolag-input/vitest.config.ts
new file mode 100644
index 00000000..9b17944f
--- /dev/null
+++ b/packages/xterm-zerolag-input/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ include: ['test/**/*.test.ts'],
+ },
+});