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
+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({