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 @@
-
+
@@ -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'),
},