diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx index 10ab0640ded..54f3a73f56b 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx @@ -308,6 +308,36 @@ describe('Windows IME keyboard ownership', () => { harness.dispose() }) + it('does not open file search for an IME-consumed Ctrl+Shift+F', () => { + // Why: matchFileSearchShortcut runs before the shortcut resolver and takes the + // physical-code fallback itself — `Process` is not a Latin key, so a composing + // Ctrl+Shift+F matched `sidebar.search.toggle` on its `code`. The IME guard has + // to sit above it, not just above the resolver. + const harness = createHarness() + // The shortcut only acts when there is a selection to search for, so give it one. + const pane = harness.deps.managerRef.current?.getActivePane() + vi.mocked(pane!.terminal.getSelection).mockReturnValue('needle') + const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps)) + harness.startComposition() + const consumed = keyboardEvent('keydown', { + key: 'Process', + code: 'KeyF', + keyCode: 229, + timeStamp: 10, + isComposing: true, + ctrlKey: true, + shiftKey: true + }) + + harness.terminalInput.dispatchEvent(consumed) + vi.runAllTimers() + + expect(harness.deps.onSearchSelectedText).not.toHaveBeenCalled() + expect(harness.sendInput).not.toHaveBeenCalled() + hook.unmount() + harness.dispose() + }) + it.each([ { label: 'Ctrl+KeyK', code: 'KeyK' }, { label: 'Ctrl+KeyW', code: 'KeyW' } diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 9bec65af01e..efec591ccf3 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -490,6 +490,26 @@ export function useTerminalKeyboardShortcuts({ return } + const terminalPaneForImeShortcut = manager.getActivePane() ?? manager.getPanes()[0] + const hasPendingImeComposition = hasPendingTerminalImeComposition( + terminalPaneForImeShortcut?.terminal.element + ) + const imeProcessEnter = isWindows && hasPendingImeComposition && isTerminalImeProcessEnter(e) + if ( + isWindows && + hasPendingImeComposition && + !imeProcessEnter && + isTerminalImeConsumedKey(e) + ) { + // Why: Process has no logical key, so shortcut matching falls back to the physical code and + // fires Ctrl+K/Ctrl+W here and in window-level handlers mid-composition. This must run before + // matchFileSearchShortcut, which takes that same fallback and would claim a composing + // Ctrl+Shift+F. xterm already ignores keyCode 229 while composing, so swallowing the chord + // loses no input. + e.stopImmediatePropagation() + return + } + if (matchFileSearchShortcut(e, shortcutPlatform, keybindings, terminalShortcutPolicy)) { const pane = manager.getActivePane() ?? manager.getPanes()[0] const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection()) @@ -529,23 +549,6 @@ export function useTerminalKeyboardShortcuts({ return } - const terminalPaneForImeShortcut = manager.getActivePane() ?? manager.getPanes()[0] - const hasPendingImeComposition = hasPendingTerminalImeComposition( - terminalPaneForImeShortcut?.terminal.element - ) - const imeProcessEnter = isWindows && hasPendingImeComposition && isTerminalImeProcessEnter(e) - if ( - isWindows && - hasPendingImeComposition && - !imeProcessEnter && - isTerminalImeConsumedKey(e) - ) { - // Why: Process has no logical key, so shortcut matching would fall back to the physical code and - // fire Ctrl+K/Ctrl+W here and in window-level handlers mid-composition. xterm already ignores - // keyCode 229 while composing, so swallowing the chord loses no input. - e.stopImmediatePropagation() - return - } const shortcutEvent = imeProcessEnter ? { key: 'Enter', diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts index 79a8fdaeed7..96af5f67462 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts @@ -769,12 +769,14 @@ describe('xterm IME composition de-duplication', () => { await nextEventLoop() textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true })) - const pending = getPendingComposition(terminal) - expect(pending?.finalizerTimer).toBeDefined() + // Read the state again after Escape rather than asserting on a captured + // reference: the cancel path clears the field in place, but other paths + // detach the pending object outright, and either way no timer must remain. + expect(getPendingComposition(terminal)?.finalizerTimer).toBeDefined() dispatchKeydown(textarea, 'Escape', 'Escape', 229, true, 100) dispatchKeydown(textarea, 'Escape', 'Escape', 27, false, 100) - expect(pending?.finalizerTimer).toBeUndefined() + expect(getPendingComposition(terminal)?.finalizerTimer).toBeUndefined() await nextEventLoop() expect(emitted).toEqual([])