refactor(terminal): share platform policy across keyboard shortcuts

Use the existing platform input for editing, Find, and clipboard behavior. Exercise Mac and Linux modifier chords through the app and a real PTY, and keep pending modifier chips authoritative.
This commit is contained in:
Mohamed Boudra
2026-10-03 13:15:29 +02:00
parent 70210f1071
commit e18f9e8f43
6 changed files with 112 additions and 33 deletions
@@ -199,6 +199,49 @@ async function quitVim(page: Page) {
});
}
interface TerminalChord {
key: string;
bytes: string;
}
async function openKeyboardCapture(page: Page) {
await test.step("Open a real terminal process that reports received bytes", async () => {
const terminal = await harness.createTerminal({
name: "Keyboard bytes",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
let received = Buffer.alloc(0);
process.stdin.on('data', chunk => {
received = Buffer.concat([received, chunk]);
process.stdout.write('\\r\\nBYTES:' + received.toString('hex') + ':END\\r\\n');
});
console.log('KEYBOARD_READY');
`,
],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expect.poll(() => getTerminalBufferText(page)).toContain("KEYBOARD_READY");
});
}
async function expectTerminalChords(page: Page, chords: TerminalChord[]) {
let received = "";
for (const chord of chords) {
await test.step(`${chord.key} sends ${JSON.stringify(chord.bytes)}`, async () => {
await pressTerminalShortcut(page, chord.key);
// A printable sentinel proves delivery even for chords expected to send nothing.
await input(page).pressSequentially("x");
received += Buffer.from(chord.bytes + "x").toString("hex");
await expect.poll(() => getTerminalBufferText(page)).toContain(`BYTES:${received}:END`);
});
}
await recordTerminalEvidence("keyboard-bytes", await getTerminalBufferText(page));
await page.screenshot({ path: test.info().outputPath("keyboard-chords.png") });
}
let harness: TerminalE2EHarness;
test.beforeEach(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" });
@@ -213,6 +256,28 @@ test.describe("macOS terminal shortcuts", () => {
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
});
test("encodes editing chords without confusing Control and Command", async ({ page }) => {
await openKeyboardCapture(page);
await expectTerminalChords(page, [
{ key: "Meta+ArrowLeft", bytes: "\x01" },
{ key: "Meta+ArrowRight", bytes: "\x05" },
{ key: "Alt+ArrowLeft", bytes: "\x1bb" },
{ key: "Alt+ArrowRight", bytes: "\x1bf" },
{ key: "Meta+Backspace", bytes: "\x15" },
{ key: "Control+c", bytes: "\x03" },
{ key: "Control+a", bytes: "\x01" },
{ key: "Control+e", bytes: "\x05" },
{ key: "Control+v", bytes: "\x16" },
{ key: "Control+f", bytes: "\x06" },
{ key: "ArrowLeft", bytes: "\x1b[D" },
{ key: "ArrowRight", bytes: "\x1b[C" },
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
{ key: "Control+Alt+ArrowLeft", bytes: "\x1b[1;7D" },
]);
await pressTerminalShortcut(page, "Meta+f");
await expect(query(page)).toBeFocused();
});
test("sends Control+f to cat and opens Find with Meta+f", async ({ page }) => {
await openControlCharacterTerminal(page);
await pressTerminalShortcut(page, "Control+f");
@@ -232,6 +297,26 @@ test.describe("macOS terminal shortcuts", () => {
});
});
test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }) => {
await openKeyboardCapture(page);
await expectTerminalChords(page, [
{ key: "Meta+ArrowLeft", bytes: "" },
{ key: "Meta+ArrowRight", bytes: "" },
{ key: "Alt+ArrowLeft", bytes: "\x1b[1;3D" },
{ key: "Alt+ArrowRight", bytes: "\x1b[1;3C" },
{ key: "Meta+Backspace", bytes: "\x7f" },
{ key: "Control+c", bytes: "\x03" },
{ key: "Control+a", bytes: "\x01" },
{ key: "Control+e", bytes: "\x05" },
{ key: "Control+ArrowLeft", bytes: "\x1b[1;5D" },
{ key: "Control+ArrowRight", bytes: "\x1b[1;5C" },
{ key: "Control+Backspace", bytes: "\x08" },
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
]);
await pressTerminalShortcut(page, "Control+f");
await expect(query(page)).toBeFocused();
});
test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => {
await openControlCharacterTerminal(page);
await pressTerminalShortcut(page, "Control+f");
@@ -77,7 +77,7 @@ interface CreateTerminalHostInput {
width: number;
height: number;
scrollback?: number;
isMacLikePlatform?: boolean;
isMac?: boolean;
}
function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
@@ -100,7 +100,9 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
const terminalKeys: TerminalKeyRecord[] = [];
const inputModeChanges: TerminalInputModeState[] = [];
const openedUrls: string[] = [];
const runtime = new TerminalEmulatorRuntime();
const runtime = new TerminalEmulatorRuntime(
input.isMac === undefined ? undefined : { isMac: input.isMac },
);
runtime.setCallbacks({
callbacks: {
onInput: (data) => {
@@ -125,7 +127,6 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
host,
initialSnapshot: null,
scrollback: input.scrollback ?? 10_000,
isMacLikePlatform: input.isMacLikePlatform,
theme: {
background: "#0b0b0b",
foreground: "#e6e6e6",
@@ -527,7 +528,7 @@ describe("terminal emulator runtime in a real browser", () => {
it("translates mac editing shortcuts into shell editing sequences", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: true });
const mounted = createTerminalHost({ width: 720, height: 360, isMac: true });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
@@ -550,9 +551,23 @@ describe("terminal emulator runtime in a real browser", () => {
expect(mounted.terminalKeys).toEqual([]);
});
it.each([true, false])("keeps pending modifier chips authoritative (isMac=%s)", async (isMac) => {
const mounted = createTerminalHost({ width: 720, height: 360, isMac });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.setPendingModifiers({
pendingModifiers: { ctrl: true, alt: false, shift: false },
});
dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true });
await nextFrame();
expect(mounted.inputs).toEqual([]);
expect(mounted.terminalKeys).toEqual([
{ key: "ArrowLeft", ctrl: true, alt: true, shift: false, meta: false },
]);
});
it("keeps cmd+shift+arrow free for app-level shortcuts", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: true });
const mounted = createTerminalHost({ width: 720, height: 360, isMac: true });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
@@ -571,7 +586,7 @@ describe("terminal emulator runtime in a real browser", () => {
it("does not remap modified arrows on non-mac platforms", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: false });
const mounted = createTerminalHost({ width: 720, height: 360, isMac: false });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
@@ -241,6 +241,8 @@ describe("terminal-emulator-runtime", () => {
hasSelection: () => false,
getSelection: () => "",
paste: () => {},
input: () => {},
scrollToBottom: () => {},
});
expect(passedToXterm).toBe(!opensFind);
expect(findRequests).toBe(opensFind ? 1 : 0);
@@ -17,7 +17,6 @@ import {
type PendingTerminalModifiers,
hasPendingTerminalModifiers,
isAppleHandheldPlatform,
isMacLikePlatform,
isTerminalModifierDomKey,
mergeTerminalModifiers,
normalizeDomTerminalKey,
@@ -60,7 +59,6 @@ export interface TerminalEmulatorRuntimeMountInput {
theme: ITheme;
fontFamily?: string;
fontSize?: number;
isMacLikePlatform?: boolean;
}
export interface TerminalEmulatorRuntimeCallbacks {
@@ -146,16 +144,6 @@ declare global {
}
}
function detectMacLikePlatform(): boolean {
return (
typeof navigator !== "undefined" &&
isMacLikePlatform({
userAgent: navigator.userAgent,
platform: (navigator as Navigator & { platform?: string }).platform,
})
);
}
const isAppleHandheld =
typeof navigator !== "undefined" &&
isAppleHandheldPlatform({
@@ -262,7 +250,6 @@ export class TerminalEmulatorRuntime {
private hasUngatedWrites = false;
private readonly inputModeDecoder = new TextDecoder();
private suppressInput = false;
private isMacLikePlatform = false;
private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
private themeBackgroundElements: HTMLElement[] = [];
@@ -395,7 +382,7 @@ export class TerminalEmulatorRuntime {
...modifiers,
});
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
if (hasPendingTerminalModifiers(this.pendingModifiers)) {
this.callbacks.onPendingModifiersConsumed?.();
}
@@ -454,7 +441,7 @@ export class TerminalEmulatorRuntime {
terminal: Pick<Terminal, "input" | "scrollToBottom">,
event: KeyboardEvent,
): boolean {
if (!this.isMacLikePlatform || hasPendingTerminalModifiers(this.pendingModifiers)) {
if (!this.options.isMac || hasPendingTerminalModifiers(this.pendingModifiers)) {
return false;
}
const editingShortcutData = resolveMacTerminalEditingShortcut(event);
@@ -475,7 +462,6 @@ export class TerminalEmulatorRuntime {
input.host.innerHTML = "";
this.lastSize = null;
this.isMacLikePlatform = input.isMacLikePlatform ?? detectMacLikePlatform();
this.inputModeTracker.reset();
this.emitInputModeChange();
File diff suppressed because one or more lines are too long
+1 -10
View File
@@ -82,21 +82,12 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers)
return modifiers.ctrl || modifiers.shift || modifiers.alt;
}
interface MacPlatformDetectionInput {
interface AppleHandheldDetectionInput {
userAgent: string | null | undefined;
platform: string | null | undefined;
}
interface AppleHandheldDetectionInput extends MacPlatformDetectionInput {
maxTouchPoints: number | null | undefined;
}
// Matches macOS and iPadOS (which reports a Mac user agent in WKWebView); both
// follow the same cmd/option editing conventions on hardware keyboards.
export function isMacLikePlatform(input: MacPlatformDetectionInput): boolean {
return /Macintosh|Mac OS/i.test(input.userAgent ?? "") || /Mac/i.test(input.platform ?? "");
}
// iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish
// iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS.
export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean {