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:
Codeman maintainer
2026-08-09 04:33:09 +02:00
parent 6c55ce3f8d
commit 5bde897752
6 changed files with 390 additions and 4 deletions
+10
View File
@@ -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.
+3
View File
@@ -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' },
+93 -2
View File
@@ -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 {}
+3
View File
@@ -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