fix(terminal): swallow Shift+Enter keypress so it no longer submits

xterm runs the custom key handler for keypress too and drops Ctrl/Alt
keypresses but not Shift-only ones, so the stray \r submitted the prompt
after the newline. Swallow every event type for Shift/Ctrl+Enter and send
only on keydown, in the primary pane and Pane B. Adds a static guard and a
real xterm + Chromium browser test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-10-02 19:44:58 +08:00
co-authored by Claude Sonnet 5.5
parent 9240493c43
commit c9a5fdab00
6 changed files with 124 additions and 10 deletions
@@ -0,0 +1,21 @@
// @vitest-environment node
// Regression guard: xterm runs the custom key handler for keydown AND keypress.
// It discards a keypress carrying Ctrl/Alt but not one carrying only Shift, so
// a handler that returns false for keydown alone lets Shift+Enter's keypress
// through as a bare \r (submit). The Enter gate must therefore not be keyed on
// ev.type === 'keydown'; only the send-key fetch is.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => {
const src = readFileSync(join(PUBLIC, file), 'utf8');
it('swallows every event type for Shift/Ctrl+Enter', () => {
expect(src).toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\)\) \{/);
expect(src).not.toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\) && ev\.type === 'keydown'/);
});
});
+81
View File
@@ -0,0 +1,81 @@
// @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.
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';
const XTERM = readFileSync(join(process.cwd(), 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
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;
};
}
}
describe('Shift/Ctrl+Enter reach the PTY as a bare \\r only if the handler lets keypress through', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
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();
});
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);
await page.keyboard.press(key);
return page.evaluate((w) => [...window.__t[w].sent], which);
}
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([]);
});
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('leaves plain Enter and Alt+Enter alone', async () => {
expect(await typed('shipped', 'Enter')).toEqual(['\r']);
expect(await typed('shipped', 'Alt+Enter')).toEqual(['\x1b\r']);
});
});