mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(predictive-echo): PredictiveEchoAddon + Layers 1-3 test suites (0.2.0)
Mosh-style write-through prediction: the consumer sends every keystroke unchanged; the addon paints predicted glyphs and reconciles against the parsed buffer. Confirm = cell match + cursor advance (placeholder-safe, repaint-safe); two-pass mismatch cascade with neutral blanks (measured: codex clears its placeholder on first echo); TTL bound; baseY-based line reads; scroll/resize/off-row clears. Zero edits to zerolag-input-addon.ts. Tests: 30 addon-law specs + renderer geometry (fake performance clock for TTL/grace), 6 replay suites running the real algorithm through a real @xterm/headless parser fed by the recorded codex fixtures, and a 500-iteration seeded fuzz with per-op span/record + grid invariants. 227 total, the pre-existing 175 untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
|
||||
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
|
||||
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
|
||||
<img src="https://img.shields.io/badge/Tests-175-22c55e?style=flat-square" alt="175 tests">
|
||||
<img src="https://img.shields.io/badge/Tests-227-22c55e?style=flat-square" alt="175 tests">
|
||||
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
|
||||
</p>
|
||||
</p>
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,8 +13,7 @@ import type { XtermTerminal, CellDimensions } from './types.js';
|
||||
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;
|
||||
const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;
|
||||
|
||||
// Try v7+ public API first
|
||||
if (t.dimensions?.css?.cell) {
|
||||
@@ -23,7 +22,7 @@ export function getCellDimensions(terminal: XtermTerminal): CellDimensions | nul
|
||||
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,
|
||||
charHeight: (t.dimensions?.device?.char?.height ?? cellH * dpr) / dpr,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +35,7 @@ export function getCellDimensions(terminal: XtermTerminal): CellDimensions | nul
|
||||
width: dims.css.cell.width,
|
||||
height: cellH,
|
||||
charTop: (dims.device?.char?.top ?? 0) / dpr,
|
||||
charHeight: (dims.device?.char?.height ?? (cellH * dpr)) / dpr,
|
||||
charHeight: (dims.device?.char?.height ?? cellH * dpr) / dpr,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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,
|
||||
@@ -8,3 +10,4 @@ export type {
|
||||
PromptPosition,
|
||||
CellDimensions,
|
||||
} from './types.js';
|
||||
export type { PredictiveEchoOptions, PredictionState } from './predictive-echo-addon.js';
|
||||
|
||||
@@ -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<number, HTMLSpanElement>();
|
||||
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<typeof setTimeout> | null = null;
|
||||
private _reconcileScheduled = false;
|
||||
private _disposables: Array<{ dispose(): void }> = [];
|
||||
private _predictWhen: ((terminal: XtermTerminal) => boolean) | null;
|
||||
private _options: Required<Omit<PredictiveEchoOptions, 'foregroundColor' | 'backgroundColor' | 'predictWhen'>> &
|
||||
Pick<PredictiveEchoOptions, 'foregroundColor' | 'backgroundColor'>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,7 @@ 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 {
|
||||
export function findPrompt(terminal: XtermTerminal, finder: PromptFinder): PromptPosition | null {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const viewportTop = buffer.viewportY;
|
||||
@@ -30,9 +27,7 @@ export function findPrompt(
|
||||
// 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;
|
||||
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;
|
||||
@@ -65,11 +60,7 @@ 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 {
|
||||
export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosition, offset: number): string {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const absRow = buffer.viewportY + prompt.row;
|
||||
|
||||
@@ -23,7 +23,8 @@ describe('getCellDimensions', () => {
|
||||
|
||||
it('returns charTop from device.char.top divided by DPR', () => {
|
||||
const mock = createMockTerminal({
|
||||
cellWidth: 8, cellHeight: 19,
|
||||
cellWidth: 8,
|
||||
cellHeight: 19,
|
||||
deviceCharTop: 2,
|
||||
});
|
||||
cleanups.push(mock.cleanup);
|
||||
@@ -35,7 +36,8 @@ describe('getCellDimensions', () => {
|
||||
|
||||
it('returns charHeight from device.char.height divided by DPR', () => {
|
||||
const mock = createMockTerminal({
|
||||
cellWidth: 8, cellHeight: 19,
|
||||
cellWidth: 8,
|
||||
cellHeight: 19,
|
||||
deviceCharHeight: 16,
|
||||
});
|
||||
cleanups.push(mock.cleanup);
|
||||
@@ -84,7 +86,8 @@ describe('getCellDimensions', () => {
|
||||
|
||||
it('divides device.char.top by DPR', () => {
|
||||
const mock = createMockTerminal({
|
||||
cellWidth: 16, cellHeight: 38,
|
||||
cellWidth: 16,
|
||||
cellHeight: 38,
|
||||
deviceCharTop: 4,
|
||||
deviceCharHeight: 32,
|
||||
});
|
||||
|
||||
@@ -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<typeof classifyPredictInput>;
|
||||
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);
|
||||
});
|
||||
@@ -3,10 +3,17 @@
|
||||
*
|
||||
* 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;
|
||||
getCell?(x: number): { getChars(): string; getWidth(): number } | undefined;
|
||||
}
|
||||
|
||||
interface MockBufferOptions {
|
||||
@@ -35,21 +42,47 @@ interface MockTerminalOptions {
|
||||
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 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,
|
||||
}));
|
||||
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) };
|
||||
};
|
||||
|
||||
let mockLines = bufOpts.lines.map(makeLine);
|
||||
|
||||
// Create minimal DOM structure
|
||||
const element = document.createElement('div');
|
||||
@@ -72,6 +105,9 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
||||
// 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,
|
||||
@@ -89,7 +125,7 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
||||
cursorX: bufOpts.cursorX ?? 0,
|
||||
cursorY: bufOpts.cursorY ?? 0,
|
||||
getLine: (absRow: number): MockLine | undefined => {
|
||||
return mockLines[absRow - viewportY];
|
||||
return mockLines[absRow - viewportY]?.line;
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -108,6 +144,18 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
||||
},
|
||||
},
|
||||
},
|
||||
...(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);
|
||||
@@ -118,10 +166,37 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
||||
terminal,
|
||||
/** Update buffer lines for subsequent calls */
|
||||
setLines(newLines: string[]) {
|
||||
mockLines.length = 0;
|
||||
for (const text of newLines) {
|
||||
mockLines.push({ translateToString: () => text });
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -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<number, HTMLSpanElement>, over: Record<string, unknown> = {}) {
|
||||
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<number, HTMLSpanElement>();
|
||||
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<number, HTMLSpanElement>();
|
||||
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<number, HTMLSpanElement>();
|
||||
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<number, HTMLSpanElement>();
|
||||
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<number, HTMLSpanElement>();
|
||||
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<number, HTMLSpanElement>();
|
||||
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<number, HTMLSpanElement>();
|
||||
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<number, HTMLSpanElement>();
|
||||
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<number, HTMLSpanElement>();
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createMockTerminal>[0] = {}) {
|
||||
return createMockTerminal({
|
||||
buffer: { lines: ['› Use /skills to list', '', ''], cursorX: 2, cursorY: 0 },
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function spansOf(mock: ReturnType<typeof createMockTerminal>): 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<typeof createMockTerminal>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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=<seed> FUZZ_ITERS=<n> 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);
|
||||
}
|
||||
});
|
||||
@@ -34,11 +34,7 @@ describe('findPrompt', () => {
|
||||
});
|
||||
|
||||
it('scans bottom-up and returns lowest match', () => {
|
||||
const { terminal, cleanup } = term([
|
||||
'$ old prompt',
|
||||
'output',
|
||||
'$ current prompt',
|
||||
]);
|
||||
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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'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<void>;
|
||||
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<void>((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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user