Skip to content

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

Open
ethznn wants to merge 3 commits into
stablyai:mainfrom
ethznn:fix/ime-chord-sent-once
Open

ethznn wants to merge 3 commits into
stablyai:mainfrom
ethznn:fix/ime-chord-sent-once

Conversation

@ethznn

@ethznn ethznn commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

ELI5

When you press Option+← while a Korean syllable is still being typed, the cursor jumps two words instead of one. Orca holds the arrow back until the syllable lands — that part is right — but macOS then hands the same press over a second time, and Orca sends it again. This makes it keep the second copy instead of sending it.

What Changed

A chord held for a live composition now owes one absorb credit, and the keydown path spends it on the replay that follows.

The identity is the one the Enter path already uses — Pick<KeyboardEvent, 'code' | 'timeStamp'> — because Chromium keeps the original timeStamp when it re-dispatches an event. A genuine second press carries its own timeStamp and is not absorbed.

Three files, and nothing outside the composing-chord path moves.

Why

2-Set Korean treats a cursor chord as a commit trigger: the composition ends on the chord, and the platform then replays that press unmarked. Both copies resolve to the same action, so the held chord fires on compositionend and the replay fires straight through:

keydown  ArrowLeft  isComposing: true   → deferred, waits for the commit
compositionend                          → deferred send fires   \x1bb
keydown  ArrowLeft  isComposing: false  → same press, replayed  \x1bb

Cmd+← hides it because \x01 is idempotent — two of them land where one does. Option+← does not.

Japanese and Chinese conversions swallow the chord inside a live preedit instead of committing and replaying it, so nothing is replayed there and the credit is simply never spent. It goes when the deferral settles, so an unspent credit cannot sit waiting to eat a later press.

#14730 and #15017 fixed the ordering — the syllable no longer travels to the cursor destination, which is what #12871 was about. The count was never part of that issue and is not addressed by either: #15017 discards a timed-out chord rather than sending it, which does not deduplicate.

Linked Issue

Fixes #17616

Visual Proof

가나 다라 마바사 with 사 held in the preedit, one Option+←:

cursor lands
v1.4.192 before 다라 — two words
this branch at the start of 마바사 — one word

The identity this relies on is measured rather than assumed. Logging keydown on a text input under 2-Set Korean, one Option+← over a preedit produces two ArrowLeft keydowns reporting the same timeStamp:

keydown  ArrowLeft  timeStamp 23884.000  isComposing: true
keydown  ArrowLeft  timeStamp 23884.000  isComposing: false

That is the replay, and it is what the credit is keyed on.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Reproduced by hand on v1.4.192, macOS, 2-Set Korean, and the measurement above is from the same machine.

terminal-ime-deferred-chord.test.ts gains four cases:

  • the replay of a held chord is absorbed exactly once
  • a different timeStamp, and a different code, both go through — a genuine second press is not eaten
  • the credit is gone once the deferral settles on compositionend
  • the credit is gone when the chord is abandoned at the ceiling

The second is the one that would catch this change going wrong rather than restate it: absorbing too eagerly would swallow real input, which is worse than the bug.

AI Disclosure

Claude Opus 5 via Claude Code, used throughout: reading the existing Enter path, writing the change and its tests, and running the gates. The reproduction and the timeStamp measurement are mine on my own machine, and every figure quoted here came from running the command rather than an estimate.

Review

Code review summary, per CONTRIBUTING:

  • Cross-platform. No platform branch is touched. The credit is only ever created by the composing-chord deferral, which is the same on every platform; where no replay arrives the credit is never spent.
  • Remote / SSH. Renderer-side input handling only. Nothing crosses the wire, and the bytes that reach the pty are unchanged except that the duplicate is no longer among them.
  • Agents and integrations. Nothing keys off which agent is running. Any TUI reading cursor chords benefits.
  • Performance. One Map lookup on the keydown path that already resolves a shortcut action, and one small entry per held chord, removed when the deferral settles. No timer, listener or layout read is added.
  • Security. No new input sink, no network, no filesystem. The identity is a code string and a numeric timeStamp.
  • Backwards compatibility. defer takes the identity as a new first argument; the only callers are in this repo and both are updated. Behaviour with no replay is what it was.

