From 33744ad33070a675410b199dab65876c7c8dba98 Mon Sep 17 00:00:00 2001 From: orca-bug-scan-bot Date: Sat, 11 Jul 2026 14:42:01 -0700 Subject: [PATCH] fix: address pr-bug-scan validated finding from #7634 Post-compositionend candidate-key window no longer arms when the committing selector was consumed during composition, so a genuine Space/digit within 250ms reaches the PTY. --- .../terminal-ime-composition-tracker.test.ts | 19 +++++++++++++- .../terminal-ime-composition-tracker.ts | 25 ++++++++++++++++++- .../use-terminal-pane-lifecycle.ts | 18 +++++++++---- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts index 1e363adcec4..e78621c21b9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts @@ -11,7 +11,10 @@ type TrackerHarness = { tracker: TerminalImeCompositionTracker element: HTMLElement advance: (ms: number) => void - composition: (type: 'compositionstart' | 'compositionupdate' | 'compositionend', data: string) => void + composition: ( + type: 'compositionstart' | 'compositionupdate' | 'compositionend', + data: string + ) => void input: (inputType: string) => void blur: () => void } @@ -141,6 +144,20 @@ describe('installTerminalImeCompositionTracker', () => { expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) }) + it('does not arm the post-composition window when the committing selector was consumed during composition', () => { + // Regression: committing Chinese via a Space/digit candidate key (already + // suppressed during the live composition) armed the post-compositionend + // window, which then swallowed the user's very next genuine Space/digit + // typed within 250ms (e.g. "2024" -> "024", or a separator space lost). + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '') + // The candidate selector was suppressed while the composition was live. + harness.tracker.noteCandidateSelectionDuringComposition() + harness.composition('compositionend', '你好') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + it('drops the post-composition window once ordinary typing resumes', () => { const harness = installTracker() harness.composition('compositionstart', '') diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts index 73f6d83a0b0..5bd089d507c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts @@ -6,6 +6,11 @@ export type TerminalImeCompositionTracker = IDisposable & { * IME-owned: during a live composition, and briefly after compositionend to * absorb the committing key's trailing press/release. */ isCandidateKeyGuardActive: () => boolean + /** Called by the key handler when a candidate-selection key (Space/digit) is + * suppressed while a composition is still live. The committing selector has + * then already been consumed, so the post-compositionend window must not arm + * and swallow the user's next genuine Space/digit typed within 250ms. */ + noteCandidateSelectionDuringComposition: () => void } // Why: suppressed candidate keys are preventDefault-ed and fire no input @@ -26,6 +31,14 @@ export function installTerminalImeCompositionTracker( let lastCompositionEventAt: number | null = null let compositionEndedAt: number | null = null let sawEmptyCompositionUpdate = false + // Why: when the committing Space/digit is consumed during the live + // composition, no trailing selector arrives after compositionend, so arming + // the post-composition window would only swallow the user's next real key. + let candidateSelectionDuringComposition = false + + const noteCandidateSelectionDuringComposition = (): void => { + candidateSelectionDuringComposition = true + } const isActiveAt = (at: number): boolean => active && @@ -47,6 +60,7 @@ export function installTerminalImeCompositionTracker( return { isActive: () => active, isCandidateKeyGuardActive, + noteCandidateSelectionDuringComposition, dispose: () => undefined } } @@ -56,6 +70,7 @@ export function installTerminalImeCompositionTracker( lastCompositionEventAt = now() compositionEndedAt = null sawEmptyCompositionUpdate = false + candidateSelectionDuringComposition = false } const updateComposition = (event: Event): void => { lastCompositionEventAt = now() @@ -75,8 +90,13 @@ export function installTerminalImeCompositionTracker( active = false // Why: only Sogou/fcitx-style empty updates prove a trailing plain // Space/digit is likely IME-owned; broad post-end guards drop real typing. - compositionEndedAt = sawEmptyCompositionUpdate ? now() : null + // If the committing selector was already consumed during the composition, + // no trailing selector follows, so arming the window would only swallow + // the user's next genuine Space/digit. + compositionEndedAt = + sawEmptyCompositionUpdate && !candidateSelectionDuringComposition ? now() : null sawEmptyCompositionUpdate = false + candidateSelectionDuringComposition = false } const handleInput = (event: Event): void => { if (event instanceof InputEvent && event.inputType === 'insertCompositionText') { @@ -87,12 +107,14 @@ export function installTerminalImeCompositionTracker( // the post-end window would swallow a legitimate Space/digit. compositionEndedAt = null sawEmptyCompositionUpdate = false + candidateSelectionDuringComposition = false } const markInactive = (): void => { active = false lastCompositionEventAt = null compositionEndedAt = null sawEmptyCompositionUpdate = false + candidateSelectionDuringComposition = false } terminalElement.addEventListener('compositionstart', markActive, true) @@ -104,6 +126,7 @@ export function installTerminalImeCompositionTracker( return { isActive: () => isActiveAt(now()), isCandidateKeyGuardActive, + noteCandidateSelectionDuringComposition, dispose: () => { terminalElement.removeEventListener('compositionstart', markActive, true) terminalElement.removeEventListener('compositionupdate', updateComposition, true) 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 e342778eed5..f99ee111169 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 @@ -881,11 +881,12 @@ export function useTerminalPaneLifecycle({ imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((e) => { const now = Date.now() - const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease( - e, - pendingTerminalImeCandidateKeyReleases, - now - ) + const pendingCandidateReleaseGuardActive = + shouldApplyTerminalImePendingCandidateKeyRelease( + e, + pendingTerminalImeCandidateKeyReleases, + now + ) const imeKeyboardOptions = { compositionActive: imeCompositionTracker.isActive(), candidateKeyGuardActive: @@ -908,6 +909,13 @@ export function useTerminalPaneLifecycle({ e, now ) + if (imeKeyboardOptions.compositionActive) { + // Why: the committing Space/digit was consumed during the live + // composition, so the post-compositionend window must not also + // arm and swallow the user's next genuine Space/digit typed + // within 250ms. + imeCompositionTracker.noteCandidateSelectionDuringComposition() + } } return false }