From 5aa59c70ccb4a2944f9929b56fba83069610fc8d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 04:10:17 +0200 Subject: [PATCH 1/9] feat(predictive-echo): Phase 0 codex fixtures, measurements, package scaffolding Recorder (scripts/dev/record-codex-frames.mjs) captures real codex 0.147 TUI output through the production pipeline (tmux status-off + the codex-mode full strip from session.ts) into JSONL fixtures with keystroke injection points; analyzer replays them through @xterm/headless for the measurements in docs/predictive-echo-plan.md. Composer signature /^> /-style (U+203A), modal and wrapped rows correctly rejected, echo is unstyled default-fg, tmux delivers echo as minimal in-place deltas. Package: types.ts gains optional cursorX/cursorY, getCell, onWriteParsed, onResize (all additive); prediction-renderer.ts renders per-glyph spans keyed by prediction seq; @xterm/headless@^6.0.0 devDep for replay tests. Co-Authored-By: Claude Fable 5 --- docs/predictive-echo-plan.md | 135 ++++++++++ package-lock.json | 11 + packages/xterm-zerolag-input/package.json | 1 + .../src/prediction-renderer.ts | 59 +++++ packages/xterm-zerolag-input/src/types.ts | 10 + .../test/fixtures/codex/paste-bracketed.jsonl | 28 ++ .../test/fixtures/codex/slash-picker.jsonl | 32 +++ .../test/fixtures/codex/streaming-burst.jsonl | 233 ++++++++++++++++ .../test/fixtures/codex/trust-modal.jsonl | 28 ++ .../test/fixtures/codex/type-hello.jsonl | 33 +++ .../test/fixtures/codex/wrap.jsonl | 250 ++++++++++++++++++ scripts/dev/analyze-codex-frames.mjs | 57 ++++ scripts/dev/record-codex-frames.mjs | 190 +++++++++++++ 13 files changed, 1067 insertions(+) create mode 100644 docs/predictive-echo-plan.md create mode 100644 packages/xterm-zerolag-input/src/prediction-renderer.ts create mode 100644 packages/xterm-zerolag-input/test/fixtures/codex/paste-bracketed.jsonl create mode 100644 packages/xterm-zerolag-input/test/fixtures/codex/slash-picker.jsonl create mode 100644 packages/xterm-zerolag-input/test/fixtures/codex/streaming-burst.jsonl create mode 100644 packages/xterm-zerolag-input/test/fixtures/codex/trust-modal.jsonl create mode 100644 packages/xterm-zerolag-input/test/fixtures/codex/type-hello.jsonl create mode 100644 packages/xterm-zerolag-input/test/fixtures/codex/wrap.jsonl create mode 100644 scripts/dev/analyze-codex-frames.mjs create mode 100644 scripts/dev/record-codex-frames.mjs diff --git a/docs/predictive-echo-plan.md b/docs/predictive-echo-plan.md new file mode 100644 index 00000000..bb1e3623 --- /dev/null +++ b/docs/predictive-echo-plan.md @@ -0,0 +1,135 @@ +# Predictive write-through echo for codex + +Zero-lag local echo for codex sessions via a second, mosh-style mode in the +`xterm-zerolag-input` package: every keystroke goes to the PTY exactly as the +1.12.2 overlay-disabled path did (byte-identical wire behavior), while a +`PredictiveEchoAddon` simultaneously paints the predicted glyph at the predicted +cell. When the real echo lands, the prediction is confirmed and its span removed +(invisible swap: identical glyph beneath). Mispredictions drop via a mismatch +cascade + TTL. Visual-only, self-healing. + +## Why this exists + +Issues #218/#219/#220/#222 (one root cause) forced 1.12.2 to disable the +LocalEchoOverlay for codex: buffer-until-Enter starves codex's per-keystroke TUI +(live slash picker, arrows editing server-side composer state, composer +rewrap/growth, paste_burst classification). Buffer mode is structurally +incompatible with codex; write-through prediction is the only echo mode that +can coexist with it. + +## The reconciliation lesson (do not regress this) + +`docs/local-echo-overlay-plan.md` ("What NOT to Do") documented that matching +predictions against the raw output STREAM fails against Ink/TUI full-line +redraws. This design reads the parsed terminal BUFFER instead (cells after +xterm's parser ran), which converges to the same cells no matter how the bytes +arrived. The Phase 0 recordings prove the point twice over: tmux converts +codex's full-line redraws into minimal in-place deltas (an echo arrives as +`e\x1b[K\x1b[20;80H...`), and codex itself paints word gaps with ECH+cursor-forward +instead of spaces. Stream matching can never survive that; buffer diffing does +not care. + +## Phase 0 measurements (codex-cli 0.147.0 via tmux, 100x30, 2026-08-09) + +Recorded with `scripts/dev/record-codex-frames.mjs` (production pipeline: +codex inside tmux `status off`, chunks passed through the same full strip +`session.ts _handleTerminalOutput()` applies to codex mode). Fixtures in +`packages/xterm-zerolag-input/test/fixtures/codex/`; replay/measure with +`scripts/dev/analyze-codex-frames.mjs `. + +| Question | Measured answer | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Composer signature | Cursor row starts `"› "` (U+203A + space), text begins col 2. Present when empty (placeholder), while typing, and while the slash picker filters. `CODEX_COMPOSER_ROW_RE = /^› /` | +| Composer text color | Plain default foreground, zero SGR around echoed chars. Span `foregroundColor` default (theme fg) is an exact match | +| Placeholder | Cycling hint text ("Use /skills...", "Improve documentation in @filename", ...) rendered AT the cursor cell. First prediction lands over placeholder glyphs: covered by the snapshot + cursor-advance rules | +| Wrap | Word-wrap near `cols - 2`; continuation rows are indented 2 spaces WITHOUT `› `. The gate therefore suppresses predictions on wrapped lines: deliberate fallback to real echo, wrap was the #220 ghost zone. `edgeMarginCells = 4` | +| Modal (trust dialog) | Cursor parks on `" Press enter to continue"`: no `› ` prefix, gate false, zero predictions painted while keystrokes still reach the PTY (the ghost eliminator) | +| Streaming | Error/reconnect bursts render above a re-rendered composer that keeps the `› ` signature; end-of-frame cursor parks at the insertion point (col 2 of the composer row). Confirms the cursor-advance confirm rule and the no-drop-on-baseY rule | +| Echo shape under tmux | tmux emits minimal deltas for simple echoes and full repaints for busy frames; both converge in the parsed buffer | +| Slash picker | Picker rows render below; the cursor row keeps the composer signature and advances per filter char, so predictions stay active while filtering (#222 surface) | + +Constants decided at the Phase 0 gate: `CODEX_COMPOSER_ROW_RE = /^› /`, +`ttlMs = 1000`, `maxPending = 32`, `cursorGraceMs = 150`, `edgeMarginCells = 4`, +span colors = theme defaults, `underlinePredictions = false`. + +## Algorithm + +See `PredictiveEchoAddon` in +`packages/xterm-zerolag-input/src/predictive-echo-addon.ts`. Summary of the +rules and why each exists: + +- **State**: ordered `PredictionRecord[]` (`seq`, `char`, `width`, cumulative + `offsetCells`, `snapshot` of the cell at predict time, `sentAt`, + `mismatches`), plus a run `_anchor {row, col}` captured when the outstanding + count goes 0 -> 1. Positions are FIXED at predict time; confirmation deletes + spans and never re-lays-out, so partial confirmation causes zero jitter. +- **predictChar(ch)** runs an inline reconcile first and re-anchors whenever + outstanding drains to zero (absorbs the echo-landed-between-keystrokes race). + Guards: dims present, cursor numbers present, `viewportY === baseY`, + `predictWhen` gate, single codepoint >= 0x20 (not 0x7f), width <= 2, + `maxPending`, edge margin. Returns false = suppressed; the consumer sends the + keystroke regardless. +- **Coordinate base is `baseY`**: xterm's `cursorY` is baseY-relative, so + absolute buffer line = `baseY + row`. `viewportY` would only coincide while + the scrolled-to-bottom guards hold; the addon never relies on that. +- **reconcile()** (debounced `onWriteParsed` microtask, inline in predictChar, + TTL timer): clears everything when scrolled up; off-anchor-row cursor + tolerated for `cursorGraceMs` then clears; PREFIX-ONLY confirm loop requiring + cell match AND cursor advanced past the record (prevents false confirms + against placeholder glyphs and makes identical in-place tmux repaints a + no-op); TWO-PASS mismatch rule (a cell that is neither snapshot nor predicted + char must persist across two passes before cascading the drop: a half-parsed + row on pass N is fully redrawn a few ms later); TTL drop of the stale suffix. +- **No drop on baseY change**: codex streams push lines to history while the + composer stays viewport-pinned; predictions are row-relative to the pinned + composer and remain valid (measured above). +- **predictBackspace()** pops the newest outstanding record (informational + return; the consumer forwards `\x7f` unconditionally). Deleting already-echoed + text renders at RTT in v1. +- **CJK/wide**: 2-cell spans, stacking by cumulative visual width, leading-cell + confirm. In Codeman, IME input never reaches the hook (`window.cjkActive` + returns from onData first); package support exists for other consumers. + +## Integration map (Codeman) + +- Policy: `_localEchoPolicy` (`'buffer' | 'predict' | 'off'`) computed at the + end of `_updateLocalEchoState()`; codex + `localEchoEnabled` -> `'predict'` + while `_localEchoEnabled` stays false (every 1.12.2 consumer unchanged). +- onData hook sits between the buffer block and Normal Mode, classifies via + `classifyPredictInput()` (pure, on `window.CodemanTerminalInput`), never + returns, try/catch-wrapped: the wire path below is byte-identical with the + predictor active, absent, or throwing. +- Composer gate: `isCodexComposerRow()` set via `setPredictWhen()` at + construction (the vendor footer stays package-agnostic). +- Second vendor bundle `vendor/xterm-predictive-echo.js` (postinstall + build); + the zerolag bundle build command is untouched and its output byte-identical. + Missing/broken bundle = plain 1.12.2 echo (`typeof PredictiveEchoOverlay === + 'undefined'` guard). +- Prediction clears on: tab switch, SSE reconnect init, `insertTerminalText`, + `clearTerminalInput`, voice send, keyboard-accessory `sendKey`, resize, skin + and font changes re-read style via `refreshFont()`. + +## Risk register + +Eliminated structurally: other-mode regression (zero edits to buffer +addon/branches, byte-identical existing bundle, policy-matrix + byte-identity +tests); bundle breakage (separate bundle, graceful degradation); wire +corruption (no-return fall-through + try/catch + byte-identity pins at vm and +E2E level); modal ghosts (measured predictWhen gate); false confirms +(cursor-advance rule); mid-parse flicker drops (two-pass rule); wrap +misplacement (edge margin + continuation-row gate fallback + off-row grace). + +Accepted residuals (visual-only, self-healing <= ttlMs, kill-switchable via +`localEchoEnabled` per device): no predictions on wrapped continuation lines +(gate false there, deliberate); brief dropout during composer growth; DOM-span +vs WebGL glyph rendering can differ subtly (same trade-off as the buffer +overlay, same font recipe); typing during an unsynchronized half-frame can +mis-anchor one run (mismatch/TTL cleans within 1s). + +## Future work + +RTT-adaptive TTL; mosh-style confidence gating (paint only after the link +proves laggy); predicted backspace into echoed text; predict mode for shell +prompts; unifying the small font/container duplication between the two addons +once predict mode has proven out; continuation-line prediction behind a +smarter composer-extent detector. diff --git a/package-lock.json b/package-lock.json index 6e00a6f8..9989577c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4547,6 +4547,16 @@ "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", "license": "MIT" }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -12336,6 +12346,7 @@ "version": "0.1.8", "license": "MIT", "devDependencies": { + "@xterm/headless": "^6.0.0", "jsdom": "^24.1.3", "tsup": "^8.5.1", "typescript": "^5.5.0", diff --git a/packages/xterm-zerolag-input/package.json b/packages/xterm-zerolag-input/package.json index 48538c0d..584309bb 100644 --- a/packages/xterm-zerolag-input/package.json +++ b/packages/xterm-zerolag-input/package.json @@ -50,6 +50,7 @@ "directory": "packages/xterm-zerolag-input" }, "devDependencies": { + "@xterm/headless": "^6.0.0", "jsdom": "^24.1.3", "tsup": "^8.5.1", "typescript": "^5.5.0", diff --git a/packages/xterm-zerolag-input/src/prediction-renderer.ts b/packages/xterm-zerolag-input/src/prediction-renderer.ts new file mode 100644 index 00000000..888e6791 --- /dev/null +++ b/packages/xterm-zerolag-input/src/prediction-renderer.ts @@ -0,0 +1,59 @@ +/** + * Incremental DOM renderer for PredictiveEchoAddon. + * + * Unlike overlay-renderer.ts (which paints whole lines with an opaque + * background out to totalCols), prediction spans cover ONLY the predicted + * glyph's own cells: anything wider would blank real echo arriving around + * a prediction. Spans are keyed by prediction seq for O(1) removal. + */ +import type { CellDimensions, FontStyle } from './types.js'; + +export interface PredictionSpanParams { + seq: number; + /** Viewport-relative row (0-based). */ + row: number; + /** Column (0-based). */ + col: number; + char: string; + /** Cell width of the glyph (1 or 2). */ + width: 1 | 2; + dims: CellDimensions; + font: FontStyle; + underline: boolean; +} + +export function addPredictionSpan( + container: HTMLElement, + map: Map, + p: PredictionSpanParams +): void { + const span = document.createElement('span'); + // cellH+1 height: covers the sub-pixel seam between rows (same trick the + // buffer overlay renderer ships with). Background covers only this glyph's + // cells, never a full row. + span.style.cssText = + `position:absolute;left:${p.col * p.dims.width}px;top:${p.row * p.dims.height}px;` + + `width:${p.width * p.dims.width}px;height:${p.dims.height + 1}px;line-height:${p.dims.height}px;` + + `text-align:center;pointer-events:none;` + + `font-family:${p.font.fontFamily};font-size:${p.font.fontSize};font-weight:${p.font.fontWeight};` + + (p.font.letterSpacing ? `letter-spacing:${p.font.letterSpacing};` : '') + + `color:${p.font.color};background-color:${p.font.backgroundColor};` + + `font-feature-settings:'liga' 0,'calt' 0;` + + (p.underline ? 'text-decoration:underline;' : ''); + span.textContent = p.char; + map.set(p.seq, span); + container.appendChild(span); +} + +export function removePredictionSpan(map: Map, seq: number): void { + const span = map.get(seq); + if (span) { + span.remove(); + map.delete(seq); + } +} + +export function clearAllSpans(map: Map): void { + for (const span of map.values()) span.remove(); + map.clear(); +} diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts index 08dd0050..dedd7ca5 100644 --- a/packages/xterm-zerolag-input/src/types.ts +++ b/packages/xterm-zerolag-input/src/types.ts @@ -22,9 +22,15 @@ export interface XtermTerminal { readonly active: { readonly viewportY: number; readonly baseY: number; + /** Cursor column (0-based). Used by PredictiveEchoAddon. */ + readonly cursorX?: number; + /** Cursor row, relative to baseY (0-based). Used by PredictiveEchoAddon. */ + readonly cursorY?: number; getLine(y: number): | { translateToString(trimRight?: boolean): string; + /** Cell access (xterm public API). Optional: mocks/exotic hosts may omit it. */ + getCell?(x: number): { getChars(): string; getWidth(): number } | undefined; } | undefined; }; @@ -34,6 +40,10 @@ export interface XtermTerminal { getStringCellWidth(str: string): number; activeVersion?: string; }; + /** Fires after the parser finishes a write chunk. Used by PredictiveEchoAddon. */ + onWriteParsed?(cb: () => void): { dispose(): void }; + /** Fires on terminal resize. Used by PredictiveEchoAddon. */ + onResize?(cb: (size: { cols: number; rows: number }) => void): { dispose(): void }; } /** diff --git a/packages/xterm-zerolag-input/test/fixtures/codex/paste-bracketed.jsonl b/packages/xterm-zerolag-input/test/fixtures/codex/paste-bracketed.jsonl new file mode 100644 index 00000000..02dd0bac --- /dev/null +++ b/packages/xterm-zerolag-input/test/fixtures/codex/paste-bracketed.jsonl @@ -0,0 +1,28 @@ +{"scenario":"paste-bracketed","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:51:11.762Z"} +{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":45,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b(B\u001b[m$ "} +{"delayMs":638,"data":"exec codex\r\n"} +{"delayMs":420,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":182,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":5,"data":"\r\n\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[1;30r\u001b[4;1H\u001b(B\u001b[m"} +{"delayMs":1,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bWhHjh\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mSummarize rec\u001b(B\u001b[m\u001b[2ment commits\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b[14;3H\u001b(B\u001b[m"} +{"delayMs":7,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;27H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":21,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;27H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;27H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":159,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mSummarize recent commits\u001b[9;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b[7;3H\u001b(B\u001b[m"} +{"delayMs":21,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bWhHjh\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"} +{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;27H\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[24C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[24C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":3487,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bWhHjh\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CSummarize recent commits\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"} +{"keyAt":true,"data":"a"} +{"delayMs":207,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"} +{"keyAt":true,"data":"b"} +{"delayMs":91,"data":"b\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"} +{"keyAt":true,"data":"\u001b[200~XYZpasted\u001b[201~"} +{"delayMs":383,"data":"XYZpasted\u001b[K\u001b[20;80H\u001b[K\u001b[18;14H"} diff --git a/packages/xterm-zerolag-input/test/fixtures/codex/slash-picker.jsonl b/packages/xterm-zerolag-input/test/fixtures/codex/slash-picker.jsonl new file mode 100644 index 00000000..df2148d1 --- /dev/null +++ b/packages/xterm-zerolag-input/test/fixtures/codex/slash-picker.jsonl @@ -0,0 +1,32 @@ +{"scenario":"slash-picker","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:50:42.069Z"} +{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":37,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b(B\u001b[m$ "} +{"delayMs":647,"data":"exec codex\r\n"} +{"delayMs":437,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":183,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":8,"data":"\r\n\u001b[J\u001b[A\u001b[K\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b[39m \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bw9Uto\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b[1;30r\u001b[14;3H\u001b(B\u001b[m"} +{"delayMs":9,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":12,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":10,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":157,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[9;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b[7;3H\u001b(B\u001b[m"} +{"delayMs":20,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m"} +{"delayMs":0,"data":"\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bw9Uto\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"} +{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;39H\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":3476,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bw9Uto\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CUse /skills to list available skills\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"} +{"keyAt":true,"data":"/"} +{"delayMs":207,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m/\u001b[20;3H\u001b[36m\u001b[1m/model choose what model and reasoning effort to use\u001b[21;3H\u001b(B\u001b[m/fast\u001b[10C\u001b[2m1.5x speed, increased usage\u001b[22;3H\u001b(B\u001b[m/ide\u001b[11C\u001b[2minclude current selection, open files, and other context from your IDE\u001b[23;3H\u001b(B\u001b[m/permissions\u001b[3C\u001b[2mchoose what Codex is allowed to do\u001b[24;3H\u001b(B\u001b[m/keymap\u001b[8C\u001b[2mremap TUI shortcuts\u001b[25;3H\u001b(B\u001b[m/vim\u001b[11C\u001b[2mtoggle Vim mode for the composer\u001b[26;3H\u001b(B\u001b[m/experimental\u001b[2C\u001b[2mtoggle experimental features\u001b[27;3H\u001b(B\u001b[m/approve\u001b[7C\u001b[2mapprove one retry of a recent auto-review denial\u001b[18;4H\u001b(B\u001b[m"} +{"keyAt":true,"data":"m"} +{"delayMs":398,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m/m\u001b[20;3H\u001b[36m\u001b[1m/model choose what model and reasoning effort to use\u001b[21;3H\u001b(B\u001b[m/\u001b[1mm\u001b(B\u001b[memories\u001b[2C\u001b[2mconfigure memory use and generation\u001b[22;3H\u001b(B\u001b[m/\u001b[1mm\u001b(B\u001b[mention\u001b[3C\u001b[2mmention a file\u001b[23;3H\u001b(B\u001b[m/\u001b[1mm\u001b(B\u001b[mcp\u001b[7C\u001b[2mlist configured MCP tools; use /mcp verbose for details\u001b[18;5H\u001b(B\u001b[m"} +{"keyAt":true,"data":"o"} +{"delayMs":148,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m/mo\u001b[20;3H\u001b[36m\u001b[1m/model choose what model and reasoning effort to use\u001b[18;6H\u001b(B\u001b[m"} +{"keyAt":true,"data":"\u001b"} diff --git a/packages/xterm-zerolag-input/test/fixtures/codex/streaming-burst.jsonl b/packages/xterm-zerolag-input/test/fixtures/codex/streaming-burst.jsonl new file mode 100644 index 00000000..22dd53a5 --- /dev/null +++ b/packages/xterm-zerolag-input/test/fixtures/codex/streaming-burst.jsonl @@ -0,0 +1,233 @@ +{"scenario":"streaming-burst","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:51:03.828Z"} +{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":39,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b(B\u001b[m$ "} +{"delayMs":635,"data":"exec codex\r\n"} +{"delayMs":439,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":184,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":9,"data":"\r\n\u001b[J\u001b[A\u001b[K\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[1;30r\u001b[2;1H\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b(B\u001b[m \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-ruT16A\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[14;3H\u001b(B\u001b[m"} +{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":158,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[9;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[7;3H\u001b(B\u001b[m"} +{"delayMs":21,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-ruT16A\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"} +{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;39H\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":3486,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-ruT16A\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CUse /skills to list available skills\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"} +{"keyAt":true,"data":"h"} +{"delayMs":199,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"} +{"keyAt":true,"data":"e"} +{"delayMs":40,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"} +{"keyAt":true,"data":"l"} +{"delayMs":40,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;6H"} +{"keyAt":true,"data":"l"} +{"delayMs":40,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;7H"} +{"keyAt":true,"data":"o"} +{"delayMs":40,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;8H"} +{"keyAt":true,"data":"\r"} +{"delayMs":281,"data":"\u001b[16;30r\u001b[16;1H\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[18;1H"} +{"delayMs":0,"data":"\u001b[1m\u001b[2m› \u001b(B\u001b[mhello\r\n"} +{"delayMs":0,"data":"\u001b[22;3H\u001b[2mUse /skills to list available skills\u001b(B\u001b[m\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"} +{"delayMs":12,"data":"\u001b[36C\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"} +{"delayMs":6,"data":"\u001b[36C\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"} +{"delayMs":6,"data":"\u001b[36C\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"} +{"delayMs":118,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":1,"data":"\r\n•\u001b[C\u001b[2mWorking\u001b[C(0s • esc to interrupt)\u001b[24;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[26;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[24;3H\u001b(B\u001b[m"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":34,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":34,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[3AW\u001b[30C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[C\u001b(B\u001b[m\u001b[1mW\u001b(B\u001b[mo\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;4H\u001b[1mo\u001b(B\u001b[mr\u001b[28C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":32,"data":"\u001b[21;5H\u001b[1mr\u001b(B\u001b[mk\u001b[27C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":34,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;6H\u001b[1mk\u001b(B\u001b[mi\u001b[26C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[21;7H\u001b[1mi\u001b(B\u001b[mn\u001b[25C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":34,"data":"\u001b[3AW\u001b[4C\u001b[1mn\u001b(B\u001b[mg\u001b[24C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":32,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;12H\u001b[2m1\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"} +{"delayMs":19,"data":"\u001b[21;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":1,"data":"\r\n\u001b[2m◦\u001b[CReconne\u001b(B\u001b[mc\u001b[1mting.\u001b(B\u001b[m.\u001b[2m. 2/5\u001b[C(1s • esc to interrupt)\r\n └ Unexpected status 401 Unauthorized: {\r\n \"error\": {\r\n \"message\": \"Incorre, url: wss://api.openai.com/v1/responses, cf-ray: a2831cf59baa039d-ZRH,…\u001b[27;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[29;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[27;3H\u001b(B\u001b[m"} +{"delayMs":33,"data":"\u001b[21;10H\u001b[2mc\u001b(B\u001b[mt\u001b[4C\u001b[1m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;11H\u001b[2mt\u001b(B\u001b[mi\u001b[4C\u001b[1m.\u001b(B\u001b[m \u001b[27C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;12H\u001b[2mi\u001b(B\u001b[mn\u001b[4C\u001b[1m \u001b(B\u001b[m2\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;13H\u001b[2mn\u001b(B\u001b[mg\u001b[4C\u001b[1m2\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;14H\u001b[2mg\u001b(B\u001b[m.\u001b[4C\u001b[1m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":36,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[?25l\u001b[?12l\u001b[?25h\u001b[27;3H"} +{"delayMs":31,"data":"\u001b[21;15H\u001b[2m.\u001b(B\u001b[m.\u001b[4C\u001b[1m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;16H\u001b[2m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;17H\u001b[2m.\u001b(B\u001b[m \u001b[27C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":35,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;18H\u001b[2m \u001b(B\u001b[m2\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;19H\u001b[2m2\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;20H\u001b[2m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[21;21H\u001b[2m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[27;3H\u001b(B\u001b[m"} +{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":21,"data":"\u001b[21;19H\u001b[2m3\u001b(B\u001b[m\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;85H\u001b[2maca388822\u001b(B\u001b[m\u001b[6C\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;24H\u001b[2m2\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[6AR\u001b[42C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[6A\u001b[1mR\u001b(B\u001b[me\u001b[41C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":33,"data":"\u001b[21;4H\u001b[1me\u001b(B\u001b[mc\u001b[40C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;5H\u001b[1mc\u001b(B\u001b[mo\u001b[39C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;6H\u001b[1mo\u001b(B\u001b[mn\u001b[38C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;7H\u001b[1mn\u001b(B\u001b[mn\u001b[37C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[6AR\u001b[4C\u001b[1mn\u001b(B\u001b[me\u001b[36C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[6A\u001b[2mR\u001b(B\u001b[me\u001b[4C\u001b[1me\u001b(B\u001b[mc\u001b[35C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;4H\u001b[2me\u001b(B\u001b[mc\u001b[4C\u001b[1mc\u001b(B\u001b[mt\u001b[34C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;5H\u001b[2mc\u001b(B\u001b[mo\u001b[4C\u001b[1mt\u001b(B\u001b[mi\u001b[33C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;6H\u001b[2mo\u001b(B\u001b[mn\u001b[4C\u001b[1mi\u001b(B\u001b[mn\u001b[32C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;7H\u001b[2mn\u001b(B\u001b[mn\u001b[4C\u001b[1mn\u001b(B\u001b[mg\u001b[31C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[6Cn\u001b(B\u001b[me\u001b[4C\u001b[1mg\u001b(B\u001b[m.\u001b[8C\u001b[2m3\u001b[27;3H\u001b(B\u001b[m"} +{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;9H\u001b[2me\u001b(B\u001b[mc\u001b[4C\u001b[1m.\u001b(B\u001b[m.\u001b[29C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;10H\u001b[2mc\u001b(B\u001b[mt\u001b[4C\u001b[1m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":25,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;11H\u001b[2mt\u001b(B\u001b[mi\u001b[4C\u001b[1m.\u001b(B\u001b[m \u001b[2m4\u001b(B\u001b[m\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;27H\u001b[2m, url: ws\u001b[C:/\u001b[Capi.openai.com/v1/responses, cf-ray: a2831d0298dca625-ZRH,\u001b(B\u001b[m\u001b[C\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[24;98H\u001b[2m…\u001b[27;3H\u001b(B\u001b[m"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;12H\u001b[2mi\u001b(B\u001b[mn\u001b[4C\u001b[1m \u001b(B\u001b[m4\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;13H\u001b[2mn\u001b(B\u001b[mg\u001b[4C\u001b[1m4\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;14H\u001b[2mg\u001b(B\u001b[m.\u001b[4C\u001b[1m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;15H\u001b[2m.\u001b(B\u001b[m.\u001b[4C\u001b[1m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[21;16H\u001b[2m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;17H\u001b[2m.\u001b(B\u001b[m \u001b[27C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;18H\u001b[2m \u001b(B\u001b[m4\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;19H\u001b[2m4\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;20H\u001b[2m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;21H\u001b[2m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":1,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;24H\u001b[2m4\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[27;3H\u001b(B\u001b[m"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[6AR\u001b[42C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[6A\u001b[1mR\u001b(B\u001b[me\u001b[41C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;4H\u001b[1me\u001b(B\u001b[mc\u001b[40C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;5H\u001b[1mc\u001b(B\u001b[mo\u001b[39C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;6H\u001b[1mo\u001b(B\u001b[mn\u001b[38C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;7H\u001b[1mn\u001b(B\u001b[mn\u001b[37C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[6AR\u001b[4C\u001b[1mn\u001b(B\u001b[me\u001b[36C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[6A\u001b[2mR\u001b(B\u001b[me\u001b[4C\u001b[1me\u001b(B\u001b[mc\u001b[35C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} +{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[2C\u001b[2me\u001b(B\u001b[mc\u001b[4C\u001b[1mc\u001b(B\u001b[mt\u001b[27;3H"} +{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[21;5H\u001b[2mc\u001b(B\u001b[mo\u001b[4C\u001b[1mt\u001b(B\u001b[mi\u001b[33C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"} diff --git a/packages/xterm-zerolag-input/test/fixtures/codex/trust-modal.jsonl b/packages/xterm-zerolag-input/test/fixtures/codex/trust-modal.jsonl new file mode 100644 index 00000000..c8c41833 --- /dev/null +++ b/packages/xterm-zerolag-input/test/fixtures/codex/trust-modal.jsonl @@ -0,0 +1,28 @@ +{"scenario":"trust-modal","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:51:20.960Z"} +{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":28,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b(B\u001b[m$ "} +{"delayMs":654,"data":"exec codex\r\n"} +{"delayMs":486,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":168,"data":"\u001b[30d\n\u001b[K\u001b[2d\u001b[J\u001b[H\u001b[K"} +{"delayMs":2,"data":">\u001b[C\u001b[1mYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[3;3H\u001b[33mNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[4;3H/home/arkon/default/claudeman\u001b[6;3H\u001b[39mDo\u001b[Cyou\u001b[Ctrust\u001b[Cthe\u001b[Ccontents\u001b[Cof\u001b[Cthis\u001b[Cdirectory?\u001b[CWorking\u001b[Cwith\u001b[Cuntrusted\u001b[Ccontents\u001b[Ccomes\u001b[Cwith\u001b[Chigher\u001b[7;3Hrisk\u001b[Cof\u001b[Cprompt\u001b[Cinjection.\u001b[CTrusting\u001b[Cthe\u001b[Cdirectory\u001b[Callows\u001b[Cproject-local\u001b[Cconfig,\u001b[Chooks,\u001b[Cand\u001b[Cexec\u001b[8;3Hpolicies\u001b[Cto\u001b[Cload.\u001b[10;1H\u001b[36m› 1. Yes, continue\u001b[11;3H\u001b[39m2.\u001b[CNo,\u001b[Cquit\u001b[13;3H\u001b[2mPress enter to continue\u001b[?25l\u001b(B\u001b[m"} +{"delayMs":3659,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[13;26H\u001b[?25l"} +{"delayMs":0,"data":"\u001b[H>\u001b[1X\u001b[1m\u001b[CYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[K\r\n\u001b[K\u001b[3;2H\u001b[1K\u001b[33m\u001b[CNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[39m\u001b[K\u001b[4;2H\u001b[1K\u001b[33m\u001b[C/home/arkon/default/claudeman\u001b[39m\u001b[K\r\n\u001b[K\u001b[6;2H\u001b[1K\u001b[CDo\u001b[1X\u001b[Cyou\u001b[1X\u001b[Ctrust\u001b[1X\u001b[Cthe\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Cof\u001b[1X\u001b[Cthis\u001b[1X\u001b[Cdirectory?\u001b[1X\u001b[CWorking\u001b[1X\u001b[Cwith\u001b[1X\u001b[Cuntrusted\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Ccomes\u001b[1X\u001b[Cwith\u001b[1X\u001b[Chigher\u001b[K\u001b[7;2H\u001b[1K\u001b[Crisk\u001b[1X\u001b[Cof\u001b[1X\u001b[Cprompt\u001b[1X\u001b[Cinjection.\u001b[1X\u001b[CTrusting\u001b[1X\u001b[Cthe\u001b[1X\u001b[Cdirectory\u001b[1X\u001b[Callows\u001b[1X\u001b[Cproject-local\u001b[1X\u001b[Cconfig,\u001b[1X\u001b[Chooks,\u001b[1X\u001b[Cand\u001b[1X\u001b[Cexec\u001b[K\u001b[8;2H\u001b[1K\u001b[Cpolicies\u001b[1X\u001b[Cto\u001b[1X\u001b[Cload.\u001b[K\r\n\u001b[K\u001b[36m\r\n› 1. Yes, continue\u001b[39m\u001b[K\u001b[11;2H\u001b[1K\u001b[C2.\u001b[1X\u001b[CNo,\u001b[1X\u001b[Cquit\u001b[K\r\n\u001b[K\u001b[13;2H\u001b[1K\u001b[2m\u001b[CPress enter to continue\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[13;26H"} +{"keyAt":true,"data":"x"} +{"delayMs":188,"data":"\u001b[1;79H\u001b[K\u001b[3;95H\u001b[K\u001b[4;32H\u001b[K\u001b[6;97H\u001b[K\u001b[7;96H\u001b[K\u001b[8;20H\u001b[K\u001b[10;19H\u001b[K\u001b[11;14H\u001b[K\u001b[13;26H\u001b[K\u001b[30;2H"} +{"keyAt":true,"data":"\r"} +{"delayMs":849,"data":"\u001b[2;1H\u001b[J\u001b[H\u001b[K"} +{"delayMs":0,"data":"\u001bM\u001bM\u001bM\r\n\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b(B\u001b[m\u001b[1;3r\u001b[3;1H\n\u001b[A \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\r\n\u001b[K\u001b[1;30r\u001b[3;1H"} +{"delayMs":2,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[5;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-X4gHpE\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove docum\u001b(B\u001b[m"} +{"delayMs":0,"data":"\u001b[2mentation in @filename\u001b[15;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[13;3H\u001b[?12l\u001b[?25h\u001b(B\u001b[m"} +{"delayMs":7,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;37H\u001b[K\u001b[15;80H\u001b[K\u001b[13;3H"} +{"delayMs":13,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;37H\u001b[K\u001b[15;80H\u001b[K\u001b[13;3H"} +{"delayMs":165,"data":"\u001b[5;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove documentation in @filename\u001b[8;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[6;3H\u001b(B\u001b[m"} +{"delayMs":1,"data":"\u001b[34C\u001b[K\u001b[8;80H\u001b[K\u001b[6;3H"} +{"delayMs":24,"data":"\u001b[4;30r\u001b[4;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m"} +{"delayMs":0,"data":"\u001b[2m│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-X4gHpE\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[12;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"} +{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[17;37H\u001b[K\u001b[19;80H\u001b[K\u001b[17;3H"} +{"delayMs":1,"data":"\u001b[34C\u001b[K\u001b[19;80H\u001b[K\u001b[17;3H"} diff --git a/packages/xterm-zerolag-input/test/fixtures/codex/type-hello.jsonl b/packages/xterm-zerolag-input/test/fixtures/codex/type-hello.jsonl new file mode 100644 index 00000000..505aac85 --- /dev/null +++ b/packages/xterm-zerolag-input/test/fixtures/codex/type-hello.jsonl @@ -0,0 +1,33 @@ +{"scenario":"type-hello","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:50:33.854Z"} +{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"} +{"delayMs":1,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":32,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b(B\u001b[m$ "} +{"delayMs":651,"data":"exec codex\r\n"} +{"delayMs":403,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":189,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":7,"data":"\r\n\u001b[J\u001b[A\u001b[K"} +{"delayMs":4,"data":"\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[1;30r\u001b[2;1H\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b[39m \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-tXbGez\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove documentation in @filename\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b[14;3H\u001b(B\u001b[m"} +{"delayMs":6,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;37H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":25,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;37H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;37H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":185,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[5;1H"} +{"delayMs":0,"data":"\r\n\u001b[2m╭─────────────────────────────────────────────────╮\r\n\u001b(B\u001b[m"} +{"delayMs":0,"data":"\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n\u001b(B\u001b[m"} +{"delayMs":0,"data":"\u001b[2m│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-tXbGez\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n"} +{"delayMs":0,"data":" produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"} +{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;1H\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove documentation in @filename\u001b[20;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b[18;3H\u001b(B\u001b[m"} +{"delayMs":0,"data":"\u001b[34C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[34C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":3484,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-tXbGez\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CImprove documentation in @filename\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"} +{"keyAt":true,"data":"h"} +{"delayMs":208,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"} +{"keyAt":true,"data":"e"} +{"delayMs":93,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"} +{"keyAt":true,"data":"l"} +{"delayMs":89,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;6H"} +{"keyAt":true,"data":"l"} +{"delayMs":92,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;7H"} +{"keyAt":true,"data":"o"} +{"delayMs":90,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;8H"} diff --git a/packages/xterm-zerolag-input/test/fixtures/codex/wrap.jsonl b/packages/xterm-zerolag-input/test/fixtures/codex/wrap.jsonl new file mode 100644 index 00000000..17bdc056 --- /dev/null +++ b/packages/xterm-zerolag-input/test/fixtures/codex/wrap.jsonl @@ -0,0 +1,250 @@ +{"scenario":"wrap","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:50:52.462Z"} +{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":32,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b(B\u001b[m$ "} +{"delayMs":650,"data":"exec codex\r\n"} +{"delayMs":437,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":181,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":4,"data":"\r\n\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[1;30r\u001b[2;1H"} +{"delayMs":0,"data":"\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b(B\u001b[m"} +{"delayMs":1,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-VGU83J\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mWrite tests for @filename\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[14;3H\u001b(B\u001b[m"} +{"delayMs":6,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;28H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":19,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;28H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":6,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;28H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"} +{"delayMs":160,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[5;1H"} +{"delayMs":0,"data":"\r\n\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n\u001b(B\u001b[m"} +{"delayMs":0,"data":"\u001b[2m│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-VGU83J\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"} +{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;1H\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mWrite tests for @filename\u001b[20;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[18;3H\u001b(B\u001b[m"} +{"delayMs":18,"data":"\u001b[25C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":1,"data":"\u001b[25C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"} +{"delayMs":3478,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-VGU83J\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CWrite tests for @filename\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"} +{"keyAt":true,"data":"t"} +{"delayMs":232,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"} +{"keyAt":true,"data":"h"} +{"delayMs":27,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"} +{"keyAt":true,"data":"e"} +{"delayMs":27,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;6H"} +{"keyAt":true,"data":" "} +{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;7H"} +{"keyAt":true,"data":"q"} +{"delayMs":26,"data":"q\u001b[K\u001b[20;80H\u001b[K\u001b[18;8H"} +{"keyAt":true,"data":"u"} +{"delayMs":17,"data":"u\u001b[K\u001b[20;80H\u001b[K\u001b[18;9H"} +{"keyAt":true,"data":"i"} +{"delayMs":28,"data":"i\u001b[K\u001b[20;80H\u001b[K\u001b[18;10H"} +{"keyAt":true,"data":"c"} +{"delayMs":27,"data":"c\u001b[K\u001b[20;80H\u001b[K\u001b[18;11H"} +{"keyAt":true,"data":"k"} +{"delayMs":27,"data":"k\u001b[K\u001b[20;80H\u001b[K\u001b[18;12H"} +{"keyAt":true,"data":" "} +{"keyAt":true,"data":"b"} +{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;13H"} +{"delayMs":16,"data":"b\u001b[K\u001b[20;80H\u001b[K\u001b[18;14H"} +{"keyAt":true,"data":"r"} +{"delayMs":30,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;15H"} +{"keyAt":true,"data":"o"} +{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;16H"} +{"keyAt":true,"data":"w"} +{"delayMs":27,"data":"w\u001b[K\u001b[20;80H\u001b[K\u001b[18;17H"} +{"keyAt":true,"data":"n"} +{"delayMs":27,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;18H"} +{"keyAt":true,"data":" "} +{"keyAt":true,"data":"f"} +{"delayMs":45,"data":"\u001b[Cf\u001b[K\u001b[20;80H\u001b[K\u001b[18;20H"} +{"keyAt":true,"data":"o"} +{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;21H"} +{"keyAt":true,"data":"x"} +{"delayMs":26,"data":"x\u001b[K\u001b[20;80H\u001b[K\u001b[18;22H"} +{"keyAt":true,"data":" "} +{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;23H"} +{"keyAt":true,"data":"j"} +{"keyAt":true,"data":"u"} +{"delayMs":27,"data":"j\u001b[K\u001b[20;80H\u001b[K\u001b[18;24H"} +{"keyAt":true,"data":"m"} +{"delayMs":46,"data":"um\u001b[K\u001b[20;80H\u001b[K\u001b[18;26H"} +{"keyAt":true,"data":"p"} +{"delayMs":26,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;27H"} +{"keyAt":true,"data":"s"} +{"delayMs":28,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;28H"} +{"keyAt":true,"data":" "} +{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;29H"} +{"keyAt":true,"data":"o"} +{"delayMs":16,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;30H"} +{"keyAt":true,"data":"v"} +{"delayMs":31,"data":"v\u001b[K\u001b[20;80H\u001b[K\u001b[18;31H"} +{"keyAt":true,"data":"e"} +{"delayMs":26,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;32H"} +{"keyAt":true,"data":"r"} +{"delayMs":28,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;33H"} +{"keyAt":true,"data":" "} +{"delayMs":26,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;34H"} +{"keyAt":true,"data":"t"} +{"keyAt":true,"data":"h"} +{"delayMs":45,"data":"th\u001b[K\u001b[20;80H\u001b[K\u001b[18;36H"} +{"keyAt":true,"data":"e"} +{"delayMs":27,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;37H"} +{"keyAt":true,"data":" "} +{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;38H"} +{"keyAt":true,"data":"l"} +{"delayMs":27,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;39H"} +{"keyAt":true,"data":"a"} +{"keyAt":true,"data":"z"} +{"delayMs":28,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;40H"} +{"keyAt":true,"data":"y"} +{"delayMs":26,"data":"z\u001b[K\u001b[20;80H\u001b[K\u001b[18;41H"} +{"delayMs":17,"data":"y\u001b[K\u001b[20;80H\u001b[K\u001b[18;42H"} +{"keyAt":true,"data":" "} +{"delayMs":29,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;43H"} +{"keyAt":true,"data":"d"} +{"delayMs":27,"data":"d\u001b[K\u001b[20;80H\u001b[K\u001b[18;44H"} +{"keyAt":true,"data":"o"} +{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;45H"} +{"keyAt":true,"data":"g"} +{"delayMs":27,"data":"g\u001b[K\u001b[20;80H\u001b[K\u001b[18;46H"} +{"keyAt":true,"data":" "} +{"keyAt":true,"data":"a"} +{"delayMs":45,"data":"\u001b[Ca\u001b[K\u001b[20;80H\u001b[K\u001b[18;48H"} +{"keyAt":true,"data":"n"} +{"delayMs":26,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;49H"} +{"keyAt":true,"data":"d"} +{"delayMs":28,"data":"d\u001b[K\u001b[20;80H\u001b[K\u001b[18;50H"} +{"keyAt":true,"data":" "} +{"delayMs":26,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;51H"} +{"keyAt":true,"data":"k"} +{"keyAt":true,"data":"e"} +{"delayMs":27,"data":"k\u001b[20;80H\u001b[K\u001b[18;52H"} +{"keyAt":true,"data":"e"} +{"delayMs":26,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;53H"} +{"delayMs":17,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;54H"} +{"keyAt":true,"data":"p"} +{"delayMs":28,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;55H"} +{"keyAt":true,"data":"s"} +{"delayMs":27,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;56H"} +{"keyAt":true,"data":" "} +{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;57H"} +{"keyAt":true,"data":"r"} +{"keyAt":true,"data":"u"} +{"delayMs":27,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;58H"} +{"delayMs":17,"data":"u\u001b[K\u001b[20;80H\u001b[K\u001b[18;59H"} +{"keyAt":true,"data":"n"} +{"delayMs":29,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;60H"} +{"keyAt":true,"data":"n"} +{"delayMs":26,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;61H"} +{"keyAt":true,"data":"i"} +{"delayMs":28,"data":"i\u001b[K\u001b[20;80H\u001b[K\u001b[18;62H"} +{"keyAt":true,"data":"n"} +{"delayMs":26,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;63H"} +{"keyAt":true,"data":"g"} +{"keyAt":true,"data":" "} +{"delayMs":45,"data":"g\u001b[K\u001b[20;80H\u001b[K\u001b[18;65H"} +{"keyAt":true,"data":"u"} +{"delayMs":28,"data":"u\u001b[K\u001b[20;80H\u001b[K\u001b[18;66H"} +{"keyAt":true,"data":"n"} +{"delayMs":27,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;67H"} +{"keyAt":true,"data":"t"} +{"keyAt":true,"data":"i"} +{"delayMs":27,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;68H"} +{"keyAt":true,"data":"l"} +{"delayMs":45,"data":"il\u001b[K\u001b[20;80H\u001b[K\u001b[18;70H"} +{"keyAt":true,"data":" "} +{"delayMs":28,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;71H"} +{"keyAt":true,"data":"t"} +{"delayMs":26,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;72H"} +{"keyAt":true,"data":"h"} +{"keyAt":true,"data":"e"} +{"delayMs":28,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;73H"} +{"keyAt":true,"data":" "} +{"delayMs":45,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;75H"} +{"keyAt":true,"data":"c"} +{"delayMs":27,"data":"c\u001b[K\u001b[20;80H\u001b[K\u001b[18;76H"} +{"keyAt":true,"data":"o"} +{"delayMs":28,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;77H"} +{"keyAt":true,"data":"m"} +{"keyAt":true,"data":"p"} +{"delayMs":27,"data":"m\u001b[K\u001b[20;80H\u001b[K\u001b[18;78H"} +{"delayMs":16,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;79H"} +{"keyAt":true,"data":"o"} +{"delayMs":30,"data":"o\u001b[K\u001b[2B\u001b[K\u001b[2A"} +{"keyAt":true,"data":"s"} +{"delayMs":27,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;81H"} +{"keyAt":true,"data":"e"} +{"delayMs":27,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;82H"} +{"keyAt":true,"data":"r"} +{"delayMs":27,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;83H"} +{"keyAt":true,"data":" "} +{"delayMs":16,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;84H"} +{"keyAt":true,"data":"b"} +{"delayMs":30,"data":"b\u001b[K\u001b[20;80H\u001b[K\u001b[18;85H"} +{"keyAt":true,"data":"o"} +{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;86H"} +{"keyAt":true,"data":"x"} +{"delayMs":27,"data":"x\u001b[K\u001b[20;80H\u001b[K\u001b[18;87H"} +{"keyAt":true,"data":" "} +{"delayMs":26,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;88H"} +{"keyAt":true,"data":"h"} +{"delayMs":17,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;89H"} +{"keyAt":true,"data":"a"} +{"delayMs":28,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;90H"} +{"keyAt":true,"data":"s"} +{"delayMs":27,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;91H"} +{"keyAt":true,"data":" "} +{"delayMs":28,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;92H"} +{"keyAt":true,"data":"t"} +{"delayMs":26,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;93H"} +{"keyAt":true,"data":"o"} +{"keyAt":true,"data":" "} +{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;94H"} +{"delayMs":16,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;95H"} +{"keyAt":true,"data":"w"} +{"delayMs":30,"data":"w\u001b[K\u001b[20;80H\u001b[K\u001b[18;96H"} +{"keyAt":true,"data":"r"} +{"delayMs":27,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;97H"} +{"keyAt":true,"data":"a"} +{"delayMs":26,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;98H"} +{"keyAt":true,"data":"p"} +{"delayMs":27,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;99H"} +{"keyAt":true,"data":" "} +{"delayMs":27,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"} +{"keyAt":true,"data":"t"} +{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[mthe\u001b[Cquick\u001b[Cbrown\u001b[Cfox\u001b[Cjumps\u001b[Cover\u001b[Cthe\u001b[Clazy\u001b[Cdog\u001b[Cand\u001b[Ckeeps\u001b[Crunning\u001b[Cuntil\u001b[Cthe\u001b[Ccomposer\u001b[Cbox\u001b[Chas\u001b[Cto\u001b[Cwrap\u001b[21;3H\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[19;3H\u001b(B\u001b[m"} +{"keyAt":true,"data":"h"} +{"delayMs":42,"data":"\u001b[18;99H\u001b[K\u001b[19;3Hth\u001b[21;80H\u001b[K\u001b[19;5H"} +{"keyAt":true,"data":"i"} +{"delayMs":29,"data":"\u001b[18;99H\u001b[K\u001b[19;5Hi\u001b[K\u001b[21;80H\u001b[K\u001b[19;6H"} +{"keyAt":true,"data":"s"} +{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;6Hs\u001b[K\u001b[21;80H\u001b[K\u001b[19;7H"} +{"keyAt":true,"data":" "} +{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;7H\u001b[K\u001b[21;80H\u001b[K\u001b[19;8H"} +{"keyAt":true,"data":"l"} +{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;8Hl\u001b[K\u001b[21;80H\u001b[K\u001b[19;9H"} +{"keyAt":true,"data":"i"} +{"keyAt":true,"data":"n"} +{"delayMs":45,"data":"\u001b[18;99H\u001b[K\u001b[19;9Hin\u001b[K\u001b[21;80H\u001b[K\u001b[19;11H"} +{"keyAt":true,"data":"e"} +{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;11He\u001b[K\u001b[21;80H\u001b[K\u001b[19;12H"} +{"keyAt":true,"data":" "} +{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;12H\u001b[K\u001b[21;80H\u001b[K\u001b[19;13H"} +{"keyAt":true,"data":"t"} +{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;13Ht\u001b[K\u001b[21;80H\u001b[K\u001b[19;14H"} +{"keyAt":true,"data":"w"} +{"keyAt":true,"data":"i"} +{"delayMs":45,"data":"\u001b[18;99H\u001b[K\u001b[19;14Hwi\u001b[K\u001b[21;80H\u001b[K\u001b[19;16H"} +{"keyAt":true,"data":"c"} +{"delayMs":28,"data":"\u001b[18;99H\u001b[K\u001b[19;16Hc\u001b[K\u001b[21;80H\u001b[K\u001b[19;17H"} +{"keyAt":true,"data":"e"} +{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;17He\u001b[K\u001b[21;80H\u001b[K\u001b[19;18H"} +{"keyAt":true,"data":" "} +{"keyAt":true,"data":"o"} +{"delayMs":28,"data":"\u001b[18;99H\u001b[K\u001b[19;18H\u001b[K\u001b[21;80H\u001b[K\u001b[19;19H"} +{"keyAt":true,"data":"v"} +{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;19Ho\u001b[K\u001b[21;80H\u001b[K\u001b[19;20H"} +{"keyAt":true,"data":"e"} +{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;20Hv\u001b[K\u001b[21;80H\u001b[K\u001b[19;21H"} +{"delayMs":17,"data":"\u001b[18;99H\u001b[K\u001b[19;21He\u001b[K\u001b[21;80H\u001b[K\u001b[19;22H"} +{"keyAt":true,"data":"r"} +{"delayMs":29,"data":"\u001b[18;99H\u001b[K\u001b[19;22Hr\u001b[K\u001b[21;80H\u001b[K\u001b[19;23H"} diff --git a/scripts/dev/analyze-codex-frames.mjs b/scripts/dev/analyze-codex-frames.mjs new file mode 100644 index 00000000..0285c465 --- /dev/null +++ b/scripts/dev/analyze-codex-frames.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * @fileoverview Replays a codex fixture (recorded by record-codex-frames.mjs) + * through @xterm/headless and prints the measurements the predictive-echo + * design doc records: cursor position + composer-row text at every keystroke + * injection point, and the final screen with cursor + baseY state. + * + * Usage: node scripts/dev/analyze-codex-frames.mjs + */ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { Terminal } = require('@xterm/headless'); + +const file = process.argv[2]; +if (!file) throw new Error('usage: analyze-codex-frames.mjs '); +const lines = readFileSync(file, 'utf8').trim().split('\n').map(JSON.parse); +const meta = lines.shift(); +console.log('meta:', JSON.stringify(meta)); + +const term = new Terminal({ cols: meta.cols, rows: meta.rows, scrollback: 1000, allowProposedApi: true }); +const write = (data) => new Promise((r) => term.write(data, r)); + +const snap = () => { + const buf = term.buffer.active; + const row = buf.getLine(buf.baseY + buf.cursorY); + return { + cursorX: buf.cursorX, + cursorY: buf.cursorY, + baseY: buf.baseY, + rowText: row ? row.translateToString(true) : null, + cursorCell: row?.getCell?.(buf.cursorX)?.getChars() ?? null, + }; +}; + +for (const line of lines) { + if (line.keyAt) { + const s = snap(); + console.log(`KEY ${JSON.stringify(line.data)} @ cursor(${s.cursorX},${s.cursorY}) baseY=${s.baseY}`); + console.log(` row: ${JSON.stringify(s.rowText)}`); + console.log(` cell-at-cursor: ${JSON.stringify(s.cursorCell)}`); + } else { + await write(line.data); + } +} + +const final = snap(); +console.log('\nFINAL screen (| marks cursor row/col):'); +const buf = term.buffer.active; +for (let y = 0; y < meta.rows; y++) { + const line = buf.getLine(buf.baseY + y); + let text = line ? line.translateToString(true) : ''; + if (y === final.cursorY) text = text.slice(0, final.cursorX) + '|' + text.slice(final.cursorX); + if (text.trim()) console.log(String(y).padStart(3), JSON.stringify(text)); +} +console.log('cursor:', JSON.stringify(final), 'viewportY:', buf.viewportY); diff --git a/scripts/dev/record-codex-frames.mjs b/scripts/dev/record-codex-frames.mjs new file mode 100644 index 00000000..76feff38 --- /dev/null +++ b/scripts/dev/record-codex-frames.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +/** + * @fileoverview Records real codex TUI output into JSONL fixtures for the + * predictive-echo replay tests (packages/xterm-zerolag-input/test/codex-replay.test.ts). + * + * The pipeline reproduces production byte-for-byte: codex runs inside tmux + * (status off, like tmux-manager.ts sessions) driven through a node-pty client, + * and every chunk passes through the SAME full strip session.ts applies to + * codex-mode output (alt-screen toggles, \x1b[3J, mouse DECSETs, with the + * split-sequence carry). What lands in the fixture is what xterm.js receives. + * + * Fixture format: line 1 is a meta object {scenario, cols, rows, codexVersion, + * recordedAt}; every following line is {delayMs, data} where delayMs is the gap + * since the previous chunk and data is the stripped chunk. Keystroke injection + * points are recorded as {keyAt: true, data} lines so the replay knows where + * predictChar() calls belong. + * + * Usage: node scripts/dev/record-codex-frames.mjs [--out ] + * Scenarios: type-hello, slash-picker, wrap, streaming-burst, paste-bracketed + * + * The CODEX_HOME is a throwaway temp dir with a fake auth.json; the fake key is + * asserted absent from every recorded byte before the fixture is written. + */ +import pty from 'node-pty'; +import { execSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const FAKE_KEY = 'sk-test-123'; +const COLS = 100; +const ROWS = 30; +const BOOT_WAIT_MS = 4500; +// NOT under /tmp: codex prints a "Refusing to create helper binaries under +// temporary dir" warning that embeds the CODEX_HOME path when it lives in /tmp. +// The repo's gitignored tmp/ avoids both the warning and the path leak. +const SCRATCH = join(ROOT, 'tmp'); + +// Mirror of the codex-mode FULL strip in session.ts _handleTerminalOutput(). +function makeStripper() { + let carry = ''; + return (data) => { + data = carry + data; + carry = ''; + const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/); + if (splitTail) { + carry = splitTail[0]; + data = data.slice(0, -splitTail[0].length); + } + return data + .replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '') + .replace(/\x1b\[3J/g, '') + .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, ''); + }; +} + +// Each step: wait `waitMs` after the previous step, then write `keys` to the pty. +const SCENARIOS = { + 'type-hello': [ + ...'hello'.split('').map((ch, i) => ({ waitMs: i === 0 ? BOOT_WAIT_MS : 90, keys: ch })), + { waitMs: 1500, keys: '' }, + ], + 'slash-picker': [ + { waitMs: BOOT_WAIT_MS, keys: '/' }, + { waitMs: 400, keys: 'm' }, + { waitMs: 150, keys: 'o' }, + { waitMs: 1200, keys: '\x1b' }, + { waitMs: 500, keys: '' }, + ], + wrap: [ + { waitMs: BOOT_WAIT_MS, keys: '' }, + ...'the quick brown fox jumps over the lazy dog and keeps running until the composer box has to wrap this line twice over' + .split('') + .map((ch) => ({ waitMs: 25, keys: ch })), + { waitMs: 1500, keys: '' }, + ], + 'streaming-burst': [ + ...'hello'.split('').map((ch, i) => ({ waitMs: i === 0 ? BOOT_WAIT_MS : 40, keys: ch })), + { waitMs: 300, keys: '\r' }, + { waitMs: 5000, keys: '' }, + ], + 'paste-bracketed': [ + { waitMs: BOOT_WAIT_MS, keys: 'a' }, + { waitMs: 90, keys: 'b' }, + { waitMs: 400, keys: '\x1b[200~XYZpasted\x1b[201~' }, + { waitMs: 1500, keys: '' }, + ], + // First-run trust dialog: the modal surface where typed chars must NOT be + // predicted (the predictWhen ghost eliminator). Recorded UNTRUSTED so the + // dialog actually appears; 'x' exercises typing at a non-composer cursor. + 'trust-modal': { + trusted: false, + steps: [ + { waitMs: BOOT_WAIT_MS, keys: 'x' }, + { waitMs: 800, keys: '\r' }, + { waitMs: 2500, keys: '' }, + ], + }, +}; + +async function record(scenario, outDir) { + const spec = SCENARIOS[scenario]; + if (!spec) throw new Error(`unknown scenario ${scenario}`); + const steps = Array.isArray(spec) ? spec : spec.steps; + const trusted = Array.isArray(spec) ? true : (spec.trusted ?? true); + + mkdirSync(SCRATCH, { recursive: true }); + const lab = mkdtempSync(join(SCRATCH, 'codexrec-')); + writeFileSync(join(lab, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: FAKE_KEY })); + const workdir = mkdtempSync(join(SCRATCH, 'codexrec-work-')); + if (trusted) { + // Pre-trust the workdir so boot goes straight to the composer instead of + // the first-run trust dialog (which trust-modal records deliberately). + writeFileSync(join(lab, 'config.toml'), `[projects."${workdir}"]\ntrust_level = "trusted"\n`); + } + const sock = `codexrec-${process.pid}`; + const codexVersion = execSync('codex --version', { encoding: 'utf8' }).trim(); + + const lines = []; + const strip = makeStripper(); + let lastChunkAt = null; + let recording = true; + const proc = pty.spawn( + 'tmux', + ['-L', sock, '-f', '/dev/null', 'new-session', '-s', 'rec', ';', 'set', '-t', 'rec', 'status', 'off'], + { + name: 'xterm-256color', + cols: COLS, + rows: ROWS, + cwd: workdir, + env: { ...process.env, CODEX_HOME: lab, SHELL: '/bin/bash' }, + } + ); + proc.onData((data) => { + if (!recording) return; // teardown frames ([server exited]) stay out + const now = performance.now(); + const stripped = strip(data); + if (!stripped) return; // timing folds into the next chunk's delay + lines.push({ delayMs: lastChunkAt === null ? 0 : Math.round(now - lastChunkAt), data: stripped }); + lastChunkAt = now; + }); + + // tmux session starts with a shell; launch codex in it so the strip pipeline + // sees the same attach-then-launch order production uses. + await sleep(700); + proc.write(`exec codex\r`); + + for (const step of steps) { + await sleep(step.waitMs); + if (step.keys) { + lines.push({ keyAt: true, data: step.keys }); + proc.write(step.keys); + } + } + + recording = false; + try { + execSync(`tmux -L ${sock} kill-server`, { stdio: 'ignore' }); + } catch { + /* already gone */ + } + proc.kill(); + await sleep(200); + + const allBytes = lines.map((l) => l.data).join(''); + if (allBytes.includes(FAKE_KEY)) throw new Error(`fixture ${scenario} leaked the fake key; NOT writing`); + if (allBytes.includes(lab)) throw new Error(`fixture ${scenario} leaked the lab path; NOT writing`); + + mkdirSync(outDir, { recursive: true }); + const meta = { scenario, cols: COLS, rows: ROWS, codexVersion, recordedAt: new Date().toISOString() }; + const out = join(outDir, `${scenario}.jsonl`); + writeFileSync(out, [JSON.stringify(meta), ...lines.map((l) => JSON.stringify(l))].join('\n') + '\n'); + rmSync(lab, { recursive: true, force: true }); + rmSync(workdir, { recursive: true, force: true }); + console.log(`${scenario}: ${lines.length} lines -> ${out}`); +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +const arg = process.argv[2]; +const outIdx = process.argv.indexOf('--out'); +const outDir = + outIdx !== -1 ? process.argv[outIdx + 1] : join(ROOT, 'packages', 'xterm-zerolag-input', 'test', 'fixtures', 'codex'); +const wanted = arg === 'all' || !arg ? Object.keys(SCENARIOS) : [arg]; +for (const s of wanted) { + await record(s, outDir); +} From a30524060a2336ba850146101a7b0e15ffb67771 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 04:25:24 +0200 Subject: [PATCH 2/9] feat(predictive-echo): PredictiveEchoAddon + Layers 1-3 test suites (0.2.0) Mosh-style write-through prediction: the consumer sends every keystroke unchanged; the addon paints predicted glyphs and reconciles against the parsed buffer. Confirm = cell match + cursor advance (placeholder-safe, repaint-safe); two-pass mismatch cascade with neutral blanks (measured: codex clears its placeholder on first echo); TTL bound; baseY-based line reads; scroll/resize/off-row clears. Zero edits to zerolag-input-addon.ts. Tests: 30 addon-law specs + renderer geometry (fake performance clock for TTL/grace), 6 replay suites running the real algorithm through a real @xterm/headless parser fed by the recorded codex fixtures, and a 500-iteration seeded fuzz with per-op span/record + grid invariants. 227 total, the pre-existing 175 untouched. Co-Authored-By: Claude Fable 5 --- packages/xterm-zerolag-input/CHANGELOG.md | 11 + packages/xterm-zerolag-input/README.md | 110 +++- packages/xterm-zerolag-input/package.json | 6 +- .../src/cell-dimensions.ts | 59 +- packages/xterm-zerolag-input/src/index.ts | 17 +- .../src/predictive-echo-addon.ts | 460 ++++++++++++++++ .../xterm-zerolag-input/src/prompt-finder.ts | 111 ++-- .../test/cell-dimensions.test.ts | 223 ++++---- .../test/codex-replay.test.ts | 172 ++++++ packages/xterm-zerolag-input/test/helpers.ts | 291 ++++++---- .../test/prediction-renderer.test.ts | 135 +++++ .../test/predictive-echo-addon.test.ts | 505 ++++++++++++++++++ .../test/predictive-echo-fuzz.test.ts | 125 +++++ .../test/prompt-finder.test.ts | 278 +++++----- .../xterm-zerolag-input/test/raw-imports.d.ts | 5 + .../test/replay-helpers.ts | 171 ++++++ packages/xterm-zerolag-input/tsup.config.ts | 7 + 17 files changed, 2227 insertions(+), 459 deletions(-) create mode 100644 packages/xterm-zerolag-input/src/predictive-echo-addon.ts create mode 100644 packages/xterm-zerolag-input/test/codex-replay.test.ts create mode 100644 packages/xterm-zerolag-input/test/prediction-renderer.test.ts create mode 100644 packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts create mode 100644 packages/xterm-zerolag-input/test/predictive-echo-fuzz.test.ts create mode 100644 packages/xterm-zerolag-input/test/raw-imports.d.ts create mode 100644 packages/xterm-zerolag-input/test/replay-helpers.ts diff --git a/packages/xterm-zerolag-input/CHANGELOG.md b/packages/xterm-zerolag-input/CHANGELOG.md index c7e4b923..bd2f51d9 100644 --- a/packages/xterm-zerolag-input/CHANGELOG.md +++ b/packages/xterm-zerolag-input/CHANGELOG.md @@ -1,5 +1,16 @@ # xterm-zerolag-input +## 0.2.0 + +### Minor Changes + +- **New addon: `PredictiveEchoAddon`, mosh-style write-through prediction.** The second echo mode for per-keystroke TUIs (OpenAI Codex's composer, live pickers) that buffer-until-Enter starves. Every keystroke is sent by the consumer immediately and unchanged; the addon paints the predicted glyph at the predicted cell and reconciles against the PARSED terminal buffer: confirmation requires the cell match plus a cursor advance past the record, foreign non-blank content on two consecutive passes cascades a drop, blank cells are neutral, a TTL bounds everything, and scroll/resize/sustained cursor moves clear the run. Visual-only by construction; it cannot gate, delay or rewrite input. + - New exports: `PredictiveEchoAddon`, `PredictiveEchoOptions`, `PredictionState`, plus the long-intended `charCellWidth` / `stringCellWidth` helpers. + - `XtermTerminal` type gains OPTIONAL members (`buffer.active.cursorX/cursorY`, `getLine().getCell?`, `onWriteParsed?`, `onResize?`). Additive only: existing consumers and mocks are unaffected. + - IIFE build exposes `window.PredictiveEchoAddon` and a self-activating `window.PredictiveEchoOverlay`, alongside the unchanged `ZerolagInputAddon` / `LocalEchoOverlay` globals. + - Tests: 52 new (30 addon-law specs, renderer geometry, 6 replay suites driven by fixtures recorded from real codex 0.147 through tmux + the production strip, and a 500-iteration seeded fuzz with per-op invariants). `@xterm/headless` as a devDependency; runtime dependencies remain zero. + + ## 0.1.8 ### Patch Changes diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index 8b3b5842..af8f3c2c 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -9,7 +9,7 @@ MIT Zero dependencies 6.1 kB gzipped - 175 tests + 175 tests xterm.js v5 and v7+

