fix(terminal): tell a Windows IME shifted jamo apart from the committing Enter

Windows reports every IME-consumed keydown as key 'Process' with keyCode 229,
so isTerminalImeProcessEnter also matched a shifted jamo (ㅃ, ㄲ, ㅆ) typed
mid-composition. keyboard-handlers then rewrote that keydown to key: 'Enter',
the Shift+Enter rule matched, and \x1b\r reached the PTY right behind the
committed syllable. Only `code` separates the two shapes, and the predicate
never read it.

The repo's own fixtures already record both: the committing press as
Process/Enter/229, and an observed IBus jamo keydown as Process/KeyG/229.
This commit is contained in:
kunsanglee
2026-08-01 09:23:42 +09:00
parent 128cb268fd
commit c690ee6097
3 changed files with 43 additions and 3 deletions
@@ -295,6 +295,34 @@ describe('Windows IME keyboard ownership', () => {
hook.unmount()
harness.dispose()
})
// 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 }) => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
harness.startComposition()
const shiftedJamo = keyboardEvent('keydown', {
key: 'Process',
code,
keyCode: 229,
timeStamp: 10,
isComposing: true,
shiftKey: true
})
harness.terminalInput.dispatchEvent(shiftedJamo)
harness.endComposition(jamo)
vi.advanceTimersByTime(250)
expect(harness.ptyWrites).toEqual([jamo])
expect(shiftedJamo.defaultPrevented).toBe(false)
hook.unmount()
harness.dispose()
})
})
// The committing press repeats and the direct shortcut write races xterm's flush for
@@ -325,6 +325,7 @@ describe('isTerminalImeProcessEnter', () => {
const event = (overrides: Partial<KeyboardEvent> = {}) =>
({
key: 'Process',
code: 'Enter',
keyCode: 229,
metaKey: false,
ctrlKey: false,
@@ -333,7 +334,7 @@ describe('isTerminalImeProcessEnter', () => {
...overrides
}) as KeyboardEvent
it.each([{ shiftKey: true }, { shiftKey: false, ctrlKey: true }])(
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)
@@ -345,7 +346,12 @@ describe('isTerminalImeProcessEnter', () => {
{ keyCode: 13 },
{ shiftKey: false },
{ ctrlKey: true },
{ altKey: true }
{ 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: '' }
])('rejects a non-IME or ambiguous Process key', (override) => {
expect(isTerminalImeProcessEnter(event(override))).toBe(false)
})
@@ -108,11 +108,17 @@ export function getTerminalImeModifiedEnterKind(
}
export function isTerminalImeProcessEnter(
event: Pick<KeyboardEvent, 'key' | 'keyCode' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>
event: Pick<
KeyboardEvent,
'key' | 'code' | 'keyCode' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'
>
): 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.
return (
event.key === 'Process' &&
event.keyCode === 229 &&
(event.code === 'Enter' || event.code === 'NumpadEnter') &&
getTerminalImeModifiedEnterKind(event) !== null
)
}