From c6029bea109c756b2e7c03eea3d385daacae036f Mon Sep 17 00:00:00 2001 From: yukei_lee Date: Mon, 13 Jul 2026 07:30:44 +0800 Subject: [PATCH] fix(terminal): handle Linux IME candidate digits without composition events (#8241) * fix(terminal): preserve Linux IME candidate digits * fix(terminal): preserve overlapping Linux IME keys * docs(terminal): document Linux IME candidate state * docs(terminal): describe IME candidate event handling * docs(terminal): clarify Linux IME state callbacks * fix(terminal): harden Linux IME candidate fallback Co-authored-by: Orca --------- Co-authored-by: yuqili03 Co-authored-by: Jinwoo-H Co-authored-by: Orca --- config/reliability-gates.jsonc | 49 +++- ...al-ime-candidate-key-release-guard.test.ts | 7 + ...erminal-ime-candidate-key-release-guard.ts | 8 + ...terminal-ime-linux-candidate-state.test.ts | 241 ++++++++++++++++++ .../terminal-ime-linux-candidate-state.ts | 203 +++++++++++++++ .../use-terminal-pane-lifecycle.ts | 31 ++- .../xterm-bypass-policy-non-mac.test.ts | 34 +++ .../terminal-pane/xterm-bypass-policy.ts | 21 +- .../e2e/chinese-ime-chat-input-repro.spec.ts | 27 +- 9 files changed, 609 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c2cbd6e62d0..36113ee3bb6 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1772,10 +1772,11 @@ "remote-runtime" ], "coveredPlatforms": [ - "macos" + "macos", + "linux" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", + "coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, and Electron/CDP live-PTY Sogou-style repros. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6699", "https://github.com/stablyai/orca/pull/6682", @@ -1783,9 +1784,10 @@ "https://github.com/stablyai/orca/pull/6999" ], "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 post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "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-linux-candidate-state.test.ts", "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ @@ -1794,6 +1796,7 @@ "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/terminal-ime-linux-candidate-state.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", "tests/e2e/chinese-ime-chat-input-repro.spec.ts" @@ -1834,6 +1837,14 @@ "fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release" ] }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "assertions": [ + "an orphaned plain-letter keyup arms exactly the next bare digit guard", + "ordinary, overlapping, shifted, and long-held letter keydowns keep following digits available", + "physical letter tracking survives cross-pane focus handoff and clears on renderer-window blur" + ] + }, { "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", "assertions": [ @@ -1852,11 +1863,39 @@ "assertions": [ "Sogou-style Space candidate selection submits only the committed Chinese character", "Sogou-style digit candidate selection submits only the committed Chinese phrase", - "Post-composition Sogou-style digit selection stays out of the PTY after compositionend" + "Post-composition Sogou-style digit selection stays out of the PTY after compositionend", + "Legacy orphaned-letter-keyup digit selection submits no selector byte to the PTY; real legacy candidate commit preservation remains a platform gap" ] } ], "evidenceRuns": [ + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "result": "passed", + "durationSeconds": 0.619, + "summary": "1 state test file and 10 tests passed on macOS, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests." + }, + { + "date": "2026-07-11", + "runner": "local", + "platform": "linux", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "result": "passed", + "durationSeconds": 0.747, + "summary": "1 state test file and 10 tests passed in Debian 12 arm64 Docker under Node 24, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests." + }, + { + "date": "2026-07-11", + "runner": "local", + "platform": "linux", + "command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "result": "passed", + "durationSeconds": 30.3, + "summary": "Debian 12 arm64 Docker with Node 24 and Xvfb passed both live Electron/PTY IME scenarios; the orphaned-letter-keyup candidate digit submitted no selector byte, and the real-Codex opt-in scenario was skipped." + }, { "date": "2026-07-07", "runner": "local", @@ -1890,7 +1929,7 @@ }, "performanceBudget": { "required": true, - "evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added." + "evidence": "Terminal IME hot-path audit clean on 2026-07-11: candidate guards add O(1) boolean checks plus bounded per-pane Space/digit state and one renderer-scoped currently pressed physical-letter set; ref-counted renderer and pane blur listeners are disposed with pane lifecycle; no per-pane keyboard-event fan-out, polling, IPC, subprocess work, or SSH/remote transport cost added." }, "promotionCriteria": [ "Cover deterministic byte/cell oracles first.", diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts index c0112fa2c41..a9abcf86dfe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts @@ -3,6 +3,7 @@ import { armTerminalImePendingCandidateKeyRelease, clearTerminalImePendingCandidateKeyRelease, createTerminalImePendingCandidateKeyReleases, + isTerminalImeCandidateDigitKeyEvent, isTerminalImeCandidateSelectionKeyEvent, shouldApplyTerminalImePendingCandidateKeyRelease } from './terminal-ime-candidate-key-release-guard' @@ -19,6 +20,12 @@ describe('terminal IME candidate key release guard', () => { expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', shiftKey: true }))).toBe(false) }) + it('recognizes unmodified digits but not Space as digit candidate selectors', () => { + expect(isTerminalImeCandidateDigitKeyEvent(event({ key: '2' }))).toBe(true) + expect(isTerminalImeCandidateDigitKeyEvent(event({ key: ' ' }))).toBe(false) + expect(isTerminalImeCandidateDigitKeyEvent(event({ key: '2', ctrlKey: true }))).toBe(false) + }) + it('arms a pending release guard from a suppressed candidate keydown', () => { const releases = createTerminalImePendingCandidateKeyReleases() armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts index 210d8de22d2..337a3c23682 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts @@ -22,6 +22,7 @@ const TERMINAL_IME_CANDIDATE_SELECTION_KEYS = new Set([ '8', '9' ]) +const TERMINAL_IME_CANDIDATE_DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) function isTerminalImeCandidateSelectionKey(key: string): boolean { return TERMINAL_IME_CANDIDATE_SELECTION_KEYS.has(key) @@ -36,6 +37,13 @@ export function isTerminalImeCandidateSelectionKeyEvent(event: XtermBypassEvent) return isTerminalImeCandidateSelectionKey(event.key) } +/** Returns whether an event is an unmodified IME candidate digit selector. */ +export function isTerminalImeCandidateDigitKeyEvent(event: XtermBypassEvent): boolean { + return ( + isTerminalImeCandidateSelectionKeyEvent(event) && TERMINAL_IME_CANDIDATE_DIGITS.has(event.key) + ) +} + export function createTerminalImePendingCandidateKeyReleases(): TerminalImePendingCandidateKeyReleases { return new Map() } diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts new file mode 100644 index 00000000000..2aa8f2a0aff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createTerminalImeLinuxCandidateState, + installTerminalImeLinuxCandidateState +} from './terminal-ime-linux-candidate-state' +import type { XtermBypassEvent } from './xterm-bypass-policy' + +/** Creates a terminal keyboard event with default modifier state. */ +function event(overrides: Partial): XtermBypassEvent { + return { + type: 'keydown', + key: '', + code: '', + defaultPrevented: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides + } +} + +describe('createTerminalImeLinuxCandidateState', () => { + it('suppresses the next bare digit after an orphaned plain-letter keyup', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + + const orphanLetterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }) + const orphanClassification = state.classifyKeyboardEvent(orphanLetterKeyup) + expect(orphanClassification.candidateDigitGuardActive).toBe(false) + state.observeKeyboardEvent(orphanLetterKeyup, orphanClassification) + + time += 10 + const digitKeydown = event({ key: '1', code: 'Digit1', keyCode: 49 }) + const digitKeydownClassification = state.classifyKeyboardEvent(digitKeydown) + expect(digitKeydownClassification.candidateDigitGuardActive).toBe(true) + state.observeKeyboardEvent(digitKeydown, digitKeydownClassification) + + const digitKeyup = event({ type: 'keyup', key: '1', code: 'Digit1', keyCode: 49 }) + const digitKeyupClassification = state.classifyKeyboardEvent(digitKeyup) + expect(digitKeyupClassification.candidateDigitGuardActive).toBe(false) + state.observeKeyboardEvent(digitKeyup, digitKeyupClassification) + + time += 10 + const secondDigit = event({ key: '2', code: 'Digit2', keyCode: 50 }) + expect(state.classifyKeyboardEvent(secondDigit).candidateDigitGuardActive).toBe(false) + }) + + it('does not suppress normal letter->digit typing when the letter had a matching keydown', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + + const letterKeydown = event({ key: 'a', code: 'KeyA', keyCode: 65 }) + const letterKeydownClassification = state.classifyKeyboardEvent(letterKeydown) + expect(letterKeydownClassification.candidateDigitGuardActive).toBe(false) + state.observeKeyboardEvent(letterKeydown, letterKeydownClassification) + + time += 10 + const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }) + const letterKeyupClassification = state.classifyKeyboardEvent(letterKeyup) + expect(letterKeyupClassification.candidateDigitGuardActive).toBe(false) + state.observeKeyboardEvent(letterKeyup, letterKeyupClassification) + + time += 10 + const digitKeydown = event({ key: '1', code: 'Digit1', keyCode: 49 }) + expect(state.classifyKeyboardEvent(digitKeydown).candidateDigitGuardActive).toBe(false) + }) + + it('does not mistake a long-held letter release for an orphaned keyup', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + + const letterKeydown = event({ key: 'a', code: 'KeyA', keyCode: 65 }) + const letterKeydownClassification = state.classifyKeyboardEvent(letterKeydown) + state.observeKeyboardEvent(letterKeydown, letterKeydownClassification) + + time += 2_000 + const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }) + const letterKeyupClassification = state.classifyKeyboardEvent(letterKeyup) + state.observeKeyboardEvent(letterKeyup, letterKeyupClassification) + + time += 10 + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1', keyCode: 49 })) + .candidateDigitGuardActive + ).toBe(false) + }) + + it('clears a pending physical key when modifiers change before release', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + const letterKeydown = event({ key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown)) + const modifiedKeyup = event({ + type: 'keyup', + key: 'A', + code: 'KeyA', + shiftKey: true + }) + state.observeKeyboardEvent(modifiedKeyup, state.classifyKeyboardEvent(modifiedKeyup)) + + time += 10 + const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup)) + time += 10 + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(true) + }) + + it('does not mistake a shifted physical letter for an orphan after Shift releases first', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + for (const keyboardEvent of [ + event({ key: 'A', code: 'KeyA', shiftKey: true }), + event({ type: 'keyup', key: 'Shift', code: 'ShiftLeft' }), + event({ type: 'keyup', key: 'a', code: 'KeyA' }) + ]) { + state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent)) + time += 10 + } + + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(false) + }) + + it('cancels the orphan guard when another non-digit keydown intervenes', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + for (const keyboardEvent of [ + event({ type: 'keyup', key: 'a', code: 'KeyA' }), + event({ key: 'b', code: 'KeyB' }), + event({ type: 'keyup', key: 'b', code: 'KeyB' }) + ]) { + state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent)) + time += 10 + } + + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(false) + }) + + it('resets missed releases on blur and removes the listener on dispose', () => { + let time = 100 + const terminalElement = new EventTarget() + const removeEventListener = vi.spyOn(terminalElement, 'removeEventListener') + const state = installTerminalImeLinuxCandidateState(terminalElement, () => time) + const letterKeydown = event({ key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown)) + + terminalElement.dispatchEvent(new Event('blur')) + time += 10 + const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup)) + time += 10 + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(true) + + state.dispose() + expect(removeEventListener).toHaveBeenCalledWith('blur', state.resetCandidateGuard, true) + }) + + it('shares pressed letters across pane focus handoffs and disposes the tracker once', () => { + let time = 100 + const rendererWindow = new EventTarget() + const removeWindowListener = vi.spyOn(rendererWindow, 'removeEventListener') + const firstTerminal = new EventTarget() + const secondTerminal = new EventTarget() + const firstState = installTerminalImeLinuxCandidateState( + firstTerminal, + () => time, + rendererWindow + ) + const secondState = installTerminalImeLinuxCandidateState( + secondTerminal, + () => time, + rendererWindow + ) + + const letterKeydown = event({ key: 'a', code: 'KeyA' }) + firstState.observeKeyboardEvent(letterKeydown, firstState.classifyKeyboardEvent(letterKeydown)) + firstTerminal.dispatchEvent(new Event('blur')) + const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) + secondState.observeKeyboardEvent(letterKeyup, secondState.classifyKeyboardEvent(letterKeyup)) + time += 10 + expect( + secondState.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })) + .candidateDigitGuardActive + ).toBe(false) + + firstState.dispose() + expect(removeWindowListener).not.toHaveBeenCalled() + secondState.dispose() + expect(removeWindowListener).toHaveBeenCalledWith('keyup', expect.any(Function)) + }) + + it('clears renderer-wide pressed letters when the window blurs', () => { + let time = 100 + const rendererWindow = new EventTarget() + const state = installTerminalImeLinuxCandidateState( + new EventTarget(), + () => time, + rendererWindow + ) + const letterKeydown = event({ key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown)) + + rendererWindow.dispatchEvent(new Event('blur')) + const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup)) + time += 10 + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(true) + state.dispose() + }) + + it('does not suppress a digit after overlapping ordinary letter key presses', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + + for (const keyboardEvent of [ + event({ key: 'a', code: 'KeyA', keyCode: 65 }), + event({ key: 'b', code: 'KeyB', keyCode: 66 }), + event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }), + event({ type: 'keyup', key: 'b', code: 'KeyB', keyCode: 66 }) + ]) { + const classification = state.classifyKeyboardEvent(keyboardEvent) + state.observeKeyboardEvent(keyboardEvent, classification) + time += 10 + } + + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1', keyCode: 49 })) + .candidateDigitGuardActive + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts new file mode 100644 index 00000000000..5594bcb3cbe --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts @@ -0,0 +1,203 @@ +import type { XtermBypassEvent } from './xterm-bypass-policy' + +type TerminalImeLinuxCandidateState = { + /** Classifies an event before the state observes it. */ + classifyKeyboardEvent: (event: XtermBypassEvent) => { candidateDigitGuardActive: boolean } + /** Advances the state after the caller consumes an event classification. */ + observeKeyboardEvent: ( + event: XtermBypassEvent, + classification: { candidateDigitGuardActive: boolean } + ) => void + /** Drops all candidate and physical-key state. */ + reset: () => void + /** Drops only this pane's candidate window. */ + resetCandidateGuard: () => void +} + +type TerminalImeLinuxPhysicalKeyTracker = { + pressedCodes: Set + dispose: () => void +} + +const CANDIDATE_DIGIT_WINDOW_MS = 1500 +const ASCII_LOWERCASE_LETTER = /^[a-z]$/ +const ASCII_DIGIT = /^[0-9]$/ +const PHYSICAL_ASCII_LETTER_CODE = /^Key[A-Z]$/ +const physicalKeyTrackers = new WeakMap< + EventTarget, + TerminalImeLinuxPhysicalKeyTracker & { users: number } +>() + +function acquirePhysicalKeyTracker( + eventTarget: EventTarget | null +): TerminalImeLinuxPhysicalKeyTracker { + if (!eventTarget) { + return { pressedCodes: new Set(), dispose: () => undefined } + } + const existing = physicalKeyTrackers.get(eventTarget) + if (existing) { + existing.users += 1 + return { + pressedCodes: existing.pressedCodes, + dispose: () => releasePhysicalKeyTracker(eventTarget, existing) + } + } + + const pressedCodes = new Set() + const observeKeyboardEvent = (event: Event): void => { + const code = (event as Event & { code?: string }).code + if (!code || !PHYSICAL_ASCII_LETTER_CODE.test(code)) { + return + } + if (event.type === 'keydown') { + pressedCodes.add(code) + } else { + pressedCodes.delete(code) + } + } + const reset = (): void => pressedCodes.clear() + // Why: bubble-phase keyup cleanup runs after xterm's target handler, so the + // pane can still classify that release against the shared pressed-key set. + eventTarget.addEventListener('keydown', observeKeyboardEvent) + eventTarget.addEventListener('keyup', observeKeyboardEvent) + eventTarget.addEventListener('blur', reset) + const tracker = { + pressedCodes, + users: 1, + dispose: () => releasePhysicalKeyTracker(eventTarget, tracker), + observeKeyboardEvent, + reset + } + physicalKeyTrackers.set(eventTarget, tracker) + return tracker +} + +function releasePhysicalKeyTracker( + eventTarget: EventTarget, + tracker: TerminalImeLinuxPhysicalKeyTracker & { + users: number + observeKeyboardEvent?: EventListener + reset?: EventListener + } +): void { + tracker.users -= 1 + if (tracker.users > 0) { + return + } + if (tracker.observeKeyboardEvent && tracker.reset) { + eventTarget.removeEventListener('keydown', tracker.observeKeyboardEvent) + eventTarget.removeEventListener('keyup', tracker.observeKeyboardEvent) + eventTarget.removeEventListener('blur', tracker.reset) + } + tracker.pressedCodes.clear() + physicalKeyTrackers.delete(eventTarget) +} + +/** Returns whether an event is an unmodified lowercase Latin letter. */ +function isPlainAsciiLetterKey(event: XtermBypassEvent): boolean { + return ( + ASCII_LOWERCASE_LETTER.test(event.key) && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !event.shiftKey + ) +} + +/** Returns whether an event is an unmodified ASCII digit. */ +function isPlainAsciiDigitKey(event: XtermBypassEvent): boolean { + return ( + ASCII_DIGIT.test(event.key) && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !event.shiftKey + ) +} + +/** Tracks legacy desktop Linux IME candidate-selection event sequences. */ +export function createTerminalImeLinuxCandidateState( + now: () => number = () => Date.now(), + pendingPlainLetterKeydownsByCode: Set = new Set() +): TerminalImeLinuxCandidateState { + let candidateDigitUntil = 0 + + const resetCandidateGuard = (): void => { + candidateDigitUntil = 0 + } + + const reset = (): void => { + pendingPlainLetterKeydownsByCode.clear() + resetCandidateGuard() + } + + return { + reset, + resetCandidateGuard, + /** Classifies the current event before state is advanced for it. */ + classifyKeyboardEvent: (event) => { + const at = now() + return { + candidateDigitGuardActive: + event.type === 'keydown' && isPlainAsciiDigitKey(event) && candidateDigitUntil > at + } + }, + /** Records the current event after its classification is consumed. */ + observeKeyboardEvent: (event, classification) => { + const at = now() + if (classification.candidateDigitGuardActive) { + candidateDigitUntil = 0 + return + } + + if (candidateDigitUntil <= at) { + candidateDigitUntil = 0 + } + + if (event.type === 'keydown') { + if (!isPlainAsciiDigitKey(event)) { + candidateDigitUntil = 0 + } + const physicalCode = event.code + if (physicalCode && PHYSICAL_ASCII_LETTER_CODE.test(physicalCode)) { + pendingPlainLetterKeydownsByCode.add(physicalCode) + } + return + } + + if (event.type === 'keyup') { + const matchingPlainLetterKeydown = event.code + ? pendingPlainLetterKeydownsByCode.delete(event.code) + : false + if (isPlainAsciiLetterKey(event) && event.code) { + if (!matchingPlainLetterKeydown) { + // Why: some legacy Linux IME paths commit a single-letter preedit + // without composition/input events. The orphaned keyup is a narrow + // hint that the next bare digit belongs to the candidate picker. + candidateDigitUntil = at + CANDIDATE_DIGIT_WINDOW_MS + } + } + } + } + } +} + +/** Installs Linux IME candidate state with focus-loss cleanup. */ +export function installTerminalImeLinuxCandidateState( + terminalElement: EventTarget | null | undefined, + now: () => number = () => Date.now(), + rendererKeyboardEventTarget: EventTarget | null = typeof window === 'undefined' + ? (terminalElement ?? null) + : window +): TerminalImeLinuxCandidateState & { dispose: () => void } { + const physicalKeyTracker = acquirePhysicalKeyTracker(rendererKeyboardEventTarget) + const state = createTerminalImeLinuxCandidateState(now, physicalKeyTracker.pressedCodes) + terminalElement?.addEventListener('blur', state.resetCandidateGuard, true) + return { + ...state, + dispose: () => { + terminalElement?.removeEventListener('blur', state.resetCandidateGuard, true) + physicalKeyTracker.dispose() + } + } +} 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 ccb21fac182..5f68494ba15 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 @@ -66,6 +66,7 @@ import { parseOsc7 } from './parse-osc7' import { guardParserHandler } from './terminal-parser-handler-guard' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' +import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state' import { armTerminalImePendingCandidateKeyRelease, clearTerminalImePendingCandidateKeyRelease, @@ -495,6 +496,7 @@ export function getPreviousVisibleForTerminalPane(args: { return args.previous.isVisible } +/** Wires mounted terminal panes to renderer state and terminal event handling. */ export function useTerminalPaneLifecycle({ tabId, worktreeId, @@ -890,9 +892,17 @@ export function useTerminalPaneLifecycle({ !isMac && navigator.userAgent.includes('Linux') && !/Android|CrOS/.test(navigator.userAgent) + const linuxImeCandidateState = isLinux + ? installTerminalImeLinuxCandidateState(pane.terminal.element) + : null const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element) - imeCompositionDisposablesRef.current.set(pane.id, imeCompositionTracker) + imeCompositionDisposablesRef.current.set(pane.id, { + dispose: () => { + imeCompositionTracker.dispose() + linuxImeCandidateState?.dispose() + } + }) // Why: only known macOS native text paths need xterm keydown bypass. // Source gates cover physical CJK/Vietnamese IME rewrites; synthetic // Unicode key events are detected by missing physical key identity. @@ -911,6 +921,13 @@ export function useTerminalPaneLifecycle({ } imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((e) => { + const linuxCandidateClassification = linuxImeCandidateState?.classifyKeyboardEvent(e) ?? { + candidateDigitGuardActive: false + } + /** Advances the fallback state after every terminal key event path. */ + const observeLinuxCandidateEvent = (): void => { + linuxImeCandidateState?.observeKeyboardEvent(e, linuxCandidateClassification) + } const now = Date.now() const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease( @@ -924,6 +941,8 @@ export function useTerminalPaneLifecycle({ imeCompositionTracker.isCandidateKeyGuardActive() || pendingCandidateReleaseGuardActive, pendingCandidateKeyReleaseActive: pendingCandidateReleaseGuardActive, + linuxOrphanCandidateDigitGuardActive: + linuxCandidateClassification.candidateDigitGuardActive, isMac, isLinux } @@ -941,11 +960,13 @@ export function useTerminalPaneLifecycle({ now ) } + observeLinuxCandidateEvent() return false } clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e) if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(e)) { pendingTerminalInterruptKeyup = false + observeLinuxCandidateEvent() return false } if ( @@ -965,11 +986,13 @@ export function useTerminalPaneLifecycle({ } else { pendingTerminalInterruptKeyup = false } + observeLinuxCandidateEvent() return false } if (shouldSuppressTerminalModifierKeyboardEvent(e)) { // Why: stale Kitty keyboard reporting can encode standalone // modifier presses before Ctrl+C reaches the interrupt handler. + observeLinuxCandidateEvent() return false } @@ -983,6 +1006,7 @@ export function useTerminalPaneLifecycle({ // Keep it on xterm's onData path so PTY input guards still run. pane.terminal.input(jisYenInput.data) } + observeLinuxCandidateEvent() return false } @@ -998,13 +1022,16 @@ export function useTerminalPaneLifecycle({ if (imeNativeTextForwarder.claimKeyEvent(e)) { // Why: bypass xterm's kitty encoder for native text keydowns so the // committed glyph survives via the input event. + observeLinuxCandidateEvent() return false } - return !shouldBypassXtermKeyboardEvent(e, { + const shouldBypass = shouldBypassXtermKeyboardEvent(e, { isMac, hasSelection: pane.terminal.hasSelection() }) + observeLinuxCandidateEvent() + return !shouldBypass }) const linkProviderDisposable = pane.terminal.registerLinkProvider( diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index 1f3a78326cd..8ea502f9eda 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -166,6 +166,10 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { // Post-compositionend guard: the tracker is already inactive but the // committing key's trailing press/release must still be absorbed. const linuxPostCompositionGuard = { ...linuxIdle, candidateKeyGuardActive: true } + const linuxOrphanCandidateDigitGuard = { + ...linuxIdle, + linuxOrphanCandidateDigitGuardActive: true + } const windowsComposing = { ...windowsIdle, compositionActive: true, @@ -314,6 +318,21 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { } }) + it('guards only digits for the orphaned-keyup fallback', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: '2', code: 'Digit2' }), + linuxOrphanCandidateDigitGuard + ) + ).toBe(true) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space' }), + linuxOrphanCandidateDigitGuard + ) + ).toBe(false) + }) + it('does not treat modified chords such as Ctrl+Space (IME toggle) as candidate keys', () => { expect( shouldSuppressTerminalImeKeyboardEvent( @@ -406,5 +425,20 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { ) ).toBe(false) }) + + it('prevents the default only for fallback digit keydowns', () => { + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: '2', code: 'Digit2' }), + linuxOrphanCandidateDigitGuard + ) + ).toBe(true) + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: ' ', code: 'Space' }), + linuxOrphanCandidateDigitGuard + ) + ).toBe(false) + }) }) }) 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 34e1240ef84..5f9f3a63d04 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,5 +1,8 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' -import { isTerminalImeCandidateSelectionKeyEvent } from './terminal-ime-candidate-key-release-guard' +import { + isTerminalImeCandidateDigitKeyEvent, + isTerminalImeCandidateSelectionKeyEvent +} from './terminal-ime-candidate-key-release-guard' // Why: when a CLI activates kitty progressive enhancement (CSI > N u), xterm's // KittyKeyboard encoder turns every modifier chord — including plain Cmd+C — @@ -42,6 +45,9 @@ export type XtermImeKeyboardOptions = { candidateKeyGuardActive: boolean /** True when the pending-release guard already matched this specific event. */ pendingCandidateKeyReleaseActive: boolean + /** True for the narrow Linux path where the IME emits an orphaned letter + * keyup but no composition/input events before its candidate digit. */ + linuxOrphanCandidateDigitGuardActive?: boolean // Required so no caller silently falls back to non-mac 229 suppression, // which re-swallows the first key after a macOS IME input-source switch. isMac: boolean @@ -81,6 +87,7 @@ function isXtermHandledKeyEvent(type: string): boolean { return type === 'keydown' || type === 'keyup' } +/** Returns whether xterm must not process an IME-owned keyboard event. */ export function shouldSuppressTerminalImeKeyboardEvent( event: XtermBypassEvent, options: XtermImeKeyboardOptions @@ -89,13 +96,17 @@ export function shouldSuppressTerminalImeKeyboardEvent( compositionActive, candidateKeyGuardActive, pendingCandidateKeyReleaseActive, + linuxOrphanCandidateDigitGuardActive = false, isMac, isLinux } = options + const suppressOrphanCandidateDigit = + isLinux && linuxOrphanCandidateDigitGuardActive && isTerminalImeCandidateDigitKeyEvent(event) const suppressCandidateKey = isLinux && (pendingCandidateKeyReleaseActive || - (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event))) + (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) || + suppressOrphanCandidateDigit) if (event.type === 'keypress') { // Why: a suppressed candidate keydown is not preventDefault-ed by xterm, // so its native keypress still fires and _keyPress would forward the @@ -121,6 +132,7 @@ export function shouldSuppressTerminalImeKeyboardEvent( ) } +/** Returns whether a candidate keydown needs native default prevention. */ export function shouldPreventDefaultTerminalImeCandidateKey( event: XtermBypassEvent, options: XtermImeKeyboardOptions @@ -132,8 +144,9 @@ export function shouldPreventDefaultTerminalImeCandidateKey( return ( event.type === 'keydown' && options.isLinux && - options.candidateKeyGuardActive && - isTerminalImeCandidateSelectionKeyEvent(event) + ((options.candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) || + (options.linuxOrphanCandidateDigitGuardActive === true && + isTerminalImeCandidateDigitKeyEvent(event))) ) } diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index 8994e235cdd..3cdac524671 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -367,6 +367,18 @@ async function dispatchCandidateSelectionKey( }) } +async function dispatchOrphanLetterKeyup(session: CDPSession): Promise { + // Why: legacy Sogou/fcitx can emit only the release after committing a + // single-letter preedit, leaving no composition or input event to classify. + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'a', + code: 'KeyA', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65 + }) +} + async function dispatchSogouEmptyCompositionUpdate(page: Page): Promise { // Why: Sogou/fcitx emits empty compositionupdate data while its candidate // popup is still open (#6765); Orca's tracker must not flip inactive on it. @@ -654,11 +666,24 @@ test.describe('Chinese IME terminal chat input repro', () => { }) .toBe('再见') + // Some legacy desktop Linux paths omit every composition/input event and + // expose only an orphaned Latin release before the candidate digit. + await dispatchOrphanLetterKeyup(session) + await dispatchCandidateSelectionKey(session, { key: '4', code: 'Digit4', keyCode: 52 }) + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'orphan-keyup candidate digit leaked into the submitted terminal input' + }) + .toBe('') + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-orphan-keyup-digit-suppression') + const promptState = await readPromptState(orcaPage) expect( promptState?.submitted, 'candidate Space/digit selectors and pinyin preedit must not leak into the PTY' - ).toEqual(['你', '你好', '再见']) + ).toEqual(['你', '你好', '再见', '']) } finally { await attachImeEvidence(orcaPage, testInfo, 'sogou-final-ime-evidence').catch(() => undefined) await session?.detach().catch(() => undefined)