Files
Codeman/test/local-echo-codex-gating.test.ts
T
Codeman maintainer c5b59633d8 feat(pi): add Pi (pi.dev) as a sixth CLI run mode (#206)
SessionMode gains 'pi', a first-class backend alongside Claude Code,
OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose
tab identity, welcome button, run-mode entry, cron agentType, Docker and
remote-SSH command defaults, and clone-repo Brain option.

Pi is a different shape of CLI from the other four, and three decisions
follow from that:

- It has NO permission prompts and no sandbox, so there is no
  --dangerously-skip-permissions analog and none was invented. The
  privilege-shaped knob is the tri-state approveProjectTrust, which makes
  pi load and EXECUTE repo-local .pi/extensions TypeScript and install
  missing project packages. clampExternalCliBypassForOwner() therefore
  puts pi in the MATERIALIZE branch: a non-granted multi-user owner gets
  --no-approve even when no config was sent, because pi's own default is
  a prompt the session user could answer themselves. That helper had zero
  test coverage; it now has coverage for all four CLIs.
- Only the PI_ prefix joins the env allowlist. Pi's ~34 provider key vars
  share no prefix and ALLOWED_ENV_PREFIXES is one global list with no mode
  context, so admitting them would widen the allowlist for every mode at
  once. Auth goes through pi's /login or the server's own environment.
  --api-key is deliberately never wired: it would put a provider secret on
  the spawn command line.
- pi stays OUT of isAltScreenStripMode(). Its default TUI renders into the
  main screen with terminal-owned scrollback, and its 0.84.0 fullscreen
  mode is runtime-switchable via /settings; that flip was measured to put
  the pane into the alt screen, which the strip would have corrupted.

pi-cli-resolver.ts additionally sanity-probes `pi --version` and requires
semver-shaped output, because `pi` is a short generic name a stray binary
can shadow; GET /api/pi/status surfaces path and version so a
misresolution is diagnosable rather than presenting as a broken mode.

Docker installs pi in its own --ignore-scripts step so that flag cannot
affect the other four CLIs, and seeds its credentials per-file rather than
whole-dir (~/.pi/agent also holds sessions, extensions and package trees).

Verified end to end against pi 0.84.1 on an isolated instance: resolver
search-dir fallback, flag construction, piConfig persistence across a full
server restart, the trust prompt and its --no-approve suppression, the
rose Run button on the default daylight-blue skin (the nested skin block
eats per-mode gradients unless the rule lives inside it), and the buffer
local-echo policy, which pi tolerates where codex did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 13:54:47 +02:00

473 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Local-echo gating and input-ordering helpers for codex
* sessions (issues #218/#219/#220/#222).
*
* Codex's composer is interactive per keystroke: typing "/" pops a
* live-filtering command picker (#222), the composer grows as it wraps
* (#220), pastes arrive bracketed (#219) and arrows edit server-side state
* (#218). The buffer-until-Enter local echo overlay starves all of that, so
* codex-mode sessions must use plain PTY echo like shell. The shared overlay
* branch (claude/gemini/opencode) additionally flushes typed-but-unsent text
* before forwarding bracketed pastes and composer nav keys, and hands the
* session to pass-through after a nav key.
*
* Loaded via `vm` with a stubbed context (no jsdom), mirroring
* test/input-send-order.test.ts. End-to-end behavior was verified against a
* real codex 0.147.0 TUI in tmux through a headless browser.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
type OverlayStub = {
pendingText: string;
cleared: number;
suppressed: number;
prompts: unknown[];
clear(): void;
suppressBufferDetection(): void;
setPrompt(p: unknown): void;
appendText: ReturnType<typeof vi.fn>;
};
type AppInstance = {
activeSessionId: string | null;
sessions: Map<string, { mode: string }>;
terminal?: { focus: () => void };
_localEchoEnabled?: boolean;
_localEchoOverlay?: OverlayStub;
_pendingInput: string;
_flushedOffsets?: Map<string, number>;
_flushedTexts?: Map<string, string>;
_echoPassthroughSessions?: Set<string>;
loadAppSettingsFromStorage: () => Record<string, unknown>;
sendInput: ReturnType<typeof vi.fn>;
_updateLocalEchoState(): void;
_flushLocalEchoPending(): void;
insertTerminalText(text: string): void;
};
function loadContext() {
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
localStorage: {
length: 0,
key: vi.fn(),
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
window: windowStub,
MobileDetection: {
isTouchDevice: () => true,
isHandheldDevice: () => false,
getDeviceType: () => 'desktop',
},
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
return {
CodemanApp,
terminalInput: (windowStub as { CodemanTerminalInput?: Record<string, unknown> }).CodemanTerminalInput!,
};
}
const { CodemanApp, terminalInput } = loadContext();
const isComposerNavKey = terminalInput.isComposerNavKey as (data: string) => boolean;
function makeOverlay(pending = ''): OverlayStub {
return {
pendingText: pending,
cleared: 0,
suppressed: 0,
prompts: [],
clear() {
this.cleared++;
this.pendingText = '';
},
suppressBufferDetection() {
this.suppressed++;
},
setPrompt(p: unknown) {
this.prompts.push(p);
},
appendText: vi.fn(),
};
}
function makeApp(mode: string, overlay = makeOverlay()): AppInstance {
const app = Object.create(CodemanApp.prototype) as AppInstance;
app.activeSessionId = 's1';
app.sessions = new Map([['s1', { mode }]]);
app._localEchoOverlay = overlay;
app._pendingInput = '';
app._flushedOffsets = new Map([['s1', 3]]);
app._flushedTexts = new Map([['s1', 'abc']]);
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: true });
app.sendInput = vi.fn().mockResolvedValue(undefined);
return app;
}
describe('CodemanTerminalInput.isComposerNavKey', () => {
it.each([
'\x1b[A',
'\x1b[B',
'\x1b[C',
'\x1b[D',
'\x1b[H',
'\x1b[F',
'\x1bOA',
'\x1bOD',
'\x1bOH',
'\x1bOF',
'\x1b[1;5C', // Ctrl+Right
'\x1b[1;2A', // Shift+Up
'\x1b[3~', // Delete
'\x1b[3;5~', // Ctrl+Delete
'\x1b[5~', // PgUp
'\x1b[6~', // PgDn
'\x1b[1~', // Home variant
'\x1b[4~', // End variant
])('classifies %j as a composer nav key', (seq) => {
expect(isComposerNavKey(seq)).toBe(true);
});
it.each([
'\x1b[?1;2c', // DA1 response
'\x1b[>0;276;0c', // DA2 response
'\x1b[12;34R', // CPR response
'\x1b[1;3R', // CPR response (small coords)
'\x1b[0n', // DSR response
'\x1b[15~', // F5 (function keys stay out)
'\x1b[200~hi\x1b[201~', // bracketed paste
'\x1b[?u', // kitty keyboard query response
'\x1bOP', // F1
'\x1b',
'a',
'abc',
'\r',
])('does NOT classify %j as a composer nav key', (seq) => {
expect(isComposerNavKey(seq)).toBe(false);
});
it('exports the bracketed paste prefix xterm puts on terminal.paste()', () => {
expect(terminalInput.BRACKETED_PASTE_START).toBe('\x1b[200~');
});
});
describe('_updateLocalEchoState mode gating', () => {
it('disables the overlay for codex sessions even with the setting ON (issues #218/#219/#220/#222)', () => {
const overlay = makeOverlay('pending');
const app = makeApp('codex', overlay);
app._updateLocalEchoState();
expect(app._localEchoEnabled).toBe(false);
expect(overlay.cleared).toBeGreaterThan(0);
});
it('disables the overlay for shell sessions (PTY provides its own echo)', () => {
const app = makeApp('shell');
app._updateLocalEchoState();
expect(app._localEchoEnabled).toBe(false);
});
it.each(['claude', 'gemini', 'opencode', 'pi'])('keeps the overlay enabled for %s sessions', (mode) => {
const overlay = makeOverlay();
const app = makeApp(mode, overlay);
app._updateLocalEchoState();
expect(app._localEchoEnabled).toBe(true);
expect(overlay.prompts.length).toBeGreaterThan(0);
});
});
describe('_flushLocalEchoPending', () => {
it('moves pending text into _pendingInput and resets overlay + flushed tracking', () => {
const overlay = makeOverlay('hello');
const app = makeApp('claude', overlay);
app._flushLocalEchoPending();
expect(app._pendingInput).toBe('hello');
expect(overlay.cleared).toBe(1);
expect(overlay.suppressed).toBe(1);
expect(app._flushedOffsets!.has('s1')).toBe(false);
expect(app._flushedTexts!.has('s1')).toBe(false);
});
it('appends nothing when the overlay is empty', () => {
const app = makeApp('claude', makeOverlay(''));
app._flushLocalEchoPending();
expect(app._pendingInput).toBe('');
});
});
describe('insertTerminalText pass-through routing', () => {
it('appends to the overlay while local echo is buffering', () => {
const overlay = makeOverlay();
const app = makeApp('claude', overlay);
app._localEchoEnabled = true;
app.insertTerminalText('path.txt');
expect(overlay.appendText).toHaveBeenCalledWith('path.txt');
expect(app.sendInput).not.toHaveBeenCalled();
});
it('sends directly while the session is in nav-key pass-through', () => {
const overlay = makeOverlay();
const app = makeApp('claude', overlay);
app._localEchoEnabled = true;
app._echoPassthroughSessions = new Set(['s1']);
app.insertTerminalText('path.txt');
expect(app.sendInput).toHaveBeenCalledWith('path.txt');
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', 'pi'])(
"%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, 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).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();
});
});