Skip to content

fix(terminal): send a composing cursor chord once, not twice - #12732

Closed
kunsanglee wants to merge 18 commits into
stablyai:mainfrom
kunsanglee:fix/ime-deferred-terminal-shortcut-input
Closed

kunsanglee wants to merge 18 commits into
stablyai:mainfrom
kunsanglee:fix/ime-deferred-terminal-shortcut-input

Conversation

@kunsanglee

@kunsanglee kunsanglee commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #12871. Rebased onto #14730 and cut down to the one commit that is still missing from main.

Option+Left jumps two words instead of one, while a Korean syllable is composing.

Measured against a663f1b, replaying this PR's recorded macOS trace — Korean 2-Set, 사 in the preedit, Option+←:

onData              "사"        <- the syllable commits
transport.sendInput "\x1bb"     <- the marked keydown
transport.sendInput "\x1bb"     <- the platform's replay

\x1bb is one word back, so two of them are two words. Cmd+Left is line-start and therefore idempotent, which is why the same double never shows there.

#14730 fixed the order, and that half is done: the chord no longer overtakes the text it was typed after. It still resolves the chord on the composing keydown, and Korean 2-Set commits on that chord and lets the platform replay it unmarked after keyup. Both copies resolve.

One correction to the close comment: the Japanese half is not still outstanding. Replaying this PR's Kotoeri trace against a663f1b produces exactly one byte, after the commit. Removing the composing-event gate in #13282 is what let the marked keydown through, and #14730's deferral put it in the right place. That case is closed.

What this adds

The two input sources are indistinguishable while the key is down — both recorded on stock macOS, Chrome 151, as code='ArrowLeft', keyCode=229, isComposing=true. Nothing decidable is available at the keydown. By the release they have separated:

  • Korean 2-Set committed the syllable and ended the composition, and the replay is on its way. The release reports isComposing: false. Acting is the double.
  • Japanese conversion swallowed the chord whole — no commit, no replay, still composing at the release. Not acting loses it entirely.

So an exempt chord is remembered on the composing keydown and decided on its release: still composing means nothing else will deliver it, so run the action; not composing means the replay answers. Bytes take #14730's deferral either way, now reached from both paths through one condition instead of two.

"Exempt" is Cmd/Option/Ctrl over ArrowLeft, ArrowRight, Backspace, Delete — never with Shift, which Japanese conversion binds to resize the segment being converted.

Two details that are not obvious and are load-bearing:

  • The snapshot is built field by field. A KeyboardEvent keeps its fields as prototype accessors, so { ...event } is an empty object and the chord silently loses its code. happy-dom keeps them as own properties and would go on passing, so the source comment is the only warning that survives.
  • Cmd+← delivers no arrow keyup at all — recorded at Chromium's own input dispatch, where Option+← and a bare ← both deliver theirs. The Command release is the only event that ends that gesture, and it still reports the composition live.

