diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-command-release-traces.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-command-release-traces.ts new file mode 100644 index 00000000000..9e36df87c85 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-command-release-traces.ts @@ -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 } + ] + } +] diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-composing-chord.test.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-composing-chord.test.ts new file mode 100644 index 00000000000..0387820391c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-composing-chord.test.ts @@ -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 { + return { + key: '', + code: '', + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + repeat: false, + isComposing: false, + keyCode: 0, + ...overrides + } +} + +function resolveOnMac( + event: ComposingKeyEvent +): ReturnType { + 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-in-app-chord-traces.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-in-app-chord-traces.ts new file mode 100644 index 00000000000..5d1cf38805f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-in-app-chord-traces.ts @@ -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 } + ] + } +] diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts new file mode 100644 index 00000000000..7fdf3e58244 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts @@ -0,0 +1,816 @@ +// @vitest-environment happy-dom +// Recorded on stock macOS (Chrome 151) by logging keydown/keyup/composition*/input on a +// textarea, then replayed here through the production handler and a real patched Terminal. +// Every row below is captured, not authored, including the modifier presses and releases. +// +// The two input sources answer a modifier chord in opposite ways, and the marked keydown is +// identical in both — code='ArrowLeft', keyCode=229, isComposing=true — so nothing tells them +// apart while the key is down. The release does: +// +// - Korean 2-Set commits the syllable, emits compositionend, and the platform replays the +// chord unmarked. isComposing is false by its keyup, and that replay resolves on its own. +// - Japanese conversion swallows the chord outright: no compositionend, no replay, still +// composing at keyup. Before this fix its chords produced no bytes at all. +// +// Both directions are pinned together, because the failure modes point opposite ways: Korean +// must stay at one firing per chord (a second \x1bb jumps two words), Japanese must go from +// zero to one. A change that only reads the keydown cannot satisfy both. +// +// Two things this rig cannot speak for. The userAgent mock below only reaches Orca's own check; +// xterm reads `navigator.platform` once at module load and does not see a Mac here, which is +// harmless while Orca intercepts every replayed row but would diverge for anything it stops +// intercepting. And `expectEmitted` groups bytes the way a full task between rows produces — +// real input arrives in one burst and may group differently. Read `expectCalls` for the +// contract; `expectEmitted` is there to show ordering, not framing. +// +// IN_APP_TRACE_CASES is a second, independent recording taken inside the app rather than on a +// bare page (provenance and per-case notes in keyboard-handlers.issue-12871-in-app-chord-traces +// .ts). It is replayed through the same rig below. The two surfaces captured different subsets +// of the same gestures, so each case there states which half of the contract it can speak for. +// +// COMMAND_RELEASE_TRACE_CASES is a third, and the one that covers the Cmd half. Both recordings +// above were driven with the modifier folded into the target key's flags, which produces no +// modifier press or release at all; these were driven with it as its own key event, the way a +// hand types it. That is what exposed the asymmetry between the two modifiers — a key released +// while Cmd is held has no keyup on macOS, so the Cmd release is the gesture's only end. +import { Terminal } from '@xterm/xterm' +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyTransport } from './pty-transport' +import { useTerminalKeyboardShortcuts } from './keyboard-handlers' +import { COMMAND_RELEASE_TRACE_CASES } from './keyboard-handlers.issue-12871-command-release-traces' +import { IN_APP_TRACE_CASES } from './keyboard-handlers.issue-12871-in-app-chord-traces' + +type RecordedRow = { + t: string + key?: string + code?: string + keyCode?: number + isComposing?: boolean + meta?: boolean + alt?: boolean + data?: string + inputType?: string + value?: string +} + +type RecordedCase = { + name: string + expectCalls: string[] + expectEmitted: string[] + rows: RecordedRow[] + commitsAfterCapture?: true +} + +const CASES: RecordedCase[] = [ + { + name: 'Korean 2-Set, Cmd+ArrowLeft', + expectCalls: ['\x01'], + expectEmitted: ['사', '\x01'], + rows: [ + { t: 'keydown', key: 'ㅅ', code: 'KeyT', keyCode: 229 }, + { t: 'compositionstart', data: '' }, + { t: 'compositionupdate', data: 'ㅅ' }, + { t: 'input', data: 'ㅅ', inputType: 'insertCompositionText', value: 'ㅅ' }, + { t: 'keyup', key: 'ㅅ', code: 'KeyT', keyCode: 84, isComposing: true }, + { t: 'keydown', key: 'ㅏ', code: 'KeyK', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: '사' }, + { t: 'input', data: '사', inputType: 'insertCompositionText', value: '사' }, + { t: 'keyup', key: 'ㅏ', code: 'KeyK', keyCode: 75, isComposing: true }, + { 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: '사' }, + { t: 'input', data: '사', inputType: 'insertCompositionText', value: '사' }, + { t: 'compositionend', data: '사' }, + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, meta: true }, + { t: 'keydown', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, meta: true }, + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, meta: true }, + { t: 'keyup', key: 'Meta', code: 'MetaLeft', keyCode: 91 } + ] + }, + { + name: 'Korean 2-Set, Option+ArrowLeft', + expectCalls: ['\x1bb'], + expectEmitted: ['사', '\x1bb'], + rows: [ + { t: 'keydown', key: 'ㅅ', code: 'KeyT', keyCode: 229 }, + { t: 'compositionstart', data: '' }, + { t: 'compositionupdate', data: 'ㅅ' }, + { t: 'input', data: 'ㅅ', inputType: 'insertCompositionText', value: 'ㅅ' }, + { t: 'keyup', key: 'ㅅ', code: 'KeyT', keyCode: 84, isComposing: true }, + { t: 'keydown', key: 'ㅏ', code: 'KeyK', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: '사' }, + { t: 'input', data: '사', inputType: 'insertCompositionText', value: '사' }, + { t: 'keyup', key: 'ㅏ', code: 'KeyK', keyCode: 75, isComposing: true }, + { t: 'keydown', key: 'Alt', code: 'AltLeft', keyCode: 18, isComposing: true, alt: true }, + { + t: 'keydown', + key: 'ArrowLeft', + code: 'ArrowLeft', + keyCode: 229, + isComposing: true, + alt: true + }, + { t: 'compositionupdate', data: '사' }, + { t: 'input', data: '사', inputType: 'insertCompositionText', value: '사' }, + { t: 'compositionend', data: '사' }, + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, alt: true }, + { t: 'keydown', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, alt: true }, + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, alt: true }, + { t: 'keyup', key: 'Alt', code: 'AltLeft', keyCode: 18 } + ] + }, + { + name: 'Korean 2-Set, Cmd+Backspace', + expectCalls: ['\x15'], + expectEmitted: ['사', '\x15'], + rows: [ + { t: 'keydown', key: 'ㅅ', code: 'KeyT', keyCode: 229 }, + { t: 'compositionstart', data: '' }, + { t: 'compositionupdate', data: 'ㅅ' }, + { t: 'input', data: 'ㅅ', inputType: 'insertCompositionText', value: 'ㅅ' }, + { t: 'keydown', key: 'ㅏ', code: 'KeyK', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: '사' }, + { t: 'input', data: '사', inputType: 'insertCompositionText', value: '사' }, + { t: 'keyup', key: 'ㅅ', code: 'KeyT', keyCode: 84, isComposing: true }, + { t: 'keyup', key: 'ㅏ', code: 'KeyK', keyCode: 75, isComposing: true }, + { t: 'keydown', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true, meta: true }, + { + t: 'keydown', + key: 'Backspace', + code: 'Backspace', + keyCode: 229, + isComposing: true, + meta: true + }, + { t: 'compositionupdate', data: '사' }, + { t: 'input', data: '사', inputType: 'insertCompositionText', value: '사' }, + { t: 'compositionend', data: '사' }, + { t: 'keyup', key: 'Backspace', code: 'Backspace', keyCode: 8, meta: true }, + { t: 'keydown', key: 'Backspace', code: 'Backspace', keyCode: 8, meta: true }, + { t: 'keyup', key: 'Backspace', code: 'Backspace', keyCode: 8, meta: true }, + { t: 'keyup', key: 'Meta', code: 'MetaLeft', keyCode: 91 } + ] + }, + { + name: 'Japanese, a bare arrow and four chords across one live preedit', + expectCalls: ['\x01', '\x1bb', '\x15', '\x1b\x7f'], + expectEmitted: ['日本語\x01\x1bb\x15\x1b\x7f'], + rows: [ + { t: 'keydown', key: 'n', code: 'KeyN', keyCode: 229 }, + { t: 'compositionstart', data: '' }, + { t: 'compositionupdate', data: 'n' }, + { t: 'input', data: 'n', inputType: 'insertCompositionText', value: 'n' }, + { t: 'keydown', key: 'i', code: 'KeyI', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: 'に' }, + { t: 'input', data: 'に', inputType: 'insertCompositionText', value: 'に' }, + { t: 'keyup', key: 'n', code: 'KeyN', keyCode: 78, isComposing: true }, + { t: 'keyup', key: 'i', code: 'KeyI', keyCode: 73, isComposing: true }, + { t: 'keydown', key: 'h', code: 'KeyH', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: 'にh' }, + { t: 'input', data: 'にh', inputType: 'insertCompositionText', value: 'にh' }, + { t: 'keydown', key: 'o', code: 'KeyO', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: 'にほ' }, + { t: 'input', data: 'にほ', inputType: 'insertCompositionText', value: 'にほ' }, + { t: 'keyup', key: 'h', code: 'KeyH', keyCode: 72, isComposing: true }, + { t: 'keyup', key: 'o', code: 'KeyO', keyCode: 79, isComposing: true }, + { t: 'keydown', key: 'n', code: 'KeyN', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: 'にほn' }, + { t: 'input', data: 'にほn', inputType: 'insertCompositionText', value: 'にほn' }, + { t: 'keyup', key: 'n', code: 'KeyN', keyCode: 78, isComposing: true }, + { t: 'keydown', key: 'g', code: 'KeyG', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: 'にほんg' }, + { t: 'input', data: 'にほんg', inputType: 'insertCompositionText', value: 'にほんg' }, + { t: 'keydown', key: 'o', code: 'KeyO', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: '日本語' }, + { t: 'input', data: '日本語', inputType: 'insertCompositionText', value: '日本語' }, + { t: 'keyup', key: 'g', code: 'KeyG', keyCode: 71, isComposing: true }, + { t: 'keyup', key: 'o', code: 'KeyO', keyCode: 79, isComposing: true }, + { t: 'keydown', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 229, isComposing: true }, + { t: 'compositionupdate', data: '日本語' }, + { t: 'input', data: '日本語', inputType: 'insertCompositionText', value: '日本語' }, + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, isComposing: true }, + { 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: 'keyup', + key: 'ArrowLeft', + code: 'ArrowLeft', + keyCode: 37, + isComposing: true, + meta: true + }, + { t: 'keyup', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true }, + { t: 'keydown', key: 'Alt', code: 'AltLeft', keyCode: 18, isComposing: true, alt: true }, + { + t: 'keydown', + key: 'ArrowLeft', + code: 'ArrowLeft', + keyCode: 229, + isComposing: true, + alt: true + }, + { + t: 'keyup', + key: 'ArrowLeft', + code: 'ArrowLeft', + keyCode: 37, + isComposing: true, + alt: true + }, + { t: 'keyup', key: 'Alt', code: 'AltLeft', keyCode: 18, isComposing: true }, + { t: 'keydown', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true, meta: true }, + { + t: 'keydown', + key: 'Backspace', + code: 'Backspace', + keyCode: 229, + isComposing: true, + meta: true + }, + { + t: 'keyup', + key: 'Backspace', + code: 'Backspace', + keyCode: 8, + isComposing: true, + meta: true + }, + { t: 'keyup', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true }, + { t: 'keydown', key: 'Alt', code: 'AltLeft', keyCode: 18, isComposing: true, alt: true }, + { + t: 'keydown', + key: 'Backspace', + code: 'Backspace', + keyCode: 229, + isComposing: true, + alt: true + }, + { t: 'keyup', key: 'Backspace', code: 'Backspace', keyCode: 8, isComposing: true, alt: true }, + { t: 'keyup', key: 'Alt', code: 'AltLeft', keyCode: 18, isComposing: true }, + { t: 'compositionend', data: '日本語' } + ] + } +] + +const MAC_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' + +function macrotask(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)) +} + +type Rig = { + textarea: HTMLTextAreaElement + terminal: Terminal + searchSelections: string[] + closePaneCalls: number[] + inputCalls: string[] + emitted: string[] + clearPaneCalls: number + unmount: () => void +} + +function openRig(overrides: Record = {}): Rig { + const scope = document.createElement('div') + document.body.append(scope) + const container = document.createElement('div') + scope.append(container) + const terminal = new Terminal() + terminal.open(container) + const textarea = terminal.textarea + if (!textarea) { + throw new Error('xterm helper textarea was not created') + } + + // Both records are channel-agnostic: chord bytes take the transport, not terminal.input, so + // watching only the latter would report zero for every one of them. + const emitted: string[] = [] + terminal.onData((data) => emitted.push(data)) + const inputCalls: string[] = [] + const passThrough = terminal.input.bind(terminal) + vi.spyOn(terminal, 'input').mockImplementation((data: string, wasUserInput?: boolean) => { + inputCalls.push(data) + passThrough(data, wasUserInput) + }) + + const transport = { + getPtyId: () => 'pty-1', + sendInput: vi.fn((data: string) => { + inputCalls.push(data) + emitted.push(data) + return true + }) + } as unknown as PtyTransport + const pane = { id: 1, leafId: '00000000-0000-4000-8000-000000000001', terminal } + const manager = { + getActivePane: () => pane, + getPanes: () => [pane] + } as unknown as PaneManager + const deps = { + tabId: 'tab-1', + worktreeId: 'worktree-1', + isActive: true, + keyboardScopeRef: { current: scope }, + managerRef: { current: manager }, + paneTransportsRef: { current: new Map([[pane.id, transport]]) }, + panePtyBindingsRef: { current: new Map() }, + paneCwdRef: { current: new Map() }, + fallbackCwd: '', + expandedPaneIdRef: { current: null }, + setExpandedPane: vi.fn(), + restoreExpandedLayout: vi.fn(), + refreshPaneSizes: vi.fn(), + persistLayoutSnapshot: vi.fn(), + toggleExpandPane: vi.fn(), + setSearchOpen: vi.fn(), + onSearchSelectedText: vi.fn(), + onRequestClosePane: vi.fn(), + onClearPaneScrollback: vi.fn(), + onSetTitle: vi.fn(), + onClearPaneTitle: vi.fn(), + searchOpenRef: { current: false }, + searchStateRef: { current: { query: '', caseSensitive: false, regex: false } }, + macOptionAsAltRef: { current: 'false' }, + ...overrides + } as unknown as Parameters[0] + + const hook = renderHook(() => useTerminalKeyboardShortcuts(deps)) + return { + textarea, + terminal, + inputCalls, + emitted, + get closePaneCalls() { + return (deps.onRequestClosePane as ReturnType).mock.calls.map( + ([id]) => id as number + ) + }, + get searchSelections() { + return (deps.onSearchSelectedText as ReturnType).mock.calls.map( + ([text]) => text as string + ) + }, + get clearPaneCalls() { + return (deps.onClearPaneScrollback as ReturnType).mock.calls.length + }, + unmount: () => { + hook.unmount() + scope.remove() + } + } +} + +function dispatchRow(textarea: HTMLTextAreaElement, row: RecordedRow): void { + if (row.t === 'keydown' || row.t === 'keyup') { + const event = new KeyboardEvent(row.t, { + key: row.key, + code: row.code, + metaKey: row.meta === true, + altKey: row.alt === true, + bubbles: true, + cancelable: true + }) + Object.defineProperties(event, { + isComposing: { value: row.isComposing === true }, + keyCode: { value: row.keyCode } + }) + textarea.dispatchEvent(event) + return + } + if (row.t.startsWith('composition')) { + const event = new CompositionEvent(row.t, { bubbles: true }) + // happy-dom ignores CompositionEventInit.data, but Chromium supplies it. + Object.defineProperty(event, 'data', { value: row.data ?? '' }) + textarea.dispatchEvent(event) + return + } + if (row.t === 'input') { + textarea.value = row.value ?? '' + textarea.selectionStart = textarea.value.length + textarea.selectionEnd = textarea.value.length + textarea.dispatchEvent( + new InputEvent('input', { data: row.data, inputType: row.inputType, bubbles: true }) + ) + } +} + +async function replay(textarea: HTMLTextAreaElement, rows: RecordedRow[]): Promise { + for (const row of rows) { + dispatchRow(textarea, row) + // A full task between rows, deliberately: nothing here may depend on how fast the rows + // arrive. Each event is decided from its own fields, so an arbitrary delay between the + // marked press and the platform's replay must not change the outcome — spacing the rows + // tightly would hide a regression back to a timed carry. + await macrotask() + } + await macrotask() + await macrotask() +} + +// Authored, unlike every row in the tables above: the commit a mid-composition capture never +// got to record. +async function commitComposition(textarea: HTMLTextAreaElement, committed: string): Promise { + const end = new CompositionEvent('compositionend', { bubbles: true }) + Object.defineProperty(end, 'data', { value: committed }) + textarea.dispatchEvent(end) + await macrotask() + await macrotask() +} + +const JAPANESE_CASE = 'Japanese, a bare arrow and four chords across one live preedit' + +// By name, not by index: each test below needs a particular gesture, and inserting a case +// would otherwise silently repoint them at the wrong trace while still passing. +function caseNamed(name: string): RecordedCase { + const found = CASES.find((testCase) => testCase.name === name) + if (!found) { + throw new Error(`recorded case not found: ${name}`) + } + return found +} + +describe('recorded macOS chord traces during an IME composition', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(MAC_USER_AGENT) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it.each( + [...CASES, ...IN_APP_TRACE_CASES, ...COMMAND_RELEASE_TRACE_CASES].map( + (testCase) => [testCase.name, testCase] as const + ) + )('%s', async (_name, testCase) => { + const rig = openRig() + await replay(rig.textarea, testCase.rows) + + if (testCase.commitsAfterCapture) { + // Held, not dropped: nothing yet, and the same expectations must hold once it commits. + expect(rig.inputCalls).toEqual([]) + await commitComposition(rig.textarea, 'さ') + } + expect(rig.inputCalls).toEqual(testCase.expectCalls) + // Joined: the captures were taken when xterm flushed preedit and chord as one payload, and + // the transport writes each on its own. + expect(rig.emitted.join('')).toEqual(testCase.expectEmitted.join('')) + rig.unmount() + }) + + // A chord remapped onto one of these keys resolves to a pane command rather than bytes, so + // the swallowed-chord release has to reach every action, not just the byte path. Recovering + // only bytes would leave a remapped Cmd+Backspace dead during a Japanese preedit. + it('runs a remapped pane command from a swallowed release', async () => { + const rig = openRig({ keybindings: { 'terminal.clear': ['Mod+Backspace'] } }) + await replay(rig.textarea, caseNamed(JAPANESE_CASE).rows) + + expect(rig.clearPaneCalls).toBe(1) + // The remap wins outright: no kill-line byte alongside it, and the other chords are + // untouched. + expect(rig.inputCalls).toEqual(['\x01', '\x1bb', '\x1b\x7f']) + rig.unmount() + }) + + // The mirror of the above on the input source that does replay: the release sends nothing, + // so the remap must run exactly once, from the replay alone. + it('runs a remapped pane command once when the platform replays instead', async () => { + const rig = openRig({ keybindings: { 'terminal.clear': ['Mod+Backspace'] } }) + await replay(rig.textarea, caseNamed('Korean 2-Set, Cmd+Backspace').rows) + + expect(rig.clearPaneCalls).toBe(1) + expect(rig.inputCalls).toEqual([]) + rig.unmount() + }) + + // The release path reads a live composition, and a rename field or the search input can hold + // one too. Those keystrokes belong to the field, and routing them to the shell would both + // move the wrong cursor and write bytes the user never aimed at the terminal. + it('leaves a swallowed chord alone when the composition is in a text field', async () => { + const rig = openRig() + const field = document.createElement('input') + rig.textarea.parentElement?.append(field) + + for (const t of ['keydown', 'keyup'] as const) { + const event = new KeyboardEvent(t, { + key: 'ArrowLeft', + code: 'ArrowLeft', + metaKey: true, + bubbles: true, + cancelable: true + }) + Object.defineProperties(event, { + isComposing: { value: true }, + keyCode: { value: t === 'keydown' ? 229 : 37 } + }) + field.dispatchEvent(event) + await macrotask() + } + + expect(rig.inputCalls).toEqual([]) + rig.unmount() + }) +}) + +// Constructed, not recorded. Both cases below need a shape the two captures happen not to +// contain, and the file above is kept to captured rows only so its fidelity claim stays true. +describe('constructed shapes the macOS captures do not contain', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(MAC_USER_AGENT) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + // The decision is the release's alone. Pinned directly rather than left to be inferred from + // the call counts above, because `resolveTerminalKeyboardShortcutAction` does resolve a + // composing exempt chord — it is the pane that declines to act on it until the key comes up. + it('does nothing while the chord is still held down', async () => { + const rig = openRig() + const japanese = caseNamed(JAPANESE_CASE) + const throughFirstChordPress = japanese.rows.slice( + 0, + japanese.rows.findIndex( + (row) => row.keyCode === 229 && row.code === 'ArrowLeft' && row.meta + ) + 1 + ) + await replay(rig.textarea, throughFirstChordPress) + + expect(rig.inputCalls).toEqual([]) + rig.unmount() + }) + + // The keydown path lets an empty floating panel claim a remapped tab.close before the pane + // does. Without the same order on the release, one chord closes a panel when pressed outside + // a composition and closes the pane when pressed inside one. + it('lets an empty floating panel claim a remapped tab.close from a release', async () => { + const panel = document.createElement('div') + panel.setAttribute('data-floating-terminal-panel', '') + panel.setAttribute('aria-hidden', 'false') + const emptyState = document.createElement('div') + emptyState.setAttribute('data-floating-terminal-empty-state', '') + panel.append(emptyState) + document.body.append(panel) + + const rig = openRig({ keybindings: { 'tab.close': ['Mod+Backspace'] } }) + await replay(rig.textarea, caseNamed(JAPANESE_CASE).rows) + + expect(rig.closePaneCalls).toEqual([]) + // The other three chords are untouched; only the panel's chord is withheld. + expect(rig.inputCalls).toEqual(['\x01', '\x1bb', '\x1b\x7f']) + rig.unmount() + }) + + // The keydown path runs the file-search chord ahead of the shortcut policy, so a release + // that went straight to the policy would make the same remap mean two different things + // depending on whether a composition happened to be live. + it('runs the file-search chord from a release, ahead of the byte fallback', async () => { + const rig = openRig({ keybindings: { 'sidebar.search.toggle': ['Mod+Backspace'] } }) + vi.spyOn(rig.terminal, 'getSelection').mockReturnValue('src/main.ts') + await replay(rig.textarea, caseNamed(JAPANESE_CASE).rows) + + expect(rig.searchSelections).toEqual(['src/main.ts']) + // The kill-line byte the built-in binding would have sent is not sent alongside it. + expect(rig.inputCalls).toEqual(['\x01', '\x1bb', '\x1b\x7f']) + rig.unmount() + }) + + // The composition can live in a rename field or the search input, and that field can unmount + // while the key is still down — the release then arrives with the terminal as its target and + // walks straight past the editable guard. Refusing to arm from such a press closes that, + // where refusing at the release cannot. + it('never arms from a press aimed at a text field, even if the field then unmounts', async () => { + const rig = openRig() + const field = document.createElement('input') + rig.textarea.parentElement?.append(field) + + const press = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + code: 'ArrowLeft', + altKey: true, + bubbles: true, + cancelable: true + }) + Object.defineProperties(press, { isComposing: { value: true }, keyCode: { value: 229 } }) + field.dispatchEvent(press) + await macrotask() + field.remove() + + await replay(rig.textarea, [ + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, isComposing: true, alt: true } + ]) + + expect(rig.inputCalls).toEqual([]) + rig.unmount() + }) + + // The action has already run by the time the release is consumed, so there is nothing left to + // race — and cutting a keyup off at window capture keeps it from ever reaching xterm, whose + // own keyup handler clears the flags its input path reads. + it('leaves the keyup itself to finish propagating', async () => { + const rig = openRig() + const seen: string[] = [] + const listener = (event: KeyboardEvent): void => { + seen.push(event.type) + } + rig.textarea.addEventListener('keyup', listener) + await replay(rig.textarea, caseNamed(JAPANESE_CASE).rows) + rig.textarea.removeEventListener('keyup', listener) + + expect(rig.inputCalls).toEqual(['\x01', '\x1bb', '\x15', '\x1b\x7f']) + // Four chord releases plus the letters and modifiers around them: every one arrives. + expect(seen.length).toBe( + caseNamed(JAPANESE_CASE).rows.filter((row) => row.t === 'keyup').length + ) + rig.unmount() + }) + + // Three separate ways a carry can be left with no release of its own to spend it, each with + // its own escape. Split apart deliberately: one test covering all three passes as long as any + // one of them works, which pins none of them. + const ARMED_ALT_ARROW: RecordedRow[] = [ + { t: 'compositionstart', data: '' }, + { t: 'keydown', key: 'Alt', code: 'AltLeft', keyCode: 18, isComposing: true, alt: true }, + { + t: 'keydown', + key: 'ArrowLeft', + code: 'ArrowLeft', + keyCode: 229, + isComposing: true, + alt: true + } + ] + + // The user pressed the key again for its own sake. Whatever the last press carried is over, + // even though this one is IME-owned too and claims no carry of its own. + it('lets a later press of the same key supersede the carry', async () => { + const rig = openRig() + await replay(rig.textarea, [ + ...ARMED_ALT_ARROW, + { t: 'keydown', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 229, isComposing: true }, + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, isComposing: true } + ]) + + expect(rig.inputCalls).toEqual([]) + rig.unmount() + }) + + // Cmd+Tab or Spotlight takes the release with it. If the window gets the keyup back later, + // the chord it belonged to is long gone. + it('drops the carry when focus leaves the window', async () => { + const rig = openRig() + await replay(rig.textarea, ARMED_ALT_ARROW) + window.dispatchEvent(new Event('blur')) + await replay(rig.textarea, [ + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, isComposing: true, alt: true } + ]) + + expect(rig.inputCalls).toEqual([]) + rig.unmount() + }) + + // The release arrived but a guard turned it away. The key is still up, so the press is still + // over — a carry that survives a refused release answers for whatever comes next instead. + it('spends the carry on a release a guard refuses', async () => { + const rig = openRig() + const field = document.createElement('input') + rig.textarea.parentElement?.append(field) + await replay(rig.textarea, ARMED_ALT_ARROW) + + const refused = new KeyboardEvent('keyup', { + key: 'ArrowLeft', + code: 'ArrowLeft', + altKey: true, + bubbles: true, + cancelable: true + }) + Object.defineProperties(refused, { isComposing: { value: true }, keyCode: { value: 37 } }) + field.dispatchEvent(refused) + await macrotask() + + await replay(rig.textarea, [ + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, isComposing: true, alt: true } + ]) + + expect(rig.inputCalls).toEqual([]) + rig.unmount() + }) + + // A KeyboardEvent's modifier flags describe the moment it fired, so letting Cmd up before + // the arrow leaves the arrow's keyup with metaKey: false. Reading the release's own flags + // drops the chord outright, which is the bug this whole change exists to fix. + it('sends the chord when the modifier is released before the key', async () => { + const rig = openRig() + const japanese = caseNamed(JAPANESE_CASE) + const beforeFirstChord = japanese.rows.slice( + 0, + japanese.rows.findIndex((row) => row.t === 'keydown' && row.code === 'MetaLeft') + ) + await replay(rig.textarea, [ + ...beforeFirstChord, + { 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: 'keyup', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true }, + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, isComposing: true } + ]) + + expect(rig.inputCalls).toEqual([]) + await commitComposition(rig.textarea, '日本語') + expect(rig.inputCalls).toEqual(['\x01']) + rig.unmount() + }) + + // The mirror hazard: the key went down with no composition in sight, so it already resolved + // from its own keydown. A composition starting while it is held must not make the release + // send it a second time — for Option+Left that is two words instead of one. + it('does not send twice when a composition starts while the key is held', async () => { + const rig = openRig() + await replay(rig.textarea, [ + { t: 'keydown', key: 'Alt', code: 'AltLeft', keyCode: 18, alt: true }, + { t: 'keydown', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, alt: true }, + { t: 'compositionstart', data: '' }, + { t: 'keyup', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, isComposing: true, alt: true } + ]) + + expect(rig.inputCalls).toEqual(['\x1bb']) + rig.unmount() + }) + + // Windows reports an IME-consumed key as `key: 'Process'` (#12171), which normalizes to no + // token at all, so the keybinding lookup has to run on the physical `code` instead. macOS + // leaves `key` alone, so no capture reaches that substitution — without this the branch is + // only ever exercised by plain-object fixtures, never by a real event through the handler. + it('honours a remap when the input source has rewritten key to Process', async () => { + const rig = openRig({ keybindings: { 'terminal.clear': ['Mod+Backspace'] } }) + const japanese = caseNamed(JAPANESE_CASE) + const rows = japanese.rows.map((row) => + row.code === 'Backspace' ? { ...row, key: 'Process' } : row + ) + await replay(rig.textarea, rows) + + // Without the physical-code substitution this falls through to the built-in kill-line + // byte: the wrong action, silently, for anyone who remapped the chord. + expect(rig.clearPaneCalls).toBe(1) + expect(rig.inputCalls).toEqual(['\x01', '\x1bb', '\x1b\x7f']) + rig.unmount() + }) + + // Escape during a preedit ends the composition with an empty commit. xterm flushes whatever + // the chord queued at that point, so this pins where those bytes go rather than leaving it + // to be discovered in a shell. + it('still delivers a chord when the composition is cancelled instead of committed', async () => { + const rig = openRig() + const japanese = caseNamed(JAPANESE_CASE) + const upToFirstChord = japanese.rows.slice( + 0, + japanese.rows.findIndex((row) => row.t === 'keyup' && row.code === 'MetaLeft') + 1 + ) + // Escape clears the preedit out of the helper textarea before the composition ends; xterm + // reads the commit from that textarea, so omitting this row would commit 日本語 after all. + await replay(rig.textarea, [ + ...upToFirstChord, + { t: 'input', inputType: 'deleteCompositionText', value: '' }, + { t: 'compositionend', data: '' } + ]) + + // The chord was aimed at the shell's line, not the preedit, so cancelling does not retract it. + expect(rig.inputCalls).toEqual(['\x01']) + // Whether the cancelled text reaches the pty is xterm's call and deliberately not pinned — + // the bundled build now commits it from its own buffer. The chord must be last either way. + expect(rig.emitted.at(-1)).toBe('\x01') + rig.unmount() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 6580bbb81b8..acb9d469a23 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -6,7 +6,7 @@ import type { IDisposable } from '@xterm/xterm' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' import { safeFind } from '../terminal-search-safe-find' -import { resolveTerminalShortcutAction } from './terminal-shortcut-policy' +import { isImeExemptTerminalChord, resolveTerminalShortcutAction } from './terminal-shortcut-policy' import type { MacOptionAsAlt } from './terminal-shortcut-policy' import { createTerminalNativeOnlyShortcutTracker } from './terminal-native-only-shortcut' import { @@ -15,9 +15,11 @@ import { getTerminalImeModifiedEnterKind, isTerminalImeConsumedKey, isTerminalImeEnterKeyUp, - isTerminalImeProcessEnter + isTerminalImeProcessEnter, + sendTerminalInputAfterComposition } from './terminal-ime-deferred-newline' import { hasPendingTerminalImeComposition } from './terminal-ime-composition-route' +import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event' import { requestCapturedTerminalReconfirmation, sendCapturedTerminalInput @@ -54,6 +56,86 @@ import { syncTerminalScrollIntentFromViewport } from '@/lib/pane-manager/terminal-scroll-intent' +// What runShortcutAction actually touches. Narrow so the release can hand it a chord that +// answers for the press rather than for the event that happens to be in flight. +type ShortcutActionEvent = { + key: string + code?: string + repeat: boolean + preventDefault: () => void + stopImmediatePropagation: () => void +} + +type PendingImeChord = Parameters[0] & + Required[0], 'code' | 'repeat'>> & { + isComposing: boolean + } + +/** + * A chord the IME took on keydown, kept until the key comes up so the release can answer for + * the press rather than for whatever the keyboard looks like by then. + * + * Recorded on stock macOS, and the two input sources answer in opposite ways. Both marked + * keydowns are identical (`code='ArrowLeft'`, `keyCode=229`, `isComposing=true`), so nothing + * can be decided when the key goes down. By the time it comes up they have separated: + * + * - Korean 2-Set committed the syllable and ended the composition, then the platform + * replays the chord unmarked. `isComposing` is false at release, and that replay resolves + * on its own — acting there too would send it twice, which for `Option+←` is two words. + * - Japanese conversion swallowed the chord whole: no commit, no replay, and the + * composition is still live at release. Nothing else will ever deliver it. + * + * So a still-composing release means the chord has no other route to the shell. Its bytes take + * the transport, which bypasses xterm, so the release holds them until the composition commits. + * + * The snapshot is what makes reading the release safe. A `KeyboardEvent`'s modifier flags + * describe the moment it fired, and releasing `Cmd` before `←` leaves the arrow's keyup with + * `metaKey: false` — matching on that loses the chord entirely. + * + * TODO: one release is one action, so holding a swallowed chord down repeats nothing. Acting + * on the auto-repeat instead would have to run before the two cases separate, so this waits + * for a trace showing a user holds a chord mid-preedit. + */ +function imeChordSnapshot(event: KeyboardEvent): PendingImeChord { + // Field by field, and it must stay that way: a browser keeps KeyboardEvent's fields as + // prototype accessors, so `{ ...event }` is an empty object and the snapshot loses its + // `code`, which silently disables the whole recovery. happy-dom keeps them as own properties + // and would go on passing, so this is the only warning that survives in the source. + return { + key: event.key, + code: event.code, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + // One release is one action, so the auto-repeat presses behind it are not repeats of it. + repeat: false, + // `isComposing` alone marks this as IME-owned for every consumer; the keyCode that also + // marked the press adds nothing once the press is over. + isComposing: true + } +} + +/** + * Which release answers for a pending chord. Normally the chord's own key, but a key released + * while `Cmd` is held has no keyup at all: recorded in-app at Chromium's own input dispatch, + * `Cmd+←` delivers a keydown and nothing after it, while `Option+←` and a bare `←` both deliver + * their keyup there. Nothing in the app consumes it — it never arrives. The `Cmd` release does, + * still marked as composing, and it ends the same gesture. + * + * Korean cannot double-fire through this. It commits on the chord, so its own keyup arrives + * first and spends the carry, and both releases report the composition already over. + */ +function releasesPendingImeChord(chord: PendingImeChord, event: KeyboardEvent): boolean { + return chord.code === event.code || (chord.metaKey === true && event.key === 'Meta') +} + +/** + * No IME gate here on purpose: the pane calls this for a swallowed chord's RELEASE, where the + * event still reports itself as composing and resolving it is the whole point. Which composing + * events reach a resolver at all is the pane's decision, not this function's — so a keydown + * outcome asserted against this function alone pins something the pane does not do. + */ export function resolveTerminalKeyboardShortcutAction( event: Parameters[0], isMac: Parameters[1], @@ -285,6 +367,7 @@ export function useTerminalKeyboardShortcuts({ let optionKeyLocation = 0 const heldImeEnterModifiers = new Set<'shift' | 'ctrl'>() const terminalImeEnterModifierKeydowns = new Set<'shift' | 'ctrl'>() + let pendingImeChord: PendingImeChord | null = null const nativeOnlyShortcutTracker = createTerminalNativeOnlyShortcutTracker() const deferredNewlineSender = createTerminalImeDeferredNewlineSender() const modifiedEnterChordOwner = createTerminalImeModifiedEnterChordOwner() @@ -509,6 +592,26 @@ export function useTerminalKeyboardShortcuts({ if (keyboardScope && !keyboardEventBelongsToScope(e, keyboardScope)) { return } + // Any press of this key ends whatever the last one carried, whoever owns this one. A key + // pressed outside a composition resolves from here, so a composition starting while it is + // held must not turn its release into a second firing; and a carry whose release never + // arrived — focus left the window mid-chord — must not answer for an unrelated later + // press of the same key. Scoped to this code so rollover leaves other keys alone. + if (pendingImeChord?.code === e.code) { + pendingImeChord = null + } + // Only the exempt chords yield here: an IME that swallows one leaves no other route to the + // shell, so it is recovered from the release. Every other IME-owned key keeps the paths + // below, which own the composing Enter. + if (isImeOwnedKeyboardEvent(e) && isImeExemptTerminalChord(e)) { + // Not armed from a rename field or the search input: that field can unmount before the + // key comes up, and the release would then arrive with the terminal as its target and + // pass the guard below, sending a chord aimed at the field to the shell. + if (!isEditableTarget(e.target)) { + pendingImeChord = imeChordSnapshot(e) + } + return + } const modifiedEnterChord = isWindows ? getModifiedEnterChord(e) : null if ( @@ -596,7 +699,39 @@ export function useTerminalKeyboardShortcuts({ if (!action) { return } + runShortcutAction(e, action, manager, (pane, sendResolvedInput) => { + if ( + !((e.isComposing || hasPendingImeComposition) && (e.key === 'Enter' || imeProcessEnter)) + ) { + return false + } + if (isWindows) { + const chord = getModifiedEnterChord(e) + const claimedChord = chord + ? { + ...chord, + terminalModifierKeyDownObserved: terminalImeEnterModifierKeydowns.has(chord.kind) + } + : null + if (claimedChord && !modifiedEnterChordOwner.claim(claimedChord)) { + return true + } + } + deferredNewlineSender.defer(e, pane.terminal.element, sendResolvedInput) + return true + }) + } + // Shared by the keydown path and the swallowed-chord release below, so a chord recovered + // from a release runs the same action a press would — including remaps onto pane commands. + function runShortcutAction( + e: ShortcutActionEvent, + action: NonNullable>, + manager: PaneManager, + // Why a callback: a composing Enter reads the live keydown and its imeProcessEnter, which + // a chord recovered from a release has neither of — and never is. + deferSend?: (pane: ManagedPane, sendResolvedInput: () => void) => boolean + ): void { if (action.type === 'switchInputSource') { // Why: the OS must receive its default action, while xterm must receive // none of the keydown, keypress, or keyup sequence. @@ -613,20 +748,7 @@ export function useTerminalKeyboardShortcuts({ return } const sendResolvedInput = createCapturedInputSender(pane, action.data) - if ((e.isComposing || hasPendingImeComposition) && (e.key === 'Enter' || imeProcessEnter)) { - if (isWindows) { - const chord = getModifiedEnterChord(e) - const claimedChord = chord - ? { - ...chord, - terminalModifierKeyDownObserved: terminalImeEnterModifierKeydowns.has(chord.kind) - } - : null - if (claimedChord && !modifiedEnterChordOwner.claim(claimedChord)) { - return - } - } - deferredNewlineSender.defer(e, pane.terminal.element, sendResolvedInput) + if (deferSend?.(pane, sendResolvedInput)) { return } sendResolvedInput() @@ -935,6 +1057,9 @@ export function useTerminalKeyboardShortcuts({ const onNativeOnlyBlur = (): void => { nativeOnlyShortcutTracker.clear() + // Why: a chord interrupted by Cmd+Tab or Spotlight never delivers its release, and a carry + // with no release to spend it sits armed. + pendingImeChord = null heldImeEnterModifiers.clear() terminalImeEnterModifierKeydowns.clear() modifiedEnterChordOwner.clear() @@ -942,12 +1067,85 @@ export function useTerminalKeyboardShortcuts({ observedEnterKeydownTimeStamps.clear() } + const onSwallowedImeChordRelease = (e: KeyboardEvent): void => { + const chord = pendingImeChord + if (!chord || !releasesPendingImeChord(chord, e)) { + return + } + // Spent before any gate below can refuse: the key is up, so the press it carried is over + // however this release is routed. Leaving it armed past a gate is what lets a carry + // outlive its gesture and answer for someone else's press. + pendingImeChord = null + const manager = managerRef.current + if (!manager) { + return + } + const keyboardScope = keyboardScopeRef.current + if (keyboardScope && !keyboardEventBelongsToScope(e, keyboardScope)) { + return + } + // The composition ended while the key was down, so the IME took the chord as its cue to + // commit rather than eating it, and the platform's unmarked replay is on its way. + if (!isImeOwnedKeyboardEvent(e)) { + return + } + if (isEditableTarget(e.target)) { + return + } + // Matched on the press, consumed on the release. `chord` carries the modifiers as they + // were when the key went down; `preventDefault` forwards to the real event. + // + // The propagation stoppers do not. On a keydown they keep a second handler from acting on + // the same press, but by this point the action has already run and there is nothing left + // to race. Cutting a keyup off at window capture only costs: it never reaches xterm's own + // `_keyUp`, which clears the flags its input path reads, nor any window listener that + // happens to be registered after this one. + const pressed: PendingImeChord & ShortcutActionEvent & { stopPropagation: () => void } = { + ...chord, + preventDefault: () => e.preventDefault(), + stopPropagation: () => {}, + stopImmediatePropagation: () => {} + } + // Same precedence as the keydown path: a chord remapped onto tab.close closes an empty + // floating panel there, and skipping it here would close the pane instead. + if (handleEmptyFloatingWorkspacePanelCloseShortcut(pressed, shortcutPlatform, keybindings)) { + return + } + if (matchFileSearchShortcut(chord, shortcutPlatform, keybindings, terminalShortcutPolicy)) { + const pane = manager.getActivePane() ?? manager.getPanes()[0] + const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection()) + if (selectedText) { + e.preventDefault() + e.stopImmediatePropagation() + onSearchSelectedText(selectedText) + return + } + } + const action = resolveShortcutEvent(chord) + // A native-only chord arms from its press so the OS still sees the gesture; arming it + // from a release would leave the tracker holding a key that is already up. + if (!action || action.type === 'switchInputSource') { + return + } + runShortcutAction(pressed, action, manager, (pane, sendResolvedInput) => { + // Composing here by definition, and these bytes bypass xterm: sending now puts the chord + // ahead of the text still being composed — 가나 + 다 in the preedit yields 다가나. A chord + // remapped onto a pane command skips this and stays immediate, or the commit lands after + // the pane is already cleared. + sendTerminalInputAfterComposition(pane.terminal.element, sendResolvedInput, { + fallbackMs: null + }) + return true + }) + } + window.addEventListener('keydown', onModifierDown, { capture: true }) window.addEventListener('keyup', onKeyUp, { capture: true }) window.addEventListener('keydown', onKeyDown, { capture: true }) window.addEventListener('keypress', onNativeOnlyShortcutCompanion, { capture: true }) window.addEventListener('keyup', onNativeOnlyShortcutCompanion, { capture: true }) window.addEventListener('beforeinput', onNativeOnlyBeforeInput, { capture: true }) + window.addEventListener('keyup', onSwallowedImeChordRelease, { capture: true }) window.addEventListener('blur', onNativeOnlyBlur) return () => { modifiedEnterChordOwner.clear() @@ -959,6 +1157,8 @@ export function useTerminalKeyboardShortcuts({ window.removeEventListener('keypress', onNativeOnlyShortcutCompanion, { capture: true }) window.removeEventListener('keyup', onNativeOnlyShortcutCompanion, { capture: true }) window.removeEventListener('beforeinput', onNativeOnlyBeforeInput, { capture: true }) + pendingImeChord = null + window.removeEventListener('keyup', onSwallowedImeChordRelease, { capture: true }) window.removeEventListener('blur', onNativeOnlyBlur) } }, [ diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts index 2fd141fe3ce..7ade91bd32e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts @@ -5,17 +5,25 @@ import { export const TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS = 200 +/** + * `fallbackMs: null` waits however long the composition runs, and drops the input if it never + * ends. A late newline is still a newline; a cursor chord sent mid-preedit is the corruption the + * wait exists to prevent. + */ export function sendTerminalInputAfterComposition( terminalElement: HTMLElement | null | undefined, send: () => void, - options?: { fallbackMs?: number } + options?: { fallbackMs?: number | null } ): void { if (!terminalElement) { window.setTimeout(send, 0) return } - const fallbackMs = options?.fallbackMs ?? TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS + const fallbackMs = + options?.fallbackMs === null + ? null + : (options?.fallbackMs ?? TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS) let done = false const finish = (): void => { @@ -42,7 +50,7 @@ export function sendTerminalInputAfterComposition( const onCompositionSessionEnd = (): void => finishAfterPendingComposition() terminalElement.addEventListener('compositionend', onCompositionEnd) terminalElement.addEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, onCompositionSessionEnd) - const fallbackTimer = window.setTimeout(finish, fallbackMs) + const fallbackTimer = fallbackMs === null ? undefined : window.setTimeout(finish, fallbackMs) } export type TerminalImeDeferredNewlineSender = { diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 6ab00a13806..503f212cd5c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -6,6 +6,7 @@ import { type TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { WindowsShiftEnterEncoding } from './terminal-windows-shift-enter' +import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event' export type TerminalShortcutEvent = { key: string @@ -19,6 +20,43 @@ export type TerminalShortcutEvent = { export type MacOptionAsAlt = 'true' | 'false' | 'left' | 'right' +// Why: a live composition owns every keydown, but a Japanese conversion swallows a modifier +// chord over one of these keys outright — no commit, no platform replay, nothing reaching the +// shell (#12871). Exempting them lets the terminal pane recover the chord from the key's +// release, which is the first moment a swallowed chord is distinguishable from a delayed one +// (keyboard-handlers.ts, `imeChordSnapshot`). Read `code`, not `key`: Windows reports an +// IME-consumed key as `key: 'Process'` (#12171, #13033), which the keybinding matcher also +// falls back on — see PHYSICAL_CODE_FALLBACK_KEYS in shared/keybindings.ts. +// +// Adding a code here means teaching the Cmd+Up/Down branch below to read `chordKey` too; it +// reads `event.key`, which is the same value for every code outside this set. +// +// ArrowUp/ArrowDown are left out because no trace covers them, not because the reasoning +// stops there — Cmd+↑/↓ scrolls the viewport and writes no bytes, so it is the safer half. +const IME_EXEMPT_CHORD_CODES = new Set(['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete']) + +// Gated on IME ownership so an ordinary press keeps following `key`: an X11 keysym remap +// preserves `code` while changing `key`, and reading `code` there would silently override it. +function physicalChordKey(event: TerminalShortcutEvent): string { + if (!isImeOwnedKeyboardEvent(event) || !event.code) { + return event.key + } + return IME_EXEMPT_CHORD_CODES.has(event.code) ? event.code : event.key +} + +/** + * True when an IME-owned event is nonetheless an Orca terminal chord. A lone modifier is + * excluded, so a mode-switch gesture such as composing Ctrl+Space never reaches the shortcut + * policy, and so is Shift, which Japanese conversion binds to resize a segment. + */ +export function isImeExemptTerminalChord(event: TerminalShortcutEvent): boolean { + return ( + IME_EXEMPT_CHORD_CODES.has(event.code ?? '') && + !event.shiftKey && + (event.metaKey || event.ctrlKey || event.altKey) + ) +} + // Shared close-chord predicate: the terminal pane (L3) and the floating panel's focused-terminal // branch (L2) both treat terminal.closePane OR a terminal-scope tab.close as "close the active // pane," so the two layers can't diverge. Callers pass the options each binding needs — @@ -112,7 +150,7 @@ export function resolveTerminalShortcutAction( hasCtrlEnterCsiUAuthority?: () => boolean ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' - + const chordKey = physicalChordKey(event) // Why: capture this chord even on repeat without blocking the OS default input-source switch. if (keybindingMatchesAction('terminal.switchInputSource', event, platform, keybindings)) { return { type: 'switchInputSource' } @@ -214,23 +252,23 @@ export function resolveTerminalShortcutAction( !event.metaKey && !event.altKey && !event.shiftKey && - event.key === 'Backspace' + chordKey === 'Backspace' ) { return { type: 'sendInput', data: '\x17' } } if (isMac && event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) { - if (event.key === 'Backspace') { + if (chordKey === 'Backspace') { return { type: 'sendInput', data: '\x15' } } - if (event.key === 'Delete') { + if (chordKey === 'Delete') { return { type: 'sendInput', data: '\x0b' } } // Why: xterm.js has no Cmd+Arrow mapping; translate Cmd+←/→ to readline Ctrl+A/Ctrl+E for line start/end (iTerm2/Ghostty). - if (event.key === 'ArrowLeft') { + if (chordKey === 'ArrowLeft') { return { type: 'sendInput', data: '\x01' } } - if (event.key === 'ArrowRight') { + if (chordKey === 'ArrowRight') { return { type: 'sendInput', data: '\x05' } } // Why: macOS users expect Cmd+↑/↓ to scroll scrollback, not write escape bytes to the shell. @@ -247,7 +285,7 @@ export function resolveTerminalShortcutAction( !event.ctrlKey && event.altKey && !event.shiftKey && - event.key === 'Backspace' + chordKey === 'Backspace' ) { // Why: a kitty-protocol TUI binds the CSI 127;3u xterm emits natively; the legacy \x1b\x7f fallback would bypass it. if (isKittyKeyboardActivePane?.()) { @@ -261,14 +299,14 @@ export function resolveTerminalShortcutAction( !event.ctrlKey && event.altKey && !event.shiftKey && - (event.key === 'ArrowLeft' || event.key === 'ArrowRight') + (chordKey === 'ArrowLeft' || chordKey === 'ArrowRight') ) { // Why: a kitty-protocol TUI binds alt+arrow via xterm's native CSI 1;3D/C; \eb/\ef would reach it as alt+b/f. if (isKittyKeyboardActivePane?.()) { return null } // Why: readline doesn't bind xterm's \e[1;3D/C for alt+←/→, so translate to \eb/\ef for word-nav (iTerm2 "Esc+" behavior). - return { type: 'sendInput', data: event.key === 'ArrowLeft' ? '\x1bb' : '\x1bf' } + return { type: 'sendInput', data: chordKey === 'ArrowLeft' ? '\x1bb' : '\x1bf' } } if ( @@ -277,14 +315,14 @@ export function resolveTerminalShortcutAction( event.ctrlKey && !event.altKey && !event.shiftKey && - (event.key === 'ArrowLeft' || event.key === 'ArrowRight') + (chordKey === 'ArrowLeft' || chordKey === 'ArrowRight') ) { // Why: local Windows ConPTY (PSReadLine) binds Ctrl+←/→ itself; sending \eb/\ef prints stray b/f. Remote/WSL run readline. if (isLocalWindowsConptyPane?.()) { return null } // Why: readline ignores xterm's \e[1;5D/C, so translate Ctrl+←/→ to \eb/\ef for word-nav; !isMac since Mac reserves Ctrl+Arrow. - return { type: 'sendInput', data: event.key === 'ArrowLeft' ? '\x1bb' : '\x1bf' } + return { type: 'sendInput', data: chordKey === 'ArrowLeft' ? '\x1bb' : '\x1bf' } } // Why: macOptionIsMeta stays off so non-US layouts can compose @/€; match event.code since composition rewrites event.key. diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 4fd980aa402..229dc42c127 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -1597,7 +1597,9 @@ const PUNCTUATION_KEY_TOKENS = new Set([ 'Backquote' ]) -const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified']) +// 'Process' is Windows' report for a key an IME consumed (#12171): the produced key is +// genuinely unreportable, which is the same condition as 'Dead' above. +const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified', 'Process']) const SHIFTED_PUNCTUATION_KEY_TOKENS: Record = { '<': 'Comma', diff --git a/tests/e2e/macos-input-source-driver.ts b/tests/e2e/macos-input-source-driver.ts new file mode 100644 index 00000000000..13d4cd94180 --- /dev/null +++ b/tests/e2e/macos-input-source-driver.ts @@ -0,0 +1,93 @@ +import { execFileSync } from 'node:child_process' +import path from 'node:path' + +/** + * Drives real macOS input sources and synthesized key events for the #12871 captures. Playwright's + * own keyboard bypasses the IME entirely, so these go through TIS and System Events instead. + */ + +export const TWO_SET_KOREAN_ID = 'com.apple.inputmethod.Korean.2SetKorean' +export const KOTOERI_ROMAJI_ID = 'com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese' +export const KOTOERI_ROMAJI_PARENT_ID = 'com.apple.inputmethod.Kotoeri.RomajiTyping' +export const ABC_ID = 'com.apple.keylayout.ABC' + +export const KEY = { left: 123, backspace: 51, returnKey: 36, s: 1, a: 0 } as const +export const MODIFIER_KEY = { command: 55, option: 58 } as const + +const SELECT_INPUT_SOURCE = path.resolve(__dirname, 'select-input-source.swift') +const POST_MODIFIER_CHORD = path.resolve(__dirname, 'post-modifier-chord.swift') + +export function selectInputSource(id: string): void { + execFileSync('swift', [SELECT_INPUT_SOURCE, id]) +} + +export function enableInputSource(id: string): void { + execFileSync('swift', ['-'], { + input: ` +import Carbon +let properties = [kTISPropertyInputSourceID: ${JSON.stringify(id)} as CFString] as CFDictionary +let sources = TISCreateInputSourceList(properties, true).takeRetainedValue() as! [TISInputSource] +guard !sources.isEmpty else { exit(3) } +for candidate in sources { TISEnableInputSource(candidate) } +exit(0) +` + }) +} + +export function focusApp(processId: number): void { + execFileSync('osascript', [ + '-e', + `tell application "System Events" to set frontmost of first application process whose unix id is ${processId} to true`, + '-e', + 'delay 0.3' + ]) +} + +/** Switching the input source only takes effect on the next activation. */ +export function bounceFocus(processId: number): void { + execFileSync('osascript', ['-e', 'tell application "Finder" to activate', '-e', 'delay 0.4']) + focusApp(processId) +} + +export function typeKeyCodes(processId: number, keyCodes: readonly number[]): void { + focusApp(processId) + execFileSync('osascript', [ + '-e', + 'tell application "System Events"', + '-e', + `repeat with currentKeyCode in {${keyCodes.join(', ')}}`, + '-e', + 'key code (currentKeyCode as integer)', + '-e', + 'delay 0.12', + '-e', + 'end repeat', + '-e', + 'end tell' + ]) +} + +/** + * Types the chord with the modifier as its own key event. System Events folds the modifier into + * the target key's flags instead, so its press and release never reach the app at all. + */ +export function pressChordWithSeparateModifier( + processId: number, + keyCode: number, + modifier: 'command' | 'option' +): void { + focusApp(processId) + execFileSync('swift', [POST_MODIFIER_CHORD, String(MODIFIER_KEY[modifier]), String(keyCode)]) +} + +export function pressChord( + processId: number, + keyCode: number, + modifier?: 'command' | 'option' +): void { + focusApp(processId) + execFileSync('osascript', [ + '-e', + `tell application "System Events" to key code ${keyCode}${modifier ? ` using ${modifier} down` : ''}` + ]) +} diff --git a/tests/e2e/main-process-input-event-probe.ts b/tests/e2e/main-process-input-event-probe.ts new file mode 100644 index 00000000000..3de6b6a5fc9 --- /dev/null +++ b/tests/e2e/main-process-input-event-probe.ts @@ -0,0 +1,84 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' + +/** + * Records what Chromium itself delivers, before the page sees it. The renderer probe can only say + * "no keyup arrived here"; this says whether one arrived at all, which separates a release lost at + * the OS boundary from one consumed inside the app. + */ + +export type MainProcessInputRow = { + t: string + key: string + code: string + meta: boolean + alt: boolean + control: boolean + shift: boolean + isAutoRepeat: boolean + /** Read after Orca's own handler ran, since this listener registers last. */ + defaultPrevented: boolean + ts: number +} + +export async function installMainProcessInputProbe( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate(({ BrowserWindow }) => { + type ProbeGlobal = typeof globalThis & { + __mainInputProbe?: { rows: unknown[]; dispose: () => void } + } + const probeGlobal = globalThis as ProbeGlobal + probeGlobal.__mainInputProbe?.dispose() + + const mainWindow = BrowserWindow.getAllWindows()[0] + if (!mainWindow) { + throw new Error('No BrowserWindow available') + } + const rows: unknown[] = [] + const listener = (event: { defaultPrevented?: boolean }, input: Electron.Input): void => { + rows.push({ + t: input.type, + key: input.key, + code: input.code, + meta: input.meta, + alt: input.alt, + control: input.control, + shift: input.shift, + isAutoRepeat: input.isAutoRepeat, + defaultPrevented: event.defaultPrevented === true, + ts: Date.now() + }) + } + mainWindow.webContents.on('before-input-event', listener) + probeGlobal.__mainInputProbe = { + rows, + dispose: () => mainWindow.webContents.off('before-input-event', listener) + } + }) +} + +export async function readMainProcessInputProbe( + electronApp: ElectronApplication +): Promise { + const rows = await electronApp.evaluate(() => { + const probe = (globalThis as typeof globalThis & { __mainInputProbe?: { rows: unknown[] } }) + .__mainInputProbe + if (!probe) { + throw new Error('main-process input probe was never installed') + } + return [...probe.rows] + }) + return rows as MainProcessInputRow[] +} + +export async function disposeMainProcessInputProbe( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate(() => { + const probeGlobal = globalThis as typeof globalThis & { + __mainInputProbe?: { dispose: () => void } + } + probeGlobal.__mainInputProbe?.dispose() + probeGlobal.__mainInputProbe = undefined + }) +} diff --git a/tests/e2e/post-modifier-chord.swift b/tests/e2e/post-modifier-chord.swift new file mode 100644 index 00000000000..9eb2fcc71e0 --- /dev/null +++ b/tests/e2e/post-modifier-chord.swift @@ -0,0 +1,42 @@ +// Posts a chord the way a human types it: the modifier as its own key event, not as a flag +// System Events folds into the character key. Only this form produces the modifier's own +// press/release, which is what a release-driven recovery would key off. +// Usage: swift post-modifier-chord.swift + +import CoreGraphics +import Foundation + +let arguments = CommandLine.arguments +guard arguments.count >= 3, + let modifierKey = CGKeyCode(arguments[1]), + let targetKey = CGKeyCode(arguments[2]) +else { + FileHandle.standardError.write( + Data("usage: post-modifier-chord.swift \n".utf8)) + exit(2) +} + +let modifierFlag: CGEventFlags = + switch modifierKey { + case 55, 54: .maskCommand + case 58, 61: .maskAlternate + case 59, 62: .maskControl + default: [] + } + +let source = CGEventSource(stateID: .hidSystemState) + +func post(_ key: CGKeyCode, down: Bool, flags: CGEventFlags) { + guard let event = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: down) else { + exit(3) + } + event.flags = flags + event.post(tap: .cghidEventTap) + usleep(80_000) +} + +post(modifierKey, down: true, flags: modifierFlag) +post(targetKey, down: true, flags: modifierFlag) +post(targetKey, down: false, flags: modifierFlag) +post(modifierKey, down: false, flags: []) +exit(0) diff --git a/tests/e2e/renderer-chord-event-probe.ts b/tests/e2e/renderer-chord-event-probe.ts new file mode 100644 index 00000000000..da7f0caa3c9 --- /dev/null +++ b/tests/e2e/renderer-chord-event-probe.ts @@ -0,0 +1,123 @@ +import type { Page } from '@stablyai/playwright-test' + +/** + * Renderer-side listeners for the #12871 captures. Four positions plus focus ownership, so a + * release that never reaches the renderer can be told apart from one delivered somewhere else. + */ + +export function readActiveComposition(page: Page): Promise { + return page.evaluate(() => { + const textarea = document.querySelector('.xterm-helper-textarea:focus') + const composition = textarea?.parentElement?.querySelector( + '.composition-view.active' + ) + return composition?.textContent?.replaceAll('‎', '') ?? null + }) +} + +export async function installChordProbe(page: Page): Promise { + await page.evaluate(() => { + type ProbeWindow = Window & { __chordKeyupProbe?: { rows: unknown[]; dispose: () => void } } + const probeWindow = window as ProbeWindow + probeWindow.__chordKeyupProbe?.dispose() + + const textarea = document.querySelector('.xterm-helper-textarea:focus') + if (!textarea) { + throw new Error('no focused xterm helper textarea') + } + const rows: unknown[] = [] + const describe = (node: EventTarget | null): string | null => + node instanceof Element ? `${node.tagName}.${node.className}` : null + + const pushKey = (at: string, event: KeyboardEvent): void => { + rows.push({ + at, + t: event.type, + key: event.key, + code: event.code, + keyCode: event.keyCode, + isComposing: event.isComposing, + meta: event.metaKey, + alt: event.altKey, + defaultPrevented: event.defaultPrevented, + // Why these three: a release that never reaches the renderer and one that reaches a + // different target look identical from a single listener. + hasFocus: document.hasFocus(), + activeElement: describe(document.activeElement), + target: describe(event.target), + textareaConnected: textarea.isConnected, + ts: Math.round(performance.now()) + }) + } + + const positions: [string, EventTarget, boolean][] = [ + ['window-capture', window, true], + ['document-capture', document, true], + ['textarea-capture', textarea, true], + ['window-bubble', window, false] + ] + const keyListeners: (() => void)[] = [] + for (const [name, target, capture] of positions) { + for (const type of ['keydown', 'keyup']) { + const listener = (event: Event): void => pushKey(name, event as KeyboardEvent) + target.addEventListener(type, listener, capture) + keyListeners.push(() => target.removeEventListener(type, listener, capture)) + } + } + + // Focus loss between press and release would route the keyup out of this window entirely. + const focusListeners: (() => void)[] = [] + for (const [name, target, type] of [ + ['window', window, 'blur'], + ['window', window, 'focus'], + ['textarea', textarea, 'blur'], + ['textarea', textarea, 'focus'], + ['document', document, 'visibilitychange'] + ] as [string, EventTarget, string][]) { + const listener = (): void => { + rows.push({ + at: name, + t: type, + hasFocus: document.hasFocus(), + activeElement: describe(document.activeElement), + ts: Math.round(performance.now()) + }) + } + target.addEventListener(type, listener, true) + focusListeners.push(() => target.removeEventListener(type, listener, true)) + } + + for (const type of ['compositionstart', 'compositionupdate', 'compositionend', 'input']) { + const listener = (event: Event): void => { + rows.push({ + at: 'textarea', + t: event.type, + data: (event as CompositionEvent).data ?? null, + value: textarea.value, + ts: Math.round(performance.now()) + }) + } + textarea.addEventListener(type, listener, true) + focusListeners.push(() => textarea.removeEventListener(type, listener, true)) + } + + probeWindow.__chordKeyupProbe = { + rows, + dispose: () => { + for (const off of [...keyListeners, ...focusListeners]) { + off() + } + } + } + }) +} + +export function readChordProbe(page: Page): Promise { + return page.evaluate(() => { + const probe = (window as Window & { __chordKeyupProbe?: { rows: unknown[] } }).__chordKeyupProbe + if (!probe) { + throw new Error('chord probe was never installed') + } + return [...probe.rows] + }) +} diff --git a/tests/e2e/terminal-macos-chord-input-pipeline-probe.spec.ts b/tests/e2e/terminal-macos-chord-input-pipeline-probe.spec.ts new file mode 100644 index 00000000000..9aeb9cb4d4e --- /dev/null +++ b/tests/e2e/terminal-macos-chord-input-pipeline-probe.spec.ts @@ -0,0 +1,279 @@ +// Hand-run probe: needs real macOS input sources, so no workflow calls it. It records the +// traces replayed by keyboard-handlers.issue-12871-command-release-traces.ts. +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + disposeMainProcessInputProbe, + installMainProcessInputProbe, + readMainProcessInputProbe +} from './main-process-input-event-probe' +import { + ABC_ID, + bounceFocus, + enableInputSource, + KEY, + KOTOERI_ROMAJI_ID, + KOTOERI_ROMAJI_PARENT_ID, + pressChord, + pressChordWithSeparateModifier, + selectInputSource, + TWO_SET_KOREAN_ID, + typeKeyCodes +} from './macos-input-source-driver' +import { + installChordProbe, + readActiveComposition, + readChordProbe +} from './renderer-chord-event-probe' +import { + createTerminalImeByteReader, + removeTerminalImeByteReader, + startTerminalImeByteReader, + waitForTerminalImeBytes +} from './terminal-ime-byte-reader' + +/** + * #12871 capture, not a gate. The renderer probe established that a Cmd chord's keyup never + * reaches the page. It cannot say why. This records the same gesture one layer lower, at + * Chromium's own input dispatch, so three candidates separate: the release never leaves the OS, + * Orca's main-process handler consumes it, or it is dropped between Chromium and the page. + * + * The modifier's own release is recorded too, since a Meta keyup at this layer is what a + * modifier-release-driven recovery would have to key off. + */ + +const EVIDENCE_DIR = path.join(process.cwd(), 'test-results', 'terminal-ime-evidence') + +function writeEvidence(name: string, value: unknown): void { + mkdirSync(EVIDENCE_DIR, { recursive: true }) + writeFileSync(path.join(EVIDENCE_DIR, name), `${JSON.stringify(value, null, 1)}\n`) +} + +async function openFocusedTerminal(page: Page): Promise { + await waitForActiveWorktree(page) + await waitForSessionReady(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page) + await waitForActivePanePtyId(page) + await focusActiveTerminalInput(page) +} + +function requireProcessId(electronApp: ElectronApplication): number { + const processId = electronApp.process().pid + if (processId === undefined) { + throw new Error('Electron process id unavailable') + } + return processId +} + +test.describe('macOS chord input pipeline probe @headful', () => { + test.skip( + process.platform !== 'darwin' || process.env.ORCA_E2E_NATIVE_MACOS_KOREAN !== '1', + 'Requires macOS with native IME access and Accessibility permission' + ) + + test.afterEach(async ({ electronApp }) => { + await disposeMainProcessInputProbe(electronApp).catch(() => {}) + selectInputSource(TWO_SET_KOREAN_ID) + }) + + test('ABC control: which layer still has the Cmd+Left release', async ({ + electronApp, + orcaPage + }) => { + const processId = requireProcessId(electronApp) + await openFocusedTerminal(orcaPage) + + selectInputSource(ABC_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + + await installMainProcessInputProbe(electronApp) + await installChordProbe(orcaPage) + pressChord(processId, KEY.left, 'command') + await orcaPage.waitForTimeout(800) + pressChord(processId, KEY.left, 'option') + await orcaPage.waitForTimeout(800) + pressChord(processId, KEY.left) + await orcaPage.waitForTimeout(800) + + const mainRows = await readMainProcessInputProbe(electronApp) + const rendererRows = await readChordProbe(orcaPage) + writeEvidence('abc-chord-pipeline-main.json', mainRows) + writeEvidence('abc-chord-pipeline-renderer.json', rendererRows) + console.log('ABC_MAIN_ROWS', JSON.stringify(mainRows)) + + expect(mainRows.length).toBeGreaterThan(0) + }) + + test('Kotoeri: which layer still has the Cmd+Left release while さ is composing', async ({ + electronApp, + orcaPage + }) => { + const processId = requireProcessId(electronApp) + await openFocusedTerminal(orcaPage) + + enableInputSource(KOTOERI_ROMAJI_PARENT_ID) + enableInputSource(KOTOERI_ROMAJI_ID) + selectInputSource(KOTOERI_ROMAJI_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + + typeKeyCodes(processId, [KEY.s, KEY.a]) + try { + await expect + .poll(() => readActiveComposition(orcaPage), { timeout: 6_000 }) + .toMatch(/[ぁ-ん]/) + } catch { + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + typeKeyCodes(processId, [KEY.s, KEY.a]) + await expect + .poll(() => readActiveComposition(orcaPage), { timeout: 10_000 }) + .toMatch(/[ぁ-ん]/) + } + + await installMainProcessInputProbe(electronApp) + await installChordProbe(orcaPage) + pressChord(processId, KEY.left, 'command') + await orcaPage.waitForTimeout(1_500) + // A bare arrow afterwards shows the window still routes keys here at all. + pressChord(processId, KEY.left) + await orcaPage.waitForTimeout(800) + + const mainRows = await readMainProcessInputProbe(electronApp) + const rendererRows = await readChordProbe(orcaPage) + writeEvidence('kotoeri-chord-pipeline-main.json', mainRows) + writeEvidence('kotoeri-chord-pipeline-renderer.json', rendererRows) + console.log('KOTOERI_MAIN_ROWS', JSON.stringify(mainRows)) + + typeKeyCodes(processId, [KEY.backspace, KEY.backspace]) + expect(mainRows.length).toBeGreaterThan(0) + }) + + test('Command released as its own key: is that release delivered', async ({ + electronApp, + orcaPage + }) => { + const processId = requireProcessId(electronApp) + await openFocusedTerminal(orcaPage) + + enableInputSource(KOTOERI_ROMAJI_PARENT_ID) + enableInputSource(KOTOERI_ROMAJI_ID) + selectInputSource(KOTOERI_ROMAJI_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + + typeKeyCodes(processId, [KEY.s, KEY.a]) + await expect.poll(() => readActiveComposition(orcaPage), { timeout: 10_000 }).toMatch(/[ぁ-ん]/) + + await installMainProcessInputProbe(electronApp) + await installChordProbe(orcaPage) + pressChordWithSeparateModifier(processId, KEY.left, 'command') + await orcaPage.waitForTimeout(1_500) + + const mainRows = await readMainProcessInputProbe(electronApp) + const rendererRows = await readChordProbe(orcaPage) + writeEvidence('kotoeri-separate-modifier-main.json', mainRows) + writeEvidence('kotoeri-separate-modifier-renderer.json', rendererRows) + console.log('SEPARATE_MODIFIER_MAIN_ROWS', JSON.stringify(mainRows)) + + typeKeyCodes(processId, [KEY.backspace, KEY.backspace]) + expect(mainRows.length).toBeGreaterThan(0) + }) + + // Why Korean matters here: it commits on the chord and the platform replays the chord unmarked. + // Keying recovery off the Command release would fire twice if that release still reported a + // live composition, so the ordering of compositionend against it is what makes it safe. + test('Korean: where compositionend falls against the Command release', async ({ + electronApp, + orcaPage + }) => { + const processId = requireProcessId(electronApp) + await openFocusedTerminal(orcaPage) + + selectInputSource(TWO_SET_KOREAN_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + + typeKeyCodes(processId, [KEY.s]) + await expect + .poll(() => readActiveComposition(orcaPage), { timeout: 10_000 }) + .toMatch(/[ㄱ-ㅣ가-힣]/) + + await installMainProcessInputProbe(electronApp) + await installChordProbe(orcaPage) + pressChordWithSeparateModifier(processId, KEY.left, 'command') + await orcaPage.waitForTimeout(1_500) + + const mainRows = await readMainProcessInputProbe(electronApp) + const rendererRows = await readChordProbe(orcaPage) + writeEvidence('korean-separate-modifier-main.json', mainRows) + writeEvidence('korean-separate-modifier-renderer.json', rendererRows) + console.log('KOREAN_MAIN_ROWS', JSON.stringify(mainRows)) + + typeKeyCodes(processId, [KEY.backspace, KEY.backspace]) + expect(rendererRows.length).toBeGreaterThan(0) + }) + + // The one assertion in this file rather than a recording: the release-keyed recovery has to + // reach the shell, not merely resolve. Driven as a hand types it, so the Command release the + // recovery keys off actually exists. + test('Kotoeri + Cmd+Left reaches the pty as さ then line start', async ({ + electronApp, + orcaPage, + testRepoPath + }) => { + const processId = requireProcessId(electronApp) + await waitForActiveWorktree(orcaPage) + await waitForSessionReady(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + await focusActiveTerminalInput(orcaPage) + + enableInputSource(KOTOERI_ROMAJI_PARENT_ID) + enableInputSource(KOTOERI_ROMAJI_ID) + selectInputSource(KOTOERI_ROMAJI_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + + const reader = createTerminalImeByteReader(testRepoPath, 1) + await startTerminalImeByteReader(orcaPage, ptyId, reader) + await focusActiveTerminalInput(orcaPage) + try { + typeKeyCodes(processId, [KEY.s, KEY.a]) + await expect + .poll(() => readActiveComposition(orcaPage), { timeout: 10_000 }) + .toMatch(/[ぁ-ん]/) + + pressChordWithSeparateModifier(processId, KEY.left, 'command') + await orcaPage.waitForTimeout(1_200) + + // A live preedit eats the first Return; the second flushes the line. + pressChord(processId, KEY.returnKey) + let bytes = await waitForTerminalImeBytes(orcaPage, reader, 5_000).catch(() => []) + if (bytes.length === 0) { + pressChord(processId, KEY.returnKey) + bytes = await waitForTerminalImeBytes(orcaPage, reader, 10_000).catch(() => []) + } + writeEvidence('kotoeri-command-release-pty.json', bytes) + console.log('KOTOERI_COMMAND_PTY', JSON.stringify(bytes)) + + // さ then \x01, in that order: xterm queues the chord behind the preedit and flushes both + // on commit, so the chord cannot land ahead of the text being composed. + expect(bytes.join('')).toContain('e3819501') + } finally { + removeTerminalImeByteReader(reader) + selectInputSource(TWO_SET_KOREAN_ID) + } + }) +}) diff --git a/tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts b/tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts new file mode 100644 index 00000000000..d976931f9e4 --- /dev/null +++ b/tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts @@ -0,0 +1,363 @@ +// Hand-run: needs a real macOS input source, so no workflow calls it. A red run here is a +// local environment or an IME behaviour change, not necessarily a regression. +import { execFileSync } from 'node:child_process' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { pressChordWithSeparateModifier } from './macos-input-source-driver' +import { + focusActiveTerminalInput, + getTerminalContent, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + createTerminalImeByteReader, + removeTerminalImeByteReader, + startTerminalImeByteReader, + waitForTerminalImeBytes, + type TerminalImeByteReader +} from './terminal-ime-byte-reader' + +/** + * #12871 — cursor chords during a live IME composition, asserted at the PTY, with the real OS + * input methods deciding everything. The unit layer replays recorded traces; this spec is the + * layer those traces came from, so a platform change (macOS redispatch timing, Kotoeri chord + * swallowing) fails here first. + * + * What the recorded evidence established (in-app traces of 2026-08-08 at commit d64ccc71b, and + * the independent bare-page recording on #12732): + * - 2-Set Korean commits the composing syllable ON the chord; the PTY must see the syllable + * strictly before the movement byte, exactly once each. This held on main and must survive + * the #12732 exemption+ledger (whose failure mode is the byte twice). + * - Kotoeri swallows the chord: the preedit must survive the press. On main the chord byte + * then never reached the shell at all (the defect half of #12871); with #12732's exemption + * the byte queues behind the preedit and lands right after the commit. The Kotoeri byte + * expectation below therefore REQUIRES the fix — on a pre-fix build this test fails on the + * missing \x01, which is exactly the regression it exists to catch. + * - ABC control: no IME anywhere, movement bytes flow alone. + * + * Run-validity guards follow terminal-macos-korean-chord-commit-native.spec.ts: the selected + * input source is read back, and a composition must actually form before any target key is + * pressed, so a run where the IME never engaged is void rather than green. + */ + +const TWO_SET_KOREAN_ID = 'com.apple.inputmethod.Korean.2SetKorean' +const KOTOERI_ROMAJI_ID = 'com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese' +// Selecting a Kotoeri MODE fails with paramErr (-50) while its parent input method is disabled, +// and the parent has a different InputSourceID, so select-input-source.swift's own +// enable-everything-for-this-id pass cannot reach it. Enable the parent first. +const KOTOERI_ROMAJI_PARENT_ID = 'com.apple.inputmethod.Kotoeri.RomajiTyping' +const ABC_ID = 'com.apple.keylayout.ABC' +const SELECT_INPUT_SOURCE = path.resolve(__dirname, 'select-input-source.swift') + +const KEY = { + left: 123, + right: 124, + return: 36, + backspace: 51 +} as const + +function selectInputSource(id: string): void { + execFileSync('swift', [SELECT_INPUT_SOURCE, id]) +} + +/** Enable (without selecting) every TIS entry whose InputSourceID matches `id`. */ +function enableInputSource(id: string): void { + const source = ` +import Carbon +let properties = [kTISPropertyInputSourceID: ${JSON.stringify(id)} as CFString] as CFDictionary +let sources = TISCreateInputSourceList(properties, true).takeRetainedValue() as! [TISInputSource] +guard !sources.isEmpty else { exit(3) } +for candidate in sources { TISEnableInputSource(candidate) } +exit(0) +` + execFileSync('swift', ['-'], { input: source }) +} + +function focusApp(processId: number): void { + execFileSync('osascript', [ + '-e', + `tell application "System Events" to set frontmost of first application process whose unix id is ${processId} to true`, + '-e', + 'delay 0.3' + ]) +} + +/** Bounce focus away and back so the app's input context re-reads the selected input source. */ +function bounceFocus(processId: number): void { + execFileSync('osascript', ['-e', 'tell application "Finder" to activate', '-e', 'delay 0.4']) + focusApp(processId) +} + +/** Type key codes one at a time with a delay so the IME engages on every keystroke. */ +function typeKeyCodes(processId: number, keyCodes: readonly number[]): void { + focusApp(processId) + execFileSync('osascript', [ + '-e', + 'tell application "System Events"', + '-e', + `repeat with currentKeyCode in {${keyCodes.join(', ')}}`, + '-e', + 'key code (currentKeyCode as integer)', + '-e', + 'delay 0.12', + '-e', + 'end repeat', + '-e', + 'end tell' + ]) +} + +/** + * The chord itself, pressed by the OS so the IME decides how to resolve it. A modifier goes as + * its own key event rather than folded into the target key's flags, because macOS delivers no + * keyup for a key released while Command is held: the modifier's release is the only end that + * gesture has, and `using command down` never produces one. + */ +function pressChord(processId: number, keyCode: number, modifier?: 'command' | 'option'): void { + if (modifier) { + pressChordWithSeparateModifier(processId, keyCode, modifier) + return + } + focusApp(processId) + execFileSync('osascript', ['-e', `tell application "System Events" to key code ${keyCode}`]) +} + +function readActiveComposition(page: Page): Promise { + // The macOS preedit lives in xterm's `.composition-view`, not in the helper textarea value; + // null distinguishes an absent composition from an empty one. + return page.evaluate(() => { + const textarea = document.querySelector('.xterm-helper-textarea:focus') + const composition = textarea?.parentElement?.querySelector( + '.composition-view.active' + ) + return composition?.textContent?.replaceAll('\u200e', '') ?? null + }) +} + +function readInputSourceId(page: Page): Promise { + return page.evaluate(() => window.api.app.getKeyboardInputSourceId()) +} + +/** + * Run-validity warmup: compose 가, confirm real Hangul preedit, then erase it jamo-by-jamo so + * nothing commits. Kills the first-keystroke engagement race the plain 2set spec flakes on. + * + * Measured on this rig: when the app launches while another layout is still selected, the + * freshly focused input context can miss the switch entirely and the first keystrokes type + * literal r/k. Erase whatever landed (preedit jamo or literal characters — backspace edits + * either buffer without reaching the recorded line), bounce focus so the context re-reads the + * source, and prove engagement again — the same dance the Kotoeri path always needed. + */ +async function warmUpKoreanComposition(page: Page, processId: number): Promise { + typeKeyCodes(processId, [15, 40]) + try { + await expect.poll(() => readActiveComposition(page), { timeout: 6_000 }).toMatch(/[가-힣ㄱ-ㅣ]/) + } catch { + typeKeyCodes(processId, [KEY.backspace, KEY.backspace]) + bounceFocus(processId) + await focusActiveTerminalInput(page) + typeKeyCodes(processId, [15, 40]) + await expect + .poll(() => readActiveComposition(page), { timeout: 10_000 }) + .toMatch(/[가-힣ㄱ-ㅣ]/) + } + typeKeyCodes(processId, [KEY.backspace, KEY.backspace]) + await expect.poll(() => readActiveComposition(page), { timeout: 10_000 }).toBeNull() +} + +/** Press Return to flush the pending line into the byte reader; a live preedit eats the first. */ +async function flushLineToReader( + page: Page, + processId: number, + reader: TerminalImeByteReader +): Promise { + pressChord(processId, KEY.return) + try { + return await waitForTerminalImeBytes(page, reader, 5_000) + } catch { + pressChord(processId, KEY.return) + return waitForTerminalImeBytes(page, reader, 10_000) + } +} + +type SetupResult = { + ptyId: string + reader: TerminalImeByteReader +} + +async function setUpTerminalWithReader( + page: Page, + testRepoPath: string, + processId: number, + warmUpKorean: boolean +): Promise { + await waitForActiveWorktree(page) + await waitForSessionReady(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page) + const ptyId = await waitForActivePanePtyId(page) + await focusActiveTerminalInput(page) + if (warmUpKorean) { + selectInputSource(TWO_SET_KOREAN_ID) + // The app is already focused, so its input context needs the bounce to adopt the switch — + // without it the first run of a session composes nothing (the machine sat on another + // layout) while later runs pass because afterEach left Korean selected before launch. + bounceFocus(processId) + await focusActiveTerminalInput(page) + await expect.poll(() => readInputSourceId(page), { timeout: 10_000 }).toBe(TWO_SET_KOREAN_ID) + await warmUpKoreanComposition(page, processId) + } + const reader = createTerminalImeByteReader(testRepoPath, 1) + await startTerminalImeByteReader(page, ptyId, reader) + await focusActiveTerminalInput(page) + return { ptyId, reader } +} + +test.describe('Native macOS IME cursor chords during composition @headful', () => { + test.skip( + process.platform !== 'darwin' || process.env.ORCA_E2E_NATIVE_MACOS_KOREAN !== '1', + 'Requires macOS with native IME access and Accessibility permission' + ) + + test.afterEach(() => { + // Leave the machine on Korean 2-Set no matter which source a scenario selected. + selectInputSource(TWO_SET_KOREAN_ID) + }) + + // The tty converts the trailing CR to LF, so a line arriving with \n is the standing proof + // the capture reached past the renderer (#11936/#11951). + for (const chord of [ + { name: 'Cmd+Left', modifier: 'command' as const, pty: '하\x01\n' }, + { name: 'Option+Left', modifier: 'option' as const, pty: '하\x1bb\n' } + ]) { + test(`Korean 2-Set: ${chord.name} mid-syllable commits 하 before the movement byte`, async ({ + electronApp, + orcaPage, + testRepoPath + }) => { + const processId = electronApp.process().pid + if (processId === undefined) { + throw new Error('Electron process id unavailable') + } + const setup = await setUpTerminalWithReader(orcaPage, testRepoPath, processId, true) + try { + // ㅎ(5) + ㅏ(40) → 하, still composing. The poll doubles as the positive control. + typeKeyCodes(processId, [5, 40]) + await expect.poll(() => readActiveComposition(orcaPage), { timeout: 10_000 }).toBe('하') + + pressChord(processId, KEY.left, chord.modifier) + // Recorded: 2-Set Korean answers the chord with compositionend — the preedit must be + // gone before any flush. A surviving preedit here would eat the Return below and turn + // the byte assertion into a different scenario's evidence. + await expect.poll(() => readActiveComposition(orcaPage), { timeout: 10_000 }).toBeNull() + + // Exactly the recorded line: the syllable strictly before its movement byte, one of + // each. The double-fire failure mode (#12732's naive exemption) would append a second + // movement byte; a reordering regression would put it before 하. + expect(await flushLineToReader(orcaPage, processId, setup.reader)).toEqual([ + Buffer.from(chord.pty).toString('hex') + ]) + expect(await getTerminalContent(orcaPage, 100_000)).toContain('하') + } finally { + removeTerminalImeByteReader(setup.reader) + } + }) + } + + test('Kotoeri Romaji: Cmd+Left leaves the さ preedit live; its byte lands after the commit', async ({ + electronApp, + orcaPage, + testRepoPath + }) => { + const processId = electronApp.process().pid + if (processId === undefined) { + throw new Error('Electron process id unavailable') + } + // Korean warmup first proves the rig composes at all before the source switch. + const setup = await setUpTerminalWithReader(orcaPage, testRepoPath, processId, true) + try { + enableInputSource(KOTOERI_ROMAJI_PARENT_ID) + enableInputSource(KOTOERI_ROMAJI_ID) + selectInputSource(KOTOERI_ROMAJI_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + // Selecting the RomajiTyping mode succeeds, but TIS reports the current source under the + // legacy mode id 'com.apple.inputmethod.Japanese'. + await expect + .poll(() => readInputSourceId(orcaPage), { timeout: 10_000 }) + .toMatch(/^com\.apple\.inputmethod\.(Kotoeri\.RomajiTyping\.)?Japanese$/) + + // s(1) + a(0) → さ in the preedit. Bounce timing can swallow the first key; one retry. + typeKeyCodes(processId, [1, 0]) + try { + await expect + .poll(() => readActiveComposition(orcaPage), { timeout: 6_000 }) + .toMatch(/[ぁ-ん]/) + } catch { + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + typeKeyCodes(processId, [1, 0]) + await expect + .poll(() => readActiveComposition(orcaPage), { timeout: 10_000 }) + .toMatch(/[ぁ-ん]/) + } + + pressChord(processId, KEY.left, 'command') + // Recorded: Kotoeri swallows the chord — no commit, no composition event. The preedit + // surviving the press is the half of #12871 that held on main and must keep holding. + await orcaPage.waitForTimeout(700) + await expect.poll(() => readActiveComposition(orcaPage)).toMatch(/[ぁ-ん]/) + + // The Return commits さ and macOS redispatches it unmarked, which also flushes the line. + // Byte order pins the #12732 exemption end to end: the chord's \x01 was queued behind + // the preedit and must drain right after the commit — never before it, and exactly once. + // On a pre-fix build this line arrives as さ\n (the byte silently dropped), which is the + // #12871 defect this spec exists to keep fixed. + expect(await flushLineToReader(orcaPage, processId, setup.reader)).toEqual([ + Buffer.from('さ\x01\n').toString('hex') + ]) + expect(await getTerminalContent(orcaPage, 100_000)).toContain('さ') + } finally { + removeTerminalImeByteReader(setup.reader) + selectInputSource(TWO_SET_KOREAN_ID) + } + }) + + test('ABC control: the same chords with no IME flow alone', async ({ + electronApp, + orcaPage, + testRepoPath + }) => { + const processId = electronApp.process().pid + if (processId === undefined) { + throw new Error('Electron process id unavailable') + } + const setup = await setUpTerminalWithReader(orcaPage, testRepoPath, processId, false) + try { + selectInputSource(ABC_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + await expect.poll(() => readInputSourceId(orcaPage), { timeout: 10_000 }).toBe(ABC_ID) + + typeKeyCodes(processId, [0, 11, 8]) // a b c + await expect + .poll(() => getTerminalContent(orcaPage, 100_000), { timeout: 10_000 }) + .toContain('abc') + + pressChord(processId, KEY.left, 'command') + pressChord(processId, KEY.left, 'option') + + // The genuine non-IME control per the composition rulebook: with no composition anywhere + // the movement bytes flow immediately and alone, in press order. + expect(await flushLineToReader(orcaPage, processId, setup.reader)).toEqual([ + Buffer.from('abc\x01\x1bb\n').toString('hex') + ]) + } finally { + removeTerminalImeByteReader(setup.reader) + selectInputSource(TWO_SET_KOREAN_ID) + } + }) +}) diff --git a/tests/e2e/terminal-macos-kotoeri-chord-keyup-probe.spec.ts b/tests/e2e/terminal-macos-kotoeri-chord-keyup-probe.spec.ts new file mode 100644 index 00000000000..c49d5e331bf --- /dev/null +++ b/tests/e2e/terminal-macos-kotoeri-chord-keyup-probe.spec.ts @@ -0,0 +1,214 @@ +// Hand-run probe: needs Kotoeri installed on a real macOS host, so no workflow calls it. +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + ABC_ID, + bounceFocus, + enableInputSource, + KEY, + KOTOERI_ROMAJI_ID, + KOTOERI_ROMAJI_PARENT_ID, + pressChord, + selectInputSource, + TWO_SET_KOREAN_ID, + typeKeyCodes +} from './macos-input-source-driver' +import { + installChordProbe, + readActiveComposition, + readChordProbe +} from './renderer-chord-event-probe' +import { + createTerminalImeByteReader, + removeTerminalImeByteReader, + startTerminalImeByteReader, + waitForTerminalImeBytes +} from './terminal-ime-byte-reader' + +/** + * #12871 capture, not a gate. The bare-page recording of the same input source and the same + * preedit delivers the chord's keyup at every listener position, still composing; in-app the + * recovery never fires. This spec records where the release goes instead — at four positions, + * with focus ownership and the live target sampled on every row, so "never dispatched" can be + * told apart from "dispatched somewhere else". + */ + +test.describe('Kotoeri chord keyup probe @headful', () => { + test.skip( + process.platform !== 'darwin' || process.env.ORCA_E2E_NATIVE_MACOS_KOREAN !== '1', + 'Requires macOS with native IME access and Accessibility permission' + ) + + test.afterEach(() => { + selectInputSource(TWO_SET_KOREAN_ID) + }) + + test('records where the Cmd+Left release goes while さ is composing', async ({ + electronApp, + orcaPage + }) => { + const processId = electronApp.process().pid + if (processId === undefined) { + throw new Error('Electron process id unavailable') + } + await waitForActiveWorktree(orcaPage) + await waitForSessionReady(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + await focusActiveTerminalInput(orcaPage) + + enableInputSource(KOTOERI_ROMAJI_PARENT_ID) + enableInputSource(KOTOERI_ROMAJI_ID) + selectInputSource(KOTOERI_ROMAJI_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + + typeKeyCodes(processId, [1, 0]) // s, a -> さ + try { + await expect + .poll(() => readActiveComposition(orcaPage), { timeout: 6_000 }) + .toMatch(/[ぁ-ん]/) + } catch { + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + typeKeyCodes(processId, [1, 0]) + await expect + .poll(() => readActiveComposition(orcaPage), { timeout: 10_000 }) + .toMatch(/[ぁ-ん]/) + } + + await installChordProbe(orcaPage) + pressChord(processId, KEY.left, 'command') + await orcaPage.waitForTimeout(1_500) + // A bare arrow afterwards shows whether the window still routes keys here at all. + pressChord(processId, KEY.left) + await orcaPage.waitForTimeout(800) + + const rows = await readChordProbe(orcaPage) + const body = `${JSON.stringify(rows, null, 1)}\n` + const dir = path.join(process.cwd(), 'test-results', 'terminal-ime-evidence') + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, 'kotoeri-chord-keyup-probe.json'), body) + console.log('CHORD_PROBE_ROWS', body) + + typeKeyCodes(processId, [KEY.backspace, KEY.backspace]) + expect(rows.length).toBeGreaterThan(0) + }) + + test('ABC control: whether a Cmd release arrives at all with no IME present', async ({ + electronApp, + orcaPage + }) => { + const processId = electronApp.process().pid + if (processId === undefined) { + throw new Error('Electron process id unavailable') + } + await waitForActiveWorktree(orcaPage) + await waitForSessionReady(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + await focusActiveTerminalInput(orcaPage) + + selectInputSource(ABC_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + + await installChordProbe(orcaPage) + pressChord(processId, KEY.left, 'command') + await orcaPage.waitForTimeout(800) + pressChord(processId, KEY.left, 'option') + await orcaPage.waitForTimeout(800) + pressChord(processId, KEY.left) + await orcaPage.waitForTimeout(800) + + const rows = await readChordProbe(orcaPage) + const body = `${JSON.stringify(rows, null, 1)}\n` + const dir = path.join(process.cwd(), 'test-results', 'terminal-ime-evidence') + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, 'abc-chord-keyup-probe.json'), body) + expect(rows.length).toBeGreaterThan(0) + }) + + test('Kotoeri + Option+Left: does the release-driven recovery reach the pty', async ({ + electronApp, + orcaPage, + testRepoPath + }) => { + const processId = electronApp.process().pid + if (processId === undefined) { + throw new Error('Electron process id unavailable') + } + await waitForActiveWorktree(orcaPage) + await waitForSessionReady(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + await focusActiveTerminalInput(orcaPage) + + enableInputSource(KOTOERI_ROMAJI_PARENT_ID) + enableInputSource(KOTOERI_ROMAJI_ID) + selectInputSource(KOTOERI_ROMAJI_ID) + bounceFocus(processId) + await focusActiveTerminalInput(orcaPage) + + const reader = createTerminalImeByteReader(testRepoPath, 1) + await startTerminalImeByteReader(orcaPage, ptyId, reader) + await focusActiveTerminalInput(orcaPage) + try { + typeKeyCodes(processId, [1, 0]) + await expect + .poll(() => readActiveComposition(orcaPage), { timeout: 10_000 }) + .toMatch(/[\u3041-\u3093]/) + + await installChordProbe(orcaPage) + pressChord(processId, KEY.left, 'option') + await orcaPage.waitForTimeout(1_200) + + const rows = await readChordProbe(orcaPage) + mkdirSync(path.join(process.cwd(), 'test-results', 'terminal-ime-evidence'), { + recursive: true + }) + writeFileSync( + path.join( + process.cwd(), + 'test-results', + 'terminal-ime-evidence', + 'kotoeri-option-probe.json' + ), + `${JSON.stringify(rows, null, 1)}\n` + ) + + // A live preedit eats the first Return; the second one flushes the line (same as the + // contributor's flushLineToReader). + pressChord(processId, 36) + let bytes = await waitForTerminalImeBytes(orcaPage, reader, 5_000).catch(() => []) + if (bytes.length === 0) { + pressChord(processId, 36) + bytes = await waitForTerminalImeBytes(orcaPage, reader, 10_000).catch(() => []) + } + console.log('KOTOERI_OPTION_PTY', JSON.stringify(bytes)) + writeFileSync( + path.join( + process.cwd(), + 'test-results', + 'terminal-ime-evidence', + 'kotoeri-option-pty.json' + ), + `${JSON.stringify(bytes)}\n` + ) + expect(rows.length).toBeGreaterThan(0) + } finally { + removeTerminalImeByteReader(reader) + selectInputSource(TWO_SET_KOREAN_ID) + } + }) +})