From a30524060a2336ba850146101a7b0e15ffb67771 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 04:25:24 +0200 Subject: [PATCH] feat(predictive-echo): PredictiveEchoAddon + Layers 1-3 test suites (0.2.0) Mosh-style write-through prediction: the consumer sends every keystroke unchanged; the addon paints predicted glyphs and reconciles against the parsed buffer. Confirm = cell match + cursor advance (placeholder-safe, repaint-safe); two-pass mismatch cascade with neutral blanks (measured: codex clears its placeholder on first echo); TTL bound; baseY-based line reads; scroll/resize/off-row clears. Zero edits to zerolag-input-addon.ts. Tests: 30 addon-law specs + renderer geometry (fake performance clock for TTL/grace), 6 replay suites running the real algorithm through a real @xterm/headless parser fed by the recorded codex fixtures, and a 500-iteration seeded fuzz with per-op span/record + grid invariants. 227 total, the pre-existing 175 untouched. Co-Authored-By: Claude Fable 5 --- packages/xterm-zerolag-input/CHANGELOG.md | 11 + packages/xterm-zerolag-input/README.md | 110 +++- packages/xterm-zerolag-input/package.json | 6 +- .../src/cell-dimensions.ts | 59 +- packages/xterm-zerolag-input/src/index.ts | 17 +- .../src/predictive-echo-addon.ts | 460 ++++++++++++++++ .../xterm-zerolag-input/src/prompt-finder.ts | 111 ++-- .../test/cell-dimensions.test.ts | 223 ++++---- .../test/codex-replay.test.ts | 172 ++++++ packages/xterm-zerolag-input/test/helpers.ts | 291 ++++++---- .../test/prediction-renderer.test.ts | 135 +++++ .../test/predictive-echo-addon.test.ts | 505 ++++++++++++++++++ .../test/predictive-echo-fuzz.test.ts | 125 +++++ .../test/prompt-finder.test.ts | 278 +++++----- .../xterm-zerolag-input/test/raw-imports.d.ts | 5 + .../test/replay-helpers.ts | 171 ++++++ packages/xterm-zerolag-input/tsup.config.ts | 7 + 17 files changed, 2227 insertions(+), 459 deletions(-) create mode 100644 packages/xterm-zerolag-input/src/predictive-echo-addon.ts create mode 100644 packages/xterm-zerolag-input/test/codex-replay.test.ts create mode 100644 packages/xterm-zerolag-input/test/prediction-renderer.test.ts create mode 100644 packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts create mode 100644 packages/xterm-zerolag-input/test/predictive-echo-fuzz.test.ts create mode 100644 packages/xterm-zerolag-input/test/raw-imports.d.ts create mode 100644 packages/xterm-zerolag-input/test/replay-helpers.ts diff --git a/packages/xterm-zerolag-input/CHANGELOG.md b/packages/xterm-zerolag-input/CHANGELOG.md index c7e4b923..bd2f51d9 100644 --- a/packages/xterm-zerolag-input/CHANGELOG.md +++ b/packages/xterm-zerolag-input/CHANGELOG.md @@ -1,5 +1,16 @@ # xterm-zerolag-input +## 0.2.0 + +### Minor Changes + +- **New addon: `PredictiveEchoAddon`, mosh-style write-through prediction.** The second echo mode for per-keystroke TUIs (OpenAI Codex's composer, live pickers) that buffer-until-Enter starves. Every keystroke is sent by the consumer immediately and unchanged; the addon paints the predicted glyph at the predicted cell and reconciles against the PARSED terminal buffer: confirmation requires the cell match plus a cursor advance past the record, foreign non-blank content on two consecutive passes cascades a drop, blank cells are neutral, a TTL bounds everything, and scroll/resize/sustained cursor moves clear the run. Visual-only by construction; it cannot gate, delay or rewrite input. + - New exports: `PredictiveEchoAddon`, `PredictiveEchoOptions`, `PredictionState`, plus the long-intended `charCellWidth` / `stringCellWidth` helpers. + - `XtermTerminal` type gains OPTIONAL members (`buffer.active.cursorX/cursorY`, `getLine().getCell?`, `onWriteParsed?`, `onResize?`). Additive only: existing consumers and mocks are unaffected. + - IIFE build exposes `window.PredictiveEchoAddon` and a self-activating `window.PredictiveEchoOverlay`, alongside the unchanged `ZerolagInputAddon` / `LocalEchoOverlay` globals. + - Tests: 52 new (30 addon-law specs, renderer geometry, 6 replay suites driven by fixtures recorded from real codex 0.147 through tmux + the production strip, and a 500-iteration seeded fuzz with per-op invariants). `@xterm/headless` as a devDependency; runtime dependencies remain zero. + + ## 0.1.8 ### Patch Changes diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index 8b3b5842..af8f3c2c 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -9,7 +9,7 @@ MIT Zero dependencies 6.1 kB gzipped - 175 tests + 175 tests xterm.js v5 and v7+

