feat(terminal): newline chord as registry data, plus a Key tester in Settings

capabilities.newline replaces choosing the Shift+Enter bytes in the send-key
route. Key tester shows the keydown/keypress/keyup a browser reports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-10-02 21:53:31 +08:00
co-authored by Claude Sonnet 5.5
parent 9240493c43
commit f39c66e4e8
12 changed files with 208 additions and 7 deletions
@@ -0,0 +1,5 @@
---
"aicodeman": patch
---
Shift+Enter's newline chord is now registry data (`capabilities.newline`: `line-feed` by default, `esc-enter` for Codex) instead of being chosen in the `send-key` route, so a CLI with a different composer is one line in `stock.ts`. Adds a Key tester under Settings → Terminal & Input that shows the keydown/keypress/keyup events a browser reports, to diagnose a device where a shortcut behaves differently.
+1
View File
@@ -31,6 +31,7 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
];
+4
View File
@@ -118,6 +118,10 @@ sure its row is one the agent cannot write.
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
## The newline chord
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is for a composer that ignores a bare line feed, which Codex does in some terminals (#495). It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
## Arg-template safety
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
+1
View File
@@ -377,6 +377,7 @@ const capabilitiesSchema = z
privilegedEnvKeys: z.array(envName).max(8),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
newline: z.enum(['line-feed', 'esc-enter']).optional(),
customModelInjection: z.discriminatedUnion('kind', [
z
.object({
+3
View File
@@ -620,6 +620,9 @@ const CODEX: CliEntry = {
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
// Codex's composer ignores a bare line feed in some terminals (Windows browsers, #495); the
// Esc+Enter chord is the one its own Option+Enter uses.
newline: 'esc-enter',
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
// so the user's real ~/.codex/config.toml is never touched.
customModelInjection: {
+11
View File
@@ -90,6 +90,9 @@ export interface CliVariant {
args: ArgSpec[];
}
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
export type NewlineSequence = 'line-feed' | 'esc-enter';
export interface CliLaunch {
params: Record<string, ParamSpec>;
/**
@@ -511,6 +514,14 @@ export interface CliCapabilities {
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
maxFrameBytes?: number;
/**
* The bytes the web UI types into this CLI's pane for Shift+Enter (the `send-key` route).
* `line-feed` (`0x0a`, also what Ctrl+Enter sends) is what Claude Code's Ink input and most TUIs
* read as "insert a newline"; `esc-enter` (`ESC` `CR`, the same chord as Option/Alt+Enter and
* the mobile ⌥Enter key) is for a TUI that ignores a bare line feed. Absent = `line-feed`.
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
*/
newline?: NewlineSequence;
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
+16
View File
@@ -1822,6 +1822,22 @@
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Key tester</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="key tester keyboard shift enter newline diagnose keydown keypress">
<div class="set-row-text">
<span class="set-row-label">Key tester</span>
<span class="set-row-desc">Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup. Useful when a shortcut such as Shift+Enter behaves differently on one device. Nothing is sent to a session.</span>
</div>
<input type="text" id="keyTesterInput" class="set-select" readonly autocomplete="off" spellcheck="false"
placeholder="Click here, then press keys"
onkeydown="app.keyTesterEvent(event)" onkeypress="app.keyTesterEvent(event)" onkeyup="app.keyTesterEvent(event)">
</div>
<pre id="keyTesterLog" class="set-note mono" style="display:none;white-space:pre-wrap" data-i18n-skip></pre>
</div>
</div>
</section>
<!-- ══ Header &amp; Panels ═══════════════════════════════════════ -->
+21
View File
@@ -1114,6 +1114,27 @@ Object.assign(CodemanApp.prototype, {
this._updateCheck = null;
},
/**
* Settings → Terminal & Input → Key tester: prints what the browser reports for each key event.
* Read-only and local; it never reaches a session. keypress is shown on purpose: that event is
* why a Shift-only Enter used to submit (xterm drops Ctrl/Alt keypresses, not Shift ones).
*/
keyTesterEvent(ev) {
const log = document.getElementById('keyTesterLog');
if (!log) return;
// Never preventDefault on keydown: that suppresses the keypress this panel exists to show.
// The field is readonly, so nothing is typed into it either way.
const mods = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey'].filter((m) => ev[m]).map((m) => m.replace('Key', ''));
const line =
`${ev.type.padEnd(8)} key=${JSON.stringify(ev.key)} code=${ev.code || '-'} ` +
`mods=${mods.join('+') || 'none'}` +
(ev.type === 'keypress' ? ` charCode=${ev.charCode}` : '') +
(ev.repeat ? ' (repeat)' : '');
const lines = (log.textContent ? log.textContent.split('\n') : []).concat(line);
log.textContent = lines.slice(-14).join('\n');
log.style.display = 'block';
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
+14 -7
View File
@@ -107,6 +107,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js';
@@ -2100,13 +2101,7 @@ export function registerSessionRoutes(
const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : '';
// Map key names to hex byte sequences
const KEY_HEX_MAP: Record<string, string[]> = {
'S-Enter': ['0a'], // \n (line feed)
'C-Enter': ['0a'], // \n (line feed)
};
const hex = KEY_HEX_MAP[key];
if (!hex) {
if (key !== 'S-Enter' && key !== 'C-Enter') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
}
@@ -2116,6 +2111,18 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
}
// Key names map to hex byte sequences. Ctrl+Enter is always a line feed; Shift+Enter is the
// CLI's own newline chord (`capabilities.newline`, default line feed), so a CLI that wants
// Esc+Enter declares it in the registry instead of being special-cased here.
const NEWLINE_HEX: Record<NewlineSequence, string[]> = {
'line-feed': ['0a'], // \n
'esc-enter': ['1b', '0d'], // ESC CR, the Alt/Option+Enter chord
};
const hex =
key === 'C-Enter'
? NEWLINE_HEX['line-feed']
: NEWLINE_HEX[getCli(session.mode)?.capabilities.newline ?? 'line-feed'];
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
+37
View File
@@ -0,0 +1,37 @@
// @vitest-environment node
// capabilities.newline: the bytes Shift+Enter types into a CLI's pane. Data in the registry, not
// a branch on the CLI id (test/cli-registry-no-id-branching.test.ts keeps the latter true).
import { describe, expect, it } from 'vitest';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
describe('capabilities.newline', () => {
it('only codex declares a non-default chord today', () => {
const declared = Object.fromEntries(
STOCK_CLIS.filter((e) => e.capabilities.newline).map((e) => [e.id as string, e.capabilities.newline])
);
expect(declared).toEqual({ codex: 'esc-enter' });
});
it.each(['line-feed', 'esc-enter'])('schema accepts %s', (value) => {
const e = claude();
(e.capabilities as Record<string, unknown>).newline = value;
expect(CliEntrySchema.safeParse(e).success).toBe(true);
});
it.each(['lf', 'crlf', '\x1b\r', '', 0])('schema rejects %j (no free-form byte strings in config)', (value) => {
const e = claude();
(e.capabilities as Record<string, unknown>).newline = value;
expect(CliEntrySchema.safeParse(e).success).toBe(false);
});
it('is optional, so an entry that declares nothing keeps the line feed', () => {
const e = claude();
delete (e.capabilities as Record<string, unknown>).newline;
expect(CliEntrySchema.safeParse(e).success).toBe(true);
});
});
+53
View File
@@ -0,0 +1,53 @@
/** @fileoverview Settings → Terminal & Input → Key tester, driven with real keystrokes in Chromium. */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3197;
describe('Key tester in a real browser', () => {
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.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
await page.focus('#keyTesterInput');
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
const log = () => page.evaluate(() => document.getElementById('keyTesterLog')!.textContent ?? '');
it('shows keydown, keypress and keyup for Shift+Enter, with the modifier and charCode', async () => {
await page.keyboard.press('Shift+Enter');
const text = await log();
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=shift/);
// The keypress is the event that used to leak a bare \r to the PTY.
expect(text).toMatch(/keypress\s+key="Enter" code=Enter mods=shift charCode=13/);
expect(text).toMatch(/keyup\s+key="Enter" code=Enter mods=shift/);
});
it('shows Ctrl+Enter without a keypress, as xterm would never see one for Ctrl', async () => {
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
await page.keyboard.press('Control+Enter');
const text = await log();
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=ctrl/);
expect(text).toMatch(/keyup/);
});
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);
expect(await page.inputValue('#keyTesterInput')).toBe('');
});
});
+42
View File
@@ -165,6 +165,48 @@ describe('session-routes', () => {
expect(argv).toContain('-H');
});
describe('newline chord comes from the CLI registry (capabilities.newline)', () => {
const sentHex = async (mode: string, key: string): Promise<string[]> => {
execFile.mockReset();
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
cb(null)
);
const session = harness.ctx._session as unknown as { mode: string };
const before = session.mode;
session.mode = mode;
try {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key },
});
expect(res.statusCode).toBe(200);
} finally {
session.mode = before;
}
const argv = execFile.mock.calls[0][1] as string[];
return argv.slice(argv.indexOf('-H') + 3); // after "-H -t <pane>"
};
it('sends a line feed for Shift+Enter to a CLI that declares nothing', async () => {
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
expect(await sentHex('opencode', 'S-Enter')).toEqual(['0a']);
});
it('sends Esc+Enter for Shift+Enter to a CLI that declares esc-enter', async () => {
expect(await sentHex('codex', 'S-Enter')).toEqual(['1b', '0d']);
});
it('always sends a line feed for Ctrl+Enter', async () => {
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
expect(await sentHex('claude', 'C-Enter')).toEqual(['0a']);
});
it('falls back to a line feed for a mode the registry does not know', async () => {
expect(await sentHex('no-such-cli', 'S-Enter')).toEqual(['0a']);
});
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({