Known limitation, deliberately out of scope: a chord swallowed by a Japanese or Chinese conversion still produces no byte until the composition ends. That is a different defect with a different fix — it needs the chord recovered from the key's release — and it has no issue of its own yet.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

The double send was found and analysed by @kunsanglee in #14742, whose first commit describes the mechanism exactly. That PR was closed as superseded because #12871 — the ordering defect — is genuinely fixed; the count half had no issue of its own, which is why #17616 exists now. This is the narrow fix for that issue alone and does not carry #14742's broader release-keyed recovery, which remains the right shape for the swallowed-chord case and is theirs to land.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

2-Set Korean ends the composition on a cursor chord, and the platform then
replays that press unmarked. Both copies resolve to the same action, so the
chord held for the composition fires on commit and the replay fires straight
through: one `Option+←` walks the cursor two words. `Cmd+←` hides it because
`\x01` is idempotent.

A held chord now owes one absorb credit, spent on the replay that follows. The
identity is the one the Enter path already uses — `code` plus `timeStamp` —
because Chromium keeps the original timeStamp when it re-dispatches an event.
Measured on stock macOS: both ArrowLeft keydowns of a single Option+← over a
preedit report timeStamp 23884.000. A genuine second press carries its own and
is not absorbed.

Japanese and Chinese conversions swallow the chord instead of replaying it, so
the credit is never spent there and goes when the deferral settles, rather than
waiting to eat a later press.

stablyai#14730 and stablyai#15017 fixed the ordering this shares a file with — the syllable no
longer travels to the cursor destination, which is what stablyai#12871 was about. The
count was never part of that issue.

Fixes stablyai#17616
@greptile-apps

greptile-apps Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Greptile Summary

The PR adds identity-scoped replay absorption for terminal cursor chords deferred during IME composition.

  • Tracks deferred chords by keyboard code and event timestamp.
  • Absorbs one matching redispatched keydown after composition.
  • Expires unused credits and clears them during pane teardown.
  • Adds focused coverage for replay identity, single-use credits, expiry, and abandonment.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.ts Adds bounded, identity-keyed state for deferred chord replay absorption and lifecycle cleanup.
src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts Passes chord identity into deferral and suppresses a matching non-composing replay before terminal input is sent.
src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.test.ts Covers one-time absorption, identity mismatches, expiry, abandonment, and replay timing after composition.
src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts Updates the keyboard-handler test double for the new replay-absorption method.

Sequence Diagram

sequenceDiagram
    participant OS as macOS IME
    participant Handler as Keyboard handler
    participant Sender as Deferred chord sender
    participant PTY as Terminal PTY
    OS->>Handler: keydown (composing, code + timestamp)
    Handler->>Sender: defer(identity, send)
    OS->>Sender: compositionend
    Sender->>PTY: send chord once
    OS->>Handler: redispatched keydown (same identity)
    Handler->>Sender: absorbRedispatchedChord(identity)
    Sender-->>Handler: true
    Handler--xPTY: duplicate suppressed
Loading

Reviews (3): Last reviewed commit: "docs(terminal): record that the replay i..." | Re-trigger Greptile

@coderabbitai

coderabbitai Bot commented Aug 31, 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: Team

Run ID: 968ce7a0-7b57-4237-a5df-e1022e6ae6ff

📥 Commits

Reviewing files that changed from the base of the PR and between ac9948deadacc3f5079efd518c1709fb657273ac and d7ce5b4.

📒 Files selected for processing (1)
  • src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The deferred IME chord sender now tracks held chords by code and timeStamp. It retains one replay credit after composition settlement for a one-second window. The keyboard handler absorbs matching redispatched presses and sends other resolved input. Tests cover credit consumption, distinct presses, expiration, abandonment, cancellation, and updated fixtures.

Merge Risk: 🔵 Low · up to d7ce5

