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 ────────────────────────────────────