From 306b4578aaa5c55f27add2a75607fd5eb347eca2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:28:30 -0700 Subject: [PATCH] Enable Option shortcuts for ABC keyboards in Auto mode (#24528) * Clarify Option shortcut settings and cover punctuation input * Enable Auto Option shortcuts on ABC keyboards safely --- src/main/ipc/app.test.ts | 145 +++---- src/main/ipc/app.ts | 64 +-- .../settings/TerminalMacKeyboardSection.tsx | 59 ++- .../terminal-option-punctuation.test.ts | 179 +++++++++ src/renderer/src/i18n/locales/en.json | 6 + src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/fr.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 8 +- .../keyboard-layout/detect-option-as-alt.ts | 11 +- .../keyboard-layout/input-source-id.test.ts | 26 +- .../lib/keyboard-layout/input-source-id.ts | 16 +- .../option-as-alt-probe.test.ts | 247 +++++++++--- .../keyboard-layout/option-as-alt-probe.ts | 31 +- .../terminal-option-composed-ascii.spec.ts | 375 +++++++++++------- tests/e2e/terminal-option-key-input.ts | 210 ++++++++++ 17 files changed, 1033 insertions(+), 368 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-option-punctuation.test.ts create mode 100644 tests/e2e/terminal-option-key-input.ts diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e52668e1ce9..aba55bee6e5 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -275,44 +275,53 @@ describe('registerAppHandlers', () => { expect(appExitMock).not.toHaveBeenCalled() }) - it('returns the selected macOS input mode before the keyboard layout fallback', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - spawnMock.mockImplementation(() => - createFakeSpawnChild({ - stdout: JSON.stringify([ - { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' }, - { - 'Bundle ID': 'com.apple.inputmethod.SCIM', - 'Input Mode': 'com.apple.inputmethod.SCIM.ITABC', - InputSourceKind: 'Input Mode' - } - ]) - }) - ) - registerAppHandlers({} as never) + it.each([true, false])( + 'prioritizes the selected input mode regardless of record order (%s)', + async (modeLast) => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + const inputMode = { + 'Bundle ID': 'com.apple.inputmethod.SCIM', + 'Input Mode': 'com.apple.inputmethod.SCIM.ITABC', + InputSourceKind: 'Input Mode' + } + const keyboardLayout = { + InputSourceKind: 'Keyboard Layout', + 'KeyboardLayout Name': 'ABC', + 'KeyboardLayout ID': 252 + } + spawnMock.mockImplementation(() => + createFakeSpawnChild({ + stdout: JSON.stringify([ + { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' }, + ...(modeLast ? [keyboardLayout, inputMode] : [inputMode, keyboardLayout]) + ]) + }) + ) + registerAppHandlers({} as never) - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.inputmethod.SCIM.ITABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(1) - // Why: macOS 15's `plutil -extract json` aborts on the input-source - // array, so the probe reads live cfprefsd via `defaults export` and dodges - // the bug with an xml1 extract before converting the clean subtree to JSON. - // Pin the exact pipeline (absolute paths, stdin markers) so dropping any - // stage silently regressing CJK detection to the fallback fails the test. - expect(spawnMock).toHaveBeenCalledWith( - '/bin/sh', - [ - '-c', - '/usr/bin/defaults export com.apple.HIToolbox - | ' + - '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' + - '/usr/bin/plutil -convert json -o - -' - ], - expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) - ) - }) + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( + 'com.apple.inputmethod.SCIM.ITABC' + ) + expect(spawnMock).toHaveBeenCalledTimes(1) + // Why: macOS 15's `plutil -extract json` aborts on the input-source + // array, so the probe reads live cfprefsd via `defaults export` and dodges + // the bug with an xml1 extract before converting the clean subtree to JSON. + // Pin the exact pipeline (absolute paths, stdin markers) so dropping any + // stage silently regressing CJK detection to the fallback fails the test. + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + [ + '-c', + '/usr/bin/defaults export com.apple.HIToolbox - | ' + + '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' + + '/usr/bin/plutil -convert json -o - -' + ], + expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + ) + } + ) - it('falls back to the keyboard layout when no keyboard input mode is selected', async () => { + it('reads the layout ID only after a selected keyboard layout without a bundle ID is proved', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) spawnMock .mockImplementationOnce(() => @@ -321,6 +330,11 @@ describe('registerAppHandlers', () => { { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' + }, + { + InputSourceKind: 'Keyboard Layout', + 'KeyboardLayout Name': 'ABC', + 'KeyboardLayout ID': 252 } ]) }) @@ -339,42 +353,34 @@ describe('registerAppHandlers', () => { ) }) - it('falls back to the keyboard layout when the selected input source probe exits non-zero', async () => { + it.each([ + { name: 'nonzero exit', result: { code: 1 } }, + { name: 'spawn failure', result: { error: new Error('spawn ENOENT') } }, + { name: 'invalid JSON', result: { stdout: '{' } }, + { name: 'non-array JSON', result: { stdout: '{}' } }, + { name: 'empty records', result: { stdout: '[]' } }, + { name: 'unknown record', result: { stdout: '[{"InputSourceKind":"Unknown"}]' } }, + { + name: 'unidentified input mode', + result: { stdout: '[{"InputSourceKind":"Keyboard Layout"},{"InputSourceKind":"Input Mode"}]' } + }, + { + name: 'non-keyboard record', + result: { + stdout: + '[{"InputSourceKind":"Non Keyboard Input Method","Bundle ID":"com.apple.PressAndHold"}]' + } + } + ])('does not infer the backing layout after $name', async ({ result }) => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - // Why: reproduces macOS 15's `plutil` abort — the pipeline exits non-zero, so - // the probe rejects on the `close` branch and the handler falls back. - spawnMock - .mockImplementationOnce(() => createFakeSpawnChild({ code: 1 })) - .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) + spawnMock.mockImplementation(() => createFakeSpawnChild(result)) registerAppHandlers({} as never) - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.keylayout.ABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock).toHaveBeenLastCalledWith( - '/usr/bin/defaults', - ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], - expect.objectContaining({ detached: true }) - ) + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBeNull() + expect(spawnMock).toHaveBeenCalledTimes(1) }) - it('falls back to the keyboard layout when the selected input source probe fails to spawn', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - // Why: a spawn-level failure (ENOENT/EACCES) emits 'error'; the handler must - // still fall back rather than reject out of the IPC call. - spawnMock - .mockImplementationOnce(() => createFakeSpawnChild({ error: new Error('spawn ENOENT') })) - .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) - registerAppHandlers({} as never) - - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.keylayout.ABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(2) - }) - - it('falls back when macOS keyboard input source probes never report completion', async () => { + it('returns unknown and cleans up when the selected-source probe times out', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true })) registerAppHandlers({} as never) @@ -391,9 +397,8 @@ describe('registerAppHandlers', () => { expect(settled).toBe(true) await expect(resultPromise).resolves.toBeNull() - // Why: both wedged probes get a process-group SIGKILL (negative pid) so the - // shell and any orphaned `defaults`/`plutil` stages are reaped on timeout. - expect(processKillSpy).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(processKillSpy).toHaveBeenCalledTimes(1) expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL') }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 17b7663e11b..ea74a240c38 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -183,7 +183,9 @@ function readCommandStdout( }) } -function readSelectedInputSourceIdFromJson(stdout: string): string | null { +type SelectedKeyboardInputSource = { kind: 'inputSource'; id: string } | { kind: 'keyboardLayout' } + +function readSelectedInputSourceFromJson(stdout: string): SelectedKeyboardInputSource | null { let records: unknown try { records = JSON.parse(stdout) @@ -194,54 +196,62 @@ function readSelectedInputSourceIdFromJson(stdout: string): string | null { return null } + let hasSelectedKeyboardLayout = false for (const record of records.slice().toReversed()) { if (!record || typeof record !== 'object') { continue } - const fields = record as Record - const kind = typeof fields.InputSourceKind === 'string' ? fields.InputSourceKind : '' - if (kind.toLowerCase().includes('non keyboard')) { + const kind = + 'InputSourceKind' in record && typeof record.InputSourceKind === 'string' + ? record.InputSourceKind.trim().toLowerCase() + : '' + if (kind === 'keyboard layout') { + hasSelectedKeyboardLayout = true continue } - const inputMode = fields['Input Mode'] - if (typeof inputMode === 'string' && inputMode.trim()) { - return inputMode.trim() + if (kind.includes('non keyboard')) { + continue } - const bundleId = fields['Bundle ID'] - if (typeof bundleId === 'string' && bundleId.trim()) { - return bundleId.trim() + if (kind !== 'input mode' && kind !== 'keyboard input method') { + return null } + const inputMode = 'Input Mode' in record ? record['Input Mode'] : undefined + const bundleId = 'Bundle ID' in record ? record['Bundle ID'] : undefined + const id = typeof inputMode === 'string' && inputMode.trim() ? inputMode : bundleId + if (typeof id === 'string' && id.trim()) { + return { kind: 'inputSource', id: id.trim() } + } + return null } - return null + return hasSelectedKeyboardLayout ? { kind: 'keyboardLayout' } : null } -async function readSelectedKeyboardInputSourceId(): Promise { +async function readSelectedKeyboardInputSource(): Promise { try { const stdout = await readCommandStdout( '/bin/sh', ['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND], 'Selected keyboard input source probe timed out' ) - return readSelectedInputSourceIdFromJson(stdout) + return readSelectedInputSourceFromJson(stdout) } catch { return null } } -function readKeyboardLayoutInputSourceId(): Promise { - return readCommandStdout( - '/usr/bin/defaults', - ['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'], - 'Keyboard layout input source probe timed out' - ) -} - async function readKeyboardInputSourceId(): Promise { - const selectedInputSourceId = await readSelectedKeyboardInputSourceId() - if (selectedInputSourceId) { - return selectedInputSourceId + const selectedInputSource = await readSelectedKeyboardInputSource() + if (selectedInputSource?.kind === 'inputSource') { + return selectedInputSource.id } - return readKeyboardLayoutInputSourceId() + // An IME can use ABC underneath; the backing layout alone cannot identify the selected source. + return selectedInputSource?.kind === 'keyboardLayout' + ? readCommandStdout( + '/usr/bin/defaults', + ['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'], + 'Keyboard layout input source probe timed out' + ) + : null } export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void { @@ -270,7 +280,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('pwsh:isAvailable', (): Promise => isPwshAvailableAsync()) ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable()) - // Why: renderer layout fingerprint tags ABC/CJK-Roman as 'us', breaking Option+letter (#1205); HIToolbox prefs override it. + // The selected IME identity must win over its US-shaped backing keyboard layout. ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise => { if (process.platform !== 'darwin') { return null @@ -281,7 +291,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp const trimmed = stdout?.trim() ?? '' return trimmed.length > 0 ? trimmed : null } catch { - // Why: probe can fail (missing keys on first boot, sandbox) — treat as "no signal" and fall back to the fingerprint. + // A failed probe must not promote an IME's backing layout into an Alt default. return null } }) diff --git a/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx index f02cbf3d614..bade2bc7741 100644 --- a/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx +++ b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx @@ -16,10 +16,19 @@ export function TerminalMacKeyboardSection({ const detectedLayout = useDetectedOptionAsAlt() const detectedLayoutLabel = detectedLayout === 'us' - ? 'US English — Option sends Alt/Esc sequences' + ? translate( + 'settings.terminal.optionLayoutAlt', + 'ABC or U.S. — Option sends Alt/Esc sequences' + ) : detectedLayout === 'non-us' - ? 'non-US layout — Option composes characters like @, €, [, ]' - : 'unknown layout — Option composes characters (safe default)' + ? translate( + 'settings.terminal.optionLayoutCompose', + 'layout uses Option to compose characters like @, €, [, ]' + ) + : translate( + 'settings.terminal.optionLayoutUnknown', + 'unknown layout — Option composes characters (safe default)' + ) return ( <> @@ -49,29 +58,35 @@ export function TerminalMacKeyboardSection({ alignTop label={translate('auto.components.settings.TerminalPane.0a10420e1a', 'Option as Alt')} description={ - settings.terminalMacOptionAsAlt === 'auto' - ? translate( - 'auto.components.settings.TerminalPane.d21c493808', - 'Auto — detected: {{value0}}.', - { - value0: detectedLayoutLabel - } - ) - : settings.terminalMacOptionAsAlt === 'false' + <> + {settings.terminalMacOptionAsAlt === 'auto' ? translate( - 'auto.components.settings.TerminalPane.d8998bb328', - 'Option composes special characters for your keyboard layout.' + 'auto.components.settings.TerminalPane.d21c493808', + 'Auto — detected: {{value0}}.', + { + value0: detectedLayoutLabel + } ) - : settings.terminalMacOptionAsAlt === 'true' + : settings.terminalMacOptionAsAlt === 'false' ? translate( - 'auto.components.settings.TerminalPane.b62373091a', - 'Both Option keys send Alt/Esc sequences.' - ) - : translate( - 'auto.components.settings.TerminalPane.ce3aadf0b2', - 'The {{value0}} Option key sends Alt/Esc; the other composes special characters.', - { value0: settings.terminalMacOptionAsAlt } + 'auto.components.settings.TerminalPane.d8998bb328', + 'Option composes special characters for your keyboard layout.' ) + : settings.terminalMacOptionAsAlt === 'true' + ? translate( + 'auto.components.settings.TerminalPane.b62373091a', + 'Both Option keys send Alt/Esc sequences.' + ) + : translate( + 'auto.components.settings.TerminalPane.ce3aadf0b2', + 'The {{value0}} Option key sends Alt/Esc; the other composes special characters.', + { value0: settings.terminalMacOptionAsAlt } + )}{' '} + {translate( + 'settings.terminal.optionShortcutHint', + 'Choose Both for Option shortcuts, Off for accents and symbols, or Left/Right to use one Option key for each.' + )} + } control={ [0] +type OptionContext = Parameters[1] + +const punctuation = [ + { code: 'Semicolon', key: '…', base: ';', codePoint: 59 }, + { code: 'Period', key: '≥', base: '.', codePoint: 46 }, + { code: 'Comma', key: '≤', base: ',', codePoint: 44 } +] as const + +const altModes = [ + { macOptionAsAlt: 'true', optionKeyLocations: 0 }, + { macOptionAsAlt: 'left', optionKeyLocations: 1 }, + { macOptionAsAlt: 'right', optionKeyLocations: 2 } +] as const + +const composeModes = [ + { macOptionAsAlt: 'false', optionKeyLocations: 0 }, + { macOptionAsAlt: 'left', optionKeyLocations: 2 }, + { macOptionAsAlt: 'right', optionKeyLocations: 1 } +] as const + +function optionEvent(overrides: Partial): OptionEvent { + return { + key: '', + code: '', + altKey: true, + shiftKey: false, + metaKey: false, + ctrlKey: false, + repeat: false, + ...overrides + } +} + +function optionContext(overrides: Partial = {}): OptionContext { + return { + isMac: true, + macOptionAsAlt: 'true', + optionKeyLocations: 0, + getKittyKeyboardFlags: () => 1, + ...overrides + } +} + +describe.each(punctuation)('Option+$base punctuation ($code)', ({ code, key, base, codePoint }) => { + it.each([1, 7])('reports the configured Alt side under keyboard flags %i', (flags) => { + for (const mode of altModes) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => flags }) + ) + ).toEqual({ + type: 'sendInput', + data: `\x1b[${codePoint};3u`, + optionKittyRelease: flags === 7 ? { flags } : undefined + }) + } + }) + + it('reports repeats and one release for the original punctuation key', () => { + for (const mode of altModes) { + const sendInput = vi.fn() + const releases = createTerminalOptionKittyReleaseTracker() + const context = optionContext({ ...mode, getKittyKeyboardFlags: () => 7 }) + for (const repeat of [false, true]) { + const event = optionEvent({ code, key, repeat }) + const action = resolveTerminalOptionShortcutAction(event, context) + expect(action).toEqual({ + type: 'sendInput', + data: `\x1b[${codePoint};3${repeat ? ':2' : ''}u`, + optionKittyRelease: { flags: 7 } + }) + if (action?.type === 'sendInput' && action.optionKittyRelease) { + sendInput(action.data) + releases.arm(event, action.optionKittyRelease, sendInput, context.getKittyKeyboardFlags) + } + } + const release = optionEvent({ code, key: base, altKey: false }) + expect(releases.settle(release)).toBe(true) + expect(releases.settle(release)).toBe(false) + expect(sendInput.mock.calls.map(([data]) => data)).toEqual([ + `\x1b[${codePoint};3u`, + `\x1b[${codePoint};3:2u`, + `\x1b[${codePoint};1:3u` + ]) + } + }) + + it.each([1, 7])('preserves the composed symbol on the text side under flags %i', (flags) => { + for (const mode of composeModes) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => flags }) + ) + ).toEqual({ + type: 'sendInput', + data: key, + optionKittyRelease: flags === 7 ? { flags } : undefined + }) + } + }) + + it('uses legacy escape bytes for a selected Alt side in an ordinary shell', () => { + for (const mode of altModes.slice(1)) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => 0 }) + ) + ).toEqual({ type: 'sendInput', data: `\x1b${base}` }) + } + for (const mode of [altModes[0], ...composeModes]) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => 0 }) + ) + ).toBeNull() + } + }) + + it.each([{ isComposing: true }, { keyCode: 229 }, { key: 'Process' }, { key: 'Unidentified' }])( + 'leaves IME-owned punctuation to native input (%j)', + (imeState) => { + for (const mode of [...altModes, ...composeModes]) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key, ...imeState }), + optionContext({ ...mode, getKittyKeyboardFlags: () => 7 }) + ) + ).toBeNull() + } + } + ) + + it('leaves non-Mac input and additional command modifiers untouched', () => { + for (const overrides of [{ isMac: false }, { isMac: true }]) { + const context = optionContext(overrides) + const events = + overrides.isMac === false + ? [optionEvent({ code, key })] + : [optionEvent({ code, key, ctrlKey: true }), optionEvent({ code, key, metaKey: true })] + for (const event of events) { + expect(resolveTerminalOptionShortcutAction(event, context)).toBeNull() + } + } + }) +}) + +it('uses the active layout character rather than the US punctuation position', () => { + const event = optionEvent({ code: 'Semicolon', key: 'µ' }) + const layout = (code: string): string | undefined => (code === 'Semicolon' ? 'm' : undefined) + for (const mode of altModes) { + expect( + resolveTerminalOptionShortcutAction( + event, + optionContext({ ...mode, layoutCharacterForCode: layout, getKittyKeyboardFlags: () => 7 }) + ) + ).toEqual({ + type: 'sendInput', + data: '\x1b[109::59;3u', + optionKittyRelease: { flags: 7 } + }) + } + for (const mode of altModes.slice(1)) { + expect( + resolveTerminalOptionShortcutAction( + event, + optionContext({ ...mode, layoutCharacterForCode: layout, getKittyKeyboardFlags: () => 0 }) + ) + ).toEqual({ type: 'sendInput', data: '\x1bm' }) + } +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 48f6eec3cb1..21fcdf4beea 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -127,6 +127,12 @@ "retrying": "Retrying…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC or U.S. — Option sends Alt/Esc sequences", + "optionLayoutCompose": "layout uses Option to compose characters like @, €, [, ]", + "optionLayoutUnknown": "unknown layout — Option composes characters (safe default)", + "optionShortcutHint": "Choose Both for Option shortcuts, Off for accents and symbols, or Left/Right to use one Option key for each." + }, "appearance": { "language": { "title": "Language", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 84dffe050fd..c1dc27fd97d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11,6 +11,12 @@ "retrying": "Reintentando…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC o EE. UU. — Option envía secuencias Alt/Esc", + "optionLayoutCompose": "la distribución usa Option para escribir caracteres como @, €, [, ]", + "optionLayoutUnknown": "distribución desconocida — Option escribe caracteres (opción predeterminada segura)", + "optionShortcutHint": "Elige Ambos para los atajos con Option, Desactivado para acentos y símbolos, o Izquierda/Derecha para dedicar una tecla Option a cada uso." + }, "appearance": { "language": { "title": "Idioma", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d8086183f21..cc9d5683689 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -110,6 +110,12 @@ "retrying": "Nouvelle tentative…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC ou U.S. — Option envoie des séquences Alt/Esc", + "optionLayoutCompose": "la disposition utilise Option pour composer des caractères comme @, €, [, ]", + "optionLayoutUnknown": "disposition inconnue — Option compose des caractères (choix sûr par défaut)", + "optionShortcutHint": "Choisissez Les deux pour les raccourcis Option, Désactivé pour les accents et les symboles, ou Gauche/Droite pour attribuer un usage à chaque touche Option." + }, "appearance": { "language": { "title": "Langue", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0997b8b616b..485f45e6e6f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11,6 +11,12 @@ "retrying": "再試行中…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABCまたはU.S. — OptionはAlt/Escシーケンスを送信します", + "optionLayoutCompose": "この配列ではOptionで@、€、[、]などの文字を入力します", + "optionLayoutUnknown": "不明な配列 — Optionで文字を入力します(安全な既定値)", + "optionShortcutHint": "Optionショートカットには「両方」、アクセント付き文字や記号には「オフ」、用途を左右のOptionキーに分けるには「左」または「右」を選んでください。" + }, "appearance": { "language": { "title": "言語", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 29149cd5a65..23f07a9d2e9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11,6 +11,12 @@ "retrying": "재시도 중…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC 또는 U.S. — Option 키가 Alt/Esc 시퀀스를 전송합니다", + "optionLayoutCompose": "이 배열에서는 Option 키로 @, €, [, ] 같은 문자를 입력합니다", + "optionLayoutUnknown": "알 수 없는 배열 — Option 키로 문자를 입력합니다(안전한 기본값)", + "optionShortcutHint": "Option 단축키에는 둘 다, 악센트 문자와 기호에는 끄기, 각 Option 키를 다른 용도로 쓰려면 왼쪽 또는 오른쪽을 선택하세요." + }, "appearance": { "language": { "title": "언어", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d907237dea8..6b13b751dd6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11,6 +11,12 @@ "retrying": "正在重试..." }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC 或 U.S. — Option 键发送 Alt/Esc 序列", + "optionLayoutCompose": "此布局使用 Option 键输入 @、€、[、] 等字符", + "optionLayoutUnknown": "未知布局 — Option 键输入字符(安全的默认设置)", + "optionShortcutHint": "选择“两个都”使用 Option 快捷键,选择“关”输入重音字符和符号,或选择“左边”/“右边”让两个 Option 键各司其职。" + }, "appearance": { "language": { "title": "语言", @@ -8549,7 +8555,7 @@ "19f4935159": "JIS 日元 (¥) 至 反斜杠 (\\\\)", "1c337bef4a": "控制按 JIS 日元 (¥) 键是否发送反斜杠 (\\\\)。", "3fe1c5bfe0": "关", - "c73d510938": "正确的", + "c73d510938": "右边", "e7aec1fd60": "左边", "badb1219fc": "两个都", "43c2ff7b0e": "自动", diff --git a/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts b/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts index da681a78954..e45faa0ed36 100644 --- a/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts +++ b/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts @@ -10,11 +10,12 @@ * The only defensible default is the one that varies per layout. This * module fingerprints the active layout from Chromium's * navigator.keyboard.getLayoutMap() (ships in Chrome 69+, so every Electron - * we could run). The base layer cannot separate US from US-International or - * ABC, so every US-shaped layout maps to `true` here and `input-source-id.ts` - * narrows that to plain US whenever macOS gives us the real input source ID. + * we could run). The base layer cannot separate standard ABC/US from + * US-International, so every US-shaped layout maps to `true` here and + * `input-source-id.ts` narrows that to ABC and US using the native input source ID. * Everything else — Dvorak, Colemak, UK, every international layout — maps - * to `false`. + * to `false`. Missing native identity on macOS also stays conservative: + * an IME can expose a US-shaped backing layout without identifying itself. * */ @@ -59,7 +60,7 @@ export type DetectedLayoutCategory = * Semicolon (`o` vs `;`). Dvorak fails KeyQ immediately. Both get classified * as `non-us` and default to `'false'`; users who want `'true'` flip the * explicit override. The native input-source classifier distinguishes - * plain US from US-shaped composition layouts. + * standard ABC/US from US-shaped international composition layouts. */ const US_FINGERPRINT: Record = { KeyQ: 'q', diff --git a/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts b/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts index 215d19666a7..4ba160a6632 100644 --- a/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts +++ b/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts @@ -8,8 +8,8 @@ describe('classifyInputSourceId', () => { expect(classifyInputSourceId('')).toBe('unknown') }) - it('allowlists plain US Standard as meta', () => { - expect(classifyInputSourceId('com.apple.keylayout.US')).toBe('meta') + it.each(['US', 'ABC'])('allowlists standard %s as meta', (name) => { + expect(classifyInputSourceId(`com.apple.keylayout.${name}`)).toBe('meta') }) it('classifies US International PC as compose (Option+C → ç repro)', () => { @@ -19,13 +19,8 @@ describe('classifyInputSourceId', () => { it('is case-insensitive on the allowlist (defaults differ between macOS versions)', () => { expect(classifyInputSourceId('COM.APPLE.KEYLAYOUT.US')).toBe('meta') expect(classifyInputSourceId('com.apple.keylayout.us')).toBe('meta') - }) - - it('classifies ABC as compose (the user-reported Option+A → å repro)', () => { - // ABC looks US on the base layer but composes Option+A → å. Pre-fix, - // the fingerprint alone drove the decision and flipped - // macOptionIsMeta=true, silently swallowing the composition. - expect(classifyInputSourceId('com.apple.keylayout.ABC')).toBe('compose') + expect(classifyInputSourceId('COM.APPLE.KEYLAYOUT.ABC')).toBe('meta') + expect(classifyInputSourceId('com.apple.keylayout.abc')).toBe('meta') }) it('classifies Polish Pro as compose (#1205)', () => { @@ -38,9 +33,7 @@ describe('classifyInputSourceId', () => { }) it('classifies every other Apple-shipped layout as compose (default-deny)', () => { - // Only plain US is allowlisted; everything else (Dvorak, Colemak, - // German, French, Turkish, Spanish, Swedish, every CJK Roman IME) - // falls back to compose. + // Only standard ABC/US are allowlisted; other layouts retain composition. expect(classifyInputSourceId('com.apple.keylayout.Dvorak')).toBe('compose') expect(classifyInputSourceId('com.apple.keylayout.Colemak')).toBe('compose') expect(classifyInputSourceId('com.apple.keylayout.German')).toBe('compose') @@ -51,10 +44,13 @@ describe('classifyInputSourceId', () => { expect(classifyInputSourceId('com.apple.inputmethod.Korean.2SetKorean')).toBe('compose') }) - it('does not prefix-leak the US allowlist into extended variants', () => { - // `com.apple.keylayout.US` must not silently allowlist `USExtended`. - // The matcher is full-ID equality (case-insensitive), not prefix. + it('does not prefix-leak the standard allowlist into extended or custom variants', () => { expect(classifyInputSourceId('com.apple.keylayout.USExtended')).toBe('compose') expect(classifyInputSourceId('com.apple.keylayout.US.variant')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABCExtended')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABC.variant')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABCInternational')).toBe('compose') + expect(classifyInputSourceId('org.custom.keylayout.ABC')).toBe('compose') + expect(classifyInputSourceId('unknown')).toBe('compose') }) }) diff --git a/src/renderer/src/lib/keyboard-layout/input-source-id.ts b/src/renderer/src/lib/keyboard-layout/input-source-id.ts index 4f5022fb739..1eb2906ffeb 100644 --- a/src/renderer/src/lib/keyboard-layout/input-source-id.ts +++ b/src/renderer/src/lib/keyboard-layout/input-source-id.ts @@ -1,17 +1,19 @@ -// The base-layer probe cannot distinguish US from layouts whose Option layer composes text. -const META_INPUT_SOURCE_IDS: readonly string[] = ['com.apple.keylayout.us'] +// ABC and US share the standard layout; international variants retain Option composition. +const META_INPUT_SOURCE_IDS: readonly string[] = [ + 'com.apple.keylayout.us', + 'com.apple.keylayout.abc' +] export type InputSourceOverride = - /** Option-as-Meta is safe on this input source. Resolves to `'us'` + /** Auto uses Option-as-Meta on this standard input source. Resolves to `'us'` * for `effectiveMacOptionAsAlt`. */ | 'meta' /** Option composes layout characters on this input source. Resolves * to `'non-us'` so `macOptionIsMeta` stays off and compositions like - * Option+A → å / ą reach the shell. */ + * Option+A → ą reach the shell. */ | 'compose' - /** No macOS input source ID available (non-Darwin, IPC failure, - * sandboxed defaults). The caller should fall back to the layout - * fingerprint. */ + /** No input source ID available. macOS stays conservative; other + * platforms may use the layout fingerprint. */ | 'unknown' export function classifyInputSourceId(id: string | null | undefined): InputSourceOverride { diff --git a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts index 3af50308e8c..05b777b2254 100644 --- a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts +++ b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createOptionAsAltProbe } from './option-as-alt-probe' -import type { LayoutMapLike } from './detect-option-as-alt' +import { effectiveMacOptionAsAlt, type LayoutMapLike } from './detect-option-as-alt' import type { KeyboardLayoutChangeEvent } from '../../../../shared/keyboard-layout-events' const US_MAP: LayoutMapLike = { @@ -37,6 +37,7 @@ const TURKISH_MAP: LayoutMapLike = { type MockWindow = { navigator: { + userAgent: string keyboard?: { getLayoutMap: () => Promise } } addEventListener: (type: string, fn: EventListener) => void @@ -44,11 +45,12 @@ type MockWindow = { fireFocus: () => void } -function makeMockWindow(initial: LayoutMapLike | null): MockWindow { +function makeMockWindow(initial: LayoutMapLike | null, userAgent = 'Linux'): MockWindow { const focusListeners = new Set() let current = initial return { navigator: { + userAgent, keyboard: current ? { getLayoutMap: vi.fn(async () => current!) @@ -82,24 +84,29 @@ describe('createOptionAsAltProbe', () => { vi.unstubAllGlobals() }) - it('uses the native snapshot identity before the preference fallback', async () => { - const getKeyboardLayoutSnapshot = vi.fn(async () => ({ - inputSourceId: 'com.apple.keylayout.ABC', - keyCharacters: {} - })) - const getKeyboardInputSourceId = vi.fn(async () => 'com.apple.keylayout.US') - vi.stubGlobal('window', { - api: { app: { getKeyboardLayoutSnapshot, getKeyboardInputSourceId } } - }) - const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window) + it.each(['com.apple.keylayout.ABCExtended', 'com.apple.inputmethod.SCIM.ITABC'])( + 'uses the native input source %s before its backing layout or preference', + async (inputSourceId) => { + const getKeyboardLayoutSnapshot = vi.fn(async () => ({ + inputSourceId, + layoutSourceId: 'com.apple.keylayout.ABC', + keyCharacters: {} + })) + const getKeyboardInputSourceId = vi.fn(async () => 'com.apple.keylayout.US') + vi.stubGlobal('window', { + api: { app: { getKeyboardLayoutSnapshot, getKeyboardInputSourceId } } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(makeMockWindow(US_MAP, 'Macintosh') as unknown as Window) - await probe.refresh() + await probe.refresh() - expect(probe.getCurrent()).toBe('non-us') - expect(getKeyboardLayoutSnapshot).toHaveBeenCalled() - expect(getKeyboardInputSourceId).not.toHaveBeenCalled() - probe.dispose() - }) + expect(probe.getCurrent()).toBe('non-us') + expect(getKeyboardLayoutSnapshot).toHaveBeenCalled() + expect(getKeyboardInputSourceId).not.toHaveBeenCalled() + probe.dispose() + } + ) it('starts as unknown, upgrades after first probe resolves', async () => { const win = makeMockWindow(US_MAP) @@ -158,8 +165,8 @@ describe('createOptionAsAltProbe', () => { probe.dispose() }) - it('invalidates immediately and refreshes on a native layout-change notification', async () => { - let activeInputSourceId = 'com.apple.keylayout.US' + it('updates Auto on ABC/international switches while preserving every explicit mode', async () => { + let activeInputSourceId = 'com.apple.keylayout.ABC' let notifyLayoutChanged: (() => void) | undefined const unsubscribe = vi.fn() const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window, { @@ -169,15 +176,29 @@ describe('createOptionAsAltProbe', () => { return unsubscribe } }) + const expectEffectiveModes = (automatic: 'true' | 'false') => { + expect(effectiveMacOptionAsAlt('auto', probe.getCurrent())).toBe(automatic) + for (const mode of ['true', 'false', 'left', 'right'] as const) { + expect(effectiveMacOptionAsAlt(mode, probe.getCurrent())).toBe(mode) + } + } await probe.refresh() expect(probe.getCurrent()).toBe('us') + expectEffectiveModes('true') - activeInputSourceId = 'com.apple.keylayout.ABC' - notifyLayoutChanged?.() - expect(probe.getCurrent()).toBe('unknown') - await Promise.resolve() - await Promise.resolve() - expect(probe.getCurrent()).toBe('non-us') + for (const [id, category, automatic] of [ + ['USInternational-PC', 'non-us', 'false'], + ['ABC', 'us', 'true'] + ] as const) { + activeInputSourceId = `com.apple.keylayout.${id}` + notifyLayoutChanged?.() + expect(probe.getCurrent()).toBe('unknown') + expectEffectiveModes('false') + await Promise.resolve() + await Promise.resolve() + expect(probe.getCurrent()).toBe(category) + expectEffectiveModes(automatic) + } probe.dispose() expect(unsubscribe).toHaveBeenCalledOnce() @@ -192,7 +213,7 @@ describe('createOptionAsAltProbe', () => { const readInputSourceId = vi .fn<() => Promise>() .mockReturnValueOnce(oldRead) - .mockResolvedValue('com.apple.keylayout.ABC') + .mockResolvedValue('com.apple.keylayout.PolishPro') const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window, { readInputSourceId, subscribeKeyboardLayoutChanged: (callback) => { @@ -202,7 +223,7 @@ describe('createOptionAsAltProbe', () => { }) notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) - finishOldRead('com.apple.keylayout.US') + finishOldRead('com.apple.keylayout.ABC') await Promise.resolve() await Promise.resolve() expect(probe.getCurrent()).toBe('unknown') @@ -281,11 +302,13 @@ describe('createOptionAsAltProbe', () => { }) it('forces non-us when the input source ID is not on the Option-as-Meta allowlist (#1205)', async () => { - // ABC and Polish Pro both report a US-identical base layer to - // getLayoutMap(); without the input-source override they would classify - // as 'us' → macOptionIsMeta=true and swallow every Option+letter - // composition (Option+A → å on ABC, ą on Polish Pro). - for (const id of ['com.apple.keylayout.ABC', 'com.apple.keylayout.PolishPro']) { + // The native ID protects composition even when the base layer matches US. + for (const id of [ + 'com.apple.keylayout.USInternational-PC', + 'com.apple.keylayout.USExtended', + 'com.apple.keylayout.ABCExtended', + 'com.apple.keylayout.PolishPro' + ]) { const win = makeMockWindow(US_MAP) const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId: async () => id @@ -296,27 +319,98 @@ describe('createOptionAsAltProbe', () => { } }) - it('resolves to us when the input source ID is plain US (allowlist match)', async () => { - const win = makeMockWindow(US_MAP) - const probe = createOptionAsAltProbe(win as unknown as Window, { - readInputSourceId: async () => 'com.apple.keylayout.US' - }) + it.each(['US', 'ABC'])( + 'resolves to us for standard %s without a browser layout map', + async (id) => { + const win = makeMockWindow(null, 'Macintosh') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => `com.apple.keylayout.${id}` + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + probe.dispose() + } + ) + + it.each(['Linux', 'Windows'])( + 'uses the fingerprint without native identity on %s', + async (userAgent) => { + const win = makeMockWindow(US_MAP, userAgent) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => null + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + probe.dispose() + } + ) + + it.each(['unavailable', 'rejected'] as const)( + 'stays conservative on macOS when current-source identity is %s', + async (result) => { + const win = makeMockWindow(US_MAP, 'Macintosh') + const readInputSourceId = vi.fn(async () => { + if (result === 'rejected') { + throw new Error('identity unavailable') + } + return null + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId }) + await probe.refresh() + expect(probe.getCurrent()).toBe('unknown') + expect(effectiveMacOptionAsAlt('auto', probe.getCurrent())).toBe('false') + for (const mode of ['true', 'false', 'left', 'right'] as const) { + expect(effectiveMacOptionAsAlt(mode, probe.getCurrent())).toBe(mode) + } + expect(win.navigator.keyboard?.getLayoutMap).not.toHaveBeenCalled() + probe.dispose() + } + ) + + it('stays conservative on macOS without either native identity API', async () => { + vi.stubGlobal('window', { api: { app: {} } }) + const win = makeMockWindow(US_MAP, 'Macintosh') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window) await probe.refresh() - expect(probe.getCurrent()).toBe('us') + expect(probe.getCurrent()).toBe('unknown') + expect(win.navigator.keyboard?.getLayoutMap).not.toHaveBeenCalled() probe.dispose() }) - it('falls back to the fingerprint when the input-source reader returns null (non-Darwin)', async () => { - const win = makeMockWindow(US_MAP) + it('recovers macOS source identity after losing it without trusting the backing map', async () => { + let activeInputSourceId: string | null = 'com.apple.keylayout.ABC' + const win = makeMockWindow(US_MAP, 'Macintosh') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. const probe = createOptionAsAltProbe(win as unknown as Window, { - readInputSourceId: async () => null + readInputSourceId: async () => activeInputSourceId }) - await probe.refresh() - expect(probe.getCurrent()).toBe('us') + const listener = vi.fn() + probe.subscribe(listener) + for (const [id, category] of [ + ['com.apple.keylayout.ABC', 'us'], + [null, 'unknown'], + ['com.apple.inputmethod.SCIM.ITABC', 'non-us'], + ['com.apple.keylayout.ABC', 'us'] + ] as const) { + activeInputSourceId = id + await probe.refresh() + expect(probe.getCurrent()).toBe(category) + } + expect(listener.mock.calls.map(([category]) => category)).toEqual([ + 'us', + 'unknown', + 'non-us', + 'us' + ]) + expect(win.navigator.keyboard?.getLayoutMap).not.toHaveBeenCalled() probe.dispose() }) - it('falls back to the fingerprint when the input-source reader throws', async () => { + it('falls back to the fingerprint off macOS when the input-source reader throws', async () => { const win = makeMockWindow(TURKISH_MAP) const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId: async () => { @@ -329,10 +423,8 @@ describe('createOptionAsAltProbe', () => { }) it('re-probes the input source ID on focus-in so mid-session layout switches are picked up', async () => { - // Simulate: user boots on US, flips to ABC via the Input Source menu, - // Orca regains focus. Fingerprint stays US the whole time; the - // input-source override is what notices the switch. - let activeInputSourceId: string | null = 'com.apple.keylayout.US' + // The browser fingerprint stays US while the native identity changes. + let activeInputSourceId: string | null = 'com.apple.keylayout.ABC' const win = makeMockWindow(US_MAP) const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId: async () => activeInputSourceId @@ -340,7 +432,7 @@ describe('createOptionAsAltProbe', () => { await probe.refresh() expect(probe.getCurrent()).toBe('us') - activeInputSourceId = 'com.apple.keylayout.ABC' + activeInputSourceId = 'com.apple.keylayout.USInternational-PC' win.fireFocus() // Let the focus-triggered probe resolve. await Promise.resolve() @@ -368,13 +460,64 @@ describe('createOptionAsAltProbe', () => { }) const newestProbe = probe.refresh() - resolveNew('com.apple.keylayout.ABC') + resolveNew('com.apple.keylayout.PolishPro') await newestProbe expect(probe.getCurrent()).toBe('non-us') - resolveOld('com.apple.keylayout.US') + resolveOld('com.apple.keylayout.ABC') await Promise.resolve() await Promise.resolve() expect(probe.getCurrent()).toBe('non-us') probe.dispose() }) + + it.each(['before', 'after'] as const)( + 'fences superseded layout generations when the stale read resolves %s the newest', + async (order) => { + let activeRead: Promise = Promise.resolve('com.apple.keylayout.ABC') + let notifyLayoutChanged: ((event: KeyboardLayoutChangeEvent) => void) | undefined + const readInputSourceId = vi.fn(() => activeRead) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window, { + readInputSourceId, + subscribeKeyboardLayoutChanged: (callback) => { + notifyLayoutChanged = callback + return vi.fn() + } + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + const listener = vi.fn() + probe.subscribe(listener) + + const staleRead = Promise.withResolvers() + const newestRead = Promise.withResolvers() + activeRead = staleRead.promise + notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) + notifyLayoutChanged?.({ phase: 'refresh', generation: 1 }) + notifyLayoutChanged?.({ phase: 'invalidated', generation: 2 }) + const readsBeforeStaleNotifications = readInputSourceId.mock.calls.length + notifyLayoutChanged?.({ phase: 'refresh', generation: 1 }) + notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) + expect(readInputSourceId).toHaveBeenCalledTimes(readsBeforeStaleNotifications) + + activeRead = newestRead.promise + notifyLayoutChanged?.({ phase: 'refresh', generation: 2 }) + if (order === 'before') { + staleRead.resolve('com.apple.keylayout.PolishPro') + await Promise.resolve() + expect(probe.getCurrent()).toBe('unknown') + } + newestRead.resolve('com.apple.keylayout.ABC') + await Promise.resolve() + expect(probe.getCurrent()).toBe('us') + if (order === 'after') { + staleRead.resolve('com.apple.keylayout.PolishPro') + await Promise.resolve() + } + notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) + expect(probe.getCurrent()).toBe('us') + expect(listener.mock.calls.map(([category]) => category)).toEqual(['unknown', 'us']) + probe.dispose() + } + ) }) diff --git a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts index 2ae80dff636..5694969a91a 100644 --- a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts +++ b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts @@ -4,16 +4,12 @@ * Runs at boot, on native macOS input-source notifications, and on focus as a * fallback. The browser Keyboard API has no usable layout-change event. * - * Why two signals (input source ID + fingerprint): the fingerprint can - * only see the base (unshifted) layer, which is identical to US QWERTY - * on a large set of Apple-shipped layouts — ABC, Polish Pro, US - * Extended, ABC Extended, and every CJK Roman IME all trap on it. They - * repurpose Option for dead-key composition (Option+A → å / ą), so - * trusting the fingerprint alone makes macOptionIsMeta=true and - * silently swallows those characters (issue #1205). On macOS we treat - * the input source ID as authoritative and only fall back to the - * fingerprint when the ID is unavailable (non-Darwin, sandboxed - * defaults, IPC failure). See ./input-source-id.ts for the allowlist. + * The base-layer fingerprint cannot distinguish standard ABC/US from + * composition layouts such as Polish Pro, US Extended, ABC Extended, + * and CJK Roman IMEs. Native identity protects their Option text (#1205); + * macOS stays conservative without native identity; other platforms use + * the browser fingerprint as a fallback. + * See ./input-source-id.ts for the exact standard-layout allowlist. */ import { detectOptionAsAltFromLayoutMap, @@ -100,9 +96,7 @@ function defaultInputSourceIdReader(): InputSourceIdReader { try { return await reader() } catch { - // Why: the IPC can transiently reject during main-process teardown - // (e.g. app quitting mid-probe). Treat as no signal so the - // fingerprint remains the sole input. + // Missing identity stays conservative on macOS, including during teardown. return null } } @@ -118,6 +112,7 @@ export function createOptionAsAltProbe( let probeGeneration = 0 let layoutChangeGeneration = 0 let layoutRefreshBlocked = false + const isMac = win.navigator.userAgent.includes('Mac') const readInputSourceId = options.readInputSourceId ?? defaultInputSourceIdReader() const subscribeKeyboardLayoutChanged = options.subscribeKeyboardLayoutChanged ?? defaultKeyboardLayoutChangeSubscriber() @@ -144,14 +139,12 @@ export function createOptionAsAltProbe( const nav = win.navigator as NavigatorWithKeyboard const keyboard = nav?.keyboard - // Why: read the input-source ID first. On macOS this resolves to a - // concrete ID (e.g. com.apple.keylayout.ABC); on every other platform - // it resolves to null and we fall through to the fingerprint. + // Read current-source identity before trusting a potentially IME-backed base layer. let inputSourceId: string | null = null try { inputSourceId = await readInputSourceId() } catch { - // Treat errors as no signal — the fingerprint still runs below. + // Missing identity stays conservative on macOS. inputSourceId = null } @@ -169,6 +162,10 @@ export function createOptionAsAltProbe( notify('non-us') return } + if (isMac) { + notify('unknown') + return + } if (!keyboard?.getLayoutMap) { // Non-Chromium or Electron stripped of the Keyboard API. Stay at diff --git a/tests/e2e/terminal-option-composed-ascii.spec.ts b/tests/e2e/terminal-option-composed-ascii.spec.ts index 8aec5f4471c..a060d655900 100644 --- a/tests/e2e/terminal-option-composed-ascii.spec.ts +++ b/tests/e2e/terminal-option-composed-ascii.spec.ts @@ -1,161 +1,232 @@ // Option composition must survive kitty negotiation (#14024, #20171, #20850). import { test, expect } from './helpers/orca-app' -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { focusActiveTerminalInput, waitForTerminalOutput } from './helpers/terminal' +import { clearTerminalPtyWriteLog as clearPtyWriteLog } from './helpers/terminal-pty-write-spy' import { - execInTerminal, - waitForTerminalOutput, - waitForActiveTerminalManager, - waitForActivePanePtyId -} from './helpers/terminal' -import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' -import { - clearTerminalPtyWriteLog as clearPtyWriteLog, - installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, - readTerminalPtyWrites as getPtyWrites -} from './helpers/terminal-pty-write-spy' - -type MacOptionAsAltSetting = 'auto' | 'true' | 'false' | 'left' | 'right' - -async function setMacOptionAsAlt(page: Page, value: MacOptionAsAltSetting): Promise { - await page.evaluate(async (value) => { - await window.__store?.getState().updateSettings({ terminalMacOptionAsAlt: value }) - }, value) - await expect - .poll( - async () => - page.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt ?? null), - { timeout: 5_000, message: 'terminalMacOptionAsAlt did not apply' } - ) - .toBe(value) -} - -/** Reads the pane's mirrored kitty flags — the exact value the policy consults. */ -async function getPaneKittyKeyboardFlags(page: Page): Promise { - return page.evaluate(() => { - const state = window.__store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const terminal = pane?.terminal as - | { - core?: { coreService?: { kittyKeyboard?: { flags?: number } } } - _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } - } - | undefined - return ( - terminal?.core?.coreService?.kittyKeyboard?.flags ?? - terminal?._core?.coreService?.kittyKeyboard?.flags ?? - 0 - ) - }) -} - -/** - * Dispatches the keydown macOS delivers for an Option-composed key: `key` is - * already the composed glyph while `code` still names the physical key. - */ -async function pressOptionComposedKey( - page: Page, - press: { key: string; code: string; shiftKey?: boolean } -): Promise<{ keydownDefaultPrevented: boolean }> { - return page.evaluate((press) => { - const state = window.__store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const textarea = pane?.container.querySelector( - '.xterm-helper-textarea' - ) as HTMLTextAreaElement | null - if (!pane || !textarea) { - throw new Error('No active terminal textarea for the Option chord dispatch') - } - pane.terminal.focus() - textarea.focus() - - // Why: the policy resolves left-vs-right Option from the modifier's own - // keydown, so the chord has to be preceded by a real AltLeft press. - const modifierInit = { key: 'Alt', code: 'AltLeft', altKey: true, bubbles: true } - const altDown = new KeyboardEvent('keydown', modifierInit) - Object.defineProperty(altDown, 'location', { get: () => 1 }) - textarea.dispatchEvent(altDown) - - const keydown = new KeyboardEvent('keydown', { - key: press.key, - code: press.code, - altKey: true, - shiftKey: press.shiftKey === true, - bubbles: true, - cancelable: true - }) - textarea.dispatchEvent(keydown) - - textarea.dispatchEvent( - new KeyboardEvent('keyup', { - key: press.key, - code: press.code, - altKey: true, - shiftKey: press.shiftKey === true, - bubbles: true, - cancelable: true - }) - ) - const altUp = new KeyboardEvent('keyup', modifierInit) - Object.defineProperty(altUp, 'location', { get: () => 1 }) - textarea.dispatchEvent(altUp) - - return { keydownDefaultPrevented: keydown.defaultPrevented } - }, press) -} - -async function armKittyKeyboardFromPty(page: Page, ptyId: string, flags: number): Promise { - // Why: this is the byte a real kitty-protocol TUI pushes at startup; routing it - // through the PTY exercises the same output-scanning mirror the policy reads. - // `cat` stays in the foreground: flags left armed at exit are grounded by the host. - await execInTerminal(page, ptyId, `printf '\\033[>${flags}u'; cat`) - await expect - .poll(async () => getPaneKittyKeyboardFlags(page), { - timeout: 15_000, - message: 'the pane never mirrored the application kitty keyboard flags' - }) - .toBe(flags) -} - -async function setUpPane( - page: Page, - app: ElectronApplication, - kittyFlags = 1 -): Promise<{ joinedWrites: () => Promise }> { - await waitForSessionReady(page) - await waitForActiveWorktree(page) - await ensureTerminalVisible(page) - await waitForActiveTerminalManager(page) - const ptyId = await waitForActivePanePtyId(page) - await installMainProcessPtyWriteSpy(app) - await armKittyKeyboardFromPty(page, ptyId, kittyFlags) - return { joinedWrites: async () => (await getPtyWrites(app)).join('') } -} + setMacOptionAsAlt, + pressOptionComposedKey, + setUpOptionKeyboardPane, + publishMacKeyboardLayout, + waitForPaneOptionAsAlt, + pressChromiumOptionPunctuation +} from './terminal-option-key-input' test.describe('Option-composed text in a kitty-keyboard pane', () => { test.skip(process.platform !== 'darwin', 'Option composition is a macOS-only input path (#14024)') + test('the settings control enables punctuation shortcuts and keeps the other Option side as text', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 7) + await orcaPage.evaluate(async () => { + const state = window.__store?.getState() + await state?.updateSettings({ uiLanguage: 'en' }) + state?.openSettingsTarget({ pane: 'terminal', repoId: null }) + state?.openSettingsPage() + state?.setSettingsSearchQuery('Option as Alt') + }) + const control = orcaPage.getByRole('radiogroup', { name: 'Option as Alt', exact: true }) + await expect( + orcaPage.getByText(/Choose Both for Option shortcuts, Off for accents and symbols/) + ).toBeVisible() + const keys = [ + { key: '…', code: 'Semicolon', codePoint: 59 }, + { key: '≥', code: 'Period', codePoint: 46 }, + { key: '≤', code: 'Comma', codePoint: 44 } + ] + for (const [label, setting] of [ + ['Both', 'true'], + ['Left', 'left'], + ['Right', 'right'] + ] as const) { + await control.getByRole('radio', { name: label, exact: true }).click() + await expect + .poll(() => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt) + ) + .toBe(setting) + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + for (const side of ['left', 'right'] as const) { + await clearPtyWriteLog(electronApp) + for (const key of keys) { + await pressOptionComposedKey(orcaPage, { ...key, side }) + } + const isAlt = setting === 'true' || setting === side + const expected = keys + .map( + ({ key, codePoint }) => `${isAlt ? `\x1b[${codePoint};3u` : key}\x1b[${codePoint};3:3u` + ) + .join('') + await expect.poll(joinedWrites).toBe(expected) + } + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.setSettingsSearchQuery('Option as Alt') + }) + } + }) + + test('Chromium Option punctuation produces text in compose mode and shortcuts in Both mode', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 7) + const cdp = await orcaPage.context().newCDPSession(orcaPage) + try { + for (const setting of ['false', 'true'] as const) { + await setMacOptionAsAlt(orcaPage, setting) + await clearPtyWriteLog(electronApp) + for (const key of [ + { key: '…', code: 'Semicolon', base: ';', codePoint: 59, windowsVirtualKeyCode: 186 }, + { key: '≥', code: 'Period', base: '.', codePoint: 46, windowsVirtualKeyCode: 190 }, + { key: '≤', code: 'Comma', base: ',', codePoint: 44, windowsVirtualKeyCode: 188 } + ]) { + await pressChromiumOptionPunctuation(cdp, key) + } + const expected = [ + ['…', 59], + ['≥', 46], + ['≤', 44] + ] + .map( + ([key, codePoint]) => + `${setting === 'true' ? `\x1b[${codePoint};3u` : key}\x1b[${codePoint};3:3u` + ) + .join('') + await expect.poll(joinedWrites).toBe(expected) + if (setting === 'false') { + for (const glyph of ['…', '≥', '≤']) { + await waitForTerminalOutput(orcaPage, glyph) + } + } + } + } finally { + await cdp.detach() + } + }) + + test('Auto follows ABC and composing input sources without replacing explicit Option settings', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 7) + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ uiLanguage: 'en' }) + }) + await setMacOptionAsAlt(orcaPage, 'auto') + const abc = 'com.apple.keylayout.ABC' + const international = 'com.apple.keylayout.USInternational-PC' + const pinyin = 'com.apple.inputmethod.SCIM.ITABC' + const keyCharacters = { + Semicolon: { unmodified: ';', shifted: ':' }, + Period: { unmodified: '.', shifted: '>' }, + Comma: { unmodified: ',', shifted: '<' } + } + const period = { key: '≥', code: 'Period', base: '.', windowsVirtualKeyCode: 190 } + let generation = Date.now() + const cdp = await orcaPage.context().newCDPSession(orcaPage) + try { + for (const inputSourceId of [abc, international, abc, pinyin, abc, null, abc]) { + await publishMacKeyboardLayout( + electronApp, + inputSourceId + ? { + inputSourceId, + layoutSourceId: inputSourceId === pinyin ? abc : inputSourceId, + keyCharacters + } + : null, + ++generation + ) + const isAlt = inputSourceId === abc + await waitForPaneOptionAsAlt(orcaPage, isAlt) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsTarget({ pane: 'terminal', repoId: null }) + state?.openSettingsPage() + state?.setSettingsSearchQuery('Option as Alt') + }) + const control = orcaPage.getByRole('radiogroup', { name: 'Option as Alt', exact: true }) + await expect(control.getByRole('radio', { name: 'Auto', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await expect( + orcaPage.getByText( + isAlt + ? /Auto — detected: ABC or U.S. — Option sends Alt\/Esc sequences/ + : inputSourceId + ? /Auto — detected: layout uses Option to compose characters/ + : /Auto — detected: unknown layout — Option composes characters/ + ) + ).toBeVisible() + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + await focusActiveTerminalInput(orcaPage) + await clearPtyWriteLog(electronApp) + await pressChromiumOptionPunctuation(cdp, period) + await expect.poll(joinedWrites).toBe(`${isAlt ? '\x1b[46;3u' : '≥'}\x1b[46;3:3u`) + await expect + .poll(() => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt) + ) + .toBe('auto') + } + await setMacOptionAsAlt(orcaPage, 'false') + await waitForPaneOptionAsAlt(orcaPage, false) + await clearPtyWriteLog(electronApp) + await pressChromiumOptionPunctuation(cdp, period) + await expect.poll(joinedWrites).toBe('≥\x1b[46;3:3u') + await setMacOptionAsAlt(orcaPage, 'left') + for (const inputSourceId of [international, abc]) { + await publishMacKeyboardLayout( + electronApp, + { inputSourceId, layoutSourceId: inputSourceId, keyCharacters }, + ++generation + ) + for (const side of ['left', 'right'] as const) { + await clearPtyWriteLog(electronApp) + await pressOptionComposedKey(orcaPage, { ...period, side }) + await expect + .poll(joinedWrites) + .toBe(`${side === 'left' ? '\x1b[46;3u' : '≥'}\x1b[46;3:3u`) + } + expect( + await orcaPage.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt) + ).toBe('left') + } + } finally { + await cdp.detach() + } + }) + + test('configured Option punctuation keeps legacy Alt bytes without enhanced reporting', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 0) + for (const setting of ['true', 'left', 'right'] as const) { + await setMacOptionAsAlt(orcaPage, setting) + await clearPtyWriteLog(electronApp) + const side = setting === 'right' ? 'right' : 'left' + for (const key of [ + { key: '…', code: 'Semicolon' }, + { key: '≥', code: 'Period' }, + { key: '≤', code: 'Comma' } + ]) { + await pressOptionComposedKey(orcaPage, { ...key, side }) + } + await expect.poll(joinedWrites).toBe('\x1b;\x1b.\x1b,') + } + }) + test('types the composed character instead of reporting the physical Alt chord', async ({ orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) @@ -174,7 +245,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { }) test('types a composed character that also needs Shift', async ({ orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) @@ -200,7 +271,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'true') await clearPtyWriteLog(electronApp) @@ -220,7 +291,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'true') await clearPtyWriteLog(electronApp) @@ -241,7 +312,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp, 5) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 5) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) const letters = [ @@ -278,7 +349,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp, 29) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 29) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) await pressOptionComposedKey(orcaPage, { key: 'ą', code: 'KeyA' }) @@ -289,7 +360,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }, testInfo) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp, 5) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 5) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) const cdp = await orcaPage.context().newCDPSession(orcaPage) diff --git a/tests/e2e/terminal-option-key-input.ts b/tests/e2e/terminal-option-key-input.ts new file mode 100644 index 00000000000..a56cfe0065f --- /dev/null +++ b/tests/e2e/terminal-option-key-input.ts @@ -0,0 +1,210 @@ +import type { CDPSession, ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' +import { + execInTerminal, + waitForActiveTerminalManager, + waitForActivePanePtyId +} from './helpers/terminal' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, + readTerminalPtyWrites as getPtyWrites +} from './helpers/terminal-pty-write-spy' +import type { KeyboardLayoutSnapshot } from '../../src/shared/keyboard-layout-snapshot' + +type MacOptionAsAltSetting = 'auto' | 'true' | 'false' | 'left' | 'right' + +export async function setMacOptionAsAlt(page: Page, value: MacOptionAsAltSetting): Promise { + await page.evaluate(async (value) => { + await window.__store?.getState().updateSettings({ terminalMacOptionAsAlt: value }) + }, value) + await expect + .poll( + async () => + page.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt ?? null), + { timeout: 5_000, message: 'terminalMacOptionAsAlt did not apply' } + ) + .toBe(value) +} + +/** Reads the pane's mirrored kitty flags — the exact value the policy consults. */ +async function getPaneKittyKeyboardFlags(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm exposes negotiated flags through either bundled core shape. + const terminal = pane?.terminal as + | { + core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + } + | undefined + return ( + terminal?.core?.coreService?.kittyKeyboard?.flags ?? + terminal?._core?.coreService?.kittyKeyboard?.flags ?? + 0 + ) + }) +} + +/** + * Dispatches the keydown macOS delivers for an Option-composed key: `key` is + * already the composed glyph while `code` still names the physical key. + */ +export async function pressOptionComposedKey( + page: Page, + press: { key: string; code: string; shiftKey?: boolean; side?: 'left' | 'right' } +): Promise<{ keydownDefaultPrevented: boolean }> { + return page.evaluate((press) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const textarea = pane?.container.querySelector('.xterm-helper-textarea') + if (!pane || !textarea) { + throw new Error('No active terminal textarea for the Option chord dispatch') + } + pane.terminal.focus() + textarea.focus() + + // The side-specific setting reads the modifier's location before the chord. + const location = press.side === 'right' ? 2 : 1 + const modifierInit = { + key: 'Alt', + code: location === 2 ? 'AltRight' : 'AltLeft', + altKey: true, + bubbles: true + } + const altDown = new KeyboardEvent('keydown', modifierInit) + Object.defineProperty(altDown, 'location', { get: () => location }) + textarea.dispatchEvent(altDown) + + const keydown = new KeyboardEvent('keydown', { + key: press.key, + code: press.code, + altKey: true, + shiftKey: press.shiftKey === true, + bubbles: true, + cancelable: true + }) + const keyCodes: Record = { Semicolon: 186, Comma: 188, Period: 190 } + const keyCode = keyCodes[press.code] + if (keyCode) { + Object.defineProperty(keydown, 'keyCode', { value: keyCode }) + } + textarea.dispatchEvent(keydown) + + textarea.dispatchEvent( + new KeyboardEvent('keyup', { + key: press.key, + code: press.code, + altKey: true, + shiftKey: press.shiftKey === true, + bubbles: true, + cancelable: true + }) + ) + const altUp = new KeyboardEvent('keyup', modifierInit) + Object.defineProperty(altUp, 'location', { get: () => location }) + textarea.dispatchEvent(altUp) + + return { keydownDefaultPrevented: keydown.defaultPrevented } + }, press) +} + +async function armKittyKeyboardFromPty(page: Page, ptyId: string, flags: number): Promise { + // Why: this is the byte a real kitty-protocol TUI pushes at startup; routing it + // through the PTY exercises the same output-scanning mirror the policy reads. + // `cat` stays in the foreground: flags left armed at exit are grounded by the host. + await execInTerminal(page, ptyId, `printf '\\033[>${flags}u'; cat`) + await expect + .poll(async () => getPaneKittyKeyboardFlags(page), { + timeout: 15_000, + message: 'the pane never mirrored the application kitty keyboard flags' + }) + .toBe(flags) +} + +export async function setUpOptionKeyboardPane( + page: Page, + app: ElectronApplication, + kittyFlags = 1 +): Promise<{ joinedWrites: () => Promise }> { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page) + const ptyId = await waitForActivePanePtyId(page) + await installMainProcessPtyWriteSpy(app) + await armKittyKeyboardFromPty(page, ptyId, kittyFlags) + return { joinedWrites: async () => (await getPtyWrites(app)).join('') } +} + +export async function publishMacKeyboardLayout( + app: ElectronApplication, + snapshot: KeyboardLayoutSnapshot | null, + generation: number +): Promise { + await app.evaluate( + ({ ipcMain, BrowserWindow }, { snapshot, generation }) => { + ipcMain.removeHandler('app:getKeyboardLayoutSnapshot') + ipcMain.handle('app:getKeyboardLayoutSnapshot', () => snapshot) + ipcMain.removeHandler('app:getKeyboardInputSourceId') + ipcMain.handle('app:getKeyboardInputSourceId', () => snapshot?.inputSourceId ?? null) + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('app:keyboardLayoutChanged', { phase: 'refresh', generation }) + } + }, + { snapshot, generation } + ) +} + +export async function waitForPaneOptionAsAlt(page: Page, expected: boolean): Promise { + await expect + .poll(() => + page.evaluate(() => { + const state = window.__store?.getState() + const tabId = state?.activeTabId + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.terminal.options.macOptionIsMeta + }) + ) + .toBe(expected) +} + +export async function pressChromiumOptionPunctuation( + cdp: CDPSession, + key: { key: string; code: string; base: string; windowsVirtualKeyCode: number } +): Promise { + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: key.key, + code: key.code, + modifiers: 1, + text: key.key, + unmodifiedText: key.base, + windowsVirtualKeyCode: key.windowsVirtualKeyCode + }) + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: key.key, + code: key.code, + modifiers: 1, + windowsVirtualKeyCode: key.windowsVirtualKeyCode + }) +}