The PR prevents duplicate cursor-chord sends, but a bounded lifecycle edge could rarely suppress or misorder later terminal input if identical events are reused around cancellation. It is mergeable with explicit owner awareness or follow-up on generation-safe cleanup.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary fix: preventing a composing terminal chord from being sent twice.
Description check ✅ Passed The description includes all required sections, explains the problem and solution, links issue #17616, documents testing and limitations, and completes the checklist. The visual proof is described in …
Linked Issues check ✅ Passed The implementation directly addresses issue #17616 by absorbing the replayed composing chord, preserving genuine subsequent presses through code and timeStamp identity checks, and cleaning up unused c…
Out of Scope Changes check ✅ Passed The changes are limited to the composing-chord deferral logic, its keyboard-handler integration, and related tests. No unrelated code changes are identified.
Full details: Description check

Explanation

The description includes all required sections, explains the problem and solution, links issue #17616, documents testing and limitations, and completes the checklist. The visual proof is described in text rather than provided as an attachment, but the description remains substantially complete.

Full details: Linked Issues check

Explanation

The implementation directly addresses issue #17616 by absorbing the replayed composing chord, preserving genuine subsequent presses through code and timeStamp identity checks, and cleaning up unused credits. The automated tests cover the required behaviors.

  • Fix all pre-merge checks with AI

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.

@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: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e0552dc-6cdb-4263-8592-77e7b7cec2f8

📥 Commits

Reviewing files that changed from the base of the PR and between 5897b7b and b0e0424.

📒 Files selected for processing (4)
  • src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.test.ts
  • src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.ts
  • src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts
  • src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment thread src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.ts Outdated

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

  • Chord identity + absorb credit — defer now takes { code, timeStamp }, records a single-use absorb credit keyed by that identity, and absorbRedispatchedChord consumes it exactly once.
  • Keydown handler wiring — non-composing sendInput keydowns consult absorbRedispatchedChord before sendResolvedInput, so the platform's unmarked replay of a committing chord is dropped while a genuine second press (own timeStamp) passes through.
  • Tests — four new sender cases pin the absorb-once behavior, the different-timeStamp/different-code passthrough, and credit release on both settle and abandon.

The mechanism mirrors the already-shipped Enter path (terminal-ime-deferred-newline.ts), including the code+timeStamp identity and the ordering where the replayed keydown arrives before the setTimeout(0) deferred send. Targeted tests, tc:web, and the changed-files quality gate all pass.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

  • Credit outlives the composition — settle (formerly stopWaiting) now clears the hold and arms a TERMINAL_IME_CHORD_REPLAY_WINDOW_MS (1s) expiry instead of forgetting the chord immediately, so the unmarked replay that lands after the commit can still spend its credit.
  • State machine folded into the map — DeferredChordState now carries the stopWaiting settle closure and expiryTimer, replacing the old pendingStops set; absorbRedispatchedChord forgets the state as soon as it is spent-and-settled, and cancelPending collects closures and clears timers.
  • Tests pinned to the real ordering — six sender cases model the replay-after-commit sequence, the still-held spend, different-code/timeStamp passthrough, and credit release on spend, settle, and abandon.

The ordering assumption from the prior review (replay before the deferred-send macrotask) was wrong, and this delta fixes exactly that: the credit is now measured against the correct sequence, and both orderings are handled. Targeted tests and check:code-quality:changed pass.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@kunsanglee

Copy link
Copy Markdown
Contributor

Not taking it — keep yours. The keyboard file was split by #16335 after I last touched it, so my version would need re-homing across several new modules to do what your 131 lines do inside the sender that already owns the wait.

Could the commit carry

Co-Authored-By: kunsanglee <85242378+kunsanglee@users.noreply.github.com>

The timeStamp measurement your commit message cites comes from the probes in #14742, and the two-word jump is what those recordings were taken to pin down.

