mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
The two keys #408 adds to the mobile keyboard accessory bar send Shift+Left and Shift+Right, which are Codex bindings (edit the last queued message, step back through the prompt stack). They shipped on both agent layouts, so a claude, pi, grok, omp, deepseek or gemini session got two keys that do nothing. That was not only cosmetic: a tap goes through sendNavKey(), which adds the session to _echoPassthroughSessions and hands editing to plain PTY echo until Enter or Ctrl+C, so on a phone a dead key also switched off the local echo that makes typing feel instant there. The reveal now follows the shape the 🧠 key already uses. The buttons stay in both templates, carry an accessory-btn-codex marker class, and are display:none in styles.css until the bar element carries codex-enabled. The class has to live on the bar rather than on the keys because setMode() rebuilds the buttons' innerHTML on every layout switch. syncCodexKeys() toggles it from the active session's mode (the same lookup _isShellSession() uses) and is called at init and from refreshForActiveSession(), which selectSession() already invokes on every switch. A session's mode is readonly on the server and fixed at create, so no other event can change the answer; the welcome screen (no active session) reads as not codex and hides the keys. The frontend id-branching guard (test/cli-registry-no-id-branching.test.ts) scans only src/**/*.ts, so the mode comparison in a public JS file is in bounds, the same as the existing shell check beside it. Tests: the new describe block in test/mobile-shell-keyboard.test.ts pins the marker class in both templates, the CSS pair, the class for a codex session in both layouts, its absence for claude/shell/pi/omp/deepseek, the re-sync in both directions on a session switch, the no-session case, and the init + refresh wiring. All six positive assertions fail without the source change. README and the changeset now say the keys are Codex-only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
743 lines
28 KiB
TypeScript
743 lines
28 KiB
TypeScript
/**
|
|
* @fileoverview Shell-specific mobile keyboard bar and its one-shot Ctrl
|
|
* modifier (issue #262).
|
|
*
|
|
* The bar is a `const` singleton in a non-module script, so it is loaded with
|
|
* `vm` against a small fake DOM (no jsdom in this repo), the same approach as
|
|
* test/path-picker-ui.test.ts. What matters here is the state machine: which
|
|
* layout a session gets, when the modifier arms, what byte a keystroke turns
|
|
* into, and every path that must disarm it. Behavior against a real shell
|
|
* (Ctrl+C reaching the PTY) is covered in test/mobile/keyboard.test.ts.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
|
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
|
|
|
type TerminalInput = { isTerminalFocusOrMouseReport(data: string): boolean };
|
|
type TerminalModule = {
|
|
terminalInput: TerminalInput;
|
|
CodemanApp: { prototype: Record<string, (...args: never[]) => unknown> };
|
|
bar: Bar;
|
|
};
|
|
let terminalModule: TerminalModule | null = null;
|
|
|
|
/**
|
|
* terminal-ui.js in a vm, with the REAL accessory bar in the same script scope
|
|
* (it is a `const` singleton, so only a shared scope makes the bare
|
|
* `KeyboardAccessoryBar` reference in the CJK path resolve). Its IIFE only
|
|
* needs a window to hang `CodemanTerminalInput` on, but the rest of the file
|
|
* assigns to CodemanApp.prototype at top level, so constants.js + app.js load
|
|
* first — the same recipe as test/local-echo-codex-gating.test.ts.
|
|
*/
|
|
function loadTerminalModule(): TerminalModule {
|
|
if (terminalModule) return terminalModule;
|
|
const read = (file: string) => readFileSync(resolve(`src/web/public/${file}`), 'utf8');
|
|
const windowStub: Record<string, unknown> = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
|
const context = vm.createContext({
|
|
console,
|
|
setInterval: vi.fn(),
|
|
clearInterval: vi.fn(),
|
|
setTimeout,
|
|
clearTimeout,
|
|
requestAnimationFrame: vi.fn(),
|
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
|
WebSocket: { OPEN: 1 },
|
|
fetch: vi.fn(),
|
|
URLSearchParams,
|
|
document: { addEventListener: vi.fn(), documentElement: { dataset: {} }, getElementById: () => null },
|
|
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${keyboardSource}\n${read('app.js')}\n${terminalSource}\n` +
|
|
`globalThis.__CodemanApp = CodemanApp; globalThis.__bar = KeyboardAccessoryBar;`,
|
|
context
|
|
);
|
|
const exported = context as unknown as { __CodemanApp: TerminalModule['CodemanApp']; __bar: Bar };
|
|
terminalModule = {
|
|
terminalInput: (windowStub as { CodemanTerminalInput?: TerminalInput }).CodemanTerminalInput!,
|
|
CodemanApp: exported.__CodemanApp,
|
|
bar: exported.__bar,
|
|
};
|
|
return terminalModule;
|
|
}
|
|
|
|
function loadTerminalInput(): TerminalInput {
|
|
return loadTerminalModule().terminalInput;
|
|
}
|
|
|
|
type FakeButton = {
|
|
dataset: { action: string };
|
|
classList: { has: Set<string>; toggle(name: string, on: boolean): void; contains(name: string): boolean };
|
|
attrs: Record<string, string>;
|
|
setAttribute(name: string, value: string): void;
|
|
};
|
|
|
|
function fakeButton(action: string): FakeButton {
|
|
const has = new Set<string>();
|
|
return {
|
|
dataset: { action },
|
|
classList: {
|
|
has,
|
|
toggle(name: string, on: boolean) {
|
|
if (on) has.add(name);
|
|
else has.delete(name);
|
|
},
|
|
contains: (name: string) => has.has(name),
|
|
},
|
|
attrs: {},
|
|
setAttribute(name: string, value: string) {
|
|
this.attrs[name] = value;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Fake bar element: tracks the button set parsed out of the assigned HTML. */
|
|
function fakeBarElement() {
|
|
let html = '';
|
|
let buttons = new Map<string, FakeButton>();
|
|
const classes = new Set<string>();
|
|
return {
|
|
className: '',
|
|
classList: {
|
|
add: (name: string) => classes.add(name),
|
|
remove: (name: string) => classes.delete(name),
|
|
contains: (name: string) => classes.has(name),
|
|
// init() calls syncReadMyMind(), which toggles the 🧠 marker class on the
|
|
// bar with an explicit force argument.
|
|
toggle: (name: string, force?: boolean) => {
|
|
const on = force === undefined ? !classes.has(name) : force;
|
|
if (on) classes.add(name);
|
|
else classes.delete(name);
|
|
return on;
|
|
},
|
|
},
|
|
get innerHTML() {
|
|
return html;
|
|
},
|
|
set innerHTML(next: string) {
|
|
html = next;
|
|
buttons = new Map();
|
|
for (const match of next.matchAll(/data-action="([^"]+)"/g)) {
|
|
buttons.set(match[1], fakeButton(match[1]));
|
|
}
|
|
},
|
|
get actions() {
|
|
return [...buttons.keys()];
|
|
},
|
|
querySelector(selector: string) {
|
|
const match = /\[data-action="([^"]+)"\]/.exec(selector);
|
|
return match ? (buttons.get(match[1]) ?? null) : null;
|
|
},
|
|
addEventListener: vi.fn(),
|
|
};
|
|
}
|
|
|
|
type Bar = {
|
|
element: ReturnType<typeof fakeBarElement>;
|
|
_mode: string;
|
|
init(): void;
|
|
setMode(mode: string): void;
|
|
refreshForActiveSession(): void;
|
|
handleAction(action: string, btn?: unknown): void;
|
|
isCtrlArmed(): boolean;
|
|
toggleCtrl(): void;
|
|
clearCtrl(): void;
|
|
consumeCtrl(data: string): string;
|
|
ctrlByteFor(char: string): string | null;
|
|
hide(): void;
|
|
show(): void;
|
|
};
|
|
|
|
function loadBar(sessionMode = 'claude') {
|
|
const app = {
|
|
activeSessionId: 'session-1',
|
|
sessions: new Map<string, { mode: string }>([['session-1', { mode: sessionMode }]]),
|
|
terminal: { focus: vi.fn() },
|
|
};
|
|
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, catch: () => {} }));
|
|
const barElement = fakeBarElement();
|
|
const context = vm.createContext({
|
|
app,
|
|
MobileDetection: { isTouchDevice: () => true },
|
|
URLSearchParams,
|
|
fetch: fetchMock,
|
|
document: {
|
|
createElement: () => barElement,
|
|
querySelector: () => ({ parentNode: { insertBefore: vi.fn() } }),
|
|
},
|
|
setTimeout: (fn: () => void) => {
|
|
fn();
|
|
return 1;
|
|
},
|
|
clearTimeout: vi.fn(),
|
|
});
|
|
vm.runInContext(`${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar;`, context, {
|
|
filename: 'keyboard-accessory.js',
|
|
});
|
|
const bar = (context as unknown as { __bar: Bar }).__bar;
|
|
bar.init();
|
|
return { app, bar, barElement, fetchMock };
|
|
}
|
|
|
|
describe('ctrlByteFor: character to control byte', () => {
|
|
const { bar } = loadBar();
|
|
|
|
it.each([
|
|
['c', '\x03'], // interrupt
|
|
['d', '\x04'], // EOF
|
|
['z', '\x1a'], // suspend
|
|
['r', '\x12'], // reverse search
|
|
['l', '\x0c'], // clear
|
|
['a', '\x01'],
|
|
['e', '\x05'],
|
|
['w', '\x17'],
|
|
['u', '\x15'],
|
|
['k', '\x0b'],
|
|
])('maps %s to its control byte', (char, byte) => {
|
|
expect(bar.ctrlByteFor(char)).toBe(byte);
|
|
});
|
|
|
|
it('maps uppercase the same as lowercase (Ctrl+C == Ctrl+c)', () => {
|
|
expect(bar.ctrlByteFor('C')).toBe('\x03');
|
|
expect(bar.ctrlByteFor('D')).toBe('\x04');
|
|
});
|
|
|
|
it('maps the punctuation controls a terminal defines', () => {
|
|
expect(bar.ctrlByteFor('@')).toBe('\x00');
|
|
expect(bar.ctrlByteFor('[')).toBe('\x1b'); // Ctrl+[ is Escape
|
|
expect(bar.ctrlByteFor('\\')).toBe('\x1c');
|
|
expect(bar.ctrlByteFor(']')).toBe('\x1d');
|
|
expect(bar.ctrlByteFor('^')).toBe('\x1e');
|
|
expect(bar.ctrlByteFor('_')).toBe('\x1f');
|
|
expect(bar.ctrlByteFor(' ')).toBe('\x00'); // Ctrl+Space = NUL
|
|
expect(bar.ctrlByteFor('?')).toBe('\x7f'); // Ctrl+? = DEL
|
|
});
|
|
|
|
it('returns null for characters with no control equivalent', () => {
|
|
// A hardware keyboard types these straight through under Ctrl.
|
|
for (const char of ['1', '9', '.', ',', '/', '-', '=', 'é']) {
|
|
expect(bar.ctrlByteFor(char)).toBeNull();
|
|
}
|
|
expect(bar.ctrlByteFor('ab')).toBeNull();
|
|
expect(bar.ctrlByteFor('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('shell keyboard bar selection', () => {
|
|
it('gives a shell session the terminal bar', () => {
|
|
const { bar, barElement } = loadBar('shell');
|
|
bar.refreshForActiveSession();
|
|
expect(bar._mode).toBe('shell');
|
|
expect(barElement.actions).toEqual([
|
|
'ctrl',
|
|
'esc',
|
|
'tab',
|
|
'scroll-up',
|
|
'scroll-down',
|
|
'arrow-left',
|
|
'arrow-right',
|
|
'paste',
|
|
'dismiss',
|
|
]);
|
|
});
|
|
|
|
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity'])('leaves a %s session on the agent bar', (mode) => {
|
|
const { bar, barElement } = loadBar(mode);
|
|
bar.refreshForActiveSession();
|
|
expect(bar._mode).toBe('simple');
|
|
expect(barElement.actions).toContain('init');
|
|
expect(barElement.actions).not.toContain('ctrl');
|
|
});
|
|
|
|
it('remembers the extended-bar preference across a shell session', () => {
|
|
const { app, bar, barElement } = loadBar('claude');
|
|
bar.setMode('extended');
|
|
expect(bar._mode).toBe('extended');
|
|
|
|
app.sessions.set('shell-1', { mode: 'shell' });
|
|
app.activeSessionId = 'shell-1';
|
|
bar.refreshForActiveSession();
|
|
expect(bar._mode).toBe('shell');
|
|
|
|
// Settings saved while the shell bar is up must not yank it away...
|
|
bar.setMode('extended');
|
|
expect(bar._mode).toBe('shell');
|
|
|
|
// ...and switching back to the agent session restores the user's choice.
|
|
app.activeSessionId = 'session-1';
|
|
bar.refreshForActiveSession();
|
|
expect(bar._mode).toBe('extended');
|
|
expect(barElement.actions).toContain('compact');
|
|
});
|
|
|
|
it('falls back to the agent bar with no active session', () => {
|
|
const { app, bar } = loadBar('shell');
|
|
app.activeSessionId = null as unknown as string;
|
|
bar.refreshForActiveSession();
|
|
expect(bar._mode).toBe('simple');
|
|
});
|
|
});
|
|
|
|
describe('one-shot Ctrl modifier', () => {
|
|
function shellBar() {
|
|
const loaded = loadBar('shell');
|
|
loaded.bar.refreshForActiveSession();
|
|
return loaded;
|
|
}
|
|
|
|
it('is disarmed until the Ctrl key is tapped', () => {
|
|
const { bar } = shellBar();
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
expect(bar.consumeCtrl('c')).toBe('c');
|
|
});
|
|
|
|
it('arms visibly and rewrites the next character as its control byte', () => {
|
|
const { bar, barElement } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
|
|
expect(bar.isCtrlArmed()).toBe(true);
|
|
const button = barElement.querySelector('[data-action="ctrl"]')!;
|
|
expect(button.classList.contains('armed')).toBe(true);
|
|
expect(button.attrs['aria-pressed']).toBe('true');
|
|
|
|
expect(bar.consumeCtrl('c')).toBe('\x03');
|
|
|
|
// One shot: spent, and the button says so.
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
expect(button.classList.contains('armed')).toBe(false);
|
|
expect(button.attrs['aria-pressed']).toBe('false');
|
|
expect(bar.consumeCtrl('c')).toBe('c');
|
|
});
|
|
|
|
it('sends Ctrl+D for the next key too', () => {
|
|
const { bar } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
expect(bar.consumeCtrl('d')).toBe('\x04');
|
|
});
|
|
|
|
it('cancels on a second tap of Ctrl', () => {
|
|
const { bar, barElement } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
bar.handleAction('ctrl');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
expect(barElement.querySelector('[data-action="ctrl"]')!.classList.contains('armed')).toBe(false);
|
|
expect(bar.consumeCtrl('c')).toBe('c');
|
|
});
|
|
|
|
it('passes a character with no control byte through unchanged, spending the modifier', () => {
|
|
const { bar } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
expect(bar.consumeCtrl('7')).toBe('7');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
|
|
it('spends the modifier on a paste instead of leaving it armed for the next keystroke', () => {
|
|
const { bar } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
expect(bar.consumeCtrl('git status')).toBe('git status');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
|
|
it('is cancelled by any other accessory key', () => {
|
|
const { bar } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
bar.handleAction('esc');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
|
|
it('is cancelled by a session switch', () => {
|
|
const { app, bar } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
expect(bar.isCtrlArmed()).toBe(true);
|
|
|
|
app.sessions.set('shell-2', { mode: 'shell' });
|
|
app.activeSessionId = 'shell-2';
|
|
bar.refreshForActiveSession();
|
|
|
|
// Same layout, but the modifier must not survive into the next session.
|
|
expect(bar._mode).toBe('shell');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
|
|
it('is cancelled when the keyboard is dismissed', () => {
|
|
const { bar } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
bar.hide();
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
|
|
it('drops the armed state when the layout is swapped out from under it', () => {
|
|
const { app, bar } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
app.sessions.set('agent-1', { mode: 'claude' });
|
|
app.activeSessionId = 'agent-1';
|
|
bar.refreshForActiveSession();
|
|
expect(bar._mode).toBe('simple');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
expect(bar.consumeCtrl('c')).toBe('c');
|
|
});
|
|
});
|
|
|
|
describe('terminal input wiring', () => {
|
|
it('applies the modifier in onData after the query-response filter and before the send paths', () => {
|
|
const hook = terminalSource.indexOf('KeyboardAccessoryBar.consumeCtrl(data)');
|
|
const queryFilter = terminalSource.indexOf('shouldSuppressTerminalQueryResponse(data)', hook - 4000);
|
|
const firstSend = terminalSource.indexOf('this._lastTerminalData', hook - 4000);
|
|
|
|
expect(hook).toBeGreaterThan(0);
|
|
// xterm answers DA/CPR queries through onData as well; letting one of those
|
|
// spend the modifier would silently eat the user's Ctrl.
|
|
expect(queryFilter).toBeGreaterThan(0);
|
|
expect(queryFilter).toBeLessThan(hook);
|
|
// Every send path (local echo, predictive echo, plain flush) reads `data`
|
|
// after this point, so the control byte reaches the PTY unchanged.
|
|
expect(firstSend).toBeGreaterThan(hook);
|
|
});
|
|
|
|
it('guards the hook so a page without the bar (desktop) still types normally', () => {
|
|
expect(terminalSource).toContain("typeof KeyboardAccessoryBar !== 'undefined'");
|
|
});
|
|
|
|
it('skips terminal-generated focus and mouse reports', () => {
|
|
// Pins the gate itself: without it the modifier is spent by the `\x1b[I`
|
|
// that the Ctrl button's own refocus emits (see the describe below).
|
|
expect(terminalSource).toContain('!window.CodemanTerminalInput?.isTerminalFocusOrMouseReport(data)');
|
|
});
|
|
});
|
|
|
|
describe('CodemanTerminalInput.isTerminalFocusOrMouseReport', () => {
|
|
const isReport = loadTerminalInput().isTerminalFocusOrMouseReport;
|
|
|
|
it.each([
|
|
['\x1b[I', 'focus in (DECSET 1004)'],
|
|
['\x1b[O', 'focus out (DECSET 1004)'],
|
|
['\x1b[<0;10;5M', 'SGR mouse press'],
|
|
['\x1b[<0;10;5m', 'SGR mouse release'],
|
|
['\x1b[<64;10;5M', 'SGR wheel up'],
|
|
['\x1b[M !!', 'legacy X10 mouse'],
|
|
])('classifies %j as terminal-generated (%s)', (data) => {
|
|
expect(isReport(data)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
['c', 'a typed character'],
|
|
['\x03', 'a control byte'],
|
|
['\r', 'Enter'],
|
|
['\x1b', 'the Escape key'],
|
|
['\x1b[A', 'an arrow key'],
|
|
['\x1b[200~hi\x1b[201~', 'a bracketed paste'],
|
|
['\x1b[?1;2c', 'a DA reply'],
|
|
['I', 'the letter I'],
|
|
])('leaves %j alone (%s)', (data) => {
|
|
expect(isReport(data)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('one-shot Ctrl vs terminal-generated reports', () => {
|
|
// The onData gate, as terminal-ui.js writes it. The wiring test above pins
|
|
// the real source; this proves the behavior the gate buys.
|
|
function feed(bar: Bar, data: string): string {
|
|
const isReport = loadTerminalInput().isTerminalFocusOrMouseReport;
|
|
return bar.isCtrlArmed() && !isReport(data) ? bar.consumeCtrl(data) : data;
|
|
}
|
|
|
|
function shellBar() {
|
|
const loaded = loadBar('shell');
|
|
loaded.bar.refreshForActiveSession();
|
|
return loaded;
|
|
}
|
|
|
|
it('survives a tap once an app in the pane turns mouse reporting on', () => {
|
|
const { bar } = shellBar();
|
|
// The live case: a shell session keeps the narrow scrollback strip, so mouse
|
|
// DECSETs reach the browser. Measured against a real shell with vim-style
|
|
// tracking on, one tap on the terminal spent the armed modifier silently.
|
|
bar.handleAction('ctrl');
|
|
expect(feed(bar, '\x1b[<0;10;5M')).toBe('\x1b[<0;10;5M');
|
|
expect(feed(bar, '\x1b[<0;10;5m')).toBe('\x1b[<0;10;5m');
|
|
expect(bar.isCtrlArmed()).toBe(true);
|
|
|
|
// ...so the character the user actually types is still the one modified.
|
|
expect(feed(bar, 'd')).toBe('\x04');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
|
|
it('survives a focus report, should one ever reach xterm', () => {
|
|
// Defense in depth: FOCUS_ESCAPE_FILTER (session.ts) strips `\x1b[?1004h`
|
|
// from every PTY read, so sendFocusMode never turns on today. If it did,
|
|
// the bar's own post-key refocus would emit `\x1b[I` and eat the modifier
|
|
// before the user typed a single character.
|
|
const { bar } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
expect(feed(bar, '\x1b[I')).toBe('\x1b[I');
|
|
expect(bar.isCtrlArmed()).toBe(true);
|
|
expect(feed(bar, 'c')).toBe('\x03');
|
|
});
|
|
|
|
it('still spends the modifier on a paste, which is real input', () => {
|
|
const { bar } = shellBar();
|
|
bar.handleAction('ctrl');
|
|
expect(feed(bar, 'git status')).toBe('git status');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('one-shot Ctrl through the CJK input field', () => {
|
|
// The CJK textarea swallows keystrokes before onData sees them, so the CJK
|
|
// send path needs the modifier applied too. These drive the REAL
|
|
// _handleCjkInput against the REAL bar, both loaded into one vm scope.
|
|
function cjkApp() {
|
|
const { CodemanApp, bar } = loadTerminalModule();
|
|
bar.clearCtrl();
|
|
const app = Object.create(CodemanApp.prototype) as {
|
|
activeSessionId: string;
|
|
_sendInputAsync: ReturnType<typeof vi.fn>;
|
|
_handleCjkInput(text: string): void;
|
|
};
|
|
app.activeSessionId = 'cjk-session';
|
|
app._sendInputAsync = vi.fn();
|
|
return { app, bar };
|
|
}
|
|
|
|
it('sends the control byte for a character typed into the CJK field', () => {
|
|
const { app, bar } = cjkApp();
|
|
bar.toggleCtrl();
|
|
app._handleCjkInput('c');
|
|
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '\x03');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
|
|
it('leaves ordinary CJK input untouched when nothing is armed', () => {
|
|
const { app } = cjkApp();
|
|
app._handleCjkInput('你好');
|
|
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '你好');
|
|
});
|
|
|
|
it('spends the modifier on a committed IME word instead of stranding it', () => {
|
|
// The gap this closes: with the field focused the modifier could neither
|
|
// fire nor be spent, so it survived to bite a later innocent keystroke.
|
|
const { app, bar } = cjkApp();
|
|
bar.toggleCtrl();
|
|
app._handleCjkInput('你好');
|
|
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '你好');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
|
|
it('spends the modifier on Enter, like every other non-character key', () => {
|
|
const { app, bar } = cjkApp();
|
|
bar.toggleCtrl();
|
|
app._handleCjkInput('\r');
|
|
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '\r');
|
|
expect(bar.isCtrlArmed()).toBe(false);
|
|
});
|
|
|
|
it('drops the input, and does not spend the modifier, with no active session', () => {
|
|
const { app, bar } = cjkApp();
|
|
(app as unknown as { activeSessionId: string | null }).activeSessionId = null;
|
|
bar.toggleCtrl();
|
|
app._handleCjkInput('c');
|
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
|
expect(bar.isCtrlArmed()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('armed styling survives the light-skin overrides', () => {
|
|
const mobileCss = readFileSync(resolve('src/web/public/mobile.css'), 'utf8');
|
|
|
|
it('excludes .armed from the light-skin .accessory-btn repaint', () => {
|
|
// That selector is (0,3,1): `:is()` takes the specificity of its most
|
|
// specific argument and the list holds `.btn-toolbar.btn-shell`. It
|
|
// therefore OUTRANKS the (0,3,0) armed rules in both stylesheets, and a
|
|
// bare `.accessory-btn` there paints the armed modifier back to a resting
|
|
// button on all four light skins (measured across every skin at 390px).
|
|
const lightSkinRule = mobileCss
|
|
.split('\n')
|
|
.find((line) => line.includes('[data-skin="paper-gray"]') && line.includes('.btn-voice-mobile,'));
|
|
|
|
expect(lightSkinRule).toBeDefined();
|
|
expect(lightSkinRule).toContain('.accessory-btn:not(.armed)');
|
|
});
|
|
|
|
it('keeps an armed rule in both stylesheets', () => {
|
|
// mobile.css hardcodes the phone palette, styles.css carries the
|
|
// skin-aware one for everything wider.
|
|
expect(mobileCss).toContain('.accessory-btn.accessory-btn-ctrl.armed');
|
|
expect(readFileSync(resolve('src/web/public/styles.css'), 'utf8')).toContain(
|
|
'.accessory-btn.accessory-btn-ctrl.armed'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('composer nav keys from the bar', () => {
|
|
/** loadBar()'s app stub plus the local-echo state the flush path reads. */
|
|
function barWithDraft(draft: string) {
|
|
const loaded = loadBar('claude');
|
|
const overlay = {
|
|
pendingText: draft,
|
|
clear: vi.fn(() => {
|
|
overlay.pendingText = '';
|
|
}),
|
|
suppressBufferDetection: vi.fn(),
|
|
};
|
|
const app = loaded.app as unknown as Record<string, unknown>;
|
|
app._localEchoEnabled = true;
|
|
app._localEchoOverlay = overlay;
|
|
app.sendInput = vi.fn();
|
|
app._flushedOffsets = new Map([['session-1', 3]]);
|
|
app._flushedTexts = new Map([['session-1', 'dra']]);
|
|
return { ...loaded, app, overlay };
|
|
}
|
|
|
|
const sentKeys = (fetchMock: { mock: { calls: unknown[][] } }) =>
|
|
fetchMock.mock.calls.map((call) => JSON.parse((call[1] as { body: string }).body).input);
|
|
|
|
it.each(['simple', 'extended'])('exposes Shift arrows in the %s agent layout', (mode) => {
|
|
const { bar, barElement } = loadBar('codex');
|
|
bar.setMode(mode);
|
|
expect(barElement.actions).toContain('shift-left');
|
|
expect(barElement.actions).toContain('shift-right');
|
|
});
|
|
|
|
it.each([
|
|
['shift-left', '\x1b[1;2D'],
|
|
['shift-right', '\x1b[1;2C'],
|
|
])('%s flushes the draft before navigation and hands editing to the PTY', (action, sequence) => {
|
|
const { bar, app, overlay, fetchMock } = barWithDraft('unfinished follow-up');
|
|
const events: string[] = [];
|
|
app.sendInput = vi.fn(() => events.push('draft'));
|
|
fetchMock.mockImplementation(() => {
|
|
events.push('key');
|
|
return Promise.resolve({ ok: true, catch: () => {} });
|
|
});
|
|
|
|
bar.handleAction(action);
|
|
|
|
expect(app.sendInput).toHaveBeenCalledWith('unfinished follow-up');
|
|
expect(events).toEqual(['draft', 'key']);
|
|
expect(sentKeys(fetchMock)).toEqual([sequence]);
|
|
expect(overlay.pendingText).toBe('');
|
|
expect([...(app._echoPassthroughSessions as Set<string>)]).toEqual(['session-1']);
|
|
});
|
|
|
|
it('flushes the unsent draft before sending the arrow', () => {
|
|
// On a phone the typed text lives in the overlay and has NEVER reached the
|
|
// PTY, so an arrow sent on its own arrives at a composer the CLI still
|
|
// considers empty: Up recalls a history entry into it while the overlay
|
|
// goes on painting the draft over the same row and still believes it is
|
|
// pending. Flushing first is also what makes the draft recoverable: the
|
|
// CLI stashes the live composer and hands it back on Down.
|
|
const { bar, app, overlay, fetchMock } = barWithDraft('draft I typed');
|
|
|
|
bar.handleAction('scroll-up');
|
|
|
|
expect(app.sendInput).toHaveBeenCalledWith('draft I typed');
|
|
expect(sentKeys(fetchMock)).toEqual(['\x1b[A']);
|
|
expect(overlay.pendingText).toBe('');
|
|
expect(overlay.suppressBufferDetection).toHaveBeenCalled();
|
|
// The overlay's bookkeeping for this session has to go with it.
|
|
expect((app._flushedOffsets as Map<string, number>).has('session-1')).toBe(false);
|
|
expect((app._flushedTexts as Map<string, string>).has('session-1')).toBe(false);
|
|
});
|
|
|
|
it('hands the session to plain PTY echo, like a typed nav key does', () => {
|
|
// After a nav key the real cursor can sit mid-text, where the overlay's
|
|
// append-only buffering cannot track edits (issue #218). terminal-ui.js's
|
|
// onData branch does exactly this for a nav key typed on a keyboard.
|
|
const { bar, app } = barWithDraft('');
|
|
|
|
bar.handleAction('arrow-left');
|
|
|
|
expect([...(app._echoPassthroughSessions as Set<string>)]).toEqual(['session-1']);
|
|
});
|
|
|
|
it('sends all four arrows and skips the flush when there is no draft', () => {
|
|
const { bar, app, fetchMock } = barWithDraft('');
|
|
|
|
bar.handleAction('scroll-up');
|
|
bar.handleAction('scroll-down');
|
|
bar.handleAction('arrow-left');
|
|
bar.handleAction('arrow-right');
|
|
|
|
expect(sentKeys(fetchMock)).toEqual(['\x1b[A', '\x1b[B', '\x1b[D', '\x1b[C']);
|
|
expect(app.sendInput).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Codex shift-arrow keys are gated on the active session', () => {
|
|
// ⇧←/⇧→ are Codex bindings. They ship in both agent templates, but a tap in
|
|
// any other CLI would do nothing AND hand the session to PTY echo
|
|
// (sendNavKey adds it to _echoPassthroughSessions), so on a phone a dead key
|
|
// would also switch local echo off for the rest of the prompt. The reveal
|
|
// follows the 🧠 key's shape: a marker class on the BAR element, because
|
|
// setMode() rebuilds the buttons' innerHTML on every layout switch.
|
|
const stylesSource = readFileSync(resolve('src/web/public/styles.css'), 'utf8');
|
|
|
|
it('marks both shift keys in both agent templates so one CSS rule can hide them', () => {
|
|
const simple = keyboardSource.match(/_simpleButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
|
|
const extended = keyboardSource.match(/_extendedButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
|
|
for (const template of [simple, extended]) {
|
|
expect(template).toMatch(/accessory-btn-codex[^>]*data-action="shift-left"/);
|
|
expect(template).toMatch(/accessory-btn-codex[^>]*data-action="shift-right"/);
|
|
}
|
|
});
|
|
|
|
it('hides the keys in styles.css until the bar carries codex-enabled', () => {
|
|
expect(stylesSource).toMatch(/\.keyboard-accessory-bar \.accessory-btn-codex \{\s*display: none;/);
|
|
expect(stylesSource).toMatch(
|
|
/\.keyboard-accessory-bar\.codex-enabled \.accessory-btn-codex \{\s*display: inline-flex;/
|
|
);
|
|
});
|
|
|
|
it.each(['simple', 'extended'])('carries codex-enabled for a codex session in the %s layout', (mode) => {
|
|
const { bar, barElement } = loadBar('codex');
|
|
bar.setMode(mode);
|
|
expect(barElement.classList.contains('codex-enabled')).toBe(true);
|
|
// The buttons themselves are still in the DOM; the class is what reveals them.
|
|
expect(barElement.actions).toContain('shift-left');
|
|
expect(barElement.actions).toContain('shift-right');
|
|
});
|
|
|
|
it.each(['claude', 'shell', 'pi', 'omp', 'deepseek'])(
|
|
'does not carry codex-enabled for a %s session',
|
|
(sessionMode) => {
|
|
const { bar, barElement } = loadBar(sessionMode);
|
|
bar.setMode('extended');
|
|
expect(barElement.classList.contains('codex-enabled')).toBe(false);
|
|
}
|
|
);
|
|
|
|
it('re-syncs the class on a session switch, in both directions', () => {
|
|
const { app, bar, barElement } = loadBar('codex');
|
|
expect(barElement.classList.contains('codex-enabled')).toBe(true);
|
|
|
|
app.sessions.set('session-2', { mode: 'claude' });
|
|
app.activeSessionId = 'session-2';
|
|
bar.refreshForActiveSession();
|
|
expect(barElement.classList.contains('codex-enabled')).toBe(false);
|
|
|
|
app.activeSessionId = 'session-1';
|
|
bar.refreshForActiveSession();
|
|
expect(barElement.classList.contains('codex-enabled')).toBe(true);
|
|
});
|
|
|
|
it('drops the class when no session is active (welcome screen)', () => {
|
|
const { app, bar, barElement } = loadBar('codex');
|
|
app.activeSessionId = '';
|
|
bar.refreshForActiveSession();
|
|
expect(barElement.classList.contains('codex-enabled')).toBe(false);
|
|
});
|
|
|
|
it('is wired at init and on every session switch, like the 🧠 key', () => {
|
|
const initBody = keyboardSource.match(/\n init\(\) \{([\s\S]*?)\n \},/)?.[1] ?? '';
|
|
const refreshBody = keyboardSource.match(/\n refreshForActiveSession\(\) \{([\s\S]*?)\n \},/)?.[1] ?? '';
|
|
expect(initBody).toContain('this.syncCodexKeys();');
|
|
expect(refreshBody).toContain('this.syncCodexKeys();');
|
|
});
|
|
});
|