Skip to content

fix(terminal): hold a cursor chord until the composing syllable commits - #14730

Merged
nwparker merged 4 commits into
mainfrom
nwparker/ime-composing-chord-order
Aug 15, 2026
Merged

nwparker merged 4 commits into
mainfrom
nwparker/ime-composing-chord-order

Conversation

@nwparker

@nwparker nwparker commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #12871. Reproduced by hand on macOS with 2-Set Korean, then pinned at the pty by a test that fails on main.

The bug

The composed glyph reaches the pty from the composition session-end handler, which runs after the chord's keydown. Only Enter was held for that; every other chord went straight out on the transport and overtook the text it was typed after.

With 가나 on the line, typing 가나다 and pressing Cmd+Left:

transport.sendInput "\x01"    <- cursor to line start, arriving first
terminal.input      "다"       <- the syllable, arriving second

On screen: 다가나. QWERTY keys to reproduce with 한 active: r k s k e k, then Cmd+Left.

Why Korean shows it even though isComposing is false. The platform replays the chord unmarked after keyup, so by the time it resolves isComposing === false — but xterm has not yet emitted the session end that writes the syllable, so hasPendingTerminalImeComposition is still true. Gating on either condition covers that shape and the still-marked one.

The change

+13 / −1 in production code, and the single deletion is a comma on an import line.

A sibling condition rather than a nested one, deliberately: nesting the new branch inside the Enter condition re-indented the entire Enter block, which is exactly the diff shape that can silently alter it. As written, the Enter path does not appear in the diff at all — that is the regression argument, and it is checkable rather than asserted.

No fallback timer on this path. The 200 ms fallback is right for a newline — arriving late still means arriving. It is wrong here: a conversion can hold its candidate window open for seconds, so the timer would fire mid-preedit and reproduce exactly the corruption the wait exists to prevent. A composition that never ends drops the chord instead, costing one keypress rather than a mangled line. sendTerminalInputAfterComposition grows a fallbackMs: null option for that.

Pane commands are unaffected — a chord remapped onto terminal.clear or closePane is not a sendInput action and never reaches this branch. Waiting would let committed text land after the pane had cleared.

Evidence it discriminates

Both layers were run against keyboard-handlers.ts restored from main:

Unit — 3 of 4 fail: chord must not reach the pty while the glyph is pending: expected [ '\x01' ] to deeply equal []

E2E, at the pty — real composition via CDP, chord pressed mid-preedit, bytes read where the two routes merge:

pty bytes
main 01 eb8ba4 0a — chord first
this branch eb8ba4 01 0a — syllable first

That is the reported corruption byte-for-byte, and its absence afterwards.

The e2e pins the renderer to macOS via applyImePlatformPolicy. That was not cosmetic: Cmd+Left resolves to \x01 only under the macOS branch of the shortcut policy, so on a Linux shard the chord produced no byte and the spec passed by measuring nothing. It failed in CI for that reason before the pin, which is the failure mode expectImePlatformPolicy exists to make loud.

Review evidence

Scope

Narrower than #12732, which fixes this and a second defect: a chord swallowed outright by a Japanese conversion, recovered at keyup. That one is real and still unfixed, but it is Japanese-only, I have not reproduced it, and getting it wrong double-sends on Korean where the platform already replays. This is the half verified end to end.

No regression

  • 268 files / 3475 tests in the terminal pane, typecheck, oxlint, oxfmt clean
  • Full unit suite: 4903 files / 52,757 passed. Four src/main/ files (daemon, pty-subprocess, updater) fail under full-suite load; the same four fail worse on unmodified main (2 files / 6 tests, versus 1 file / 5 on this branch), so they are pre-existing load-dependent flakes unrelated to this path
  • Second macOS host: 268 files / 3474 passed, 1 skipped

