Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ describe('a cursor chord pressed during a composition', () => {
vi.restoreAllMocks()
})

// The gesture runs to its release because a chord the IME swallowed is only resolvable there:
// on the keydown, Korean's committing source and Japanese's swallowing one are byte-identical,
// and acting on both would fire Korean's twice once the platform replays it. Recorded on macOS
// 26.5.1: `Cmd+←` delivers no arrow keyup at all, so the Command release ends it.
function pressCmdArrowLeft(
harness: ReturnType<typeof createHarness>,
isComposing: boolean
Expand All @@ -184,6 +188,9 @@ describe('a cursor chord pressed during a composition', () => {
isComposing
})
)
harness.terminalInput.dispatchEvent(
keyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing })
)
}

// The Korean 2-Set shape: the platform replays the chord unmarked after keyup, so `isComposing`
Expand All @@ -204,7 +211,11 @@ describe('a cursor chord pressed during a composition', () => {
harness.dispose()
})

// The Japanese shape: still marked composing when the chord is resolved.
// The Japanese shape: still marked composing when the chord is resolved. The preedit spans
// several characters because that is the reported symptom — a relocated multi-character preedit
// also overwrote the glyph already at the destination cell. Only the order is asserted, though:
// shorten 日本語 to one character and this still passes, because where the text lands on the grid
// is not something this harness can see.
it('holds the chord while the keydown is still marked composing', () => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
Expand Down Expand Up @@ -247,6 +258,107 @@ describe('a cursor chord pressed during a composition', () => {
harness.dispose()
})

/**
* The Korean 2-Set gesture as recorded: marked press, commit, unmarked release, then the
* platform's replay of the chord.
*
* What the cases below pin is the count, not the route. The byte comes from the replay alone —
* drop the marked press from these rows and they still pass, because there is then nothing that
* could have fired a second time. Drop the arming instead and they fail with the byte twice,
* which is #12871's Korean half. So read them as "the remembered press adds nothing on top of
* the replay", and read the recorded-trace fixtures for which event carries the byte.
*
* The arrow keyup here is under a held Command, which the Korean capture does contain — the
* missing-keyup finding is about the sources that swallow the chord, where the IME consumed the
* key and only the Command release ends the gesture.
*/
function playCommittingChord(
harness: ReturnType<typeof createHarness>,
chord: { code: string; keyCode: number; mods: KeyboardEventInit }
): void {
const { code, keyCode, mods } = chord
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', { key: code, code, keyCode: 229, isComposing: true, ...mods })
)
harness.terminalInput.dispatchEvent(
keyboardEvent('keyup', { key: code, code, keyCode, ...mods })
)
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', { key: code, code, keyCode, ...mods })
)
}

// With "가나 다라 마바" on the line and "사" still composing, each of these relocated the
// composing syllable to wherever the cursor landed. The Cmd+← cases above reach neither the
// other direction nor Option's word jump, and each byte is a separate resolver branch.
it.each([
{
name: 'Option+ArrowLeft word jump',
code: 'ArrowLeft',
keyCode: 37,
mods: { altKey: true },
sent: '\x1bb'
},
{
name: 'Option+ArrowRight word jump',
code: 'ArrowRight',
keyCode: 39,
mods: { altKey: true },
sent: '\x1bf'
},
{
name: 'Cmd+ArrowRight line-end jump',
code: 'ArrowRight',
keyCode: 39,
mods: { metaKey: true },
sent: '\x05'
}
])('sends a $name once, behind the syllable it committed', ({ code, keyCode, mods, sent }) => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))

harness.startComposition()
playCommittingChord(harness, { code, keyCode, mods })
expect(harness.wire, 'nothing may reach the pty while the syllable is pending').toEqual([])

harness.endComposition('사')
vi.runAllTimers()

expect(harness.wire).toEqual(['사', sent])
hook.unmount()
harness.dispose()
})

// Off macOS the release-keyed recovery never arms, so the chord waits for the commit instead and
// no release takes part at all. Transcribed from the same macOS session as the cases above rather
// than captured on win32: what it pins is the resolver's non-mac branch, not the platform.
it('holds a Windows Ctrl+ArrowLeft word jump behind the composing syllable', () => {
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
)
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))

harness.startComposition()
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', {
key: 'ArrowLeft',
code: 'ArrowLeft',
keyCode: 37,
ctrlKey: true,
isComposing: true
})
)
expect(harness.wire).toEqual([])

harness.endComposition('사')
vi.runAllTimers()

expect(harness.wire).toEqual(['사', '\x1bb'])
hook.unmount()
harness.dispose()
})