I ran #14742's recorded traces against ac9948de to see what carries over. 22 of 31 pass unchanged. Of the 9 that don't, 4 are my fixtures' limitation — they record type/key/code/keyCode but no timeStamp, so each replayed keydown gets a fresh one and the credit can never match. 4 more assert which event carries the byte, which is a statement about my routing rather than about the recording; on the Cmd+ArrowRight trace mine expects nothing where yours sends \x05, and the captured PTY line for that session is 가나나\x05\x1b[C, so yours is the one matching the hardware there. The last is a remap not resolving when the input source rewrote key to Process — Process isn't in PHYSICAL_CODE_FALLBACK_KEYS, which predates this and is orthogonal to #17616.

The part worth raising: the property the fix rests on — Chromium keeping the original timeStamp on re-dispatch — isn't covered by a test on either side. Your unit tests hand absorbRedispatchedChord a matching timeStamp directly, so they pin the absorb logic given that premise, not the premise. And recording timeStamp into my fixtures wouldn't fix that — a frozen fixture asserts the value I put in it, so if Chromium ever stops preserving it the fixture still passes. Only a hardware assertion can fail on that, and nothing sets the gate for the macOS IME specs in CI. Not asking you to solve it here; flagging it so 23884.000 doesn't end up load-bearing and unrecorded.

Happy to port the portable fixtures whenever it's useful — before or after this merges, your call.

ethznn and others added 2 commits September 1, 2026 11:18
The credit died with the composition that issued it, which is exactly when it
was needed: on 2-Set Korean the chord ends the composition, the commit releases
the held chord, and only then does the platform replay the same press. The
absorb therefore found nothing and the chord still went twice — the fix did not
work.

Hold the credit past the commit instead, on its own short window. That is safe
because the identity carries the press's own timeStamp, so no later press can
spend it; the window only stops a credit that is never claimed — Japanese and
Chinese conversions swallow the chord and never replay it — from sitting in the
map for the life of the pane.

The tests missed this because none of them ran the real order. One absorbed
straight after `defer`, before any commit, and another asserted the credit was
gone after `compositionend` — pinning the defect as intended behaviour. Both
are replaced by cases that dispatch the commit first, and reintroducing the old
`forget` on settle now fails two of them.

Also pins that a credit is spent while the chord is still held: with the entry
still in the map, a check that never decremented would swallow every replay of
that press, and no case caught that.

Co-Authored-By: kunsanglee <85242378+kunsanglee@users.noreply.github.com>
The absorb keys on Chromium preserving an event's `timeStamp` across a
re-dispatch. That was measured by hand, and nothing in the tree asserts it: the
unit tests hand the absorb a matching timeStamp, which pins what the credit
does given the premise rather than the premise, and a recorded trace would not
close it either — a frozen fixture asserts the value written into it and keeps
passing if the field stops being preserved. Only real hardware can fail on it,
and the macOS IME specs that could carry the assertion do not run in CI.

Written down so the observation is not load-bearing and undocumented.

Co-Authored-By: kunsanglee <85242378+kunsanglee@users.noreply.github.com>
@ethznn
ethznn force-pushed the fix/ime-chord-sent-once branch from ac9948d to d7ce5b4 Compare September 1, 2026 02:19
@ethznn

ethznn commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Added, on both commits — thank you, and for running the traces against it rather than taking my word for the behaviour.

The Cmd+ArrowRight cell is the useful one there: your fixture expects nothing where this sends \x05, and the captured PTY line for that session is 가나나\x05\x1b[C. That is a difference in routing worth keeping visible rather than smoothing over, and the recording is what settles it.

On the premise — you are right, and it is the part of this I am least comfortable with. Recorded it in d7ce5b4 rather than leave 23884.000 sitting in a commit message: the unit tests hand the absorb a matching timeStamp, so they pin what the credit does given the premise and not the premise, and a frozen fixture would keep passing if Chromium stopped preserving the field. A note is not a test, but at least the next person reading this knows which floor is not load-bearing.

Yes to the fixtures, and after this merges if that suits you — the four cells that cannot match today need timeStamp in the recording, which means re-capturing on hardware, and that is a change to your traces rather than a port of them. I would rather that land as its own thing with your name on it than get folded in here.

This branch has not been deployed

No deployments
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]: A cursor chord over a composing Hangul syllable is sent twice — one Option+← jumps two words

2 participants