From 035d8c2a54c072cce01378520fbbafac033ebc72 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:46:22 -0700 Subject: [PATCH] fix(terminal): preserve macOS Korean composition (#12280) --- .../terminal-ime-input-source.test.ts | 11 +++ .../terminal-ime-input-source.ts | 25 +++++- .../terminal-ime-native-text-candidates.ts | 18 ++++ ...terminal-ime-native-text-forwarder.test.ts | 26 ++++++ .../terminal-macos-2set-korean-native.spec.ts | 88 +++++++++++++++++++ 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/terminal-macos-2set-korean-native.spec.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts index 40cf41087..9e159e6c5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts @@ -17,6 +17,7 @@ describe('getMacNativeTextInputSourceFeatures', () => { 'com.apple.inputmethod.Korean.2SetKorean' ]) { expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({ + forwardHangulJamo: sourceId.includes('Korean'), forwardAsciiPunctuation: true, forwardShortTextReplacements: false }) @@ -25,18 +26,22 @@ describe('getMacNativeTextInputSourceFeatures', () => { it('accepts common third-party CJK input source IDs', () => { expect(getMacNativeTextInputSourceFeatures('com.google.inputmethod.Japanese.base')).toEqual({ + forwardHangulJamo: false, forwardAsciiPunctuation: true, forwardShortTextReplacements: false }) expect(getMacNativeTextInputSourceFeatures('com.sogou.inputmethod.sogou.pinyin')).toEqual({ + forwardHangulJamo: false, forwardAsciiPunctuation: true, forwardShortTextReplacements: false }) expect(getMacNativeTextInputSourceFeatures('com.bytedance.inputmethod.Doubao')).toEqual({ + forwardHangulJamo: false, forwardAsciiPunctuation: true, forwardShortTextReplacements: false }) expect(getMacNativeTextInputSourceFeatures('im.rime.inputmethod.Squirrel.Rime')).toEqual({ + forwardHangulJamo: false, forwardAsciiPunctuation: true, forwardShortTextReplacements: false }) @@ -50,6 +55,7 @@ describe('getMacNativeTextInputSourceFeatures', () => { 'org.unikey.inputmethod.Unikey' ]) { expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({ + forwardHangulJamo: false, forwardAsciiPunctuation: false, forwardShortTextReplacements: true }) @@ -58,6 +64,7 @@ describe('getMacNativeTextInputSourceFeatures', () => { it('rejects plain keyboard layouts and unrelated input methods', () => { const disabled = { + forwardHangulJamo: false, forwardAsciiPunctuation: false, forwardShortTextReplacements: false } @@ -90,6 +97,7 @@ describe('createMacNativeTextInputSourceTracker', () => { await tracker.refresh() expect(tracker.isActive()).toBe(false) expect(tracker.getFeatures()).toEqual({ + forwardHangulJamo: false, forwardAsciiPunctuation: false, forwardShortTextReplacements: false }) @@ -98,6 +106,7 @@ describe('createMacNativeTextInputSourceTracker', () => { await tracker.refresh() expect(tracker.isActive()).toBe(true) expect(tracker.getFeatures()).toEqual({ + forwardHangulJamo: false, forwardAsciiPunctuation: true, forwardShortTextReplacements: false }) @@ -106,6 +115,7 @@ describe('createMacNativeTextInputSourceTracker', () => { await tracker.refresh() expect(tracker.isActive()).toBe(true) expect(tracker.getFeatures()).toEqual({ + forwardHangulJamo: false, forwardAsciiPunctuation: false, forwardShortTextReplacements: true }) @@ -140,6 +150,7 @@ describe('createMacNativeTextInputSourceTracker', () => { await vi.waitFor(() => expect(tracker.getFeatures()).toEqual({ + forwardHangulJamo: false, forwardAsciiPunctuation: true, forwardShortTextReplacements: false }) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-input-source.ts b/src/renderer/src/components/terminal-pane/terminal-ime-input-source.ts index d4e49dad1..7ffb8bfb9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-source.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-input-source.ts @@ -1,6 +1,7 @@ import type { IDisposable } from '@xterm/xterm' export type MacNativeTextInputSourceFeatures = Readonly<{ + forwardHangulJamo: boolean forwardAsciiPunctuation: boolean forwardShortTextReplacements: boolean }> @@ -14,16 +15,25 @@ export type MacNativeTextInputSourceTracker = IDisposable & { type KeyboardInputSourceReader = () => Promise export const DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({ + forwardHangulJamo: false, forwardAsciiPunctuation: false, forwardShortTextReplacements: false }) satisfies MacNativeTextInputSourceFeatures const CJK_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({ + forwardHangulJamo: false, + forwardAsciiPunctuation: true, + forwardShortTextReplacements: false +}) satisfies MacNativeTextInputSourceFeatures + +const KOREAN_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({ + forwardHangulJamo: true, forwardAsciiPunctuation: true, forwardShortTextReplacements: false }) satisfies MacNativeTextInputSourceFeatures const VIETNAMESE_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({ + forwardHangulJamo: false, forwardAsciiPunctuation: false, forwardShortTextReplacements: true }) satisfies MacNativeTextInputSourceFeatures @@ -55,6 +65,7 @@ const CJK_INPUT_SOURCE_TERMS = [ ] as const const VIETNAMESE_INPUT_SOURCE_TERMS = ['telex', 'unikey', 'vietnam', 'vni'] as const +const KOREAN_INPUT_SOURCE_TERMS = ['hangul', 'korean'] as const const KEYBOARD_ACTIVITY_REFRESH_COOLDOWN_MS = 1000 @@ -90,6 +101,9 @@ export function getMacNativeTextInputSourceFeatures( if (!normalized) { return DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES } + if (KOREAN_INPUT_SOURCE_TERMS.some((term) => normalized.includes(term))) { + return KOREAN_NATIVE_TEXT_INPUT_SOURCE_FEATURES + } if (CJK_INPUT_SOURCE_TERMS.some((term) => normalized.includes(term))) { return CJK_NATIVE_TEXT_INPUT_SOURCE_FEATURES } @@ -162,7 +176,11 @@ export function createMacNativeTextInputSourceTracker( requestKeyboardActivityRefresh(true) return } - if (!features.forwardAsciiPunctuation && !features.forwardShortTextReplacements) { + if ( + !features.forwardHangulJamo && + !features.forwardAsciiPunctuation && + !features.forwardShortTextReplacements + ) { requestKeyboardActivityRefresh(false) } } @@ -175,7 +193,10 @@ export function createMacNativeTextInputSourceTracker( requestRefresh() return { - isActive: () => features.forwardAsciiPunctuation || features.forwardShortTextReplacements, + isActive: () => + features.forwardHangulJamo || + features.forwardAsciiPunctuation || + features.forwardShortTextReplacements, getFeatures: () => features, refresh, dispose: () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-candidates.ts b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-candidates.ts index 0de03f33e..c940d5d5f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-candidates.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-candidates.ts @@ -86,6 +86,21 @@ function isCjkDirectPunctuationKey(key: string): boolean { return Array.from(key).length === 1 && CJK_DIRECT_PUNCTUATION_KEYS.has(key) } +function isHangulJamoKey(key: string): boolean { + const chars = Array.from(key) + if (chars.length !== 1) { + return false + } + const codePoint = chars[0].codePointAt(0)! + return ( + (codePoint >= 0x1100 && codePoint <= 0x11ff) || + (codePoint >= 0x3130 && codePoint <= 0x318f) || + (codePoint >= 0xa960 && codePoint <= 0xa97f) || + (codePoint >= 0xd7b0 && codePoint <= 0xd7ff) || + (codePoint >= 0xffa0 && codePoint <= 0xffdc) + ) +} + function isAsciiShortTextReplacementKey(key: string): boolean { const code = isSingleAsciiKey(key) if (code === null) { @@ -143,6 +158,9 @@ export function isImeNativeTextKeydownCandidate( if (isSyntheticUnicodeTextKey(event)) { return true } + if (inputSourceFeatures.forwardHangulJamo && isHangulJamoKey(event.key)) { + return true + } if (isCjkDirectPunctuationKey(event.key)) { return true } diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts index 776fe42b8..8b1f49aed 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts @@ -8,16 +8,25 @@ import { import type { MacNativeTextInputSourceFeatures } from './terminal-ime-input-source' const CJK_FEATURES = { + forwardHangulJamo: false, + forwardAsciiPunctuation: true, + forwardShortTextReplacements: false +} satisfies MacNativeTextInputSourceFeatures + +const KOREAN_FEATURES = { + forwardHangulJamo: true, forwardAsciiPunctuation: true, forwardShortTextReplacements: false } satisfies MacNativeTextInputSourceFeatures const VIETNAMESE_FEATURES = { + forwardHangulJamo: false, forwardAsciiPunctuation: false, forwardShortTextReplacements: true } satisfies MacNativeTextInputSourceFeatures const DISABLED_FEATURES = { + forwardHangulJamo: false, forwardAsciiPunctuation: false, forwardShortTextReplacements: false } satisfies MacNativeTextInputSourceFeatures @@ -68,6 +77,23 @@ describe('isImeNativeTextKeydownCandidate', () => { ).toBe(true) }) + it('returns initial Korean jamo keydowns to the native IME', () => { + for (const key of ['ㅎ', 'ㅏ', 'ㄴ', 'ᄀ', 'ᄀ']) { + expect(isImeNativeTextKeydownCandidate(keyEvent({ key }), false, KOREAN_FEATURES)).toBe(true) + expect(isImeNativeTextKeydownCandidate(keyEvent({ key }), false, CJK_FEATURES)).toBe(false) + } + }) + + it('keeps Korean jamo modifier chords in terminal shortcut handling', () => { + expect( + isImeNativeTextKeydownCandidate( + keyEvent({ key: 'ㅎ', metaKey: true }), + false, + KOREAN_FEATURES + ) + ).toBe(false) + }) + // Why: macOS Korean sources emit ₩ from Backquote, and a DefaultKeyBinding.dict // entry can rewrite it to ` — but only in the keypress/input events. it('accepts the Korean won-sign key even when the input source probe is stale', () => { diff --git a/tests/e2e/terminal-macos-2set-korean-native.spec.ts b/tests/e2e/terminal-macos-2set-korean-native.spec.ts new file mode 100644 index 000000000..ede3e1e8c --- /dev/null +++ b/tests/e2e/terminal-macos-2set-korean-native.spec.ts @@ -0,0 +1,88 @@ +import { execFileSync } from 'node:child_process' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + attachTerminalImeBoundaryEvidence, + disposeTerminalImeBoundaryProbe, + installTerminalImeBoundaryProbe, + readTerminalImeBoundaryTrace +} from './terminal-ime-boundary-probe' +import { + createTerminalImeByteReader, + removeTerminalImeByteReader, + startTerminalImeByteReader, + waitForTerminalImeBytes +} from './terminal-ime-byte-reader' + +const TWO_SET_KOREAN_ID = 'com.apple.inputmethod.Korean.2SetKorean' + +function typeNativeTwoSetKorean(processId: number): void { + execFileSync('osascript', [ + '-e', + `tell application "System Events" to set frontmost of first application process whose unix id is ${processId} to true`, + '-e', + 'tell application "System Events" to key code {5, 40, 1, 15, 46, 3, 36}' + ]) +} + +async function runNativeScenario( + page: Page, + testInfo: TestInfo, + testRepoPath: string, + processId: number +): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await expect(page.evaluate(() => window.api.app.getKeyboardInputSourceId())).resolves.toBe( + TWO_SET_KOREAN_ID + ) + + const ptyId = await waitForActivePanePtyId(page) + const reader = createTerminalImeByteReader(testRepoPath, 1) + let completed = false + try { + await startTerminalImeByteReader(page, ptyId, reader) + await focusActiveTerminalInput(page) + await installTerminalImeBoundaryProbe(page) + typeNativeTwoSetKorean(processId) + + const receivedBytes = await waitForTerminalImeBytes(page, reader) + expect(receivedBytes).toEqual([Buffer.from('한글\n').toString('hex')]) + const trace = await readTerminalImeBoundaryTrace(page) + expect(trace.onData.join('')).toBe('한글\r') + completed = true + } finally { + await attachTerminalImeBoundaryEvidence(page, testInfo, 'native-macos-2set-boundaries').catch( + () => undefined + ) + await disposeTerminalImeBoundaryProbe(page).catch(() => undefined) + if (!completed) { + await sendToTerminal(page, ptyId, '\x03').catch(() => undefined) + } + removeTerminalImeByteReader(reader) + } +} + +test.describe('Native macOS 2-Set Korean terminal input @headful', () => { + test.skip( + process.platform !== 'darwin' || process.env.ORCA_E2E_NATIVE_MACOS_KOREAN !== '1', + 'Requires macOS with 2-Set Korean selected and Accessibility access' + ) + + test('forwards physical Hangul input as exact PTY bytes', async ({ + electronApp, + orcaPage, + testRepoPath + }, testInfo) => { + await runNativeScenario(orcaPage, testInfo, testRepoPath, electronApp.process().pid!) + }) +})