The composed glyph reaches the pty from the composition session-end handler,
which runs after the chord's keydown. Only Enter was held for that, so every
other chord went straight out on the transport and overtook the text it was
typed after: with 가나 on the line, typing 가나다 and pressing Cmd+Left left
다가나, the composing 다 landed at the cursor's destination.

Defer any sendInput chord while a composition is live or its session has not
yet flushed. Korean 2-Set shows the shape most clearly — the platform replays
the chord unmarked after keyup, so isComposing is already false while the
session is still pending.

No fallback timer on this path. A newline arriving late still arrives, which is
what that timer is for; a chord arriving mid-preedit is the corruption the wait
exists to prevent, and a conversion can hold its candidate window open for
seconds. Dropping the chord costs one keypress, firing early costs a line.

Pane commands are unaffected: they are not sendInput actions.

Fixes #12871
@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a1e0d967-6aa2-40c9-a2d9-625e200476c9

📥 Commits

Reviewing files that changed from the base of the PR and between f93dd92 and 690998a.

📒 Files selected for processing (2)
  • src/renderer/src/components/terminal-pane/keyboard-handlers.ts
  • tests/e2e/terminal-korean-composing-chord-order.spec.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/e2e/terminal-korean-composing-chord-order.spec.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.ts

📝 Walkthrough

Walkthrough

The change defers non-Enter terminal shortcuts during IME composition until compositionend. The deferred-input helper now supports fallbackMs: null, which disables timer fallback. Existing Enter and Windows modified-Enter handling remains deferred through the existing newline flow. Tests cover Korean and Japanese composition, composing key states, long-lived compositions, timer suppression, ordered PTY bytes, and immediate shortcut dispatch without active composition.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes defer cursor chords until IME output is flushed, which addresses issue #12871 for Korean and Japanese composition.
Out of Scope Changes check ✅ Passed The implementation and regression tests remain focused on IME cursor-chord ordering and related deferred-input behavior.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly and concisely describes the main change: deferring cursor chords until the composing syllable commits.
Description check ✅ Passed The description clearly explains the bug, implementation, scope, linked issue, regression coverage, platform considerations, and test results.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

The unit coverage asserts the handler's ordering against a synthetic transport.
This asserts it where it is actually observable: the committed glyph and the
chord reach the pty by two different routes, and only their merged order is
visible to the shell.

Verified to discriminate — against keyboard-handlers.ts from main the same spec
reads 01 eb8ba4 0a, the chord ahead of the syllable, which is the reported
corruption byte-for-byte.

@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)
tests/e2e/terminal-korean-composing-chord-order.spec.ts (1)

1-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reduce the file header comment.

Keep one concise comment that states the PTY ordering contract. Remove issue history and test implementation details.

As per coding guidelines, “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4232e88c-e139-43b9-8ea5-7aedca70c006

📥 Commits

Reviewing files that changed from the base of the PR and between 1eb788c and f93dd92.

📒 Files selected for processing (1)
  • tests/e2e/terminal-korean-composing-chord-order.spec.ts

… the Enter path

Nesting the new branch inside the Enter condition re-indented the whole Enter
block, which is the kind of diff that can silently change it. Keeping them as
sibling conditions leaves the Enter path out of the diff entirely.
Cmd+Left resolves to \x01 only under the macOS branch of the shortcut policy, so
on a Linux shard the chord produced no byte and the spec passed by measuring
nothing — it failed in CI for that reason, not for the behaviour under test.
Pinning the platform is the established pattern for these specs, and
expectImePlatformPolicy fails loudly if the override does not take.
@nwparker
nwparker merged commit a663f1b into main Aug 15, 2026
47 checks passed
@nwparker
nwparker deleted the nwparker/ime-composing-chord-order branch August 15, 2026 07:13
kunsanglee added a commit to kunsanglee/orca that referenced this pull request Aug 15, 2026
stablyai#14730 fixed the order: a chord resolved mid-composition now waits for the
syllable to commit instead of overtaking it. It still resolves that chord on the
composing keydown, and on Korean 2-Set the platform then replays the same chord
unmarked after keyup, so both copies fire. Cmd+Left hides it by being idempotent;
Option+Left sends \x1bb twice and jumps two words.

