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)', () => {