fix(terminal): preserve macOS Korean composition (#12280)

This commit is contained in:
Neil 2026-08-03 02:46:22 -07:00 committed by GitHub
parent f82d4100b7
commit 035d8c2a54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 166 additions and 2 deletions

View File

@ -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
})

View File

@ -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<string | null>
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: () => {

View File

@ -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
}

View File

@ -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', () => {

View File

@ -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<void> {
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!)
})
})