mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -699,6 +699,10 @@ class CodemanApp {
|
|||||||
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
||||||
this._localEchoOverlay = null; // created after terminal.open()
|
this._localEchoOverlay = null; // created after terminal.open()
|
||||||
this._localEchoEnabled = false; // true when setting on + session active
|
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
|
this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps
|
||||||
|
|
||||||
// Accessibility: Focus trap for modals
|
// Accessibility: Focus trap for modals
|
||||||
@@ -3021,6 +3025,9 @@ class CodemanApp {
|
|||||||
// terminal buffer reloads and prompt is visible again. _render() re-scans
|
// terminal buffer reloads and prompt is visible again. _render() re-scans
|
||||||
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
|
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
|
||||||
this._localEchoOverlay?.rerender();
|
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
|
// Clear pending hooks
|
||||||
this.pendingHooks.clear();
|
this.pendingHooks.clear();
|
||||||
// Clear parent name cache (prevents stale session name entries accumulating)
|
// Clear parent name cache (prevents stale session name entries accumulating)
|
||||||
@@ -4127,6 +4134,9 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._localEchoOverlay?.clear();
|
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
|
// Prevent _detectBufferText() from picking up Claude's Ink UI text
|
||||||
// (status bar, model info, etc.) as "user input" on fresh sessions.
|
// (status bar, model info, etc.) as "user input" on fresh sessions.
|
||||||
// Only sessions with prior flushed text (from tab-switch-away) need detection.
|
// Only sessions with prior flushed text (from tab-switch-away) need detection.
|
||||||
|
|||||||
@@ -601,6 +601,9 @@ const KeyboardAccessoryBar = {
|
|||||||
* must be written raw to be interpreted as key presses by Ink. */
|
* must be written raw to be interpreted as key presses by Ink. */
|
||||||
sendKey(escapeSequence) {
|
sendKey(escapeSequence) {
|
||||||
if (!app.activeSessionId) return;
|
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`, {
|
fetch(`/api/sessions/${app.activeSessionId}/input`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -62,6 +62,42 @@
|
|||||||
return COMPOSER_NAV_KEY_PATTERN.test(data);
|
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) {
|
function isTerminalQueryResponse(data) {
|
||||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||||
}
|
}
|
||||||
@@ -99,6 +135,9 @@
|
|||||||
isTerminalQueryResponse,
|
isTerminalQueryResponse,
|
||||||
shouldSuppressTerminalQueryResponse,
|
shouldSuppressTerminalQueryResponse,
|
||||||
isComposerNavKey,
|
isComposerNavKey,
|
||||||
|
classifyPredictInput,
|
||||||
|
isCodexComposerRow,
|
||||||
|
CODEX_COMPOSER_ROW_RE,
|
||||||
BRACKETED_PASTE_START,
|
BRACKETED_PASTE_START,
|
||||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||||
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
|
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
|
||||||
@@ -399,6 +438,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
|
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()) {
|
if (MobileDetection.isTouchDevice()) {
|
||||||
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
|
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
|
||||||
this.terminal.onRender(() => 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) ──
|
// ── Normal Mode (echo disabled) ──
|
||||||
this._pendingInput += data;
|
this._pendingInput += data;
|
||||||
|
|
||||||
@@ -2450,8 +2502,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// grows and rewraps as it fills (#220), pastes are bracketed (#219)
|
// grows and rewraps as it fills (#220), pastes are bracketed (#219)
|
||||||
// and arrows/history edit server-side state (#218). Buffering
|
// and arrows/history edit server-side state (#218). Buffering
|
||||||
// keystrokes until Enter starves all of that, so codex sessions use
|
// keystrokes until Enter starves all of that, so codex sessions use
|
||||||
// plain PTY echo like shell.
|
// plain PTY echo like shell — visually augmented by the predictive
|
||||||
// Disable it by clearing any pending text.
|
// write-through echo (see _localEchoPolicy below and the onData hook).
|
||||||
|
// Disable the buffer overlay by clearing any pending text.
|
||||||
this._localEchoOverlay.clear();
|
this._localEchoOverlay.clear();
|
||||||
this._localEchoEnabled = false;
|
this._localEchoEnabled = false;
|
||||||
} else {
|
} 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
|
// 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. */
|
/** Insert editable text at the active prompt without pressing Enter. */
|
||||||
insertTerminalText(text) {
|
insertTerminalText(text) {
|
||||||
if (!this.activeSessionId || !text) return;
|
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 (
|
if (
|
||||||
this._localEchoEnabled &&
|
this._localEchoEnabled &&
|
||||||
this._localEchoOverlay &&
|
this._localEchoOverlay &&
|
||||||
@@ -2873,6 +2960,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._inputFlushTimeout = null;
|
this._inputFlushTimeout = null;
|
||||||
}
|
}
|
||||||
this._pendingInput = '';
|
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) {
|
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||||
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
||||||
@@ -3379,6 +3468,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
localStorage.setItem('codeman-font-size', size);
|
localStorage.setItem('codeman-font-size', size);
|
||||||
// Update overlay font cache and re-render at new cell dimensions
|
// Update overlay font cache and re-render at new cell dimensions
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
|
this._predictiveEcho?.refreshFont();
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFontSize() {
|
loadFontSize() {
|
||||||
@@ -3513,6 +3603,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Refresh it on live skin changes so typed text never keeps the prior
|
// Refresh it on live skin changes so typed text never keeps the prior
|
||||||
// theme's dark backing surface or foreground color.
|
// theme's dark backing surface or foreground color.
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
|
this._predictiveEcho?.refreshFont();
|
||||||
try {
|
try {
|
||||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -612,6 +612,9 @@ const VoiceInput = {
|
|||||||
if (text) app.sendInput(text).catch(() => {});
|
if (text) app.sendInput(text).catch(() => {});
|
||||||
setTimeout(() => app.sendInput('\r').catch(() => {}), 80);
|
setTimeout(() => app.sendInput('\r').catch(() => {}), 80);
|
||||||
} else {
|
} 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(() => {});
|
app.sendInput('\r').catch(() => {});
|
||||||
}
|
}
|
||||||
// Blink then restore
|
// Blink then restore
|
||||||
|
|||||||
@@ -238,3 +238,230 @@ describe('insertTerminalText pass-through routing', () => {
|
|||||||
expect(overlay.appendText).not.toHaveBeenCalled();
|
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Predictive write-through echo (codex) ──────────────────────────────────
|
||||||
|
|
||||||
|
type PredictorStub = {
|
||||||
|
predictChar: ReturnType<typeof vi.fn>;
|
||||||
|
predictBackspace: ReturnType<typeof vi.fn>;
|
||||||
|
clearPredictions: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -841,7 +841,7 @@ describe('Virtual Keyboard', () => {
|
|||||||
app.activeSessionId = 'mobile-visible-input-test';
|
app.activeSessionId = 'mobile-visible-input-test';
|
||||||
app.sessions.set('mobile-visible-input-test', {
|
app.sessions.set('mobile-visible-input-test', {
|
||||||
id: 'mobile-visible-input-test',
|
id: 'mobile-visible-input-test',
|
||||||
mode: 'codex',
|
mode: 'claude',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
});
|
});
|
||||||
app.hideWelcome();
|
app.hideWelcome();
|
||||||
@@ -887,7 +887,7 @@ describe('Virtual Keyboard', () => {
|
|||||||
app.activeSessionId = 'mobile-cursor-fallback-test';
|
app.activeSessionId = 'mobile-cursor-fallback-test';
|
||||||
app.sessions.set('mobile-cursor-fallback-test', {
|
app.sessions.set('mobile-cursor-fallback-test', {
|
||||||
id: 'mobile-cursor-fallback-test',
|
id: 'mobile-cursor-fallback-test',
|
||||||
mode: 'codex',
|
mode: 'claude',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
});
|
});
|
||||||
app.hideWelcome();
|
app.hideWelcome();
|
||||||
@@ -915,6 +915,58 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(state.overlayState?.visible).toBe(true);
|
expect(state.overlayState?.visible).toBe(true);
|
||||||
expect(state.overlayState?.promptPosition).not.toBeNull();
|
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<void>((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 ────────────────────────────────────
|
// ── Cross-device keyboard behavior ────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user