@@ -46,6 +46,15 @@ Same keystroke, same link. The only difference is who you wait for: the server, **No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire. +Since 0.2.0 the package ships **two addons for two kinds of TUIs**: + +| Addon | Model | Use when | +| --------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `ZerolagInputAddon` | **Buffer**: hold keystrokes locally, flush on Enter | The remote side is a line-oriented prompt (shells, REPLs, Claude Code's composer) that only needs the finished line | +| `PredictiveEchoAddon` | **Predictive write-through**: send every keystroke immediately, paint a prediction, confirm against the parsed buffer | The remote side is a per-keystroke TUI (OpenAI Codex's composer, live pickers) that buffering would starve | + +`ZerolagInputAddon` is documented below; jump to [PredictiveEchoAddon](#predictiveechoaddon-write-through-prediction) for the second mode. + ## Why this one | | | @@ -268,6 +277,105 @@ Finds text that exists after the prompt but was never typed through the overlay. --- +## `PredictiveEchoAddon` (write-through prediction) + +Buffering is the wrong model for TUIs that react to every keystroke: a slash +command picker filters live, arrows edit server-side state, the composer +rewraps as it grows. For those, `PredictiveEchoAddon` works like +[mosh](https://mosh.org/): the keystroke goes to the PTY **immediately and +unchanged**, and the addon simultaneously paints the predicted glyph at the +predicted cell. When the real echo lands, the prediction is confirmed and its +span removed: an invisible swap, identical glyph beneath. Mispredictions +self-heal via a mismatch cascade and a TTL. It is visual-only by construction: +nothing it does can gate, delay, reorder or rewrite what you send. + +```typescript +import { Terminal } from '@xterm/xterm'; +import { PredictiveEchoAddon } from 'xterm-zerolag-input'; + +const terminal = new Terminal(); +const predictor = new PredictiveEchoAddon({ + // Optional: only predict when the cursor sits on a composer row + predictWhen: (t) => { + const buf = t.buffer.active; + const line = buf.getLine(buf.baseY + buf.cursorY); + return !!line && /^› /.test(line.translateToString(true)); + }, +}); +terminal.loadAddon(predictor); + +terminal.onData((data) => { + const cps = Array.from(data); + if (cps.length === 1) { + const cp = cps[0].codePointAt(0); + if (cp === 0x7f) predictor.predictBackspace(); + else if (cp >= 0x20) predictor.predictChar(data); + else predictor.clearPredictions(); // Enter, Ctrl+C, ... + } else if (data.charCodeAt(0) === 0x1b) { + predictor.clearPredictions(); // nav keys, bracketed paste + } + pty.write(data); // ALWAYS, unconditionally +}); +``` + +### How reconciliation works + +Predictions are reconciled against the **parsed terminal buffer** (cells after +xterm's parser ran), never the raw output stream. That distinction is +load-bearing: TUIs redraw whole lines, paint gaps with `ECH` + cursor-forward +instead of spaces, and multiplexers like tmux rewrite everything into minimal +deltas. Stream matching breaks on all of that; buffer cells converge to the +same values no matter how the bytes arrived. + +A prediction is **confirmed** only when its cell shows the predicted glyph AND +the cursor has advanced past it (so a placeholder that happens to match, or an +identical in-place repaint, never false-confirms). A cell showing foreign +non-blank content on two consecutive passes drops that prediction and all +later ones (one pass tolerates half-parsed frames). Blank cells are neutral: +they are what "not yet echoed" looks like. Whatever remains is dropped by TTL. +Scrolling up, resizing, or a sustained cursor move clears the run. + +### API + +```typescript +predictChar(ch: string): boolean; // false = suppressed (still SEND the key) +predictBackspace(): boolean; // pops the newest prediction (still send \x7f) +clearPredictions(): void; +reconcile(): void; // manual pass (no onWriteParsed available) +setPredictWhen(fn | null): void; // swap the gate at runtime +refreshFont(): void; // after font/theme changes +get hasPredictions(): boolean; +get state(): PredictionState; // { outstanding, confirmedTotal, droppedTotal, anchor } +``` + +### Options + +```typescript +{ + zIndex?: number, // Default: 7 + underlinePredictions?: boolean, // Default: false (underline unconfirmed glyphs) + foregroundColor?: string, // Default: terminal theme / computed .xterm-rows style + backgroundColor?: string, // Default: terminal theme background + ttlMs?: number, // Default: 1000 + maxPending?: number, // Default: 32 + cursorGraceMs?: number, // Default: 150 + edgeMarginCells?: number, // Default: 4 (suppress near the right edge) + predictWhen?: (t) => boolean, // Default: predict everywhere +} +``` + +### Which addon should I use? + +- The remote program shows a **line prompt** and ignores partial input: + `ZerolagInputAddon`. You also get backspace-before-send and batching. +- The remote program **reacts per keystroke** (pickers, filters, composers + that rewrap): `PredictiveEchoAddon`. It never withholds bytes, so the TUI + behaves exactly as with no addon at all; you just stop waiting for the RTT. +- Both can be loaded on one terminal and toggled per session mode; that is + exactly what Codeman does (buffer for Claude Code, predict for Codex). + +--- + ## Integration patterns ### Buffered input (hold until Enter) diff --git a/packages/xterm-zerolag-input/package.json b/packages/xterm-zerolag-input/package.json index 584309bb..e9f67f0a 100644 --- a/packages/xterm-zerolag-input/package.json +++ b/packages/xterm-zerolag-input/package.json @@ -1,6 +1,6 @@ { "name": "xterm-zerolag-input", - "version": "0.1.8", + "version": "0.2.0", "description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections", "type": "module", "main": "dist/index.cjs", @@ -37,7 +37,9 @@ "ssh", "remote-terminal", "overlay", - "addon" + "addon", + "predictive", + "write-through" ], "license": "MIT", "homepage": "https://github.com/Ark0N/Codeman/tree/master/packages/xterm-zerolag-input#readme", diff --git a/packages/xterm-zerolag-input/src/cell-dimensions.ts b/packages/xterm-zerolag-input/src/cell-dimensions.ts index 6410dab2..bf4c17d8 100644 --- a/packages/xterm-zerolag-input/src/cell-dimensions.ts +++ b/packages/xterm-zerolag-input/src/cell-dimensions.ts @@ -11,37 +11,36 @@ import type { XtermTerminal, CellDimensions } from './types.js'; * unavailable. */ export function getCellDimensions(terminal: XtermTerminal): CellDimensions | null { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const t = terminal as any; - const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 - ? devicePixelRatio : 1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const t = terminal as any; + const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1; - // Try v7+ public API first - if (t.dimensions?.css?.cell) { - const cellH = t.dimensions.css.cell.height; - return { - width: t.dimensions.css.cell.width, - height: cellH, - charTop: (t.dimensions?.device?.char?.top ?? 0) / dpr, - charHeight: (t.dimensions?.device?.char?.height ?? (cellH * dpr)) / dpr, - }; + // Try v7+ public API first + if (t.dimensions?.css?.cell) { + const cellH = t.dimensions.css.cell.height; + return { + width: t.dimensions.css.cell.width, + height: cellH, + charTop: (t.dimensions?.device?.char?.top ?? 0) / dpr, + charHeight: (t.dimensions?.device?.char?.height ?? cellH * dpr) / dpr, + }; + } + + // Fall back to v5 private API + try { + const dims = t._core?._renderService?.dimensions; + if (dims?.css?.cell) { + const cellH = dims.css.cell.height; + return { + width: dims.css.cell.width, + height: cellH, + charTop: (dims.device?.char?.top ?? 0) / dpr, + charHeight: (dims.device?.char?.height ?? cellH * dpr) / dpr, + }; } + } catch { + // Private API may throw in some environments + } - // Fall back to v5 private API - try { - const dims = t._core?._renderService?.dimensions; - if (dims?.css?.cell) { - const cellH = dims.css.cell.height; - return { - width: dims.css.cell.width, - height: cellH, - charTop: (dims.device?.char?.top ?? 0) / dpr, - charHeight: (dims.device?.char?.height ?? (cellH * dpr)) / dpr, - }; - } - } catch { - // Private API may throw in some environments - } - - return null; + return null; } diff --git a/packages/xterm-zerolag-input/src/index.ts b/packages/xterm-zerolag-input/src/index.ts index c16416d9..f10d5265 100644 --- a/packages/xterm-zerolag-input/src/index.ts +++ b/packages/xterm-zerolag-input/src/index.ts @@ -1,10 +1,13 @@ export { ZerolagInputAddon } from './zerolag-input-addon.js'; +export { PredictiveEchoAddon } from './predictive-echo-addon.js'; +export { charCellWidth, stringCellWidth } from './overlay-renderer.js'; export type { - XtermTerminal, - XtermAddon, - ZerolagInputOptions, - ZerolagInputState, - PromptFinder, - PromptPosition, - CellDimensions, + XtermTerminal, + XtermAddon, + ZerolagInputOptions, + ZerolagInputState, + PromptFinder, + PromptPosition, + CellDimensions, } from './types.js'; +export type { PredictiveEchoOptions, PredictionState } from './predictive-echo-addon.js'; diff --git a/packages/xterm-zerolag-input/src/predictive-echo-addon.ts b/packages/xterm-zerolag-input/src/predictive-echo-addon.ts new file mode 100644 index 00000000..dee3005a --- /dev/null +++ b/packages/xterm-zerolag-input/src/predictive-echo-addon.ts @@ -0,0 +1,460 @@ +/** + * PredictiveEchoAddon: mosh-style write-through local echo. + * + * The consumer sends every keystroke to the PTY unchanged (write-through); + * this addon simultaneously paints the predicted glyph at the predicted cell. + * When the real echo lands, the prediction is confirmed and its span removed + * (an invisible swap: identical glyph beneath). Mispredictions self-heal via + * a mismatch cascade and a TTL. Everything here is visual-only: no method + * gates, delays, or rewrites what the consumer sends. + * + * Reconciliation reads the parsed terminal BUFFER (cells after xterm's parser + * ran), never the raw output stream. Full-line redraws, ECH-based gap + * painting, and tmux's in-place deltas all converge to the same cells; stream + * matching cannot survive them (see docs/local-echo-overlay-plan.md's + * "What NOT to Do" in the consuming repo). + * + * Coordinate base: xterm's `cursorY` is relative to `baseY`, so the absolute + * buffer line for a viewport row is `baseY + row`. `viewportY` would only + * coincide while scrolled to the bottom; this file never relies on that. + */ +import { getCellDimensions } from './cell-dimensions.js'; +import { charCellWidth } from './overlay-renderer.js'; +import { addPredictionSpan, clearAllSpans, removePredictionSpan } from './prediction-renderer.js'; +import type { FontStyle, XtermAddon, XtermTerminal } from './types.js'; + +export interface PredictiveEchoOptions { + /** Z-index of the span container. @default 7 (same layer as the buffer overlay) */ + zIndex?: number; + /** Render predicted glyphs underlined (visual hedge on unreliable links). @default false */ + underlinePredictions?: boolean; + /** Predicted glyph color. @default theme foreground / computed .xterm-rows color */ + foregroundColor?: string; + /** Predicted glyph background. @default theme background */ + backgroundColor?: string; + /** Drop predictions older than this. @default 1000 */ + ttlMs?: number; + /** Maximum outstanding predictions per run. @default 32 */ + maxPending?: number; + /** How long the cursor may sit off the anchor row before predictions clear. @default 150 */ + cursorGraceMs?: number; + /** Suppress predictions that would land within this many cells of the right edge. @default 4 */ + edgeMarginCells?: number; + /** Gate: return false to suppress prediction (e.g. cursor not on a composer row). */ + predictWhen?: (terminal: XtermTerminal) => boolean; +} + +export interface PredictionState { + outstanding: number; + confirmedTotal: number; + droppedTotal: number; + anchor: { row: number; col: number } | null; +} + +interface PredictionRecord { + seq: number; + char: string; + /** Cells this glyph occupies. */ + width: 1 | 2; + /** Cumulative cell offset from the anchor column BEFORE this char. */ + offsetCells: number; + /** Cell content at predict time, '' normalized to ' '. */ + snapshot: string; + sentAt: number; + /** Consecutive reconcile passes that saw foreign non-blank content. */ + mismatches: number; +} + +const DEFAULT_OPTIONS = { + zIndex: 7, + underlinePredictions: false, + ttlMs: 1000, + maxPending: 32, + cursorGraceMs: 150, + edgeMarginCells: 4, +} as const; + +const DEFAULT_BG = '#000000'; +const DEFAULT_FG = '#ffffff'; + +export class PredictiveEchoAddon implements XtermAddon { + private _terminal: XtermTerminal | null = null; + private _container: HTMLDivElement | null = null; + private _spans = new Map(); + private _outstanding: PredictionRecord[] = []; + private _anchor: { row: number; col: number } | null = null; + private _cursorOffRowSince: number | null = null; + private _seq = 0; + private _confirmedTotal = 0; + private _droppedTotal = 0; + private _ttlTimer: ReturnType | null = null; + private _reconcileScheduled = false; + private _disposables: Array<{ dispose(): void }> = []; + private _predictWhen: ((terminal: XtermTerminal) => boolean) | null; + private _options: Required> & + Pick; + private _font: FontStyle = { + fontFamily: 'monospace', + fontSize: '14px', + fontWeight: 'normal', + color: DEFAULT_FG, + backgroundColor: DEFAULT_BG, + letterSpacing: '', + }; + + constructor(options?: PredictiveEchoOptions) { + this._options = { + zIndex: options?.zIndex ?? DEFAULT_OPTIONS.zIndex, + underlinePredictions: options?.underlinePredictions ?? DEFAULT_OPTIONS.underlinePredictions, + ttlMs: options?.ttlMs ?? DEFAULT_OPTIONS.ttlMs, + maxPending: options?.maxPending ?? DEFAULT_OPTIONS.maxPending, + cursorGraceMs: options?.cursorGraceMs ?? DEFAULT_OPTIONS.cursorGraceMs, + edgeMarginCells: options?.edgeMarginCells ?? DEFAULT_OPTIONS.edgeMarginCells, + foregroundColor: options?.foregroundColor, + backgroundColor: options?.backgroundColor, + }; + this._predictWhen = options?.predictWhen ?? null; + } + + // ─── Lifecycle ──────────────────────────────────────────────────── + + /** Called by `terminal.loadAddon()`. Do not call directly. */ + activate(terminal: XtermTerminal): void { + this._terminal = terminal; + + this._container = document.createElement('div'); + this._container.setAttribute('data-predictive-echo', ''); + this._container.style.cssText = `position:absolute;left:0;top:0;z-index:${this._options.zIndex};pointer-events:none`; + const screen = terminal.element?.querySelector('.xterm-screen'); + if (screen) screen.appendChild(this._container); + + this._readFontStyle(); + + // Debounced post-parse reconcile: xterm fires onWriteParsed after the + // parser finishes a write chunk, so buffer reads see consistent state. + // The microtask coalesces multi-chunk bursts into one pass. + if (typeof terminal.onWriteParsed === 'function') { + try { + this._disposables.push( + terminal.onWriteParsed(() => { + if (this._reconcileScheduled) return; + this._reconcileScheduled = true; + queueMicrotask(() => { + this._reconcileScheduled = false; + this._safeReconcile(); + }); + }) + ); + } catch { + /* consumers without a working emitter fall back to manual reconcile() */ + } + } + if (typeof terminal.onResize === 'function') { + try { + this._disposables.push(terminal.onResize(() => this.clearPredictions())); + } catch { + /* ignore */ + } + } + } + + dispose(): void { + this.clearPredictions(); + for (const d of this._disposables) { + try { + d.dispose(); + } catch { + /* ignore */ + } + } + this._disposables = []; + this._container?.remove(); + this._container = null; + this._terminal = null; + } + + // ─── Public API ─────────────────────────────────────────────────── + + /** + * Predict a single typed character at the current insertion point. + * Returns false when suppressed; the consumer sends the keystroke to the + * PTY either way (the return value is informational, never a send gate). + */ + predictChar(ch: string): boolean { + try { + this._reconcile(); + + const t = this._terminal; + if (!t || !this._container) return false; + const dims = getCellDimensions(t); + if (!dims) return false; + const buf = t.buffer.active; + if (typeof buf.cursorX !== 'number' || typeof buf.cursorY !== 'number') return false; + if (buf.viewportY !== buf.baseY) return false; + if (this._predictWhen && this._predictWhen(t) === false) return false; + + const cps = Array.from(ch); + if (cps.length !== 1) return false; + const cp = cps[0].codePointAt(0)!; + if (cp < 0x20 || cp === 0x7f) return false; + const w = charCellWidth(t, cps[0]); + if (w !== 1 && w !== 2) return false; + if (w === 2 && !this._hasGetCell()) return false; // ASCII fallback misaligns on wide cols + if (this._outstanding.length >= this._options.maxPending) return false; + + if (this._outstanding.length === 0) { + this._anchor = { row: buf.cursorY, col: buf.cursorX }; + this._cursorOffRowSince = null; + } + const anchor = this._anchor!; + const last = this._outstanding[this._outstanding.length - 1]; + const offset = last ? last.offsetCells + last.width : 0; + const col = anchor.col + offset; + if (col + w > t.cols - this._options.edgeMarginCells) return false; + + const rec: PredictionRecord = { + seq: this._seq++, + char: cps[0], + width: w, + offsetCells: offset, + snapshot: this._readCell(anchor.row, col), + sentAt: performance.now(), + mismatches: 0, + }; + this._outstanding.push(rec); + addPredictionSpan(this._container, this._spans, { + seq: rec.seq, + row: anchor.row, + col, + char: rec.char, + width: w, + dims, + font: this._font, + underline: this._options.underlinePredictions, + }); + this._armTtl(); + return true; + } catch { + return false; + } + } + + /** + * Pop the newest outstanding prediction (visual only). Returns false when + * none are outstanding. The consumer forwards \x7f UNCONDITIONALLY either + * way; deleting already-echoed text renders at RTT. + */ + predictBackspace(): boolean { + try { + const rec = this._outstanding.pop(); + if (!rec) return false; + removePredictionSpan(this._spans, rec.seq); + if (this._outstanding.length === 0) this._resetRun(); + return true; + } catch { + return false; + } + } + + /** Drop every outstanding prediction and its spans. */ + clearPredictions(): void { + try { + this._droppedTotal += this._outstanding.length; + this._outstanding = []; + clearAllSpans(this._spans); + this._resetRun(); + } catch { + /* ignore */ + } + } + + /** Manual reconcile pass, for consumers without onWriteParsed. */ + reconcile(): void { + this._safeReconcile(); + } + + /** Swap the prediction gate at runtime (mirrors the buffer addon's setPrompt). */ + setPredictWhen(fn: ((terminal: XtermTerminal) => boolean) | null): void { + this._predictWhen = fn; + } + + /** Re-read font/theme (call after skin or font-size changes). */ + refreshFont(): void { + this._readFontStyle(); + } + + get hasPredictions(): boolean { + return this._outstanding.length > 0; + } + + get state(): PredictionState { + return { + outstanding: this._outstanding.length, + confirmedTotal: this._confirmedTotal, + droppedTotal: this._droppedTotal, + anchor: this._anchor ? { ...this._anchor } : null, + }; + } + + // ─── Reconciliation ─────────────────────────────────────────────── + + private _safeReconcile(): void { + try { + this._reconcile(); + } catch { + /* predictions may degrade, never break input */ + } + } + + private _reconcile(): void { + const t = this._terminal; + if (!t) return; + if (this._outstanding.length === 0) return; // streaming cost: one boolean + const buf = t.buffer.active; + if (buf.viewportY !== buf.baseY) { + this.clearPredictions(); // user scrolled up + return; + } + if (typeof buf.cursorX !== 'number' || typeof buf.cursorY !== 'number') return; // TTL will clean + const anchor = this._anchor!; + const now = performance.now(); + + // Off-row grace: transient cursor excursions (repaints park the cursor + // elsewhere mid-frame) are tolerated; a sustained move means the composer + // relocated or the user navigated, so predictions are stale. + if (buf.cursorY !== anchor.row) { + this._cursorOffRowSince ??= now; + if (now - this._cursorOffRowSince > this._options.cursorGraceMs) { + this.clearPredictions(); + return; + } + } else { + this._cursorOffRowSince = null; + } + + // Confirm loop: PREFIX-ONLY, and only with the cursor advanced past the + // record. Cell match alone is not enough: the predicted char may equal + // pre-existing content (placeholder glyphs), and an identical in-place + // tmux repaint must be a no-op (cells match snapshots, cursor unmoved). + while (this._outstanding.length > 0) { + const rec = this._outstanding[0]; + const cell = this._readCell(anchor.row, anchor.col + rec.offsetCells); + if (cell === rec.char && buf.cursorY === anchor.row && buf.cursorX >= anchor.col + rec.offsetCells + rec.width) { + this._outstanding.shift(); + removePredictionSpan(this._spans, rec.seq); + this._confirmedTotal++; + } else { + break; + } + } + + // Mismatch scan (two-pass rule): a half-parsed row on pass N is fully + // redrawn a few ms later, so only content foreign on TWO consecutive + // passes cascades. Blank cells are NEUTRAL, not foreign: codex clears its + // placeholder on the first echo, and the blanks left under later + // predictions are what "not yet echoed" looks like, not evidence of a + // redraw (measured 2026-08-09; without this, fast typing over the + // placeholder cascades exactly when RTT is high). TTL still bounds them. + let dropFrom = -1; + for (let i = 0; i < this._outstanding.length; i++) { + const rec = this._outstanding[i]; + const cell = this._readCell(anchor.row, anchor.col + rec.offsetCells); + if (cell !== rec.snapshot && cell !== rec.char && cell !== ' ') { + rec.mismatches++; + if (rec.mismatches >= 2) { + dropFrom = i; + break; + } + } else { + rec.mismatches = 0; + } + } + if (dropFrom !== -1) this._dropFrom(dropFrom); + + // TTL: the first stale record drops itself and everything after it. + for (let i = 0; i < this._outstanding.length; i++) { + if (now - this._outstanding[i].sentAt > this._options.ttlMs) { + this._dropFrom(i); + break; + } + } + + if (this._outstanding.length === 0) { + this._resetRun(); + } else { + this._armTtl(); + } + } + + private _dropFrom(index: number): void { + const dropped = this._outstanding.splice(index); + for (const rec of dropped) removePredictionSpan(this._spans, rec.seq); + this._droppedTotal += dropped.length; + } + + private _resetRun(): void { + this._anchor = null; + this._cursorOffRowSince = null; + if (this._ttlTimer !== null) { + clearTimeout(this._ttlTimer); + this._ttlTimer = null; + } + } + + private _armTtl(): void { + if (this._ttlTimer !== null) return; + const oldest = this._outstanding[0]; + if (!oldest) return; + const delay = Math.max(0, oldest.sentAt + this._options.ttlMs - performance.now()) + 1; + this._ttlTimer = setTimeout(() => { + this._ttlTimer = null; + this._safeReconcile(); + this._armTtl(); + }, delay); + } + + // ─── Cell access ────────────────────────────────────────────────── + + private _hasGetCell(): boolean { + const buf = this._terminal?.buffer.active; + if (!buf) return false; + const line = buf.getLine(buf.baseY + (buf.cursorY ?? 0)); + return typeof line?.getCell === 'function'; + } + + /** Read one cell's chars at (viewport-relative row, col); '' -> ' '. */ + private _readCell(row: number, col: number): string { + const buf = this._terminal!.buffer.active; + const line = buf.getLine(buf.baseY + row); + if (!line) return ' '; + if (typeof line.getCell === 'function') { + const chars = line.getCell(col)?.getChars() ?? ''; + return chars === '' ? ' ' : chars; + } + // ASCII fallback: code-unit index, misaligns after wide columns, which is + // why width-2 predictions are suppressed without getCell. + const text = line.translateToString(true); + return text[col] ?? ' '; + } + + // ─── Font ───────────────────────────────────────────────────────── + + /** Same recipe as the buffer addon's _cacheFont (kept private on purpose: + * zerolag-input-addon.ts must stay untouched by this feature). */ + private _readFontStyle(): void { + const t = this._terminal; + if (!t) return; + 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 = ''; + 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; + } + } +} diff --git a/packages/xterm-zerolag-input/src/prompt-finder.ts b/packages/xterm-zerolag-input/src/prompt-finder.ts index 90ea0cf0..9d660ede 100644 --- a/packages/xterm-zerolag-input/src/prompt-finder.ts +++ b/packages/xterm-zerolag-input/src/prompt-finder.ts @@ -6,55 +6,50 @@ import type { XtermTerminal, PromptFinder, PromptPosition } from './types.js'; * * @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; +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': { - // Create a fresh non-global regex to avoid lastIndex mutation - // and ensure .match() returns a single result with .index - const pattern = finder.pattern; - const safePattern = pattern.global - ? new RegExp(pattern.source, pattern.flags.replace('g', '')) - : pattern; - 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(safePattern); - if (match) { - const col = match.index ?? 0; - return { row, col }; - } - } - return null; - } - - case 'custom': - return finder.find(terminal); - - default: - return null; + 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 }; } - } catch { + return null; + } + + case 'regex': { + // Create a fresh non-global regex to avoid lastIndex mutation + // and ensure .match() returns a single result with .index + const pattern = finder.pattern; + const safePattern = pattern.global ? new RegExp(pattern.source, pattern.flags.replace('g', '')) : pattern; + 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(safePattern); + if (match) { + const col = match.index ?? 0; + return { row, col }; + } + } + return null; + } + + case 'custom': + return finder.find(terminal); + + default: return null; } + } catch { + return null; + } } /** @@ -65,19 +60,15 @@ export function findPrompt( * @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 ''; - } +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/test/cell-dimensions.test.ts b/packages/xterm-zerolag-input/test/cell-dimensions.test.ts index 165a2b1f..b07c1840 100644 --- a/packages/xterm-zerolag-input/test/cell-dimensions.test.ts +++ b/packages/xterm-zerolag-input/test/cell-dimensions.test.ts @@ -6,122 +6,125 @@ import type { XtermTerminal } from '../src/types.js'; let cleanups: (() => void)[] = []; afterEach(() => { - for (const fn of cleanups) fn(); - cleanups = []; + for (const fn of cleanups) fn(); + cleanups = []; }); describe('getCellDimensions', () => { - describe('v5 private API (mock _core._renderService)', () => { - it('returns cell width and height from css.cell', () => { - const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims).not.toBeNull(); - expect(dims!.width).toBe(8.4); - expect(dims!.height).toBe(19); - }); - - it('returns charTop from device.char.top divided by DPR', () => { - const mock = createMockTerminal({ - cellWidth: 8, cellHeight: 19, - deviceCharTop: 2, - }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims).not.toBeNull(); - // DPR=1 in jsdom, so charTop = 2 / 1 = 2 - expect(dims!.charTop).toBe(2); - }); - - it('returns charHeight from device.char.height divided by DPR', () => { - const mock = createMockTerminal({ - cellWidth: 8, cellHeight: 19, - deviceCharHeight: 16, - }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims).not.toBeNull(); - // DPR=1, so charHeight = 16 / 1 = 16 - expect(dims!.charHeight).toBe(16); - }); - - it('defaults charTop to 0 when device.char not present', () => { - // Default mock has deviceCharTop=0 - const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims!.charTop).toBe(0); - }); - - it('defaults charHeight to cellH when device.char.height not set', () => { - // Default mock has deviceCharHeight=cellH - const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims!.charHeight).toBe(19); - }); + describe('v5 private API (mock _core._renderService)', () => { + it('returns cell width and height from css.cell', () => { + const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + expect(dims!.width).toBe(8.4); + expect(dims!.height).toBe(19); }); - describe('DPR simulation', () => { - const originalDPR = globalThis.devicePixelRatio; - - beforeEach(() => { - // Set DPR=2 to test division - Object.defineProperty(globalThis, 'devicePixelRatio', { - value: 2, - writable: true, - configurable: true, - }); - }); - - afterEach(() => { - Object.defineProperty(globalThis, 'devicePixelRatio', { - value: originalDPR, - writable: true, - configurable: true, - }); - }); - - it('divides device.char.top by DPR', () => { - const mock = createMockTerminal({ - cellWidth: 16, cellHeight: 38, - deviceCharTop: 4, - deviceCharHeight: 32, - }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims).not.toBeNull(); - // charTop = 4 / 2 = 2 - expect(dims!.charTop).toBe(2); - // charHeight = 32 / 2 = 16 - expect(dims!.charHeight).toBe(16); - }); + it('returns charTop from device.char.top divided by DPR', () => { + const mock = createMockTerminal({ + cellWidth: 8, + cellHeight: 19, + deviceCharTop: 2, + }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + // DPR=1 in jsdom, so charTop = 2 / 1 = 2 + expect(dims!.charTop).toBe(2); }); - describe('null cases', () => { - it('returns null for terminal without _core', () => { - const terminal = { - element: document.createElement('div'), - cols: 80, - rows: 24, - options: {}, - buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, - } as unknown as XtermTerminal; - const dims = getCellDimensions(terminal); - expect(dims).toBeNull(); - }); - - it('returns null for terminal with no dimensions', () => { - const terminal = { - element: document.createElement('div'), - cols: 80, - rows: 24, - options: {}, - buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, - _core: { _renderService: {} }, - } as unknown as XtermTerminal; - const dims = getCellDimensions(terminal); - expect(dims).toBeNull(); - }); + it('returns charHeight from device.char.height divided by DPR', () => { + const mock = createMockTerminal({ + cellWidth: 8, + cellHeight: 19, + deviceCharHeight: 16, + }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + // DPR=1, so charHeight = 16 / 1 = 16 + expect(dims!.charHeight).toBe(16); }); + + it('defaults charTop to 0 when device.char not present', () => { + // Default mock has deviceCharTop=0 + const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims!.charTop).toBe(0); + }); + + it('defaults charHeight to cellH when device.char.height not set', () => { + // Default mock has deviceCharHeight=cellH + const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims!.charHeight).toBe(19); + }); + }); + + describe('DPR simulation', () => { + const originalDPR = globalThis.devicePixelRatio; + + beforeEach(() => { + // Set DPR=2 to test division + Object.defineProperty(globalThis, 'devicePixelRatio', { + value: 2, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'devicePixelRatio', { + value: originalDPR, + writable: true, + configurable: true, + }); + }); + + it('divides device.char.top by DPR', () => { + const mock = createMockTerminal({ + cellWidth: 16, + cellHeight: 38, + deviceCharTop: 4, + deviceCharHeight: 32, + }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + // charTop = 4 / 2 = 2 + expect(dims!.charTop).toBe(2); + // charHeight = 32 / 2 = 16 + expect(dims!.charHeight).toBe(16); + }); + }); + + describe('null cases', () => { + it('returns null for terminal without _core', () => { + const terminal = { + element: document.createElement('div'), + cols: 80, + rows: 24, + options: {}, + buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, + } as unknown as XtermTerminal; + const dims = getCellDimensions(terminal); + expect(dims).toBeNull(); + }); + + it('returns null for terminal with no dimensions', () => { + const terminal = { + element: document.createElement('div'), + cols: 80, + rows: 24, + options: {}, + buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, + _core: { _renderService: {} }, + } as unknown as XtermTerminal; + const dims = getCellDimensions(terminal); + expect(dims).toBeNull(); + }); + }); }); diff --git a/packages/xterm-zerolag-input/test/codex-replay.test.ts b/packages/xterm-zerolag-input/test/codex-replay.test.ts new file mode 100644 index 00000000..1e5aad38 --- /dev/null +++ b/packages/xterm-zerolag-input/test/codex-replay.test.ts @@ -0,0 +1,172 @@ +/** + * @vitest-environment jsdom + * + * Layer 2 (the load-bearing suite): the REAL algorithm against the REAL xterm + * parser, fed by fixtures recorded from real codex 0.147 through the + * production pipeline (tmux + the codex full strip). See + * scripts/dev/record-codex-frames.mjs in the consuming repo. + * + * Every replay ends with the convergence invariant: predictions never outlive + * their run (outstanding 0, span container empty). + */ +import { describe, expect, it } from 'vitest'; +import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js'; +import { + CELL_H, + CELL_W, + classifyPredictInput, + codexComposerGate, + createReplayTerminal, + loadFixture, + type ReplayTerminal, +} from './replay-helpers.js'; + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +interface KeyEvent { + key: string; + kind: ReturnType; + painted: boolean; + spansAfter: number; +} + +function assertSpansInGrid(rt: ReplayTerminal) { + for (const s of rt.spans()) { + const left = parseFloat(s.style.left); + const width = parseFloat(s.style.width); + const top = parseFloat(s.style.top); + expect(left + width).toBeLessThanOrEqual(rt.hybrid.cols * CELL_W); + expect(top).toBeLessThanOrEqual((rt.hybrid.rows - 1) * CELL_H); + expect(left).toBeGreaterThanOrEqual(0); + expect(top).toBeGreaterThanOrEqual(0); + } +} + +async function replay(name: string) { + const { meta, lines } = loadFixture(name); + const rt = createReplayTerminal(meta.cols, meta.rows); + const addon = new PredictiveEchoAddon({ predictWhen: codexComposerGate }); + addon.activate(rt.hybrid); + + const events: KeyEvent[] = []; + for (const line of lines) { + if (line.keyAt) { + const kind = classifyPredictInput(line.data); + let painted = false; + if (kind === 'char') painted = addon.predictChar(line.data); + else if (kind === 'backspace') addon.predictBackspace(); + else if (kind === 'clear') addon.clearPredictions(); + // Span/record parity and grid bounds hold at every step + expect(rt.spanCount()).toBe(addon.state.outstanding); + assertSpansInGrid(rt); + events.push({ key: line.data, kind, painted, spansAfter: rt.spanCount() }); + } else { + await rt.write(line.data); + await flushMicrotasks(); + } + } + return { rt, addon, events, meta }; +} + +/** Convergence invariant: after the last chunk + reconcile (+ TTL if needed), + * nothing outlives the run. */ +async function converge(rt: ReplayTerminal, addon: PredictiveEchoAddon) { + addon.reconcile(); + if (addon.state.outstanding > 0) { + await sleep(1100); // ttlMs default + addon.reconcile(); + } + expect(addon.state.outstanding).toBe(0); + expect(rt.spanCount()).toBe(0); +} + +describe('codex replay', () => { + it('type-hello: all 5 predictions confirm, zero drops, composer converges', async () => { + const { rt, addon, events } = await replay('type-hello'); + const chars = events.filter((e) => e.kind === 'char'); + expect(chars).toHaveLength(5); + expect(chars.every((e) => e.painted)).toBe(true); + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(5); + expect(addon.state.droppedTotal).toBe(0); + expect(rt.cursorRowText()).toBe('› hello'); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('slash-picker: "/" and filter chars confirm; no ghosts while picker rows redraw', async () => { + const { rt, addon, events } = await replay('slash-picker'); + const chars = events.filter((e) => e.kind === 'char'); + expect(chars.map((e) => e.key)).toEqual(['/', 'm', 'o']); + expect(chars.every((e) => e.painted)).toBe(true); + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(3); + expect(addon.state.droppedTotal).toBe(0); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('wrap: predictions stay inside the grid, continuation rows fall back to real echo, buffer converges', async () => { + const { rt, addon, events } = await replay('wrap'); + // The gate goes false once the cursor is on a wrapped continuation row + // (2-space indent, no "› "): a tail of keystrokes must be suppressed. + const chars = events.filter((e) => e.kind === 'char'); + expect(chars.some((e) => !e.painted)).toBe(true); + expect(chars.some((e) => e.painted)).toBe(true); + await converge(rt, addon); + // The composer content is exactly what was typed (word-wrapped) + const b = rt.term.buffer.active; + const cursorRow = b.cursorY; + expect(rt.rowText(cursorRow).trim()).toBe('this line twice over'); + expect(rt.rowText(cursorRow - 1)).toMatch(/^› the quick brown fox/); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('streaming-burst: typed predictions confirm; the re-rendered composer keeps its signature', async () => { + const { rt, addon, events } = await replay('streaming-burst'); + const chars = events.filter((e) => e.kind === 'char'); + expect(chars).toHaveLength(5); // "hello" (the \r is kind 'clear') + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(5); + expect(addon.state.droppedTotal).toBe(0); + // After the 401 burst codex re-renders a fresh composer at the cursor + expect(rt.cursorRowText()).toMatch(/^› /); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('paste-bracketed: typed chars confirm, the paste clears predictions, content intact', async () => { + const { rt, addon, events } = await replay('paste-bracketed'); + const paste = events.find((e) => e.key.startsWith('\x1b[200~'))!; + expect(paste.kind).toBe('clear'); + expect(paste.spansAfter).toBe(0); + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(2); // 'a', 'b' + expect(rt.cursorRowText()).toContain('abXYZpasted'); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('trust-modal: the predictWhen gate paints ZERO spans on the modal (ghost eliminator)', async () => { + const { rt, addon, events } = await replay('trust-modal'); + const x = events.find((e) => e.key === 'x')!; + expect(x.painted).toBe(false); + expect(x.spansAfter).toBe(0); + expect(events.every((e) => e.spansAfter === 0)).toBe(true); + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(0); + expect(addon.state.droppedTotal).toBe(0); + // The transition landed on the real composer afterwards + expect(rt.cursorRowText()).toMatch(/^› /); + addon.dispose(); + rt.cleanup(); + }, 15000); +}); diff --git a/packages/xterm-zerolag-input/test/helpers.ts b/packages/xterm-zerolag-input/test/helpers.ts index e1639b8a..d2229576 100644 --- a/packages/xterm-zerolag-input/test/helpers.ts +++ b/packages/xterm-zerolag-input/test/helpers.ts @@ -3,129 +3,204 @@ * * Creates a minimal Terminal-like object that satisfies the addon's * requirements without needing a real xterm.js instance or DOM renderer. + * + * PredictiveEchoAddon additions (all ADDITIVE, existing tests unchanged): + * mutable cursor via setCursor(), wide-char-aware getCell() on mock lines, + * onWriteParsed/onResize emitters with fire* triggers, and opt-outs for + * getCell support and the emitters (getCellSupport / emitters options). */ +import { charCellWidth } from '../src/overlay-renderer.js'; interface MockLine { - translateToString(_trimRight?: boolean): string; + translateToString(_trimRight?: boolean): string; + getCell?(x: number): { getChars(): string; getWidth(): number } | undefined; } interface MockBufferOptions { - lines: string[]; - viewportY?: number; - baseY?: number; - cursorX?: number; - cursorY?: number; + 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; - /** Device-pixel char top offset (for charTop calculation). Default: 0 */ - deviceCharTop?: number; - /** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */ - deviceCharHeight?: number; + buffer?: MockBufferOptions; + cols?: number; + rows?: number; + fontFamily?: string; + fontSize?: number; + fontWeight?: string | number; + theme?: { + background?: string; + foreground?: string; + cursor?: string; + }; + cellWidth?: number; + cellHeight?: number; + /** Device-pixel char top offset (for charTop calculation). Default: 0 */ + deviceCharTop?: number; + /** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */ + deviceCharHeight?: number; + /** Provide getCell() on mock lines (PredictiveEchoAddon). Default: true */ + getCellSupport?: boolean; + /** Provide onWriteParsed/onResize emitters (PredictiveEchoAddon). Default: true */ + emitters?: boolean; +} + +/** Column-indexed cell access over a plain string, wide-char aware. */ +function cellAt(text: string, col: number): { getChars(): string; getWidth(): number } { + let c = 0; + for (const ch of text) { + const w = charCellWidth(null, ch); + if (col === c) return { getChars: () => ch, getWidth: () => w }; + if (w === 2 && col === c + 1) return { getChars: () => '', getWidth: () => 0 }; + c += w; + } + return { getChars: () => '', getWidth: () => 1 }; } 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 bufOpts = opts.buffer ?? { lines: ['$ '] }; + const viewportY = bufOpts.viewportY ?? 0; + const baseY = bufOpts.baseY ?? viewportY; + const cols = opts.cols ?? 80; + const rows = opts.rows ?? Math.max(bufOpts.lines.length, 24); + const cellW = opts.cellWidth ?? 8.4; + const cellH = opts.cellHeight ?? 17; + const getCellSupport = opts.getCellSupport ?? true; + const emitters = opts.emitters ?? true; - 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 }, - }, - device: { - char: { - top: opts.deviceCharTop ?? 0, - height: opts.deviceCharHeight ?? cellH, - }, - }, - }, - }, - }, - // Simulate loadAddon - loadAddon(addon: { activate: (t: unknown) => void }) { - addon.activate(this); - }, + const makeLine = (text: string): { line: MockLine; set(t: string): void } => { + let current = text; + const line: MockLine = { + translateToString: () => current, }; + if (getCellSupport) { + line.getCell = (x: number) => cellAt(current, x); + } + return { line, set: (t: string) => (current = t) }; + }; - return { - terminal, - /** Update buffer lines for subsequent calls */ - setLines(newLines: string[]) { - mockLines.length = 0; - for (const text of newLines) { - mockLines.push({ translateToString: () => text }); - } + let mockLines = bufOpts.lines.map(makeLine); + + // 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 writeParsedCbs = new Set<() => void>(); + const resizeCbs = new Set<(s: { cols: number; rows: number }) => void>(); + + 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]?.line; }, - /** Clean up DOM */ - cleanup() { - element.remove(); + }, + }, + _core: { + _renderService: { + dimensions: { + css: { + cell: { width: cellW, height: cellH }, + }, + device: { + char: { + top: opts.deviceCharTop ?? 0, + height: opts.deviceCharHeight ?? cellH, + }, + }, }, - }; + }, + }, + ...(emitters + ? { + onWriteParsed(cb: () => void) { + writeParsedCbs.add(cb); + return { dispose: () => writeParsedCbs.delete(cb) }; + }, + onResize(cb: (s: { cols: number; rows: number }) => void) { + resizeCbs.add(cb); + return { dispose: () => resizeCbs.delete(cb) }; + }, + } + : {}), + // Simulate loadAddon + loadAddon(addon: { activate: (t: unknown) => void }) { + addon.activate(this); + }, + }; + + return { + terminal, + /** Update buffer lines for subsequent calls */ + setLines(newLines: string[]) { + mockLines = newLines.map(makeLine); + }, + /** Update one line's text in place (PredictiveEchoAddon echo simulation) */ + setLine(index: number, text: string) { + mockLines[index]?.set(text); + }, + /** Move the mock cursor (PredictiveEchoAddon) */ + setCursor(x: number, y: number) { + terminal.buffer.active.cursorX = x; + terminal.buffer.active.cursorY = y; + }, + /** Set scroll state (viewportY / baseY) */ + setScroll(newViewportY: number, newBaseY: number) { + terminal.buffer.active.viewportY = newViewportY; + terminal.buffer.active.baseY = newBaseY; + }, + /** Fire the onWriteParsed emitter (PredictiveEchoAddon reconcile trigger) */ + fireWriteParsed() { + for (const cb of [...writeParsedCbs]) cb(); + }, + /** Fire the onResize emitter */ + fireResize(newCols = cols, newRows = rows) { + for (const cb of [...resizeCbs]) cb({ cols: newCols, rows: newRows }); + }, + /** Number of live onWriteParsed listeners (dispose assertions) */ + writeParsedListenerCount() { + return writeParsedCbs.size; + }, + /** Number of live onResize listeners (dispose assertions) */ + resizeListenerCount() { + return resizeCbs.size; + }, + /** Clean up DOM */ + cleanup() { + element.remove(); + }, + }; } diff --git a/packages/xterm-zerolag-input/test/prediction-renderer.test.ts b/packages/xterm-zerolag-input/test/prediction-renderer.test.ts new file mode 100644 index 00000000..8280807c --- /dev/null +++ b/packages/xterm-zerolag-input/test/prediction-renderer.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + * + * prediction-renderer unit tests: span geometry math, seam-cover height, + * ligature suppression, incremental add/remove keyed by seq, and geometry + * stability under a non-1 devicePixelRatio (all dims are CSS px). + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { addPredictionSpan, clearAllSpans, removePredictionSpan } from '../src/prediction-renderer.js'; +import type { CellDimensions, FontStyle } from '../src/types.js'; + +const dims: CellDimensions = { width: 9, height: 18, charTop: 1, charHeight: 16 }; +const font: FontStyle = { + fontFamily: 'monospace', + fontSize: '14px', + fontWeight: 'normal', + color: '#e0e0e0', + backgroundColor: '#101010', + letterSpacing: '0.5px', +}; + +function makeContainer() { + const el = document.createElement('div'); + document.body.appendChild(el); + return el; +} + +function span(container: HTMLElement, map: Map, over: Record = {}) { + addPredictionSpan(container, map, { + seq: 1, + row: 3, + col: 5, + char: 'x', + width: 1, + dims, + font, + underline: false, + ...over, + } as never); + return map.get((over.seq as number) ?? 1)!; +} + +describe('prediction-renderer', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('positions a width-1 span on the exact cell grid', () => { + const map = new Map(); + const s = span(makeContainer(), map); + expect(s.style.left).toBe(`${5 * 9}px`); + expect(s.style.top).toBe(`${3 * 18}px`); + expect(s.style.width).toBe(`${9}px`); + expect(s.textContent).toBe('x'); + }); + + it('positions a width-2 span across two cells', () => { + const map = new Map(); + const s = span(makeContainer(), map, { char: '你', width: 2 }); + expect(s.style.width).toBe(`${2 * 9}px`); + }); + + it('covers the row seam: height is cellH+1 with line-height cellH', () => { + const map = new Map(); + const s = span(makeContainer(), map); + expect(s.style.height).toBe(`${18 + 1}px`); + expect(s.style.lineHeight).toBe('18px'); + }); + + it('disables ligatures and pointer events, applies font + letter-spacing', () => { + const map = new Map(); + const s = span(makeContainer(), map); + expect(s.style.cssText).toContain("'liga' 0"); + expect(s.style.cssText).toContain("'calt' 0"); + expect(s.style.pointerEvents).toBe('none'); + expect(s.style.fontFamily).toBe('monospace'); + expect(s.style.letterSpacing).toBe('0.5px'); + expect(s.style.textAlign).toBe('center'); + }); + + it('paints an opaque background over only its own cells', () => { + const map = new Map(); + const s = span(makeContainer(), map); + expect(['#101010', 'rgb(16, 16, 16)']).toContain(s.style.backgroundColor); + // Background is bounded by the span's own width, never a full row + expect(s.style.width).toBe('9px'); + }); + + it('underline renders only when requested', () => { + const map = new Map(); + const container = makeContainer(); + const plain = span(container, map, { seq: 1 }); + const lined = span(container, map, { seq: 2, underline: true }); + expect(plain.style.textDecoration).toBe(''); + expect(lined.style.textDecoration).toBe('underline'); + }); + + it('adds and removes incrementally, keyed by seq', () => { + const map = new Map(); + const container = makeContainer(); + span(container, map, { seq: 1 }); + span(container, map, { seq: 2, col: 6 }); + span(container, map, { seq: 3, col: 7 }); + expect(container.children).toHaveLength(3); + + removePredictionSpan(map, 2); + expect(container.children).toHaveLength(2); + expect(map.has(2)).toBe(false); + expect(map.has(1)).toBe(true); + expect(map.has(3)).toBe(true); + + removePredictionSpan(map, 999); // unknown seq: no-op + expect(container.children).toHaveLength(2); + }); + + it('clearAllSpans empties both the DOM and the map', () => { + const map = new Map(); + const container = makeContainer(); + span(container, map, { seq: 1 }); + span(container, map, { seq: 2, col: 6 }); + clearAllSpans(map); + expect(container.children).toHaveLength(0); + expect(map.size).toBe(0); + }); + + it('geometry is stable under devicePixelRatio 2 (dims are CSS px)', () => { + vi.stubGlobal('devicePixelRatio', 2); + const map = new Map(); + const s = span(makeContainer(), map); + expect(s.style.left).toBe(`${5 * 9}px`); + expect(s.style.top).toBe(`${3 * 18}px`); + expect(s.style.width).toBe('9px'); + }); +}); diff --git a/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts b/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts new file mode 100644 index 00000000..bbeca038 --- /dev/null +++ b/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts @@ -0,0 +1,505 @@ +/** + * @vitest-environment jsdom + * + * PredictiveEchoAddon unit tests: the algorithm laws (anchoring, prefix-only + * confirmation with cursor advance, two-pass mismatch cascade with neutral + * blanks, TTL, off-row grace, gates) and lifecycle safety. + * + * Timer-based cases fake `performance` explicitly: the addon clocks + * sentAt/TTL/grace with performance.now(), which vitest does NOT fake by + * default. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js'; +import { createMockTerminal } from './helpers.js'; + +const TIMER_CONFIG = { + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date', 'performance'] as const, +}; + +/** Composer-like buffer: `› ` marker + placeholder, cursor at col 2 row 0. */ +function composerMock(opts: Parameters[0] = {}) { + return createMockTerminal({ + buffer: { lines: ['› Use /skills to list', '', ''], cursorX: 2, cursorY: 0 }, + ...opts, + }); +} + +function spansOf(mock: ReturnType): HTMLSpanElement[] { + const screen = mock.terminal.element.querySelector('.xterm-screen')!; + return Array.from(screen.querySelectorAll('[data-predictive-echo] span')) as HTMLSpanElement[]; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('PredictiveEchoAddon', () => { + let mock: ReturnType; + let addon: PredictiveEchoAddon; + + beforeEach(() => { + vi.useFakeTimers(TIMER_CONFIG); + mock = composerMock(); + addon = new PredictiveEchoAddon(); + addon.activate(mock.terminal as never); + }); + + afterEach(() => { + addon.dispose(); + mock.cleanup(); + vi.useRealTimers(); + }); + + it('paints a span at the cursor cell and returns true', () => { + expect(addon.predictChar('h')).toBe(true); + const spans = spansOf(mock); + expect(spans).toHaveLength(1); + expect(spans[0].textContent).toBe('h'); + expect(spans[0].style.left).toBe(`${2 * 8.4}px`); + expect(spans[0].style.top).toBe('0px'); + expect(addon.state.outstanding).toBe(1); + }); + + it('stacks predictions at anchor+cumulative width while the cursor is unmoved', () => { + addon.predictChar('h'); + addon.predictChar('e'); + addon.predictChar('y'); + const spans = spansOf(mock); + expect(spans.map((s) => s.style.left)).toEqual([`${2 * 8.4}px`, `${3 * 8.4}px`, `${4 * 8.4}px`]); + expect(addon.state.anchor).toEqual({ row: 0, col: 2 }); + }); + + it('re-anchors at the new cursor once outstanding drains to zero', async () => { + addon.predictChar('h'); + mock.setLine(0, '› h'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); + expect(addon.state.anchor).toBeNull(); + + addon.predictChar('i'); + expect(addon.state.anchor).toEqual({ row: 0, col: 3 }); + expect(spansOf(mock)[0].style.left).toBe(`${3 * 8.4}px`); + }); + + it('inline reconcile inside predictChar absorbs an echo that landed between keystrokes', () => { + addon.predictChar('h'); + // Echo lands but no onWriteParsed fires before the next keystroke + mock.setLine(0, '› h'); + mock.setCursor(3, 0); + expect(addon.predictChar('i')).toBe(true); + // 'h' confirmed inline; 'i' anchored at the advanced cursor, not stacked + expect(addon.state.outstanding).toBe(1); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.anchor).toEqual({ row: 0, col: 3 }); + }); + + it('confirms and removes exactly the echoed prefix (cell match + cursor advance)', async () => { + addon.predictChar('a'); + addon.predictChar('b'); + addon.predictChar('c'); + mock.setLine(0, '› ab'); + mock.setCursor(4, 0); // advanced past 'a' and 'b' only + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.confirmedTotal).toBe(2); + expect(addon.state.outstanding).toBe(1); + expect(spansOf(mock).map((s) => s.textContent)).toEqual(['c']); + }); + + it('partial confirmation never moves remaining spans (no jitter)', async () => { + addon.predictChar('a'); + addon.predictChar('b'); + const bLeft = spansOf(mock)[1].style.left; + mock.setLine(0, '› a'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(spansOf(mock)).toHaveLength(1); + expect(spansOf(mock)[0].style.left).toBe(bLeft); + }); + + it('does NOT confirm when the cell matches but the cursor has not advanced (in-place repaint)', async () => { + // Predict 'U' over the placeholder whose cell already shows 'U' + addon.predictChar('U'); + expect(addon.state.outstanding).toBe(1); + // tmux repaints the identical row; cursor stays at the anchor + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); + expect(addon.state.confirmedTotal).toBe(0); + }); + + it('does NOT confirm or drop when the predicted char equals the pre-existing snapshot', async () => { + addon.predictChar('U'); + // Several passes over the unchanged placeholder: no confirm, no cascade + for (let i = 0; i < 4; i++) { + mock.fireWriteParsed(); + await flushMicrotasks(); + } + expect(addon.state.outstanding).toBe(1); + expect(addon.state.droppedTotal).toBe(0); + }); + + it('one transient mismatch survives; a persistent foreign cell cascades (two-pass rule)', async () => { + addon.predictChar('a'); + mock.setLine(0, '› Z'); // foreign non-blank at the predicted cell + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); // pass 1: survives + + // Transient recovery resets the counter + mock.setLine(0, '› Use /skills to list'); + mock.fireWriteParsed(); + await flushMicrotasks(); + mock.setLine(0, '› Z'); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); // count restarted, pass 1 again + + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); // pass 2: cascaded + expect(addon.state.droppedTotal).toBe(1); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('blank cells are neutral: placeholder cleared under predictions does not cascade', async () => { + // Predict over placeholder text, then codex clears the placeholder on + // first echo: later cells become blank, which must NOT count as + // foreign (measured behavior; without this, fast typing over the + // placeholder drops exactly when RTT is high). + addon.predictChar('h'); + addon.predictChar('i'); + mock.setLine(0, '› h'); // 'h' echoed; placeholder gone; 'i' cell now blank + mock.setCursor(3, 0); + for (let i = 0; i < 4; i++) { + mock.fireWriteParsed(); + await flushMicrotasks(); + } + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.outstanding).toBe(1); // 'i' still pending, TTL-bounded + expect(addon.state.droppedTotal).toBe(0); + }); + + it('mismatch cascade drops the record and all later ones, earlier confirmed stay gone', async () => { + addon.predictChar('a'); + addon.predictChar('b'); + addon.predictChar('c'); + mock.setLine(0, '› aXX'); // 'a' echoed; foreign 'X' under 'b' and 'c' + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.droppedTotal).toBe(2); + expect(addon.state.outstanding).toBe(0); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('TTL expiry drops predictions and leaves no timers armed (fake timers)', () => { + addon.predictChar('a'); + addon.predictChar('b'); + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(1100); + expect(addon.state.outstanding).toBe(0); + expect(addon.state.droppedTotal).toBe(2); + expect(spansOf(mock)).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('TTL timer re-arms for remaining records after a partial confirm', async () => { + addon.predictChar('a'); // t=0, deadline ~1001 + vi.advanceTimersByTime(600); + addon.predictChar('b'); // t=600, deadline ~1601 + // Echo confirms 'a' before its TTL; 'b' remains + mock.setLine(0, '› a'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); + vi.advanceTimersByTime(450); // t=1050: a's timer fired, b (age 450) survives + expect(addon.state.outstanding).toBe(1); + expect(vi.getTimerCount()).toBe(1); // re-armed for b + vi.advanceTimersByTime(600); // t=1650: b expired + expect(addon.state.outstanding).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('cursor off anchor row within grace keeps predictions; sustained off-row drops all', async () => { + addon.predictChar('a'); + mock.setCursor(0, 5); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); // transient excursion tolerated + + vi.advanceTimersByTime(200); // > cursorGraceMs (150) + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('viewportY !== baseY clears predictions (scrolled up)', async () => { + addon.predictChar('a'); + mock.setScroll(0, 5); // user scrolled: viewport pinned above baseY + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); + // And no new predictions while scrolled + expect(addon.predictChar('b')).toBe(false); + }); + + it('maxPending: the 33rd predictChar returns false', () => { + for (let i = 0; i < 32; i++) { + expect(addon.predictChar('x')).toBe(true); + } + expect(addon.predictChar('y')).toBe(false); + expect(addon.state.outstanding).toBe(32); + }); + + it('edge margin: a prediction landing within edgeMarginCells of cols returns false', () => { + mock.setCursor(75, 0); // cols 80, margin 4: col 75 + 1 <= 76 allowed + expect(addon.predictChar('a')).toBe(true); + // Next lands at col 76: 77 > 76 suppressed + expect(addon.predictChar('b')).toBe(false); + }); + + it('predictWhen gate false suppresses painting, predictChar just returns false', () => { + addon.setPredictWhen(() => false); + expect(addon.predictChar('a')).toBe(false); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('setPredictWhen(null) removes the gate at runtime', () => { + addon.setPredictWhen(() => false); + expect(addon.predictChar('a')).toBe(false); + addon.setPredictWhen(null); + expect(addon.predictChar('a')).toBe(true); + }); + + it('multi-codepoint graphemes and control chars return false', () => { + for (const bad of ['ab', '\x1b', '\x03', '\r', '\n', '\t', '\x7f', '👨‍👩‍👧', '']) { + expect(addon.predictChar(bad)).toBe(false); + } + expect(spansOf(mock)).toHaveLength(0); + // Single astral emoji IS a single codepoint: predicted (width 2) + expect(addon.predictChar('😀')).toBe(true); + }); + + it('CJK: 2-cell span, next prediction offsets by 2, confirm reads the leading cell', async () => { + expect(addon.predictChar('你')).toBe(true); + const first = spansOf(mock)[0]; + expect(first.style.width).toBe(`${2 * 8.4}px`); + addon.predictChar('a'); + expect(spansOf(mock)[1].style.left).toBe(`${4 * 8.4}px`); // 2 + width 2 + + mock.setLine(0, '› 你'); + mock.setCursor(4, 0); // advanced past the wide char + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.outstanding).toBe(1); + }); + + it('getCell-less terminal: ASCII fallback works, wide chars suppressed', () => { + const bare = createMockTerminal({ + buffer: { lines: ['› ', ''], cursorX: 2, cursorY: 0 }, + getCellSupport: false, + }); + const a = new PredictiveEchoAddon(); + a.activate(bare.terminal as never); + expect(a.predictChar('x')).toBe(true); + expect(a.predictChar('你')).toBe(false); + a.dispose(); + bare.cleanup(); + }); + + it("'' and ' ' cell reads are equivalent for snapshot and confirm", async () => { + // Snapshot beyond the line text reads '' -> normalized ' ' + mock.setLine(0, '› '); + addon.predictChar('a'); // snapshot at col 2 is '' -> ' ' + // A repaint that writes explicit spaces must not count as foreign + mock.setLine(0, '› '); + mock.fireWriteParsed(); + await flushMicrotasks(); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); + expect(addon.state.droppedTotal).toBe(0); + }); + + it('predictBackspace pops newest, returns false when empty, never touches confirmed', async () => { + expect(addon.predictBackspace()).toBe(false); + addon.predictChar('a'); + addon.predictChar('b'); + expect(addon.predictBackspace()).toBe(true); + expect(addon.state.outstanding).toBe(1); + expect(spansOf(mock).map((s) => s.textContent)).toEqual(['a']); + + mock.setLine(0, '› a'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.predictBackspace()).toBe(false); // confirmed text is not popped + }); + + it('clearPredictions empties the container, resets anchor, cancels the timer', () => { + addon.predictChar('a'); + addon.predictChar('b'); + expect(vi.getTimerCount()).toBe(1); + addon.clearPredictions(); + expect(spansOf(mock)).toHaveLength(0); + expect(addon.state.outstanding).toBe(0); + expect(addon.state.anchor).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('onWriteParsed reconcile is debounced to one pass per burst', async () => { + addon.predictChar('a'); + mock.setLine(0, '› Z'); // foreign cell: each PASS increments mismatches + mock.fireWriteParsed(); + mock.fireWriteParsed(); + mock.fireWriteParsed(); + await flushMicrotasks(); + // Three synchronous fires coalesced into ONE pass: not dropped yet + expect(addon.state.outstanding).toBe(1); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); // second pass cascades + }); + + it('onResize clears predictions (cell geometry changed)', () => { + addon.predictChar('a'); + mock.fireResize(120, 40); + expect(addon.state.outstanding).toBe(0); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('works without onWriteParsed via manual reconcile()', () => { + const bare = composerMock({ emitters: false }); + const a = new PredictiveEchoAddon(); + a.activate(bare.terminal as never); + a.predictChar('h'); + bare.setLine(0, '› h'); + bare.setCursor(3, 0); + a.reconcile(); + expect(a.state.confirmedTotal).toBe(1); + expect(a.state.outstanding).toBe(0); + a.dispose(); + bare.cleanup(); + }); + + it('dispose unhooks listeners and removes the container', () => { + expect(mock.writeParsedListenerCount()).toBe(1); + expect(mock.resizeListenerCount()).toBe(1); + addon.predictChar('a'); + addon.dispose(); + expect(mock.writeParsedListenerCount()).toBe(0); + expect(mock.resizeListenerCount()).toBe(0); + const screen = mock.terminal.element.querySelector('.xterm-screen')!; + expect(screen.querySelector('[data-predictive-echo]')).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('every public method is safe before activate and after dispose', () => { + const fresh = new PredictiveEchoAddon(); + expect(fresh.predictChar('a')).toBe(false); + expect(fresh.predictBackspace()).toBe(false); + fresh.clearPredictions(); + fresh.reconcile(); + fresh.refreshFont(); + fresh.setPredictWhen(() => true); + expect(fresh.hasPredictions).toBe(false); + expect(fresh.state.outstanding).toBe(0); + + addon.dispose(); + expect(addon.predictChar('a')).toBe(false); + expect(addon.predictBackspace()).toBe(false); + addon.clearPredictions(); + addon.reconcile(); + addon.refreshFont(); + expect(addon.hasPredictions).toBe(false); + }); + + it('hostile terminal stubs never propagate exceptions', () => { + const hostile = { + element: document.createElement('div'), + cols: 80, + rows: 24, + options: {}, + buffer: { + active: { + viewportY: 0, + baseY: 0, + cursorX: 0, + cursorY: 0, + getLine: () => { + throw new Error('boom'); + }, + }, + }, + }; + const a = new PredictiveEchoAddon(); + expect(() => a.activate(hostile as never)).not.toThrow(); + expect(a.predictChar('x')).toBe(false); // getLine throws inside -> caught + expect(() => a.reconcile()).not.toThrow(); + a.dispose(); + + // Terminal with no render dimensions: addon inert, no throws + const dimless = composerMock(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (dimless.terminal as any)._core; + const b = new PredictiveEchoAddon(); + b.activate(dimless.terminal as never); + expect(b.predictChar('x')).toBe(false); + b.dispose(); + dimless.cleanup(); + }); + + it('underlinePredictions styles spans; refreshFont re-reads the rendered color', () => { + const themed = composerMock({ theme: { foreground: '#aabbcc', background: '#112233' } }); + // The recipe prefers the computed .xterm-rows color (what xterm really + // renders with); give the mock rows an explicit color like a real skin. + const rows = themed.terminal.element.querySelector('.xterm-rows') as HTMLElement; + rows.style.color = 'rgb(170, 187, 204)'; + const a = new PredictiveEchoAddon({ underlinePredictions: true }); + a.activate(themed.terminal as never); + a.predictChar('u'); + const span = themed.terminal.element.querySelector('.xterm-screen span') as HTMLSpanElement; + expect(span.style.textDecoration).toBe('underline'); + expect(span.style.color).toBe('rgb(170, 187, 204)'); + + rows.style.color = 'rgb(255, 0, 0)'; // skin change + a.refreshFont(); + a.clearPredictions(); + a.predictChar('v'); + const span2 = themed.terminal.element.querySelector('.xterm-screen span') as HTMLSpanElement; + expect(span2.style.color).toBe('rgb(255, 0, 0)'); + a.dispose(); + themed.cleanup(); + }); + + it('state getter reports outstanding/confirmedTotal/droppedTotal/anchor', async () => { + expect(addon.state).toEqual({ outstanding: 0, confirmedTotal: 0, droppedTotal: 0, anchor: null }); + addon.predictChar('a'); + addon.predictChar('b'); + expect(addon.state.outstanding).toBe(2); + expect(addon.state.anchor).toEqual({ row: 0, col: 2 }); + expect(addon.hasPredictions).toBe(true); + + mock.setLine(0, '› a'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + addon.clearPredictions(); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.droppedTotal).toBe(1); + expect(addon.hasPredictions).toBe(false); + }); +}); diff --git a/packages/xterm-zerolag-input/test/predictive-echo-fuzz.test.ts b/packages/xterm-zerolag-input/test/predictive-echo-fuzz.test.ts new file mode 100644 index 00000000..8c5915c3 --- /dev/null +++ b/packages/xterm-zerolag-input/test/predictive-echo-fuzz.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment jsdom + * + * Layer 3: seeded property fuzz against the REAL xterm parser. Random + * interleavings of predictions, backspaces, clears, echo writes (correct, + * partial, foreign), screen clears, scrolls and cursor jumps; invariants + * checked after EVERY op: + * 1. span count === outstanding record count, every span inside the grid + * 2. no public method throws + * 3. eventual convergence: after the run settles (TTL elapse + reconcile), + * outstanding === 0 and the span container is empty + * + * Reproduce a failure with FUZZ_SEED= FUZZ_ITERS= npx vitest run + * test/predictive-echo-fuzz.test.ts (the failing seed+iter is in the + * assertion message). + */ +import { describe, expect, it } from 'vitest'; +import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js'; +import { CELL_H, CELL_W, createReplayTerminal } from './replay-helpers.js'; + +const SEED = Number(process.env.FUZZ_SEED ?? 1337); +const TOTAL_ITERS = Number(process.env.FUZZ_ITERS ?? 500); +const BATCHES = 4; +const TTL_MS = 5; + +function mulberry32(seed: number) { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const ALPHABET = [...'abcdefghij XZ!?', '你', '好', '😀']; + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function fuzzIteration(iter: number, label: string) { + const rand = mulberry32(SEED + iter); + const rt = createReplayTerminal(60, 12); + const addon = new PredictiveEchoAddon({ ttlMs: TTL_MS }); + addon.activate(rt.hybrid); + const ctx = `${label} seed=${SEED} iter=${iter}`; + + // Park the cursor mid-screen like a composer would + await rt.write('\x1b[6;3H'); + + const ops = 4 + Math.floor(rand() * 12); + for (let i = 0; i < ops; i++) { + const r = rand(); + if (r < 0.35) { + addon.predictChar(ALPHABET[Math.floor(rand() * ALPHABET.length)]); + } else if (r < 0.43) { + addon.predictBackspace(); + } else if (r < 0.48) { + addon.clearPredictions(); + } else if (r < 0.62) { + // Correct-ish echo: write a run of random chars at the anchor and + // leave the cursor advanced (confirms whatever happens to match) + const a = addon.state.anchor; + if (a) { + const n = 1 + Math.floor(rand() * 3); + let text = ''; + for (let k = 0; k < n; k++) text += ALPHABET[Math.floor(rand() * ALPHABET.length)]; + await rt.write(`\x1b[${a.row + 1};${a.col + 1}H${text}`); + } + } else if (r < 0.72) { + // Foreign rewrite across the anchor row + await rt.write(`\x1b[6;1H${'Q'.repeat(1 + Math.floor(rand() * 20))}`); + } else if (r < 0.8) { + // Scroll: newlines at the bottom push history + await rt.write(`\x1b[12;1H${'\r\n'.repeat(1 + Math.floor(rand() * 3))}`); + } else if (r < 0.85) { + await rt.write('\x1b[2J\x1b[H'); // clear screen + home + } else if (r < 0.95) { + addon.reconcile(); + } else { + // Cursor jump + const row = 1 + Math.floor(rand() * 12); + const col = 1 + Math.floor(rand() * 60); + await rt.write(`\x1b[${row};${col}H`); + } + await Promise.resolve(); // flush the debounced reconcile microtask + + // Invariant 1: span/record parity + grid bounds, after every op + expect(rt.spanCount(), ctx).toBe(addon.state.outstanding); + for (const s of rt.spans()) { + const left = parseFloat(s.style.left); + const width = parseFloat(s.style.width); + const top = parseFloat(s.style.top); + expect(left + width, ctx).toBeLessThanOrEqual(60 * CELL_W); + expect(top, ctx).toBeLessThanOrEqual(11 * CELL_H); + expect(left, ctx).toBeGreaterThanOrEqual(0); + } + } + + // Invariant 3: eventual convergence via echo/TTL, never via dispose + if (addon.state.outstanding > 0) { + await sleep(TTL_MS + 15); + addon.reconcile(); + } + expect(addon.state.outstanding, ctx).toBe(0); + expect(rt.spanCount(), ctx).toBe(0); + + addon.dispose(); + rt.cleanup(); +} + +describe(`predictive echo fuzz (${TOTAL_ITERS} iterations, seed ${SEED})`, () => { + const perBatch = Math.ceil(TOTAL_ITERS / BATCHES); + for (let b = 0; b < BATCHES; b++) { + it(`batch ${b + 1}/${BATCHES}`, async () => { + const start = b * perBatch; + const end = Math.min(start + perBatch, TOTAL_ITERS); + for (let iter = start; iter < end; iter++) { + await fuzzIteration(iter, `batch${b + 1}`); + } + }, 60000); + } +}); diff --git a/packages/xterm-zerolag-input/test/prompt-finder.test.ts b/packages/xterm-zerolag-input/test/prompt-finder.test.ts index 8313e377..69942a31 100644 --- a/packages/xterm-zerolag-input/test/prompt-finder.test.ts +++ b/packages/xterm-zerolag-input/test/prompt-finder.test.ts @@ -4,159 +4,155 @@ import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js'; import type { XtermTerminal, PromptFinder } from '../src/types.js'; function term(lines: string[]) { - return createMockTerminal({ buffer: { lines } }); + 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('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(); }); - 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(); - }); - - it('handles global flag safely (strips g to avoid lastIndex)', () => { - const { terminal, cleanup } = term(['user@host:~$ cmd']); - const finder: PromptFinder = { type: 'regex', pattern: /\$/g }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).not.toBeNull(); - expect(pos!.col).toBe(11); - // Call again — should return same result (no lastIndex drift) - const pos2 = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos2).toEqual(pos); - 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(); }); - 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(); - }); + 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(); + }); + + it('handles global flag safely (strips g to avoid lastIndex)', () => { + const { terminal, cleanup } = term(['user@host:~$ cmd']); + const finder: PromptFinder = { type: 'regex', pattern: /\$/g }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).not.toBeNull(); + expect(pos!.col).toBe(11); + // Call again — should return same result (no lastIndex drift) + const pos2 = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos2).toEqual(pos); + 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('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('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('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(); - }); + 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/raw-imports.d.ts b/packages/xterm-zerolag-input/test/raw-imports.d.ts new file mode 100644 index 00000000..7da7f31f --- /dev/null +++ b/packages/xterm-zerolag-input/test/raw-imports.d.ts @@ -0,0 +1,5 @@ +/** Vite `?raw` imports used by replay-helpers.ts (fixture JSONL as strings). */ +declare module '*.jsonl?raw' { + const content: string; + export default content; +} diff --git a/packages/xterm-zerolag-input/test/replay-helpers.ts b/packages/xterm-zerolag-input/test/replay-helpers.ts new file mode 100644 index 00000000..542f3788 --- /dev/null +++ b/packages/xterm-zerolag-input/test/replay-helpers.ts @@ -0,0 +1,171 @@ +/** + * Replay-test helpers: a structural hybrid terminal whose buffer, cursor and + * onWriteParsed delegate to a REAL @xterm/headless Terminal (so fixtures run + * through the real parser), while `element` is a jsdom div the addon can + * paint spans into. Works because XtermTerminal is structurally typed. + * + * Also carries the test-side mirror of Codeman's classifyPredictInput() and + * codex composer gate (the real ones live in terminal-ui.js and are pinned by + * the repo's Layer 4 vm tests; keep the two in sync). + */ +import { Terminal } from '@xterm/headless'; +import type { XtermTerminal } from '../src/types.js'; +// ?raw imports keep the jsdom environment free of node: builtins +import pasteBracketed from './fixtures/codex/paste-bracketed.jsonl?raw'; +import slashPicker from './fixtures/codex/slash-picker.jsonl?raw'; +import streamingBurst from './fixtures/codex/streaming-burst.jsonl?raw'; +import trustModal from './fixtures/codex/trust-modal.jsonl?raw'; +import typeHello from './fixtures/codex/type-hello.jsonl?raw'; +import wrap from './fixtures/codex/wrap.jsonl?raw'; + +const FIXTURES: Record = { + 'paste-bracketed': pasteBracketed, + 'slash-picker': slashPicker, + 'streaming-burst': streamingBurst, + 'trust-modal': trustModal, + 'type-hello': typeHello, + wrap, +}; + +export const CELL_W = 9; +export const CELL_H = 18; + +export interface FixtureLine { + delayMs?: number; + keyAt?: boolean; + data: string; +} + +export interface FixtureMeta { + scenario: string; + cols: number; + rows: number; + codexVersion: string; + recordedAt: string; +} + +export function loadFixture(name: string): { meta: FixtureMeta; lines: FixtureLine[] } { + const content = FIXTURES[name]; + if (!content) throw new Error(`unknown fixture ${name}`); + const raw = content + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + return { meta: raw[0] as FixtureMeta, lines: raw.slice(1) as FixtureLine[] }; +} + +export interface ReplayTerminal { + hybrid: XtermTerminal; + term: Terminal; + write(data: string): Promise; + cursorRowText(): string; + rowText(viewportRow: number): string; + spanCount(): number; + spans(): HTMLSpanElement[]; + cleanup(): void; +} + +export function createReplayTerminal(cols: number, rows: number): ReplayTerminal { + const term = new Terminal({ cols, rows, scrollback: 2000, allowProposedApi: true }); + + const element = document.createElement('div'); + element.className = 'terminal xterm'; + const screen = document.createElement('div'); + screen.className = 'xterm-screen'; + const rowsEl = document.createElement('div'); + rowsEl.className = 'xterm-rows'; + element.appendChild(screen); + screen.appendChild(rowsEl); + document.body.appendChild(element); + + const hybrid = { + element, + get cols() { + return term.cols; + }, + get rows() { + return term.rows; + }, + options: { fontFamily: 'monospace', fontSize: 14, fontWeight: 'normal', theme: {} }, + buffer: { + active: { + get viewportY() { + return term.buffer.active.viewportY; + }, + get baseY() { + return term.buffer.active.baseY; + }, + get cursorX() { + return term.buffer.active.cursorX; + }, + get cursorY() { + return term.buffer.active.cursorY; + }, + getLine: (y: number) => term.buffer.active.getLine(y), + }, + }, + onWriteParsed: (cb: () => void) => term.onWriteParsed(cb), + onResize: (cb: (s: { cols: number; rows: number }) => void) => term.onResize(cb), + _core: { + _renderService: { + dimensions: { + css: { cell: { width: CELL_W, height: CELL_H } }, + device: { char: { top: 0, height: CELL_H } }, + }, + }, + }, + }; + + return { + hybrid: hybrid as unknown as XtermTerminal, + term, + write: (data: string) => new Promise((resolve) => term.write(data, () => resolve())), + cursorRowText() { + const b = term.buffer.active; + return b.getLine(b.baseY + b.cursorY)?.translateToString(true) ?? ''; + }, + rowText(viewportRow: number) { + const b = term.buffer.active; + return b.getLine(b.baseY + viewportRow)?.translateToString(true) ?? ''; + }, + spanCount() { + return element.querySelectorAll('[data-predictive-echo] span').length; + }, + spans() { + return Array.from(element.querySelectorAll('[data-predictive-echo] span')) as HTMLSpanElement[]; + }, + cleanup() { + term.dispose(); + element.remove(); + }, + }; +} + +// ─── Codeman-side mirrors (keep in sync with terminal-ui.js) ──────────── + +/** Mirror of window.CodemanTerminalInput.classifyPredictInput. */ +export function classifyPredictInput(data: string): 'char' | 'backspace' | 'clear' | 'text' { + const cps = Array.from(data); + if (cps.length === 1) { + const cp = cps[0].codePointAt(0)!; + if (cp === 0x7f) return 'backspace'; + if (cp >= 0x20) return 'char'; + return 'clear'; + } + if (data.charCodeAt(0) === 0x1b) return 'clear'; + if (data.charCodeAt(0) >= 0x20) return 'text'; + return 'clear'; +} + +/** Mirror of the codex composer-row gate (CODEX_COMPOSER_ROW_RE). */ +export const CODEX_COMPOSER_ROW_RE = /^› /; + +export function codexComposerGate(terminal: XtermTerminal): boolean { + try { + const buf = terminal.buffer.active; + const line = buf.getLine(buf.baseY + (buf.cursorY ?? 0)); + return !!line && CODEX_COMPOSER_ROW_RE.test(line.translateToString(true)); + } catch { + return false; + } +} diff --git a/packages/xterm-zerolag-input/tsup.config.ts b/packages/xterm-zerolag-input/tsup.config.ts index f3e3678e..72c92fd4 100644 --- a/packages/xterm-zerolag-input/tsup.config.ts +++ b/packages/xterm-zerolag-input/tsup.config.ts @@ -26,6 +26,13 @@ export default defineConfig([ ' this.activate(terminal);', ' }', ' };', + ' window.PredictiveEchoAddon=XtermZerolagInput.PredictiveEchoAddon;', + ' window.PredictiveEchoOverlay=class extends XtermZerolagInput.PredictiveEchoAddon{', + ' constructor(terminal){', + ' super({});', + ' this.activate(terminal);', + ' }', + ' };', '}', ].join('\n'), },