diff --git a/packages/app/e2e/browser/terminal-find.spec.ts b/packages/app/e2e/browser/terminal-find.spec.ts index 00f80ff4cb..56159b49c4 100644 --- a/packages/app/e2e/browser/terminal-find.spec.ts +++ b/packages/app/e2e/browser/terminal-find.spec.ts @@ -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"); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 020caa23c7..b173545abc 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -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 }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 0108815add..a11cedbfe2 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -241,6 +241,8 @@ describe("terminal-emulator-runtime", () => { hasSelection: () => false, getSelection: () => "", paste: () => {}, + input: () => {}, + scrollToBottom: () => {}, }); expect(passedToXterm).toBe(!opensFind); expect(findRequests).toBe(opensFind ? 1 : 0); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 49c667e7eb..cc4462ff7b 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -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, 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(); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index bddf068153..c7fc27652e 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index 79d4f4820b..09ba53c182 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -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 {