// STA-4476: an indefinite wait has no exit of its own. Without a disposer the listeners outlive
// the pane and a later composition on the same element flushes the stale chord.
it('drops the held chord and its listeners when the pane tears down', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Recorded IME chord traces for #12871 from the two Chinese input sources, taken inside a dev e2e
// Orca build (app commit 84517d74b268bca888a44b285627ec82ffde1361, macOS 26.5.2/25F84, darwin
// arm64) at the xterm helper textarea, with PTY-side ground-truth bytes read by a child on the
// pty. The per-case `cn-*.json` names below are the recording files, which live outside this repo
// on stablyai/orca#12732 — unlike the in-app family, whose sibling module carries a SHA-256 per
// file, there is no digest here to check a copy against.
// Replayed by keyboard-handlers.issue-12871-recorded-chord-traces.test.ts through the same
// rig as the other recordings. Unlike the Kotoeri and Korean families, these rows cannot be
// re-recorded from this repo: tests/e2e/terminal-macos-chord-input-pipeline-probe.spec.ts is the
// recorder and was never extended past Japanese, Korean and ABC. The Chinese cases in
// tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts drive the same gestures and assert the
// resulting line end to end, but they check the rows rather than produce them.
//
// These replace an earlier Chinese recording entirely. That one was driven with the modifier
// folded into the target key's flags, which produces no modifier press or release at all, so it
// could not see the Command release these cases turn on — and it predates that release being
// honoured. Nothing from it survives here.
//
// What the three cases establish, measured rather than assumed: Chinese behaves like Kotoeri and
// not like Korean. Both sources swallow the chord and keep composing, and the byte drains at the
// commit. The two modifiers still reach the handler by different routes:
//
// - `Cmd+←` delivers no arrow keyup, at any listener position or at Chromium's own input
// dispatch. The `Cmd` release is the gesture's only end and arrives still marked composing.
// - `Option+←` delivers the arrow's own keyup and resolves through it.
//
// Read these as recordings first and tests second. The Zhuyin `Cmd+←` rows came out byte-identical
// to the Kotoeri ones in keyboard-handlers.issue-12871-command-release-traces.ts — that identity is
// the measurement, and it is why a third input source was worth recording at all. But it also means
// that case cannot fail on its own: replayed here it is the Kotoeri case under another name, and
// only the e2e cell below drives the real Zhuyin source.
//
// A fourth cell, Zhuyin `Option+←`, was recorded and then DELIBERATELY NOT INCLUDED. Under every
// synthesis this rig can produce it commits the composition on the chord, and a human at the same
// keyboard cannot reproduce that — the preedit block stays up for them, as it does for Pinyin and
// Kotoeri. Three timing regimes were tried (a flat 80ms with the modifier as a synthetic key
// event, then the hardware medians 190/110/160ms and 700/400/500ms with the modifier posted as a
// real flagsChanged) and all three committed on the press. Only the first has a harness in this
// tree, tests/e2e/post-modifier-chord.swift; the other two were run out of tree, so that account
// is history rather than something you can re-run here. Rather than freeze a recording no hand
// can reproduce, the cell is left out. Its byte order is still asserted in
// terminal-macos-ime-cursor-chord-native.spec.ts, where it holds under both behaviours — but that
// spec is hand-run behind ORCA_E2E_NATIVE_MACOS_KOREAN and no workflow sets it, so be plain about
// what that leaves: after this file, Zhuyin `Option+←` has no coverage that runs on its own.
// The raw recordings and the human cross-check behind the call are attached to stablyai/orca#12732,
// where this round was measured.
import type { RecordedChordCase } from './keyboard-handlers.issue-12871-in-app-chord-traces'

export const CHINESE_TRACE_CASES: RecordedChordCase[] = [
{
// cn-zhuyin-command.json. Composition view read 你好 before and after the press; captured
// PTY line 你好\x01\n. The rows below stop at the Command release, so what is replayed is
// the gesture alone, without the commit that follows it.
name: 'Traditional Zhuyin, Cmd+ArrowLeft over a live 你好 preedit, ended by the Command release',
expectCalls: ['\x01'],
expectEmitted: ['\x01'],
commitsAfterCapture: '你好',
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, exactly as in the Kotoeri recording.
{ t: 'keyup', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true, meta: false }
]
},
{
// cn-pinyin-nocand-command.json. Captured PTY line nihao\x01\n — Pinyin's Return commits the
// letters rather than the highlighted candidate, so the committed text is ASCII while the
// composition is unmistakably live (the view reads `ni hao`, segmented by the IME).
//
// The composition update between the press and the Command release is why this case earns its
// place next to the Zhuyin one, whose rows carry no composition activity at all: the carry has
// to survive the IME editing its own preedit mid-gesture. The Option case below has the same
// shape, so a recovery that disarmed on composition activity would take both of them down —
// what this one adds is that it happens on the Command route too.
name: 'Simplified Pinyin, Cmd+ArrowLeft over a live ni hao preedit updated mid-gesture',
expectCalls: ['\x01'],
expectEmitted: ['\x01'],
commitsAfterCapture: 'nihao',
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: 'ni hao', value: 'ni hao' },
{ t: 'input', data: 'ni hao', value: 'ni hao' },
{ t: 'keyup', key: 'Meta', code: 'MetaLeft', keyCode: 91, isComposing: true, meta: false }
]
},
{
// cn-pinyin-nocand-option.json. Captured at the boundary as nihao\x1bb. The arrow's own keyup
// arrives here, still marked composing, and spends the carry before the Alt release can —
// the half that already worked, pinned so it fails if it stops.
name: 'Simplified Pinyin, Option+ArrowLeft over a live ni hao preedit, ended by the arrow keyup',
expectCalls: ['\x1bb'],
expectEmitted: ['\x1bb'],
commitsAfterCapture: 'nihao',
rows: [
{ 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: 'ni hao', value: 'ni hao' },
{ t: 'input', data: 'ni hao', value: 'ni hao' },
{
t: 'keyup',
key: 'ArrowLeft',
code: 'ArrowLeft',
keyCode: 37,
isComposing: true,
alt: true
},
{ t: 'keyup', key: 'Alt', code: 'AltLeft', keyCode: 18, isComposing: false, alt: false }
]
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 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 IN-APP recording contains a modifier press or release
// and why the Cmd half of the gesture looked like it had no end. The bare-page recordings in
// keyboard-handlers.issue-12871-recorded-chord-traces.test.ts do carry both.
//
// 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 { RecordedChordCase } from './keyboard-handlers.issue-12871-in-app-chord-traces'

export const COMMAND_RELEASE_TRACE_CASES: RecordedChordCase[] = [
{
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: 'さ',
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 }
]
}
]
Loading