From f39c66e4e89568d8581251d501f488f1399b15b7 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:53:31 +0800 Subject: [PATCH] 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 --- .changeset/newline-capability-key-tester.md | 5 ++ config/test-suites.ts | 1 + docs/cli-registry.md | 4 ++ src/config/cli-registry/schema.ts | 1 + src/config/cli-registry/stock.ts | 3 ++ src/config/cli-registry/types.ts | 11 +++++ src/web/public/index.html | 16 +++++++ src/web/public/settings-ui.js | 21 ++++++++ src/web/routes/session-routes.ts | 21 +++++--- test/cli-newline-capability.test.ts | 37 ++++++++++++++ test/key-tester.browser.test.ts | 53 +++++++++++++++++++++ test/routes/session-routes.test.ts | 42 ++++++++++++++++ 12 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 .changeset/newline-capability-key-tester.md create mode 100644 test/cli-newline-capability.test.ts create mode 100644 test/key-tester.browser.test.ts diff --git a/.changeset/newline-capability-key-tester.md b/.changeset/newline-capability-key-tester.md new file mode 100644 index 00000000..6bafdb98 --- /dev/null +++ b/.changeset/newline-capability-key-tester.md @@ -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. diff --git a/config/test-suites.ts b/config/test-suites.ts index 9f3ed295..8414a767 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -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', ]; diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 351d6e49..8b80534a 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -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: diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index b72c58fd..83a22b9d 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -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({ diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 465da26a..f47cd699 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -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: { diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index c39007cd..767d417c 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -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; /** @@ -511,6 +514,14 @@ export interface CliCapabilities { gates: Record; /** 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 diff --git a/src/web/public/index.html b/src/web/public/index.html index 40d19750..73f62d05 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1822,6 +1822,22 @@ + +
+

Key tester

device
+
+
+
+ Key tester + 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. +
+ +
+ +
+
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 6c5208e8..27783cdb 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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; } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6a7b78ad..d76cdde6 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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; const key = typeof body?.key === 'string' ? body.key : ''; - // Map key names to hex byte sequences - const KEY_HEX_MAP: Record = { - '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 = { + '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). diff --git a/test/cli-newline-capability.test.ts b/test/cli-newline-capability.test.ts new file mode 100644 index 00000000..0c9017bd --- /dev/null +++ b/test/cli-newline-capability.test.ts @@ -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).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).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).newline; + expect(CliEntrySchema.safeParse(e).success).toBe(true); + }); +}); diff --git a/test/key-tester.browser.test.ts b/test/key-tester.browser.test.ts new file mode 100644 index 00000000..c3295997 --- /dev/null +++ b/test/key-tester.browser.test.ts @@ -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(''); + }); +}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index cc9c9422..c1990e91 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -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 => { + 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 " + }; + + 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({