fix(terminal): drive the shipped Shift+Enter handlers and the Key tester cap in their browser tests (#520, #522 review)

- Minor: the Key tester "14 lines" test never pressed a key into the
  tester (the previous test blurred it, so the presses landed on <body>
  and the cap was never exercised). It now refocuses the field, asserts
  the focus, clears the log, checks 2 presses accumulate to 6 lines, then
  4 presses of another key cap the log at exactly 14 with the oldest 4
  lines evicted in order, and the readonly field stays empty. Verified to
  fail with the cap changed to 20.
- Nit: the split-pane invariant implied Ctrl+Enter could use the CLI's
  declared newline chord. Reworded after checking the send-key route:
  Ctrl+Enter is always a real 0x0a, Shift+Enter is the declared
  capabilities.newline chord (0x0a unless the CLI declares another), sent
  on keydown only. The same imprecision in the auto-named sessions
  paragraph is corrected too.
- Nit: docs/wiki/Settings-Reference.md now lists the Key tester row in
  the Terminal & Input table.

- Nit: test/shift-enter-keypress.browser.test.ts exercised a hand-copied
  predicate named `shipped`. It now loads the real app from a real
  WebServer and presses real keys into the handlers terminal-ui.js
  (app.terminal, recording the real _sendInputAsync send path) and
  terminal-split.js (a real SplitTerminalPane) attach, recording the
  send-key POSTs through a fetch wrapper. It asserts no \r reaches either
  send path for Shift/Ctrl+Enter, exactly one send-key per press for the
  right session, and that Enter and Alt+Enter are untouched. The old
  keydown-only gate stays as a labelled reproduction of xterm's keypress
  behaviour on a bare Terminal. Verified to fail on both panes with the
  gate narrowed back to keydown.
- Nit: the keypress trap is now written down beside the other key-gate
  rules (Command palette and shortcut registry): xterm runs the custom
  handler for keydown, keypress and keyup and drops only Ctrl/Alt/Meta
  keypresses, so a gate on a chord that can carry Shift alone must
  swallow every event type. The smart-copy keydown-only rule points at it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-04 23:52:41 +02:00
parent 36af183f97
commit 52267f8617
4 changed files with 223 additions and 63 deletions
File diff suppressed because one or more lines are too long
+1
View File
@@ -50,6 +50,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
| Key tester | n/a | A diagnostic that stores nothing. Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup, for when a chord such as Shift+Enter behaves differently on one device. Keys pressed there reach no session and trigger no shortcut. |
### Header & Panels
+23 -2
View File
@@ -89,8 +89,29 @@ describe('Key tester in a real browser', () => {
});
it('keeps only the last 14 lines and never types into the field', async () => {
for (let i = 0; i < 8; i++) await page.keyboard.press('a');
expect((await log()).split('\n').length).toBeLessThanOrEqual(14);
// The test above blurred the field, so focus it again: without this the presses land on
// <body>, the log keeps whatever the earlier tests left, and the cap is never exercised.
await page.focus('#keyTesterInput');
expect(await page.evaluate(() => document.activeElement?.id)).toBe('keyTesterInput');
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
const lines = async () => (await log()).split('\n');
// A printable key fires keydown, keypress and keyup, so 2 presses are 6 lines: under the cap
// the log accumulates rather than showing only the latest event.
for (let i = 0; i < 2; i++) await page.keyboard.press('a');
expect(await lines()).toHaveLength(6);
// A different key, so eviction is visible: 4 x 3 = 12 more lines makes 18, capped to 14. The
// oldest 4 go (all of the first 'a' press and the second one's keydown), the newest stay in order.
for (let i = 0; i < 4; i++) await page.keyboard.press('b');
const capped = await lines();
expect(capped).toHaveLength(14);
expect(capped.filter((l) => l.includes('key="a"'))).toHaveLength(2);
expect(capped.filter((l) => l.includes('key="b"'))).toHaveLength(12);
expect(capped[0]).toMatch(/^keypress\s+key="a" code=KeyA mods=none charCode=97$/);
expect(capped[13]).toMatch(/^keyup\s+key="b" code=KeyB mods=none$/);
// Readonly: none of those presses typed anything into the field itself.
expect(await page.inputValue('#keyTesterInput')).toBe('');
});
});
+194 -58
View File
@@ -1,81 +1,217 @@
// @vitest-environment node
// Real xterm.js in real Chromium, real keystrokes. xterm runs the custom key handler for
// keydown AND keypress, and drops a keypress that carries Ctrl/Alt but NOT a Shift-only one,
// so a handler that returns false for keydown alone lets Shift+Enter fall through to a bare
// \r (submit). That is why Ctrl+Enter and Alt+Enter inserted a newline while Shift+Enter
// submitted. Self-contained: no server, only the xterm bundle.
/**
* @fileoverview Shift/Ctrl+Enter against the SHIPPED key handlers, with real keystrokes in Chromium.
*
* xterm runs the custom key handler for keydown, keypress AND keyup, and drops a keypress that
* carries Ctrl/Alt but NOT a Shift-only one. A handler that returns false for keydown alone
* therefore lets Shift+Enter's keypress fall through to onData as a bare \r, which went out over
* the WebSocket ahead of the async send-key fetch and submitted the prompt (#520). Ctrl+Enter
* worked all along because Chromium fires no keypress for it.
*
* The page is the real app served by a real WebServer, so the handlers under test are the ones
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
* no session exists behind the ids, so nothing is ever typed into a real pane.
*
* Browser-driven, so it is excluded from `npm run test:ci` like the other Playwright suites:
* npm run test:browser -- test/shift-enter-keypress.browser.test.ts
* The CI gate's guard on the same handlers is the source check in
* test/shift-enter-keypress-swallowed.test.ts.
*
* Port: 3267 (per CLAUDE.md, ports 3150+ for tests)
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const XTERM = readFileSync(join(process.cwd(), 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
const PORT = 3267;
const BASE_URL = `http://localhost:${PORT}`;
const MAIN_ID = 'shift-enter-probe-main';
const PANE_B_ID = 'shift-enter-probe-pane-b';
declare global {
interface Window {
__t: Record<string, { term: { focus(): void }; sent: string[] }>;
Terminal: new () => {
open(el: HTMLElement): void;
focus(): void;
onData(cb: (d: string) => void): void;
attachCustomKeyEventHandler(cb: (ev: KeyboardEvent) => boolean): void;
};
}
interface Pressed {
/** What xterm emitted through onData, i.e. what would have been written to the PTY. */
data: string[];
/** The send-key POSTs the handler made, as `<session id> <key>`. */
sendKeys: string[];
/** Whether xterm's own textarea held focus, so a key that emits nothing was really delivered. */
focused: boolean;
}
describe('Shift/Ctrl+Enter reach the PTY as a bare \\r only if the handler lets keypress through', () => {
describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be swallowed too)', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.setContent('<body></body>');
await page.addScriptTag({ content: XTERM });
await page.evaluate(() => {
const mk = (handler: (ev: KeyboardEvent) => boolean) => {
const host = document.createElement('div');
document.body.appendChild(host);
const term = new window.Terminal();
term.open(host);
const sent: string[] = [];
term.onData((d) => sent.push(d));
term.attachCustomKeyEventHandler(handler);
return { term, sent };
};
// The shipped predicate (terminal-ui.js / terminal-split.js) and the one it replaced.
const shipped = (ev: KeyboardEvent) => !(ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey));
const keydownOnly = (ev: KeyboardEvent) =>
!(ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown');
window.__t = { shipped: mk(shipped), keydownOnly: mk(keydownOnly) };
});
});
afterAll(async () => {
await browser?.close();
});
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(
({ paneBId }) => {
const w = window as any;
// Record send-key instead of reaching the server (no tmux pane exists behind these ids).
w.__sendKeys = [] as string[];
const realFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const m = /\/api\/sessions\/([^/]+)\/send-key$/.exec(url);
if (m) {
w.__sendKeys.push(`${m[1]} ${JSON.parse(String(init?.body ?? '{}')).key}`);
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
);
}
return realFetch(input, init);
};
async function typed(which: 'shipped' | 'keydownOnly', key: string): Promise<string[]> {
await page.evaluate((w) => {
window.__t[w].sent.length = 0;
window.__t[w].term.focus();
}, which);
// Pane B, built by the real class. connect() installs the key handler synchronously,
// before its first await; the buffer fetch and WebSocket for the missing session just fail.
const mount = document.createElement('div');
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
document.body.appendChild(mount);
const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' });
void pane.connect().catch(() => {});
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
w.__paneBData = [] as string[];
pane.terminal.onData((d: string) => w.__paneBData.push(d));
w.__paneB = pane;
// The bare xterm the old keydown-only gate is reproduced on (see the first test).
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:420px;top:0;width:400px;height:300px;';
document.body.appendChild(host);
const old = new w.Terminal();
old.open(host);
w.__oldData = [] as string[];
old.onData((d: string) => w.__oldData.push(d));
old.attachCustomKeyEventHandler(
(ev: KeyboardEvent) => !(ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown')
);
w.__oldTerm = old;
},
{ paneBId: PANE_B_ID }
);
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/**
* Press `key` in the main pane. `sessionId` null is the welcome screen; otherwise the app thinks
* that session is active, local echo is off (the desktop default) and its real send path,
* `_sendInputAsync`, is recorded instead of posting.
*/
async function pressMain(key: string, sessionId: string | null): Promise<Pressed> {
await page.evaluate((id) => {
const w = window as any;
const app = w.app;
w.__mainSaved ??= { id: app.activeSessionId, echo: app._localEchoEnabled, send: app._sendInputAsync };
w.__mainData = [] as string[];
w.__sendKeys.length = 0;
app.activeSessionId = id;
app._localEchoEnabled = false;
app._pendingInput = '';
app._sendInputAsync = (_sid: string, chunk: string) => void w.__mainData.push(chunk);
app.terminal.focus();
}, sessionId);
const focused = await page.evaluate(() => document.activeElement === (window as any).app.terminal.textarea);
await page.keyboard.press(key);
return page.evaluate((w) => [...window.__t[w].sent], which);
// The main pane batches onData through a short flush timer before _sendInputAsync.
await page.waitForTimeout(150);
return page.evaluate((f) => {
const w = window as any;
const app = w.app;
const out = { data: [...w.__mainData], sendKeys: [...w.__sendKeys], focused: f };
app.activeSessionId = w.__mainSaved.id;
app._localEchoEnabled = w.__mainSaved.echo;
app._sendInputAsync = w.__mainSaved.send;
app._pendingInput = '';
return out;
}, focused);
}
it('reproduces the bug with the old keydown-only handler', async () => {
expect(await typed('keydownOnly', 'Shift+Enter')).toEqual(['\r']);
expect(await typed('keydownOnly', 'Control+Enter')).toEqual([]);
async function pressIn(which: 'paneB' | 'old', key: string): Promise<Pressed> {
await page.evaluate((k) => {
const w = window as any;
w[k === 'paneB' ? '__paneBData' : '__oldData'].length = 0;
w.__sendKeys.length = 0;
(k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).focus();
}, which);
const focused = await page.evaluate((k) => {
const w = window as any;
return document.activeElement === (k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).textarea;
}, which);
await page.keyboard.press(key);
await page.waitForTimeout(50);
return page.evaluate(
({ k, f }) => {
const w = window as any;
return { data: [...w[k === 'paneB' ? '__paneBData' : '__oldData']], sendKeys: [...w.__sendKeys], focused: f };
},
{ k: which, f: focused }
);
}
it('reproduces the leak on a bare xterm with the old keydown-only gate (the behaviour the fix depends on)', async () => {
// Not shipped code: this pins the xterm behaviour, so a future xterm that stops emitting the
// Shift-only keypress shows up here instead of silently turning the tests below vacuous.
const shift = await pressIn('old', 'Shift+Enter');
expect(shift.focused).toBe(true);
expect(shift.data).toEqual(['\r']);
expect((await pressIn('old', 'Control+Enter')).data).toEqual([]);
});
it('sends nothing for Shift+Enter and Ctrl+Enter with the shipped handler (the send-key route supplies the newline)', async () => {
expect(await typed('shipped', 'Shift+Enter')).toEqual([]);
expect(await typed('shipped', 'Control+Enter')).toEqual([]);
it('main pane (terminal-ui.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key exactly once', async () => {
const shift = await pressMain('Shift+Enter', MAIN_ID);
expect(shift.focused).toBe(true);
expect(shift.data).toEqual([]);
// Once, from the keydown: the swallowed keypress and keyup must not send again.
expect(shift.sendKeys).toEqual([`${MAIN_ID} S-Enter`]);
const ctrl = await pressMain('Control+Enter', MAIN_ID);
expect(ctrl.data).toEqual([]);
expect(ctrl.sendKeys).toEqual([`${MAIN_ID} C-Enter`]);
});
it('leaves plain Enter and Alt+Enter alone', async () => {
expect(await typed('shipped', 'Enter')).toEqual(['\r']);
expect(await typed('shipped', 'Alt+Enter')).toEqual(['\x1b\r']);
it('main pane: plain Enter still reaches the send path as \\r, and Alt+Enter as ESC CR', async () => {
const enter = await pressMain('Enter', MAIN_ID);
expect(enter.focused).toBe(true);
expect(enter.data).toEqual(['\r']);
expect(enter.sendKeys).toEqual([]);
const alt = await pressMain('Alt+Enter', MAIN_ID);
expect(alt.data).toEqual(['\x1b\r']);
expect(alt.sendKeys).toEqual([]);
});
it('main pane on the welcome screen: Shift+Enter sends nothing and posts no send-key', async () => {
// With no active session the app's onData drops input anyway, so this pins the send-key
// guard (`this.activeSessionId`), not the keypress swallow; the tests above pin that.
const shift = await pressMain('Shift+Enter', null);
expect(shift.focused).toBe(true);
expect(shift.data).toEqual([]);
expect(shift.sendKeys).toEqual([]);
});
it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
const shift = await pressIn('paneB', 'Shift+Enter');
expect(shift.focused).toBe(true);
expect(shift.data).toEqual([]);
expect(shift.sendKeys).toEqual([`${PANE_B_ID} S-Enter`]);
const ctrl = await pressIn('paneB', 'Control+Enter');
expect(ctrl.data).toEqual([]);
expect(ctrl.sendKeys).toEqual([`${PANE_B_ID} C-Enter`]);
const enter = await pressIn('paneB', 'Enter');
expect(enter.data).toEqual(['\r']);
expect(enter.sendKeys).toEqual([]);
});
});