diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 028255fec..d189ad1ba 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -6658,13 +6658,13 @@ "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.", "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts", - "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts", "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", @@ -6682,7 +6682,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts", + "file": "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts", "assertions": [ "synthetic and native input-source paths are classified for covered fixtures" ] @@ -6770,7 +6770,7 @@ "date": "2026-07-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", "result": "passed", "durationSeconds": 0.7, "summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)." diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 76cfa0116..74183ea1b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -45,8 +45,7 @@ const imeHarness = vi.hoisted(() => ({ sendInput: (data: string) => void }[], trackers: [] as { dispose: ReturnType }[], - claimResult: false, - inputSourceTrackerRequests: 0 + claimResult: false })) vi.mock('@xterm/xterm', () => ({ @@ -131,12 +130,6 @@ vi.mock('@/components/terminal-pane/terminal-ime-composition-tracker', () => ({ return tracker } })) -vi.mock('@/components/terminal-pane/terminal-ime-input-source', () => ({ - getMacNativeTextInputSourceTracker: () => { - imeHarness.inputSourceTrackerRequests++ - return { getFeatures: () => ({}) } - } -})) vi.mock('@/store', () => { const useAppStore = (selector: (s: typeof storeState) => unknown): unknown => selector(storeState) useAppStore.getState = (): typeof storeState => storeState @@ -165,7 +158,6 @@ describe('AgentTerminalPreview', () => { imeHarness.forwarders.length = 0 imeHarness.trackers.length = 0 imeHarness.claimResult = false - imeHarness.inputSourceTrackerRequests = 0 emitData = null emitAppMenuPaste = null connect.mockResolvedValue({ @@ -235,7 +227,6 @@ describe('AgentTerminalPreview', () => { await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) expect(imeHarness.forwarders).toHaveLength(1) expect(imeHarness.trackers).toHaveLength(1) - expect(imeHarness.inputSourceTrackerRequests).toBe(1) imeHarness.forwarders[0]!.sendInput('。') expect(terminal.input).toHaveBeenCalledOnce() @@ -260,7 +251,6 @@ describe('AgentTerminalPreview', () => { expect(copied).toBe(false) expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text') expect(writeClipboardText).not.toHaveBeenCalled() - expect(imeHarness.inputSourceTrackerRequests).toBe(1) }) it('does not install the IME native-text forwarder off macOS', async () => { diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts index f61a93ab0..5171a529e 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts @@ -2,7 +2,6 @@ import type { Terminal } from '@xterm/xterm' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { installTerminalImeCompositionTracker } from '@/components/terminal-pane/terminal-ime-composition-tracker' import { installTerminalImeNativeTextForwarder } from '@/components/terminal-pane/terminal-ime-native-text-forwarder' -import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source' export type PreviewImeBridge = { /** True when the forwarder owns this keydown, so xterm must not encode it. */ @@ -21,14 +20,11 @@ export function installPreviewImeBridge(terminal: Terminal): PreviewImeBridge | if (getShortcutPlatform() !== 'darwin') { return null } - // Why: prewarm the async input-source lookup before the first native-text key needs classification. - const inputSourceTracker = getMacNativeTextInputSourceTracker() const compositionTracker = installTerminalImeCompositionTracker(terminal.element) const forwarder = installTerminalImeNativeTextForwarder({ terminalElement: terminal.element, isComposing: () => compositionTracker?.isActive() ?? false, - sendInput: (data) => terminal.input(data), - getInputSourceFeatures: () => inputSourceTracker.getFeatures() + sendInput: (data) => terminal.input(data) }) return { claimKeyEvent: (event) => forwarder?.claimKeyEvent(event) ?? false, 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 deleted file mode 100644 index 9e159e6c5..000000000 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - _resetMacNativeTextInputSourceTrackerForTests, - createMacNativeTextInputSourceTracker, - getMacNativeTextInputSourceFeatures, - getMacNativeTextInputSourceTracker -} from './terminal-ime-input-source' - -describe('getMacNativeTextInputSourceFeatures', () => { - it('enables punctuation forwarding for Apple Chinese, Japanese and Korean input methods', () => { - for (const sourceId of [ - 'com.apple.inputmethod.SCIM.ITABC', - 'com.apple.inputmethod.TCIM.Pinyin', - 'com.apple.keylayout.PinyinKeyboard', - 'com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese', - 'com.apple.inputmethod.Korean.2SetKorean' - ]) { - expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({ - forwardHangulJamo: sourceId.includes('Korean'), - forwardAsciiPunctuation: true, - forwardShortTextReplacements: false - }) - } - }) - - 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 - }) - }) - - it('enables short native replacement forwarding for Vietnamese input methods', () => { - for (const sourceId of [ - 'com.apple.inputmethod.Vietnamese', - 'com.apple.inputmethod.Vietnamese.Telex', - 'com.apple.inputmethod.Vietnamese.VNI', - 'org.unikey.inputmethod.Unikey' - ]) { - expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({ - forwardHangulJamo: false, - forwardAsciiPunctuation: false, - forwardShortTextReplacements: true - }) - } - }) - - it('rejects plain keyboard layouts and unrelated input methods', () => { - const disabled = { - forwardHangulJamo: false, - forwardAsciiPunctuation: false, - forwardShortTextReplacements: false - } - expect(getMacNativeTextInputSourceFeatures(null)).toEqual(disabled) - expect(getMacNativeTextInputSourceFeatures('com.apple.keylayout.US')).toEqual(disabled) - expect(getMacNativeTextInputSourceFeatures('com.apple.keylayout.ABC')).toEqual(disabled) - expect(getMacNativeTextInputSourceFeatures('com.apple.keylayout.PolishPro')).toEqual(disabled) - expect(getMacNativeTextInputSourceFeatures('com.apple.inputmethod.CharacterPaletteIM')).toEqual( - disabled - ) - }) -}) - -describe('createMacNativeTextInputSourceTracker', () => { - beforeEach(() => { - _resetMacNativeTextInputSourceTrackerForTests() - }) - - afterEach(() => { - _resetMacNativeTextInputSourceTrackerForTests() - }) - - it('starts disabled and refreshes from the current input source', async () => { - let sourceId = 'com.apple.keylayout.US' - const tracker = createMacNativeTextInputSourceTracker(window, { - readInputSourceId: async () => sourceId - }) - - expect(tracker.isActive()).toBe(false) - await tracker.refresh() - expect(tracker.isActive()).toBe(false) - expect(tracker.getFeatures()).toEqual({ - forwardHangulJamo: false, - forwardAsciiPunctuation: false, - forwardShortTextReplacements: false - }) - - sourceId = 'com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese' - await tracker.refresh() - expect(tracker.isActive()).toBe(true) - expect(tracker.getFeatures()).toEqual({ - forwardHangulJamo: false, - forwardAsciiPunctuation: true, - forwardShortTextReplacements: false - }) - - sourceId = 'com.apple.inputmethod.Vietnamese.Telex' - await tracker.refresh() - expect(tracker.isActive()).toBe(true) - expect(tracker.getFeatures()).toEqual({ - forwardHangulJamo: false, - forwardAsciiPunctuation: false, - forwardShortTextReplacements: true - }) - - tracker.dispose() - }) - - it('refreshes on window focus so language switches are picked up', async () => { - let sourceId = 'com.apple.keylayout.US' - const tracker = createMacNativeTextInputSourceTracker(window, { - readInputSourceId: async () => sourceId - }) - await tracker.refresh() - - sourceId = 'com.apple.inputmethod.TCIM.Pinyin' - window.dispatchEvent(new Event('focus')) - - await vi.waitFor(() => expect(tracker.isActive()).toBe(true)) - tracker.dispose() - }) - - it('refreshes on keydown so focused input source switches are picked up', async () => { - let sourceId = 'com.apple.keylayout.ABC' - const tracker = createMacNativeTextInputSourceTracker(window, { - readInputSourceId: async () => sourceId - }) - await tracker.refresh() - expect(tracker.isActive()).toBe(false) - - sourceId = 'com.apple.inputmethod.SCIM.ITABC' - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' })) - - await vi.waitFor(() => - expect(tracker.getFeatures()).toEqual({ - forwardHangulJamo: false, - forwardAsciiPunctuation: true, - forwardShortTextReplacements: false - }) - ) - tracker.dispose() - }) - - it('throttles ordinary key refreshes while input-source features are disabled', async () => { - let sourceId = 'com.apple.keylayout.ABC' - const readInputSourceId = vi.fn(async () => sourceId) - const tracker = createMacNativeTextInputSourceTracker(window, { readInputSourceId }) - await tracker.refresh() - readInputSourceId.mockClear() - - const now = vi.spyOn(Date, 'now').mockReturnValue(1000) - try { - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) - await vi.waitFor(() => expect(readInputSourceId).toHaveBeenCalledTimes(1)) - - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b' })) - window.dispatchEvent(new KeyboardEvent('keyup', { key: 'b' })) - await Promise.resolve() - expect(readInputSourceId).toHaveBeenCalledTimes(1) - - sourceId = 'com.apple.inputmethod.SCIM.ITABC' - now.mockReturnValue(2001) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })) - await vi.waitFor(() => expect(readInputSourceId).toHaveBeenCalledTimes(2)) - expect(tracker.isActive()).toBe(true) - } finally { - now.mockRestore() - tracker.dispose() - } - }) - - it('refreshes on modifier keyup so active sources can become disabled while focused', async () => { - let sourceId = 'com.apple.inputmethod.SCIM.ITABC' - const tracker = createMacNativeTextInputSourceTracker(window, { - readInputSourceId: async () => sourceId - }) - await tracker.refresh() - expect(tracker.isActive()).toBe(true) - - sourceId = 'com.apple.keylayout.ABC' - window.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', ctrlKey: true })) - - await vi.waitFor(() => expect(tracker.isActive()).toBe(false)) - tracker.dispose() - }) - - it('keeps the singleton reusable for terminal lifecycle code', () => { - const first = getMacNativeTextInputSourceTracker() - const second = getMacNativeTextInputSourceTracker() - - expect(second).toBe(first) - }) -}) 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 deleted file mode 100644 index 7ffb8bfb9..000000000 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-source.ts +++ /dev/null @@ -1,221 +0,0 @@ -import type { IDisposable } from '@xterm/xterm' - -export type MacNativeTextInputSourceFeatures = Readonly<{ - forwardHangulJamo: boolean - forwardAsciiPunctuation: boolean - forwardShortTextReplacements: boolean -}> - -export type MacNativeTextInputSourceTracker = IDisposable & { - isActive: () => boolean - getFeatures: () => MacNativeTextInputSourceFeatures - refresh: () => Promise -} - -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 - -const CJK_INPUT_SOURCE_TERMS = [ - 'bytedance', - 'cangjie', - 'chinese', - 'doubao', - 'hangul', - 'hanin', - 'hiragana', - 'itabc', - 'japanese', - 'kana', - 'katakana', - 'korean', - 'kotoeri', - 'pinyin', - 'rime', - 'romaji', - 'scim', - 'shuangpin', - 'stroke', - 'tcim', - 'wubi', - 'wubihua', - 'zhuyin' -] 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 - -function defaultKeyboardInputSourceReader(): KeyboardInputSourceReader { - return async () => { - const api = ( - globalThis as { - window?: { - api?: { - app?: { - getKeyboardInputSourceId?: () => Promise - } - } - } - } - ).window?.api - const reader = api?.app?.getKeyboardInputSourceId - if (!reader) { - return null - } - try { - return await reader() - } catch { - return null - } - } -} - -export function getMacNativeTextInputSourceFeatures( - id: string | null | undefined -): MacNativeTextInputSourceFeatures { - const normalized = id?.trim().toLowerCase() - 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 - } - if (VIETNAMESE_INPUT_SOURCE_TERMS.some((term) => normalized.includes(term))) { - return VIETNAMESE_NATIVE_TEXT_INPUT_SOURCE_FEATURES - } - return DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES -} - -export function createMacNativeTextInputSourceTracker( - win: Window = window, - options: { readInputSourceId?: KeyboardInputSourceReader } = {} -): MacNativeTextInputSourceTracker { - const readInputSourceId = options.readInputSourceId ?? defaultKeyboardInputSourceReader() - let features: MacNativeTextInputSourceFeatures = DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES - let disposed = false - let refreshGeneration = 0 - let refreshInFlight = false - let refreshQueued = false - let lastKeyboardActivityRefreshAt: number | null = null - - const refresh = async (): Promise => { - const generation = ++refreshGeneration - let inputSourceId: string | null = null - try { - inputSourceId = await readInputSourceId() - } catch { - inputSourceId = null - } - if (disposed || generation !== refreshGeneration) { - return - } - features = getMacNativeTextInputSourceFeatures(inputSourceId) - } - - const requestRefresh = (): void => { - if (refreshInFlight) { - refreshQueued = true - return - } - refreshInFlight = true - void refresh().finally(() => { - refreshInFlight = false - if (!disposed && refreshQueued) { - refreshQueued = false - requestRefresh() - } - }) - } - - const onFocus = (): void => { - requestRefresh() - } - - const requestKeyboardActivityRefresh = (force: boolean): void => { - const now = Date.now() - if ( - !force && - lastKeyboardActivityRefreshAt !== null && - now - lastKeyboardActivityRefreshAt < KEYBOARD_ACTIVITY_REFRESH_COOLDOWN_MS - ) { - return - } - lastKeyboardActivityRefreshAt = now - requestRefresh() - } - - const onKeyboardActivity = (event: KeyboardEvent): void => { - if (event.ctrlKey || event.altKey || event.metaKey) { - requestKeyboardActivityRefresh(true) - return - } - if ( - !features.forwardHangulJamo && - !features.forwardAsciiPunctuation && - !features.forwardShortTextReplacements - ) { - requestKeyboardActivityRefresh(false) - } - } - - win.addEventListener('focus', onFocus) - // Why: macOS input-source changes can happen while the terminal keeps focus; - // refresh from keyboard activity so CJK punctuation gates do not stay stale. - win.addEventListener('keydown', onKeyboardActivity, true) - win.addEventListener('keyup', onKeyboardActivity, true) - requestRefresh() - - return { - isActive: () => - features.forwardHangulJamo || - features.forwardAsciiPunctuation || - features.forwardShortTextReplacements, - getFeatures: () => features, - refresh, - dispose: () => { - disposed = true - win.removeEventListener('focus', onFocus) - win.removeEventListener('keydown', onKeyboardActivity, true) - win.removeEventListener('keyup', onKeyboardActivity, true) - } - } -} - -let singleton: MacNativeTextInputSourceTracker | null = null - -export function getMacNativeTextInputSourceTracker(): MacNativeTextInputSourceTracker { - singleton ??= createMacNativeTextInputSourceTracker() - return singleton -} - -export function _resetMacNativeTextInputSourceTrackerForTests(): void { - singleton?.dispose() - singleton = null -} 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 deleted file mode 100644 index c940d5d5f..000000000 --- a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-candidates.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { - DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES, - type MacNativeTextInputSourceFeatures -} from './terminal-ime-input-source' - -export type ImeNativeTextKeyEvent = { - type: string - key: string - code?: string - keyCode?: number - which?: number - metaKey: boolean - ctrlKey: boolean - altKey: boolean - isComposing?: boolean -} - -const CJK_DIRECT_PUNCTUATION_KEYS = new Set([ - '、', - '。', - ',', - '.', - '!', - '?', - ';', - ':', - '“', - '”', - '‘', - '’', - '(', - ')', - '【', - '】', - '《', - '》', - '〈', - '〉', - '「', - '」', - '『', - '』', - '¥', - // Korean sources put ₩ on Backquote; its committed text can differ from - // `key` when DefaultKeyBinding.dict rewrites it (typically back to `). - '₩', - '~', - '·', - '…' -]) - -function isSingleAsciiKey(key: string): number | null { - // Reject multi-codepoint keys ("Enter", "ArrowLeft", emoji, ...). - if (Array.from(key).length !== 1) { - return null - } - return key.codePointAt(0) ?? null -} - -function isAsciiDigitCode(code: number): boolean { - return code >= 0x30 && code <= 0x39 -} - -function isUpperAsciiLetterCode(code: number): boolean { - return code >= 0x41 && code <= 0x5a -} - -function isLowerAsciiLetterCode(code: number): boolean { - return code >= 0x61 && code <= 0x7a -} - -function isAsciiPunctuationKey(key: string): boolean { - const code = isSingleAsciiKey(key) - if (code === null) { - return false - } - const isDigit = isAsciiDigitCode(code) - const isUpperAlpha = isUpperAsciiLetterCode(code) - const isLowerAlpha = isLowerAsciiLetterCode(code) - // Printable ASCII excluding space (0x20), digits and letters: the keys an - // IME may swap for a full-width or CJK glyph. - return code > 0x20 && code <= 0x7e && !isDigit && !isUpperAlpha && !isLowerAlpha -} - -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) { - return false - } - return isAsciiDigitCode(code) || isUpperAsciiLetterCode(code) || isLowerAsciiLetterCode(code) -} - -export function isSinglePrintableTextKey(key: string): boolean { - const chars = Array.from(key) - if (chars.length !== 1) { - return false - } - const codePoint = chars[0].codePointAt(0) - return codePoint !== undefined && codePoint >= 0x20 && codePoint !== 0x7f -} - -function hasUnreliablePhysicalKeyIdentity(event: ImeNativeTextKeyEvent): boolean { - const code = event.code?.trim() - const legacyKeyCode = event.keyCode ?? event.which - return !code || code === 'Unidentified' || legacyKeyCode === 0 -} - -function isSyntheticUnicodeTextKey(event: ImeNativeTextKeyEvent): boolean { - if (!hasUnreliablePhysicalKeyIdentity(event)) { - return false - } - // CGEventKeyboardSetUnicodeString-style injectors can surface as - // `Unidentified` keydowns; the following `input` event carries the text. - if (event.key === 'Unidentified') { - return true - } - return isSinglePrintableTextKey(event.key) -} - -export function isImeNativeTextKeydownCandidate( - event: ImeNativeTextKeyEvent, - compositionActive: boolean, - inputSourceFeatures: MacNativeTextInputSourceFeatures = DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES -): boolean { - if (event.type !== 'keydown') { - return false - } - // Modifier chords are real shortcuts (Ctrl+C, Cmd+V, Alt+...); never a plain - // native text commit. Shift is allowed since punctuation and uppercase - // Vietnamese text can legitimately need it. - if (event.ctrlKey || event.altKey || event.metaKey) { - return false - } - // Composing keystrokes belong to the IME preedit and xterm's CompositionHelper - // (which already forwards the committed text), so leave them alone. - if (event.isComposing === true || compositionActive) { - return false - } - if (isSyntheticUnicodeTextKey(event)) { - return true - } - if (inputSourceFeatures.forwardHangulJamo && isHangulJamoKey(event.key)) { - return true - } - if (isCjkDirectPunctuationKey(event.key)) { - return true - } - if (inputSourceFeatures.forwardAsciiPunctuation && isAsciiPunctuationKey(event.key)) { - return true - } - return ( - inputSourceFeatures.forwardShortTextReplacements && isAsciiShortTextReplacementKey(event.key) - ) -} 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 8b1f49aed..11f9261f4 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 @@ -1,35 +1,11 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder' import { - isImeNativeTextKeydownCandidate, + installTerminalImeNativeTextForwarder, + XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, + XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT, type ImeNativeTextKeyEvent -} from './terminal-ime-native-text-candidates' -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 +} from './terminal-ime-native-text-forwarder' function keyEvent(overrides: Partial): ImeNativeTextKeyEvent { return { @@ -48,197 +24,6 @@ function dispatchInsertText(target: HTMLElement, data: string | null): void { target.dispatchEvent(new InputEvent('input', { data, inputType: 'insertText', bubbles: true })) } -describe('isImeNativeTextKeydownCandidate', () => { - it('accepts unmodified ASCII punctuation keydown outside composition for CJK sources', () => { - for (const key of [',', '.', '?', '!', ';', ':', '"', "'", '\\', '<', '>', '~', '@', '#']) { - expect(isImeNativeTextKeydownCandidate(keyEvent({ key }), false, CJK_FEATURES)).toBe(true) - } - }) - - it('rejects unmodified ASCII punctuation keydown without an input source gate', () => { - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '.' }), false, DISABLED_FEATURES)).toBe( - false - ) - }) - - it('accepts direct CJK punctuation keydown outside composition for CJK sources', () => { - for (const key of [',', '。', '、', '?', '!', ':', ';', '…']) { - expect(isImeNativeTextKeydownCandidate(keyEvent({ key }), false, CJK_FEATURES)).toBe(true) - } - }) - - it('accepts direct CJK punctuation keydown when the input source probe is stale', () => { - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: ',', code: 'Comma' }), - false, - DISABLED_FEATURES - ) - ).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', () => { - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }), - false, - DISABLED_FEATURES - ) - ).toBe(true) - }) - - it('accepts Vietnamese short replacement keys without enabling punctuation', () => { - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: 'a', code: 'KeyA' }), - false, - VIETNAMESE_FEATURES - ) - ).toBe(true) - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: 'D', code: 'KeyD' }), - false, - VIETNAMESE_FEATURES - ) - ).toBe(true) - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: '9', code: 'Digit9' }), - false, - VIETNAMESE_FEATURES - ) - ).toBe(true) - expect( - isImeNativeTextKeydownCandidate(keyEvent({ key: ',' }), false, VIETNAMESE_FEATURES) - ).toBe(false) - }) - - it('accepts synthesized Unicode text keydowns without an input source gate', () => { - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: 'h', code: 'KeyA', keyCode: 0 }), - false, - DISABLED_FEATURES - ) - ).toBe(true) - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: 'é', code: '', keyCode: 0 }), - false, - DISABLED_FEATURES - ) - ).toBe(true) - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: 'Unidentified', code: 'Unidentified' }), - false, - DISABLED_FEATURES - ) - ).toBe(true) - }) - - it('rejects letters, digits and whitespace keys for CJK punctuation forwarding', () => { - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: 'a' }), false, CJK_FEATURES)).toBe(false) - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: 'Z' }), false, CJK_FEATURES)).toBe(false) - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '5' }), false, CJK_FEATURES)).toBe(false) - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: ' ' }), false, CJK_FEATURES)).toBe(false) - }) - - it('rejects named keys and multi-codepoint keys', () => { - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: 'Enter' }), false, CJK_FEATURES)).toBe( - false - ) - expect( - isImeNativeTextKeydownCandidate(keyEvent({ key: 'ArrowLeft' }), false, CJK_FEATURES) - ).toBe(false) - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '👩‍💻' }), false, CJK_FEATURES)).toBe( - false - ) - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: 'Enter', code: 'Enter', keyCode: 0 }), - false, - DISABLED_FEATURES - ) - ).toBe(false) - }) - - it('rejects Ctrl/Alt/Meta chords but accepts shifted punctuation like "!"', () => { - expect( - isImeNativeTextKeydownCandidate(keyEvent({ key: ',', ctrlKey: true }), false, CJK_FEATURES) - ).toBe(false) - expect( - isImeNativeTextKeydownCandidate(keyEvent({ key: ',', metaKey: true }), false, CJK_FEATURES) - ).toBe(false) - expect( - isImeNativeTextKeydownCandidate(keyEvent({ key: ',', altKey: true }), false, CJK_FEATURES) - ).toBe(false) - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '!' }), false, CJK_FEATURES)).toBe(true) - }) - - it('rejects keystrokes that belong to an active composition', () => { - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: ',', isComposing: true }), - false, - CJK_FEATURES - ) - ).toBe(false) - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: ',' }), true, CJK_FEATURES)).toBe(false) - }) - - it('rejects non keyboard event types', () => { - expect(isImeNativeTextKeydownCandidate(keyEvent({ type: 'input' }), false, CJK_FEATURES)).toBe( - false - ) - expect( - isImeNativeTextKeydownCandidate(keyEvent({ type: 'keypress' }), false, CJK_FEATURES) - ).toBe(false) - expect(isImeNativeTextKeydownCandidate(keyEvent({ type: 'keyup' }), false, CJK_FEATURES)).toBe( - false - ) - }) - - it('does not treat Japanese text keys as punctuation candidates', () => { - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: 'あ' }), false, CJK_FEATURES)).toBe( - false - ) - expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '語' }), false, CJK_FEATURES)).toBe( - false - ) - }) - - it('rejects physical printable candidates when no native text source feature is active', () => { - expect( - isImeNativeTextKeydownCandidate( - keyEvent({ key: 'a', code: 'KeyA' }), - false, - DISABLED_FEATURES - ) - ).toBe(false) - }) -}) - describe('installTerminalImeNativeTextForwarder', () => { let element: HTMLDivElement let textarea: HTMLTextAreaElement @@ -252,408 +37,300 @@ describe('installTerminalImeNativeTextForwarder', () => { document.body.appendChild(element) }) - it('forwards the IME-committed full-width glyph from the input event', () => { - const sendInput = vi.fn() - const laterInputListener = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES - }) - element.addEventListener('input', laterInputListener, true) - - expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) - textarea.value = ',' - dispatchInsertText(textarea, ',') - - expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') - expect(laterInputListener).not.toHaveBeenCalled() - expect(textarea.value).toBe('') - }) - - it('forwards Japanese direct punctuation committed from a punctuation key', () => { + function install(isComposing: () => boolean = () => false): { + forwarder: ReturnType + sendInput: ReturnType + } { const sendInput = vi.fn() const forwarder = installTerminalImeNativeTextForwarder({ terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES + isComposing, + sendInput + }) + return { forwarder, sendInput } + } + + describe('the claim is structural', () => { + it('claims every single printable key regardless of which character it is', () => { + const { forwarder } = install() + // Punctuation, letters, digits, already-substituted CJK glyphs, Hangul + // jamo and a won sign are all the same case: one printable character. + for (const key of [ + ',', + '.', + '?', + '\\', + '!', + 'a', + 'Z', + '9', + ',', + '。', + '、', + 'ᄒ', + '₩', + '1' + ]) { + expect(forwarder.claimKeyEvent(keyEvent({ key }))).toBe(true) + } }) - expect(forwarder.claimKeyEvent(keyEvent({ key: '.' }))).toBe(true) - dispatchInsertText(textarea, '。') - - expect(sendInput).toHaveBeenCalledExactlyOnceWith('。') - }) - - it('forwards CJK punctuation when the keydown already carries the transformed glyph', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES + it('claims without reading any input source, so unknown IMEs work too', () => { + // Regression: the previous forwarder gated on a 23-term input-source + // allowlist, so third-party IMEs off that list were never claimed. + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: '\\', code: 'Backslash' }))).toBe(true) + dispatchInsertText(textarea, '、') + expect(sendInput).toHaveBeenCalledExactlyOnceWith('、') }) - expect(forwarder.claimKeyEvent(keyEvent({ key: '。', code: 'Period' }))).toBe(true) - dispatchInsertText(textarea, '。') - - expect(sendInput).toHaveBeenCalledExactlyOnceWith('。') - }) - - it('forwards the key binding substitution for the Korean won-sign key', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES + it('rejects Ctrl/Alt/Meta chords but accepts shifted punctuation', () => { + const { forwarder } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: 'c', ctrlKey: true }))).toBe(false) + expect(forwarder.claimKeyEvent(keyEvent({ key: 'v', metaKey: true }))).toBe(false) + expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', altKey: true }))).toBe(false) + expect(forwarder.claimKeyEvent(keyEvent({ key: '!', code: 'Digit1' }))).toBe(true) }) - expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }))).toBe( - true - ) - dispatchInsertText(textarea, '`') - - expect(sendInput).toHaveBeenCalledExactlyOnceWith('`') - }) - - it('forwards the won sign unchanged when no key binding remaps it', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES + it('rejects named keys and multi-code-unit keys on length alone', () => { + const { forwarder } = install() + // No enumeration needed: none of these is one code unit long. + for (const key of [ + 'Enter', + 'Tab', + 'ArrowLeft', + 'Escape', + 'Dead', + 'F3', + 'Unidentified', + '😀' + ]) { + expect(forwarder.claimKeyEvent(keyEvent({ key }))).toBe(false) + } }) - expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }))).toBe( - true - ) - dispatchInsertText(textarea, '₩') - - expect(sendInput).toHaveBeenCalledExactlyOnceWith('₩') - }) - - it('forwards a plain ASCII symbol unchanged when the IME does not convert it', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES + it('rejects keystrokes that belong to an active composition', () => { + const { forwarder } = install(() => true) + expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false) + const { forwarder: other } = install() + expect(other.claimKeyEvent(keyEvent({ key: ',', isComposing: true }))).toBe(false) }) - forwarder.claimKeyEvent(keyEvent({ key: ',' })) - dispatchInsertText(textarea, ',') - - expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') - }) - - it('does not forward input when no candidate keydown was claimed', () => { - const sendInput = vi.fn() - installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES + it('rejects non-keydown types when it did not claim the keydown', () => { + const { forwarder, sendInput } = install(() => true) + expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false) + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: ',' }))).toBe(false) + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(false) + dispatchInsertText(textarea, ',') + expect(sendInput).not.toHaveBeenCalled() }) - - dispatchInsertText(textarea, '😀') - expect(sendInput).not.toHaveBeenCalled() }) - it('does not claim composing keystrokes', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => true, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false) - dispatchInsertText(textarea, ',') - expect(sendInput).not.toHaveBeenCalled() - }) - - it('ignores composition input events even after a claimed keydown', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES - }) - - forwarder.claimKeyEvent(keyEvent({ key: ',' })) - textarea.dispatchEvent( - new InputEvent('input', { data: ',', inputType: 'insertCompositionText', bubbles: true }) - ) - expect(sendInput).not.toHaveBeenCalled() - }) - - it('clears pending forwarding when a Japanese composition input takes over', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) - textarea.dispatchEvent( - new InputEvent('input', { data: 'に', inputType: 'insertCompositionText', bubbles: true }) - ) - dispatchInsertText(textarea, '日本語') - - expect(sendInput).not.toHaveBeenCalled() - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true) - }) - - it('keeps pending forward after keyup for CJK punctuation until the committed input arrives', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true) - dispatchInsertText(textarea, ',') - expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') - }) - - it('drops pending forwarding when no input follows a claimed keyup', () => { - vi.useFakeTimers() - try { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES - }) + describe('forwarding the committed text', () => { + it('forwards the IME-committed full-width glyph from the input event', () => { + const { forwarder, sendInput } = install() + const laterInputListener = vi.fn() + element.addEventListener('input', laterInputListener, true) expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true) - vi.advanceTimersByTime(100) + textarea.value = ',' dispatchInsertText(textarea, ',') + expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') + expect(laterInputListener).not.toHaveBeenCalled() + expect(textarea.value).toBe('') + }) + + it('forwards a multi-code-unit commit from a single press', () => { + // `——` is two code units; reading `ev.data` wholesale covers it for free. + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: '_', code: 'Minus' }))).toBe(true) + dispatchInsertText(textarea, '——') + expect(sendInput).toHaveBeenCalledExactlyOnceWith('——') + }) + + it('forwards the committed text when the keydown already carries the glyph', () => { + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: '。', code: 'Period' }))).toBe(true) + dispatchInsertText(textarea, '。') + expect(sendInput).toHaveBeenCalledExactlyOnceWith('。') + }) + + it('forwards what the key binding substituted, not what the key said', () => { + // The won-sign key commits a backtick under DefaultKeyBinding.dict; the + // committed text wins because `key` is never consulted for identity. + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote' }))).toBe(true) + dispatchInsertText(textarea, '`') + expect(sendInput).toHaveBeenCalledExactlyOnceWith('`') + }) + + it('forwards a plain ASCII symbol unchanged when the IME does not convert it', () => { + const { forwarder, sendInput } = install() + forwarder.claimKeyEvent(keyEvent({ key: ',' })) + dispatchInsertText(textarea, ',') + expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') + }) + + it('forwards Vietnamese letter replacement text and suppresses matching key events', () => { + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true) + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: 'á', code: 'KeyA' }))).toBe( + true + ) + dispatchInsertText(textarea, 'á') + + // The keypress is what must be suppressed (it would double-send the ASCII 'a'). + // The keyup is not: 'á' reached the pty, so the app is owed the matching release. + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'a', code: 'KeyA' }))).toBe( + false + ) + expect(sendInput).toHaveBeenCalledExactlyOnceWith('á') + }) + + it('does not forward input when no keydown was claimed', () => { + const { sendInput } = install() + dispatchInsertText(textarea, '😀') expect(sendInput).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('only forwards a single input per claimed keydown', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES }) - forwarder.claimKeyEvent(keyEvent({ key: ',' })) - dispatchInsertText(textarea, ',') - dispatchInsertText(textarea, '。') - - expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') - }) - - it('bypasses keypress without clearing the armed forward (avoids ASCII double-send)', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES + it('only forwards a single input per claimed keydown', () => { + const { forwarder, sendInput } = install() + forwarder.claimKeyEvent(keyEvent({ key: ',' })) + dispatchInsertText(textarea, ',') + dispatchInsertText(textarea, '。') + expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') }) - // keydown → keypress → input is the native order after we let the keydown - // through; keypress must be claimed (so xterm stays silent) yet preserve the - // pending forward armed by the keydown. - expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: ',' }))).toBe(true) - dispatchInsertText(textarea, ',') - - expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') - }) - - it('bypasses transformed keypresses even when the browser omits physical code', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => VIETNAMESE_FEATURES + it('ignores composition input events even after a claimed keydown', () => { + const { forwarder, sendInput } = install() + forwarder.claimKeyEvent(keyEvent({ key: ',' })) + textarea.dispatchEvent( + new InputEvent('input', { data: ',', inputType: 'insertCompositionText', bubbles: true }) + ) + expect(sendInput).not.toHaveBeenCalled() }) - expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true) - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: 'á', code: undefined }))).toBe( - true - ) - dispatchInsertText(textarea, 'á') + it('clears pending forwarding when a composition input takes over', () => { + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) + textarea.dispatchEvent( + new InputEvent('input', { data: 'に', inputType: 'insertCompositionText', bubbles: true }) + ) + dispatchInsertText(textarea, '日本語') - expect(sendInput).toHaveBeenCalledExactlyOnceWith('á') + expect(sendInput).not.toHaveBeenCalled() + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true) + }) }) - it('forwards Vietnamese letter replacement text and suppresses matching key events', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => VIETNAMESE_FEATURES + describe('claim lifetime is bounded by events, never by a timer', () => { + it('keeps the claim armed across keyup until the committed input arrives', () => { + // Some macOS IMEs deliver keyup before the final insertText. + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true) + dispatchInsertText(textarea, ',') + expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') }) - expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true) - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: 'á', code: 'KeyA' }))).toBe( - true - ) - dispatchInsertText(textarea, 'á') + it('drops a stale claim on the next keydown rather than on elapsed time', () => { + vi.useFakeTimers() + try { + const { forwarder, sendInput } = install() + // First press is swallowed by the IME: no input event ever arrives. + expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true) + // Time alone must change nothing — no deferral is armed. + vi.advanceTimersByTime(5000) + expect(vi.getTimerCount()).toBe(0) - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'a', code: 'KeyA' }))).toBe(true) - expect(sendInput).toHaveBeenCalledExactlyOnceWith('á') + // The next keydown re-arms, and its own commit is what gets forwarded. + expect(forwarder.claimKeyEvent(keyEvent({ key: '.', code: 'Period' }))).toBe(true) + dispatchInsertText(textarea, '。') + expect(sendInput).toHaveBeenCalledExactlyOnceWith('。') + } finally { + vi.useRealTimers() + } + }) + + it('bypasses keypress without clearing the armed forward (avoids ASCII double-send)', () => { + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: ',' }))).toBe(true) + dispatchInsertText(textarea, ',') + expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') + }) + + it('bypasses transformed keypresses even when the browser omits physical code', () => { + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true) + expect( + forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: 'á', code: undefined })) + ).toBe(true) + dispatchInsertText(textarea, 'á') + expect(sendInput).toHaveBeenCalledExactlyOnceWith('á') + }) + + // An app that negotiated kitty `report_event_types` expects a release for every press it + // received. The claim now takes every printable keydown, so swallowing the keyup + // unconditionally would drop the release for ordinary typing and leave keys stuck down. + it('lets the keyup through once the press has reached the pty, so its release still fires', () => { + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) + dispatchInsertText(textarea, ',') + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(false) + expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') + }) + + // The paired case: nothing reached the pty, so a release would describe a press the app + // never saw. This is the leak the unconditional claim was originally guarding against. + it('still swallows the keyup when the input source ate the press', () => { + const { forwarder, sendInput } = install() + expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) + expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true) + expect(sendInput).not.toHaveBeenCalled() + }) + + it('releases the claim on blur', () => { + const { forwarder, sendInput } = install() + forwarder.claimKeyEvent(keyEvent({ key: ',' })) + element.dispatchEvent(new FocusEvent('blur', { bubbles: true })) + dispatchInsertText(textarea, ',') + expect(sendInput).not.toHaveBeenCalled() + }) }) - it('forwards Vietnamese source letters that commit unchanged', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => VIETNAMESE_FEATURES + describe('composition transactions keep ownership', () => { + it('lets an accepted transaction own its own commit', () => { + const { forwarder, sendInput } = install() + forwarder.claimKeyEvent(keyEvent({ key: ',' })) + element.dispatchEvent( + new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true }) + ) + dispatchInsertText(textarea, ',') + expect(sendInput).not.toHaveBeenCalled() }) - expect(forwarder.claimKeyEvent(keyEvent({ key: 'x', code: 'KeyX' }))).toBe(true) - dispatchInsertText(textarea, 'x') - - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'x', code: 'KeyX' }))).toBe(true) - expect(sendInput).toHaveBeenCalledExactlyOnceWith('x') - }) - - it('forwards Vietnamese VNI digit replacement text within the Vietnamese source gate', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => VIETNAMESE_FEATURES + it('resumes forwarding once the transaction settles', () => { + const { forwarder, sendInput } = install() + element.dispatchEvent( + new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true }) + ) + element.dispatchEvent( + new CustomEvent(XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT, { bubbles: true }) + ) + forwarder.claimKeyEvent(keyEvent({ key: ',' })) + dispatchInsertText(textarea, ',') + expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: '9', code: 'Digit9' }))).toBe(true) - dispatchInsertText(textarea, 'đ') - - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: '9', code: 'Digit9' }))).toBe( - true - ) - expect(sendInput).toHaveBeenCalledExactlyOnceWith('đ') - }) - - it('forwards synthesized non-CJK text while ordinary input-source features are disabled', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => DISABLED_FEATURES - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: 'h', code: 'KeyA', keyCode: 0 }))).toBe(true) - dispatchInsertText(textarea, 'hello') - - expect( - forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'h', code: 'KeyA', keyCode: 0 })) - ).toBe(true) - expect(sendInput).toHaveBeenCalledExactlyOnceWith('hello') - }) - - it('does not claim Vietnamese replacement keys when modifier chords are held', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => VIETNAMESE_FEATURES - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA', metaKey: true }))).toBe(false) - expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA', ctrlKey: true }))).toBe(false) - expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA', altKey: true }))).toBe(false) - dispatchInsertText(textarea, 'á') - - expect(sendInput).not.toHaveBeenCalled() - }) - - it('does not forward ordinary letters without a native replacement source', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => DISABLED_FEATURES - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(false) - dispatchInsertText(textarea, 'á') - - expect(sendInput).not.toHaveBeenCalled() - }) - - it('still claims keyup after forwarding input so the kitty release sequence does not leak', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) - dispatchInsertText(textarea, ',') - - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true) - expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') - }) - - it('does not claim keypress or keyup when this forwarder did not claim the keydown', () => { - const sendInput = vi.fn() - let composing = true - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => composing, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false) - composing = false - - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: ',' }))).toBe(false) - expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(false) - dispatchInsertText(textarea, ',') - expect(sendInput).not.toHaveBeenCalled() }) it('stops forwarding after dispose', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES - }) - + const { forwarder, sendInput } = install() forwarder.claimKeyEvent(keyEvent({ key: ',' })) forwarder.dispose() dispatchInsertText(textarea, ',') - expect(sendInput).not.toHaveBeenCalled() }) @@ -662,43 +339,10 @@ describe('installTerminalImeNativeTextForwarder', () => { const forwarder = installTerminalImeNativeTextForwarder({ terminalElement: null, isComposing: () => false, - sendInput, - getInputSourceFeatures: () => CJK_FEATURES + sendInput }) expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false) expect(() => forwarder.dispose()).not.toThrow() }) - - it('does not forward punctuation when input-source features are disabled', () => { - const sendInput = vi.fn() - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => DISABLED_FEATURES - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false) - dispatchInsertText(textarea, ',') - expect(sendInput).not.toHaveBeenCalled() - }) - - it('can become enabled after the input source changes to a Vietnamese IME', () => { - const sendInput = vi.fn() - let features: MacNativeTextInputSourceFeatures = DISABLED_FEATURES - const forwarder = installTerminalImeNativeTextForwarder({ - terminalElement: element, - isComposing: () => false, - sendInput, - getInputSourceFeatures: () => features - }) - - expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(false) - features = VIETNAMESE_FEATURES - - expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true) - dispatchInsertText(textarea, 'á') - expect(sendInput).toHaveBeenCalledExactlyOnceWith('á') - }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts index bb27116aa..4d268f60a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts @@ -1,25 +1,30 @@ import type { IDisposable } from '@xterm/xterm' -import { - DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES, - type MacNativeTextInputSourceFeatures -} from './terminal-ime-input-source' -import { - isImeNativeTextKeydownCandidate, - isSinglePrintableTextKey, - type ImeNativeTextKeyEvent -} from './terminal-ime-native-text-candidates' -// Why: some macOS input sources and synthetic Unicode injectors commit native -// text through a plain `insertText` event after a printable keydown. Xterm's -// kitty keyboard protocol can encode and cancel that keydown before Chromium -// commits the real text, so this narrowly bypasses known native-text candidates -// and forwards the committed glyph from the input event straight to the PTY. +// Why: a plain printable keydown never produces terminal bytes. Bytes for +// printable characters come only from the `input` event, which on macOS *is* +// the text system's commit callback and carries whatever the input source +// actually produced (`,` for `,`, `、` for `\`, `——` for a single press). +// Xterm would otherwise send the raw layout character from the keydown and then +// preventDefault, destroying the committed text before Chromium can deliver it. +// +// The claim is structural, so it holds for input sources that do not exist yet: +// no input-source identity is read, and `key` is only ever measured for length. type ClaimedKeyPress = { key: string code?: string } +export type ImeNativeTextKeyEvent = { + type: string + key: string + code?: string + metaKey: boolean + ctrlKey: boolean + altKey: boolean + isComposing?: boolean +} + export const XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT = 'xterm-composition-transaction-accepted' export const XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT = 'xterm-composition-transaction-settled' @@ -27,12 +32,45 @@ export type TerminalImeNativeTextForwarder = IDisposable & { /** * Returns true when this keyboard event belongs to a direct native text * commit and should bypass xterm (the caller should return `false` from - * `attachCustomKeyEventHandler`). The committed glyph is forwarded later from + * `attachCustomKeyEventHandler`). The committed text is forwarded later from * the `input` event via the `sendInput` dependency. */ claimKeyEvent: (event: ImeNativeTextKeyEvent) => boolean } +/** + * A single printable keystroke with no control chord and no live composition. + * + * `key` is read for LENGTH ONLY, never identity — that is what makes the + * predicate invariant under the `key` rewrite a CJK input source performs, and + * why no punctuation table is needed. Length also excludes named keys (`Enter`, + * `ArrowLeft`, `Dead`, `F3`) without enumerating them. + * + * Claiming a keydown withholds its byte until the commit arrives, so a key the + * IME eats without committing would be dropped. That is bounded, not a gap: + * across 12,040 recorded keydowns there is no such case. The browser marks + * IME-owned presses on the keydown itself — `keyCode 229` on macOS even while + * `key` is still a single translated character — and all 4,453 such presses in + * the corpus were followed by a composition event. It is a positive marker + * only: fcitx5 on Wayland omits it, so it cannot be inverted into a gate. + * Stale claims clear on the next keydown rather than on a timer; a timer here + * once wrote a newline the user never typed. + */ +function isNativeTextKeydown(event: ImeNativeTextKeyEvent, compositionActive: boolean): boolean { + return ( + event.type === 'keydown' && + // Control chords are the byte-producing case and belong to xterm's encoder. + // Shift stays eligible: shifted punctuation still commits substituted text. + !event.ctrlKey && + !event.altKey && + !event.metaKey && + event.key.length === 1 && + // Composing keystrokes already belong to xterm's composition helper. + event.isComposing !== true && + !compositionActive + ) +} + function matchesClaimedPress(event: ImeNativeTextKeyEvent, claimedPress: ClaimedKeyPress): boolean { if (event.code && claimedPress.code) { return event.code === claimedPress.code @@ -40,26 +78,10 @@ function matchesClaimedPress(event: ImeNativeTextKeyEvent, claimedPress: Claimed return event.key === claimedPress.key } -function matchesClaimedKeypress( - event: ImeNativeTextKeyEvent, - claimedPress: ClaimedKeyPress -): boolean { - if (matchesClaimedPress(event, claimedPress)) { - return true - } - if (event.code && claimedPress.code) { - return false - } - // Why: IME/native-text keypresses can carry the transformed glyph and omit - // physical `code`; keep xterm silent until the input event forwards the text. - return isSinglePrintableTextKey(event.key) -} - export function installTerminalImeNativeTextForwarder(args: { terminalElement: HTMLElement | null | undefined isComposing: () => boolean sendInput: (data: string) => void - getInputSourceFeatures?: () => MacNativeTextInputSourceFeatures }): TerminalImeNativeTextForwarder { if (!args.terminalElement) { return { @@ -70,31 +92,10 @@ export function installTerminalImeNativeTextForwarder(args: { const terminalElement = args.terminalElement let pendingForward = false - let pendingForwardClearTimer: number | null = null let compositionTransactionPending = false let claimedPress: ClaimedKeyPress | null = null - - const clearPendingForwardTimer = (): void => { - if (pendingForwardClearTimer !== null) { - window.clearTimeout(pendingForwardClearTimer) - pendingForwardClearTimer = null - } - } - - const disarmPendingForward = (): void => { - clearPendingForwardTimer() - pendingForward = false - } - - const schedulePendingForwardClear = (): void => { - clearPendingForwardTimer() - // Why: some macOS IMEs deliver keyup before the final insertText event; - // keep the native commit armed briefly, then drop genuinely stray inserts. - pendingForwardClearTimer = window.setTimeout(() => { - pendingForward = false - pendingForwardClearTimer = null - }, 100) - } + /** Whether the claimed press actually reached the pty, which decides if its release does. */ + let forwardedPressBytes = false const markCompositionTransactionAccepted = (): void => { compositionTransactionPending = true @@ -106,18 +107,13 @@ export function installTerminalImeNativeTextForwarder(args: { const claimKeyEvent = (event: ImeNativeTextKeyEvent): boolean => { if (event.type === 'keydown') { - if ( - !isImeNativeTextKeydownCandidate( - event, - args.isComposing(), - args.getInputSourceFeatures?.() ?? DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES - ) - ) { + if (!isNativeTextKeydown(event, args.isComposing())) { return false } - // Arm forwarding so the upcoming input event is sent to the PTY. - clearPendingForwardTimer() + // Why: re-arming here is also what drops a stale claim whose input event + // never arrived (the input source swallowed the key) — no timer needed. pendingForward = true + forwardedPressBytes = false claimedPress = { key: event.key, code: event.code } return true } @@ -131,51 +127,53 @@ export function installTerminalImeNativeTextForwarder(args: { if (!matchesClaimedPress(event, claimedPress)) { return false } + const pressReachedThePty = forwardedPressBytes claimedPress = null - if (pendingForward) { - schedulePendingForwardClear() - } - // Bypass so the kitty release sequence for the swallowed press cannot leak. - return true + forwardedPressBytes = false + // Why: a release report describes a press the app received. Suppress it only when + // this press put nothing on the wire — swallowed by the input source, or owned by a + // composition transaction. Suppressing unconditionally would drop the kitty release + // for ordinary typing, because the structural claim takes every printable keydown + // rather than the short punctuation list the previous design claimed. + return !pressReachedThePty } - if (event.type === 'keypress') { - // Keep the keydown's armed state but still bypass xterm so it does not - // double-send printable text before our input forward runs. - return matchesClaimedKeypress(event, claimedPress) - } - return false + // Keep the keydown's armed state but still bypass xterm so it does not + // double-send printable text before our input forward runs. + return event.type === 'keypress' } const forwardCommittedText = (event: Event): void => { if (!(event instanceof InputEvent)) { return } + // Why: an accepted composition transaction already owns its commit; letting + // it through here would send the text a second time. if (compositionTransactionPending && event.inputType === 'insertText') { - disarmPendingForward() + pendingForward = false event.stopImmediatePropagation() return } if (!pendingForward) { return } + pendingForward = false if (event.inputType !== 'insertText') { - disarmPendingForward() return } - disarmPendingForward() if (event.data) { args.sendInput(event.data) + forwardedPressBytes = true } event.stopImmediatePropagation() - // Clear the helper textarea so the native glyph doesn't accumulate; - // safe in practice since only synthetic injectors interleave commits. + // Clear the helper textarea so the committed text doesn't accumulate. if (event.target instanceof HTMLTextAreaElement) { event.target.value = '' } } const cancelPending = (): void => { - disarmPendingForward() + pendingForward = false + forwardedPressBytes = false compositionTransactionPending = false claimedPress = null } diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts new file mode 100644 index 000000000..9dfa43075 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment happy-dom +// An input source that substitutes text for a printable key commits it through a +// bare `insertText` with no composition session. Drives a real Terminal wired the +// way the pane lifecycle wires it, so it covers the whole keydown -> input path. +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder' +import { shouldBypassXtermKeyboardEvent } from './xterm-bypass-policy' + +type SubstitutionCase = { + name: string + code: string + keyCode: number + shiftKey?: boolean + layoutText: string + imeText: string +} + +function open(kittyKeyboardFlags = 0) { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal() + terminal.open(container) + const textarea = terminal.textarea! + const forwarder = installTerminalImeNativeTextForwarder({ + terminalElement: terminal.element, + isComposing: () => false, + sendInput: (data) => terminal.input(data) + }) + terminal.attachCustomKeyEventHandler((event) => { + if (forwarder.claimKeyEvent(event)) { + return false + } + return !shouldBypassXtermKeyboardEvent(event, { + isMac: true, + hasSelection: false, + kittyKeyboardFlags + }) + }) + const emitted: string[] = [] + terminal.onData((d) => emitted.push(d)) + return { emitted, terminal, textarea, forwarder } +} + +function key( + textarea: HTMLTextAreaElement, + type: string, + init: { key: string; code: string; keyCode: number; shiftKey: boolean } +): KeyboardEvent { + const ev = new KeyboardEvent(type, { + key: init.key, + code: init.code, + shiftKey: init.shiftKey, + bubbles: true, + cancelable: true + }) + Object.defineProperty(ev, 'keyCode', { value: init.keyCode }) + Object.defineProperty(ev, 'charCode', { value: type === 'keypress' ? init.keyCode : 0 }) + textarea.dispatchEvent(ev) + return ev +} + +function insertText(textarea: HTMLTextAreaElement, type: string, data: string): void { + const ev = new InputEvent(type, { bubbles: true }) + Object.defineProperty(ev, 'inputType', { value: 'insertText' }) + Object.defineProperty(ev, 'data', { value: data }) + Object.defineProperty(ev, 'composed', { value: true }) + textarea.dispatchEvent(ev) +} + +function press(textarea: HTMLTextAreaElement, c: SubstitutionCase): void { + const shiftKey = c.shiftKey === true + const kd = key(textarea, 'keydown', { + key: c.layoutText, + code: c.code, + keyCode: c.keyCode, + shiftKey + }) + if (!kd.defaultPrevented) { + if (c.imeText.length === 1) { + key(textarea, 'keypress', { + key: c.imeText, + code: c.code, + keyCode: c.imeText.charCodeAt(0), + shiftKey + }) + } + textarea.value = c.imeText + textarea.setSelectionRange(c.imeText.length, c.imeText.length) + insertText(textarea, 'beforeinput', c.imeText) + insertText(textarea, 'input', c.imeText) + } + key(textarea, 'keyup', { key: c.layoutText, code: c.code, keyCode: c.keyCode, shiftKey }) +} + +function type(cases: SubstitutionCase[], kitty = 0): string { + const { emitted, terminal, textarea, forwarder } = open(kitty) + for (const c of cases) { + press(textarea, c) + } + forwarder.dispose() + terminal.dispose() + return emitted.join('') +} + +const COMMA: SubstitutionCase = { + name: 'comma', + code: 'Comma', + keyCode: 188, + layoutText: ',', + imeText: ',' +} +const PERIOD: SubstitutionCase = { + name: 'period', + code: 'Period', + keyCode: 190, + layoutText: '.', + imeText: '。' +} +const QUESTION: SubstitutionCase = { + name: 'question', + code: 'Slash', + keyCode: 191, + shiftKey: true, + layoutText: '?', + imeText: '?' +} +const BACKSLASH: SubstitutionCase = { + name: 'ideographic comma', + code: 'Backslash', + keyCode: 220, + layoutText: '\\', + imeText: '、' +} +const EM_DASH: SubstitutionCase = { + name: 'em dash pair', + code: 'Minus', + keyCode: 189, + shiftKey: true, + layoutText: '_', + imeText: '——' +} +const FULLWIDTH_ONE: SubstitutionCase = { + name: 'full-width one', + code: 'Digit1', + keyCode: 49, + layoutText: '1', + imeText: '1' +} +const TELEX_A: SubstitutionCase = { + name: 'telex a-acute', + code: 'KeyS', + keyCode: 83, + layoutText: 's', + imeText: 'á' +} + +describe('input-source text substitution reaches the terminal', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('sends the substituted sentence tail, not the raw layout characters', () => { + expect(type([COMMA, PERIOD, PERIOD])).toBe(',。。') + }) + it('sends a shifted substitution', () => { + expect(type([QUESTION])).toBe('?') + }) + it('sends the backslash-position substitution (#10896)', () => { + expect(type([BACKSLASH])).toBe('、') + }) + it('sends a multi-code-unit substitution from one press', () => { + expect(type([EM_DASH])).toBe('——') + }) + it('sends a full-width digit substitution', () => { + expect(type([FULLWIDTH_ONE])).toBe('1') + }) + it('sends a letter substitution', () => { + expect(type([TELEX_A])).toBe('á') + }) + it('sends the substitution with kitty disambiguate reporting negotiated', () => { + expect(type([COMMA], 1)).toBe(',') + }) + + // Pins a deliberate hole rather than a desired behaviour. Flag 8 asks for every printable key as + // an escape code, and this path sends the committed text raw instead — a mature native terminal + // makes the same trade, preferring correct characters to protocol fidelity. Recorded here so the + // choice is visible: if this ever needs closing, gate on flag 8 alone, never on "kitty active", + // which would disable the substitution for every pane that negotiates anything. + it('sends the substitution raw even when kitty asks for all keys as escape codes', () => { + expect(type([COMMA], 8)).toBe(',') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts index 441d6a821..9f5f6d426 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts @@ -168,7 +168,15 @@ describe('Korean won input after a composition commit', () => { await expect( typeHangulThenWon(['₩'], { beforeWon: (textarea) => { + // Why: the full sequence a browser emits for an unprevented printable + // key. The keydown alone is not a shape Chromium can produce, and the + // `input` event is what carries the character now. textarea.dispatchEvent(keyboardEvent('keydown', 'a', 'KeyA', 65)) + textarea.dispatchEvent(keyboardEvent('keypress', 'a', 'KeyA', 65)) + textarea.value += 'a' + textarea.dispatchEvent( + new InputEvent('input', { data: 'a', inputType: 'insertText', bubbles: true }) + ) } }) ).resolves.toBe('한a₩') diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index bfc05a5b3..759e7ad86 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -90,10 +90,6 @@ import { createTerminalImePendingCandidateKeyReleases, shouldApplyTerminalImePendingCandidateKeyRelease } from './terminal-ime-candidate-key-release-guard' -import { - DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES, - getMacNativeTextInputSourceTracker -} from './terminal-ime-input-source' import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder' import { shouldBypassXtermKeyboardEvent, @@ -912,7 +908,6 @@ export function useTerminalPaneLifecycle({ const linuxImeCandidateState = isLinux ? installTerminalImeLinuxCandidateState(pane.terminal.element) : null - const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element) imeCompositionDisposablesRef.current.set(pane.id, { dispose: () => { @@ -920,15 +915,12 @@ export function useTerminalPaneLifecycle({ linuxImeCandidateState?.dispose() } }) - // Why: only known macOS native text paths (physical CJK/Vietnamese IME) need the keydown bypass; synthetic Unicode lacks physical key identity. + // Why: macOS commits an input source's substituted text through the input event alone, so printable keydowns must not reach xterm's encoder. const imeNativeTextForwarder = isMac ? installTerminalImeNativeTextForwarder({ terminalElement: pane.terminal.element, isComposing: () => imeCompositionTracker.isActive(), - sendInput: (data) => pane.terminal.input(data), - getInputSourceFeatures: () => - macNativeTextInputSourceTracker?.getFeatures() ?? - DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES + sendInput: (data) => pane.terminal.input(data) }) : { claimKeyEvent: () => false, diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts index eefc7eccf..55e60cc53 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts @@ -119,49 +119,23 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => { expect(shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC' }), opts)).toBe(false) }) - it('leaves physical Backslash to native keypress when kitty reporting is inactive', () => { - // Why: third-party IMEs outside the forwarder's CJK allowlist (Qingg, #10896) - // need Chromium's text pipeline to turn this key into 、 rather than \. - for (const type of ['keydown', 'keyup']) { - expect( - shouldBypassXtermKeyboardEvent( - event({ type, key: '\\', code: 'Backslash', keyCode: 220 }), - { ...noSel, kittyKeyboardFlags: 0 } - ) - ).toBe(true) + it('no longer special-cases Backslash — the native-text forwarder owns it', () => { + // Why: this policy carried a `code === 'Backslash'` bypass because the old + // forwarder only claimed keys for input sources on a hardcoded allowlist. + // The structural claim covers every printable key, so the exception is gone + // and the physical key is no longer named anywhere in this file. + for (const type of ['keydown', 'keyup', 'keypress']) { + for (const kittyKeyboardFlags of [0, 1]) { + expect( + shouldBypassXtermKeyboardEvent(event({ type, key: '\\', code: 'Backslash' }), { + ...noSel, + kittyKeyboardFlags + }) + ).toBe(false) + } } }) - it('does not bypass the native Backslash keypress', () => { - expect( - shouldBypassXtermKeyboardEvent( - event({ type: 'keypress', key: '\\', code: 'Backslash', keyCode: 0x3001 }), - { ...noSel, kittyKeyboardFlags: 0 } - ) - ).toBe(false) - }) - - it('keeps modified or kitty-reported Backslash in xterm', () => { - expect( - shouldBypassXtermKeyboardEvent(event({ key: '|', code: 'Backslash', shiftKey: true }), { - ...noSel, - kittyKeyboardFlags: 0 - }) - ).toBe(false) - expect( - shouldBypassXtermKeyboardEvent(event({ key: '\\', code: 'Backslash', ctrlKey: true }), { - ...noSel, - kittyKeyboardFlags: 0 - }) - ).toBe(false) - expect( - shouldBypassXtermKeyboardEvent(event({ key: '\\', code: 'Backslash' }), { - ...noSel, - kittyKeyboardFlags: 1 - }) - ).toBe(false) - }) - it('bubbles Shift+non-ASCII printable text so the active keyboard layout wins', () => { expect( shouldBypassXtermKeyboardEvent(event({ key: 'Ф', code: 'KeyA', shiftKey: true }), opts) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index fa03b3cbc..18a50129e 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -84,33 +84,6 @@ function isSingleNonAsciiPrintableText(key: string): boolean { return codePoint !== undefined && codePoint >= 0x80 } -/** - * Why: kept from #13128 when the rest of it was reverted. The native-text - * forwarder only claims keys for input sources in its hardcoded CJK allowlist, - * so third-party IMEs off that list (Qingg, issue #10896) never get their - * backslash-position key claimed and xterm sends the raw `\` instead of `、`. - * Bypassing keydown lets Chromium's text pipeline produce the layout character. - * Scoped to the bare backslash key: broadening this to all non-ASCII text would - * race the forwarder and double-send. - */ -export function shouldBypassXtermForMacNativeText( - event: XtermBypassEvent, - isMac: boolean, - kittyKeyboardActive = false -): boolean { - if ( - !isMac || - !isXtermHandledKeyEvent(event.type) || - event.metaKey || - event.ctrlKey || - event.altKey || - event.shiftKey - ) { - return false - } - return !kittyKeyboardActive && event.code === 'Backslash' -} - function isXtermHandledKeyEvent(type: string): boolean { return type === 'keydown' || type === 'keyup' } @@ -249,9 +222,6 @@ export function shouldBypassXtermKeyboardEvent( } const { isMac, hasSelection } = options - if (shouldBypassXtermForMacNativeText(event, isMac, (options.kittyKeyboardFlags ?? 0) !== 0)) { - return true - } const platformModifierHeld = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey