Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
359fd2e
fix(terminal): sequence shortcut bytes behind a pending IME composition
kunsanglee Aug 5, 2026
8aec625
test(terminal): pin cursor-chord relocation regressions during IME co…
ethznn Aug 6, 2026
039db86
Merge upstream/main into fix/ime-deferred-terminal-shortcut-input
kunsanglee Aug 7, 2026
e2c23b9
Merge upstream/main into fix/ime-deferred-terminal-shortcut-input
kunsanglee Aug 8, 2026
07f7233
fix(terminal): send IME-swallowed chords once, from recorded traces
kunsanglee Aug 8, 2026
396c274
fix(terminal): close the redispatch ledger's gaps found in review
kunsanglee Aug 8, 2026
dcb8948
fix(terminal): decide a swallowed IME chord from its release
kunsanglee Aug 8, 2026
3543cff
test(terminal): pin the IME chord branches the macOS captures miss
kunsanglee Aug 8, 2026
81cd563
fix(terminal): answer a swallowed chord from its press, not its release
kunsanglee Aug 8, 2026
fd2df45
fix(terminal): release the remembered chord when its keyup never comes
kunsanglee Aug 8, 2026
eab431d
refactor(keybindings): fall back to the physical code for 'Process'
kunsanglee Aug 8, 2026
13b1e8c
fix(terminal): stop consuming the keyup, and never arm from a text field
kunsanglee Aug 8, 2026
079735b
test(terminal): replay the in-app IME chord traces through the releas…
ethznn Aug 8, 2026
8ba40b7
test(e2e): assert IME cursor-chord byte order on the real macOS IMEs
ethznn Aug 8, 2026
4887d92
test(e2e): probe where an IME chord's keyup goes inside the app
kunsanglee Aug 8, 2026
84517d7
fix(terminal): end a composing Cmd chord on the Command release
kunsanglee Aug 8, 2026
fb781a8
Merge upstream/main into fix/ime-deferred-terminal-shortcut-input
kunsanglee Aug 9, 2026
a61583b
fix(terminal): hold a swallowed IME chord until its composition commits
kunsanglee Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Recorded IME chord traces for #12871, taken inside a dev e2e Orca build (app commit
// 4887d924ca81c1518481dd2d9e798b6b08e74cc8, macOS 26.5.1/25F80, darwin arm64) at the xterm
// helper textarea, driven through the real OS input methods. Replayed by
// keyboard-handlers.issue-12871-recorded-chord-traces.test.ts through the same rig as the other
// recordings. Reproduce with tests/e2e/terminal-macos-chord-input-pipeline-probe.spec.ts.
//
// What is new here is the driver: these were posted as CGEvents with the modifier as its own
// key event, the way a hand types it. System Events folds the modifier into the target key's
// flags instead, which is why no earlier recording contains a modifier press or release at all
// and why the Cmd half of the gesture looked like it had no end.
//
// With that end recorded, the two input sources separate on `Cmd` exactly as they already did on
// `Option`, and the same recordings show why the arrow's own release cannot carry the decision:
//
// - Kotoeri swallows the chord and keeps composing. No arrow keyup is ever delivered, at any
// listener position or at Chromium's own input dispatch. The `Cmd` release is the only
// event that marks the gesture's end, and it still reports the composition live.
// - Korean 2-Set commits on the chord. Its arrow keyup does arrive, after compositionend and
// with `isComposing` false, so it spends the carry without firing and the `Cmd` release that
// follows finds nothing armed. Two independent reasons the committing source stays silent.
import type { InAppRecordedCase } from './keyboard-handlers.issue-12871-in-app-chord-traces'

