fix(terminal): keep IME-consumed Process chords out of Orca shortcuts

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-30 17:10:34 -07:00
co-authored by Orca
parent d0d7e7754f
commit 9f449d75b9
3 changed files with 55 additions and 4 deletions
@@ -280,7 +280,10 @@ describe('Windows IME keyboard ownership', () => {
it.each([
// Why: an active IME reports every consumed key as Process/229, e.g. Shift+ㅃ or an MS-IME Ctrl chord.
{ label: 'Shift+KeyQ', code: 'KeyQ', modifier: { shiftKey: true } },
{ label: 'Ctrl+KeyI', code: 'KeyI', modifier: { ctrlKey: true } }
{ label: 'Ctrl+KeyI', code: 'KeyI', modifier: { ctrlKey: true } },
// Ctrl+K / Ctrl+W are bound on Windows, so a leaked Process chord would clear or close the pane.
{ label: 'Ctrl+KeyK', code: 'KeyK', modifier: { ctrlKey: true } },
{ label: 'Ctrl+KeyW', code: 'KeyW', modifier: { ctrlKey: true } }
])('does not treat an IME-consumed $label as a modified Enter', ({ code, modifier }) => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
@@ -299,6 +302,36 @@ describe('Windows IME keyboard ownership', () => {
expect(consumed.defaultPrevented).toBe(false)
expect(harness.sendInput).not.toHaveBeenCalled()
expect(harness.deps.onClearPaneScrollback).not.toHaveBeenCalled()
expect(harness.deps.onRequestClosePane).not.toHaveBeenCalled()
hook.unmount()
harness.dispose()
})
it.each([
{ label: 'Ctrl+KeyK', code: 'KeyK' },
{ label: 'Ctrl+KeyW', code: 'KeyW' }
])('stops an IME-consumed $label from reaching window-level shortcuts', ({ code }) => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
const windowHandler = vi.fn()
window.addEventListener('keydown', windowHandler)
harness.startComposition()
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', {
key: 'Process',
code,
keyCode: 229,
timeStamp: 10,
isComposing: true,
ctrlKey: true
})
)
vi.runAllTimers()
expect(windowHandler).not.toHaveBeenCalled()
window.removeEventListener('keydown', windowHandler)
hook.unmount()
harness.dispose()
})
@@ -13,6 +13,7 @@ import {
createTerminalImeDeferredNewlineSender,
createTerminalImeModifiedEnterChordOwner,
getTerminalImeModifiedEnterKind,
isTerminalImeConsumedKey,
isTerminalImeEnterKeyUp,
isTerminalImeProcessEnter
} from './terminal-ime-deferred-newline'
@@ -500,6 +501,18 @@ export function useTerminalKeyboardShortcuts({
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',
@@ -106,6 +106,11 @@ export function getTerminalImeModifiedEnterKind(
return null
}
// Why: an active Windows IME reports every key it consumes as Process/229, whatever the physical key was.
export function isTerminalImeConsumedKey(event: Pick<KeyboardEvent, 'key' | 'keyCode'>): boolean {
return event.key === 'Process' && event.keyCode === 229
}
export function isTerminalImeProcessEnter(
event: Pick<
KeyboardEvent,
@@ -113,9 +118,9 @@ export function isTerminalImeProcessEnter(
>
): boolean {
return (
event.key === 'Process' &&
event.keyCode === 229 &&
// Why: an active Windows IME reports every consumed key as Process/229, so only the physical code distinguishes Enter.
isTerminalImeConsumedKey(event) &&
// Only the physical code distinguishes Enter. Keep this strict: a key event without a scan code
// reports code '' and falls back to the Enter keyup path rather than guessing.
(event.code === 'Enter' || event.code === 'NumpadEnter') &&
getTerminalImeModifiedEnterKind(event) !== null
)