@@ -46,6 +46,15 @@ Same keystroke, same link. The only difference is who you wait for: the server, **No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire. +Since 0.2.0 the package ships **two addons for two kinds of TUIs**: + +| Addon | Model | Use when | +| --------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `ZerolagInputAddon` | **Buffer**: hold keystrokes locally, flush on Enter | The remote side is a line-oriented prompt (shells, REPLs, Claude Code's composer) that only needs the finished line | +| `PredictiveEchoAddon` | **Predictive write-through**: send every keystroke immediately, paint a prediction, confirm against the parsed buffer | The remote side is a per-keystroke TUI (OpenAI Codex's composer, live pickers) that buffering would starve | + +`ZerolagInputAddon` is documented below; jump to [PredictiveEchoAddon](#predictiveechoaddon-write-through-prediction) for the second mode. + ## Why this one | | | @@ -268,6 +277,105 @@ Finds text that exists after the prompt but was never typed through the overlay. --- +## `PredictiveEchoAddon` (write-through prediction) + +Buffering is the wrong model for TUIs that react to every keystroke: a slash +command picker filters live, arrows edit server-side state, the composer +rewraps as it grows. For those, `PredictiveEchoAddon` works like +[mosh](https://mosh.org/): the keystroke goes to the PTY **immediately and +unchanged**, and the addon simultaneously paints the predicted glyph at the +predicted cell. When the real echo lands, the prediction is confirmed and its +span removed: an invisible swap, identical glyph beneath. Mispredictions +self-heal via a mismatch cascade and a TTL. It is visual-only by construction: +nothing it does can gate, delay, reorder or rewrite what you send. + +```typescript +import { Terminal } from '@xterm/xterm'; +import { PredictiveEchoAddon } from 'xterm-zerolag-input'; + +const terminal = new Terminal(); +const predictor = new PredictiveEchoAddon({ + // Optional: only predict when the cursor sits on a composer row + predictWhen: (t) => { + const buf = t.buffer.active; + const line = buf.getLine(buf.baseY + buf.cursorY); + return !!line && /^› /.test(line.translateToString(true)); + }, +}); +terminal.loadAddon(predictor); + +terminal.onData((data) => { + const cps = Array.from(data); + if (cps.length === 1) { + const cp = cps[0].codePointAt(0); + if (cp === 0x7f) predictor.predictBackspace(); + else if (cp >= 0x20) predictor.predictChar(data); + else predictor.clearPredictions(); // Enter, Ctrl+C, ... + } else if (data.charCodeAt(0) === 0x1b) { + predictor.clearPredictions(); // nav keys, bracketed paste + } + pty.write(data); // ALWAYS, unconditionally +}); +``` + +### How reconciliation works + +Predictions are reconciled against the **parsed terminal buffer** (cells after +xterm's parser ran), never the raw output stream. That distinction is +load-bearing: TUIs redraw whole lines, paint gaps with `ECH` + cursor-forward +instead of spaces, and multiplexers like tmux rewrite everything into minimal +deltas. Stream matching breaks on all of that; buffer cells converge to the +same values no matter how the bytes arrived. + +A prediction is **confirmed** only when its cell shows the predicted glyph AND +the cursor has advanced past it (so a placeholder that happens to match, or an +identical in-place repaint, never false-confirms). A cell showing foreign +non-blank content on two consecutive passes drops that prediction and all +later ones (one pass tolerates half-parsed frames). Blank cells are neutral: +they are what "not yet echoed" looks like. Whatever remains is dropped by TTL. +Scrolling up, resizing, or a sustained cursor move clears the run. + +### API + +```typescript +predictChar(ch: string): boolean; // false = suppressed (still SEND the key) +predictBackspace(): boolean; // pops the newest prediction (still send \x7f) +clearPredictions(): void; +reconcile(): void; // manual pass (no onWriteParsed available) +setPredictWhen(fn | null): void; // swap the gate at runtime +refreshFont(): void; // after font/theme changes +get hasPredictions(): boolean; +get state(): PredictionState; // { outstanding, confirmedTotal, droppedTotal, anchor } +``` + +### Options + +```typescript +{ + zIndex?: number, // Default: 7 + underlinePredictions?: boolean, // Default: false (underline unconfirmed glyphs) + foregroundColor?: string, // Default: terminal theme / computed .xterm-rows style + backgroundColor?: string, // Default: terminal theme background + ttlMs?: number, // Default: 1000 + maxPending?: number, // Default: 32 + cursorGraceMs?: number, // Default: 150 + edgeMarginCells?: number, // Default: 4 (suppress near the right edge) + predictWhen?: (t) => boolean, // Default: predict everywhere +} +``` + +### Which addon should I use? + +- The remote program shows a **line prompt** and ignores partial input: + `ZerolagInputAddon`. You also get backspace-before-send and batching. +- The remote program **reacts per keystroke** (pickers, filters, composers + that rewrap): `PredictiveEchoAddon`. It never withholds bytes, so the TUI + behaves exactly as with no addon at all; you just stop waiting for the RTT. +- Both can be loaded on one terminal and toggled per session mode; that is + exactly what Codeman does (buffer for Claude Code, predict for Codex). + +--- + ## Integration patterns ### Buffered input (hold until Enter) diff --git a/packages/xterm-zerolag-input/package.json b/packages/xterm-zerolag-input/package.json index 584309bb..e9f67f0a 100644 --- a/packages/xterm-zerolag-input/package.json +++ b/packages/xterm-zerolag-input/package.json @@ -1,6 +1,6 @@ { "name": "xterm-zerolag-input", - "version": "0.1.8", + "version": "0.2.0", "description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections", "type": "module", "main": "dist/index.cjs", @@ -37,7 +37,9 @@ "ssh", "remote-terminal", "overlay", - "addon" + "addon", + "predictive", + "write-through" ], "license": "MIT", "homepage": "https://github.com/Ark0N/Codeman/tree/master/packages/xterm-zerolag-input#readme", diff --git a/packages/xterm-zerolag-input/src/cell-dimensions.ts b/packages/xterm-zerolag-input/src/cell-dimensions.ts index 6410dab2..bf4c17d8 100644 --- a/packages/xterm-zerolag-input/src/cell-dimensions.ts +++ b/packages/xterm-zerolag-input/src/cell-dimensions.ts @@ -11,37 +11,36 @@ import type { XtermTerminal, CellDimensions } from './types.js'; * unavailable. */ export function getCellDimensions(terminal: XtermTerminal): CellDimensions | null { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const t = terminal as any; - const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 - ? devicePixelRatio : 1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const t = terminal as any; + const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1; - // Try v7+ public API first - if (t.dimensions?.css?.cell) { - const cellH = t.dimensions.css.cell.height; - return { - width: t.dimensions.css.cell.width, - height: cellH, - charTop: (t.dimensions?.device?.char?.top ?? 0) / dpr, - charHeight: (t.dimensions?.device?.char?.height ?? (cellH * dpr)) / dpr, - }; + // Try v7+ public API first + if (t.dimensions?.css?.cell) { + const cellH = t.dimensions.css.cell.height; + return { + width: t.dimensions.css.cell.width, + height: cellH, + charTop: (t.dimensions?.device?.char?.top ?? 0) / dpr, + charHeight: (t.dimensions?.device?.char?.height ?? cellH * dpr) / dpr, + }; + } + + // Fall back to v5 private API + try { + const dims = t._core?._renderService?.dimensions; + if (dims?.css?.cell) { + const cellH = dims.css.cell.height; + return { + width: dims.css.cell.width, + height: cellH, + charTop: (dims.device?.char?.top ?? 0) / dpr, + charHeight: (dims.device?.char?.height ?? cellH * dpr) / dpr, + }; } + } catch { + // Private API may throw in some environments + } - // Fall back to v5 private API - try { - const dims = t._core?._renderService?.dimensions; - if (dims?.css?.cell) { - const cellH = dims.css.cell.height; - return { - width: dims.css.cell.width, - height: cellH, - charTop: (dims.device?.char?.top ?? 0) / dpr, - charHeight: (dims.device?.char?.height ?? (cellH * dpr)) / dpr, - }; - } - } catch { - // Private API may throw in some environments - } - - return null; + return null; } diff --git a/packages/xterm-zerolag-input/src/index.ts b/packages/xterm-zerolag-input/src/index.ts index c16416d9..f10d5265 100644 --- a/packages/xterm-zerolag-input/src/index.ts +++ b/packages/xterm-zerolag-input/src/index.ts @@ -1,10 +1,13 @@ export { ZerolagInputAddon } from './zerolag-input-addon.js'; +export { PredictiveEchoAddon } from './predictive-echo-addon.js'; +export { charCellWidth, stringCellWidth } from './overlay-renderer.js'; export type { - XtermTerminal, - XtermAddon, - ZerolagInputOptions, - ZerolagInputState, - PromptFinder, - PromptPosition, - CellDimensions, + XtermTerminal, + XtermAddon, + ZerolagInputOptions, + ZerolagInputState, + PromptFinder, + PromptPosition, + CellDimensions, } from './types.js'; +export type { PredictiveEchoOptions, PredictionState } from './predictive-echo-addon.js'; diff --git a/packages/xterm-zerolag-input/src/predictive-echo-addon.ts b/packages/xterm-zerolag-input/src/predictive-echo-addon.ts new file mode 100644 index 00000000..dee3005a --- /dev/null +++ b/packages/xterm-zerolag-input/src/predictive-echo-addon.ts @@ -0,0 +1,460 @@ +/** + * PredictiveEchoAddon: mosh-style write-through local echo. + * + * The consumer sends every keystroke to the PTY unchanged (write-through); + * this addon simultaneously paints the predicted glyph at the predicted cell. + * When the real echo lands, the prediction is confirmed and its span removed + * (an invisible swap: identical glyph beneath). Mispredictions self-heal via + * a mismatch cascade and a TTL. Everything here is visual-only: no method + * gates, delays, or rewrites what the consumer sends. + * + * Reconciliation reads the parsed terminal BUFFER (cells after xterm's parser + * ran), never the raw output stream. Full-line redraws, ECH-based gap + * painting, and tmux's in-place deltas all converge to the same cells; stream + * matching cannot survive them (see docs/local-echo-overlay-plan.md's + * "What NOT to Do" in the consuming repo). + * + * Coordinate base: xterm's `cursorY` is relative to `baseY`, so the absolute + * buffer line for a viewport row is `baseY + row`. `viewportY` would only + * coincide while scrolled to the bottom; this file never relies on that. + */ +import { getCellDimensions } from './cell-dimensions.js'; +import { charCellWidth } from './overlay-renderer.js'; +import { addPredictionSpan, clearAllSpans, removePredictionSpan } from './prediction-renderer.js'; +import type { FontStyle, XtermAddon, XtermTerminal } from './types.js'; + +export interface PredictiveEchoOptions { + /** Z-index of the span container. @default 7 (same layer as the buffer overlay) */ + zIndex?: number; + /** Render predicted glyphs underlined (visual hedge on unreliable links). @default false */ + underlinePredictions?: boolean; + /** Predicted glyph color. @default theme foreground / computed .xterm-rows color */ + foregroundColor?: string; + /** Predicted glyph background. @default theme background */ + backgroundColor?: string; + /** Drop predictions older than this. @default 1000 */ + ttlMs?: number; + /** Maximum outstanding predictions per run. @default 32 */ + maxPending?: number; + /** How long the cursor may sit off the anchor row before predictions clear. @default 150 */ + cursorGraceMs?: number; + /** Suppress predictions that would land within this many cells of the right edge. @default 4 */ + edgeMarginCells?: number; + /** Gate: return false to suppress prediction (e.g. cursor not on a composer row). */ + predictWhen?: (terminal: XtermTerminal) => boolean; +} + +export interface PredictionState { + outstanding: number; + confirmedTotal: number; + droppedTotal: number; + anchor: { row: number; col: number } | null; +} + +interface PredictionRecord { + seq: number; + char: string; + /** Cells this glyph occupies. */ + width: 1 | 2; + /** Cumulative cell offset from the anchor column BEFORE this char. */ + offsetCells: number; + /** Cell content at predict time, '' normalized to ' '. */ + snapshot: string; + sentAt: number; + /** Consecutive reconcile passes that saw foreign non-blank content. */ + mismatches: number; +} + +const DEFAULT_OPTIONS = { + zIndex: 7, + underlinePredictions: false, + ttlMs: 1000, + maxPending: 32, + cursorGraceMs: 150, + edgeMarginCells: 4, +} as const; + +const DEFAULT_BG = '#000000'; +const DEFAULT_FG = '#ffffff'; + +export class PredictiveEchoAddon implements XtermAddon { + private _terminal: XtermTerminal | null = null; + private _container: HTMLDivElement | null = null; + private _spans = new Map(); + private _outstanding: PredictionRecord[] = []; + private _anchor: { row: number; col: number } | null = null; + private _cursorOffRowSince: number | null = null; + private _seq = 0; + private _confirmedTotal = 0; + private _droppedTotal = 0; + private _ttlTimer: ReturnType | null = null; + private _reconcileScheduled = false; + private _disposables: Array<{ dispose(): void }> = []; + private _predictWhen: ((terminal: XtermTerminal) => boolean) | null; + private _options: Required> & + Pick; + private _font: FontStyle = { + fontFamily: 'monospace', + fontSize: '14px', + fontWeight: 'normal', + color: DEFAULT_FG, + backgroundColor: DEFAULT_BG, + letterSpacing: '', + }; + + constructor(options?: PredictiveEchoOptions) { + this._options = { + zIndex: options?.zIndex ?? DEFAULT_OPTIONS.zIndex, + underlinePredictions: options?.underlinePredictions ?? DEFAULT_OPTIONS.underlinePredictions, + ttlMs: options?.ttlMs ?? DEFAULT_OPTIONS.ttlMs, + maxPending: options?.maxPending ?? DEFAULT_OPTIONS.maxPending, + cursorGraceMs: options?.cursorGraceMs ?? DEFAULT_OPTIONS.cursorGraceMs, + edgeMarginCells: options?.edgeMarginCells ?? DEFAULT_OPTIONS.edgeMarginCells, + foregroundColor: options?.foregroundColor, + backgroundColor: options?.backgroundColor, + }; + this._predictWhen = options?.predictWhen ?? null; + } + + // ─── Lifecycle ──────────────────────────────────────────────────── + + /** Called by `terminal.loadAddon()`. Do not call directly. */ + activate(terminal: XtermTerminal): void { + this._terminal = terminal; + + this._container = document.createElement('div'); + this._container.setAttribute('data-predictive-echo', ''); + this._container.style.cssText = `position:absolute;left:0;top:0;z-index:${this._options.zIndex};pointer-events:none`; + const screen = terminal.element?.querySelector('.xterm-screen'); + if (screen) screen.appendChild(this._container); + + this._readFontStyle(); + + // Debounced post-parse reconcile: xterm fires onWriteParsed after the + // parser finishes a write chunk, so buffer reads see consistent state. + // The microtask coalesces multi-chunk bursts into one pass. + if (typeof terminal.onWriteParsed === 'function') { + try { + this._disposables.push( + terminal.onWriteParsed(() => { + if (this._reconcileScheduled) return; + this._reconcileScheduled = true; + queueMicrotask(() => { + this._reconcileScheduled = false; + this._safeReconcile(); + }); + }) + ); + } catch { + /* consumers without a working emitter fall back to manual reconcile() */ + } + } + if (typeof terminal.onResize === 'function') { + try { + this._disposables.push(terminal.onResize(() => this.clearPredictions())); + } catch { + /* ignore */ + } + } + } + + dispose(): void { + this.clearPredictions(); + for (const d of this._disposables) { + try { + d.dispose(); + } catch { + /* ignore */ + } + } + this._disposables = []; + this._container?.remove(); + this._container = null; + this._terminal = null; + } + + // ─── Public API ─────────────────────────────────────────────────── + + /** + * Predict a single typed character at the current insertion point. + * Returns false when suppressed; the consumer sends the keystroke to the + * PTY either way (the return value is informational, never a send gate). + */ + predictChar(ch: string): boolean { + try { + this._reconcile(); + + const t = this._terminal; + if (!t || !this._container) return false; + const dims = getCellDimensions(t); + if (!dims) return false; + const buf = t.buffer.active; + if (typeof buf.cursorX !== 'number' || typeof buf.cursorY !== 'number') return false; + if (buf.viewportY !== buf.baseY) return false; + if (this._predictWhen && this._predictWhen(t) === false) return false; + + const cps = Array.from(ch); + if (cps.length !== 1) return false; + const cp = cps[0].codePointAt(0)!; + if (cp < 0x20 || cp === 0x7f) return false; + const w = charCellWidth(t, cps[0]); + if (w !== 1 && w !== 2) return false; + if (w === 2 && !this._hasGetCell()) return false; // ASCII fallback misaligns on wide cols + if (this._outstanding.length >= this._options.maxPending) return false; + + if (this._outstanding.length === 0) { + this._anchor = { row: buf.cursorY, col: buf.cursorX }; + this._cursorOffRowSince = null; + } + const anchor = this._anchor!; + const last = this._outstanding[this._outstanding.length - 1]; + const offset = last ? last.offsetCells + last.width : 0; + const col = anchor.col + offset; + if (col + w > t.cols - this._options.edgeMarginCells) return false; + + const rec: PredictionRecord = { + seq: this._seq++, + char: cps[0], + width: w, + offsetCells: offset, + snapshot: this._readCell(anchor.row, col), + sentAt: performance.now(), + mismatches: 0, + }; + this._outstanding.push(rec); + addPredictionSpan(this._container, this._spans, { + seq: rec.seq, + row: anchor.row, + col, + char: rec.char, + width: w, + dims, + font: this._font, + underline: this._options.underlinePredictions, + }); + this._armTtl(); + return true; + } catch { + return false; + } + } + + /** + * Pop the newest outstanding prediction (visual only). Returns false when + * none are outstanding. The consumer forwards \x7f UNCONDITIONALLY either + * way; deleting already-echoed text renders at RTT. + */ + predictBackspace(): boolean { + try { + const rec = this._outstanding.pop(); + if (!rec) return false; + removePredictionSpan(this._spans, rec.seq); + if (this._outstanding.length === 0) this._resetRun(); + return true; + } catch { + return false; + } + } + + /** Drop every outstanding prediction and its spans. */ + clearPredictions(): void { + try { + this._droppedTotal += this._outstanding.length; + this._outstanding = []; + clearAllSpans(this._spans); + this._resetRun(); + } catch { + /* ignore */ + } + } + + /** Manual reconcile pass, for consumers without onWriteParsed. */ + reconcile(): void { + this._safeReconcile(); + } + + /** Swap the prediction gate at runtime (mirrors the buffer addon's setPrompt). */ + setPredictWhen(fn: ((terminal: XtermTerminal) => boolean) | null): void { + this._predictWhen = fn; + } + + /** Re-read font/theme (call after skin or font-size changes). */ + refreshFont(): void { + this._readFontStyle(); + } + + get hasPredictions(): boolean { + return this._outstanding.length > 0; + } + + get state(): PredictionState { + return { + outstanding: this._outstanding.length, + confirmedTotal: this._confirmedTotal, + droppedTotal: this._droppedTotal, + anchor: this._anchor ? { ...this._anchor } : null, + }; + } + + // ─── Reconciliation ─────────────────────────────────────────────── + + private _safeReconcile(): void { + try { + this._reconcile(); + } catch { + /* predictions may degrade, never break input */ + } + } + + private _reconcile(): void { + const t = this._terminal; + if (!t) return; + if (this._outstanding.length === 0) return; // streaming cost: one boolean + const buf = t.buffer.active; + if (buf.viewportY !== buf.baseY) { + this.clearPredictions(); // user scrolled up + return; + } + if (typeof buf.cursorX !== 'number' || typeof buf.cursorY !== 'number') return; // TTL will clean + const anchor = this._anchor!; + const now = performance.now(); + + // Off-row grace: transient cursor excursions (repaints park the cursor + // elsewhere mid-frame) are tolerated; a sustained move means the composer + // relocated or the user navigated, so predictions are stale. + if (buf.cursorY !== anchor.row) { + this._cursorOffRowSince ??= now; + if (now - this._cursorOffRowSince > this._options.cursorGraceMs) { + this.clearPredictions(); + return; + } + } else { + this._cursorOffRowSince = null; + } + + // Confirm loop: PREFIX-ONLY, and only with the cursor advanced past the + // record. Cell match alone is not enough: the predicted char may equal + // pre-existing content (placeholder glyphs), and an identical in-place + // tmux repaint must be a no-op (cells match snapshots, cursor unmoved). + while (this._outstanding.length > 0) { + const rec = this._outstanding[0]; + const cell = this._readCell(anchor.row, anchor.col + rec.offsetCells); + if (cell === rec.char && buf.cursorY === anchor.row && buf.cursorX >= anchor.col + rec.offsetCells + rec.width) { + this._outstanding.shift(); + removePredictionSpan(this._spans, rec.seq); + this._confirmedTotal++; + } else { + break; + } + } + + // Mismatch scan (two-pass rule): a half-parsed row on pass N is fully + // redrawn a few ms later, so only content foreign on TWO consecutive + // passes cascades. Blank cells are NEUTRAL, not foreign: codex clears its + // placeholder on the first echo, and the blanks left under later + // predictions are what "not yet echoed" looks like, not evidence of a + // redraw (measured 2026-08-09; without this, fast typing over the + // placeholder cascades exactly when RTT is high). TTL still bounds them. + let dropFrom = -1; + for (let i = 0; i < this._outstanding.length; i++) { + const rec = this._outstanding[i]; + const cell = this._readCell(anchor.row, anchor.col + rec.offsetCells); + if (cell !== rec.snapshot && cell !== rec.char && cell !== ' ') { + rec.mismatches++; + if (rec.mismatches >= 2) { + dropFrom = i; + break; + } + } else { + rec.mismatches = 0; + } + } + if (dropFrom !== -1) this._dropFrom(dropFrom); + + // TTL: the first stale record drops itself and everything after it. + for (let i = 0; i < this._outstanding.length; i++) { + if (now - this._outstanding[i].sentAt > this._options.ttlMs) { + this._dropFrom(i); + break; + } + } + + if (this._outstanding.length === 0) { + this._resetRun(); + } else { + this._armTtl(); + } + } + + private _dropFrom(index: number): void { + const dropped = this._outstanding.splice(index); + for (const rec of dropped) removePredictionSpan(this._spans, rec.seq); + this._droppedTotal += dropped.length; + } + + private _resetRun(): void { + this._anchor = null; + this._cursorOffRowSince = null; + if (this._ttlTimer !== null) { + clearTimeout(this._ttlTimer); + this._ttlTimer = null; + } + } + + private _armTtl(): void { + if (this._ttlTimer !== null) return; + const oldest = this._outstanding[0]; + if (!oldest) return; + const delay = Math.max(0, oldest.sentAt + this._options.ttlMs - performance.now()) + 1; + this._ttlTimer = setTimeout(() => { + this._ttlTimer = null; + this._safeReconcile(); + this._armTtl(); + }, delay); + } + + // ─── Cell access ────────────────────────────────────────────────── + + private _hasGetCell(): boolean { + const buf = this._terminal?.buffer.active; + if (!buf) return false; + const line = buf.getLine(buf.baseY + (buf.cursorY ?? 0)); + return typeof line?.getCell === 'function'; + } + + /** Read one cell's chars at (viewport-relative row, col); '' -> ' '. */ + private _readCell(row: number, col: number): string { + const buf = this._terminal!.buffer.active; + const line = buf.getLine(buf.baseY + row); + if (!line) return ' '; + if (typeof line.getCell === 'function') { + const chars = line.getCell(col)?.getChars() ?? ''; + return chars === '' ? ' ' : chars; + } + // ASCII fallback: code-unit index, misaligns after wide columns, which is + // why width-2 predictions are suppressed without getCell. + const text = line.translateToString(true); + return text[col] ?? ' '; + } + + // ─── Font ───────────────────────────────────────────────────────── + + /** Same recipe as the buffer addon's _cacheFont (kept private on purpose: + * zerolag-input-addon.ts must stay untouched by this feature). */ + private _readFontStyle(): void { + const t = this._terminal; + if (!t) return; + this._font.fontFamily = t.options.fontFamily || 'monospace'; + this._font.fontSize = (t.options.fontSize || 14) + 'px'; + this._font.fontWeight = String(t.options.fontWeight || 'normal'); + this._font.backgroundColor = this._options.backgroundColor ?? t.options.theme?.background ?? DEFAULT_BG; + this._font.color = this._options.foregroundColor ?? t.options.theme?.foreground ?? DEFAULT_FG; + this._font.letterSpacing = ''; + const rows = t.element?.querySelector('.xterm-rows'); + if (rows) { + const cs = getComputedStyle(rows); + this._font.letterSpacing = cs.letterSpacing; + if (!this._options.foregroundColor && cs.color) this._font.color = cs.color; + } + } +} diff --git a/packages/xterm-zerolag-input/src/prompt-finder.ts b/packages/xterm-zerolag-input/src/prompt-finder.ts index 90ea0cf0..9d660ede 100644 --- a/packages/xterm-zerolag-input/src/prompt-finder.ts +++ b/packages/xterm-zerolag-input/src/prompt-finder.ts @@ -6,55 +6,50 @@ import type { XtermTerminal, PromptFinder, PromptPosition } from './types.js'; * * @returns The prompt position (viewport-relative), or `null` if not found. */ -export function findPrompt( - terminal: XtermTerminal, - finder: PromptFinder, -): PromptPosition | null { - try { - const buffer = terminal.buffer.active; - const viewportTop = buffer.viewportY; +export function findPrompt(terminal: XtermTerminal, finder: PromptFinder): PromptPosition | null { + try { + const buffer = terminal.buffer.active; + const viewportTop = buffer.viewportY; - switch (finder.type) { - case 'character': { - for (let row = terminal.rows - 1; row >= 0; row--) { - const line = buffer.getLine(viewportTop + row); - if (!line) continue; - const text = line.translateToString(true); - const idx = text.lastIndexOf(finder.char); - if (idx >= 0) return { row, col: idx }; - } - return null; - } - - case 'regex': { - // Create a fresh non-global regex to avoid lastIndex mutation - // and ensure .match() returns a single result with .index - const pattern = finder.pattern; - const safePattern = pattern.global - ? new RegExp(pattern.source, pattern.flags.replace('g', '')) - : pattern; - for (let row = terminal.rows - 1; row >= 0; row--) { - const line = buffer.getLine(viewportTop + row); - if (!line) continue; - const text = line.translateToString(true); - const match = text.match(safePattern); - if (match) { - const col = match.index ?? 0; - return { row, col }; - } - } - return null; - } - - case 'custom': - return finder.find(terminal); - - default: - return null; + switch (finder.type) { + case 'character': { + for (let row = terminal.rows - 1; row >= 0; row--) { + const line = buffer.getLine(viewportTop + row); + if (!line) continue; + const text = line.translateToString(true); + const idx = text.lastIndexOf(finder.char); + if (idx >= 0) return { row, col: idx }; } - } catch { + return null; + } + + case 'regex': { + // Create a fresh non-global regex to avoid lastIndex mutation + // and ensure .match() returns a single result with .index + const pattern = finder.pattern; + const safePattern = pattern.global ? new RegExp(pattern.source, pattern.flags.replace('g', '')) : pattern; + for (let row = terminal.rows - 1; row >= 0; row--) { + const line = buffer.getLine(viewportTop + row); + if (!line) continue; + const text = line.translateToString(true); + const match = text.match(safePattern); + if (match) { + const col = match.index ?? 0; + return { row, col }; + } + } + return null; + } + + case 'custom': + return finder.find(terminal); + + default: return null; } + } catch { + return null; + } } /** @@ -65,19 +60,15 @@ export function findPrompt( * @param offset - Characters to skip after the prompt marker (e.g., 2 for "> ") * @returns The text after the prompt, trimmed. Empty string if nothing found. */ -export function readTextAfterPrompt( - terminal: XtermTerminal, - prompt: PromptPosition, - offset: number, -): string { - try { - const buffer = terminal.buffer.active; - const absRow = buffer.viewportY + prompt.row; - const line = buffer.getLine(absRow); - if (!line) return ''; - const lineText = line.translateToString(true); - return lineText.slice(prompt.col + offset).trimEnd(); - } catch { - return ''; - } +export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosition, offset: number): string { + try { + const buffer = terminal.buffer.active; + const absRow = buffer.viewportY + prompt.row; + const line = buffer.getLine(absRow); + if (!line) return ''; + const lineText = line.translateToString(true); + return lineText.slice(prompt.col + offset).trimEnd(); + } catch { + return ''; + } } diff --git a/packages/xterm-zerolag-input/test/cell-dimensions.test.ts b/packages/xterm-zerolag-input/test/cell-dimensions.test.ts index 165a2b1f..b07c1840 100644 --- a/packages/xterm-zerolag-input/test/cell-dimensions.test.ts +++ b/packages/xterm-zerolag-input/test/cell-dimensions.test.ts @@ -6,122 +6,125 @@ import type { XtermTerminal } from '../src/types.js'; let cleanups: (() => void)[] = []; afterEach(() => { - for (const fn of cleanups) fn(); - cleanups = []; + for (const fn of cleanups) fn(); + cleanups = []; }); describe('getCellDimensions', () => { - describe('v5 private API (mock _core._renderService)', () => { - it('returns cell width and height from css.cell', () => { - const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims).not.toBeNull(); - expect(dims!.width).toBe(8.4); - expect(dims!.height).toBe(19); - }); - - it('returns charTop from device.char.top divided by DPR', () => { - const mock = createMockTerminal({ - cellWidth: 8, cellHeight: 19, - deviceCharTop: 2, - }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims).not.toBeNull(); - // DPR=1 in jsdom, so charTop = 2 / 1 = 2 - expect(dims!.charTop).toBe(2); - }); - - it('returns charHeight from device.char.height divided by DPR', () => { - const mock = createMockTerminal({ - cellWidth: 8, cellHeight: 19, - deviceCharHeight: 16, - }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims).not.toBeNull(); - // DPR=1, so charHeight = 16 / 1 = 16 - expect(dims!.charHeight).toBe(16); - }); - - it('defaults charTop to 0 when device.char not present', () => { - // Default mock has deviceCharTop=0 - const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims!.charTop).toBe(0); - }); - - it('defaults charHeight to cellH when device.char.height not set', () => { - // Default mock has deviceCharHeight=cellH - const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims!.charHeight).toBe(19); - }); + describe('v5 private API (mock _core._renderService)', () => { + it('returns cell width and height from css.cell', () => { + const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + expect(dims!.width).toBe(8.4); + expect(dims!.height).toBe(19); }); - describe('DPR simulation', () => { - const originalDPR = globalThis.devicePixelRatio; - - beforeEach(() => { - // Set DPR=2 to test division - Object.defineProperty(globalThis, 'devicePixelRatio', { - value: 2, - writable: true, - configurable: true, - }); - }); - - afterEach(() => { - Object.defineProperty(globalThis, 'devicePixelRatio', { - value: originalDPR, - writable: true, - configurable: true, - }); - }); - - it('divides device.char.top by DPR', () => { - const mock = createMockTerminal({ - cellWidth: 16, cellHeight: 38, - deviceCharTop: 4, - deviceCharHeight: 32, - }); - cleanups.push(mock.cleanup); - const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); - expect(dims).not.toBeNull(); - // charTop = 4 / 2 = 2 - expect(dims!.charTop).toBe(2); - // charHeight = 32 / 2 = 16 - expect(dims!.charHeight).toBe(16); - }); + it('returns charTop from device.char.top divided by DPR', () => { + const mock = createMockTerminal({ + cellWidth: 8, + cellHeight: 19, + deviceCharTop: 2, + }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + // DPR=1 in jsdom, so charTop = 2 / 1 = 2 + expect(dims!.charTop).toBe(2); }); - describe('null cases', () => { - it('returns null for terminal without _core', () => { - const terminal = { - element: document.createElement('div'), - cols: 80, - rows: 24, - options: {}, - buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, - } as unknown as XtermTerminal; - const dims = getCellDimensions(terminal); - expect(dims).toBeNull(); - }); - - it('returns null for terminal with no dimensions', () => { - const terminal = { - element: document.createElement('div'), - cols: 80, - rows: 24, - options: {}, - buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, - _core: { _renderService: {} }, - } as unknown as XtermTerminal; - const dims = getCellDimensions(terminal); - expect(dims).toBeNull(); - }); + it('returns charHeight from device.char.height divided by DPR', () => { + const mock = createMockTerminal({ + cellWidth: 8, + cellHeight: 19, + deviceCharHeight: 16, + }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + // DPR=1, so charHeight = 16 / 1 = 16 + expect(dims!.charHeight).toBe(16); }); + + it('defaults charTop to 0 when device.char not present', () => { + // Default mock has deviceCharTop=0 + const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims!.charTop).toBe(0); + }); + + it('defaults charHeight to cellH when device.char.height not set', () => { + // Default mock has deviceCharHeight=cellH + const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims!.charHeight).toBe(19); + }); + }); + + describe('DPR simulation', () => { + const originalDPR = globalThis.devicePixelRatio; + + beforeEach(() => { + // Set DPR=2 to test division + Object.defineProperty(globalThis, 'devicePixelRatio', { + value: 2, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'devicePixelRatio', { + value: originalDPR, + writable: true, + configurable: true, + }); + }); + + it('divides device.char.top by DPR', () => { + const mock = createMockTerminal({ + cellWidth: 16, + cellHeight: 38, + deviceCharTop: 4, + deviceCharHeight: 32, + }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + // charTop = 4 / 2 = 2 + expect(dims!.charTop).toBe(2); + // charHeight = 32 / 2 = 16 + expect(dims!.charHeight).toBe(16); + }); + }); + + describe('null cases', () => { + it('returns null for terminal without _core', () => { + const terminal = { + element: document.createElement('div'), + cols: 80, + rows: 24, + options: {}, + buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, + } as unknown as XtermTerminal; + const dims = getCellDimensions(terminal); + expect(dims).toBeNull(); + }); + + it('returns null for terminal with no dimensions', () => { + const terminal = { + element: document.createElement('div'), + cols: 80, + rows: 24, + options: {}, + buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, + _core: { _renderService: {} }, + } as unknown as XtermTerminal; + const dims = getCellDimensions(terminal); + expect(dims).toBeNull(); + }); + }); }); diff --git a/packages/xterm-zerolag-input/test/codex-replay.test.ts b/packages/xterm-zerolag-input/test/codex-replay.test.ts new file mode 100644 index 00000000..1e5aad38 --- /dev/null +++ b/packages/xterm-zerolag-input/test/codex-replay.test.ts @@ -0,0 +1,172 @@ +/** + * @vitest-environment jsdom + * + * Layer 2 (the load-bearing suite): the REAL algorithm against the REAL xterm + * parser, fed by fixtures recorded from real codex 0.147 through the + * production pipeline (tmux + the codex full strip). See + * scripts/dev/record-codex-frames.mjs in the consuming repo. + * + * Every replay ends with the convergence invariant: predictions never outlive + * their run (outstanding 0, span container empty). + */ +import { describe, expect, it } from 'vitest'; +import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js'; +import { + CELL_H, + CELL_W, + classifyPredictInput, + codexComposerGate, + createReplayTerminal, + loadFixture, + type ReplayTerminal, +} from './replay-helpers.js'; + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +interface KeyEvent { + key: string; + kind: ReturnType; + painted: boolean; + spansAfter: number; +} + +function assertSpansInGrid(rt: ReplayTerminal) { + for (const s of rt.spans()) { + const left = parseFloat(s.style.left); + const width = parseFloat(s.style.width); + const top = parseFloat(s.style.top); + expect(left + width).toBeLessThanOrEqual(rt.hybrid.cols * CELL_W); + expect(top).toBeLessThanOrEqual((rt.hybrid.rows - 1) * CELL_H); + expect(left).toBeGreaterThanOrEqual(0); + expect(top).toBeGreaterThanOrEqual(0); + } +} + +async function replay(name: string) { + const { meta, lines } = loadFixture(name); + const rt = createReplayTerminal(meta.cols, meta.rows); + const addon = new PredictiveEchoAddon({ predictWhen: codexComposerGate }); + addon.activate(rt.hybrid); + + const events: KeyEvent[] = []; + for (const line of lines) { + if (line.keyAt) { + const kind = classifyPredictInput(line.data); + let painted = false; + if (kind === 'char') painted = addon.predictChar(line.data); + else if (kind === 'backspace') addon.predictBackspace(); + else if (kind === 'clear') addon.clearPredictions(); + // Span/record parity and grid bounds hold at every step + expect(rt.spanCount()).toBe(addon.state.outstanding); + assertSpansInGrid(rt); + events.push({ key: line.data, kind, painted, spansAfter: rt.spanCount() }); + } else { + await rt.write(line.data); + await flushMicrotasks(); + } + } + return { rt, addon, events, meta }; +} + +/** Convergence invariant: after the last chunk + reconcile (+ TTL if needed), + * nothing outlives the run. */ +async function converge(rt: ReplayTerminal, addon: PredictiveEchoAddon) { + addon.reconcile(); + if (addon.state.outstanding > 0) { + await sleep(1100); // ttlMs default + addon.reconcile(); + } + expect(addon.state.outstanding).toBe(0); + expect(rt.spanCount()).toBe(0); +} + +describe('codex replay', () => { + it('type-hello: all 5 predictions confirm, zero drops, composer converges', async () => { + const { rt, addon, events } = await replay('type-hello'); + const chars = events.filter((e) => e.kind === 'char'); + expect(chars).toHaveLength(5); + expect(chars.every((e) => e.painted)).toBe(true); + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(5); + expect(addon.state.droppedTotal).toBe(0); + expect(rt.cursorRowText()).toBe('› hello'); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('slash-picker: "/" and filter chars confirm; no ghosts while picker rows redraw', async () => { + const { rt, addon, events } = await replay('slash-picker'); + const chars = events.filter((e) => e.kind === 'char'); + expect(chars.map((e) => e.key)).toEqual(['/', 'm', 'o']); + expect(chars.every((e) => e.painted)).toBe(true); + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(3); + expect(addon.state.droppedTotal).toBe(0); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('wrap: predictions stay inside the grid, continuation rows fall back to real echo, buffer converges', async () => { + const { rt, addon, events } = await replay('wrap'); + // The gate goes false once the cursor is on a wrapped continuation row + // (2-space indent, no "› "): a tail of keystrokes must be suppressed. + const chars = events.filter((e) => e.kind === 'char'); + expect(chars.some((e) => !e.painted)).toBe(true); + expect(chars.some((e) => e.painted)).toBe(true); + await converge(rt, addon); + // The composer content is exactly what was typed (word-wrapped) + const b = rt.term.buffer.active; + const cursorRow = b.cursorY; + expect(rt.rowText(cursorRow).trim()).toBe('this line twice over'); + expect(rt.rowText(cursorRow - 1)).toMatch(/^› the quick brown fox/); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('streaming-burst: typed predictions confirm; the re-rendered composer keeps its signature', async () => { + const { rt, addon, events } = await replay('streaming-burst'); + const chars = events.filter((e) => e.kind === 'char'); + expect(chars).toHaveLength(5); // "hello" (the \r is kind 'clear') + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(5); + expect(addon.state.droppedTotal).toBe(0); + // After the 401 burst codex re-renders a fresh composer at the cursor + expect(rt.cursorRowText()).toMatch(/^› /); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('paste-bracketed: typed chars confirm, the paste clears predictions, content intact', async () => { + const { rt, addon, events } = await replay('paste-bracketed'); + const paste = events.find((e) => e.key.startsWith('\x1b[200~'))!; + expect(paste.kind).toBe('clear'); + expect(paste.spansAfter).toBe(0); + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(2); // 'a', 'b' + expect(rt.cursorRowText()).toContain('abXYZpasted'); + addon.dispose(); + rt.cleanup(); + }, 15000); + + it('trust-modal: the predictWhen gate paints ZERO spans on the modal (ghost eliminator)', async () => { + const { rt, addon, events } = await replay('trust-modal'); + const x = events.find((e) => e.key === 'x')!; + expect(x.painted).toBe(false); + expect(x.spansAfter).toBe(0); + expect(events.every((e) => e.spansAfter === 0)).toBe(true); + await converge(rt, addon); + expect(addon.state.confirmedTotal).toBe(0); + expect(addon.state.droppedTotal).toBe(0); + // The transition landed on the real composer afterwards + expect(rt.cursorRowText()).toMatch(/^› /); + addon.dispose(); + rt.cleanup(); + }, 15000); +}); diff --git a/packages/xterm-zerolag-input/test/helpers.ts b/packages/xterm-zerolag-input/test/helpers.ts index e1639b8a..d2229576 100644 --- a/packages/xterm-zerolag-input/test/helpers.ts +++ b/packages/xterm-zerolag-input/test/helpers.ts @@ -3,129 +3,204 @@ * * Creates a minimal Terminal-like object that satisfies the addon's * requirements without needing a real xterm.js instance or DOM renderer. + * + * PredictiveEchoAddon additions (all ADDITIVE, existing tests unchanged): + * mutable cursor via setCursor(), wide-char-aware getCell() on mock lines, + * onWriteParsed/onResize emitters with fire* triggers, and opt-outs for + * getCell support and the emitters (getCellSupport / emitters options). */ +import { charCellWidth } from '../src/overlay-renderer.js'; interface MockLine { - translateToString(_trimRight?: boolean): string; + translateToString(_trimRight?: boolean): string; + getCell?(x: number): { getChars(): string; getWidth(): number } | undefined; } interface MockBufferOptions { - lines: string[]; - viewportY?: number; - baseY?: number; - cursorX?: number; - cursorY?: number; + lines: string[]; + viewportY?: number; + baseY?: number; + cursorX?: number; + cursorY?: number; } interface MockTerminalOptions { - buffer?: MockBufferOptions; - cols?: number; - rows?: number; - fontFamily?: string; - fontSize?: number; - fontWeight?: string | number; - theme?: { - background?: string; - foreground?: string; - cursor?: string; - }; - cellWidth?: number; - cellHeight?: number; - /** Device-pixel char top offset (for charTop calculation). Default: 0 */ - deviceCharTop?: number; - /** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */ - deviceCharHeight?: number; + buffer?: MockBufferOptions; + cols?: number; + rows?: number; + fontFamily?: string; + fontSize?: number; + fontWeight?: string | number; + theme?: { + background?: string; + foreground?: string; + cursor?: string; + }; + cellWidth?: number; + cellHeight?: number; + /** Device-pixel char top offset (for charTop calculation). Default: 0 */ + deviceCharTop?: number; + /** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */ + deviceCharHeight?: number; + /** Provide getCell() on mock lines (PredictiveEchoAddon). Default: true */ + getCellSupport?: boolean; + /** Provide onWriteParsed/onResize emitters (PredictiveEchoAddon). Default: true */ + emitters?: boolean; +} + +/** Column-indexed cell access over a plain string, wide-char aware. */ +function cellAt(text: string, col: number): { getChars(): string; getWidth(): number } { + let c = 0; + for (const ch of text) { + const w = charCellWidth(null, ch); + if (col === c) return { getChars: () => ch, getWidth: () => w }; + if (w === 2 && col === c + 1) return { getChars: () => '', getWidth: () => 0 }; + c += w; + } + return { getChars: () => '', getWidth: () => 1 }; } export function createMockTerminal(opts: MockTerminalOptions = {}) { - const bufOpts = opts.buffer ?? { lines: ['$ '] }; - const lines = bufOpts.lines; - const viewportY = bufOpts.viewportY ?? 0; - const baseY = bufOpts.baseY ?? viewportY; - const cols = opts.cols ?? 80; - const rows = opts.rows ?? Math.max(lines.length, 24); - const cellW = opts.cellWidth ?? 8.4; - const cellH = opts.cellHeight ?? 17; + const bufOpts = opts.buffer ?? { lines: ['$ '] }; + const viewportY = bufOpts.viewportY ?? 0; + const baseY = bufOpts.baseY ?? viewportY; + const cols = opts.cols ?? 80; + const rows = opts.rows ?? Math.max(bufOpts.lines.length, 24); + const cellW = opts.cellWidth ?? 8.4; + const cellH = opts.cellHeight ?? 17; + const getCellSupport = opts.getCellSupport ?? true; + const emitters = opts.emitters ?? true; - const mockLines: MockLine[] = lines.map((text) => ({ - translateToString: () => text, - })); - - // Create minimal DOM structure - const element = document.createElement('div'); - element.className = 'terminal xterm'; - - const viewport = document.createElement('div'); - viewport.className = 'xterm-viewport'; - - const screen = document.createElement('div'); - screen.className = 'xterm-screen'; - screen.style.position = 'relative'; - - const xtermRows = document.createElement('div'); - xtermRows.className = 'xterm-rows'; - - element.appendChild(viewport); - element.appendChild(screen); - screen.appendChild(xtermRows); - - // Append to document so getComputedStyle works - document.body.appendChild(element); - - const terminal = { - element, - cols, - rows, - options: { - fontFamily: opts.fontFamily ?? 'monospace', - fontSize: opts.fontSize ?? 14, - fontWeight: opts.fontWeight ?? 'normal', - theme: opts.theme ?? {}, - }, - buffer: { - active: { - viewportY, - baseY, - cursorX: bufOpts.cursorX ?? 0, - cursorY: bufOpts.cursorY ?? 0, - getLine: (absRow: number): MockLine | undefined => { - return mockLines[absRow - viewportY]; - }, - }, - }, - _core: { - _renderService: { - dimensions: { - css: { - cell: { width: cellW, height: cellH }, - }, - device: { - char: { - top: opts.deviceCharTop ?? 0, - height: opts.deviceCharHeight ?? cellH, - }, - }, - }, - }, - }, - // Simulate loadAddon - loadAddon(addon: { activate: (t: unknown) => void }) { - addon.activate(this); - }, + const makeLine = (text: string): { line: MockLine; set(t: string): void } => { + let current = text; + const line: MockLine = { + translateToString: () => current, }; + if (getCellSupport) { + line.getCell = (x: number) => cellAt(current, x); + } + return { line, set: (t: string) => (current = t) }; + }; - return { - terminal, - /** Update buffer lines for subsequent calls */ - setLines(newLines: string[]) { - mockLines.length = 0; - for (const text of newLines) { - mockLines.push({ translateToString: () => text }); - } + let mockLines = bufOpts.lines.map(makeLine); + + // Create minimal DOM structure + const element = document.createElement('div'); + element.className = 'terminal xterm'; + + const viewport = document.createElement('div'); + viewport.className = 'xterm-viewport'; + + const screen = document.createElement('div'); + screen.className = 'xterm-screen'; + screen.style.position = 'relative'; + + const xtermRows = document.createElement('div'); + xtermRows.className = 'xterm-rows'; + + element.appendChild(viewport); + element.appendChild(screen); + screen.appendChild(xtermRows); + + // Append to document so getComputedStyle works + document.body.appendChild(element); + + const writeParsedCbs = new Set<() => void>(); + const resizeCbs = new Set<(s: { cols: number; rows: number }) => void>(); + + const terminal = { + element, + cols, + rows, + options: { + fontFamily: opts.fontFamily ?? 'monospace', + fontSize: opts.fontSize ?? 14, + fontWeight: opts.fontWeight ?? 'normal', + theme: opts.theme ?? {}, + }, + buffer: { + active: { + viewportY, + baseY, + cursorX: bufOpts.cursorX ?? 0, + cursorY: bufOpts.cursorY ?? 0, + getLine: (absRow: number): MockLine | undefined => { + return mockLines[absRow - viewportY]?.line; }, - /** Clean up DOM */ - cleanup() { - element.remove(); + }, + }, + _core: { + _renderService: { + dimensions: { + css: { + cell: { width: cellW, height: cellH }, + }, + device: { + char: { + top: opts.deviceCharTop ?? 0, + height: opts.deviceCharHeight ?? cellH, + }, + }, }, - }; + }, + }, + ...(emitters + ? { + onWriteParsed(cb: () => void) { + writeParsedCbs.add(cb); + return { dispose: () => writeParsedCbs.delete(cb) }; + }, + onResize(cb: (s: { cols: number; rows: number }) => void) { + resizeCbs.add(cb); + return { dispose: () => resizeCbs.delete(cb) }; + }, + } + : {}), + // Simulate loadAddon + loadAddon(addon: { activate: (t: unknown) => void }) { + addon.activate(this); + }, + }; + + return { + terminal, + /** Update buffer lines for subsequent calls */ + setLines(newLines: string[]) { + mockLines = newLines.map(makeLine); + }, + /** Update one line's text in place (PredictiveEchoAddon echo simulation) */ + setLine(index: number, text: string) { + mockLines[index]?.set(text); + }, + /** Move the mock cursor (PredictiveEchoAddon) */ + setCursor(x: number, y: number) { + terminal.buffer.active.cursorX = x; + terminal.buffer.active.cursorY = y; + }, + /** Set scroll state (viewportY / baseY) */ + setScroll(newViewportY: number, newBaseY: number) { + terminal.buffer.active.viewportY = newViewportY; + terminal.buffer.active.baseY = newBaseY; + }, + /** Fire the onWriteParsed emitter (PredictiveEchoAddon reconcile trigger) */ + fireWriteParsed() { + for (const cb of [...writeParsedCbs]) cb(); + }, + /** Fire the onResize emitter */ + fireResize(newCols = cols, newRows = rows) { + for (const cb of [...resizeCbs]) cb({ cols: newCols, rows: newRows }); + }, + /** Number of live onWriteParsed listeners (dispose assertions) */ + writeParsedListenerCount() { + return writeParsedCbs.size; + }, + /** Number of live onResize listeners (dispose assertions) */ + resizeListenerCount() { + return resizeCbs.size; + }, + /** Clean up DOM */ + cleanup() { + element.remove(); + }, + }; } diff --git a/packages/xterm-zerolag-input/test/prediction-renderer.test.ts b/packages/xterm-zerolag-input/test/prediction-renderer.test.ts new file mode 100644 index 00000000..8280807c --- /dev/null +++ b/packages/xterm-zerolag-input/test/prediction-renderer.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + * + * prediction-renderer unit tests: span geometry math, seam-cover height, + * ligature suppression, incremental add/remove keyed by seq, and geometry + * stability under a non-1 devicePixelRatio (all dims are CSS px). + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { addPredictionSpan, clearAllSpans, removePredictionSpan } from '../src/prediction-renderer.js'; +import type { CellDimensions, FontStyle } from '../src/types.js'; + +const dims: CellDimensions = { width: 9, height: 18, charTop: 1, charHeight: 16 }; +const font: FontStyle = { + fontFamily: 'monospace', + fontSize: '14px', + fontWeight: 'normal', + color: '#e0e0e0', + backgroundColor: '#101010', + letterSpacing: '0.5px', +}; + +function makeContainer() { + const el = document.createElement('div'); + document.body.appendChild(el); + return el; +} + +function span(container: HTMLElement, map: Map, over: Record = {}) { + addPredictionSpan(container, map, { + seq: 1, + row: 3, + col: 5, + char: 'x', + width: 1, + dims, + font, + underline: false, + ...over, + } as never); + return map.get((over.seq as number) ?? 1)!; +} + +describe('prediction-renderer', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('positions a width-1 span on the exact cell grid', () => { + const map = new Map(); + const s = span(makeContainer(), map); + expect(s.style.left).toBe(`${5 * 9}px`); + expect(s.style.top).toBe(`${3 * 18}px`); + expect(s.style.width).toBe(`${9}px`); + expect(s.textContent).toBe('x'); + }); + + it('positions a width-2 span across two cells', () => { + const map = new Map(); + const s = span(makeContainer(), map, { char: '你', width: 2 }); + expect(s.style.width).toBe(`${2 * 9}px`); + }); + + it('covers the row seam: height is cellH+1 with line-height cellH', () => { + const map = new Map(); + const s = span(makeContainer(), map); + expect(s.style.height).toBe(`${18 + 1}px`); + expect(s.style.lineHeight).toBe('18px'); + }); + + it('disables ligatures and pointer events, applies font + letter-spacing', () => { + const map = new Map(); + const s = span(makeContainer(), map); + expect(s.style.cssText).toContain("'liga' 0"); + expect(s.style.cssText).toContain("'calt' 0"); + expect(s.style.pointerEvents).toBe('none'); + expect(s.style.fontFamily).toBe('monospace'); + expect(s.style.letterSpacing).toBe('0.5px'); + expect(s.style.textAlign).toBe('center'); + }); + + it('paints an opaque background over only its own cells', () => { + const map = new Map(); + const s = span(makeContainer(), map); + expect(['#101010', 'rgb(16, 16, 16)']).toContain(s.style.backgroundColor); + // Background is bounded by the span's own width, never a full row + expect(s.style.width).toBe('9px'); + }); + + it('underline renders only when requested', () => { + const map = new Map(); + const container = makeContainer(); + const plain = span(container, map, { seq: 1 }); + const lined = span(container, map, { seq: 2, underline: true }); + expect(plain.style.textDecoration).toBe(''); + expect(lined.style.textDecoration).toBe('underline'); + }); + + it('adds and removes incrementally, keyed by seq', () => { + const map = new Map(); + const container = makeContainer(); + span(container, map, { seq: 1 }); + span(container, map, { seq: 2, col: 6 }); + span(container, map, { seq: 3, col: 7 }); + expect(container.children).toHaveLength(3); + + removePredictionSpan(map, 2); + expect(container.children).toHaveLength(2); + expect(map.has(2)).toBe(false); + expect(map.has(1)).toBe(true); + expect(map.has(3)).toBe(true); + + removePredictionSpan(map, 999); // unknown seq: no-op + expect(container.children).toHaveLength(2); + }); + + it('clearAllSpans empties both the DOM and the map', () => { + const map = new Map(); + const container = makeContainer(); + span(container, map, { seq: 1 }); + span(container, map, { seq: 2, col: 6 }); + clearAllSpans(map); + expect(container.children).toHaveLength(0); + expect(map.size).toBe(0); + }); + + it('geometry is stable under devicePixelRatio 2 (dims are CSS px)', () => { + vi.stubGlobal('devicePixelRatio', 2); + const map = new Map(); + const s = span(makeContainer(), map); + expect(s.style.left).toBe(`${5 * 9}px`); + expect(s.style.top).toBe(`${3 * 18}px`); + expect(s.style.width).toBe('9px'); + }); +}); diff --git a/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts b/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts new file mode 100644 index 00000000..bbeca038 --- /dev/null +++ b/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts @@ -0,0 +1,505 @@ +/** + * @vitest-environment jsdom + * + * PredictiveEchoAddon unit tests: the algorithm laws (anchoring, prefix-only + * confirmation with cursor advance, two-pass mismatch cascade with neutral + * blanks, TTL, off-row grace, gates) and lifecycle safety. + * + * Timer-based cases fake `performance` explicitly: the addon clocks + * sentAt/TTL/grace with performance.now(), which vitest does NOT fake by + * default. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js'; +import { createMockTerminal } from './helpers.js'; + +const TIMER_CONFIG = { + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date', 'performance'] as const, +}; + +/** Composer-like buffer: `› ` marker + placeholder, cursor at col 2 row 0. */ +function composerMock(opts: Parameters[0] = {}) { + return createMockTerminal({ + buffer: { lines: ['› Use /skills to list', '', ''], cursorX: 2, cursorY: 0 }, + ...opts, + }); +} + +function spansOf(mock: ReturnType): HTMLSpanElement[] { + const screen = mock.terminal.element.querySelector('.xterm-screen')!; + return Array.from(screen.querySelectorAll('[data-predictive-echo] span')) as HTMLSpanElement[]; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('PredictiveEchoAddon', () => { + let mock: ReturnType; + let addon: PredictiveEchoAddon; + + beforeEach(() => { + vi.useFakeTimers(TIMER_CONFIG); + mock = composerMock(); + addon = new PredictiveEchoAddon(); + addon.activate(mock.terminal as never); + }); + + afterEach(() => { + addon.dispose(); + mock.cleanup(); + vi.useRealTimers(); + }); + + it('paints a span at the cursor cell and returns true', () => { + expect(addon.predictChar('h')).toBe(true); + const spans = spansOf(mock); + expect(spans).toHaveLength(1); + expect(spans[0].textContent).toBe('h'); + expect(spans[0].style.left).toBe(`${2 * 8.4}px`); + expect(spans[0].style.top).toBe('0px'); + expect(addon.state.outstanding).toBe(1); + }); + + it('stacks predictions at anchor+cumulative width while the cursor is unmoved', () => { + addon.predictChar('h'); + addon.predictChar('e'); + addon.predictChar('y'); + const spans = spansOf(mock); + expect(spans.map((s) => s.style.left)).toEqual([`${2 * 8.4}px`, `${3 * 8.4}px`, `${4 * 8.4}px`]); + expect(addon.state.anchor).toEqual({ row: 0, col: 2 }); + }); + + it('re-anchors at the new cursor once outstanding drains to zero', async () => { + addon.predictChar('h'); + mock.setLine(0, '› h'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); + expect(addon.state.anchor).toBeNull(); + + addon.predictChar('i'); + expect(addon.state.anchor).toEqual({ row: 0, col: 3 }); + expect(spansOf(mock)[0].style.left).toBe(`${3 * 8.4}px`); + }); + + it('inline reconcile inside predictChar absorbs an echo that landed between keystrokes', () => { + addon.predictChar('h'); + // Echo lands but no onWriteParsed fires before the next keystroke + mock.setLine(0, '› h'); + mock.setCursor(3, 0); + expect(addon.predictChar('i')).toBe(true); + // 'h' confirmed inline; 'i' anchored at the advanced cursor, not stacked + expect(addon.state.outstanding).toBe(1); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.anchor).toEqual({ row: 0, col: 3 }); + }); + + it('confirms and removes exactly the echoed prefix (cell match + cursor advance)', async () => { + addon.predictChar('a'); + addon.predictChar('b'); + addon.predictChar('c'); + mock.setLine(0, '› ab'); + mock.setCursor(4, 0); // advanced past 'a' and 'b' only + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.confirmedTotal).toBe(2); + expect(addon.state.outstanding).toBe(1); + expect(spansOf(mock).map((s) => s.textContent)).toEqual(['c']); + }); + + it('partial confirmation never moves remaining spans (no jitter)', async () => { + addon.predictChar('a'); + addon.predictChar('b'); + const bLeft = spansOf(mock)[1].style.left; + mock.setLine(0, '› a'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(spansOf(mock)).toHaveLength(1); + expect(spansOf(mock)[0].style.left).toBe(bLeft); + }); + + it('does NOT confirm when the cell matches but the cursor has not advanced (in-place repaint)', async () => { + // Predict 'U' over the placeholder whose cell already shows 'U' + addon.predictChar('U'); + expect(addon.state.outstanding).toBe(1); + // tmux repaints the identical row; cursor stays at the anchor + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); + expect(addon.state.confirmedTotal).toBe(0); + }); + + it('does NOT confirm or drop when the predicted char equals the pre-existing snapshot', async () => { + addon.predictChar('U'); + // Several passes over the unchanged placeholder: no confirm, no cascade + for (let i = 0; i < 4; i++) { + mock.fireWriteParsed(); + await flushMicrotasks(); + } + expect(addon.state.outstanding).toBe(1); + expect(addon.state.droppedTotal).toBe(0); + }); + + it('one transient mismatch survives; a persistent foreign cell cascades (two-pass rule)', async () => { + addon.predictChar('a'); + mock.setLine(0, '› Z'); // foreign non-blank at the predicted cell + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); // pass 1: survives + + // Transient recovery resets the counter + mock.setLine(0, '› Use /skills to list'); + mock.fireWriteParsed(); + await flushMicrotasks(); + mock.setLine(0, '› Z'); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); // count restarted, pass 1 again + + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); // pass 2: cascaded + expect(addon.state.droppedTotal).toBe(1); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('blank cells are neutral: placeholder cleared under predictions does not cascade', async () => { + // Predict over placeholder text, then codex clears the placeholder on + // first echo: later cells become blank, which must NOT count as + // foreign (measured behavior; without this, fast typing over the + // placeholder drops exactly when RTT is high). + addon.predictChar('h'); + addon.predictChar('i'); + mock.setLine(0, '› h'); // 'h' echoed; placeholder gone; 'i' cell now blank + mock.setCursor(3, 0); + for (let i = 0; i < 4; i++) { + mock.fireWriteParsed(); + await flushMicrotasks(); + } + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.outstanding).toBe(1); // 'i' still pending, TTL-bounded + expect(addon.state.droppedTotal).toBe(0); + }); + + it('mismatch cascade drops the record and all later ones, earlier confirmed stay gone', async () => { + addon.predictChar('a'); + addon.predictChar('b'); + addon.predictChar('c'); + mock.setLine(0, '› aXX'); // 'a' echoed; foreign 'X' under 'b' and 'c' + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.droppedTotal).toBe(2); + expect(addon.state.outstanding).toBe(0); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('TTL expiry drops predictions and leaves no timers armed (fake timers)', () => { + addon.predictChar('a'); + addon.predictChar('b'); + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(1100); + expect(addon.state.outstanding).toBe(0); + expect(addon.state.droppedTotal).toBe(2); + expect(spansOf(mock)).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('TTL timer re-arms for remaining records after a partial confirm', async () => { + addon.predictChar('a'); // t=0, deadline ~1001 + vi.advanceTimersByTime(600); + addon.predictChar('b'); // t=600, deadline ~1601 + // Echo confirms 'a' before its TTL; 'b' remains + mock.setLine(0, '› a'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); + vi.advanceTimersByTime(450); // t=1050: a's timer fired, b (age 450) survives + expect(addon.state.outstanding).toBe(1); + expect(vi.getTimerCount()).toBe(1); // re-armed for b + vi.advanceTimersByTime(600); // t=1650: b expired + expect(addon.state.outstanding).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('cursor off anchor row within grace keeps predictions; sustained off-row drops all', async () => { + addon.predictChar('a'); + mock.setCursor(0, 5); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); // transient excursion tolerated + + vi.advanceTimersByTime(200); // > cursorGraceMs (150) + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('viewportY !== baseY clears predictions (scrolled up)', async () => { + addon.predictChar('a'); + mock.setScroll(0, 5); // user scrolled: viewport pinned above baseY + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); + // And no new predictions while scrolled + expect(addon.predictChar('b')).toBe(false); + }); + + it('maxPending: the 33rd predictChar returns false', () => { + for (let i = 0; i < 32; i++) { + expect(addon.predictChar('x')).toBe(true); + } + expect(addon.predictChar('y')).toBe(false); + expect(addon.state.outstanding).toBe(32); + }); + + it('edge margin: a prediction landing within edgeMarginCells of cols returns false', () => { + mock.setCursor(75, 0); // cols 80, margin 4: col 75 + 1 <= 76 allowed + expect(addon.predictChar('a')).toBe(true); + // Next lands at col 76: 77 > 76 suppressed + expect(addon.predictChar('b')).toBe(false); + }); + + it('predictWhen gate false suppresses painting, predictChar just returns false', () => { + addon.setPredictWhen(() => false); + expect(addon.predictChar('a')).toBe(false); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('setPredictWhen(null) removes the gate at runtime', () => { + addon.setPredictWhen(() => false); + expect(addon.predictChar('a')).toBe(false); + addon.setPredictWhen(null); + expect(addon.predictChar('a')).toBe(true); + }); + + it('multi-codepoint graphemes and control chars return false', () => { + for (const bad of ['ab', '\x1b', '\x03', '\r', '\n', '\t', '\x7f', '👨‍👩‍👧', '']) { + expect(addon.predictChar(bad)).toBe(false); + } + expect(spansOf(mock)).toHaveLength(0); + // Single astral emoji IS a single codepoint: predicted (width 2) + expect(addon.predictChar('😀')).toBe(true); + }); + + it('CJK: 2-cell span, next prediction offsets by 2, confirm reads the leading cell', async () => { + expect(addon.predictChar('你')).toBe(true); + const first = spansOf(mock)[0]; + expect(first.style.width).toBe(`${2 * 8.4}px`); + addon.predictChar('a'); + expect(spansOf(mock)[1].style.left).toBe(`${4 * 8.4}px`); // 2 + width 2 + + mock.setLine(0, '› 你'); + mock.setCursor(4, 0); // advanced past the wide char + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.outstanding).toBe(1); + }); + + it('getCell-less terminal: ASCII fallback works, wide chars suppressed', () => { + const bare = createMockTerminal({ + buffer: { lines: ['› ', ''], cursorX: 2, cursorY: 0 }, + getCellSupport: false, + }); + const a = new PredictiveEchoAddon(); + a.activate(bare.terminal as never); + expect(a.predictChar('x')).toBe(true); + expect(a.predictChar('你')).toBe(false); + a.dispose(); + bare.cleanup(); + }); + + it("'' and ' ' cell reads are equivalent for snapshot and confirm", async () => { + // Snapshot beyond the line text reads '' -> normalized ' ' + mock.setLine(0, '› '); + addon.predictChar('a'); // snapshot at col 2 is '' -> ' ' + // A repaint that writes explicit spaces must not count as foreign + mock.setLine(0, '› '); + mock.fireWriteParsed(); + await flushMicrotasks(); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(1); + expect(addon.state.droppedTotal).toBe(0); + }); + + it('predictBackspace pops newest, returns false when empty, never touches confirmed', async () => { + expect(addon.predictBackspace()).toBe(false); + addon.predictChar('a'); + addon.predictChar('b'); + expect(addon.predictBackspace()).toBe(true); + expect(addon.state.outstanding).toBe(1); + expect(spansOf(mock).map((s) => s.textContent)).toEqual(['a']); + + mock.setLine(0, '› a'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.predictBackspace()).toBe(false); // confirmed text is not popped + }); + + it('clearPredictions empties the container, resets anchor, cancels the timer', () => { + addon.predictChar('a'); + addon.predictChar('b'); + expect(vi.getTimerCount()).toBe(1); + addon.clearPredictions(); + expect(spansOf(mock)).toHaveLength(0); + expect(addon.state.outstanding).toBe(0); + expect(addon.state.anchor).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('onWriteParsed reconcile is debounced to one pass per burst', async () => { + addon.predictChar('a'); + mock.setLine(0, '› Z'); // foreign cell: each PASS increments mismatches + mock.fireWriteParsed(); + mock.fireWriteParsed(); + mock.fireWriteParsed(); + await flushMicrotasks(); + // Three synchronous fires coalesced into ONE pass: not dropped yet + expect(addon.state.outstanding).toBe(1); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.state.outstanding).toBe(0); // second pass cascades + }); + + it('onResize clears predictions (cell geometry changed)', () => { + addon.predictChar('a'); + mock.fireResize(120, 40); + expect(addon.state.outstanding).toBe(0); + expect(spansOf(mock)).toHaveLength(0); + }); + + it('works without onWriteParsed via manual reconcile()', () => { + const bare = composerMock({ emitters: false }); + const a = new PredictiveEchoAddon(); + a.activate(bare.terminal as never); + a.predictChar('h'); + bare.setLine(0, '› h'); + bare.setCursor(3, 0); + a.reconcile(); + expect(a.state.confirmedTotal).toBe(1); + expect(a.state.outstanding).toBe(0); + a.dispose(); + bare.cleanup(); + }); + + it('dispose unhooks listeners and removes the container', () => { + expect(mock.writeParsedListenerCount()).toBe(1); + expect(mock.resizeListenerCount()).toBe(1); + addon.predictChar('a'); + addon.dispose(); + expect(mock.writeParsedListenerCount()).toBe(0); + expect(mock.resizeListenerCount()).toBe(0); + const screen = mock.terminal.element.querySelector('.xterm-screen')!; + expect(screen.querySelector('[data-predictive-echo]')).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('every public method is safe before activate and after dispose', () => { + const fresh = new PredictiveEchoAddon(); + expect(fresh.predictChar('a')).toBe(false); + expect(fresh.predictBackspace()).toBe(false); + fresh.clearPredictions(); + fresh.reconcile(); + fresh.refreshFont(); + fresh.setPredictWhen(() => true); + expect(fresh.hasPredictions).toBe(false); + expect(fresh.state.outstanding).toBe(0); + + addon.dispose(); + expect(addon.predictChar('a')).toBe(false); + expect(addon.predictBackspace()).toBe(false); + addon.clearPredictions(); + addon.reconcile(); + addon.refreshFont(); + expect(addon.hasPredictions).toBe(false); + }); + + it('hostile terminal stubs never propagate exceptions', () => { + const hostile = { + element: document.createElement('div'), + cols: 80, + rows: 24, + options: {}, + buffer: { + active: { + viewportY: 0, + baseY: 0, + cursorX: 0, + cursorY: 0, + getLine: () => { + throw new Error('boom'); + }, + }, + }, + }; + const a = new PredictiveEchoAddon(); + expect(() => a.activate(hostile as never)).not.toThrow(); + expect(a.predictChar('x')).toBe(false); // getLine throws inside -> caught + expect(() => a.reconcile()).not.toThrow(); + a.dispose(); + + // Terminal with no render dimensions: addon inert, no throws + const dimless = composerMock(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (dimless.terminal as any)._core; + const b = new PredictiveEchoAddon(); + b.activate(dimless.terminal as never); + expect(b.predictChar('x')).toBe(false); + b.dispose(); + dimless.cleanup(); + }); + + it('underlinePredictions styles spans; refreshFont re-reads the rendered color', () => { + const themed = composerMock({ theme: { foreground: '#aabbcc', background: '#112233' } }); + // The recipe prefers the computed .xterm-rows color (what xterm really + // renders with); give the mock rows an explicit color like a real skin. + const rows = themed.terminal.element.querySelector('.xterm-rows') as HTMLElement; + rows.style.color = 'rgb(170, 187, 204)'; + const a = new PredictiveEchoAddon({ underlinePredictions: true }); + a.activate(themed.terminal as never); + a.predictChar('u'); + const span = themed.terminal.element.querySelector('.xterm-screen span') as HTMLSpanElement; + expect(span.style.textDecoration).toBe('underline'); + expect(span.style.color).toBe('rgb(170, 187, 204)'); + + rows.style.color = 'rgb(255, 0, 0)'; // skin change + a.refreshFont(); + a.clearPredictions(); + a.predictChar('v'); + const span2 = themed.terminal.element.querySelector('.xterm-screen span') as HTMLSpanElement; + expect(span2.style.color).toBe('rgb(255, 0, 0)'); + a.dispose(); + themed.cleanup(); + }); + + it('state getter reports outstanding/confirmedTotal/droppedTotal/anchor', async () => { + expect(addon.state).toEqual({ outstanding: 0, confirmedTotal: 0, droppedTotal: 0, anchor: null }); + addon.predictChar('a'); + addon.predictChar('b'); + expect(addon.state.outstanding).toBe(2); + expect(addon.state.anchor).toEqual({ row: 0, col: 2 }); + expect(addon.hasPredictions).toBe(true); + + mock.setLine(0, '› a'); + mock.setCursor(3, 0); + mock.fireWriteParsed(); + await flushMicrotasks(); + addon.clearPredictions(); + expect(addon.state.confirmedTotal).toBe(1); + expect(addon.state.droppedTotal).toBe(1); + expect(addon.hasPredictions).toBe(false); + }); +}); diff --git a/packages/xterm-zerolag-input/test/predictive-echo-fuzz.test.ts b/packages/xterm-zerolag-input/test/predictive-echo-fuzz.test.ts new file mode 100644 index 00000000..8c5915c3 --- /dev/null +++ b/packages/xterm-zerolag-input/test/predictive-echo-fuzz.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment jsdom + * + * Layer 3: seeded property fuzz against the REAL xterm parser. Random + * interleavings of predictions, backspaces, clears, echo writes (correct, + * partial, foreign), screen clears, scrolls and cursor jumps; invariants + * checked after EVERY op: + * 1. span count === outstanding record count, every span inside the grid + * 2. no public method throws + * 3. eventual convergence: after the run settles (TTL elapse + reconcile), + * outstanding === 0 and the span container is empty + * + * Reproduce a failure with FUZZ_SEED= FUZZ_ITERS= npx vitest run + * test/predictive-echo-fuzz.test.ts (the failing seed+iter is in the + * assertion message). + */ +import { describe, expect, it } from 'vitest'; +import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js'; +import { CELL_H, CELL_W, createReplayTerminal } from './replay-helpers.js'; + +const SEED = Number(process.env.FUZZ_SEED ?? 1337); +const TOTAL_ITERS = Number(process.env.FUZZ_ITERS ?? 500); +const BATCHES = 4; +const TTL_MS = 5; + +function mulberry32(seed: number) { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const ALPHABET = [...'abcdefghij XZ!?', '你', '好', '😀']; + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function fuzzIteration(iter: number, label: string) { + const rand = mulberry32(SEED + iter); + const rt = createReplayTerminal(60, 12); + const addon = new PredictiveEchoAddon({ ttlMs: TTL_MS }); + addon.activate(rt.hybrid); + const ctx = `${label} seed=${SEED} iter=${iter}`; + + // Park the cursor mid-screen like a composer would + await rt.write('\x1b[6;3H'); + + const ops = 4 + Math.floor(rand() * 12); + for (let i = 0; i < ops; i++) { + const r = rand(); + if (r < 0.35) { + addon.predictChar(ALPHABET[Math.floor(rand() * ALPHABET.length)]); + } else if (r < 0.43) { + addon.predictBackspace(); + } else if (r < 0.48) { + addon.clearPredictions(); + } else if (r < 0.62) { + // Correct-ish echo: write a run of random chars at the anchor and + // leave the cursor advanced (confirms whatever happens to match) + const a = addon.state.anchor; + if (a) { + const n = 1 + Math.floor(rand() * 3); + let text = ''; + for (let k = 0; k < n; k++) text += ALPHABET[Math.floor(rand() * ALPHABET.length)]; + await rt.write(`\x1b[${a.row + 1};${a.col + 1}H${text}`); + } + } else if (r < 0.72) { + // Foreign rewrite across the anchor row + await rt.write(`\x1b[6;1H${'Q'.repeat(1 + Math.floor(rand() * 20))}`); + } else if (r < 0.8) { + // Scroll: newlines at the bottom push history + await rt.write(`\x1b[12;1H${'\r\n'.repeat(1 + Math.floor(rand() * 3))}`); + } else if (r < 0.85) { + await rt.write('\x1b[2J\x1b[H'); // clear screen + home + } else if (r < 0.95) { + addon.reconcile(); + } else { + // Cursor jump + const row = 1 + Math.floor(rand() * 12); + const col = 1 + Math.floor(rand() * 60); + await rt.write(`\x1b[${row};${col}H`); + } + await Promise.resolve(); // flush the debounced reconcile microtask + + // Invariant 1: span/record parity + grid bounds, after every op + expect(rt.spanCount(), ctx).toBe(addon.state.outstanding); + for (const s of rt.spans()) { + const left = parseFloat(s.style.left); + const width = parseFloat(s.style.width); + const top = parseFloat(s.style.top); + expect(left + width, ctx).toBeLessThanOrEqual(60 * CELL_W); + expect(top, ctx).toBeLessThanOrEqual(11 * CELL_H); + expect(left, ctx).toBeGreaterThanOrEqual(0); + } + } + + // Invariant 3: eventual convergence via echo/TTL, never via dispose + if (addon.state.outstanding > 0) { + await sleep(TTL_MS + 15); + addon.reconcile(); + } + expect(addon.state.outstanding, ctx).toBe(0); + expect(rt.spanCount(), ctx).toBe(0); + + addon.dispose(); + rt.cleanup(); +} + +describe(`predictive echo fuzz (${TOTAL_ITERS} iterations, seed ${SEED})`, () => { + const perBatch = Math.ceil(TOTAL_ITERS / BATCHES); + for (let b = 0; b < BATCHES; b++) { + it(`batch ${b + 1}/${BATCHES}`, async () => { + const start = b * perBatch; + const end = Math.min(start + perBatch, TOTAL_ITERS); + for (let iter = start; iter < end; iter++) { + await fuzzIteration(iter, `batch${b + 1}`); + } + }, 60000); + } +}); diff --git a/packages/xterm-zerolag-input/test/prompt-finder.test.ts b/packages/xterm-zerolag-input/test/prompt-finder.test.ts index 8313e377..69942a31 100644 --- a/packages/xterm-zerolag-input/test/prompt-finder.test.ts +++ b/packages/xterm-zerolag-input/test/prompt-finder.test.ts @@ -4,159 +4,155 @@ import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js'; import type { XtermTerminal, PromptFinder } from '../src/types.js'; function term(lines: string[]) { - return createMockTerminal({ buffer: { lines } }); + return createMockTerminal({ buffer: { lines } }); } describe('findPrompt', () => { - describe('character strategy', () => { - it('finds $ prompt at column 0', () => { - const { terminal, cleanup } = term(['output line', '$ ls -la']); - const finder: PromptFinder = { type: 'character', char: '$' }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).toEqual({ row: 1, col: 0 }); - cleanup(); - }); - - it('finds > prompt', () => { - const { terminal, cleanup } = term(['> hello']); - const finder: PromptFinder = { type: 'character', char: '>' }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).toEqual({ row: 0, col: 0 }); - cleanup(); - }); - - it('finds prompt with prefix (user@host)', () => { - const { terminal, cleanup } = term(['user@host:~$ command']); - const finder: PromptFinder = { type: 'character', char: '$' }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).toEqual({ row: 0, col: 11 }); - cleanup(); - }); - - it('scans bottom-up and returns lowest match', () => { - const { terminal, cleanup } = term([ - '$ old prompt', - 'output', - '$ current prompt', - ]); - const finder: PromptFinder = { type: 'character', char: '$' }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).toEqual({ row: 2, col: 0 }); - cleanup(); - }); - - it('returns null when no prompt found', () => { - const { terminal, cleanup } = term(['no prompt here', 'or here']); - const finder: PromptFinder = { type: 'character', char: '$' }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).toBeNull(); - cleanup(); - }); - - it('finds Unicode prompt character', () => { - const { terminal, cleanup } = term(['\u276f hello']); - const finder: PromptFinder = { type: 'character', char: '\u276f' }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).toEqual({ row: 0, col: 0 }); - cleanup(); - }); + describe('character strategy', () => { + it('finds $ prompt at column 0', () => { + const { terminal, cleanup } = term(['output line', '$ ls -la']); + const finder: PromptFinder = { type: 'character', char: '$' }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).toEqual({ row: 1, col: 0 }); + cleanup(); }); - describe('regex strategy', () => { - it('finds regex prompt', () => { - const { terminal, cleanup } = term(['user@host:~/dir$ ls']); - const finder: PromptFinder = { type: 'regex', pattern: /\$/ }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).not.toBeNull(); - expect(pos!.col).toBe(15); - cleanup(); - }); - - it('matches complex PS1 patterns', () => { - const { terminal, cleanup } = term(['(venv) user % cmd']); - const finder: PromptFinder = { type: 'regex', pattern: /%/ }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).not.toBeNull(); - expect(pos!.col).toBe(12); - cleanup(); - }); - - it('returns null on no match', () => { - const { terminal, cleanup } = term(['just output']); - const finder: PromptFinder = { type: 'regex', pattern: /\$\s*$/ }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).toBeNull(); - cleanup(); - }); - - it('handles global flag safely (strips g to avoid lastIndex)', () => { - const { terminal, cleanup } = term(['user@host:~$ cmd']); - const finder: PromptFinder = { type: 'regex', pattern: /\$/g }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).not.toBeNull(); - expect(pos!.col).toBe(11); - // Call again — should return same result (no lastIndex drift) - const pos2 = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos2).toEqual(pos); - cleanup(); - }); + it('finds > prompt', () => { + const { terminal, cleanup } = term(['> hello']); + const finder: PromptFinder = { type: 'character', char: '>' }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).toEqual({ row: 0, col: 0 }); + cleanup(); }); - describe('custom strategy', () => { - it('uses custom finder function', () => { - const { terminal, cleanup } = term(['anything']); - const finder: PromptFinder = { - type: 'custom', - find: () => ({ row: 5, col: 10 }), - }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).toEqual({ row: 5, col: 10 }); - cleanup(); - }); - - it('handles null from custom finder', () => { - const { terminal, cleanup } = term(['anything']); - const finder: PromptFinder = { - type: 'custom', - find: () => null, - }; - const pos = findPrompt(terminal as unknown as XtermTerminal, finder); - expect(pos).toBeNull(); - cleanup(); - }); + it('finds prompt with prefix (user@host)', () => { + const { terminal, cleanup } = term(['user@host:~$ command']); + const finder: PromptFinder = { type: 'character', char: '$' }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).toEqual({ row: 0, col: 11 }); + cleanup(); }); + + it('scans bottom-up and returns lowest match', () => { + const { terminal, cleanup } = term(['$ old prompt', 'output', '$ current prompt']); + const finder: PromptFinder = { type: 'character', char: '$' }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).toEqual({ row: 2, col: 0 }); + cleanup(); + }); + + it('returns null when no prompt found', () => { + const { terminal, cleanup } = term(['no prompt here', 'or here']); + const finder: PromptFinder = { type: 'character', char: '$' }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).toBeNull(); + cleanup(); + }); + + it('finds Unicode prompt character', () => { + const { terminal, cleanup } = term(['\u276f hello']); + const finder: PromptFinder = { type: 'character', char: '\u276f' }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).toEqual({ row: 0, col: 0 }); + cleanup(); + }); + }); + + describe('regex strategy', () => { + it('finds regex prompt', () => { + const { terminal, cleanup } = term(['user@host:~/dir$ ls']); + const finder: PromptFinder = { type: 'regex', pattern: /\$/ }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).not.toBeNull(); + expect(pos!.col).toBe(15); + cleanup(); + }); + + it('matches complex PS1 patterns', () => { + const { terminal, cleanup } = term(['(venv) user % cmd']); + const finder: PromptFinder = { type: 'regex', pattern: /%/ }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).not.toBeNull(); + expect(pos!.col).toBe(12); + cleanup(); + }); + + it('returns null on no match', () => { + const { terminal, cleanup } = term(['just output']); + const finder: PromptFinder = { type: 'regex', pattern: /\$\s*$/ }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).toBeNull(); + cleanup(); + }); + + it('handles global flag safely (strips g to avoid lastIndex)', () => { + const { terminal, cleanup } = term(['user@host:~$ cmd']); + const finder: PromptFinder = { type: 'regex', pattern: /\$/g }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).not.toBeNull(); + expect(pos!.col).toBe(11); + // Call again — should return same result (no lastIndex drift) + const pos2 = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos2).toEqual(pos); + cleanup(); + }); + }); + + describe('custom strategy', () => { + it('uses custom finder function', () => { + const { terminal, cleanup } = term(['anything']); + const finder: PromptFinder = { + type: 'custom', + find: () => ({ row: 5, col: 10 }), + }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).toEqual({ row: 5, col: 10 }); + cleanup(); + }); + + it('handles null from custom finder', () => { + const { terminal, cleanup } = term(['anything']); + const finder: PromptFinder = { + type: 'custom', + find: () => null, + }; + const pos = findPrompt(terminal as unknown as XtermTerminal, finder); + expect(pos).toBeNull(); + cleanup(); + }); + }); }); describe('readTextAfterPrompt', () => { - it('reads text after prompt with offset', () => { - const { terminal, cleanup } = term(['$ hello world']); - const prompt = { row: 0, col: 0 }; - const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2); - expect(text).toBe('hello world'); - cleanup(); - }); + it('reads text after prompt with offset', () => { + const { terminal, cleanup } = term(['$ hello world']); + const prompt = { row: 0, col: 0 }; + const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2); + expect(text).toBe('hello world'); + cleanup(); + }); - it('returns empty string for empty prompt line', () => { - const { terminal, cleanup } = term(['$ ']); - const prompt = { row: 0, col: 0 }; - const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2); - expect(text).toBe(''); - cleanup(); - }); + it('returns empty string for empty prompt line', () => { + const { terminal, cleanup } = term(['$ ']); + const prompt = { row: 0, col: 0 }; + const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2); + expect(text).toBe(''); + cleanup(); + }); - it('trims trailing whitespace', () => { - const { terminal, cleanup } = term(['$ hello ']); - const prompt = { row: 0, col: 0 }; - const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2); - expect(text).toBe('hello'); - cleanup(); - }); + it('trims trailing whitespace', () => { + const { terminal, cleanup } = term(['$ hello ']); + const prompt = { row: 0, col: 0 }; + const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2); + expect(text).toBe('hello'); + cleanup(); + }); - it('handles offset for complex prompts', () => { - const { terminal, cleanup } = term(['user@host:~$ ls -la']); - const prompt = { row: 0, col: 11 }; - const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2); - expect(text).toBe('ls -la'); - cleanup(); - }); + it('handles offset for complex prompts', () => { + const { terminal, cleanup } = term(['user@host:~$ ls -la']); + const prompt = { row: 0, col: 11 }; + const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2); + expect(text).toBe('ls -la'); + cleanup(); + }); }); diff --git a/packages/xterm-zerolag-input/test/raw-imports.d.ts b/packages/xterm-zerolag-input/test/raw-imports.d.ts new file mode 100644 index 00000000..7da7f31f --- /dev/null +++ b/packages/xterm-zerolag-input/test/raw-imports.d.ts @@ -0,0 +1,5 @@ +/** Vite `?raw` imports used by replay-helpers.ts (fixture JSONL as strings). */ +declare module '*.jsonl?raw' { + const content: string; + export default content; +} diff --git a/packages/xterm-zerolag-input/test/replay-helpers.ts b/packages/xterm-zerolag-input/test/replay-helpers.ts new file mode 100644 index 00000000..542f3788 --- /dev/null +++ b/packages/xterm-zerolag-input/test/replay-helpers.ts @@ -0,0 +1,171 @@ +/** + * Replay-test helpers: a structural hybrid terminal whose buffer, cursor and + * onWriteParsed delegate to a REAL @xterm/headless Terminal (so fixtures run + * through the real parser), while `element` is a jsdom div the addon can + * paint spans into. Works because XtermTerminal is structurally typed. + * + * Also carries the test-side mirror of Codeman's classifyPredictInput() and + * codex composer gate (the real ones live in terminal-ui.js and are pinned by + * the repo's Layer 4 vm tests; keep the two in sync). + */ +import { Terminal } from '@xterm/headless'; +import type { XtermTerminal } from '../src/types.js'; +// ?raw imports keep the jsdom environment free of node: builtins +import pasteBracketed from './fixtures/codex/paste-bracketed.jsonl?raw'; +import slashPicker from './fixtures/codex/slash-picker.jsonl?raw'; +import streamingBurst from './fixtures/codex/streaming-burst.jsonl?raw'; +import trustModal from './fixtures/codex/trust-modal.jsonl?raw'; +import typeHello from './fixtures/codex/type-hello.jsonl?raw'; +import wrap from './fixtures/codex/wrap.jsonl?raw'; + +const FIXTURES: Record = { + 'paste-bracketed': pasteBracketed, + 'slash-picker': slashPicker, + 'streaming-burst': streamingBurst, + 'trust-modal': trustModal, + 'type-hello': typeHello, + wrap, +}; + +export const CELL_W = 9; +export const CELL_H = 18; + +export interface FixtureLine { + delayMs?: number; + keyAt?: boolean; + data: string; +} + +export interface FixtureMeta { + scenario: string; + cols: number; + rows: number; + codexVersion: string; + recordedAt: string; +} + +export function loadFixture(name: string): { meta: FixtureMeta; lines: FixtureLine[] } { + const content = FIXTURES[name]; + if (!content) throw new Error(`unknown fixture ${name}`); + const raw = content + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + return { meta: raw[0] as FixtureMeta, lines: raw.slice(1) as FixtureLine[] }; +} + +export interface ReplayTerminal { + hybrid: XtermTerminal; + term: Terminal; + write(data: string): Promise; + cursorRowText(): string; + rowText(viewportRow: number): string; + spanCount(): number; + spans(): HTMLSpanElement[]; + cleanup(): void; +} + +export function createReplayTerminal(cols: number, rows: number): ReplayTerminal { + const term = new Terminal({ cols, rows, scrollback: 2000, allowProposedApi: true }); + + const element = document.createElement('div'); + element.className = 'terminal xterm'; + const screen = document.createElement('div'); + screen.className = 'xterm-screen'; + const rowsEl = document.createElement('div'); + rowsEl.className = 'xterm-rows'; + element.appendChild(screen); + screen.appendChild(rowsEl); + document.body.appendChild(element); + + const hybrid = { + element, + get cols() { + return term.cols; + }, + get rows() { + return term.rows; + }, + options: { fontFamily: 'monospace', fontSize: 14, fontWeight: 'normal', theme: {} }, + buffer: { + active: { + get viewportY() { + return term.buffer.active.viewportY; + }, + get baseY() { + return term.buffer.active.baseY; + }, + get cursorX() { + return term.buffer.active.cursorX; + }, + get cursorY() { + return term.buffer.active.cursorY; + }, + getLine: (y: number) => term.buffer.active.getLine(y), + }, + }, + onWriteParsed: (cb: () => void) => term.onWriteParsed(cb), + onResize: (cb: (s: { cols: number; rows: number }) => void) => term.onResize(cb), + _core: { + _renderService: { + dimensions: { + css: { cell: { width: CELL_W, height: CELL_H } }, + device: { char: { top: 0, height: CELL_H } }, + }, + }, + }, + }; + + return { + hybrid: hybrid as unknown as XtermTerminal, + term, + write: (data: string) => new Promise((resolve) => term.write(data, () => resolve())), + cursorRowText() { + const b = term.buffer.active; + return b.getLine(b.baseY + b.cursorY)?.translateToString(true) ?? ''; + }, + rowText(viewportRow: number) { + const b = term.buffer.active; + return b.getLine(b.baseY + viewportRow)?.translateToString(true) ?? ''; + }, + spanCount() { + return element.querySelectorAll('[data-predictive-echo] span').length; + }, + spans() { + return Array.from(element.querySelectorAll('[data-predictive-echo] span')) as HTMLSpanElement[]; + }, + cleanup() { + term.dispose(); + element.remove(); + }, + }; +} + +// ─── Codeman-side mirrors (keep in sync with terminal-ui.js) ──────────── + +/** Mirror of window.CodemanTerminalInput.classifyPredictInput. */ +export function classifyPredictInput(data: string): 'char' | 'backspace' | 'clear' | 'text' { + const cps = Array.from(data); + if (cps.length === 1) { + const cp = cps[0].codePointAt(0)!; + if (cp === 0x7f) return 'backspace'; + if (cp >= 0x20) return 'char'; + return 'clear'; + } + if (data.charCodeAt(0) === 0x1b) return 'clear'; + if (data.charCodeAt(0) >= 0x20) return 'text'; + return 'clear'; +} + +/** Mirror of the codex composer-row gate (CODEX_COMPOSER_ROW_RE). */ +export const CODEX_COMPOSER_ROW_RE = /^› /; + +export function codexComposerGate(terminal: XtermTerminal): boolean { + try { + const buf = terminal.buffer.active; + const line = buf.getLine(buf.baseY + (buf.cursorY ?? 0)); + return !!line && CODEX_COMPOSER_ROW_RE.test(line.translateToString(true)); + } catch { + return false; + } +} diff --git a/packages/xterm-zerolag-input/tsup.config.ts b/packages/xterm-zerolag-input/tsup.config.ts index f3e3678e..72c92fd4 100644 --- a/packages/xterm-zerolag-input/tsup.config.ts +++ b/packages/xterm-zerolag-input/tsup.config.ts @@ -26,6 +26,13 @@ export default defineConfig([ ' this.activate(terminal);', ' }', ' };', + ' window.PredictiveEchoAddon=XtermZerolagInput.PredictiveEchoAddon;', + ' window.PredictiveEchoOverlay=class extends XtermZerolagInput.PredictiveEchoAddon{', + ' constructor(terminal){', + ' super({});', + ' this.activate(terminal);', + ' }', + ' };', '}', ].join('\n'), }, From 6c55ce3f8dd2a0b3a8a487dbfff1cd167af31816 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 04:26:39 +0200 Subject: [PATCH 3/9] feat(predictive-echo): second vendor bundle wiring postinstall + build.mjs build vendor/xterm-predictive-echo.js as a SEPARATE IIFE (window.PredictiveEchoAddon + self-activating PredictiveEchoOverlay); the zerolag bundle command is untouched and its output verified sha256-identical. index.html loads it after the zerolag tag (cacheBustAssets covers it), sw.js precaches it, build.mjs HASHABLE content-hashes it. A missing or broken bundle degrades codex to plain PTY echo. Co-Authored-By: Claude Fable 5 --- scripts/build.mjs | 17 +++++++++++++++++ scripts/postinstall.js | 29 +++++++++++++++++++++++++++++ src/web/public/index.html | 1 + src/web/public/sw.js | 1 + 4 files changed, 48 insertions(+) diff --git a/scripts/build.mjs b/scripts/build.mjs index 830d5ee1..eac90a18 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -65,6 +65,22 @@ appendFileSync( '}\n' ); +// Predictive echo (codex): separate bundle so the zerolag bundle stays byte-identical +run('xterm-predictive-echo', 'npx esbuild packages/xterm-zerolag-input/src/predictive-echo-addon.ts --bundle --minify --format=iife --global-name=XtermPredictiveEcho --outfile=dist/web/public/vendor/xterm-predictive-echo.js'); +appendFileSync( + join(ROOT, 'dist/web/public/vendor/xterm-predictive-echo.js'), + '\n// Global aliases for browser usage\n' + + 'if(typeof window!=="undefined"){' + + 'window.PredictiveEchoAddon=XtermPredictiveEcho.PredictiveEchoAddon;' + + 'window.PredictiveEchoOverlay=class extends XtermPredictiveEcho.PredictiveEchoAddon{' + + 'constructor(terminal){' + + 'super({});' + + 'this.activate(terminal);' + + '}' + + '};' + + '}\n' +); + // 4. Minify frontend assets run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite'); run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite'); @@ -106,6 +122,7 @@ console.log('\n[build] content-hash cache busting'); 'subagent-windows.js', 'image-input.js', 'vendor/xterm-zerolag-input.js', + 'vendor/xterm-predictive-echo.js', ]; const manifest = {}; for (const file of HASHABLE) { diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 7654705f..02d23f59 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -304,6 +304,35 @@ if (isGlobalInstall) { } catch { console.log(colors.yellow('⚠ Failed to bundle xterm-zerolag-input — overlay may not work in dev mode')); } + + // Predictive echo (codex): SEPARATE bundle so the zerolag bundle above stays + // byte-identical. If this file is missing or broken, codex simply falls back + // to plain PTY echo (pre-predictive behavior); nothing else is affected. + try { + const predSrc = join(import.meta.dirname, '..', 'packages', 'xterm-zerolag-input', 'src', 'predictive-echo-addon.ts'); + const predOut = join(vendorDir, 'xterm-predictive-echo.js'); + execSync( + `npx esbuild "${predSrc}" --bundle --format=iife --global-name=XtermPredictiveEcho --outfile="${predOut}"`, + { stdio: 'pipe' } + ); + const { appendFileSync } = await import('fs'); + appendFileSync( + predOut, + '\n// Global aliases for browser usage\n' + + 'if(typeof window!=="undefined"){' + + 'window.PredictiveEchoAddon=XtermPredictiveEcho.PredictiveEchoAddon;' + + 'window.PredictiveEchoOverlay=class extends XtermPredictiveEcho.PredictiveEchoAddon{' + + 'constructor(terminal){' + + 'super({});' + + 'this.activate(terminal);' + + '}' + + '};' + + '}\n' + ); + console.log(colors.green('✓ xterm-predictive-echo bundled to vendor/')); + } catch (e) { + console.log(colors.yellow('⚠ predictive-echo bundle failed (codex uses plain echo): ' + e.message)); + } } catch (err) { hasWarnings = true; console.log(colors.yellow('⚠ Failed to copy xterm vendor files')); diff --git a/src/web/public/index.html b/src/web/public/index.html index 48cf978f..adebd440 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -38,6 +38,7 @@ + diff --git a/src/web/public/sw.js b/src/web/public/sw.js index ca1b2f12..454d96b1 100644 --- a/src/web/public/sw.js +++ b/src/web/public/sw.js @@ -40,6 +40,7 @@ const APP_SHELL = [ '/vendor/xterm-addon-fit.min.js', '/vendor/xterm-addon-unicode11.min.js', '/vendor/xterm-zerolag-input.js', + '/vendor/xterm-predictive-echo.js', '/vendor/xterm.css', '/icon-192.png', '/icon-512.png', From 5bde8977527b77e96676ea8db8c90c0762bbace1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 04:33:09 +0200 Subject: [PATCH 4/9] feat(predictive-echo): Codeman integration + Layer 4 vm tests terminal-ui.js: _localEchoPolicy ('buffer'|'predict'|'off') computed at the end of _updateLocalEchoState with _localEchoEnabled keeping its exact 1.12.2 values; _predictHookOnData called as a plain statement between the buffer block and Normal Mode (visual-only, try/catch, never returns, never touches _pendingInput); classifyPredictInput + isCodexComposerRow (baseY-based, measured /^> /-signature gate) on CodemanTerminalInput; construction beside the LocalEchoOverlay from the separate bundle with graceful absence; insertTerminalText/clearTerminalInput/setFontSize/applyTerminalSkin clear or refresh predictions. app.js: fields + tab-switch and SSE-reconnect clears. voice-input '\r' branch and keyboard-accessory sendKey clear predictions (both bypass onData). sendEnterKey needs no change: codex falls through to the immediate-flush branch. Layer 4 vm tests: classify truth table (20 cases), composer-row gate incl. the baseY pin, policy matrix with the 1.12.2 invariants untouched, wire neutrality + throwing-predictor pins. Stale mobile keyboard codex-buffering tests repointed at claude; new codex twin asserts write-through streaming, prediction spans and TTL self-heal. Co-Authored-By: Claude Fable 5 --- src/web/public/app.js | 10 ++ src/web/public/keyboard-accessory.js | 3 + src/web/public/terminal-ui.js | 95 ++++++++++- src/web/public/voice-input.js | 3 + test/local-echo-codex-gating.test.ts | 227 +++++++++++++++++++++++++++ test/mobile/keyboard.test.ts | 56 ++++++- 6 files changed, 390 insertions(+), 4 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 157fc392..d3d6d34f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -699,6 +699,10 @@ class CodemanApp { // (not at buffer.cursorY, which reflects Ink's internal cursor position) this._localEchoOverlay = null; // created after terminal.open() this._localEchoEnabled = false; // true when setting on + session active + // Predictive write-through echo (codex) — created after terminal.open() + // from the separate vendor/xterm-predictive-echo.js bundle (may stay null) + this._predictiveEcho = null; + this._localEchoPolicy = 'off'; // 'buffer' | 'predict' | 'off' (per active session) this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps // Accessibility: Focus trap for modals @@ -3021,6 +3025,9 @@ class CodemanApp { // terminal buffer reloads and prompt is visible again. _render() re-scans // for the ❯ prompt on every call, so rerender() after buffer load repositions it. this._localEchoOverlay?.rerender(); + // Deliberate asymmetry: buffer-mode pending text SURVIVES reconnect (not + // yet sent); predictions do not (their keystrokes were already delivered). + this._predictiveEcho?.clearPredictions(); // Clear pending hooks this.pendingHooks.clear(); // Clear parent name cache (prevents stale session name entries accumulating) @@ -4127,6 +4134,9 @@ class CodemanApp { } } this._localEchoOverlay?.clear(); + // Predictions are ephemeral + already sent: nothing to save/restore + // across a tab switch (unlike the buffer overlay's setFlushed machinery) + this._predictiveEcho?.clearPredictions(); // Prevent _detectBufferText() from picking up Claude's Ink UI text // (status bar, model info, etc.) as "user input" on fresh sessions. // Only sessions with prior flushed text (from tab-switch-away) need detection. diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 68e08fd2..5fc3bb3c 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -601,6 +601,9 @@ const KeyboardAccessoryBar = { * must be written raw to be interpreted as key presses by Ink. */ sendKey(escapeSequence) { if (!app.activeSessionId) return; + // Arrows/Esc move the server-side cursor and bypass onData: clear + // predictions now instead of waiting out the ~150ms off-row grace. + app._predictiveEcho?.clearPredictions(); fetch(`/api/sessions/${app.activeSessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 1cd5ed49..0315d53a 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -62,6 +62,42 @@ return COMPOSER_NAV_KEY_PATTERN.test(data); } + // Codex composer-row signature, measured against codex-cli 0.147.0 + // (docs/predictive-echo-plan.md): the composer's cursor row starts with + // "› " (U+203A + space) when empty (placeholder text), while typing, and + // while the slash picker filters. Modal rows ("Press enter to continue") + // and wrapped continuation rows (2-space indent) do NOT match — that is + // the ghost eliminator: no prediction is ever painted there. + const CODEX_COMPOSER_ROW_RE = /^› /; + + // Classify onData for the predictive echo hook. Terminal query responses + // never reach this (suppressed earlier in onData); bracketed pastes, nav + // keys and mouse reports all start with ESC => 'clear'. + function classifyPredictInput(data) { + const cps = Array.from(data); // astral-safe + if (cps.length === 1) { + const cp = cps[0].codePointAt(0); + if (cp === 0x7f) return 'backspace'; + if (cp >= 0x20) return 'char'; // incl. a single astral emoji + return 'clear'; // \r \n \t \x03, bare ESC, ... + } + if (data.charCodeAt(0) === 0x1b) return 'clear'; // ESC seq: nav, paste, mouse SGR + if (data.charCodeAt(0) >= 0x20) return 'text'; // multi-char printable (plain paste, + return 'clear'; // ZWJ emoji cluster): wire only, no visual + } + + // Predictive-echo gate: predict only while the cursor sits on the codex + // composer row. cursorY is baseY-relative (xterm API), hence baseY + cursorY. + function isCodexComposerRow(terminal) { + try { + const buf = terminal.buffer.active; + const line = buf.getLine(buf.baseY + buf.cursorY); + return !!line && CODEX_COMPOSER_ROW_RE.test(line.translateToString(true)); + } catch { + return false; + } + } + function isTerminalQueryResponse(data) { return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data); } @@ -99,6 +135,9 @@ isTerminalQueryResponse, shouldSuppressTerminalQueryResponse, isComposerNavKey, + classifyPredictInput, + isCodexComposerRow, + CODEX_COMPOSER_ROW_RE, BRACKETED_PASTE_START, USER_SCROLL_STICKY_SUPPRESS_MS, TOUCH_COMPAT_MOUSE_SUPPRESS_MS, @@ -399,6 +438,12 @@ Object.assign(CodemanApp.prototype, { } this._localEchoOverlay = new LocalEchoOverlay(this.terminal); + // Predictive write-through echo (codex): separate opt-in bundle + // (vendor/xterm-predictive-echo.js); when it is missing or failed to + // load, codex falls back to plain PTY echo exactly like 1.12.2. + this._predictiveEcho = + typeof PredictiveEchoOverlay !== 'undefined' ? new PredictiveEchoOverlay(this.terminal) : null; + this._predictiveEcho?.setPredictWhen((terminal) => window.CodemanTerminalInput.isCodexComposerRow(terminal)); if (MobileDetection.isTouchDevice()) { this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor()); this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor()); @@ -1108,6 +1153,13 @@ Object.assign(CodemanApp.prototype, { } } + // ── Predictive Echo (codex): visual only. A plain statement, never a + // `return`: control ALWAYS falls through into the send path below, + // which is the byte-identity guarantee for #218/#219/#220/#222 — + // with the predictor active, absent or throwing, the wire sees the + // same bytes. Body in _predictHookOnData (vm-testable). + this._predictHookOnData(data); + // ── Normal Mode (echo disabled) ── this._pendingInput += data; @@ -2450,8 +2502,9 @@ Object.assign(CodemanApp.prototype, { // grows and rewraps as it fills (#220), pastes are bracketed (#219) // and arrows/history edit server-side state (#218). Buffering // keystrokes until Enter starves all of that, so codex sessions use - // plain PTY echo like shell. - // Disable it by clearing any pending text. + // plain PTY echo like shell — visually augmented by the predictive + // write-through echo (see _localEchoPolicy below and the onData hook). + // Disable the buffer overlay by clearing any pending text. this._localEchoOverlay.clear(); this._localEchoEnabled = false; } else { @@ -2484,6 +2537,37 @@ Object.assign(CodemanApp.prototype, { }); } } + + // Per-session echo policy: 'buffer' (overlay), 'predict' (codex + // write-through, see the onData predict hook), 'off'. _localEchoEnabled + // keeps its exact historical values above (false for codex/shell), so + // every existing consumer is unchanged; this field is purely additive. + let policy = 'off'; + if (session && echoEnabled) { + if (session.mode === 'codex') policy = 'predict'; + else if (session.mode !== 'shell') policy = 'buffer'; + } + this._localEchoPolicy = policy; + if (policy !== 'predict') this._predictiveEcho?.clearPredictions(); + }, + + /** + * Predictive-echo onData hook (codex write-through). VISUAL ONLY: paints, + * pops or clears prediction spans and never touches _pendingInput, never + * sends, never throws into the caller. The onData wire path behaves + * byte-identically with this active, absent or broken. + */ + _predictHookOnData(data) { + if (this._localEchoPolicy !== 'predict' || !this._predictiveEcho) return; + try { + const kind = window.CodemanTerminalInput.classifyPredictInput(data); + if (kind === 'char') this._predictiveEcho.predictChar(data); + else if (kind === 'backspace') this._predictiveEcho.predictBackspace(); + else if (kind === 'clear') this._predictiveEcho.clearPredictions(); + // kind === 'text' (plain multi-char paste): wire only, no visual + } catch { + /* predictions must never block the wire */ + } }, // CJK textarea already provides visual feedback — bypass local echo @@ -2848,6 +2932,9 @@ Object.assign(CodemanApp.prototype, { /** Insert editable text at the active prompt without pressing Enter. */ insertTerminalText(text) { if (!this.activeSessionId || !text) return; + // Under predict the text goes out via sendInput (bypasses onData), so the + // hook never sees it: clear outstanding predictions here instead. + if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions(); if ( this._localEchoEnabled && this._localEchoOverlay && @@ -2873,6 +2960,8 @@ Object.assign(CodemanApp.prototype, { this._inputFlushTimeout = null; } this._pendingInput = ''; + // Composer content is about to change out from under any predictions + if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions(); if (this._localEchoEnabled && this._localEchoOverlay) { const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' }; @@ -3379,6 +3468,7 @@ Object.assign(CodemanApp.prototype, { localStorage.setItem('codeman-font-size', size); // Update overlay font cache and re-render at new cell dimensions this._localEchoOverlay?.refreshFont(); + this._predictiveEcho?.refreshFont(); }, loadFontSize() { @@ -3513,6 +3603,7 @@ Object.assign(CodemanApp.prototype, { // Refresh it on live skin changes so typed text never keeps the prior // theme's dark backing surface or foreground color. this._localEchoOverlay?.refreshFont(); + this._predictiveEcho?.refreshFont(); try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {} diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js index 4f79d038..1bcfaec2 100644 --- a/src/web/public/voice-input.js +++ b/src/web/public/voice-input.js @@ -612,6 +612,9 @@ const VoiceInput = { if (text) app.sendInput(text).catch(() => {}); setTimeout(() => app.sendInput('\r').catch(() => {}), 80); } else { + // Predict-mode sessions (codex) take this branch: the send bypasses + // onData, so clear outstanding predictions here (composer will reset) + app._predictiveEcho?.clearPredictions(); app.sendInput('\r').catch(() => {}); } // Blink then restore diff --git a/test/local-echo-codex-gating.test.ts b/test/local-echo-codex-gating.test.ts index 3bbf66c9..9817912e 100644 --- a/test/local-echo-codex-gating.test.ts +++ b/test/local-echo-codex-gating.test.ts @@ -238,3 +238,230 @@ describe('insertTerminalText pass-through routing', () => { expect(overlay.appendText).not.toHaveBeenCalled(); }); }); + +// ─── Predictive write-through echo (codex) ────────────────────────────────── + +type PredictorStub = { + predictChar: ReturnType; + predictBackspace: ReturnType; + clearPredictions: ReturnType; +}; + +type PredictiveApp = AppInstance & { + _localEchoPolicy?: string; + _predictiveEcho?: PredictorStub | null; + _predictHookOnData(data: string): void; +}; + +function makePredictor(): PredictorStub { + return { + predictChar: vi.fn().mockReturnValue(true), + predictBackspace: vi.fn().mockReturnValue(true), + clearPredictions: vi.fn(), + }; +} + +const classifyPredictInput = terminalInput.classifyPredictInput as (data: string) => string; +const isCodexComposerRow = terminalInput.isCodexComposerRow as (t: unknown) => boolean; + +describe('CodemanTerminalInput.classifyPredictInput', () => { + it.each([ + ['a', 'char'], + [' ', 'char'], + ['€', 'char'], + ['你', 'char'], + ['😀', 'char'], // single astral codepoint + ['\x7f', 'backspace'], + ['\r', 'clear'], + ['\n', 'clear'], + ['\t', 'clear'], + ['\x03', 'clear'], // Ctrl+C + ['\x15', 'clear'], // Ctrl+U + ['\x1b', 'clear'], // bare ESC + ['\x1b[A', 'clear'], // arrow + ['\x1bOA', 'clear'], // SS3 arrow + ['\x1b[3~', 'clear'], // Delete + ['\x1b[200~hi\x1b[201~', 'clear'], // bracketed paste + ['\x1b[<0;10;5M', 'clear'], // mouse SGR report + ['abc', 'text'], // plain multi-char paste + ['👨‍👩‍👧', 'text'], // ZWJ emoji cluster + ['\r\n', 'clear'], + ])('classifies %j as %s', (data, expected) => { + expect(classifyPredictInput(data)).toBe(expected); + }); +}); + +describe('CodemanTerminalInput.isCodexComposerRow', () => { + function terminalWithCursorRow(text: string | null) { + return { + buffer: { + active: { + baseY: 4, + cursorY: 2, + getLine: (y: number) => (y === 6 && text !== null ? { translateToString: () => text } : undefined), + }, + }, + }; + } + + it.each([ + '› ', // empty composer + '› Use /skills to list available skills', // placeholder + '› hello', // typed text + '› /mo', // slash picker filtering + ])('matches the composer row %j', (row) => { + expect(isCodexComposerRow(terminalWithCursorRow(row))).toBe(true); + }); + + it.each([ + ' Press enter to continue', // trust/approval modal + ' this line twice over', // wrapped continuation row (2-space indent) + '›no-space', + '1. Yes, continue', + '', + ])('rejects the non-composer row %j', (row) => { + expect(isCodexComposerRow(terminalWithCursorRow(row))).toBe(false); + }); + + it('reads the cursor row baseY-relative (baseY + cursorY)', () => { + // terminalWithCursorRow only answers getLine(6) = baseY 4 + cursorY 2; + // a viewportY-based read would ask for a different line and get undefined + expect(isCodexComposerRow(terminalWithCursorRow('› x'))).toBe(true); + }); + + it('returns false when the row is missing or getLine throws', () => { + expect(isCodexComposerRow(terminalWithCursorRow(null))).toBe(false); + const hostile = { + buffer: { + active: { + baseY: 0, + cursorY: 0, + getLine: () => { + throw new Error('boom'); + }, + }, + }, + }; + expect(isCodexComposerRow(hostile)).toBe(false); + }); +}); + +describe('_updateLocalEchoState echo policy', () => { + it("codex + setting ON -> policy 'predict' while _localEchoEnabled stays false", () => { + const app = makeApp('codex') as PredictiveApp; + app._predictiveEcho = makePredictor(); + app._updateLocalEchoState(); + expect(app._localEchoPolicy).toBe('predict'); + expect(app._localEchoEnabled).toBe(false); // 1.12.2 invariant untouched + expect(app._predictiveEcho.clearPredictions).not.toHaveBeenCalled(); + }); + + it("codex + setting OFF -> policy 'off' and predictions cleared (kill switch)", () => { + const app = makeApp('codex') as PredictiveApp; + app._predictiveEcho = makePredictor(); + app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false }); + app._updateLocalEchoState(); + expect(app._localEchoPolicy).toBe('off'); + expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); + }); + + it("shell -> policy 'off'", () => { + const app = makeApp('shell') as PredictiveApp; + app._predictiveEcho = makePredictor(); + app._updateLocalEchoState(); + expect(app._localEchoPolicy).toBe('off'); + expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); + }); + + it.each(['claude', 'gemini', 'opencode'])("%s -> policy 'buffer' + overlay enabled (existing behavior)", (mode) => { + const overlay = makeOverlay(); + const app = makeApp(mode, overlay) as PredictiveApp; + app._predictiveEcho = makePredictor(); + app._updateLocalEchoState(); + expect(app._localEchoPolicy).toBe('buffer'); + expect(app._localEchoEnabled).toBe(true); + expect(overlay.prompts.length).toBeGreaterThan(0); // setPrompt still called + expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); // not predict -> stray spans cleared + }); + + it('no active session -> policy off, no crash without a predictor instance', () => { + const app = makeApp('codex') as PredictiveApp; + app._predictiveEcho = null; + app.activeSessionId = null; + expect(() => app._updateLocalEchoState()).not.toThrow(); + expect(app._localEchoPolicy).toBe('off'); + }); +}); + +describe('_predictHookOnData (wire neutrality)', () => { + function makePredictApp(): PredictiveApp { + const app = makeApp('codex') as PredictiveApp; + app._predictiveEcho = makePredictor(); + app._updateLocalEchoState(); // -> 'predict' + return app; + } + + it('routes char/backspace/clear kinds to the predictor', () => { + const app = makePredictApp(); + app._predictHookOnData('h'); + expect(app._predictiveEcho!.predictChar).toHaveBeenCalledWith('h'); + app._predictHookOnData('\x7f'); + expect(app._predictiveEcho!.predictBackspace).toHaveBeenCalled(); + app._predictHookOnData('\r'); + expect(app._predictiveEcho!.clearPredictions).toHaveBeenCalled(); + }); + + it("kind 'text' (plain paste) takes no visual action", () => { + const app = makePredictApp(); + app._predictHookOnData('pasted text'); + expect(app._predictiveEcho!.predictChar).not.toHaveBeenCalled(); + expect(app._predictiveEcho!.clearPredictions).not.toHaveBeenCalled(); + }); + + it('never touches _pendingInput and never sends (visual-only pin)', () => { + const app = makePredictApp(); + app._pendingInput = 'queued'; + for (const data of ['h', 'i', '\x7f', '\r', '\x1b[A', 'multi char', '\x1b[200~x\x1b[201~']) { + app._predictHookOnData(data); + } + expect(app._pendingInput).toBe('queued'); + expect(app.sendInput).not.toHaveBeenCalled(); + }); + + it('is inert under buffer/off policies and without a predictor', () => { + const buffered = makeApp('claude') as PredictiveApp; + buffered._predictiveEcho = makePredictor(); + buffered._updateLocalEchoState(); // 'buffer' + buffered._predictHookOnData('h'); + expect(buffered._predictiveEcho.predictChar).not.toHaveBeenCalled(); + + const bundleless = makeApp('codex') as PredictiveApp; + bundleless._predictiveEcho = null; + bundleless._updateLocalEchoState(); + expect(() => bundleless._predictHookOnData('h')).not.toThrow(); + }); + + it('a throwing predictor cannot break the hook (exception pin)', () => { + const app = makePredictApp(); + app._predictiveEcho!.predictChar.mockImplementation(() => { + throw new Error('boom'); + }); + app._pendingInput = 'queued'; + expect(() => app._predictHookOnData('h')).not.toThrow(); + expect(app._pendingInput).toBe('queued'); + expect(app.sendInput).not.toHaveBeenCalled(); + }); +}); + +describe('insertTerminalText under predict policy', () => { + it('routes to sendInput (not the overlay) and clears predictions', () => { + const overlay = makeOverlay(); + const app = makeApp('codex', overlay) as PredictiveApp; + app._predictiveEcho = makePredictor(); + app._updateLocalEchoState(); // predict; _localEchoEnabled false + app.insertTerminalText('path.txt'); + expect(app.sendInput).toHaveBeenCalledWith('path.txt'); + expect(overlay.appendText).not.toHaveBeenCalled(); + expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); + }); +}); diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index f17cd104..9c818901 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -841,7 +841,7 @@ describe('Virtual Keyboard', () => { app.activeSessionId = 'mobile-visible-input-test'; app.sessions.set('mobile-visible-input-test', { id: 'mobile-visible-input-test', - mode: 'codex', + mode: 'claude', status: 'running', }); app.hideWelcome(); @@ -887,7 +887,7 @@ describe('Virtual Keyboard', () => { app.activeSessionId = 'mobile-cursor-fallback-test'; app.sessions.set('mobile-cursor-fallback-test', { id: 'mobile-cursor-fallback-test', - mode: 'codex', + mode: 'claude', status: 'running', }); app.hideWelcome(); @@ -915,6 +915,58 @@ describe('Virtual Keyboard', () => { expect(state.overlayState?.visible).toBe(true); expect(state.overlayState?.promptPosition).not.toBeNull(); }); + + it('codex: streams keystrokes write-through and paints predictions (no buffering)', async () => { + await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-codex-predict-test'; + app.sessions.set('mobile-codex-predict-test', { + id: 'mobile-codex-predict-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + // Paint a codex-like composer row so the predictWhen gate passes + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('\u203a ', resolve)); + app.terminal.focus(); + }); + + await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); + await page.keyboard.type('hey'); + + // Write-through: the keystrokes reach the send path BEFORE any Enter, + // and the buffer overlay holds nothing + await page.waitForFunction(() => window.__sentInputs?.join('') === 'hey'); + const typed = await page.evaluate(() => ({ + policy: app._localEchoPolicy, + pendingText: app._localEchoOverlay?.pendingText ?? '', + outstanding: app._predictiveEcho?.state.outstanding ?? -1, + spans: document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length, + })); + expect(typed.policy).toBe('predict'); + expect(typed.pendingText).toBe(''); + expect(typed.outstanding).toBeGreaterThan(0); + expect(typed.spans).toBe(typed.outstanding); + + // No echo ever arrives (stubbed session): TTL self-heals within ~1s + await page.waitForFunction( + () => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0, + undefined, + { timeout: 4000 } + ); + const settled = await page.evaluate(() => app._predictiveEcho?.state.outstanding); + expect(settled).toBe(0); + }); }); // ── Cross-device keyboard behavior ──────────────────────────────────── From fa02bd45038fc41107312e126ea801d4aeed909f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 05:02:14 +0200 Subject: [PATCH 5/9] test(predictive-echo): E2E suite against a real codex TUI (Layer 5) Out-of-process lab server (VITEST markers stripped so tmux/codex are real), CODEMAN_INSTANCE=codexlab on port 3222, throwaway CODEX_HOME with a fake key. Ten scenarios: bundle smoke, predict+converge typing, the #218 arrow retest (submitted text exact), the #222 live picker, the #219 paste order, the #220 wrap, the trust-modal ghost eliminator, the localEchoEnabled kill switch, the end-to-end byte-identity trace (predictor active vs null), and a display-delayed 300ms-RTT run pinning instant spans with exact pixel geometry plus arrow-edit correctness under lag. Live-TUI hardening learned the hard way: codex Ctrl+U kills only to line start (End first), a fake-key submit leaves a Reconnecting loop that can kill codex seconds later (retry-cancel + composer stability probe; the submitting scenario runs after all composer-state ones), and typing must wait for the predictWhen gate itself, not merely a rendered composer. CI-excluded like the other Playwright suites; skips cleanly when codex is not installed. Co-Authored-By: Claude Fable 5 --- config/vitest.ci.config.ts | 1 + test/codex-predictive-echo.test.ts | 606 +++++++++++++++++++++++++++++ 2 files changed, 607 insertions(+) create mode 100644 test/codex-predictive-echo.test.ts diff --git a/config/vitest.ci.config.ts b/config/vitest.ci.config.ts index 0efce453..b5bc31b9 100644 --- a/config/vitest.ci.config.ts +++ b/config/vitest.ci.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ 'test/opencode-resize.test.ts', // browser (Playwright) 'test/webgl-fallback.test.ts', // browser (Playwright) 'test/terminal-copy-shortcut.test.ts', // browser (Playwright) + 'test/codex-predictive-echo.test.ts', // browser (Playwright) + real codex binary ], setupFiles: ['./test/setup.ts'], fileParallelism: false, diff --git a/test/codex-predictive-echo.test.ts b/test/codex-predictive-echo.test.ts new file mode 100644 index 00000000..ec8010af --- /dev/null +++ b/test/codex-predictive-echo.test.ts @@ -0,0 +1,606 @@ +/** + * @fileoverview Predictive-echo E2E against a REAL codex 0.147 TUI (issues + * #218/#219/#220/#222 retest scenarios + the byte-identity and simulated-RTT + * pins). CI-EXCLUDED (needs chromium + the codex binary); a REQUIRED item of + * the release checklist. + * + * Unlike the other Playwright tests this one cannot use the in-process + * WebServer: under VITEST the mux layer is a pure in-memory mock and Session + * spawns an echo PTY, so no real codex would ever run. The lab server is a + * CHILD PROCESS with the VITEST markers stripped from its env, isolated via + * CODEMAN_INSTANCE=codexlab (own data dir under the per-test fixture HOME + + * own tmux socket) on port 3222 (3220/3221 are taken; see the port sweep in + * the plan). Codex runs against a throwaway CODEX_HOME with a fake API key + * (never leaves the box: the first request 401s, which is fine — every + * scenario here is about the composer, not completions). + * + * Run: npx vitest run --config config/vitest.config.ts test/codex-predictive-echo.test.ts + */ +import { execSync, spawn, type ChildProcess } from 'node:child_process'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { userInfo } from 'node:os'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; + +const PORT = 3222; +const BASE_URL = `http://localhost:${PORT}`; +const INSTANCE = 'codexlab'; +const TMUX = `tmux -L codeman-${INSTANCE}`; +const ROOT = resolve(import.meta.dirname, '..'); +const CODEX_BIN_DIR = `${userInfo().homedir}/.local/bin`; + +let server: ChildProcess | null = null; +let browser: Browser; +let context: BrowserContext; +let page: Page; +let codexHome: string; +let trustedWorkdir: string; +let untrustedWorkdir: string; +let sessionId: string; +const createdSessions: string[] = []; + +function hasCodex(): boolean { + try { + execSync(`PATH="${CODEX_BIN_DIR}:$PATH" codex --version`, { stdio: 'pipe' }); + return true; + } catch (e) { + console.error('[codex-e2e] codex unavailable:', (e as Error).message.slice(0, 300)); + return false; + } +} + +async function api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return res.json(); +} + +const paneByTail = new Map(); + +/** Rendered pane text (tmux ground truth). Panes are found once by the + * workdir basename codex prints in its banner, then cached by pane id — + * the lab tmux socket is exclusive to this test. */ +function capturePaneFor(workdir: string): string { + const tail = workdir.split('/').pop()!; + const panes = execSync(`${TMUX} list-panes -a -F '#{pane_id}'`, { encoding: 'utf8' }).trim().split('\n'); + const capture = (p: string) => execSync(`${TMUX} capture-pane -p -t '${p}'`, { encoding: 'utf8' }); + const cached = paneByTail.get(tail); + if (cached && panes.includes(cached)) return capture(cached); + for (const p of panes.filter(Boolean)) { + const text = capture(p); + if (text.includes(tail)) { + paneByTail.set(tail, p); + return text; + } + } + throw new Error(`no pane showing workdir ${tail}; panes: ${panes.join(', ')}`); +} + +async function createCodexSession(workingDir: string): Promise { + const created = await api('POST', '/api/sessions', { + mode: 'codex', + workingDir, + envOverrides: { CODEX_HOME: codexHome }, + }); + // Tolerate both the ApiResponse envelope and the legacy raw shape + const payload = created.data ?? created; + const id = payload.session?.id ?? payload.id ?? payload.sessionId; + expect(id, JSON.stringify(created).slice(0, 300)).toBeTruthy(); + createdSessions.push(id); + return id; +} + +/** Select the session in the UI and wait for the codex composer to render. */ +async function openInBrowser(id: string): Promise { + await page.evaluate((sid) => (window as any).app.selectSession(sid), id); + await page.waitForFunction( + () => { + const app = (window as any).app; + const buf = app.terminal?.buffer.active; + if (!buf) return false; + for (let y = 0; y < app.terminal.rows; y++) { + const t = buf.getLine(buf.baseY + y)?.translateToString(true) ?? ''; + if (/^› /.test(t)) return true; + } + return false; + }, + undefined, + { timeout: 30000 } + ); + await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } }); + await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict'); + // Wait for the exact typing precondition: the CURSOR parked on the composer + // row (the predictWhen gate itself), not merely a composer row existing — + // during the boot animation the cursor roams and predictions are suppressed. + await page.waitForFunction( + () => { + const w = window as any; + return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal); + }, + undefined, + { timeout: 20000 } + ); + await new Promise((r) => setTimeout(r, 300)); +} + +function predictState(): Promise<{ outstanding: number; confirmedTotal: number; droppedTotal: number }> { + return page.evaluate(() => (window as any).app._predictiveEcho.state); +} + +function spanCount(): Promise { + return page.evaluate(() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length); +} + +/** Deterministic composer reset: codex's Ctrl+U kills only to LINE START, so + * End first; then a settle. Leaves the cursor parked on an empty composer. */ +async function resetComposer(): Promise { + // Codex can be mid-respawn after an exhausted 401 retry loop killed it: + // wait for a live composer (the gate itself) before touching the keyboard. + await page.waitForFunction( + () => { + const w = window as any; + return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal); + }, + undefined, + { timeout: 25000 } + ); + await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } }); + await page.keyboard.press('End'); + await page.keyboard.press('Control+u'); + await new Promise((r) => setTimeout(r, 400)); +} + +/** After a submit 401s, codex sits in a Reconnecting retry loop that can eat + * typed input; Esc interrupts it. Wait until the retry line is gone. */ +async function cancelRetryLoop(): Promise { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + const pane = capturePaneFor(trustedWorkdir); + if (!/Reconnecting|esc to interrupt/.test(pane)) break; + await page.keyboard.press('Escape'); + await new Promise((r) => setTimeout(r, 500)); + } + // Settle to a live composer (codex may have died at 5/5 and respawned), + // then require it to STAY alive: the fake-key request can kill codex + // seconds later, so a single gate-true observation is not enough. + const stableDeadline = Date.now() + 30000; + for (;;) { + await resetComposer(); + let stable = true; + for (let i = 0; i < 3; i++) { + await new Promise((r) => setTimeout(r, 1000)); + const alive = await page.evaluate(() => { + const w = window as any; + return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal); + }); + if (!alive) { + stable = false; + break; + } + } + if (stable || Date.now() > stableDeadline) return; + } +} + +const CODEX_AVAILABLE = hasCodex(); +const d = CODEX_AVAILABLE ? describe : describe.skip; + +beforeAll(async () => { + if (!CODEX_AVAILABLE) return; + codexHome = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-')); + trustedWorkdir = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-work-')); + untrustedWorkdir = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-untrusted-')); + mkdirSync(codexHome, { recursive: true }); + writeFileSync(resolve(codexHome, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: 'sk-test-123' })); + writeFileSync(resolve(codexHome, 'config.toml'), `[projects."${trustedWorkdir}"]\ntrust_level = "trusted"\n`); + + // Child env: strip the vitest markers so the lab server runs REAL tmux/codex + const env: Record = { ...process.env }; + delete env.VITEST; + delete env.VITEST_MODE; + delete env.VITEST_POOL_ID; + delete env.VITEST_WORKER_ID; + delete env.NODE_ENV; + env.CODEMAN_INSTANCE = INSTANCE; + env.PATH = `${CODEX_BIN_DIR}:${env.PATH}`; + + server = spawn('npx', ['tsx', 'src/index.ts', 'web', '--port', String(PORT)], { + cwd: ROOT, + env: env as NodeJS.ProcessEnv, + stdio: 'ignore', + detached: false, + }); + + // Wait for the lab server + const deadline = Date.now() + 30000; + for (;;) { + try { + const res = await fetch(`${BASE_URL}/api/status`); + if (res.ok) break; + } catch { + /* not up yet */ + } + if (Date.now() > deadline) throw new Error('lab server did not start on :3222'); + await new Promise((r) => setTimeout(r, 300)); + } + + browser = await chromium.launch({ headless: true }); + context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + page = await context.newPage(); + await page.addInitScript(() => { + localStorage.setItem('codeman-app-settings', JSON.stringify({ localEchoEnabled: true })); + }); + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10000 }); + + sessionId = await createCodexSession(trustedWorkdir); + await openInBrowser(sessionId); +}, 120_000); + +afterAll(async () => { + for (const id of createdSessions) { + try { + await api('DELETE', `/api/sessions/${id}`); + } catch { + /* best effort */ + } + } + await context?.close(); + await browser?.close(); + server?.kill('SIGTERM'); + try { + execSync(`${TMUX} kill-server`, { stdio: 'ignore' }); + } catch { + /* already gone */ + } +}, 30_000); + +d('codex predictive echo E2E (real codex TUI)', () => { + it('bundle smoke: both echo globals are defined', async () => { + const globals = await page.evaluate(() => ({ + zerolag: typeof (window as any).LocalEchoOverlay, + predictive: typeof (window as any).PredictiveEchoOverlay, + instance: !!(window as any).app._predictiveEcho, + })); + expect(globals.zerolag).toBe('function'); + expect(globals.predictive).toBe('function'); + expect(globals.instance).toBe(true); + }); + + it('typing predicts every char and converges into the real echo', async () => { + await page.keyboard.type('hello', { delay: 30 }); + // Keystrokes went through the predictor (a transient repaint may suppress + // one or two: that is designed graceful degradation, never a ghost) + await page.waitForFunction(() => { + const s = (window as any).app._predictiveEcho.state; + return s.confirmedTotal + s.outstanding >= 3; + }); + // Convergence: spans gone, composer shows the text, ZERO mispredictions + await page.waitForFunction( + () => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0 + ); + const s = await predictState(); + expect(s.confirmedTotal).toBeGreaterThanOrEqual(3); + expect(s.droppedTotal).toBe(0); + const deadline = Date.now() + 10000; + let pane = ''; + while (Date.now() < deadline) { + pane = capturePaneFor(trustedWorkdir); + if (pane.includes('hello')) break; + await new Promise((r) => setTimeout(r, 250)); + } + expect(pane).toContain('hello'); + }, 30_000); + + it('#222: slash picker filters live while predictions confirm away', async () => { + await resetComposer(); + await page.keyboard.type('/', { delay: 30 }); + const deadline = Date.now() + 10000; + let pane = ''; + while (Date.now() < deadline) { + pane = capturePaneFor(trustedWorkdir); + if (/\/model|\/skills|\/init/.test(pane)) break; + await new Promise((r) => setTimeout(r, 250)); + } + expect(pane).toMatch(/\/model|\/skills|\/init/); + await page.keyboard.type('mo', { delay: 40 }); + await page.waitForFunction( + () => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0 + ); + const filtered = capturePaneFor(trustedWorkdir); + expect(filtered).toContain('/model'); + await page.keyboard.press('Escape'); // close the picker + await page.keyboard.press('Control+u'); // deterministically empty the composer + await new Promise((r) => setTimeout(r, 300)); + const s = await predictState(); + expect(s.outstanding).toBe(0); + }, 30_000); + + it('#219: typed text + paste land in order with nothing dropped', async () => { + await resetComposer(); + const pidBefore = execSync(`${TMUX} list-panes -a -F '#{pane_id} #{pane_pid}'`, { encoding: 'utf8' }).trim(); + await page.evaluate(() => { + const app = (window as any).app; + (window as any).__dbg = []; + if (!(window as any).__origSend2) (window as any).__origSend2 = app._sendInputAsync.bind(app); + app._sendInputAsync = (sid: string, data: string, opts?: unknown) => { + (window as any).__dbg.push([sid.slice(0, 8), JSON.stringify(data)]); + return (window as any).__origSend2(sid, data, opts); + }; + }); + await page.keyboard.type('abc', { delay: 30 }); + await page.evaluate(() => (window as any).app.terminal.paste('XYZ')); + const deadline = Date.now() + 15000; + let pane = ''; + while (Date.now() < deadline) { + pane = capturePaneFor(trustedWorkdir); + if (pane.includes('abcXYZ')) break; + await new Promise((r) => setTimeout(r, 250)); + } + if (!pane.includes('abcXYZ')) { + const pidAfter = execSync(`${TMUX} list-panes -a -F '#{pane_id} #{pane_pid}'`, { encoding: 'utf8' }).trim(); + const dbg = await page.evaluate(() => (window as any).__dbg); + const row = await page.evaluate(() => { + const b = (window as any).app.terminal.buffer.active; + return b.getLine(b.baseY + b.cursorY)?.translateToString(true); + }); + console.log( + 'DBG219 pids-before:', + pidBefore, + '| pids-after:', + pidAfter, + '| sends:', + JSON.stringify(dbg), + '| app-cursor-row:', + JSON.stringify(row) + ); + } + expect(pane).toContain('abcXYZ'); + expect(await spanCount()).toBe(0); // paste classified 'clear' + await page.keyboard.press('Control+u'); // Ctrl+U: clear the composer line + }, 30_000); + + it('#220: wrapped input renders exactly, no ghost glyphs past the edge', async () => { + await resetComposer(); + const long = 'the quick brown fox jumps over the lazy dog and keeps running until the composer has to wrap'; + await page.keyboard.type(long, { delay: 5 }); + // Predictions on the first composer row confirm or drop; continuation + // rows are gate-suppressed. Everything converges: + await page.waitForFunction( + () => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0, + undefined, + { timeout: 10000 } + ); + const deadline = Date.now() + 10000; + let ok = false; + while (Date.now() < deadline) { + const pane = capturePaneFor(trustedWorkdir).replace(/\s+/g, ' '); + if (pane.includes('composer has to wrap')) { + ok = true; + break; + } + await new Promise((r) => setTimeout(r, 250)); + } + expect(ok).toBe(true); + await page.keyboard.press('Control+u'); + await new Promise((r) => setTimeout(r, 200)); + }, 45_000); + + it('modal ghost eliminator: zero spans while typing on the trust dialog', async () => { + const modalSession = await createCodexSession(untrustedWorkdir); + try { + await page.evaluate((sid) => (window as any).app.selectSession(sid), modalSession); + // Wait for the trust dialog (the pane may not exist for the first second) + const deadline = Date.now() + 25000; + let pane = ''; + while (Date.now() < deadline) { + try { + pane = capturePaneFor(untrustedWorkdir); + if (pane.includes('Press enter to continue')) break; + } catch { + /* session still spawning */ + } + await new Promise((r) => setTimeout(r, 300)); + } + expect(pane).toContain('Press enter to continue'); + await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict'); + await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } }); + await page.keyboard.type('x', { delay: 30 }); + expect(await spanCount()).toBe(0); // gate rejected: no ghost on the modal + expect((await predictState()).outstanding).toBe(0); + } finally { + // Never strand later tests on the modal session + await api('DELETE', `/api/sessions/${modalSession}`); + await page.evaluate((sid) => (window as any).app.selectSession(sid), sessionId); + await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict'); + await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } }); + } + }, 45_000); + + it('kill switch: localEchoEnabled OFF clears spans and typing still streams', async () => { + await page.evaluate(() => { + const app = (window as any).app; + const s = app.loadAppSettingsFromStorage(); + s.localEchoEnabled = false; + app.saveAppSettingsToStorage(s); + app._updateLocalEchoState(); + }); + expect(await page.evaluate(() => (window as any).app._localEchoPolicy)).toBe('off'); + expect(await spanCount()).toBe(0); + await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } }); + await page.keyboard.press('End'); + await page.keyboard.press('Control+u'); + await page.keyboard.type('still-live', { delay: 20 }); + const deadline = Date.now() + 10000; + let pane = ''; + while (Date.now() < deadline) { + pane = capturePaneFor(trustedWorkdir); + if (pane.includes('still-live')) break; + await new Promise((r) => setTimeout(r, 250)); + } + expect(pane).toContain('still-live'); // 1.12.2 behavior exactly + expect(await spanCount()).toBe(0); + await page.evaluate(() => { + const app = (window as any).app; + const s = app.loadAppSettingsFromStorage(); + s.localEchoEnabled = true; + app.saveAppSettingsToStorage(s); + app._updateLocalEchoState(); + }); + await page.keyboard.press('Control+u'); + await new Promise((r) => setTimeout(r, 200)); + }, 30_000); + + it('byte-identity: the wire receives the same bytes with the predictor active vs absent', async () => { + const script = async () => { + await resetComposer(); + await page.keyboard.type('ab', { delay: 60 }); + await page.keyboard.press('Backspace'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type('c', { delay: 60 }); + await page.evaluate(() => (window as any).app.terminal.paste('PQ')); + await new Promise((r) => setTimeout(r, 250)); + await page.keyboard.press('End'); + await page.keyboard.press('Control+u'); + await new Promise((r) => setTimeout(r, 250)); + }; + + const record = () => + page.evaluate(() => { + const app = (window as any).app; + (window as any).__trace = []; + if (!(window as any).__origSend) (window as any).__origSend = app._sendInputAsync.bind(app); + app._sendInputAsync = (sid: string, data: string, opts?: unknown) => { + (window as any).__trace.push(data); + return (window as any).__origSend(sid, data, opts); + }; + }); + const trace = () => page.evaluate(() => ((window as any).__trace as string[]).join('')); + + await record(); + await script(); + const withPredictor = await trace(); + + await page.evaluate(() => { + (window as any).__savedPredictor = (window as any).app._predictiveEcho; + (window as any).app._predictiveEcho = null; + }); + await record(); + await script(); + const withoutPredictor = await trace(); + await page.evaluate(() => { + (window as any).app._predictiveEcho = (window as any).__savedPredictor; + }); + + expect(withPredictor.length).toBeGreaterThan(0); + expect(withoutPredictor).toBe(withPredictor); // the visual-only invariant, end to end + }, 60_000); + + it('#218: arrows + mid-word insert submit the exact edited text', async () => { + await resetComposer(); + await page.keyboard.type('hello', { delay: 30 }); + await new Promise((r) => setTimeout(r, 300)); + await page.keyboard.press('ArrowLeft'); + // Nav keys clear predictions immediately (classify 'clear') + expect(await predictState()).toMatchObject({ outstanding: 0 }); + expect(await spanCount()).toBe(0); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type('X', { delay: 30 }); + await page.keyboard.press('Enter'); + // The submitted transcript line carries the edited text + const deadline = Date.now() + 15000; + let pane = ''; + while (Date.now() < deadline) { + pane = capturePaneFor(trustedWorkdir); + if (pane.includes('helXlo')) break; + await new Promise((r) => setTimeout(r, 300)); + } + expect(pane).toContain('helXlo'); + expect(await spanCount()).toBe(0); + await cancelRetryLoop(); + }, 45_000); + + it('simulated 300ms RTT: instant spans with correct pixel geometry, exact convergence', async () => { + await resetComposer(); + // Delay every terminal.write chunk by 300ms: display-side injection only, + // the wire is untouched. This is the condition the feature exists for. + await page.evaluate(() => { + const app = (window as any).app; + (window as any).__origWrite = app.terminal.write.bind(app.terminal); + app.terminal.write = (data: unknown, cb?: () => void) => + setTimeout(() => (window as any).__origWrite(data, cb), 300); + }); + // Let any pre-wrapper chunks and their reconcile passes settle first + await new Promise((r) => setTimeout(r, 700)); + const base = await predictState(); + + await page.keyboard.press('h'); + // The span exists NOW, long before the delayed echo can land + const snap = await page.evaluate(() => { + const app = (window as any).app; + const span = document.querySelector('.xterm-screen [data-predictive-echo] span') as HTMLElement; + const screen = document.querySelector('.xterm-screen') as HTMLElement; + const dims = app.terminal._core._renderService.dimensions.css.cell; + const buf = app.terminal.buffer.active; + return span + ? { + outstanding: app._predictiveEcho.state.outstanding, + spanLeft: span.getBoundingClientRect().left - screen.getBoundingClientRect().left, + spanTop: span.getBoundingClientRect().top - screen.getBoundingClientRect().top, + expectedLeft: buf.cursorX * dims.width, + expectedTop: buf.cursorY * dims.height, + text: span.textContent, + } + : null; + }); + expect(snap).not.toBeNull(); + expect(snap!.outstanding).toBe(1); + expect(snap!.text).toBe('h'); + // Pixel geometry: the DOM span sits on the exact cell the echo will use + expect(Math.abs(snap!.spanLeft - snap!.expectedLeft)).toBeLessThan(1.5); + expect(Math.abs(snap!.spanTop - snap!.expectedTop)).toBeLessThan(1.5); + + await page.keyboard.type('igh rtt', { delay: 40 }); + // (No mid-flight outstanding assertion: with local codex the delayed echo + // begins confirming DURING the typing. The snap above already pinned the + // zero-lag property; convergence + the delta below pin the rest.) + + // Full convergence after the delayed echo lands + await page.waitForFunction( + () => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0, + undefined, + { timeout: 15000 } + ); + const deadline = Date.now() + 10000; + let pane = ''; + while (Date.now() < deadline) { + pane = capturePaneFor(trustedWorkdir); + if (pane.includes('high rtt')) break; + await new Promise((r) => setTimeout(r, 250)); + } + expect(pane).toContain('high rtt'); + const after = await predictState(); + expect(after.confirmedTotal - base.confirmedTotal).toBeGreaterThanOrEqual(6); // h + igh rtt + + // #218 under RTT: arrows still edit correctly with the delayed display + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type('Z', { delay: 40 }); + await page.keyboard.press('Enter'); + const deadline2 = Date.now() + 15000; + let pane2 = ''; + while (Date.now() < deadline2) { + pane2 = capturePaneFor(trustedWorkdir); + if (pane2.includes('high rtZt')) break; + await new Promise((r) => setTimeout(r, 300)); + } + expect(pane2).toContain('high rtZt'); + + await page.evaluate(() => { + (window as any).app.terminal.write = (window as any).__origWrite; + }); + }, 90_000); +}); From 55bff4a4bf0737f07f4a7b474e23ed2aa31f77ea Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 05:11:18 +0200 Subject: [PATCH 6/9] docs+ci(predictive-echo): CI package-suite step, invariants, changeset ci.yml runs the xterm-zerolag-input suite (Layers 1-3) after the root npm ci (workspaces hoisting; no separate install). CLAUDE.md and architecture-invariants.md rewrite the codex echo story: predictive write-through with the wire-neutrality, separate-bundle, composer-gate, baseY and blank-neutral invariants spelled out; the single-source section now covers both vendor bundles and why their entry points differ. Changeset: minor for aicodeman + xterm-zerolag-input. Co-Authored-By: Claude Fable 5 --- .changeset/c4baa514.md | 14 ++++++++++++++ .github/workflows/ci.yml | 8 ++++++++ CLAUDE.md | 4 ++-- docs/architecture-invariants.md | 4 ++-- 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 .changeset/c4baa514.md diff --git a/.changeset/c4baa514.md b/.changeset/c4baa514.md new file mode 100644 index 00000000..f07e65cc --- /dev/null +++ b/.changeset/c4baa514.md @@ -0,0 +1,14 @@ +--- +"aicodeman": minor +"xterm-zerolag-input": minor +--- + +Zero-lag predictive echo for Codex sessions (mosh-style write-through prediction). + +Codex's per-keystroke composer forced 1.12.2 to disable the local-echo overlay (issues #218/#219/#220/#222), leaving Codex typing at full round-trip latency on remote links. This release adds a second echo mode instead of re-enabling the first: every keystroke still goes to the PTY exactly as before (byte-identical wire behavior, pinned by vm-level and end-to-end trace-equality tests), while the new `PredictiveEchoAddon` in `xterm-zerolag-input` 0.2.0 paints the predicted glyph at the predicted cell. When the real echo lands, the prediction is confirmed and its span removed (an invisible swap); mispredictions self-heal via a two-pass mismatch cascade and a TTL. + +- Reconciliation reads the parsed terminal buffer, never the raw stream: full-line redraws, ECH gap painting and tmux's in-place deltas all converge to the same cells. Confirmation requires the cell match PLUS a cursor advance, so placeholder glyphs and identical repaints never false-confirm; blank cells are neutral (codex clears its placeholder on the first echo). +- Predictions paint only while the cursor sits on the measured Codex composer row (`/^› /`, codex-cli 0.147): trust/approval modals and wrapped continuation rows get no ghosts, deliberately falling back to real echo. +- Ships as a SEPARATE `vendor/xterm-predictive-echo.js` bundle: the existing zerolag bundle is byte-identical (sha256-verified), and a missing or broken bundle degrades Codex to exact 1.12.2 behavior. The per-device `localEchoEnabled` toggle is the kill switch. +- Claude/Gemini/OpenCode/Antigravity keep buffer mode untouched; shell stays off. +- Tests: 52 new package tests including replay suites driven by fixtures recorded from a real codex TUI through the production tmux+strip pipeline (`scripts/dev/record-codex-frames.mjs`) and a 500-iteration seeded fuzz; new vm policy/wire-neutrality suites; a 10-scenario Playwright E2E against real codex covering the #218/#219/#220/#222 retests, byte-identity, and a simulated 300ms-RTT run. The package test suite now runs in CI. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 755396df..4378d627 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,14 @@ jobs: # Safe in CI: TmuxManager no-ops all shell commands under VITEST (test/setup.ts). run: npm run test:ci + - name: Run xterm-zerolag-input package tests + # Layers 1-3 of the predictive-echo suites (unit laws, fixture replay, + # seeded fuzz): deterministic, no browser, no live server. Depends on + # the ROOT `npm ci` above — workspaces hoist the package's vitest into + # the root node_modules; do not add a separate install here. + run: npx vitest run + working-directory: packages/xterm-zerolag-input + # Note: The browser-driven mobile suite (test/mobile/**) is excluded from CI — # it needs a live server + chromium + environment-specific PNG baselines. # Run it locally/manually. All other tests run via the `test` job above. diff --git a/CLAUDE.md b/CLAUDE.md index 806069c0..790da44a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not - **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-integration.md` - **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice -- **`xterm-zerolag-input` is single-source** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/`, and is bundled into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` (dev, by `scripts/postinstall.js`) and `dist/.../vendor/` (prod, by `scripts/build.mjs`). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundle.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md` +- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md` - **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md` - **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger) - **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ Look in **`prebuilds/-/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable) @@ -198,7 +198,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **The local-echo overlay is DISABLED for codex sessions** (`_updateLocalEchoState` in terminal-ui.js, same branch as shell): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222. Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini) +**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index c53c0530..56209760 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -22,7 +22,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough **External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All four modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). -**Codex input path (issues #218/#219/#220/#222)**: the local-echo overlay is **DISABLED for codex-mode sessions** (`_updateLocalEchoState` in terminal-ui.js, same branch as shell). Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key classifier truth table, per-mode gating, flush helper, pass-through routing). +**Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incl. byte-identity + 300ms-RTT). ### Remote sessions over SSH @@ -275,7 +275,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina ### xterm-zerolag-input is single-source -**`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs` (the `xterm-zerolag-input` esbuild step, for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`. +**`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundles** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/` (also published to npm as a standalone library — see README "Published Packages") and are bundled into TWO **gitignored** vendor files with DIFFERENT entry points: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`, `window.LocalEchoOverlay` alias) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`, `window.PredictiveEchoOverlay` alias) — dev by `scripts/postinstall.js` (reruns on `npm install`), prod by `scripts/build.mjs`. The separate entries are DELIBERATE: adding exports to the package's `index.ts` cannot change the zerolag bundle's bytes, which is what keeps predictive-echo changes structurally unable to regress buffer mode (verified sha256-identical at introduction). `app.js`/terminal-ui.js only **consume** the addons via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)` — there is NO inline copy to keep in sync. **Never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundles.** A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md` (buffer mode) and `docs/predictive-echo-plan.md` (predict mode). ### node-pty's macOS spawn-helper must be executable From 9e7c537e14b0aede91b9185ddba186d10fb74ebb Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 05:29:45 +0200 Subject: [PATCH 7/9] fix(predictive-echo): anchor hold after unpredicted wire edits (review findings) Independent post-build review found three gaps, all one family: input that changes the composer without a prediction leaves the DISPLAYED cursor stale for one RTT, and anchoring a new run on it painted ghosts one cell off (blank-neutral, so they lived out the full TTL: "tehh" on backspace-then-retype, exactly on the links the feature targets). Fix: the addon now HOLDS new predictions after any such edit (backspace with nothing outstanding = deleting echoed text, clearPredictions, and now also IME/plain-paste 'text' commits, which the hook clears like 'clear') until the next PARSED write releases the hold. The inline predictChar reconcile deliberately does not count: only the emitter pass or the public reconcile() is the display-caught-up contract. Worst case is exactly one unpredicted keystroke, whose own echo releases the hold. Also patched the one bypass path the PR had missed: _handleCjkInput now clears predictions like insertTerminalText and the other bypass sends. Package suite 230, vm gating 85, E2E 10/10 all green after the change. Co-Authored-By: Claude Fable 5 --- docs/architecture-invariants.md | 2 +- docs/predictive-echo-plan.md | 8 +++++ packages/xterm-zerolag-input/CHANGELOG.md | 1 + packages/xterm-zerolag-input/README.md | 7 +++- .../src/predictive-echo-addon.ts | 26 +++++++++++++-- .../test/codex-replay.test.ts | 2 +- .../test/predictive-echo-addon.test.ts | 33 +++++++++++++++++++ src/web/public/terminal-ui.js | 8 +++-- test/local-echo-codex-gating.test.ts | 6 ++-- 9 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 56209760..74b178b6 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -22,7 +22,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough **External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All four modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). -**Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incl. byte-identity + 300ms-RTT). +**Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) after an UNPREDICTED wire edit (backspace into echoed text, any 'clear'-classified input, an IME/plain-paste 'text' commit, or every bypass send incl. `_handleCjkInput`) the addon holds new predictions until the next PARSED write: the displayed cursor is stale for one RTT and anchoring on it paints ghosts one cell off; (6) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incl. byte-identity + 300ms-RTT). ### Remote sessions over SSH diff --git a/docs/predictive-echo-plan.md b/docs/predictive-echo-plan.md index bb1e3623..5fad2b90 100644 --- a/docs/predictive-echo-plan.md +++ b/docs/predictive-echo-plan.md @@ -83,6 +83,14 @@ rules and why each exists: - **No drop on baseY change**: codex streams push lines to history while the composer stays viewport-pinned; predictions are row-relative to the pinned composer and remain valid (measured above). +- **Anchor hold** (added by the independent post-build review): after any wire + input whose cursor effect the display has not shown yet (backspace with + nothing outstanding = deleting echoed text, every 'clear'-classified input, + an IME/plain-paste 'text' commit, and the bypass send paths), new + predictions are suppressed until the next PARSED write. Anchoring on the + stale cursor painted ghosts one cell off ("tehh" on backspace-then-retype + within RTT), blank-neutral and therefore TTL-lived. Worst case is exactly + one unpredicted keystroke: its own echo is a write, which releases the hold. - **predictBackspace()** pops the newest outstanding record (informational return; the consumer forwards `\x7f` unconditionally). Deleting already-echoed text renders at RTT in v1. diff --git a/packages/xterm-zerolag-input/CHANGELOG.md b/packages/xterm-zerolag-input/CHANGELOG.md index bd2f51d9..0c5a235e 100644 --- a/packages/xterm-zerolag-input/CHANGELOG.md +++ b/packages/xterm-zerolag-input/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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. + - Anchor-hold rule: after an unpredicted wire edit (backspace into echoed text, cleared input, an IME text commit) new predictions hold until the next parsed write, so a stale displayed cursor can never mis-anchor a run (worst case: exactly one unpredicted keystroke). - 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. diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index af8f3c2c..ae49fec8 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -333,7 +333,12 @@ 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. +Scrolling up, resizing, or a sustained cursor move clears the run. After a +backspace into already-echoed text, a cleared input, or a multi-char commit, +the addon **holds** new predictions until the next parsed write: the displayed +cursor is stale for one round trip, and anchoring on it would paint ghosts one +cell off (worst case: exactly one unpredicted keystroke, whose own echo +releases the hold). ### API diff --git a/packages/xterm-zerolag-input/src/predictive-echo-addon.ts b/packages/xterm-zerolag-input/src/predictive-echo-addon.ts index dee3005a..ca91db59 100644 --- a/packages/xterm-zerolag-input/src/predictive-echo-addon.ts +++ b/packages/xterm-zerolag-input/src/predictive-echo-addon.ts @@ -88,6 +88,14 @@ export class PredictiveEchoAddon implements XtermAddon { private _confirmedTotal = 0; private _droppedTotal = 0; private _ttlTimer: ReturnType | null = null; + /** Anchor hold: set after an unpredicted wire edit (backspace into echoed + * text, any cleared input, an IME text commit). While held, new + * predictions are suppressed: the displayed cursor is stale until the + * next parsed write, and anchoring on it paints ghosts one cell off + * (found by review: backspace-then-retype within RTT). Cleared by the + * onWriteParsed pass and by public reconcile(), never by the inline + * predictChar pass (which runs before the display could catch up). */ + private _anchorHold = false; private _reconcileScheduled = false; private _disposables: Array<{ dispose(): void }> = []; private _predictWhen: ((terminal: XtermTerminal) => boolean) | null; @@ -141,6 +149,7 @@ export class PredictiveEchoAddon implements XtermAddon { this._reconcileScheduled = true; queueMicrotask(() => { this._reconcileScheduled = false; + this._anchorHold = false; // a parse pass ran: the display caught up this._safeReconcile(); }); }) @@ -183,6 +192,7 @@ export class PredictiveEchoAddon implements XtermAddon { predictChar(ch: string): boolean { try { this._reconcile(); + if (this._anchorHold) return false; // display has not caught up with a wire edit const t = this._terminal; if (!t || !this._container) return false; @@ -247,7 +257,12 @@ export class PredictiveEchoAddon implements XtermAddon { predictBackspace(): boolean { try { const rec = this._outstanding.pop(); - if (!rec) return false; + if (!rec) { + // \x7f goes to the wire and will delete ECHOED text: the cursor is + // about to move in a way we cannot see yet + this._anchorHold = true; + return false; + } removePredictionSpan(this._spans, rec.seq); if (this._outstanding.length === 0) this._resetRun(); return true; @@ -256,9 +271,12 @@ export class PredictiveEchoAddon implements XtermAddon { } } - /** Drop every outstanding prediction and its spans. */ + /** Drop every outstanding prediction and its spans. Also arms the anchor + * hold: consumers clear on inputs (Enter, Esc, arrows, pastes) whose + * cursor effect is unknown until the next parsed write. */ clearPredictions(): void { try { + this._anchorHold = true; this._droppedTotal += this._outstanding.length; this._outstanding = []; clearAllSpans(this._spans); @@ -268,8 +286,10 @@ export class PredictiveEchoAddon implements XtermAddon { } } - /** Manual reconcile pass, for consumers without onWriteParsed. */ + /** Manual reconcile pass, for consumers without onWriteParsed. By contract + * it is called after writes parsed, so it also releases the anchor hold. */ reconcile(): void { + this._anchorHold = false; this._safeReconcile(); } diff --git a/packages/xterm-zerolag-input/test/codex-replay.test.ts b/packages/xterm-zerolag-input/test/codex-replay.test.ts index 1e5aad38..5d5298e4 100644 --- a/packages/xterm-zerolag-input/test/codex-replay.test.ts +++ b/packages/xterm-zerolag-input/test/codex-replay.test.ts @@ -62,7 +62,7 @@ async function replay(name: string) { let painted = false; if (kind === 'char') painted = addon.predictChar(line.data); else if (kind === 'backspace') addon.predictBackspace(); - else if (kind === 'clear') addon.clearPredictions(); + else addon.clearPredictions(); // 'clear' AND 'text', like the terminal-ui hook // Span/record parity and grid bounds hold at every step expect(rt.spanCount()).toBe(addon.state.outstanding); assertSpansInGrid(rt); diff --git a/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts b/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts index bbeca038..7c4ea821 100644 --- a/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts +++ b/packages/xterm-zerolag-input/test/predictive-echo-addon.test.ts @@ -335,6 +335,7 @@ describe('PredictiveEchoAddon', () => { it('predictBackspace pops newest, returns false when empty, never touches confirmed', async () => { expect(addon.predictBackspace()).toBe(false); + addon.reconcile(); // the empty pop armed the anchor hold; release it addon.predictChar('a'); addon.predictChar('b'); expect(addon.predictBackspace()).toBe(true); @@ -478,6 +479,7 @@ describe('PredictiveEchoAddon', () => { rows.style.color = 'rgb(255, 0, 0)'; // skin change a.refreshFont(); a.clearPredictions(); + a.reconcile(); // release the anchor hold armed by the clear a.predictChar('v'); const span2 = themed.terminal.element.querySelector('.xterm-screen span') as HTMLSpanElement; expect(span2.style.color).toBe('rgb(255, 0, 0)'); @@ -485,6 +487,37 @@ describe('PredictiveEchoAddon', () => { themed.cleanup(); }); + it('anchor hold: backspace into echoed text suppresses prediction until a write parses', async () => { + // \x7f went to the wire with nothing outstanding: the cursor will move + // in a way the display has not shown, so anchoring now paints one cell + // off (review finding: "tehh" ghosts on backspace-then-retype at RTT) + expect(addon.predictBackspace()).toBe(false); + expect(addon.predictChar('x')).toBe(false); + expect(spansOf(mock)).toHaveLength(0); + mock.fireWriteParsed(); // the display caught up + await flushMicrotasks(); + expect(addon.predictChar('x')).toBe(true); + }); + + it('anchor hold: clearPredictions suppresses until a write parses (or manual reconcile)', async () => { + addon.predictChar('a'); + addon.clearPredictions(); // consumer saw Enter/Esc/arrow/paste + expect(addon.predictChar('b')).toBe(false); + mock.fireWriteParsed(); + await flushMicrotasks(); + expect(addon.predictChar('b')).toBe(true); + }); + + it('anchor hold: the inline predictChar reconcile does NOT release it', () => { + addon.clearPredictions(); + // Several keystrokes in a row before any echo: all suppressed, because + // predictChar's inline pass must not count as the display catching up + expect(addon.predictChar('a')).toBe(false); + expect(addon.predictChar('b')).toBe(false); + addon.reconcile(); // public/manual pass IS the caught-up contract + expect(addon.predictChar('c')).toBe(true); + }); + it('state getter reports outstanding/confirmedTotal/droppedTotal/anchor', async () => { expect(addon.state).toEqual({ outstanding: 0, confirmedTotal: 0, droppedTotal: 0, anchor: null }); addon.predictChar('a'); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 0315d53a..53cdd12a 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2563,8 +2563,10 @@ Object.assign(CodemanApp.prototype, { const kind = window.CodemanTerminalInput.classifyPredictInput(data); if (kind === 'char') this._predictiveEcho.predictChar(data); else if (kind === 'backspace') this._predictiveEcho.predictBackspace(); - else if (kind === 'clear') this._predictiveEcho.clearPredictions(); - // kind === 'text' (plain multi-char paste): wire only, no visual + // 'clear' AND 'text' (plain paste, IME word commits) both change the + // composer in ways the display has not shown yet: clear the run and let + // the addon's anchor hold suppress prediction until the echo catches up + else this._predictiveEcho.clearPredictions(); } catch { /* predictions must never block the wire */ } @@ -2577,6 +2579,8 @@ Object.assign(CodemanApp.prototype, { _crashDiag.log(`CJK send DROP no-session len=${text.length}`); return; } + // Bypasses onData (like insertTerminalText): predictions cannot see this + if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions(); _crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`); this._sendInputAsync(this.activeSessionId, text); }, diff --git a/test/local-echo-codex-gating.test.ts b/test/local-echo-codex-gating.test.ts index 9817912e..e3570159 100644 --- a/test/local-echo-codex-gating.test.ts +++ b/test/local-echo-codex-gating.test.ts @@ -411,11 +411,13 @@ describe('_predictHookOnData (wire neutrality)', () => { expect(app._predictiveEcho!.clearPredictions).toHaveBeenCalled(); }); - it("kind 'text' (plain paste) takes no visual action", () => { + it("kind 'text' (plain paste, IME commit) clears the run like 'clear'", () => { + // Review finding: an IME word-commit changes the composer without a + // prediction; new predictions after it would mis-anchor until cascade. const app = makePredictApp(); app._predictHookOnData('pasted text'); expect(app._predictiveEcho!.predictChar).not.toHaveBeenCalled(); - expect(app._predictiveEcho!.clearPredictions).not.toHaveBeenCalled(); + expect(app._predictiveEcho!.clearPredictions).toHaveBeenCalled(); }); it('never touches _pendingInput and never sends (visual-only pin)', () => { From 04de943b7f6cf0a6236a55dbc17bd9177eeeb2e0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 05:30:07 +0200 Subject: [PATCH 8/9] chore(predictive-echo): changeset notes cover the anchor-hold review fix Co-Authored-By: Claude Fable 5 --- .changeset/c4baa514.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/c4baa514.md b/.changeset/c4baa514.md index f07e65cc..9d7bffbe 100644 --- a/.changeset/c4baa514.md +++ b/.changeset/c4baa514.md @@ -11,4 +11,5 @@ Codex's per-keystroke composer forced 1.12.2 to disable the local-echo overlay ( - Predictions paint only while the cursor sits on the measured Codex composer row (`/^› /`, codex-cli 0.147): trust/approval modals and wrapped continuation rows get no ghosts, deliberately falling back to real echo. - Ships as a SEPARATE `vendor/xterm-predictive-echo.js` bundle: the existing zerolag bundle is byte-identical (sha256-verified), and a missing or broken bundle degrades Codex to exact 1.12.2 behavior. The per-device `localEchoEnabled` toggle is the kill switch. - Claude/Gemini/OpenCode/Antigravity keep buffer mode untouched; shell stays off. -- Tests: 52 new package tests including replay suites driven by fixtures recorded from a real codex TUI through the production tmux+strip pipeline (`scripts/dev/record-codex-frames.mjs`) and a 500-iteration seeded fuzz; new vm policy/wire-neutrality suites; a 10-scenario Playwright E2E against real codex covering the #218/#219/#220/#222 retests, byte-identity, and a simulated 300ms-RTT run. The package test suite now runs in CI. +- A post-build adversarial review added the anchor-hold rule: after an unpredicted wire edit (backspace into echoed text, cleared input, IME text commits) new predictions hold until the next parsed write, so a stale displayed cursor can never mis-anchor a run. +- Tests: 55 new package tests including replay suites driven by fixtures recorded from a real codex TUI through the production tmux+strip pipeline (`scripts/dev/record-codex-frames.mjs`) and a 500-iteration seeded fuzz; new vm policy/wire-neutrality suites; a 10-scenario Playwright E2E against real codex covering the #218/#219/#220/#222 retests, byte-identity, and a simulated 300ms-RTT run. The package test suite now runs in CI. From b191f3c2c68dada437129f3982b235675cc22894 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 11:35:08 +0200 Subject: [PATCH 9/9] test(predictive-echo): real-auth streaming fixture pins baseY growth With a real codex login now available, record the one shape the fake-key lab could never produce: a genuine model reply streaming above the pinned composer, pushing lines into history (baseY grows) while keystrokes land mid-stream. The recorder gains an opt-in CODEX_RECORD_REAL=1 scenario using the user's own ~/.codex (fixture secret-scanned for key/JWT material before writing; scanned clean). The replay test pins: baseY > 0, mid-stream predictions painted, exact convergence to the typed text. Co-Authored-By: Claude Fable 5 --- .../test/codex-replay.test.ts | 16 ++ .../test/fixtures/codex/streaming-real.jsonl | 154 ++++++++++++++++++ .../test/replay-helpers.ts | 2 + scripts/dev/record-codex-frames.mjs | 40 ++++- 4 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 packages/xterm-zerolag-input/test/fixtures/codex/streaming-real.jsonl diff --git a/packages/xterm-zerolag-input/test/codex-replay.test.ts b/packages/xterm-zerolag-input/test/codex-replay.test.ts index 5d5298e4..4ec3e502 100644 --- a/packages/xterm-zerolag-input/test/codex-replay.test.ts +++ b/packages/xterm-zerolag-input/test/codex-replay.test.ts @@ -143,6 +143,22 @@ describe('codex replay', () => { rt.cleanup(); }, 15000); + it('streaming-real: mid-stream typing survives real baseY growth (recorded with real auth)', async () => { + // The one shape the fake-key lab cannot produce: a genuine model reply + // streaming above the pinned composer pushes lines into history, so + // baseY GROWS while predictions are outstanding: the no-drop-on-baseY + // rule against reality instead of a synthetic scroll. + const { rt, addon, events } = await replay('streaming-real'); + expect(rt.term.buffer.active.baseY).toBeGreaterThan(0); // history really grew + const midStream = events.filter((e) => e.kind === 'char' && ['a', 'b', 'c'].includes(e.key)); + expect(midStream.length).toBe(3); + expect(midStream.some((e) => e.painted)).toBe(true); // predictions ran mid-stream + await converge(rt, addon); + expect(rt.cursorRowText()).toBe('› abc'); // the mid-stream chars landed intact + 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~'))!; diff --git a/packages/xterm-zerolag-input/test/fixtures/codex/streaming-real.jsonl b/packages/xterm-zerolag-input/test/fixtures/codex/streaming-real.jsonl new file mode 100644 index 00000000..469d48c7 --- /dev/null +++ b/packages/xterm-zerolag-input/test/fixtures/codex/streaming-real.jsonl @@ -0,0 +1,154 @@ +{"scenario":"streaming-real","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T09:31:57.351Z"} +{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"} +{"delayMs":1,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"} +{"delayMs":35,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b(B\u001b[m$ "} +{"delayMs":647,"data":"exec codex\r\n"} +{"delayMs":479,"data":"\u001b[30d\n\u001b[K\u001b[2d\u001b[J\u001b[H\u001b[K\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":2,"data":">\u001b[C\u001b[1mYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[3;3H\u001b[33mNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[4;3H/home/arkon/default/claudeman\u001b[6;3H\u001b[39mDo\u001b[Cyou\u001b[Ctrust\u001b[Cthe\u001b[Ccontents\u001b[Cof\u001b[Cthis\u001b[Cdirectory?\u001b[CWorking\u001b[Cwith\u001b[Cuntrusted\u001b[Ccontents\u001b[Ccomes\u001b[Cwith\u001b[Chigher\u001b[7;3Hrisk\u001b[Cof\u001b[Cprompt\u001b[Cinjection.\u001b[CTrusting\u001b[Cthe\u001b[Cdirectory\u001b[Callows\u001b[Cproject-local\u001b[Cconfig,\u001b[Chooks,\u001b[Cand\u001b[Cexec\u001b[8;3Hpolicies\u001b[Cto\u001b[Cload.\u001b[10;1H\u001b[36m› 1. Yes, continue\u001b[11;3H\u001b[39m2.\u001b[CNo,\u001b[Cquit\u001b[13;3H\u001b[2mPress enter to continue\u001b[?25l\u001b(B\u001b[m"} +{"delayMs":3830,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[13;26H\u001b[?25l"} +{"delayMs":1,"data":"\u001b[H>\u001b[1X\u001b[1m\u001b[CYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[K\r\n\u001b[K\u001b[3;2H\u001b[1K\u001b[33m\u001b[CNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[39m\u001b[K\u001b[4;2H\u001b[1K\u001b[33m\u001b[C/home/arkon/default/claudeman\u001b[39m\u001b[K\r\n\u001b[K\u001b[6;2H\u001b[1K\u001b[CDo\u001b[1X\u001b[Cyou\u001b[1X\u001b[Ctrust\u001b[1X\u001b[Cthe\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Cof\u001b[1X\u001b[Cthis\u001b[1X\u001b[Cdirectory?\u001b[1X\u001b[CWorking\u001b[1X\u001b[Cwith\u001b[1X\u001b[Cuntrusted\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Ccomes\u001b[1X\u001b[Cwith\u001b[1X\u001b[Chigher\u001b[K\u001b[7;2H\u001b[1K\u001b[Crisk\u001b[1X\u001b[Cof\u001b[1X\u001b[Cprompt\u001b[1X\u001b[Cinjection.\u001b[1X\u001b[CTrusting\u001b[1X\u001b[Cthe\u001b[1X\u001b[Cdirectory\u001b[1X\u001b[Callows\u001b[1X\u001b[Cproject-local\u001b[1X\u001b[Cconfig,\u001b[1X\u001b[Chooks,\u001b[1X\u001b[Cand\u001b[1X\u001b[Cexec\u001b[K\u001b[8;2H\u001b[1K\u001b[Cpolicies\u001b[1X\u001b[Cto\u001b[1X\u001b[Cload.\u001b[K\r\n\u001b[K\u001b[36m\r\n› 1. Yes, continue\u001b[39m\u001b[K\u001b[11;2H\u001b[1K\u001b[C2.\u001b[1X\u001b[CNo,\u001b[1X\u001b[Cquit\u001b[K\r\n\u001b[K\u001b[13;2H\u001b[1K\u001b[2m\u001b[CPress enter to continue\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[13;26H"} +{"keyAt":true,"data":"\r"} +{"delayMs":235,"data":"\u001b[2;1H\u001b[J\u001b[H\u001b[K"} +{"delayMs":0,"data":"\u001bM\u001bM\u001bM\r\n\u001b[33m⚠\u001b[39m\u001b[1;3r\u001b[3;1H\n\u001b[1;2H\u001b[33m Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\r\n\u001b[K\u001b[1;30r\u001b[3;1H"} +{"delayMs":1,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[5;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-SFpno1\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills t\u001b(B\u001b[m\u001b[2mo list available skills\u001b[15;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[13;3H\u001b[?12l\u001b[?25h\u001b(B\u001b[m"} +{"delayMs":10,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;39H\u001b[K\u001b[15;82H\u001b[K\u001b[13;3H"} +{"delayMs":12,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;39H\u001b[K\u001b[15;82H\u001b[K\u001b[13;3H"} +{"delayMs":208,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[5;1H\u001b[J\u001b[A\u001b[K\u001b[4;30r\u001b[4;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[5;1H"} +{"delayMs":0,"data":"\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n\u001b(B\u001b[m"} +{"delayMs":0,"data":"\u001b[2m│ model: \u001b(B\u001b[mgpt-5.6-terra\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-SFpno1\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[12;1H\u001b(B\u001b[m"} +{"delayMs":0,"data":" \u001b[1mTip:\u001b(B\u001b[m \u001b[3mNew\u001b(B\u001b[m For a limited time, Codex is included in your plan for free – let’s build together.\u001b[14;1H•\u001b[C\u001b[2mBooting MCP server: codex_apps\u001b[C(0s • esc to interrupt)\u001b[17;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[19;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[17;3H\u001b(B\u001b[m"} +{"delayMs":0,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":19,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":1,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":33,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":1,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":33,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":33,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":28,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":34,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[3AB\u001b[53C\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"} +{"delayMs":2,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":5,"data":"\u001b[14;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[17;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[15;3H\u001b(B\u001b[m"} +{"delayMs":279,"data":"\u001b[36C\u001b[K\u001b[17;82H\u001b[K\u001b[15;3H"} +{"delayMs":86,"data":"\u001b[36C\u001b[K\u001b[17;82H\u001b[K\u001b[15;3H"} +{"delayMs":71,"data":"\u001b[36C\u001b[K\u001b[17;82H\u001b[K\u001b[15;3H"} +{"keyAt":true,"data":"r"} +{"keyAt":true,"data":"e"} +{"keyAt":true,"data":"p"} +{"keyAt":true,"data":"l"} +{"keyAt":true,"data":"y"} +{"keyAt":true,"data":" "} +{"keyAt":true,"data":"w"} +{"keyAt":true,"data":"i"} +{"keyAt":true,"data":"t"} +{"keyAt":true,"data":"h"} +{"keyAt":true,"data":" "} +{"keyAt":true,"data":"t"} +{"keyAt":true,"data":"h"} +{"keyAt":true,"data":"e"} +{"keyAt":true,"data":" "} +{"keyAt":true,"data":"s"} +{"keyAt":true,"data":"i"} +{"keyAt":true,"data":"n"} +{"keyAt":true,"data":"g"} +{"keyAt":true,"data":"l"} +{"keyAt":true,"data":"e"} +{"keyAt":true,"data":" "} +{"keyAt":true,"data":"w"} +{"keyAt":true,"data":"o"} +{"keyAt":true,"data":"r"} +{"keyAt":true,"data":"d"} +{"keyAt":true,"data":" "} +{"keyAt":true,"data":"h"} +{"keyAt":true,"data":"e"} +{"keyAt":true,"data":"l"} +{"keyAt":true,"data":"l"} +{"keyAt":true,"data":"o"} +{"delayMs":2010,"data":"reply with the single word hello\u001b[K\u001b[17;82H\u001b[K\u001b[15;35H"} +{"keyAt":true,"data":"\r"} +{"delayMs":382,"data":"\u001b[13;30r\u001b[13;1H\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[15;1H"} +{"delayMs":0,"data":"\u001b[1m\u001b[2m› \u001b(B\u001b[mreply with the single word hello\r\n"} +{"delayMs":0,"data":"\u001b[19;3H\u001b[2mUse /skills to list available skills\u001b(B\u001b[m\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"} +{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"} +{"delayMs":20,"data":"\u001b[36C\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"} +{"delayMs":8,"data":"\u001b[36C\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"} +{"delayMs":78,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\r\n•\u001b[C\u001b[2mWor\u001b(B\u001b[mk\u001b[1ming\u001b[C\u001b(B\u001b[m\u001b[2m(0s • esc to interrupt)\u001b[21;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[23;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[21;3H\u001b(B\u001b[m"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;6H\u001b[2mk\u001b(B\u001b[mi\u001b[26C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":35,"data":"\u001b[18;7H\u001b[2mi\u001b(B\u001b[mn\u001b[25C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;8H\u001b[2mn\u001b(B\u001b[mg\u001b[24C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;9H\u001b[2mg\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[18;1H\u001b[2m◦\u001b[21;3H\u001b(B\u001b[m"} +{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":32,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;12H\u001b[2m1\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":0,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":32,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[18;1H•\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":34,"data":"\u001b[3AW\u001b[30C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[3A\u001b[1mW\u001b(B\u001b[mo\u001b[29C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;4H\u001b[1mo\u001b(B\u001b[mr\u001b[28C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;5H\u001b[1mr\u001b(B\u001b[mk\u001b[27C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":33,"data":"\u001b[18;6H\u001b[1mk\u001b(B\u001b[mi\u001b[26C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":32,"data":"\u001b[18;1H\u001b[J\u001b[A\u001b[K"} +{"delayMs":0,"data":"\u001b[17;30r\u001b[17;1H\u001bM\u001bM\u001b[1;30r\u001b[18;1H"} +{"delayMs":0,"data":"\u001b[2m• \u001b(B\u001b[mhello\u001b[21;1H\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[23;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[21;3H\u001b(B\u001b[m"} +{"delayMs":25,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"delayMs":6,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"} +{"delayMs":3,"data":"\u001b[36C\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"} +{"keyAt":true,"data":"a"} +{"delayMs":2252,"data":"a\u001b[K\u001b[23;82H\u001b[K\u001b[21;4H"} +{"keyAt":true,"data":"b"} +{"delayMs":121,"data":"b\u001b[K\u001b[23;82H\u001b[K\u001b[21;5H"} +{"keyAt":true,"data":"c"} +{"delayMs":121,"data":"c\u001b[K\u001b[23;82H\u001b[K\u001b[21;6H"} diff --git a/packages/xterm-zerolag-input/test/replay-helpers.ts b/packages/xterm-zerolag-input/test/replay-helpers.ts index 542f3788..6c297839 100644 --- a/packages/xterm-zerolag-input/test/replay-helpers.ts +++ b/packages/xterm-zerolag-input/test/replay-helpers.ts @@ -14,6 +14,7 @@ import type { XtermTerminal } from '../src/types.js'; 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 streamingReal from './fixtures/codex/streaming-real.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'; @@ -22,6 +23,7 @@ const FIXTURES: Record = { 'paste-bracketed': pasteBracketed, 'slash-picker': slashPicker, 'streaming-burst': streamingBurst, + 'streaming-real': streamingReal, 'trust-modal': trustModal, 'type-hello': typeHello, wrap, diff --git a/scripts/dev/record-codex-frames.mjs b/scripts/dev/record-codex-frames.mjs index 76feff38..9db5aa16 100644 --- a/scripts/dev/record-codex-frames.mjs +++ b/scripts/dev/record-codex-frames.mjs @@ -86,6 +86,25 @@ const SCENARIOS = { { waitMs: 400, keys: '\x1b[200~XYZpasted\x1b[201~' }, { waitMs: 1500, keys: '' }, ], + // REAL-AUTH streaming (CODEX_RECORD_REAL=1 only): a genuine model response + // streaming above the pinned composer while keystrokes land mid-stream. + // This is the one shape the fake-key lab can never produce: real output + // pushes lines to history (baseY grows), exercising the no-drop-on-baseY + // rule against reality. Uses the user's real ~/.codex; the fixture is + // secret-scanned (sk- / JWT prefixes) before it is written. + 'streaming-real': { + realAuth: true, + steps: [ + { waitMs: BOOT_WAIT_MS, keys: '\r' }, // trust dialog (untrusted workdir) + { waitMs: 2500, keys: '' }, + ...'reply with the single word hello'.split('').map((ch) => ({ waitMs: 15, keys: ch })), + { waitMs: 400, keys: '\r' }, + { waitMs: 4000, keys: 'a' }, // typed MID-STREAM + { waitMs: 120, keys: 'b' }, + { waitMs: 120, keys: 'c' }, + { waitMs: 14000, keys: '' }, + ], + }, // First-run trust dialog: the modal surface where typed chars must NOT be // predicted (the predictWhen ghost eliminator). Recorded UNTRUSTED so the // dialog actually appears; 'x' exercises typing at a non-composer cursor. @@ -104,15 +123,22 @@ async function record(scenario, outDir) { if (!spec) throw new Error(`unknown scenario ${scenario}`); const steps = Array.isArray(spec) ? spec : spec.steps; const trusted = Array.isArray(spec) ? true : (spec.trusted ?? true); + const realAuth = Array.isArray(spec) ? false : (spec.realAuth ?? false); + if (realAuth && process.env.CODEX_RECORD_REAL !== '1') { + console.log(`${scenario}: SKIPPED (needs CODEX_RECORD_REAL=1 and a real ~/.codex login)`); + return; + } mkdirSync(SCRATCH, { recursive: true }); const lab = mkdtempSync(join(SCRATCH, 'codexrec-')); - writeFileSync(join(lab, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: FAKE_KEY })); const workdir = mkdtempSync(join(SCRATCH, 'codexrec-work-')); - if (trusted) { - // Pre-trust the workdir so boot goes straight to the composer instead of - // the first-run trust dialog (which trust-modal records deliberately). - writeFileSync(join(lab, 'config.toml'), `[projects."${workdir}"]\ntrust_level = "trusted"\n`); + if (!realAuth) { + writeFileSync(join(lab, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: FAKE_KEY })); + if (trusted) { + // Pre-trust the workdir so boot goes straight to the composer instead of + // the first-run trust dialog (which trust-modal records deliberately). + writeFileSync(join(lab, 'config.toml'), `[projects."${workdir}"]\ntrust_level = "trusted"\n`); + } } const sock = `codexrec-${process.pid}`; const codexVersion = execSync('codex --version', { encoding: 'utf8' }).trim(); @@ -129,7 +155,7 @@ async function record(scenario, outDir) { cols: COLS, rows: ROWS, cwd: workdir, - env: { ...process.env, CODEX_HOME: lab, SHELL: '/bin/bash' }, + env: realAuth ? { ...process.env, SHELL: '/bin/bash' } : { ...process.env, CODEX_HOME: lab, SHELL: '/bin/bash' }, } ); proc.onData((data) => { @@ -166,6 +192,8 @@ async function record(scenario, outDir) { const allBytes = lines.map((l) => l.data).join(''); if (allBytes.includes(FAKE_KEY)) throw new Error(`fixture ${scenario} leaked the fake key; NOT writing`); if (allBytes.includes(lab)) throw new Error(`fixture ${scenario} leaked the lab path; NOT writing`); + if (realAuth && /sk-[A-Za-z0-9_-]{8}|eyJ[A-Za-z0-9_-]{20}/.test(allBytes)) + throw new Error(`fixture ${scenario} may contain credential material; NOT writing`); mkdirSync(outDir, { recursive: true }); const meta = { scenario, cols: COLS, rows: ROWS, codexVersion, recordedAt: new Date().toISOString() };