export const COMMAND_RELEASE_TRACE_CASES: InAppRecordedCase[] = [
{
name: 'Japanese, Cmd+ArrowLeft over a live さ preedit, ended by the Command release',
expectCalls: ['\x01'],
expectEmitted: ['\x01'],
// Kotoeri is still converting when the capture stops.
commitsAfterCapture: true,
rows: [
{ t: 'keydown', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true, meta: true },
{
t: 'keydown',
key: 'ArrowLeft',
code: 'ArrowLeft',
keyCode: 229,
isComposing: true,
meta: true
},
// No arrow keyup between these two rows. That absence is the recording's whole point.
{ t: 'keyup', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true, meta: false }
]
},
{
// The platform's unmarked replay is absent from these rows for the same reason it is absent
// from the other in-app recordings: the app's own window-level capture consumed it above the
// probe. So this case pins the half it can speak for, which is the half a release-keyed
// recovery can break — that neither release fires anything on a committing input source.
name: 'Korean 2-Set, Cmd+ArrowLeft commits the syllable and neither release fires',
expectCalls: [],
// Empty because the capture opens mid-gesture: xterm commits nothing for a session it never
// saw begin. What this case pins is `expectCalls` — neither release fired.
expectEmitted: [],
rows: [
{ t: 'keydown', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true, meta: true },
{
t: 'keydown',
key: 'ArrowLeft',
code: 'ArrowLeft',
keyCode: 229,
isComposing: true,
meta: true
},
{ t: 'compositionupdate', data: 'ㄴ', value: 'ㄴ' },
{ t: 'input', data: 'ㄴ', value: 'ㄴ' },
{ t: 'compositionend', data: 'ㄴ', value: 'ㄴ' },
{
t: 'keyup',
key: 'ArrowLeft',
code: 'ArrowLeft',
keyCode: 37,
isComposing: false,
meta: true
},
{ t: 'keyup', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: false, meta: false }
]
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Issue #12871. Unit half: which events the shortcut layer still resolves while a composition
// is live. The end-to-end half lives in keyboard-handlers.issue-12871-recorded-chord-traces.test.ts,
// which replays captured macOS Korean and Japanese traces through the real handler and xterm.
//
// The exemption exists because a Japanese conversion swallows a modifier chord outright — no
// commit, no platform replay, nothing reaches the shell. It is what lets the pane resolve such
// a chord from its *release*; on the keydown the pane still yields, because Korean's input
// source replays the chord instead and acting on both copies would fire it twice.
//
// Scope: shapes here are constructed from the DOM contract, not captured. The captured file is
// where hardware fidelity lives; this file pins the decision boundary, including the negatives.
import { describe, expect, it } from 'vitest'
import { resolveTerminalKeyboardShortcutAction } from './keyboard-handlers'
import type { TerminalShortcutEvent } from './terminal-shortcut-policy'

// `isComposing` alone decides ownership; `keyCode` is carried so each fixture reads as the
// shape hardware actually produces, matching the recorded traces in the sibling IME tests.
type ComposingKeyEvent = TerminalShortcutEvent & { isComposing: boolean; keyCode: number }

function keyEvent(overrides: Partial<ComposingKeyEvent>): ComposingKeyEvent {
return {
key: '',
code: '',
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
repeat: false,
isComposing: false,
keyCode: 0,
...overrides
}
}

function resolveOnMac(
event: ComposingKeyEvent
): ReturnType<typeof resolveTerminalKeyboardShortcutAction> {
return resolveTerminalKeyboardShortcutAction(
event,
true,
'false',
0,
false,
undefined,
undefined,
undefined,
undefined,
undefined,
() => false
)
}

describe('terminal chords stay live during an IME composition', () => {
it.each([
['Cmd+ArrowLeft', keyEvent({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true }), '\x01'],
['Cmd+ArrowRight', keyEvent({ key: 'ArrowRight', code: 'ArrowRight', metaKey: true }), '\x05'],
['Cmd+Backspace', keyEvent({ key: 'Backspace', code: 'Backspace', metaKey: true }), '\x15'],
['Cmd+Delete', keyEvent({ key: 'Delete', code: 'Delete', metaKey: true }), '\x0b'],
['Option+ArrowLeft', keyEvent({ key: 'ArrowLeft', code: 'ArrowLeft', altKey: true }), '\x1bb'],
[
'Option+ArrowRight',
keyEvent({ key: 'ArrowRight', code: 'ArrowRight', altKey: true }),
'\x1bf'
]
])('resolves %s while a composition is live', (_label, event, expected) => {
expect(resolveOnMac({ ...event, isComposing: true })).toEqual({
type: 'sendInput',
data: expected
})
})

// Chromium defines KeyboardEvent's fields as accessors on the prototype, not as own
// properties, so anything that copies an event by enumerating it gets nothing. happy-dom
// does the opposite, which means a plain-object fixture cannot see that class of bug and a
// real `new KeyboardEvent(...)` cannot either under this runner. Modelling the browser's
// shape explicitly is the only form of it that runs here.
it('resolves an event whose fields live on the prototype, as in a browser', () => {
const fields = keyEvent({
key: 'Process',
code: 'Backspace',
metaKey: true,
isComposing: true,
keyCode: 229
})
const prototype = Object.create(
null,
Object.fromEntries(
Object.entries(fields).map(([name, value]) => [name, { get: () => value }])
)
) as ComposingKeyEvent
const event = Object.create(prototype) as ComposingKeyEvent
expect(Object.keys(event)).toEqual([])

expect(
resolveTerminalKeyboardShortcutAction(
event,
true,
'false',
0,
false,
{ 'terminal.clear': ['Mod+Backspace'] },
undefined,
undefined,
undefined,
undefined,
() => false
)
).toEqual({ type: 'clearActivePane' })
})

// A CJK input source rewrites `key` while `code` keeps the physical key, which is what
// #12171 and #13033 turned on. Matching `key` here would drop the chord again.
it('matches the physical code when the input source has rewritten key', () => {
const event = keyEvent({
key: 'Process',
code: 'ArrowLeft',
metaKey: true,
isComposing: true,
keyCode: 229
})

expect(resolveOnMac(event)).toEqual({ type: 'sendInput', data: '\x01' })
})

// The exemption is for chords over a physical key, not for the IME's own gesture keys.
// A composing Ctrl+Space mode switch reaches the policy as a lone Control keydown.
it.each([
['lone Control', keyEvent({ key: 'Control', code: 'ControlLeft', keyCode: 17, ctrlKey: true })],
['lone Meta', keyEvent({ key: 'Meta', code: 'MetaLeft', keyCode: 91, metaKey: true })],
['bare ArrowLeft', keyEvent({ key: 'ArrowLeft', code: 'ArrowLeft' })],
// Japanese conversion binds Shift+Arrow to resize the segment being converted, so a
// modifier alongside it does not make the chord ours.
[
'Cmd+Shift+ArrowLeft',
keyEvent({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true, shiftKey: true })
],
['Cmd+A', keyEvent({ key: 'a', code: 'KeyA', metaKey: true })]
])('still yields %s to the IME while composing', (_label, event) => {
expect(resolveOnMac({ ...event, isComposing: true })).toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Recorded IME chord traces for #12871 from a SECOND, independent surface: a boundary probe at
// the xterm helper textarea of a dev e2e Orca build (app commit
// d64ccc71bd7daf7ff64fe182b9bf522b90293397, macOS 26.5.2/25F84, darwin arm64), driven through
// the real OS input methods via System Events CGEvents, with PTY-side ground-truth bytes read
// by a child on the pty. Replayed by keyboard-handlers.issue-12871-recorded-chord-traces.test.ts
// alongside the bare-page recording that lives there. Source traces (SHA-256):
// ime-traces/korean-cmdright.json d2c12ee4828cfe8671248122f9d36875a22c020c72500eda886d7a1f04c1b790
// ime-traces/negative-abc.json 9c9b128406381a6479256abdec8088397940d9eb436ca219660901c4205a3aa9
//
// Both cases below are NEGATIVES, and deliberately so. The bare-page recording already pins
// where the chord bytes come from; what this surface adds is the keys around them, which is
// where a release-keyed recovery can misfire — a carry that outlives its own gesture, or one
// armed from a press that had already resolved. This surface also differs from the bare-page one
// in ways the cases note inline (its window-level capture consumed unmarked chords above the
// probe, and the CGEvent driver produced no lone-modifier keydowns), so what each case can speak
// for is stated with it. Each case opens with a snapshot row restoring the textarea state the
// probe found mid-session (the leading ㄱ residue is real; the recorded composition offsets
// depend on it). A snapshot row carries only `value`, so dispatching it is a no-op for xterm.
//
// A third recording, ime-traces/japanese-overwrite-cmdleft.json (Kotoeri Romaji さ preedit,
// Cmd+ArrowLeft), is deliberately NOT replayed here. Its chord press is followed by no arrow
// keyup at all, and a window-level capture probe run on this branch found none there either, so
// replaying it could only assert that no byte is produced — which pins the #12871 defect rather
// than the fix. That recording is evidence about the platform, not a contract for this handler,
// and is raised on #12732 instead of frozen into a green assertion here.

// Structurally identical to the replaying test's own RecordedRow/RecordedCase; declared here so
// the data module stands alone and the test file keeps its types private.
export type InAppRecordedRow = {
t: string
key?: string
code?: string
keyCode?: number
isComposing?: boolean
meta?: boolean
alt?: boolean
data?: string
inputType?: string
value?: string
}

export type InAppRecordedCase = {
name: string
expectCalls: string[]
expectEmitted: string[]
rows: InAppRecordedRow[]
/** Rows end mid-composition: assert nothing sent yet, then drive a commit and check again. */
commitsAfterCapture?: true
}

export const IN_APP_TRACE_CASES: InAppRecordedCase[] = [
{
// korean-cmdright.json dom[36..50]: committed 가나 with 나 composing, Cmd+ArrowRight.
// Captured PTY line: 가나나\x05\x1b[C\n.
//
// The \x05 is absent from expectCalls because it is absent from these rows: the app's own
// window-level capture consumed the platform's unmarked replay above the probe, so the press
// that delivers the byte was never recorded. What survives is the half this surface can
// speak for, and it is the half a release-keyed recovery can break — both of the pane's own
// chances to fire on a committing input source, plus the key that follows:
//
// - the marked keydown (keyCode 229, isComposing) must produce nothing;
// - the release must produce nothing either, because the composition has already ended by
// then and the platform's replay is the thing that answers;
// - and the PLAIN ArrowRight pressed one beat later must reach xterm as the recorded
// \x1b[C. This surface recorded no lone-modifier keyup, so a carry left armed past its
// own release would still be holding when this bare press arrives, and it matches by the
// same `code` — the supersede is what keeps it from answering as a stray \x05.
name: 'Korean 2-Set, Cmd+ArrowRight, then a bare ArrowRight (in-app trace)',
expectCalls: [],
expectEmitted: ['나', '\u001b[C', '\r'],
rows: [
{ t: 'input', value: 'ㄱ가나' },
{ t: 'compositionstart', data: '' },
{ t: 'compositionupdate', data: '나' },
{ t: 'input', data: '나', inputType: 'insertCompositionText', value: 'ㄱ가나나' },
{ t: 'keyup', key: 'ㅏ', code: 'KeyK', keyCode: 75, isComposing: true },
{
t: 'keydown',
key: 'ArrowRight',
code: 'ArrowRight',
keyCode: 229,
isComposing: true,
meta: true
},
{ t: 'compositionupdate', data: '나' },
{ t: 'input', data: '나', inputType: 'insertCompositionText', value: 'ㄱ가나나' },
{ t: 'compositionend', data: '나' },
{ t: 'keyup', key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, meta: true },
{ t: 'keydown', key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
{ t: 'keyup', key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
{ t: 'keydown', key: 'Enter', code: 'Enter', keyCode: 13 },
{ t: 'keyup', key: 'Enter', code: 'Enter', keyCode: 13 }
]
},
{
// negative-abc.json dom[0..8], the recorded non-IME control the composition rulebook asks
// for: ABC layout, not a Latin preedit — the whole capture holds no 229, no isComposing,
// no composition event. expectEmitted equals the captured onData array verbatim, and the
// captured PTY line is abc\x01\x1bb\n.
//
// The two chord KEYDOWN rows are the one reconstruction in this module: the probe sat
// below the window capture, which consumed them (their bytes still arrived — that
// consumption is itself the recorded proof the handler owns unmarked chords). Their fields
// are the captured Option keyup's (dom[6]) with the type flipped, and the Cmd copy mirrors
// it; the captured onData/PTY pin the bytes they must produce.
//
// Against a release-keyed recovery it is also the arming negative: no composition is live at
// either press, so both resolve from their own keydown, and the recorded keyup that follows
// must not send \x1bb a second time.
name: 'ABC layout, Cmd+ArrowLeft then Option+ArrowLeft with no IME (in-app trace)',
expectCalls: ['\u0001', '\u001bb'],
expectEmitted: ['a', 'b', 'c', '\u0001', '\u001bb', '\r'],
rows: [
{ t: 'keydown', key: 'a', code: 'KeyA', keyCode: 65 },
{ t: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 },
{ t: 'keydown', key: 'b', code: 'KeyB', keyCode: 66 },
{ t: 'keyup', key: 'b', code: 'KeyB', keyCode: 66 },
{ t: 'keydown', key: 'c', code: 'KeyC', keyCode: 67 },
{ t: 'keyup', key: 'c', code: 'KeyC', keyCode: 67 },
{ t: 'keydown', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, meta: true },
{ t: 'keydown', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, alt: true },
{ t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, alt: true },
{ t: 'keydown', key: 'Enter', code: 'Enter', keyCode: 13 },
{ t: 'keyup', key: 'Enter', code: 'Enter', keyCode: 13 }
]
}
]
Loading