fix(terminal): keep a blank Windows IME code out of the shifted-jamo rejection

#11293 records the Windows Process sequence arriving with a blank `code` (see
"owns one Windows Process sequence across changing timestamps and blank codes"),
so requiring code Enter/NumpadEnter would have dropped a real committing Enter
on that path. An undeterminable code now keeps its previous verdict; only a
known non-Enter physical code, which is what a shifted jamo carries, is refused.

Also pins the two guards this branch relies on and had left untested: the
Windows chord slot stays free for a Ctrl+Enter behind a deferred Ctrl+Backspace,
and the composition marker is never read as its own re-dispatch. Both were
verified to fail without the guard they cover.
This commit is contained in:
kunsanglee
2026-08-02 01:41:00 +09:00
parent c690ee6097
commit 506524085f
4 changed files with 82 additions and 26 deletions
@@ -298,16 +298,13 @@ describe('Windows IME keyboard ownership', () => {
// Windows gives a shifted jamo the same Process/229/shiftKey shape as the committing
// Enter, so treating it as Enter injects a Shift+Enter newline into ordinary Hangul.
it.each([
{ code: 'KeyQ', jamo: 'ㅃ' },
{ code: 'KeyE', jamo: 'ㄸ' }
])('does not read a shifted $jamo as the committing Enter', ({ code, jamo }) => {
it('does not read a shifted jamo as the committing Enter', () => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
harness.startComposition()
const shiftedJamo = keyboardEvent('keydown', {
key: 'Process',
code,
code: 'KeyQ',
keyCode: 229,
timeStamp: 10,
isComposing: true,
@@ -315,14 +312,49 @@ describe('Windows IME keyboard ownership', () => {
})
harness.terminalInput.dispatchEvent(shiftedJamo)
harness.endComposition(jamo)
harness.endComposition('ㅃ')
vi.advanceTimersByTime(250)
expect(harness.ptyWrites).toEqual([jamo])
expect(harness.ptyWrites).toEqual(['ㅃ'])
expect(shiftedJamo.defaultPrevented).toBe(false)
hook.unmount()
harness.dispose()
})
// The chord owner keys off the held modifier alone, so a deferred Ctrl+Backspace must not
// claim its single slot — the real Ctrl+Enter behind it would then fail to claim and vanish.
it('leaves the Enter chord slot free for a Ctrl+Enter behind a deferred Ctrl+Backspace', () => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
harness.startComposition()
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', {
key: 'Backspace',
code: 'Backspace',
keyCode: 8,
timeStamp: 10,
isComposing: true,
ctrlKey: true
})
)
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
timeStamp: 20,
isComposing: true,
ctrlKey: true
})
)
harness.endComposition('녕')
vi.advanceTimersByTime(250)
expect(harness.ptyWrites).toEqual(['녕', '\x17', '\x1b[13;5u'])
hook.unmount()
harness.dispose()
})
})
// The committing press repeats and the direct shortcut write races xterm's flush for
@@ -444,16 +444,13 @@ export function useTerminalKeyboardShortcuts({
return
}
const modifiedEnterChord = isWindows ? getModifiedEnterChord(e) : null
// Why: the chord owner tracks Enter chords only, while the deferral covers every
// key the IME re-dispatches — Ctrl/Cmd+Backspace, Cmd+Delete and the word-motion
// arrows double the same way Enter does.
const isRedispatchedEnter = e.key === 'Enter' && e.keyCode === 13
// Why: the chord owner tracks Enter chords only, while the deferral covers every key
// the IME re-dispatches.
const modifiedEnterChord =
isWindows && e.key === 'Enter' && e.keyCode === 13 ? getModifiedEnterChord(e) : null
if (
!e.isComposing &&
((isRedispatchedEnter &&
modifiedEnterChord &&
modifiedEnterChordOwner.absorb(modifiedEnterChord)) ||
((modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) ||
(isTerminalImeRedispatchableKey(e) && deferredNewlineSender.absorbRedispatchedEnter(e)))
) {
// Chromium can drop the modifier when re-dispatching the committing press.
@@ -757,6 +754,9 @@ export function useTerminalKeyboardShortcuts({
heldImeEnterModifiers.delete(kind)
modifiedEnterChordOwner.release({ kind, code: e.code, timeStamp: e.timeStamp })
}
// Why: only Enter releases its absorb credit here. Releasing on the other IME-owned
// keys risks clearing a credit before the re-dispatch that needs it, and a stale one
// self-heals — clearCreditsForCode drops it on the next press of the same code.
if (e.key !== 'Enter') {
return
}
@@ -6,6 +6,7 @@ import {
createTerminalImeModifiedEnterChordOwner,
isTerminalImeEnterKeyUp,
isTerminalImeProcessEnter,
isTerminalImeRedispatchableKey,
sendTerminalInputAfterComposition
} from './terminal-ime-deferred-newline'
import {
@@ -334,12 +335,17 @@ describe('isTerminalImeProcessEnter', () => {
...overrides
}) as KeyboardEvent
it.each([{ shiftKey: true }, { shiftKey: false, ctrlKey: true }, { code: 'NumpadEnter' }])(
'recognizes a Windows IME modifier Enter reported as Process',
(modifiers) => {
expect(isTerminalImeProcessEnter(event(modifiers))).toBe(true)
}
)
it.each([
{ shiftKey: true },
{ shiftKey: false, ctrlKey: true },
{ code: 'NumpadEnter' },
// Windows sends the Process sequence without a usable code; refusing it would drop a
// real newline, so an undeterminable code keeps the pre-existing verdict.
{ code: '' },
{ code: 'Unidentified' }
])('recognizes a Windows IME modifier Enter reported as Process', (modifiers) => {
expect(isTerminalImeProcessEnter(event(modifiers))).toBe(true)
})
it.each([
{ key: 'Enter' },
@@ -349,14 +355,29 @@ describe('isTerminalImeProcessEnter', () => {
{ altKey: true },
// Windows hands every IME-consumed keydown the same Process/229 shape, so a shifted
// jamo (ㅃ on KeyQ) differs from the committing Enter only by `code`.
{ code: 'KeyQ' },
{ code: 'Digit3' },
{ code: '' }
{ code: 'KeyQ' }
])('rejects a non-IME or ambiguous Process key', (override) => {
expect(isTerminalImeProcessEnter(event(override))).toBe(false)
})
})
describe('isTerminalImeRedispatchableKey', () => {
it('accepts an IME-owned key carrying its real keyCode', () => {
expect(isTerminalImeRedispatchableKey({ key: 'Backspace', keyCode: 8 })).toBe(true)
})
it.each([
// The committing press itself: macOS marks it 229 and it must not be read as the
// re-dispatch it is about to trigger.
{ key: 'Enter', keyCode: 229 },
{ key: 'Backspace', keyCode: 229 },
// Not a key the IME consumes mid-composition.
{ key: 'a', keyCode: 65 }
])('rejects the composition marker and non-owned keys', (event) => {
expect(isTerminalImeRedispatchableKey(event)).toBe(false)
})
})
describe('isTerminalImeEnterKeyUp', () => {
it('recognizes the balancing Enter keyup when Chromium drops its modifier', () => {
expect(isTerminalImeEnterKeyUp({ key: 'Enter', keyCode: 13 })).toBe(true)
@@ -114,11 +114,14 @@ export function isTerminalImeProcessEnter(
>
): boolean {
// Why: Windows reports every IME-consumed keydown as Process/229, so a shifted jamo
// (ㅃ, ㄲ) matches the modifier check too; only `code` tells it apart from Enter.
// (ㅃ, ㄲ) matches the modifier check too; `code` is the only field telling them apart.
// An absent code stays accepted — Windows sends this sequence with a blank one, per the
// chord owner's "blank codes" case in this module's test.
const { code } = event
return (
event.key === 'Process' &&
event.keyCode === 229 &&
(event.code === 'Enter' || event.code === 'NumpadEnter') &&
(!code || code === 'Unidentified' || code === 'Enter' || code === 'NumpadEnter') &&
getTerminalImeModifiedEnterKind(event) !== null
)
}