mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 00:02:21 +00:00
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:
+75
-70
@@ -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
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
"retrying": "再試行中…"
|
||||
},
|
||||
"settings": {
|
||||
"terminal": {
|
||||
"optionLayoutAlt": "ABCまたはU.S. — OptionはAlt/Escシーケンスを送信します",
|
||||
"optionLayoutCompose": "この配列ではOptionで@、€、[、]などの文字を入力します",
|
||||
"optionLayoutUnknown": "不明な配列 — Optionで文字を入力します(安全な既定値)",
|
||||
"optionShortcutHint": "Optionショートカットには「両方」、アクセント付き文字や記号には「オフ」、用途を左右のOptionキーに分けるには「左」または「右」を選んでください。"
|
||||
},
|
||||
"appearance": {
|
||||
"language": {
|
||||
"title": "言語",
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
"retrying": "재시도 중…"
|
||||
},
|
||||
"settings": {
|
||||
"terminal": {
|
||||
"optionLayoutAlt": "ABC 또는 U.S. — Option 키가 Alt/Esc 시퀀스를 전송합니다",
|
||||
"optionLayoutCompose": "이 배열에서는 Option 키로 @, €, [, ] 같은 문자를 입력합니다",
|
||||
"optionLayoutUnknown": "알 수 없는 배열 — Option 키로 문자를 입력합니다(안전한 기본값)",
|
||||
"optionShortcutHint": "Option 단축키에는 둘 다, 악센트 문자와 기호에는 끄기, 각 Option 키를 다른 용도로 쓰려면 왼쪽 또는 오른쪽을 선택하세요."
|
||||
},
|
||||
"appearance": {
|
||||
"language": {
|
||||
"title": "언어",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user