fix(terminal): run the IME chord guard before the file-search matcher

matchFileSearchShortcut runs ahead of the shortcut resolver and takes the physical-code fallback itself. 'Process' is not a Latin key, so shouldUseNonLatinShortcutPhysicalFallback allows the fallback and a composing Ctrl+Shift+F matched sidebar.search.toggle on its code before the guard was reached. Hoisting the guard above it closes that, and the pending-composition lookup it needs is already available there.

Verified: with the guard at its previous position the new test sees onSearchSelectedText called once; hoisted, it is not called. matchSearchNavigate is unaffected -- it compares e.key directly, so 'Process' never matches.

Also reads the pending composition back after Escape in the xterm cancel test instead of asserting on a captured reference, so it states the intent whether the cancel path clears the field in place or detaches the object.

Both found by CodeRabbit on stablyai/orca#12120.
This commit is contained in:
holdn2
2026-08-03 15:26:09 +09:00
parent 2f02f87205
commit 290f42a365
3 changed files with 55 additions and 20 deletions
@@ -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' }
@@ -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',
@@ -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([])