Also here: read code rather than key for those chords while an IME owns the event, because a CJK source rewrites key to 'Process' (#12171, #13033), and add 'Process' to the keybinding matcher's physical-code fallback beside 'Dead' — same condition, an unreportable produced key.

One test from #14730 changed

keyboard-handlers-ime-composing-chord.test.tsx pressed the chord and never released it. Its press now runs to the release, with the same assertions, because that is where a swallowed chord becomes resolvable and it is what hardware delivers. Worth a look — it is the one place this PR touches someone else's just-landed test.

About the size, and the missing checks

You flagged +2640/−31 with zero checks last time. The checks are structural: a fork branch does not run pull_request workflows in this repo, and I have no push access here, so there is no version of this PR that arrives green. Local results are below.

The line count did not come down much on the rebase, and it is worth saying why rather than trimming to make the number look better. The overlap with #14730 was 14 lines — the deferral itself. The bulk was never duplicated work:

  • 816 lines replay recorded macOS traces through the real handler and xterm;
  • 206 lines are the recordings themselves, one row per real event;
  • 1198 lines are the hand-run macOS harnesses that produced them, which no workflow calls and which need real input sources installed.

The mechanism is 224 lines in keyboard-handlers.ts and 60 in terminal-shortcut-policy.ts. If you would rather the recording harnesses live outside this PR, say so and I will pull them — they are separable, and the trace files reference them only by name.

Verification

src/renderer/src/components/terminal-pane — 270 files, 3513 tests, all passing. src/shared and src/renderer/src/lib — 877 files, 8754 tests, all passing. tsc clean on all three projects. oxlint and oxfmt clean on every file this PR touches. Reliability gate manifest passes for 84 gates; max-lines ratchet OK at 340.

One thing worth knowing for anyone reproducing locally: a git worktree nested inside the main checkout resolves node_modules upward and can serve an xterm patch hash the branch lockfile never asked for. That alone produced 62 failures here that had nothing to do with the code. Install inside the worktree before believing a red IME suite.

Typing "가나" and pressing Cmd+Backspace leaves "나" on the line. "가" has
reached the pty while "나" is still in the preedit, so the line-kill byte goes
out synchronously, erases what the shell has, and only then does xterm flush
"나" from its timer.

xterm guards this in CompositionHelper.keydown by flushing the preedit before a
non-composition key runs, but Orca's window-level capture handler calls
stopImmediatePropagation, so xterm never sees the keydown. The compositionend
path sends the committed glyph from a timer, so it is asynchronous regardless.

keyboard-handlers already defers for this reason, but only for Enter. Widen it
to every sendInput action while a composition is pending. Enter keeps
deferredNewlineSender, which also owns the Windows redispatch ledger; other keys
call sendTerminalInputAfterComposition directly so they do not leave absorb
credits nothing will consume.

Also fixes Cmd+Delete, Cmd+Arrow, Ctrl+Backspace, and the Alt meta sequences,
which shared the same unsequenced path on all platforms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Terminal shortcut policy now normalizes selected IME-owned navigation and editing chords by physical key code. Keyboard handling suppresses non-exempt IME events and recovers swallowed exempt chords on keyup through the shared shortcut-action path. Native-only actions remain excluded. Scoped keyup listeners are registered and cleaned up with the keyboard hook. New tests cover composing chords, recorded Korean and Japanese traces, pane commands, terminal output, replay behavior, and text-field isolation.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation prevents IME-owned modifier chords from moving or overwriting preedit text and adds regression coverage for the reported macOS scenarios [#12871].
Out of Scope Changes check ✅ Passed The code and tests stay within IME-aware terminal shortcut handling and directly support the reported composition bug, with unrelated surfaces explicitly left unchanged.
Title check ✅ Passed The title clearly identifies the terminal IME chord fix and reflects the primary change described in the pull request.
Description check ✅ Passed The description explains the problem, implementation, issue linkage, scope, tests, and verification, although it does not follow every template heading.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ethznn

ethznn commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Heads up — I hit the user-visible side of this on macOS and filed #12871 before finding your PR: with 가나 다라 마바 committed and 사 still composing, Option+← produced 가나 '사'다라 마바 and Cmd+← produced '사'가나 다라 마바 — the composing syllable relocates to wherever the cursor lands. With a Japanese (Kotoeri) preedit the relocated text also overwrote the glyph at the destination cell. Your ordering analysis explains all of it, and I can confirm the chords in your table are exactly the ones that reproduce.

I've opened #12872 as a draft that carries your commit unchanged (359fd2e09, authorship preserved) and adds one test-only commit pinning the branches the current suite doesn't cover: Option+←/→ word jumps, Cmd+→, a multi-character Japanese preedit, and Ctrl+← on Windows. All five fail against the pre-fix handler and pass with your fix.

Not meant to supersede this PR — the fix is yours and I hope it lands as-is. Happy to rebase mine to a test-only follow-up once this merges, or for you to cherry-pick the tests into this branch directly if you'd rather keep it to one PR; equally fine with closing mine on request.

…mposition

With committed text on the line and a syllable still composing, a
cursor-movement chord relocated the composing character to wherever the
cursor landed: Option+Left word-jump turned "가나 다라 마바[사]" into
"가나 [사]다라 마바", and Cmd+Left moved the syllable to the line start.
With a Japanese preedit the relocated text also overwrote the glyph at
the destination cell. Reported with reproduction steps in stablyai#12871.

The ordering fix already covers these chords; this pins the previously
untested resolver branches so the symptom cannot quietly return:

- Option+ArrowLeft / ArrowRight word jumps (\eb / \ef)
- Cmd+ArrowRight line-end jump (\x05)
- a multi-character Japanese preedit committing before the chord byte
- Ctrl+ArrowLeft word jump on Windows (\eb)

Verified non-vacuous: against the pre-fix keyboard-handlers.ts all five
new cases fail with the chord byte recorded ahead of the commit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/renderer/src/components/terminal-pane/keyboard-handlers-ime-deferred-input.test.tsx (1)

230-234: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Condense the test rationale comments.

Keep each comment to one concise line. Preserve the required ordering rationale and remove the detailed outcome examples.

  • src/renderer/src/components/terminal-pane/keyboard-handlers-ime-deferred-input.test.tsx#L230-L234: replace with a one-line explanation that IME text must commit before cursor movement.
  • src/renderer/src/components/terminal-pane/keyboard-handlers-ime-deferred-input.test.tsx#L282-L284: replace with a one-line explanation that the complete preedit must commit before movement.

As per coding guidelines, comments must be concise and preferably one line.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 685badd9-1685-4ac3-8a14-123a0dcd84e9

📥 Commits

Reviewing files that changed from the base of the PR and between 359fd2e and 8aec625.

📒 Files selected for processing (1)
  • src/renderer/src/components/terminal-pane/keyboard-handlers-ime-deferred-input.test.tsx

@kunsanglee

Copy link
Copy Markdown
Contributor Author

Cherry-picked your test commit onto this branch as 8aec6254a, authorship preserved. Thanks — this closes a gap I knew about and had left open.

I verified the five cases independently rather than taking the claim on faith: against eea0bb64d's keyboard-handlers.ts all five fail with the chord byte recorded ahead of the commit, and the whole terminal-pane suite is green at 245 files / 3148 tests. pnpm lint and pnpm typecheck both pass.

Two scope notes, so the coverage isn't read as wider than it is.

The Windows Ctrl+← case pins the readline branch, not the local-ConPTY one — terminal-shortcut-policy.ts returns null for isLocalWindowsConptyPane, so that path emits no bytes and is unaffected either way. Worth stating because "Windows" alone would suggest both.

The Japanese case pins ordering with a multi-character commit, but the harness commits synchronously, so it doesn't exercise a preedit that outlives TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS. That 200 ms fallback is the ceiling of this whole approach: a phrase-level preedit still running at 200 ms lets the byte through early, which is the pre-fix ordering plus 200 ms of latency. I looked into removing the ceiling and it isn't a small change — _finalizeComposition is private, the only call site in CoreBrowserTerminal._keyDown sits behind the _customKeyEventHandler early-return that Orca uses to claim these keys, and @xterm/xterm is a published dependency here rather than a vendored fork. Removing the timer means either an upstream addition or restructuring Orca's window-level capture handler, so I've filed it as #12890 rather than growing this PR.

Happy for you to close #12872 whenever suits you, and thanks for filing #12871 with the reproduction — the relocation framing is clearer than mine.

@AmethystLiang

Copy link
Copy Markdown
Contributor

thanks for reporting. looking into it

1 similar comment
@AmethystLiang

Copy link
Copy Markdown
Contributor

thanks for reporting. looking into it

Conflict in keyboard-handlers.ts: stablyai#12462 added the
terminalModifierKeyDownObserved field to the Windows Enter chord claim
while this branch re-nested the same condition to cover non-Enter keys.
Kept both — the Enter branch keeps upstream's claim logic verbatim,
now inside the widened composition guard.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@nwparker

nwparker commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Heads-up: this can't land as written any more. 17b3dff merged to main and removes the first-party IME composition layer — sendTerminalInputAfterComposition and deferredNewlineSender are both gone, along with terminal-ime-deferred-newline.ts.

Not a criticism of the approach; the deferral mechanism itself turned out to be the problem. On 1.4.171 we could make Orca emit a Shift+Enter payload to the PTY with no Enter pressed — 10/10 deterministic, landing 203-218 ms after the keydown, which was the 200 ms fallback firing straight through the pending-composition gate that the event path checked. #12890 independently reported the same thing from the Japanese/Chinese side, where phrase-level preedits stay open far longer than the bound.

The symptom you're targeting is real and still open (#12871 has a good reproduction). The shape that survives is having the chord path consult composition state directly rather than sequencing bytes behind a timer — isImeOwnedKeyboardEvent in @/lib/ime-composition-keyboard-event is what the other surfaces use now.

Happy to look at a rebased version.

Upstream stablyai#13128 (17b3dff) returned IME composition ownership to xterm and
removed the deferred-newline layer this branch built on. Both this branch's
production change and its test file targeted modules that no longer exist, so
the merge takes upstream wholesale and drops the stale test.
@ethznn

ethznn commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Following nwparker's notes here and on #12871, I've started on the rework for the new architecture — recording real Korean/Japanese IME traces on stock macOS (the relocation case and the Japanese-overwrite variant he asked for), rebuilding the tests from 8aec6254a against the xterm-owned composition seam, and putting the chord-path gate in per the #12871 guidance (isImeOwnedKeyboardEvent, event.code matching).

Flagging here mainly to avoid duplicated effort: if you're already reworking this PR for the new architecture, let me know and I'll shape my branch to complement yours — tests-first, like last time. Otherwise I'll fold the chord-path fix into the same PR — either way I'll cite this PR's ordering analysis in the description, and once it's up, additive commits are welcome on my branch if you spot gaps.

@kunsanglee

Copy link
Copy Markdown
Contributor Author

Yes, I'm on it — and I have a measurement you should see before you build the chord-path gate, because it says that gate is wrong on macOS.

I merged main into this branch, dropped everything that built on the removed layer, and rebuilt the fix as the guidance describes: exempt modifier chords over ArrowLeft/ArrowRight/Backspace/Delete from isImeOwnedKeyboardEvent, matched on event.code. It passed 13 new tests and the whole renderer suite. Then I recorded a real trace on stock macOS 2-Set Korean and replayed it through the real handler and the real patched xterm, and it inverted the result.

Composing 사, then Cmd+←:

keydown  key='ㅏ'          code=KeyK       keyCode=229  isComposing=true
compositionupdate '사'
keydown  key='Meta'       code=MetaLeft   keyCode=91   isComposing=true
keydown  key='ArrowLeft'  code=ArrowLeft  keyCode=229  isComposing=true   meta
compositionupdate '사'
input    insertCompositionText '사'
compositionend '사'
keyup    key='ArrowLeft'  code=ArrowLeft  keyCode=37   isComposing=false  meta
keydown  key='ArrowLeft'  code=ArrowLeft  keyCode=37   isComposing=false  meta   <- redispatch
keyup    key='ArrowLeft'  code=ArrowLeft  keyCode=37   isComposing=false  meta

The IME ends the composition itself when the Cmd chord arrives, and macOS then redispatches the arrow unmarked. That second copy was never IME-owned, so it already resolves the chord.

Replaying that trace end to end, terminal.input calls and bytes reaching the pty:

terminal.input emitted
main as-is ['\x01'] ['사', '\x01']
with the chord-path exemption ['\x01', '\x01'] ['사\x01', '\x01']

So for this gesture main is already correct — commit first, then the cursor move — and exempting the marked keydown adds a second firing. For Cmd+← that is idempotent and invisible. For Option+← it would be a two-word jump, and for Ctrl+Backspace a two-word delete.

Two things I got wrong that are worth stating, since they're the reason I didn't catch this from tests:

Hand-built event shapes can show what a given input produces, but never that the input arrives alone. My probe fed a single isComposing: true keydown, saw null, and concluded the chord was swallowed. Hardware sends a second, unmarked copy afterwards that no hand-written fixture contains. You were right to record traces first.

Also, key is not rewritten on macOS here — it stays 'ArrowLeft' with keyCode 229. key: 'Process' is the Windows shape, from the #12171 capture. event.code matching is still the right call, but on macOS the thing that marks the event is the keyCode, not the key.

Two more measured, both from the same replay rig:

Queued bytes flush on a cancelled composition, not just a commit. handleCompositionInput appends to _pendingInput, and _finalizeComposition emits input + _pendingInput whenever the composition ends — with an empty commit that is just the chord. Measured: compose 사, terminal.input('\x15'), cancel with an empty compositionend → ['\x15'] reaches the pty with nothing committed. And a bare blur with no compositionend leaves _pendingInput populated, where it survives into the next composition, since compositionstart doesn't clear it.

On the local Windows ConPTY path Ctrl+←/→ still returns null before any of this, so that host is unaffected either way.

What I don't have is the same trace for Option+←, Cmd+Backspace and Option+Backspace while composing — the Cmd chord ends the composition, so my recording captured those three idle. If the IME only claims Cmd chords and lets Option through unmarked, then Option+Arrow really is dropped and is worth fixing, and the fix should be narrowed to exactly that. If Option behaves the same way, there may be nothing to fix on macOS Korean and the remaining question is Windows and Japanese.

Your traces settle that, so I'd rather not guess ahead of them. Concretely: you take the traces and the tests, I'll hold this PR at the merge with main and hand over the replay rig and the two findings above. Whichever branch the fix lands on is fine by me — happy for it to be yours.

@kunsanglee

Copy link
Copy Markdown
Contributor Author

I have both input sources recorded now, and they behave in opposite ways. That turns out to be the whole story here, so I'd hold off on a plain chord-path exemption — it fixes Japanese and breaks Korean.

All traces are stock macOS, Chrome 151, recorded through a page that logs keydown/keyup/composition*/input on a textarea. Replays run the rows at terminal.textarea through renderHook(useTerminalKeyboardShortcuts) with a real Terminal, spying terminal.input and recording onData, so each trace shows both the dispatch count and the byte order.

Korean 2-Set — the chord is not dropped

Composing 사, then Option+←:

keydown  Alt        code=AltLeft    keyCode=18   isComposing=true
keydown  ArrowLeft  code=ArrowLeft  keyCode=229  isComposing=true   alt
compositionupdate '사'
input    insertCompositionText '사'
compositionend '사'                                <- the IME commits and withdraws
keyup    ArrowLeft  code=ArrowLeft  keyCode=37   isComposing=false  alt
keydown  ArrowLeft  code=ArrowLeft  keyCode=37   isComposing=false  alt   <- macOS redispatches
keyup    ArrowLeft  code=ArrowLeft  keyCode=37   isComposing=false  alt

Cmd+← and Cmd+Backspace are the same shape. The redispatched copy of Cmd+Backspace even carries a real input with inputType: 'deleteSoftLineBackward', so it is a fully live press.

That unmarked copy was never IME-owned, so it already resolves normally:

chord main as-is with the exemption
Cmd+← ['사', '\x01'] ['사\x01', '\x01']
Option+← ['사', '\x1bb'] ['사\x1bb', '\x1bb']
Cmd+Backspace ['사', '\x15'] ['사\x15', '\x15']

Left column is right in every row. The exemption doubles all three; Option+← sending \x1bb twice is a two-word jump instead of one.

Japanese — the chord really is dropped

Preedit 日本語 live and unconverted, then Cmd+←, Option+←, Cmd+Backspace in sequence:

keydown  ArrowLeft  code=ArrowLeft  keyCode=229  isComposing=true  meta
keyup    ArrowLeft  code=ArrowLeft  keyCode=37   isComposing=true  meta
keydown  ArrowLeft  code=ArrowLeft  keyCode=229  isComposing=true  alt
keyup    ArrowLeft  code=ArrowLeft  keyCode=37   isComposing=true  alt
keydown  Backspace  code=Backspace  keyCode=229  isComposing=true  meta
keyup    Backspace  code=Backspace  keyCode=8    isComposing=true  meta
compositionend '日本語'

No compositionend between them, no redispatch, and isComposing stays true through every keyup — the conversion never yields. Replayed:

terminal.input emitted
main as-is [] ['日本語']
with the exemption ['\x01', '\x1bb', '\x15'] ['日本語\x01\x1bb\x15']

Three chords, zero calls on main. With the exemption each fires exactly once and lands after the commit, because there is no redispatch to double it.

Why this can't be gated at keydown

The marked keydown is identical in both: code='ArrowLeft', keyCode=229, isComposing=true, key='ArrowLeft'. Nothing distinguishes them at the moment we have to decide. The difference only appears afterwards — Korean emits compositionend and a redispatch, Japanese emits neither.

So the shape that works for both is: resolve on the marked keydown, and swallow the unmarked redispatch of the same physical key when it comes. Japanese gains the chord it was losing; Korean drops back to one firing.

That is exactly what useImeEnterGestureOwnership already does for Enter, and its own comment describes the ordering the Korean trace shows: "Windows/Linux redispatch the unmarked Enter/13 before keyup; macOS delivers keyup first and redispatches after. A token that expires synchronously on keyup therefore regresses macOS, so the carry survives until the next animation frame." The Korean trace is keyup-then-redispatch, so that carry window is the right one for arrows too.

I'm building that now, mirroring the Enter gesture rather than inventing a second mechanism. Two things I'd want a second opinion on:

With the carry in place, the Japanese chord queues in _pendingInput and only reaches the shell when the phrase finally commits. Strictly better than today, where it never arrives, but it does mean the chord looks inert until conversion ends. If that's not acceptable the alternative is forcing the composition to finalize, which is a much larger change.

And the queue drains on a cancelled composition too, not just a commit. Measured: compose, terminal.input('\x15'), cancel with an empty compositionend → ['\x15'] reaches the pty with nothing committed. Not reachable on main today, since nothing queues during a composition there, but it becomes reachable the moment chords do.

Traces and the replay rig are yours either way — say the word and I'll push the rig as a test on this branch, or hand it over for yours. Neither of us should be guessing at this layer, and I nearly shipped the exemption on the strength of hand-written fixtures that had no redispatch in them.

A modifier chord pressed during a composition arrives identically on both input
sources tested — code='ArrowLeft', keyCode=229, isComposing=true — but they
answer it in opposite ways, recorded on stock macOS:

  Korean 2-Set commits the syllable, emits compositionend, and the platform
  replays the chord unmarked after keyup. That replay already reached the shell,
  so main is correct here today.

  Japanese conversion swallows it: no commit, no replay, still composing at
  keyup. Cmd/Option+Arrow and Cmd+Backspace produced no bytes at all.

Exempting the marked keydown alone fixes Japanese and breaks Korean, where
Option+Left's \x1bb would go out twice and jump two words. So the exemption is
paired with a redispatch ledger that retires the replayed copy, mirroring
useImeEnterGestureOwnership — including its animation-frame expiry, since macOS
delivers keyup before the replay.

Chord matching for these four physical keys now reads event.code, and the
keybinding lookup reads the same resolved key, so a remapped chord cannot lose
to the built-in byte while an input source rewrites event.key.

Ordering needs no timer: xterm's patched input() queues the bytes behind the
preedit and flushes them with the commit.
Three defects, two of them introduced by the previous commit.

Claiming happened only on the byte path, but widening the keybinding lookup is
what let a remapped chord resolve mid-composition — and a remap produces a pane
command, not bytes. The replay then ran it again: two panes closed, or two
clears, from one press. Claim now covers every resolved action. Pinned by
replaying the recorded Korean trace with terminal.clear remapped onto
Mod+Backspace, which counts 2 without the fix.

The carry expired on an animation frame, so any slow task landing between keyup
and the replay let the chord fire twice. It now expires when the last chord
modifier comes up, which is the gesture's own boundary in both traces and does
not depend on timing at all. The trace replay now spaces rows by a full task
rather than a microtask, so a regression back to a timer would fail it.

A gesture interrupted by focus loss never releases its modifier, leaving the
carry armed to swallow an ordinary chord later; blur now resets it, as both
siblings in this effect already do. The keyup listener is scoped like the
keydown one so another pane's release cannot retire this pane's carry.

Also narrows the physical-key substitution to IME-owned events — an X11 keysym
remap preserves code while changing key, and reading code there would have
overridden it on ordinary presses.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@kunsanglee kunsanglee changed the title fix(terminal): sequence shortcut bytes behind a pending IME composition fix(terminal): send IME-swallowed chords once, from recorded traces Aug 8, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts (2)

45-53: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a trace with a rewritten key value.

Every case sets key equal to the physical key name, for example "key": "ArrowLeft" with "code": "ArrowLeft". So physicalChordKey in terminal-shortcut-policy.ts returns event.key unchanged, and the normalization branch at Lines 152-153 of that file never runs in this suite.

That branch is the one the PR description calls out for Windows, where an IME-consumed key arrives as key: 'Process'. It also contains the native-event spread defect flagged on terminal-shortcut-policy.ts Lines 147-153. A case with "key": "Process" and "code": "ArrowLeft" dispatched as a real KeyboardEvent would catch it.

The PR description also lists composition cancellation as in scope. No case here cancels a composition. Add a trace where the user presses Escape mid-preedit and then presses an exempt chord, so the ledger state after cancellation is pinned.


754-782: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Select recorded cases by name, not by array index.

CASES[2] is "Korean 2-Set, Cmd+Backspace" and CASES[0] is "Korean 2-Set, Cmd+ArrowLeft". Both tests depend on the specific gesture: the first needs a Backspace chord to match the Mod+Backspace remap, and the second needs the trailing modifier keyup that releases the carry. If someone inserts or reorders a case in the 545-line fixture block above, these tests silently exercise the wrong trace and may still pass.

♻️ Proposed refactor
+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', () => {
   it('runs a remapped pane command once across the replay', async () => {
+    const backspaceCase = caseNamed('Korean 2-Set, Cmd+Backspace')
     const rig = openRig({ keybindings: { 'terminal.clear': ['Mod+Backspace'] } })
-    await replay(rig.textarea, CASES[2].rows)
+    await replay(rig.textarea, backspaceCase.rows)
   it('releases the carry so a later ordinary chord still sends', async () => {
+    const arrowCase = caseNamed('Korean 2-Set, Cmd+ArrowLeft')
     const rig = openRig()
-    await replay(rig.textarea, CASES[0].rows)
-    expect(rig.inputCalls).toEqual(CASES[0].expectCalls)
+    await replay(rig.textarea, arrowCase.rows)
+    expect(rig.inputCalls).toEqual(arrowCase.expectCalls)
-    expect(rig.inputCalls).toEqual([...CASES[0].expectCalls, CASES[0].expectCalls[0]])
+    expect(rig.inputCalls).toEqual([...arrowCase.expectCalls, arrowCase.expectCalls[0]])

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ef380aae-9012-49e3-86f3-c0240261e196

📥 Commits

Reviewing files that changed from the base of the PR and between f858c5a and 396c274.

📒 Files selected for processing (6)
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-composing-chord.test.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.ts
  • src/renderer/src/components/terminal-pane/terminal-ime-chord-redispatch.test.ts
  • src/renderer/src/components/terminal-pane/terminal-ime-chord-redispatch.ts
  • src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts

Comment thread src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts Outdated
@ethznn

ethznn commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Our recordings agree with yours layer-for-layer — and the redispatch find explains the one gap in ours: we recorded through the in-app boundary probe, where Orca's window-level capture consumes the unmarked redispatch above the textarea, so we saw its effect (exactly one \x01 after the commit at the PTY) without seeing the event. Between your DOM-layer traces and our PTY-layer ones (five scenarios, in-app, plus screenshot series — happy to hand the JSONs over), the mechanism is now pinned from both ends. The Enter-gesture mirror looks right to me, and the exactly-once table settles why a plain exemption can't ship.

On your two questions: (1) Japanese queuing until the phrase commits seems strictly better than today's drop — deferred-but-delivered preserves the ordering contract, and "inert until conversion ends" is normal IME modality; worth one line in the PR notes. (2) The cancelled-composition drain feels like the real hazard: a queued \x15 acting on a line whose preedit vanished is the same class as your original Cmd+Backspace case. Since nothing queues during composition on main today, there's no compatibility constraint — I'd drop queued chord bytes on cancel (they were aimed at text that no longer exists) and pin that in a test either way. The cancel-drain semantics especially feels like one to put in front of the maintainers in the PR description when you push.

For the rig: push it on this branch — tests belong with the behavior change they verify. I'll rework my five recorded scenarios into fixtures asserting the both-worlds invariants (exactly one movement byte, strictly after the commit, destination glyph intact) and send them your way as a commit to cherry-pick, like last time — or as a follow-up tests PR if you'd rather keep this one lean.

The two input sources are indistinguishable while the key is down and
separate by the time it comes up: Korean has ended its composition and a
platform replay is on the way, Japanese is still composing and nothing
else will ever deliver the chord.

So yield every IME-owned keydown as main does, and resolve the chord from
its release only when the composition is still live. Nothing is swallowed
and no state is carried between events, which retires the redispatch
ledger and the whole class of gaps it had to cover.

Also stop rebuilding the chord event with a spread. A real KeyboardEvent
keeps its properties as prototype accessors, so `{ ...event }` produced an
empty object and every keybinding lookup silently missed in production
while the plain-object test doubles passed.

The Japanese trace is replaced with the full capture, including the
modifier presses and releases that hand transcription had dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mu6j33LNsd4Mvv8a1NuFoo

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts (1)

413-433: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Select the traces by name instead of by array index.

Both tests address a specific recorded trace: CASES[3] is the Japanese multi-chord trace, and CASES[2] is the Korean Cmd+Backspace trace. The assertions depend on those exact rows. If somebody inserts or reorders a case in CASES, these tests keep passing against the wrong trace, or they fail with an unclear reason.

Look the trace up by its name field so the intent stays explicit.

♻️ Proposed refactor
+const caseNamed = (name: string): RecordedCase => {
+  const found = CASES.find((c) => c.name === name)
+  if (!found) {
+    throw new Error(`unknown recorded case: ${name}`)
+  }
+  return found
+}
+
   it('runs a remapped pane command from a swallowed release', async () => {
     const rig = openRig({ keybindings: { 'terminal.clear': ['Mod+Backspace'] } })
-    await replay(rig.textarea, CASES[3].rows)
+    await replay(rig.textarea, caseNamed('Japanese, a bare arrow and four chords across one live preedit').rows)
 
     expect(rig.clearPaneCalls).toBe(1)
@@
   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, CASES[2].rows)
+    await replay(rig.textarea, caseNamed('Korean 2-Set, Cmd+Backspace').rows)

Adjust the type name to whatever the recorded-case type is called in this file.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 66b0e72b-a3f0-473d-8d28-2ed7116f7f2f

📥 Commits

Reviewing files that changed from the base of the PR and between 396c274 and dcb8948.

📒 Files selected for processing (4)
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-composing-chord.test.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.ts
  • src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-composing-chord.test.ts
  • src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts

kunsanglee and others added 2 commits August 8, 2026 21:08
Review found the release's own fields are the wrong ones to read, in both
directions, and both reproduce.

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. The
exemption then misses and the chord is lost outright — the bug this change
exists to fix, surviving in whichever order the user happens to let go.

The mirror case: a key that went down with no composition in sight has
already resolved from its own keydown, but a composition starting while it
is held makes its release look swallowed and sends it a second time.
Measured as \x1bb twice, which is two words instead of one.

So keep the chord from the keydown that yielded, and let the release answer
for that press: same key, composition still live, modifiers as they were
when it went down. Nothing is swallowed and the carry cannot outlive its
key, since an ordinary press of the same code drops it.

The release also now runs the file-search and floating-panel guards the
keydown path applies, matching on the press while suppressing on the real
event, and Shift is excluded from the exemption as its comment always
claimed — Japanese conversion binds Shift+arrow to resize a segment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mu6j33LNsd4Mvv8a1NuFoo
A chord interrupted by Cmd+Tab or Spotlight delivers no keyup here, and
nothing dropped what it left behind. A later bare arrow moving the preedit
caret then matched that carry by code and sent the missing chord's bytes
to a shell the user never aimed at — measured as a stray \x1bb.

Three closures, matching what the sibling tracker in this effect already
does: any press of the same code supersedes the carry whoever owns it,
the release spends it before any gate can refuse it, and window blur
drops it outright.

Also record at imeChordSnapshot why it must stay field by field. The
prototype-accessor trap moved here when the policy stopped seeing native
events, and this is the only copy of a real KeyboardEvent left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mu6j33LNsd4Mvv8a1NuFoo
@kunsanglee

Copy link
Copy Markdown
Contributor Author

Pushed. The chord-path gate is in, but not the shape I described above — the carry-and-swallow mirror of useImeEnterGestureOwnership turned out to be the wrong mechanism, and I want to be explicit about why, since I proposed it here.

What changed

Resolving on the marked keydown and swallowing the redispatch works on the traces, and that is exactly the problem: it is a decision made where the two input sources are still identical, defended afterwards by suppressing an event. Every gap it had came from the suppressing half — a carry surviving a focus change inside the window, a Cmd+← carry eating a later Cmd+Alt+←, the swallow running ahead of the editable-target guard.

The decision does not have to be made there. At the chord's keyup the two have already separated:

chord keyup, isComposing === false  ->  the composition committed; the unmarked replay is coming
chord keyup, isComposing === true   ->  the IME ate it; nothing else will ever deliver it

That holds on all four recorded traces, 8/8 chords. So the pane yields every IME-owned keydown exactly as main does, remembers the chord it yielded, and resolves that remembered press when the key comes up still composing. Korean's release finds the composition already over and does nothing — the replay resolves it, as it does on main today. Nothing is swallowed, so the entire class of gaps above does not exist.

Ablation against the recorded tests: drop the exemption and Japanese goes back to []; act on the keydown as well as the release and every Korean chord doubles, Japanese fires 8 times for 4 chords, and a remapped clear runs twice.

Four defects worth naming, because tests did not catch them

A spread over a native event is empty. The first revision built the chord's keybinding lookup with { ...event, key: chordKey }. Chromium keeps KeyboardEvent's fields as prototype accessors, so that object is {} and every remap silently lost to the built-in byte — in production only. happy-dom keeps them as own properties, so the whole suite stayed green. It is now built field by field, and pinned by a test that resolves an event whose fields live on its prototype, which is the only form of that assertion that can fail under this runner.

Modifier flags describe the moment the event fired. Releasing Cmd before ← leaves the arrow's keyup with metaKey: false. Matching on the release's own fields drops the chord outright — the original bug, surviving in whichever order the user habitually lets go. That is why the press is remembered rather than re-read.

The mirror of it. A key that went down with no composition in sight has already resolved from its keydown; a composition starting while it is held made its release look swallowed and sent it again. Measured as \x1bb twice, which is two words instead of one.

A carry with no release to spend it. A chord interrupted by Cmd+Tab or Spotlight delivers no keyup here at all. The carry sat armed, and a later bare arrow moving the preedit caret matched it by code and sent the missing chord's bytes — a stray \x1bb into a shell the user never aimed at. Any press of the same code now supersedes it, the release spends it before any guard can refuse it, and blur drops it, which is what installTerminalNativeInputListeners already does for the sibling state in the same effect.

All four are regression tests now, each failing on the revision before its fix.

@ethznn — on the cancelled composition

Measured rather than assumed, and I landed on the opposite of your suggestion, so it is worth disagreeing with if you still see it the other way. Compose 日本語, press Cmd+←, then Escape: the \x01 reaches the pty and the composed text is dropped. I have left that as-is and pinned it, because Cmd+Backspace was aimed at the shell's line rather than at the preedit — the line still exists when the preedit is abandoned, so retracting the kill would lose a deliberate keystroke. Your framing holds for anything aimed at the text; I could not find a chord in the exempt set that is.

Your PTY-layer scenarios are still very welcome as a commit to cherry-pick. The both-worlds invariants you described are the right assertions, and the rig is on this branch now (keyboard-handlers.issue-12871-recorded-chord-traces.test.ts — recorded rows, replayed through the real hook and a real patched Terminal, asserting both dispatch count and byte order).

What this does not fix

A kitty-protocol pane is half covered. The policy returns null for Option+←/→ and Option+Backspace when the pane negotiated CSI > u, on the grounds that xterm encodes them natively — during a composition it does not, because CompositionHelper.keydown returns false for keyCode === 229 and _keyDown returns before evaluateKeyDown. So in a KKP pane the Option chords stay dead while the Cmd chords now work. Strictly better than main, where none of them worked, but an odd split. Sending the legacy \eb/\ef there would reach the TUI as alt+b/f, and I have no trace saying what a kitty pane should receive instead, so I left it rather than guessed.

The dashboard popout preview terminal drops IME-owned events before reaching the policy, so it still has this bug on that surface. No traces for it either.

Korean's correctness rests on one ordering — compositionend arriving before the chord's keyup. The recorded rows pin it and the replay spaces them by a full task so nothing depends on timing, but nothing defends against that ordering inverting on some other input source. That needs a trace, not a guess.

Traces are macOS, two input sources. Windows and Linux are routed via code and read only per-event state, but that is reasoning, not measurement. Chinese is untested.

Review found the chord's keybinding lookup was reimplementing something
the matcher already has one word away. PHYSICAL_CODE_FALLBACK_KEYS holds
the keys whose produced value the platform cannot report — '', 'Dead',
'Unidentified' — and 'Process' is exactly that case: Windows' report for a
key an IME consumed. Adding it there deletes the synthetic chord event and
its fourteen renamed call sites, and fixes the same blind spot on every
other surface rather than in the terminal pane alone.

`chordKey` stays for the byte fallbacks, which read the key directly.

Also from review: the release path's floating-panel and file-search guards
now have the tests their comments were asserting without, Shift exclusion
has the composing Cmd+Shift+Arrow negative it arrived without, a one-caller
wrapper is inlined, and two comments stop pointing at a symbol deleted two
commits ago.

The idle assertion added earlier is dropped. It was restored on the claim
that terminal-shortcut-policy.test.ts leaves `code` empty for these chords;
it does not, and for a non-composing event the substitution short-circuits
before reading `code` at all, so both fixtures took the same branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mu6j33LNsd4Mvv8a1NuFoo
@kunsanglee

Copy link
Copy Markdown
Contributor Author

One follow-up, and it touches a file outside the terminal pane, so it should be an explicit decision rather than something to notice in the diff.

The chord's keybinding lookup was reimplementing a mechanism this repo already has. shared/keybindings.ts keeps PHYSICAL_CODE_FALLBACK_KEYS — the keys whose produced value the platform cannot report, so matching falls back to event.code:

const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified'])

'Process' is exactly that condition. It is Windows' report for a key an IME consumed, and 'Dead' is already there for the analogous dead-key case. Adding one word deletes the synthetic chord event I had built in terminal-shortcut-policy.ts and the fourteen renamed keybindingMatchesAction call sites that went with it.

The tradeoff worth naming: this widens from one surface to all of them. App.tsx, the floating panel and the popout preview terminal have the same blind spot today and are fixed by it. I think that is right rather than incidental — 'Process' is only ever emitted by a key an IME consumed, so falling back to code is correct wherever it appears — but it is a shared-module change inside a terminal PR, and I would rather it be refused deliberately than merged quietly. Say the word and I will scope it back to the pane.

It also retires the defect I described above: with nothing copying a native event any more, there is no spread left to be empty. The byte fallbacks still resolve the physical key themselves, gated on IME ownership so an ordinary press follows key and an X11 keysym remap is not overridden.

Three gaps closed alongside it, all cases where a comment asserted a contract nothing enforced. The release path's floating-panel and file-search guards now have tests — each fails if the guard is removed, and without them a chord remapped onto tab.close closed a panel when pressed outside a composition and closed the pane when pressed inside one. The Shift exclusion has the composing Cmd+Shift+← negative it arrived without; the existing bare-arrow negative passed either way, so it was pinning nothing.

And a correction to my own earlier reasoning. I had restored an assertion here on the claim that terminal-shortcut-policy.test.ts builds its fixtures with code: '' and so misses this path. It does not — lines 336, 342, 527 and 533 assert exactly these chords with code populated — and for a non-composing event the substitution short-circuits before reading code at all, so both shapes took the same branch regardless. The assertion is dropped.

Suite is green at 25,431 renderer and shared tests.

Three reviewers independently reached the same keyup. Suppressing it buys
nothing: by the time the release is consumed the action has already run,
so there is no second handler left to race. It costs, though — cut off at
window capture the keyup never reaches xterm's own _keyUp, which clears
the flags its input path reads, nor any window listener registered after
this one. The release now forwards preventDefault and drops the rest.

Separately, a 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 past the editable guard,
sending a chord aimed at the field to the shell. Refusing to arm from such
a press closes that, where refusing at the release cannot.

Three invariants the earlier commit messages asserted had no test behind
them: the same-code supersede, the blur release, and spending the carry
before a guard can refuse it. One test covering all three passed as long
as any one worked, so it is now three, each failing only for its own path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mu6j33LNsd4Mvv8a1NuFoo
@kunsanglee

Copy link
Copy Markdown
Contributor Author

Settled at 13b1e8cbdf. Two more things came out of review after my last comment, and both are worth stating because both were mine to get wrong.

The release no longer consumes the keyup. runShortcutAction is shared with the keydown path, where stopping propagation keeps a second handler from acting on the same press. On a release there is no such handler — the action has already run — so the suppression buys nothing, and it costs: cut off at window capture the keyup never reaches xterm's own _keyUp, which clears the flags _inputEvent reads, nor any window listener registered after this one. The release now forwards preventDefault and drops the rest. I had waved this off as narrow and self-healing; it is, but "narrow harm for zero benefit" is not a trade worth defending.

The carry is never armed from a press aimed at a text field. A 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, walks past the editable guard, and sends a chord aimed at the field to the shell. Guarding at the release cannot close that — only refusing to arm can.

Three invariants my own commit messages asserted had nothing behind them. The same-code supersede, the blur release, and spending the carry before a guard can refuse it. One test covered all three, which meant it passed as long as any one of them worked and pinned none. It is three tests now, each failing only for its own path. That is the general shape of what review caught here: not wrong reasoning so much as reasoning stated in a comment and nowhere else.

Every fix in this PR has been mutation-checked — reverted, watched to fail, restored. Full renderer and shared suites: 25,435 passing.


Where it landed, for anyone picking this up cold:

The pane yields every IME-owned keydown exactly as main does, and remembers the chord it yielded. When that key comes up with the composition still live, it resolves the remembered press — the input source ate the chord and nothing else will deliver it. When the composition has already ended, it does nothing, because the platform's unmarked replay is on its way and resolves it as main does today. Korean and Japanese answer a mid-composition chord in opposite ways and are indistinguishable at the keydown; the release is the first moment they differ.

Known bounds, all in the description: a kitty-protocol pane still loses the Option chords (xterm will not encode them during a composition either, and I have no trace saying what it should receive instead); the popout preview terminal drops IME events before reaching the policy; holding a swallowed chord repeats nothing; and the traces are macOS, two input sources — Windows, Linux and Chinese are reasoned about, not measured.

Happy to split the shared/keybindings.ts line into its own PR if you would rather this one stay inside the terminal pane.

@ethznn

ethznn commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

On the cancelled composition — you're right and I was wrong. I was picturing the queued byte as aimed at the preedit, but nothing in the exempt set is: the preedit never enters the shell's line buffer, so \x15 and the cursor bytes all target a line that outlives the cancel. Retracting them would drop a deliberate keystroke. Your pin is the correct behavior.

Korean ordering, second surface — on your note that Korean's correctness rests on compositionend arriving before the chord's keyup: my in-app recording (boundary probe on the helper textarea, pty bytes read from the child) shows the same order on 2-Set — compositionend, then keyup ArrowLeft code=ArrowLeft keyCode=37 isComposing=false meta. Different recording surface, same ordering, and the isComposing: false your release path keys on. Not proof against inversion elsewhere, but a second measurement rather than a second guess.

Kotoeri: a state where the release you key on does not exist

I ran a native e2e spec against 13b1e8cbdf. Korean passes at the pty layer both ways (하\x01\n, 하\x1bb\n), the ABC control passes (abc\x01\x1bb\n), and Kotoeri fails: the pty receives e381950a (さ\n) where the fix intends e38195010a (さ\x01\n).

The preedit-survives assertion passes first, so the IME does swallow the chord — it is the recovery that does not fire. A capture-phase probe at window (where your listener sits), with document capture and window bubble as witnesses, records the press at all three positions:

window-capture   keydown ArrowLeft code=ArrowLeft keyCode=229 isComposing=true meta
document-capture keydown ArrowLeft ...same...
window-bubble    keydown ArrowLeft ...same...

and then nothing — no keyup at any position, with .composition-view still showing さ. Keyups for the romaji letters in the same composition do arrive at the same probe (s, keyCode 83, isComposing: true), so this is specific to the swallowed chord key rather than probe placement.

The variable I'd look at: your Japanese capture is a converted phrase (日本語); mine is an unconverted single-character preedit (sa → さ). That's the only difference I can see between a chord release that arrives and one that doesn't, and it's a trace rather than a guess to settle it. This doesn't touch the Korean direction or the invariants your regression tests pin — it bounds where the recovery can fire.

What I have — two commits on ethznn:test/ime-chord-traces-v3, branched from 13b1e8cbdf, your implementation files untouched (keyboard-handlers.ts, terminal-shortcut-policy.ts, shared/keybindings.ts all zero diff):

  • 8329bee0 — the in-app trace fixtures pooled into your rig: a data module plus 7 lines in your file. Its Korean case adds an unmarked bare arrow following a chord, which CASES doesn't have. My Kotoeri case is dropped — with no chord keyup in the recording, the only assertion available is "no byte", which pins the defect rather than the fix.
  • c61a3c36 — the native macOS e2e spec: 3 pass, the Kotoeri one fails as above. It is test.skipped without ORCA_E2E_NATIVE_MACOS_KOREAN=1, so it cannot redden CI; it would sit as the local reproduction until the gap closes.

Cherry-pick whichever is useful, or leave the e2e one until after — no need to reply about it either way.

@ethznn

ethznn commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Correction: the conversion-state hypothesis I posted above is wrong. I recorded the A/B and it refutes it — a fully converted, uncommitted 日本語 preedit swallows the chord keyup exactly as unconverted さ does. Length isn't it either, and neither is language.

What the ten states actually separate is whether the IME ends the composition in response to the chord:

input source preedit state chord keyup pty
Korean 2-Set jamo composing yes — keyCode 37, isComposing: false …사\x01
Kotoeri 日本語 (live-converted) no 日本語\n
Kotoeri 日本語版 (+Space) no 日本語版\n
Kotoeri さ unconverted no さ\n
Kotoeri あいうえお unconverted, multi no あいうえお\n
Simplified Pinyin ni hao, no candidate no nihao\n
Simplified Pinyin candidate navigated no nihao\n
Traditional Zhuyin 你好 no 你好\n

2-Set treats Cmd+Arrow as a commit trigger: compositionend fires on the chord keydown, and the keyup that follows carries the real arrow keyCode with isComposing: false. Kotoeri and both Chinese engines consume the chord inside the live preedit — the composition never ends and the OS never delivers the chord keyup to the app at all. All seven non-Korean cells show the keydown at window capture (ArrowLeft, keyCode 229, isComposing: true, metaKey: true) and then no keyup at any level, on both probes. Keyups for the romaji letters of the same composition do arrive at those probes, so this is specific to the swallowed chord key.

Those rows were driven with synthesized key events, so I re-checked by hand on the same build: typing on a real keyboard, Cmd+← during a Japanese or a Pinyin preedit moves nothing — same result as the recordings.

So the release path is gated on the IME conceding the chord. Korean concedes, which is why it works there. On this machine Japanese and Chinese never do, which is where I cannot get the recovery to fire.

One thing I can't reconcile, and it may well be my setup: your Japanese capture has keyup ArrowLeft keyCode 37 isComposing true. Every delivered chord keyup I have recorded carries isComposing: false, because the composition has already ended by then — the true combination appears in none of the ten states I measured. One candidate for the difference: macOS Live Conversion is on by default, so nihongo reaches 日本語 in the preedit with no Space pressed; I had to use aiueo → あいうえお to get a genuinely unconverted multi-character Japanese preedit at all. Worth confirming the input mode and the recording surface before the release path is relied on for Japanese.

Seven trace JSONs (window / document / textarea probes, onData, and pty bytes per case, with SHA-256s) — happy to hand them over in whatever form is useful, or to record any state you name.

ethznn added 2 commits August 9, 2026 00:30
…e rig

A second recording of the same gestures, taken inside the app rather than on
a bare page, replayed through the rig already on this branch. Both cases are
negatives: the bare-page rows pin where the chord bytes come from, and these
pin the keys around them, which is where a release-keyed recovery misfires.

The Korean case carries a plain ArrowRight pressed one beat after the chord —
no bare-page case has one — so a carry left armed past its own release has
something of the same `code` to answer for. Its \x05 is not asserted: the
app's own window capture consumed the platform's unmarked replay above the
probe, so the press that delivers the byte was never recorded.

The third recording of that session, a Kotoeri Cmd+ArrowLeft, is not replayed.
Its chord press is followed by no arrow keyup anywhere, so replaying it could
only assert that no byte is produced. That is raised on the PR instead.
The unit rig replays recorded traces; this is the layer those traces came
from, with the real OS input methods deciding everything and the bytes read
off the pty. A platform change — redispatch timing, chord swallowing — fails
here first.

Korean 2-Set and the ABC control pass on this branch. The Kotoeri case does
not: the chord byte never reaches the pty, so it is currently a failing
pre-merge finding rather than a regression guard. Kept unmodified rather
than relaxed, because relaxing it would erase the evidence.
@kunsanglee

Copy link
Copy Markdown
Contributor Author

Both of your commits are on the branch at 8ba40b7c97, authorship intact. The two chord test files now run 34 tests. On the Kotoeri gap: I took the capture, and it moves the variable rather than confirming it.

The composition state is not what decides it. I rebuilt the bare-page recorder to probe the same three positions your in-app probe used — window capture, document capture, window bubble — plus the textarea, and drove it the way you drove yours, System Events key codes through the real input source. Same Chrome 151 the original recording came from. Three conditions, one variable each.

Unconverted さ (s,a), the exact condition your e2e spec composes, Cmd+ArrowLeft:

window-capture    keydown ArrowLeft code=ArrowLeft kc=229 isComposing=true  meta
document-capture  keydown ArrowLeft ...same...
textarea-capture  keydown ArrowLeft ...same...
window-bubble     keydown ArrowLeft ...same...
window-capture    keyup   ArrowLeft code=ArrowLeft kc=37  isComposing=true  meta
document-capture  keyup   ArrowLeft ...same...
window-bubble     keyup   ArrowLeft ...same...
textarea-capture  keyup   ArrowLeft ...same...

The release arrives, at every position, still composing. That is exactly the shape the recovery keys on. Converted 日本語版 (nihongo + Space) gives the same pair. The Korean control behaves as documented: compositionend between the press and the release, isComposing: false on the keyup, then the platform's unmarked replay.

So converted-phrase versus unconverted-single-character is not the discriminator. Both compose, both swallow the chord, both deliver the release.

What that leaves is the surface. Your capture and mine differ in a second way I should have named when I first read yours: yours is inside the Electron app at the xterm helper textarea, mine is a bare page in Chrome. With the composition state now controlled on my side, that difference is the only one still standing between a release that arrives and one that does not.

Which means your finding survives intact and only its explanation changes. The Kotoeri byte genuinely does not reach the pty on 13b1e8cbdf, and the release the recovery waits for genuinely is not observable in-app — I am no longer disputing either. What I can now say is that the release exists at the platform level for that composition state, so something between the OS and the window listener is where it goes missing, not the IME's decision about the chord.

That is the next capture, and your e2e spec is the harness for it — which is the main reason I took it onto the branch rather than leaving it. It carries a failing expectation, and I would rather ship it failing than relax it, for the reason you gave: relaxing it erases the evidence.

Two things I have not done and am not claiming. I have not run the in-app probe yet, so I do not know what consumes the keyup there. And the bare-page rig is a scratch harness, not committed — if the in-app run turns up something worth pinning I will bring the relevant rows in as fixtures rather than the rig.

The release-keyed recovery never fires for Cmd chords in-app while the bare-page
recording of the same input source and the same preedit delivers that release.
This records the difference instead of reasoning about it: four listener
positions, plus focus ownership and the live target on every row, so "never
dispatched" can be told apart from "dispatched somewhere else".

What it measured. Cmd+Left during a Kotoeri さ preedit produces the keydown at
all four positions and no keyup at any of them, while a bare ArrowLeft pressed
right after produces both, still composing, with the window still focused. The
ABC control has no IME anywhere and shows the same split: Cmd loses its keyup,
Option keeps it. Option+Left during the same preedit reaches the pty as
さ\x1bb — the recovery does work, on the modifier that still reports a release.

So the discriminator is the Command modifier, not the composition state, and not
the input source. Kept as the capture harness rather than a gate: it asserts only
that rows were recorded, and writes the traces under test-results.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mu6j33LNsd4Mvv8a1NuFoo
@ethznn

ethznn commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Correction, and the exact rule. My last comment said the OS never delivers the chord keyup and that the IME decides it. Both are wrong. I reran the whole ladder with a human typing on real hardware instead of System Events, and the two behaviours separate cleanly.

Option chords are queued, not lost. Kotoeri, さ composing, Option+←: the release arrives at every rung, your recovery fires, and the bytes reach the pty in a single write 18 ms after compositionend, with the xterm cursor moving 36 ms after that write. Never at chord time.

event Δ from previous
Option+← keydown —
compositionend however long the user keeps composing
pty write さ\x1bb… +18 ms
cursor moves +36 ms

The mechanism's own latency is negligible; what is unbounded is the middle row. The gap between pressing the chord and seeing it take effect is exactly however long the composition lasts — that is the _pendingInput flush you described, working as designed, and it is why it looks inert by hand.

Command chords are dropped, and it is not the IME. An arrow keyup that still carries the Command flag never reaches before-input-event; the same keyup without it does. Plain ABC with no IME anywhere behaves identically, so this is a macOS/Chromium Command-modifier behaviour rather than an input-source decision. 14 of 16 Command chords lost it.

The two that survived are the useful part. In both, the human happened to release Command before the arrow, so the keyup carried mods=[]:

flagsChanged kc=55 mods=[]        <- Command released first
keyUp        kc=124 mods=[]
R2 keyUp     key='ArrowRight'     <- delivered

Since you remember the press rather than re-reading the release, that release does resolve the remembered chord. So Cmd+← during a composition works or does not depending on which key the user lifts first — order-dependent rather than a flat "never". Worth pinning whichever way you decide it should go.

During a Japanese composition the Command byte never reaches the pty at all, at chord time or at flush. The complete pty write list for that segment is さ\x03\x03, \x03, \x03 — the Ctrl-C's the human pressed were queued and flushed alongside さ, so the queue was working and simply had no Command byte to carry.

Synthesis is vindicated. Every human cell matches its synthetic counterpart exactly — rung 1 records postedByPid: 0 for hardware and the osascript pid for synthesis, so the two runs are directly separable. Your bare-page captures can be trusted on this point; the earlier disagreement was mine to own, not a tooling problem.

One thing for the maintainers rather than for you. You flagged that a queued chord "looks inert until conversion ends". The measurement says that invisible interval is not a fixed cost but an unbounded one: it lasts as long as the user keeps composing, so a long phrase means a longer silence before the cursor jumps. Deferred-and-delivered still beats dropped, and I would not change it on my own reading. But Korean gets commit-in-place for free — its compositionend lands 8 ms after the chord keydown and the movement byte ships in the same write — and that is both what #12871 asked for and what Terminal.app does, so whether Japanese and Chinese should be steered there seems worth an explicit call from @nwparker rather than falling out of the mechanism.

Traces from the human run — five rungs, 65 chords classified individually, with the clock alignment — are yours if useful, and I can add the Kotoeri Option+← case to the e2e spec so the working path is pinned too. Say the word; otherwise I will leave the branch as it is.

macOS delivers no keyup for a key released while Command is held, so the
release-keyed recovery for stablyai#12871 could never fire for a Cmd chord. Recorded at
Chromium's own input dispatch as well as in the page: Cmd+Left arrives as a
keydown with nothing after it, while Option+Left and a bare arrow both deliver
their keyup, and defaultPrevented is false throughout — nothing in the app
consumes it, it is never made.

The Command key's own release does arrive, still marked as composing, and it
ends the same gesture. Letting it answer for a pending Cmd chord fixes that half
without touching the Option half or the design. Korean cannot double-fire: it
commits on the chord, so its arrow keyup arrives first and spends the carry, and
both releases report the composition already over.

Earlier captures missed this because System Events folds a modifier into the
target key's flags and never produces its own press or release. The new probe
posts it as its own key event, the way a hand types it, and the contributor's
native spec now drives its chords the same way.

Verified in a dev build: the pty receives e38195010a — さ, \x01, newline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mu6j33LNsd4Mvv8a1NuFoo
@kunsanglee

Copy link
Copy Markdown
Contributor Author

Your correction lands, and it left one piece on the table that turns the order-dependence into a fix.

The Command key's own release is delivered, and the composition is still live on it. You showed the arrow keyup survives only when the human happens to lift Command first. I went looking for what marks the end of the gesture in the other 14 cases, and it is Meta itself. In-app, Kotoeri, さ composing:

keydown Meta       keyCode=91   meta=true   isComposing=true
keydown ArrowLeft  keyCode=229  meta=true   isComposing=true
keyup   Meta       keyCode=91   meta=false  isComposing=true

No arrow keyup between them, exactly as you measured. But the last row is the same signal the recovery already waits for, just carried by the other key in the chord. So the carry accepts either release:

function releasesPendingImeChord(chord: PendingImeChord, event: KeyboardEvent): boolean {
  return chord.code === event.code || (chord.metaKey === true && event.key === 'Meta')
}

That is the entire logic change. Cmd+← during a composition no longer depends on which key the user lifts first: lift the arrow first and its keyup resolves the carry as it already did in your two surviving cells, lift Command first and the Meta keyup does. One slot, so whichever arrives first spends it.

Why my earlier captures could not see this. key code 123 using command down folds the modifier into the target key's flags and never emits its own press or release. Every synthetic recording on this branch was therefore missing the one event the fix needs — the signal was absent from the measurement, not from the platform. That is the difference between your human run and mine, and it is the more useful half of "synthesis is vindicated": it reproduces what it posts, and using command down does not post a modifier key. The new probe posts it as a CGEvent in its own right, and tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts now drives its chords that way too.

Your Kotoeri case passes. With さ composing, Cmd+← reaches the pty as e38195010a — さ, \x01, newline — which is the expectation you wrote. All four cases in that spec are green; nothing was relaxed.

Korean cannot double-fire through the new path, for two reasons rather than one. It commits on the chord, so its arrow keyup does arrive — after compositionend, isComposing: false — and spends the carry without firing; the Meta keyup that follows finds nothing armed, and would decline on the same isComposing check anyway. Both are on the branch as recorded traces alongside the Japanese one.

One more thing worth having on the record. I also measured a layer below the page, at Chromium's before-input-event, with no IME involved at all. Cmd+← arrives as a keydown with defaultPrevented: false and nothing after it; Option+← and a bare ← both deliver their keyup there. That rules out this app consuming the release — it is never made. tests/e2e/terminal-macos-chord-input-pipeline-probe.spec.ts is that capture, kept on the branch because the finding is not recoverable from reading the fix.

Yes to both offers. The five-rung human traces are useful, particularly the Chinese cells — the PR body no longer says Chinese is untested, it now says Pinyin and Zhuyin are affected on your measurement, and a trace would let that be pinned rather than cited. And please do add the Kotoeri Option+← case; the working path deserves a test that fails if it stops working, not just the broken one.

On the unbounded interval before a Japanese phrase flushes: I agree that is a product call rather than a mechanism bug, and I would rather @nwparker make it explicitly than have it fall out of the queue. Worth noting that it is now the only remaining asymmetry between the input sources here, since the Command half is no longer one.

@ethznn

ethznn commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Both delivered, on ethznn:test/ime-chord-round4 off 84517d74b:

  • 8e5a9355 — the Kotoeri Option+← case, looped beside the Command one in the same test rather than duplicating the setup. Green at さ\x1bb\n: same shape as Cmd, queued behind the preedit and drained at the commit. I ran your spec unmodified on this head first and confirmed 4/4, including the Kotoeri Cmd+← expectation now reaching e38195010a.
  • 878ae5ef — the Chinese traces, as fixtures rather than a citation. A new data module wired into your rig with a two-line diff (import + spread, the way COMMAND_RELEASE_TRACE_CASES goes in), plus four Chinese e2e cases.

Three fixture cases, all measured on 84517d74b:

  1. Zhuyin Cmd+← over a live 你好 preedit — no arrow keyup exists, the Meta release ends the gesture, \x01.
  2. Pinyin Cmd+← with a compositionupdate mid-gesture — the carry has to survive the IME editing its own preedit while the chord is pending.
  3. Pinyin Option+← — the arrow's own keyup spends the carry, \x1bb. The working path, pinned so it fails if it stops.

The Command cells are hand-checked. Pinyin preedit live, Cmd+←, then commit: the pty line reads nihao then \x01, byte-for-byte what the recording says, and with an emacs keymap the cursor lands at line start. So the fix holds on Chinese by hand, not only in the rig.

One case I recorded and then dropped, deliberately. Zhuyin Option+← came out of the rig committing on the chord — three releases against an already-ended composition. By hand it does not: the composition block survives the chord and the byte flushes at the commit, like Pinyin and Kotoeri. I re-recorded it under three synthesis regimes — a flat 80 ms modifier, hardware medians measured off a real keyboard (190/110/160 ms, with a genuine flagsChanged), and an exaggerated 700/400/500 ms — and all three committed on the press. So it is not a timing artifact; synthesis and hardware diverge there, and I would rather not pin a recording no hand can reproduce. The file header says so, with the regimes named, so it does not get re-added from the raw trace. The e2e case for it now asserts only the byte contract, which holds under either behaviour.

Rig 26/26, the Chinese e2e suite 9/9, oxlint and typecheck:web clean. The diff into your files is the two lines.

Unrelated observation, in case it is not just this machine: terminal-macos-korean-chord-commit-native › commits the preedit before physical Ctrl+Enter fails here on 84517d74b with my files reverted, 2/2 on repeat.

@kunsanglee

Copy link
Copy Markdown
Contributor Author

Both landed here. test/ime-chord-round4 fetched onto 84517d74b: rig 26/26, and the diff into my files is the three lines you described.

On the Zhuyin Option+← you dropped — that is the right call, and for a reason worth stating in general. A recording is only worth pinning when the synthesis that produced it makes the same events a hand does. Three regimes agreeing among themselves is not evidence the regime is faithful; it is evidence the divergence is in the synthesis, not the timing. Pinning it would have encoded the driver, and a later reader would have read it as the platform. The header naming the regimes is what stops it coming back from the raw trace.

Your Korean observation: not your machine, and not this branch

Reproduced, then bisected.

So it has never passed. The cause is in the harness, not the product:

terminal-ime-byte-reader.ts splits on 0x0a and only emits a captured line when a newline arrives:

let newlineIndex = pending.indexOf(0x0a)
while (newlineIndex >= 0) { ... }

The Shift+Enter row ends in \r, the tty converts it to \n, the line closes, one capture. The Ctrl+Enter row expects 하 하 하�[13;5u — CSI-u carries no newline, so the line never closes and the reader times out at zero captures. Expected: 1, Received: 0, which is what both of us see. Not a wrong-bytes failure; a no-line-ever failure.

It stays green in CI because the spec is gated behind ORCA_E2E_NATIVE_MACOS_KOREAN=1, which CI does not set. It only runs when someone opts in locally, which is why it took a second machine to surface.

Fixing it means either terminating the capture some other way for chords that emit no newline, or asserting at the renderer the way the sibling spec does at korean-ime-terminal-shift-enter-commit.spec.ts:384. Either is #13128's spec, not this PR's, so I would rather not fold it in here — happy to take it separately unless you have already started.

Thanks for running it with my files reverted. That framing is what made the bisect one step instead of three.

@kunsanglee

Copy link
Copy Markdown
Contributor Author

Took it separately as promised: #13277, off main, test-only.

A plain Return follows the chord for the row whose bytes do not end a line, and the expectation carries the LF it produces. Unmodified is safe because the chord already committed the preedit — the assertion's leading 하 하 하 is what proves that, since a surviving preedit would eat the Return and put its own commit ahead of the chord's bytes. Same idiom the cursor-chord spec uses to get a line out of this reader.

Also dropped the renderer field from the chord table. Its comment claimed both forms were asserted; nothing ever read it.

2/2 on three consecutive runs here, against the reproduced failure at all three commits I bisected.

@nwparker

nwparker commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

I will read above shortly

kunsanglee and others added 2 commits August 10, 2026 01:27
main reverted stablyai#13128 (`17cfc968cf`, PR stablyai#13282), which this branch was built
on. Two conflicts, both from that revert.

`terminal-pane/AGENTS.md` — took the deletion. The file was stablyai#13128's and the
revert removed it; this branch only reformatted one line of it.

`keyboard-handlers.ts` — took main's restored file and reapplied this
branch's additions on top:

- No IME gate in `resolveTerminalKeyboardShortcutAction`. This branch had
  narrowed stablyai#13128's blanket gate; with the blanket gate reverted away, adding
  the narrowed form back would newly suppress IME-owned events main routes
  through here (the Windows composing-Enter path among them).
- The keydown yield is scoped to the exempt chords. stablyai#13128 yielded on every
  IME-owned keydown; now only a chord the IME can swallow yields, and every
  other IME-owned key keeps main's restored path.
- The composing-Enter deferral moved out of `runShortcutAction` into a
  caller-supplied callback. It reads the live keydown and its
  `imeProcessEnter`; a chord recovered from a release has neither, and is
  never Enter.

KNOWN RED, and not fixable by merging: 14 cases in
`keyboard-handlers.issue-12871-recorded-chord-traces.test.ts`.

The revert changed where terminal shortcut bytes go. Before, they went
through `pane.terminal.input(...)` — xterm held them behind a live preedit and
released them at commit, which is the standing safety argument in this
branch's own comments. Now they go through `sendCapturedTerminalInput` →
`transport.sendInput(...)`, straight to the pty with nothing holding them. So
a swallowed chord recovered during a live composition lands AHEAD of the text
still being composed, and the suite's spies watch a sink the bytes no longer
reach.

The redesign rides main's own `sendTerminalInputAfterComposition`
(`terminal-ime-deferred-newline.ts`), which waits for the composition to
commit. Its open decisions are enumerated in
`.loop/spec/fix-ime-deferred-terminal-shortcut-input/` and land in a separate
commit, so this one stays what it is: taking the revert.

The other five red IME suites under `terminal-pane/` are main's own — 62
failures on `upstream/main` untouched, the same 62 here.
A chord the IME swallows is recovered from its release, and until now the
bytes went out there. They no longer travel through xterm: stablyai#13282 moved
shortcut input onto the transport, which reaches the pty directly, so a
release that fires mid-preedit puts the chord ahead of the text still being
composed. With 가나 on the line and 다 in the preedit, Cmd+ArrowLeft yielded
다가나 — the cursor moved to line start and the syllable landed there.

The release now defers through sendTerminalInputAfterComposition, with no
time bound. The 200ms fallback is right for a newline, where arriving late
still means arriving; a cursor chord sent while the preedit is open is the
corruption the wait exists to prevent, and a Japanese conversion holds its
candidate window open far longer than that. An abandoned composition drops
the chord instead, which costs one keypress rather than a mangled line.

Routing the chord back through terminal.input() was the smaller change and
does not work: on the patched xterm this tree builds, a byte handed to
input() during a live composition is dropped outright rather than queued
behind the preedit. The recordings that show it queued predate that.

A chord remapped onto a pane command keeps firing immediately. Deferring it
too would let the commit land after the pane had already cleared or closed,
which loses the text rather than ordering it.

No platform check: what arms the recovery is a release that still reports
itself composing, which an input source either produces or does not. Gating
it to macOS was tried and moved nothing measurable, so it would have been an
untested claim.

The trace rig was watching terminal.input and terminal.onData, so every
chord assertion had been reading an empty channel since stablyai#13282. Both records
now follow the bytes down either route, and expectEmitted compares as a
joined stream because the captures were taken when xterm flushed the preedit
and the chord as one payload while the transport writes each on its own.

Two captures stop mid-composition, where the contract is now "held, not
sent". They assert that, then drive the commit the recording does not
contain and check that the chord lands after it. The appended commit is
marked as authored so it cannot be read as captured.

Two expectations went stale when an earlier merge changed the bundled xterm,
and both are about what xterm emits rather than what this handler sends. It
no longer commits a session it never saw start, so a capture that opens
mid-gesture flushes nothing; and it now commits a cancelled composition from
its own buffer instead of the cleared helper textarea. The cancel case stops
pinning whether that text arrives and pins that the chord is last either way.

The three macOS e2e specs this branch adds are hand-run — no workflow calls
them and they need real input sources — and now say so in a header line, so
a red local run is not read as a regression of this change.

Not touched: the two symptoms stablyai#13282 gave for the revert (full-width
punctuation arriving as ASCII, an invisible Korean preedit). Both live in
xterm's composition rendering, which nothing here goes near.

Local: src/renderer/src/components/terminal-pane is 251 files / 3230 tests,
all passing. tsc clean on all three projects, oxlint and oxfmt clean,
reliability gate manifest passes for 73 gates, max-lines ratchet OK at 352.

Measured after installing this worktree's own dependencies. Worktrees here
resolve node_modules from the enclosing checkout, which was serving an xterm
patch hash this lockfile does not ask for; the 62 failures seen before that
install were entirely that mismatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nwparker

Copy link
Copy Markdown
Contributor

sorry I might not have looked. Let me look now

@nwparker

Copy link
Copy Markdown
Contributor

Heads-up: I landed a narrower fix for the ordering half of this in #14730 (a663f1b), which closed #12871.

Your diagnosis is what identified it — the recorded traces and the two-defect split in your description are what I worked from, and the PR body says so. What I shipped is only Defect 2: hold a sendInput chord until the composition session has flushed. It reuses the existing sendTerminalInputAfterComposition with a new fallbackMs: null option, for the reason you gave — the 200 ms fallback is right for a newline and wrong for a chord, since a conversion holds its candidate window open far past it.

Defect 1 is still unfixed and still yours. I did not touch the swallowed-chord recovery: it is Japanese-only, I could not reproduce it, and as you point out, acting on a release that is not still composing double-sends on Korean where the platform already replays. That reasoning is the part of this PR I could not have derived myself.

Two things that may help if you rebase:

Also worth knowing: CI has never run on this branch (no checks reported). For +2640/−31 that is worth fixing before review.

If you would rather rebase onto a663f1b and keep this focused on Defect 1, I think that lands faster.

@nwparker

Copy link
Copy Markdown
Contributor

Thanks again @kunsanglee !

Like the msg above states, the 2nd part couldn't be reproduced...
I will close for now but please re-open if it's not fixed in main 🙏

@nwparker nwparker closed this Aug 15, 2026
@kunsanglee kunsanglee changed the title fix(terminal): send IME-swallowed chords once, from recorded traces fix(terminal): send a composing cursor chord once, not twice Aug 15, 2026
@kunsanglee

Copy link
Copy Markdown
Contributor Author

Continued in #14742 — GitHub refuses to reopen a PR whose head branch was force-pushed after closing, and I pushed the rebase before trying to reopen. Same branch, rebased onto #14730 and down to one commit.

Short version of what changed: the Japanese half you found unreproducible really is fixed on main, and I was wrong to leave it framed as outstanding. What is still there is a Korean one — Option+Left mid-composition resolves twice, once from the marked keydown and once from the platform's unmarked replay, so it jumps two words. Cmd+Left hides it by being idempotent. Measurement against a663f1b is at the top of #14742.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Cmd/Option+Arrow during IME composition moves the composing character to the cursor destination (Korean & Japanese, macOS)

5 participants