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
This commit is contained in:
Neil
2026-10-02 00:28:30 -07:00
committed by GitHub
parent 3f37fcc423
commit 306b4578aa
17 changed files with 1033 additions and 368 deletions
+75 -70
View File
@@ -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 <key> 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 <key> 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')
})
+37 -27
View File
@@ -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<string, unknown>
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<string | null> {
async function readSelectedKeyboardInputSource(): Promise<SelectedKeyboardInputSource | null> {
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<string> {
return readCommandStdout(
'/usr/bin/defaults',
['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'],
'Keyboard layout input source probe timed out'
)
}
async function readKeyboardInputSourceId(): Promise<string | null> {
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<boolean> => 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<string | null> => {
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
}
})
@@ -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={
<SettingsSegmentedControl
@@ -0,0 +1,179 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveTerminalOptionShortcutAction } from './terminal-option-shortcut-policy'
import { createTerminalOptionKittyReleaseTracker } from './terminal-option-kitty-release'
type OptionEvent = Parameters<typeof resolveTerminalOptionShortcutAction>[0]
type OptionContext = Parameters<typeof resolveTerminalOptionShortcutAction>[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>): OptionEvent {
return {
key: '',
code: '',
altKey: true,
shiftKey: false,
metaKey: false,
ctrlKey: false,
repeat: false,
...overrides
}
}
function optionContext(overrides: Partial<OptionContext> = {}): 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' })
}
})
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -11,6 +11,12 @@
"retrying": "再試行中…"
},
"settings": {
"terminal": {
"optionLayoutAlt": "ABCまたはU.S. — OptionはAlt/Escシーケンスを送信します",
"optionLayoutCompose": "この配列ではOptionで@、€、[、]などの文字を入力します",
"optionLayoutUnknown": "不明な配列 — Optionで文字を入力します(安全な既定値)",
"optionShortcutHint": "Optionショートカットには「両方」、アクセント付き文字や記号には「オフ」、用途を左右のOptionキーに分けるには「左」または「右」を選んでください。"
},
"appearance": {
"language": {
"title": "言語",
+6
View File
@@ -11,6 +11,12 @@
"retrying": "재시도 중…"
},
"settings": {
"terminal": {
"optionLayoutAlt": "ABC 또는 U.S. — Option 키가 Alt/Esc 시퀀스를 전송합니다",
"optionLayoutCompose": "이 배열에서는 Option 키로 @, €, [, ] 같은 문자를 입력합니다",
"optionLayoutUnknown": "알 수 없는 배열 — Option 키로 문자를 입력합니다(안전한 기본값)",
"optionShortcutHint": "Option 단축키에는 둘 다, 악센트 문자와 기호에는 끄기, 각 Option 키를 다른 용도로 쓰려면 왼쪽 또는 오른쪽을 선택하세요."
},
"appearance": {
"language": {
"title": "언어",
+7 -1
View File
@@ -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": "自动",
@@ -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<string, string> = {
KeyQ: 'q',
@@ -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')
})
})
@@ -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 {
@@ -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<LayoutMapLike> }
}
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<EventListener>()
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<string>>()
.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<string | null> = 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<string | null>()
const newestRead = Promise.withResolvers<string | null>()
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()
}
)
})
@@ -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
+223 -152
View File
@@ -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<void> {
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<number> {
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<void> {
// 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<string> }> {
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)
+210
View File
@@ -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<void> {
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<number> {
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<HTMLTextAreaElement>('.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<string, number> = { 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<void> {
// 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<string> }> {
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<void> {
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<void> {
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<void> {
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
})
}