Recorded on stock macOS: the two input sources are indistinguishable while the
key is down (both `code='ArrowLeft'`, `keyCode=229`, `isComposing=true`) and have
separated by the time it comes up. Korean has committed and reports the
composition over — its replay is on the way, so the press must stay silent.
Japanese conversion swallowed the chord whole, with no commit and no replay, and
is still composing at the release — nothing else will ever deliver it.

So an exempt chord (Cmd/Option/Ctrl over ArrowLeft/ArrowRight/Backspace/Delete,
never with Shift, which Japanese binds to resize a segment) is remembered on the
composing keydown and decided on its release. A release that still reports itself
composing runs the action; one that does not lets the replay answer. Cmd+Left
delivers no arrow keyup at all, so the Command release ends that gesture. The
snapshot is taken field by field because a KeyboardEvent keeps its fields as
prototype accessors, and it carries the modifiers as they were at the press.

Bytes still take stablyai#14730's deferral, now reached by both paths through one
condition rather than two.

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

keyboard-handlers-ime-composing-chord.test.tsx from stablyai#14730: its chord press now
runs to the release, because that is where a swallowed chord is resolvable and
what hardware delivers. Same assertions.

Fixes stablyai#12871

Co-authored-by: hyeonho <prxyeo@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjefjguNCzpY2kVq3Q69wH
kunsanglee added a commit to kunsanglee/orca that referenced this pull request Aug 15, 2026
…ce for

Three independent reviews of the previous commit, run without sight of each
other, agreed on where it overreached.

Arming is now macOS-only. Both behaviours the release reads are stock-macOS
captures: that a committing source replays the chord unmarked, and that Cmd+key
delivers no keyup. An input source that commits without replaying — unrecorded
for ibus and MS-IME — would arm, see a release that no longer reports itself
composing, hand the chord to a replay that never comes, and drop it. That is
worse than the ordering bug: stablyai#14730 delivered it late, this lost it. Elsewhere
the deferred send stands, now pinned by a test on a Windows user agent.

Reading the physical code moved out of the shared policy and into the snapshot,
which is the only consumer that needed it. The policy is also called by the
dashboard popout preview terminal, whose IME gate excludes modifier chords, so a
composing Ctrl+Left there resolved to bytes with none of the recovery around it —
the ordering bug reintroduced on a surface with no deferral. Direct calls into
both trees confirmed it: `{key:'Process', code:'Backspace', ctrlKey:true,
isComposing:true}` on win32 returned null before and `\x17` after. The snapshot
stores `event.code` as its key instead, which is correct because the caller has
already narrowed to four codes whose key is that same string. `'Process'` in the
shared keybinding matcher's physical-code fallback goes with it: it short-
circuited past a guard written on purpose for AltGr, and nothing needs it now.

The arming keydown consumes the press again, as it did before this branch
existed. Returning early skipped preventDefault and stopImmediatePropagation, so
a global handler bound to the same remapped chord acted on the press while the
release acted again — one gesture, two firings.

The carry is keyed by code rather than a single slot, following the native-only
tracker in the same pane. Two exempt keys can be down under one Cmd hold, and the
Command release ends both; a single slot dropped the first without a trace.

Also: selectAll joins switchInputSource in the release guard, since both arm the
native-only tracker from a press; the file-search branch stops cutting off a keyup
the comment three lines above it says must keep propagating; and the composition
session events carry the name of the patch that emits them, since upstream xterm
does not and a regenerated patch that drops them fails silently into an unbounded
wait.

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

Copy link
Copy Markdown
Contributor

thanks for reporting. looking into it

@AmethystLiang

Copy link
Copy Markdown
Contributor

thanks for reporting. looking into it

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)

2 participants