fix(terminal): restore Polish / US-Extended Option+letter composition (#1205) (#1209)

* fix(terminal): restore Polish / US-Extended Option+letter composition (#1205)

The macOptionAsAlt layout probe only inspects `navigator.keyboard.getLayoutMap()`,
which surfaces the base (unshifted) layer. Polish Pro, US Extended, ABC
Extended, and the CJK Roman IMEs all ship a US-QWERTY base layer and put
their dead-key composition on the Option layer — invisible to the
fingerprint. The probe therefore classified them as `'us'`, flipped
`macOptionIsMeta=true`, and xterm.js translated every Option+letter into
an Esc+letter readline chord, silently swallowing ą, ć, ę, ł, etc.

Adds a main-process IPC that reads macOS's
`AppleCurrentKeyboardLayoutInputSourceID` (the same signal Ghostty uses
via TISCopyCurrentKeyboardInputSource) and an explicit denylist of input
source IDs whose Option layer composes. When the active ID is on the
denylist, the probe forces `'non-us'` so `effectiveMacOptionAsAlt`
resolves to `'false'` and Option+letter compositions reach the shell.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): widen Option+letter composition fix to ABC + all non-US layouts

My earlier attempt at #1205 missed the broader bug: Option+A wasn't working
on the plain "ABC" layout either (Option+A → å got swallowed). Two
corrections:

1. systemPreferences.getUserDefault only reads NSGlobalDomain / the app's
   own domain. AppleCurrentKeyboardLayoutInputSourceID lives in
   com.apple.HIToolbox, so the previous IPC returned null on every
   machine. Switched to execFileSync('/usr/bin/defaults') with a 500ms
   timeout — the same mechanism Apple documents — so the real ID reaches
   the renderer. Verified live: the IPC now returns
   `com.apple.keylayout.ABC` instead of `null`.

2. The denylist approach was too narrow. macOS ships many layouts that
   share a US-identical base layer but compose via Option (ABC, Polish
   Pro, US Extended, ABC Extended, every CJK Roman IME). Flipped to an
   allowlist matching Ghostty: only com.apple.keylayout.US and
   com.apple.keylayout.USInternational-PC get Option-as-Meta; everything
   else defaults to compose. When the IPC returns a concrete ID, the
   classifier is authoritative and the layout fingerprint is skipped;
   the fingerprint only runs as a fallback on non-Darwin / IPC failure.

Verified live in dev: on ABC, Option+A now produces å in the terminal.

Co-authored-by: Orca <help@stably.ai>

* fix(ipc): read keyboard input source ID asynchronously

Avoids blocking the main-process event loop on every focus-in probe
(up to 500ms). Same behavior, same timeout — just non-blocking.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-04-28 00:32:28 -07:00
committed by GitHub
co-authored by Orca
parent 6233b8e8a4
commit 6834a980d8
7 changed files with 370 additions and 2 deletions
+50
View File
@@ -1,6 +1,10 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { app, ipcMain } from 'electron'
import { isWslAvailable } from '../wsl'
const execFileAsync = promisify(execFile)
export type AppRuntimeFlags = {
/** Whether the persistent terminal daemon was actually started this session.
* The renderer compares this against the current setting to decide whether
@@ -42,6 +46,52 @@ export function registerAppHandlers(): void {
ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable())
// Why: ABC, Polish Pro, US Extended, ABC Extended, and every CJK Roman
// IME all report a US-QWERTY base layer to navigator.keyboard.getLayoutMap()
// — the layout-fingerprint probe in the renderer therefore classifies
// them as 'us' and flips macOptionIsMeta=true, silently swallowing every
// Option+letter composition (#1205: Option+A → å / ą is dropped). The
// macOS-shipped `com.apple.HIToolbox` preference
// `AppleCurrentKeyboardLayoutInputSourceID` names the actual layout
// (e.g. `com.apple.keylayout.ABC` vs `com.apple.keylayout.US`), which
// the renderer uses as an authoritative override. Non-Darwin platforms
// have no equivalent and return null so the fingerprint stays the only
// signal.
//
// Why `defaults read` (via execFileSync) and not systemPreferences
// .getUserDefault: getUserDefault only reads from NSGlobalDomain and the
// current app's own domain. The keyboard layout ID lives in the
// `com.apple.HIToolbox` domain, which getUserDefault cannot reach —
// observed to return null even when the preference is set. The `defaults`
// CLI reads any domain and is the same mechanism Apple documents for
// this value.
ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise<string | null> => {
if (process.platform !== 'darwin') {
return null
}
try {
// Why: async so the probe never blocks the main-process event loop.
// The probe re-runs on every window focus-in (see option-as-alt-probe.ts),
// and a blocking execFileSync would briefly stall unrelated IPC each
// time the user Alt-Tabbed back into the app.
const { stdout } = await execFileAsync(
'/usr/bin/defaults',
['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'],
// Why: short timeout so a wedged defaults binary (corporate-managed
// config, sandbox policy, …) never holds the handle indefinitely.
// Fall through to the fingerprint on timeout.
{ encoding: 'utf8', timeout: 500 }
)
const trimmed = stdout.trim()
return trimmed.length > 0 ? trimmed : null
} catch {
// Why: defaults exits non-zero when the key is absent (first boot
// before any input-source interaction), or when sandboxed. Treat
// that as "no signal" — the fingerprint still runs as fallback.
return null
}
})
ipcMain.handle('app:relaunch', () => {
// Why: small delay lets the renderer finish painting any "Restarting…"
// UI state before the window tears down. `app.relaunch()` schedules a
+6
View File
@@ -288,6 +288,12 @@ export type AppApi = {
/** Relaunches the app via Electron's app.relaunch() + app.exit(0). Used
* by the "Restart now" button on the Experimental settings pane. */
relaunch: () => Promise<void>
/** Returns the macOS `AppleCurrentKeyboardLayoutInputSourceID` when
* available (e.g. `com.apple.keylayout.PolishPro`). Used by the
* keyboard-layout probe to distinguish layouts whose base layer matches
* US QWERTY but whose Option layer composes characters (issue #1205).
* Returns null on non-Darwin platforms or when the defaults read fails. */
getKeyboardInputSourceId: () => Promise<string | null>
}
export type PreloadApi = {
+8 -1
View File
@@ -178,7 +178,14 @@ const api = {
ipcRenderer.invoke('app:getRuntimeFlags'),
consumeDaemonTransitionNotice: (): Promise<{ killedCount: number } | null> =>
ipcRenderer.invoke('app:consumeDaemonTransitionNotice'),
relaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch')
relaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
// Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so
// the renderer's keyboard-layout probe can distinguish Polish Pro / US
// Extended / ABC Extended / IME Roman modes from plain US QWERTY (see
// src/renderer/src/lib/keyboard-layout/input-source-id.ts, issue #1205).
// Returns null on non-Darwin or when the defaults read fails.
getKeyboardInputSourceId: (): Promise<string | null> =>
ipcRenderer.invoke('app:getKeyboardInputSourceId')
},
wsl: {
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { classifyInputSourceId } from './input-source-id'
describe('classifyInputSourceId', () => {
it('returns "unknown" for nullish input so the caller falls back to the fingerprint', () => {
expect(classifyInputSourceId(null)).toBe('unknown')
expect(classifyInputSourceId(undefined)).toBe('unknown')
expect(classifyInputSourceId('')).toBe('unknown')
})
it('allowlists plain US Standard as meta', () => {
expect(classifyInputSourceId('com.apple.keylayout.US')).toBe('meta')
})
it('allowlists US International PC as meta', () => {
expect(classifyInputSourceId('com.apple.keylayout.USInternational-PC')).toBe('meta')
})
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')
})
it('classifies Polish Pro as compose (#1205)', () => {
expect(classifyInputSourceId('com.apple.keylayout.PolishPro')).toBe('compose')
})
it('classifies US Extended and ABC Extended as compose', () => {
expect(classifyInputSourceId('com.apple.keylayout.USExtended')).toBe('compose')
expect(classifyInputSourceId('com.apple.keylayout.ABCExtended')).toBe('compose')
})
it('classifies every other Apple-shipped layout as compose (default-deny)', () => {
// Matches Ghostty: only US and USInternational-PC are allowlisted;
// everything else (Dvorak, Colemak, German, French, Turkish, Spanish,
// Swedish, every CJK Roman IME) falls back to compose.
expect(classifyInputSourceId('com.apple.keylayout.Dvorak')).toBe('compose')
expect(classifyInputSourceId('com.apple.keylayout.Colemak')).toBe('compose')
expect(classifyInputSourceId('com.apple.keylayout.German')).toBe('compose')
expect(classifyInputSourceId('com.apple.keylayout.French')).toBe('compose')
expect(classifyInputSourceId('com.apple.keylayout.Turkish-QWERTY')).toBe('compose')
expect(classifyInputSourceId('com.apple.inputmethod.Kotoeri.Roman')).toBe('compose')
expect(classifyInputSourceId('com.apple.inputmethod.TCIM.Pinyin')).toBe('compose')
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.
expect(classifyInputSourceId('com.apple.keylayout.USExtended')).toBe('compose')
expect(classifyInputSourceId('com.apple.keylayout.US.variant')).toBe('compose')
})
})
@@ -0,0 +1,80 @@
/**
* Classifier for macOS's `AppleCurrentKeyboardLayoutInputSourceID`.
*
* Why this exists alongside detect-option-as-alt: the layout-fingerprint
* probe (`detectOptionAsAltFromLayoutMap`) inspects `navigator.keyboard
* .getLayoutMap()`, which only surfaces the base (unshifted) layer. Many
* macOS layouts keep a US-identical base layer but repurpose the Option
* layer for dead-key composition — ABC (Option+A = å), Polish Pro
* (Option+A = ą), US Extended, ABC Extended, and the CJK Roman IMEs all
* share this trap. The fingerprint classifies them as `'us'`, the
* effective setting resolves to `'true'`, xterm's `macOptionIsMeta`
* turns on, and every Option+letter keystroke is silently translated to
* an Esc+letter readline chord — so typing å, ą, ï, etc., fails with no
* visible feedback (issue #1205).
*
* The only layouts where Option-as-Meta is the right default are plain
* US Standard and US-International-PC — matching Ghostty's
* `detectOptionAsAlt` (~/projects/ghostty/src/input/keyboard.zig:25-57
* + ~/projects/ghostty/macos/Sources/Helpers/KeyboardLayout.swift,
* which whitelists only `com.apple.keylayout.US` and
* `com.apple.keylayout.USInternational-PC`).
*
* When the main-process IPC returns a non-null ID, this classifier is
* authoritative: `'meta'` → Option-as-Meta is safe; `'compose'` →
* Option must compose. The fingerprint probe is only consulted when no
* ID is available (non-Darwin, sandboxed defaults, IPC failure).
*/
/**
* Input source IDs where Option-as-Meta is the correct default. The
* shipped US Standard and US-International-PC layouts are the only
* Apple layouts that don't use Option for composition. Everything else
* — including ABC, Polish Pro, US Extended, ABC Extended, every
* international layout, every CJK Roman IME — composes via Option and
* must stay `'false'`.
*
* Matching is case-insensitive full-ID. We deliberately do NOT prefix-
* match here: `com.apple.keylayout.US` must not silently allowlist
* `com.apple.keylayout.USExtended`.
*/
const META_INPUT_SOURCE_IDS: readonly string[] = [
'com.apple.keylayout.us',
'com.apple.keylayout.usinternational-pc'
]
export type InputSourceOverride =
/** Option-as-Meta is safe on this 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. */
| 'compose'
/** No macOS input source ID available (non-Darwin, IPC failure,
* sandboxed defaults). The caller should fall back to the layout
* fingerprint. */
| 'unknown'
export function classifyInputSourceId(id: string | null | undefined): InputSourceOverride {
if (!id) {
return 'unknown'
}
const normalized = id.toLowerCase()
for (const allowed of META_INPUT_SOURCE_IDS) {
if (normalized === allowed) {
return 'meta'
}
}
// Why: any other macOS input source ID composes via Option. This
// includes ABC (not to be confused with US), Polish Pro, US Extended,
// ABC Extended, every international layout, Dvorak, Colemak, and
// every CJK Roman IME. Forcing `'compose'` matches Ghostty's
// allowlist-only behavior and prevents the #1205-style silent-swallow
// bug from recurring for any future Apple-shipped layout.
return 'compose'
}
/** Test-only: exported so tests can assert the allowlist without
* reimporting the private constant. */
export const __metaInputSourceIdsForTests: readonly string[] = META_INPUT_SOURCE_IDS
@@ -170,4 +170,88 @@ describe('createOptionAsAltProbe', () => {
// No further calls after dispose.
expect(listener).not.toHaveBeenCalled()
})
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']) {
const win = makeMockWindow(US_MAP)
const probe = createOptionAsAltProbe(win as unknown as Window, {
readInputSourceId: async () => id
})
await probe.refresh()
expect(probe.getCurrent()).toBe('non-us')
probe.dispose()
}
})
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'
})
await probe.refresh()
expect(probe.getCurrent()).toBe('us')
probe.dispose()
})
it('trusts the input source ID over the fingerprint even when the fingerprint says us', async () => {
// Pre-fix: the fingerprint's 'us' verdict was authoritative and the
// macOS ID was ignored, so Turkish-F (which reports US-identical on
// several keys) plus any US-like fingerprint flipped
// macOptionIsMeta=true. Now the ID overrides.
const win = makeMockWindow(US_MAP)
const probe = createOptionAsAltProbe(win as unknown as Window, {
readInputSourceId: async () => 'com.apple.keylayout.German'
})
await probe.refresh()
expect(probe.getCurrent()).toBe('non-us')
probe.dispose()
})
it('falls back to the fingerprint when the input-source reader returns null (non-Darwin)', async () => {
const win = makeMockWindow(US_MAP)
const probe = createOptionAsAltProbe(win as unknown as Window, {
readInputSourceId: async () => null
})
await probe.refresh()
expect(probe.getCurrent()).toBe('us')
probe.dispose()
})
it('falls back to the fingerprint when the input-source reader throws', async () => {
const win = makeMockWindow(TURKISH_MAP)
const probe = createOptionAsAltProbe(win as unknown as Window, {
readInputSourceId: async () => {
throw new Error('ipc unavailable')
}
})
await probe.refresh()
expect(probe.getCurrent()).toBe('non-us')
probe.dispose()
})
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'
const win = makeMockWindow(US_MAP)
const probe = createOptionAsAltProbe(win as unknown as Window, {
readInputSourceId: async () => activeInputSourceId
})
await probe.refresh()
expect(probe.getCurrent()).toBe('us')
activeInputSourceId = 'com.apple.keylayout.ABC'
win.fireFocus()
// Let the focus-triggered probe resolve.
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
expect(probe.getCurrent()).toBe('non-us')
probe.dispose()
})
})
@@ -13,12 +13,24 @@
* reliable proxy. The only missed case is a layout change triggered by a
* key pressed while Orca is focused (e.g. a Karabiner rule), which is
* exceedingly rare and self-heals on the next blur/focus cycle.
*
* 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.
*/
import {
detectOptionAsAltFromLayoutMap,
type DetectedLayoutCategory,
type LayoutMapLike
} from './detect-option-as-alt'
import { classifyInputSourceId } from './input-source-id'
type NavigatorWithKeyboard = Navigator & {
keyboard?: {
@@ -28,6 +40,8 @@ type NavigatorWithKeyboard = Navigator & {
type Listener = (category: DetectedLayoutCategory) => void
type InputSourceIdReader = () => Promise<string | null>
export type OptionAsAltProbe = {
/** Current detected category. Starts `'unknown'` until the first probe
* resolves (within a few ms of app boot); listeners fire on every
@@ -40,10 +54,43 @@ export type OptionAsAltProbe = {
dispose: () => void
}
export function createOptionAsAltProbe(win: Window = window): OptionAsAltProbe {
type CreateProbeOptions = {
/** Injectable reader for the macOS input source ID. Defaults to the
* preload `window.api.app.getKeyboardInputSourceId` when available.
* Tests pass a stub to exercise the compose override deterministically. */
readInputSourceId?: InputSourceIdReader
}
function defaultInputSourceIdReader(): InputSourceIdReader {
return async () => {
const api = (
globalThis as {
window?: { api?: { app?: { getKeyboardInputSourceId?: () => Promise<string | null> } } }
}
).window?.api
const reader = api?.app?.getKeyboardInputSourceId
if (!reader) {
return null
}
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.
return null
}
}
}
export function createOptionAsAltProbe(
win: Window = window,
options: CreateProbeOptions = {}
): OptionAsAltProbe {
let current: DetectedLayoutCategory = 'unknown'
const listeners = new Set<Listener>()
let disposed = false
const readInputSourceId = options.readInputSourceId ?? defaultInputSourceIdReader()
const notify = (next: DetectedLayoutCategory): void => {
if (next === current) {
@@ -65,6 +112,40 @@ export function createOptionAsAltProbe(win: Window = window): OptionAsAltProbe {
}
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.
let inputSourceId: string | null = null
try {
inputSourceId = await readInputSourceId()
} catch {
// Treat errors as no signal — the fingerprint still runs below.
inputSourceId = null
}
if (disposed) {
return
}
// Why: when macOS returns a concrete input source ID, it's authoritative.
// The fingerprint can only see the base (unshifted) layer, which is
// US-identical on ABC, Polish Pro, US Extended, ABC Extended, and every
// CJK Roman IME — so trusting it flips macOptionIsMeta=true on all of
// them and silently swallows Option+letter compositions (#1205). The
// allowlist matches Ghostty: only com.apple.keylayout.US and
// com.apple.keylayout.USInternational-PC get Option-as-Meta; everything
// else composes via Option.
const override = classifyInputSourceId(inputSourceId)
if (override === 'meta') {
notify('us')
return
}
if (override === 'compose') {
notify('non-us')
return
}
if (!keyboard?.getLayoutMap) {
// Non-Chromium or Electron stripped of the Keyboard API. Stay at
// 'unknown' → terminal defaults to 'false' (safe for non-US).