Skip to content

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

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

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

Conversation

@kunsanglee

@kunsanglee kunsanglee commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Supersedes #12732, which GitHub will not let me reopen — I force-pushed the rebase before reopening it, and that is a one-way door. Same branch, same author, one commit now.

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.

The remembered chord stores the physical code as its key, because a CJK source rewrites key to 'Process' (#12171, #13033). That is safe precisely there and nowhere else: the caller has already narrowed to four codes whose key is that same string when nothing rewrote it.

Arming is macOS-only. Both behaviours the release reads are stock-macOS captures. An input source that commits without replaying — unrecorded for ibus and MS-IME — would arm, find a release that no longer reports itself composing, hand the chord to a replay that never arrives, and drop it. That is worse than the bug #14730 fixed, which delivered late rather than not at all. Everywhere else the deferred send stands untouched, and a test on a Windows user agent pins it.

Not fixed here: the dashboard popout

preview-terminal-key-handler.ts is a second terminal keyboard surface with its own listeners, and its IME gate (isNativeTextKeydown) excludes modifier chords, so a Japanese conversion still swallows Cmd+Left there. #12871 remains open in the popout. An earlier revision of this branch leaked half a fix into it by putting the physical-code reading in the shared policy; that is why the reading now lives in the pane instead.

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:

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

The mechanism is 251 lines across three files. 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, 3524 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.

Every regression test added across the second and third commits was checked to fail without its fix: the non-mac one reads [] instead of ['\x1bb'], the two-chord one drops the first chord, the consumption one sees the press reach a later window listener, and the worktree-history one sees that listener get nothing.

Widened once across terminal-pane, dashboard-popout, shared and lib — 1186 files, 12617 tests, all passing.

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.

@coderabbitai

coderabbitai Bot commented Aug 15, 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

The change adds IME-exempt chord detection and release-based recovery for terminal shortcuts. It records pending composing chords, reconstructs matching keyup events, and clears state during blur and cleanup. New fixtures and tests cover Korean, Kotoeri, Chinese, ABC, remapped commands, composition ordering, and cancellation. macOS drivers, probes, Swift utilities, and gated end-to-end suites trace input through the main process, renderer, composition layer, terminal, and PTY.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description gives detailed scope, rationale, issue linkage, implementation notes, and test results, but it omits required template sections and visual proof. Add the required ELI5, What Changed, Why, Visual Proof or N/A, Testing, Review, Checklist, AI Disclosure, and Author sections.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address [#12871] by deferring macOS IME chords, preventing duplicate movement bytes, and protecting composing text from cursor relocation or overwrite.
Out of Scope Changes check ✅ Passed The added handler logic, fixtures, replay tests, and macOS diagnostic harnesses directly support the IME chord fix and contain no unrelated production changes.
Title check ✅ Passed The title clearly summarizes the main fix: preventing composing terminal cursor chords from being sent twice.

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: 5

🧹 Nitpick comments (8)
src/renderer/src/components/terminal-pane/keyboard-handlers.ts (1)

74-98: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shorten the explanatory comment blocks.

The coding guidelines require concise, brief comments and prefer one line. Lines 74-98, Lines 119-131, and Lines 1121-1128 are multi-paragraph narratives that walk through recorded trace behavior and code flow.

Keep the non-obvious facts, such as the Korean replay versus Japanese swallow distinction and the prototype-accessor warning at Lines 100-103. Move the recorded trace details and the TODO rationale into the trace fixture files or the PR description.

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

src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-exempt-chord-resolution.test.ts (1)

130-152: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a non-composing negative case to the gate table.

Both it.each tables force isComposing: true. No row proves that isImeExemptTerminalChord returns false when no composition is live. That is the condition which prevents the handler from arming a carry for a chord that already resolved on its own keydown. The behavior is covered indirectly at keyboard-handlers.issue-12871-recorded-chord-traces.test.ts Line 759, but not pinned at the gate itself.

💚 Proposed additional row
   it.each([
     ['Cmd+ArrowLeft', keyEvent({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true })],

Add a separate assertion after the tables:

it('does not remember an exempt chord pressed outside a composition', () => {
  expect(
    isImeExemptTerminalChord(keyEvent({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true }))
  ).toBe(false)
})
src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts (3)

288-298: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Dispose the Terminal in unmount.

openRig creates a real Terminal and calls terminal.open(container) for every test and every it.each row. unmount removes the DOM scope but never calls terminal.dispose(). xterm keeps internal listeners, observers, and render timers alive after the container is removed, so each row leaves one live instance behind for the rest of the file.

♻️ Proposed change
     unmount: () => {
       hook.unmount()
+      terminal.dispose()
       scope.remove()
     }

Also applies to: 371-375


469-473: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the committed text into the case data.

The runner hardcodes 'さ' as the commit payload for every commitsAfterCapture case. The flag type is commitsAfterCapture?: true, so a future case with a different preedit would be committed as さ with no signal. Carry the text in the case instead.

♻️ Proposed change
     if (testCase.commitsAfterCapture) {
       // Held, not dropped: nothing yet, and the same expectations must hold once it commits.
       expect(rig.inputCalls).toEqual([])
-      await commitComposition(rig.textarea, 'さ')
+      await commitComposition(rig.textarea, testCase.commitsAfterCapture)
     }

Change the field type in keyboard-handlers.issue-12871-in-app-chord-traces.ts to the committed string:

/** Rows end mid-composition: the text the input source commits after the capture stops. */
commitsAfterCapture?: string

Then set commitsAfterCapture: 'さ' in keyboard-handlers.issue-12871-command-release-traces.ts and mirror the field in the local RecordedCase type.


557-562: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard the findIndex row lookups.

Three tests locate a row by predicate and slice on the result, with no check that the row exists:

  • Line 559: findIndex(...) + 1. If the row is absent the index is -1 and the slice is empty, so the test replays nothing and still passes.
  • Line 733: findIndex(...) with no offset. If the row is absent, slice(0, -1) silently drops the last row instead of failing.
  • Line 799: findIndex(...) + 1, same empty-slice outcome as Line 559.

The file already guards case lookup by name in caseNamed for this reason. Apply the same discipline to the row lookups.

💚 Proposed helper
function rowIndex(rows: RecordedRow[], match: (row: RecordedRow) => boolean): number {
  const index = rows.findIndex(match)
  if (index < 0) {
    throw new Error('recorded row not found in trace')
  }
  return index
}

Then replace each findIndex call, for example:

     const beforeFirstChord = japanese.rows.slice(
       0,
-      japanese.rows.findIndex((row) => row.t === 'keydown' && row.code === 'MetaLeft')
+      rowIndex(japanese.rows, (row) => row.t === 'keydown' && row.code === 'MetaLeft')
     )

Also applies to: 731-734, 797-800

tests/e2e/macos-input-source-driver.ts (1)

20-35: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a timeout to the execFileSync calls.

No call in this driver sets timeout. swift compiles the script on each invocation, and osascript blocks until System Events answers. If the machine denies Accessibility permission or a dialog steals focus, the call blocks the Playwright worker until the whole run times out, with no message that points at the driver.

🛡️ Proposed change
+const EXEC_TIMEOUT_MS = 30_000
+
 export function selectInputSource(id: string): void {
-  execFileSync('swift', [SELECT_INPUT_SOURCE, id])
+  execFileSync('swift', [SELECT_INPUT_SOURCE, id], { timeout: EXEC_TIMEOUT_MS })
 }

Apply the same option to enableInputSource, focusApp, bounceFocus, typeKeyCodes, pressChordWithSeparateModifier, and pressChord.

Also applies to: 52-93

tests/e2e/renderer-chord-event-probe.ts (1)

90-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename focusListeners and consider exporting a dispose helper.

Lines 90-102 push the compositionstart, compositionupdate, compositionend, and input removers into focusListeners. The array then holds focus, visibility, and composition removers, so its name describes only part of its contents. dispose still removes everything, so behavior is correct.

This module also exports no dispose function, unlike tests/e2e/main-process-input-event-probe.ts, which exports disposeMainProcessInputProbe. The specs never release the renderer probe explicitly.

♻️ Proposed change
-    const focusListeners: (() => void)[] = []
+    const stateListeners: (() => void)[] = []

Rename the remaining focusListeners references at Lines 87, 101, and 107, and add an exported disposeChordProbe(page) that mirrors disposeMainProcessInputProbe.

tests/e2e/terminal-macos-kotoeri-chord-keyup-probe.spec.ts (1)

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

Use KEY.returnKey and align the composition regex.

Three small consistency items in this test:

  • Lines 192 and 195 pass the raw key code 36. KEY is already imported and exports returnKey: 36.
  • Line 170 matches /[\u3041-\u3093]/ while Lines 78 and 85 match the same range as /[ぁ-ん]/. Use one form in this file.
  • Line 191 refers to "the contributor's flushLineToReader". The comment names no file, so it will not help a future reader. Name the spec that defines the helper, or drop the reference.
♻️ Proposed change
-      pressChord(processId, 36)
+      pressChord(processId, KEY.returnKey)
       let bytes = await waitForTerminalImeBytes(orcaPage, reader, 5_000).catch(() => [])
       if (bytes.length === 0) {
-        pressChord(processId, 36)
+        pressChord(processId, KEY.returnKey)
         bytes = await waitForTerminalImeBytes(orcaPage, reader, 10_000).catch(() => [])
       }

Also applies to: 190-196


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bd96b167-aa74-461f-9213-25125b48408b

📥 Commits

Reviewing files that changed from the base of the PR and between 2b10767 and 8a0e40d58701a2342c5ba9624280b442cf33f38c.

📒 Files selected for processing (15)
  • src/renderer/src/components/terminal-pane/keyboard-handlers-ime-composing-chord.test.tsx
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-command-release-traces.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-exempt-chord-resolution.test.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-in-app-chord-traces.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
  • src/shared/keybindings.ts
  • tests/e2e/macos-input-source-driver.ts
  • tests/e2e/main-process-input-event-probe.ts
  • tests/e2e/post-modifier-chord.swift
  • tests/e2e/renderer-chord-event-probe.ts
  • tests/e2e/terminal-macos-chord-input-pipeline-probe.spec.ts
  • tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts
  • tests/e2e/terminal-macos-kotoeri-chord-keyup-probe.spec.ts

Comment thread src/renderer/src/components/terminal-pane/keyboard-handlers.ts Outdated
Comment thread src/shared/keybindings.ts Outdated
Comment on lines +1627 to +1629
// 'Process' is Windows' report for a key an IME consumed (#12171): the produced key is
// genuinely unreportable, which is the same condition as 'Dead' above.
const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified', 'Process'])

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find keybinding match call sites and check for composition gating nearby.
rg -n -C 6 '\b(keybindingMatchesAction|matchesKeybinding)\s*\(' --type=ts -g '!**/*.test.*' -g '!**/*.spec.*'

Repository: stablyai/orca

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- keybinding matcher definitions and references ---'
rg -n -C 5 'keybindingMatchesAction|matchesKeybinding|PHYSICAL_CODE_FALLBACK_KEYS|keybinding.*Action' src --glob '*.{ts,tsx,js,jsx}' || true
printf '%s\n' '--- composition-related guards near keyboard handlers ---'
rg -n -C 8 'isComposing|composition|KeyboardEvent|keydown|keyup' src/renderer/src src/shared --glob '*.{ts,tsx,js,jsx}' | head -n 1200 || true
printf '%s\n' '--- target file outline and relevant section ---'
ast-grep outline src/shared/keybindings.ts --lang typescript || true
sed -n '1560,1665p' src/shared/keybindings.ts

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- matcher implementation and fallback semantics ---'
rg -n -C 12 'PHYSICAL_CODE_FALLBACK_KEYS|function keybindingMatchesAction|const keybindingMatchesAction|export .*keybindingMatchesAction|function keybindingMatchesInput|isComposing' src/shared/keybindings.ts
printf '%s\n' '--- production call sites only ---'
rg -n '\bkeybindingMatchesAction\s*\(' src --glob '*.{ts,tsx,js,jsx}' --glob '!**/*.test.*' --glob '!**/*.spec.*' \
  | sed -n '1,240p'
printf '%s\n' '--- production event handlers that invoke matcher ---'
python3 - <<'PY'
from pathlib import Path
import re
for p in Path('src').rglob('*'):
    if p.suffix not in {'.ts','.tsx','.js','.jsx'} or '.test.' in p.name or '.spec.' in p.name:
        continue
    s=p.read_text(errors='ignore').splitlines()
    hits=[i for i,x in enumerate(s) if re.search(r'\bkeybindingMatchesAction\s*\(',x)]
    if not hits: continue
    print(f'\n## {p}')
    for i in hits:
        lo=max(0,i-12); hi=min(len(s),i+18)
        print(f'-- lines {lo+1}-{hi} --')
        print('\n'.join(f'{j+1}: {s[j]}' for j in range(lo,hi)))
PY

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- complete fallback and matching logic ---'
sed -n '1640,1815p' src/shared/keybindings.ts
sed -n '2160,2245p' src/shared/keybindings.ts
printf '%s\n' '--- composition checks in production shortcut paths ---'
rg -n -C 4 'isComposing|compositionstart|compositionend|key === .Process.|key === .Dead.' src --glob '*.{ts,tsx,js,jsx}' --glob '!**/*.test.*' --glob '!**/*.spec.*' | sed -n '1,320p'
printf '%s\n' '--- handlers with direct KeyboardEvent matching and no obvious target gate ---'
sed -n '1,210p' src/renderer/src/app-shell/use-global-keybindings.ts
sed -n '328,355p' src/renderer/src/components/editor/PdfViewer.tsx
sed -n '1,180p' src/renderer/src/components/dictation/use-hold-dictation-gesture.ts

Repository: stablyai/orca

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Process-specific tests and related issue history in source ---'
rg -n -C 10 "'Process'|Process|`#12171`|`#13033`" src tests --glob '*.{ts,tsx,js,jsx}' 2>/dev/null | sed -n '1,500p'
printf '%s\n' '--- all input/event type definitions used by global and browser/main dispatch ---'
rg -n -C 8 'type (ShortcutDispatchInput|.*Shortcut.*Input)|interface (ShortcutDispatchInput|.*Shortcut.*Input)|isComposing|keyCode' \
  src/renderer/src/app-shell src/main/browser src/main/window src/shared --glob '*.{ts,tsx,js,jsx}' | sed -n '1,500p'
printf '%s\n' '--- exact global dispatch registration and browser event conversion ---'
rg -n -C 12 'dispatchShortcutInput|before-input-event|input:|KeyboardEvent|new KeyboardEvent|keyCode' \
  src/renderer/src/app-shell src/main/browser src/main --glob '*.{ts,tsx,js,jsx}' | sed -n '1,650p'

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Process references limited to keybinding sources/tests ---'
rg -n -C 8 'Process|PHYSICAL_CODE_FALLBACK_KEYS' src/shared/keybindings.ts src/shared/keybindings.test.ts src/renderer/src/components/terminal-pane src/renderer/src/lib/ime-composition-keyboard-event.ts
printf '%s\n' '--- action definitions and default bindings ---'
rg -n -C 3 'id:|default:|bindings:|keybinding' src/shared/keybindings.ts | sed -n '1,900p'
printf '%s\n' '--- global listener construction and input conversion ---'
rg -n -C 10 'addEventListener.*keydown|dispatchShortcutInput|ShortcutDispatchInput|toModifierDoubleTapEvent|input =' src/renderer/src/app-shell/use-global-keybindings.ts src/renderer/src/app-shell/app-command-handlers.ts
printf '%s\n' '--- browser guest input conversion only ---'
sed -n '280,440p' src/main/browser/browser-guest-ui.ts

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- compact default binding inventory ---'
python3 - <<'PY'
import re
p='src/shared/keybindings.ts'
lines=open(p, encoding='utf-8').read().splitlines()
for i,line in enumerate(lines):
    if re.search(r"(default|bindings|keybinding).*(\+|['\"])", line, re.I):
        print(f'{i+1}: {line[:240]}')
PY
printf '%s\n' '--- Process events in compact fixtures summary ---'
python3 - <<'PY'
from pathlib import Path
import json
for p in Path('src').rglob('*Process*'):
    pass
for p in Path('src/renderer/src/components/terminal-pane/__fixtures__').glob('*.json'):
    try: data=json.loads(p.read_text())
    except Exception: continue
    events=data if isinstance(data,list) else data.get('events',[])
    found=[]
    for e in events:
        if isinstance(e,dict) and e.get('type')=='keydown' and e.get('key')=='Process':
            found.append((e.get('code'), e.get('keyCode'), e.get('isComposing'), {k:e.get(k) for k in ('ctrlKey','metaKey','altKey','shiftKey') if e.get(k)}))
    if found:
        print(p.name, 'count=',len(found), 'samples=',found[:12])
PY
printf '%s\n' '--- app-level handlers and explicit composition guards ---'
rg -n 'keybindingMatchesAction|isImeOwnedKeyboardEvent|isImeCompositionKeyDown|isComposing|keyCode === 229' \
  src/renderer/src/app-shell src/renderer/src/components/browser-pane src/renderer/src/components/editor \
  src/renderer/src/components/settings src/renderer/src/components/sidebar src/renderer/src/components/right-sidebar \
  --glob '*.{ts,tsx}' --glob '!**/*.test.*' --glob '!**/*.spec.*'

Repository: stablyai/orca

Length of output: 13247


Gate IME-owned events before app-level keybinding matching. keybindingMatchesAction now maps key: 'Process' to code, but non-terminal handlers pass composing KeyboardEvent objects without a shared composition guard. A Windows IME event such as Process + KeyF can trigger a physical-code binding, including a user-defined binding. Use isImeOwnedKeyboardEvent before matching these shortcuts.

Comment thread tests/e2e/renderer-chord-event-probe.ts Outdated
Comment thread tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts Outdated
Comment thread tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts Outdated

@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/terminal-shortcut-policy.ts (1)

22-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the IME policy comment brief.

Lines 22-34 use 13 lines to describe one predicate. Keep the non-obvious reason, but reduce this to a concise comment.

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: 663d0930-893e-4d3d-a7a7-3d6d302737c4

📥 Commits

Reviewing files that changed from the base of the PR and between 8a0e40d58701a2342c5ba9624280b442cf33f38c and fd9d01b7422da05003d07b0e2316d45315c88907.

📒 Files selected for processing (7)
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-command-release-traces.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-exempt-chord-resolution.test.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-in-app-chord-traces.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-composition-route.ts
  • src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-exempt-chord-resolution.test.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-command-release-traces.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-in-app-chord-traces.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.ts

@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

🧹 Nitpick comments (2)
src/renderer/src/components/terminal-pane/keyboard-handlers.ts (1)

1098-1103: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shorten the added rationale comments.

These blocks run five to eight lines each and restate the reasoning at length. Keep the non-obvious claim and drop the narrative. For example, Line 1098-1103 reduces to: "A still-composing event means the IME consumed the key; an unmarked replay is already on its way."

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

Also applies to: 1140-1147

Source: Coding guidelines

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

746-750: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard the findIndex result.

If the fixture loses its MetaLeft keydown row, findIndex returns -1 and slice(0, -1) silently replays the whole trace except the last row. The test then exercises a different setup and can still pass. Assert the index first.

🧪 Proposed fix
-    const beforeFirstChord = japanese.rows.slice(
-      0,
-      japanese.rows.findIndex((row) => row.t === 'keydown' && row.code === 'MetaLeft')
-    )
+    const firstChordIndex = japanese.rows.findIndex(
+      (row) => row.t === 'keydown' && row.code === 'MetaLeft'
+    )
+    expect(firstChordIndex).toBeGreaterThan(0)
+    const beforeFirstChord = japanese.rows.slice(0, firstChordIndex)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b012d8bf-2356-410d-9b63-12ddee897a10

📥 Commits

Reviewing files that changed from the base of the PR and between fd9d01b7422da05003d07b0e2316d45315c88907 and d72980b45f71261232c700802a65c39564e36f8b.

📒 Files selected for processing (3)
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-exempt-chord-resolution.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
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-exempt-chord-resolution.test.ts

Comment thread src/renderer/src/components/terminal-pane/keyboard-handlers.ts
@kunsanglee

Copy link
Copy Markdown
Contributor Author

Review pass addressed in fd9d01b, d72980b and b1d2c91. Where I went a different way than suggested, here is why.

selectAll arming the tracker from a release. Fixed, but not by skipping only the arm. The chord is no longer claimed on the press when it resolves to selectAll or switchInputSource, so it stays on the keydown path and the remapped action still runs — from the press, which is where it needs to arm the tracker. Pinned by runs a remapped selectAll on the press rather than answering from the release.

Gate IME-owned events before app-level keybinding matching. The 'Process' entry in PHYSICAL_CODE_FALLBACK_KEYS is gone; src/shared/keybindings.ts is now byte-identical to main. The remembered chord stores event.code as its key instead, which is correct only where the caller has already narrowed to four codes whose key is that same string. So nothing outside the terminal pane sees a matching change, and the AltGr guard you would have been bypassing is untouched.

answersSwallowedImeChord returning true on a file-search match. Correct and now fixed — the action is resolved first and the exclusion applies to both paths. Test: does not claim a chord that is both file search and selectAll, which reads whether selectAll has already run at the press rather than whether the event was defaulted, since the ordinary keydown path defaults it too.

The e2e helper duplication. Taken, and the divergence was worse than a naming drift. pressChord existed twice with the same name and different gestures: the driver folded the modifier into the target key's flags, the spec sent it as its own key event. Three specs called it and two got the folded shape — which produces no modifier press or release at all, the exact difference that made the Command half of the gesture look like it had no end. They are now pressChordWithFoldedModifier and pressChordAsTyped. The cursor-chord spec's own copies of the constants, key table and four helpers are deleted in favour of the shared ones (83 lines). I did not add right: 124 to the driver — nothing calls it, and an unused entry in a key table is how the next drift starts.

The literal left-to-right mark and the untimed poll are both fixed as described.

Local verification after all three commits: terminal-pane 270 files / 3517 tests, widened once across terminal-pane, dashboard-popout, shared and lib at 1186 files / 12610 tests, all passing. tsc clean on three projects, oxlint and oxfmt clean, 84 reliability gates, ratchet at 340.

@ethznn

ethznn commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Confirmed by hand on 2b10767d9, on real hardware rather than a replay: with 사 composing, one Cmd+← leaves ^A^A on the line and Option+← jumps two words. Matches your trace exactly.

Thanks for carrying this through three rewrites of the ground underneath it — the revert, the release-keyed design, and now this. Re-recording rather than reasoning each time is what made the diagnosis hold up. Hope this one lands.

kunsanglee and others added 4 commits August 15, 2026 21:38
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
…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
The previous commit made the arming keydown consume the press, to stop a global
handler and this pane both acting on one remapped chord. It consumed too much:
the claim happened before anything decided whether this pane would answer.

`isImeExemptTerminalChord` accepts any of Cmd/Ctrl/Alt over the four codes, and
some of those resolve to nothing in the terminal policy. `Cmd+Alt+Left` is one,
and it is the default binding for worktree history, owned by a window handler
that mounts after this pane. Composing on macOS, the pane took that press,
stopped it reaching the global handler, and then answered for nothing at the
release. Worktree history back stopped working for the length of a composition —
no remap needed, and it worked on the parent commit.

The same early `preventDefault` also broke `switchInputSource`, whose whole
contract is that the OS keeps its default action while xterm is cut off.

One predicate now decides both ends: the press is claimed exactly when the
release would answer for it, so the two cannot drift. `switchInputSource` and
`selectAll` stay out of it — both arm the native-only tracker from a press, which
a release cannot do, so leaving them unclaimed keeps them on the keydown path
where they already work.

Also restores coverage for the snapshot's silent failure mode. The test dropped
in the simplification pass exercised the resolver, not the snapshot, so it could
not have caught a `{ ...event }` regression at all — that is the whole hazard,
since happy-dom keeps KeyboardEvent fields as own properties and Chromium does
not. `imeChordSnapshot` is exported and pinned directly against a prototype-only
object instead.

And records why the release stepping back is believed safe beyond the two
recorded input sources: the replay is Chromium redispatching a key the IME did
not consume, so a source that consumed one is still composing and is handled.
Closing the remaining gap would need a deadline on the replay, which is a second
timer able to fire the chord twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjefjguNCzpY2kVq3Q69wH
The claim predicate returned early on a file-search match without looking at the
action, so a chord carrying both bindings was claimed on the press, and a release
that found no selection fell through to selectAll — arming the native-only
tracker from a keyup with no press left to spend it. Resolving the action first
applies the exclusion to both paths.

Two things about the recording harnesses, both of which could produce a wrong
gesture rather than a wrong assertion:

`pressChord` existed twice with the same name and different behaviour. The
driver's folded the modifier into the target key's flags; the spec's local one
sent it as its own key event. Three specs called it and two got the folded shape,
which produces no modifier press or release at all — the exact difference that
made the Cmd half of the gesture look like it had no end. They are now
`pressChordWithFoldedModifier` and `pressChordAsTyped`, so a call site says which
gesture it drives.

The cursor-chord spec kept its own copies of the input-source constants, the key
table, and four helpers whose bodies match the driver's. The key table had
already drifted to a different name for Return. Deleted in favour of the shared
ones; 83 lines, and nothing left to drift.

Also: a literal left-to-right mark in the composition reader is now `‎`, so
a whitespace-stripping tool cannot silently break composition reads, matching the
sibling spec; and the assertion that a swallowed chord leaves the preedit alive
reads once instead of polling, since the settle it follows means a late preedit
would be a failure rather than a pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjefjguNCzpY2kVq3Q69wH
@kunsanglee
kunsanglee force-pushed the fix/ime-deferred-terminal-shortcut-input branch from b1d2c91 to 27f3cf9 Compare August 15, 2026 12:39
@kunsanglee

Copy link
Copy Markdown
Contributor Author

Thanks for running it on real hardware — ^A^A on one Cmd+← is the clearest statement of it I have seen. That is the half nobody had reproduced outside a replay.

Trailer added in c070b84, on the commit that carries your work, so a squash-merge picks it up. Content is unchanged: I rebuilt the four commits from the same parent and git diff between the old and new tips is empty. You are right that the squash swallowed it — three commits in the pre-squash history were authored by you (8aec625, 079735b, 8ba40b7) and folding them into one left me as sole author, which I should have caught when I did it.

While checking which of your work is still here I found something you will want to know. Two of the three are intact: keyboard-handlers.issue-12871-in-app-chord-traces.ts and tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts, and the chord table in that spec is byte-identical to the version you wrote.

The third is not. 8aec625 added 104 lines to keyboard-handlers-ime-deferred-input.test.tsx, and that file no longer exists — it went out in an earlier merge of main into the branch, before the squash. So these are missing from the PR:

  • an Option+ArrowLeft and Option+ArrowRight word-jump pair,
  • a Cmd+ArrowLeft line-start jump behind a multi-character Japanese preedit,
  • a Ctrl+ArrowLeft word jump on Windows.

The Windows one is worth more now than when you wrote it. Review on this PR flagged that everything here is derived from two stock-macOS captures and nothing exercises win32 or linux, which is why the recovery is now gated to macOS — an IME that commits without replaying would otherwise lose the chord entirely rather than deliver it late. Your case is the shape that pins the non-mac side.

Say the word and I will re-home all three against keyboard-handlers.issue-12871-recorded-chord-traces.test.ts, which is where that kind of case lives now, with your authorship on the commit.

One thing I could not find: you mention Chinese fixtures, and I see none in 8aec625, 079735b or 8ba40b7, nor anywhere in the current tree. If they were in a commit outside this branch, point me at it and I will pull them in the same pass.

@ethznn

ethznn commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Thanks for chasing down what survived — and yes please, re-home all three.

The Chinese fixtures are on my fork, not on this branch: ethznn:test/ime-chord-round4, commit 878ae5ef ("test(terminal): pin the Chinese chords, measured on the Command release"). It adds keyboard-handlers.issue-12871-chinese-chord-traces.ts plus four Chinese cases in the native spec. Three cells: Zhuyin Cmd+← over a live 你好 preedit, Pinyin Cmd+← with a compositionupdate mid-gesture, and Pinyin Option+←.

Two caveats before pulling it in:

  • Those were measured on 84517d74b — your Meta-release design. The recordings are still faithful captures, but the byte expectations were written against that mechanism, so they need re-checking against the keydown hold on current main.
  • A fourth cell, Zhuyin Option+←, is deliberately absent. The rig had it committing on the chord; by hand it does not, and it stayed that way across three synthesis timings (flat 80 ms, hardware medians, and an exaggerated 700/400/500 ms). The file header says so, to stop it being re-added from the raw trace.

On the Windows Ctrl+ArrowLeft case — it was transcribed from the same macOS session as the rest, so it pins the resolver's non-mac branch rather than a win32 capture. Worth stating in whatever comment it lands under, since that is exactly the gap review flagged.

ethznn added 2 commits August 15, 2026 22:40
…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+Right moved the syllable to the line end.
Reported with reproduction steps in stablyai#12871.

Written originally in 8aec625 against
keyboard-handlers-ime-deferred-input.test.tsx, which no branch still has.
Rehomed onto keyboard-handlers-ime-composing-chord.test.tsx, whose harness
is the same shape, and reshaped for where the chord now resolves: a marked
press is remembered for its release rather than deferred to the commit, so
the macOS cases run the whole Korean gesture — press, commit, unmarked
release, the platform's replay — instead of stopping at the commit.

- Option+ArrowLeft / Option+ArrowRight / Cmd+ArrowRight, each a resolver
  branch no recorded trace reaches
- Ctrl+ArrowLeft on Windows, where no release takes part at all

The Windows rows were transcribed from the same macOS session rather than
captured on win32, so they pin the resolver's non-mac branch and claim no
platform coverage.

Cmd+ArrowLeft behind a multi-character Japanese preedit is left out: the
existing 'holds the chord while the keydown is still marked composing'
already commits 日本語 behind the same chord for the same bytes. Its note
about the overwritten destination glyph moved onto that test instead of
arriving as a second copy of it.

Verified non-vacuous against a663f1b, the parent: all three macOS cases
fail there with the chord byte twice over ('사', '\eb', '\eb') — the Korean
duplicate itself. The Windows case passes there and fails against
b849099, before the deferral landed, with the byte ahead of the commit.
The PR body no longer calls Chinese untested — it says Pinyin and Zhuyin
are affected. This replaces that citation with measurement, at both layers.

Every earlier Chinese capture is void twice over: it predates the Command
release being honoured, and it was driven with the modifier folded into the
target key's flags, which emits no modifier press or release at all and so
could not see the end those gestures turn on.

What the recordings say. Both sources swallow the chord and keep composing,
like Kotoeri and unlike Korean, and the byte drains at the commit. A Cmd
chord's arrow keydown does not reach Chromium's own input dispatch at all
and its keyup exists nowhere; the Command release carries it, still marked
composing, and both Cmd cells pass only because of that. Option resolves
through the arrow's own keyup, as it always did.

One gesture is deliberately left unpinned at the unit layer. Recorded here,
Zhuyin Option+Left commits on the chord; checked by hand at the same
keyboard, the preedit block stays up. Three synthesis regimes were tried
against that — a flat 80ms with the modifier as a synthetic key event, then
the hardware medians and a much slower run with it posted as a real
flagsChanged — and all three committed on the press, so it is not a timing
artifact this rig can drive out. Rather than freeze a recording no hand can
reproduce, that fixture case is dropped rather than softened. Its e2e case
stays, minus the intermediate-composition assertion: both behaviours put the
same line on the pty, so the byte order is asserted and the disputed state
is not.

The e2e cases assert the line's shape rather than a particular reading,
because which candidate Zhuyin commits adapts to use — one run committed
你好, another 妳好, and the byte order was the same contract in both.

Rebased from 878ae5e with the expectations re-measured, as the mechanism
moved on since: all three fixture cases now stop mid-composition and drain
at the commit, so each carries the text its source committed and
commitsAfterCapture holds that text rather than a bare flag — the rig had
been committing さ for every case. Verified non-vacuous by removing the
Command-release arm: both Cmd cases fail there alongside the Kotoeri one.
The Option case is the half that already worked and passes either way, as
its note says.

@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

🧹 Nitpick comments (1)
tests/e2e/macos-input-source-driver.ts (1)

26-74: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add explicit timeouts to the execFileSync calls.

Every call blocks the worker with no upper bound. If osascript waits on an Accessibility permission prompt, or if swift stalls while compiling, the run hangs instead of failing. The suite already requires that permission, so the prompt path is reachable. Pass a timeout so a stuck helper surfaces as a test failure.

♻️ Proposed change
+// Hand-run rig: a permission prompt or a stuck System Events target would otherwise block the
+// worker with no upper bound.
+const DRIVER_TIMEOUT_MS = 30_000
+
 export function selectInputSource(id: string): void {
-  execFileSync('swift', [SELECT_INPUT_SOURCE, id])
+  execFileSync('swift', [SELECT_INPUT_SOURCE, id], { timeout: DRIVER_TIMEOUT_MS })
 }

Apply the same option to enableInputSource, focusApp, bounceFocus, typeKeyCodes, pressChordWithSeparateModifier, pressChordWithFoldedModifier, and pressChordAsTyped.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 28a8fa0d-921e-4df3-b601-aad7fb00cdf2

📥 Commits

Reviewing files that changed from the base of the PR and between b1d2c91797d06635c15be4333b4fc9a10d1a59ee and 7e710f6.

📒 Files selected for processing (7)
  • src/renderer/src/components/terminal-pane/keyboard-handlers-ime-composing-chord.test.tsx
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-chinese-chord-traces.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-command-release-traces.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-in-app-chord-traces.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts
  • tests/e2e/macos-input-source-driver.ts
  • tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-recorded-chord-traces.test.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-command-release-traces.ts
  • src/renderer/src/components/terminal-pane/keyboard-handlers.issue-12871-in-app-chord-traces.ts

Comment thread tests/e2e/terminal-macos-ime-cursor-chord-native.spec.ts
@ethznn

ethznn commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

:410 was the same defect one block down, still live — fixed in ethznn:fix/e2e-settled-preedit-read (c777c6f), reading the preedit once after the settle to match the Kotoeri case at :248. :312 is already resolved there by 27f3cf90d.

I audited the file's other 13 expect.poll calls while in there; the rest are load-bearing (input-source readback, compose-and-retry, the Korean compositionend), so nothing else changed.

@kunsanglee

Copy link
Copy Markdown
Contributor Author

Re-homed both, authorship intact — GitHub attributes c8c2db9 and 7e710f6 to you.

Your caveat about the expected bytes was right, and it cost nothing because you flagged it. All three Chinese cases stop mid-composition, so the byte was still queued and every one asserted []. They now carry the text their source committed (你好, nihao) and drain at the commit. That also surfaced the rig committing さ for every case regardless of script, so commitsAfterCapture holds the committed text now instead of a bare flag.

The macOS cases from 8aec6254a needed the same treatment for the same reason: the mechanism moved to the release, so a press that stops at the commit produces nothing. Each now runs the whole Korean gesture — press, commit, unmarked release, the platform's replay. Verified they fail on a663f1bd0 with the byte twice over, which is the duplicate itself.

One I left out: Cmd+ArrowLeft behind a multi-character Japanese preedit. holds the chord while the keydown is still marked composing already commits 日本語 behind that chord for the same byte, so it would have arrived as a second copy. Its note about the overwritten destination glyph moved onto that test instead.

The Zhuyin Option+← omission and its header note are untouched, and the Windows rows say in the comment that they were transcribed from a macOS session and pin the resolver's non-mac branch rather than the platform.

ethznn and others added 3 commits August 15, 2026 23:32
The positive control ran as a 10 s expect.poll immediately after the 700 ms
settle, so it polled for a condition that must already hold. That hid a real
failure two ways: a source that committed on the chord and then opened a
fresh preedit satisfied the poll on the second one, which turns the byte
assertion below into evidence about a different gesture; and when the
composition was genuinely gone the run still burned the full timeout before
reporting it.

Read once instead — the shape the Kotoeri case already uses at the same point
after the same settle.
…commit hole

Review of the re-homing found three things worth changing, all in what the
tests claim rather than in what they run.

The macOS cases in keyboard-handlers-ime-composing-chord.test.tsx had a
docstring narrating the whole gesture as though each step were asserted.
Only the count is. Drop the marked press from those rows and they still pass
— nothing is left that could fire twice — while dropping the arming makes
them fail with the byte twice over. The docstring now says that, and points
at the recorded-trace fixtures for which event carries the byte.

commitsAfterCapture was read for truthiness, so a case committing the empty
string would skip both the mid-gesture guard and the commit. Today that
fails loudly, since every case expects a byte; a later case expecting none
would have passed having asserted nothing. Read for presence instead. Its
doc also now says plainly that the text is a label no assertion reads, so a
wrong one misleads a reader rather than failing.

The Chinese fixture claimed reproduction from a spec that checks those rows
rather than producing them. The recorder,
terminal-macos-chord-input-pipeline-probe.spec.ts, was never extended past
Japanese, Korean and ABC, so these rows cannot be re-measured from this
repo. Said so. And the deliberate Zhuyin Option+Left omission leaned on the
e2e spec for that gesture's coverage without noting that no workflow sets
ORCA_E2E_NATIVE_MACOS_KOREAN — so that gesture has no coverage that runs on
its own, which the note now states.

One review finding was not adopted: that the arrow keyup under a held
Command contradicts the recorded "no keyup under Cmd". That claim belongs to
the sources that swallow the chord, where the IME consumed the key. The
Korean capture in keyboard-handlers.issue-12871-recorded-chord-traces.ts
does carry an ArrowLeft keyup with metaKey set, right after compositionend.
A comment now records the distinction where it was generalized.
…each

A second review pass over the re-homed traces found six records claiming
more than they measure. None of the assertions changed; the notes around
them did.

- The Zhuyin Cmd rows are byte-identical to the Kotoeri ones, so the header
  claim that each case pins the route it recorded was false for that case:
  replayed here it is Kotoeri under another name and cannot fail on its own.
  The identity is itself the measurement — a third input source producing
  the same shape — so it stays, said plainly, with the e2e cell named as the
  only place the real Zhuyin source is driven.
- The Pinyin Cmd case justified itself by saying a recovery that disarmed on
  composition activity would drop its byte and pass everything else here.
  The Pinyin Option case carries the same compositionupdate rows and would
  fall with it. What the Cmd case adds is that route, not that property.
- The multi-character Japanese preedit note read as though the glyph count
  were pinned. Shorten 日本語 to one character and the test still passes:
  where text lands on the grid is not visible to that harness. The note now
  says it records the reported symptom, not what the assertion reads.
- The e2e header stated without qualification that both Chinese sources keep
  the preedit through the chord. Three of the four cells; the Zhuyin Option
  cell is the exception under this rig, which is why it opts out of the
  positive control.
- `commitReturns: 1` was justified by "flushLineToReader's own two presses".
  It presses Return once and again only after a five-second wait fails.
- The `cn-*.json` names have no path or digest, unlike the in-app family
  whose module carries a SHA-256 per file, and two of the three timing
  regimes cited for the Zhuyin omission were run out of tree. Both now say
  so rather than reading as things a reader could go and check.
stablyai#15017 gave the composing-chord deferral an owner and a ceiling, and stablyai#14743
moved MacOptionAsAlt into terminal-option-shortcut-policy.ts. Both landed on
the lines this branch had rewritten.

The deferral now goes through main's `deferredChordSender`, which blur and
teardown can drop, and which abandons a chord after 10 s rather than waiting
forever. The release-recovered path keeps its own `composing` argument, since a
chord answered for at keyup has no live event to read it from.

`runShortcutAction` gained main's Option-release arming. Its narrowed event type
now carries the modifier flags the tracker reads; a snapshotted chord already
held them. The arming is keydown-only in effect — the Option policy returns null
for a composing event, which every recovered chord is.
@nwparker nwparker removed the P1 label Aug 19, 2026
@ethznn

ethznn commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

On v1.4.188 the relocation is gone — #14730 shipped in v1.4.187. The double send is not: one Option+← over a composing syllable still moves the cursor two words, which is what this PR fixes. imeChordSnapshot is absent from every release tag and from main, and this is the only open PR or issue that covers it — #12871 is closed, and nothing else touches the chord path in keyboard-handlers.ts or terminal-shortcut-policy.ts.

What worries me is that this looks stuck on something mechanical rather than on review. PR Checks and PR test LoC have never executed on this branch — 19 of the 21 workflow runs are action_required, and the only two that completed were Track Community PRs. Head 0da3e745 has zero check runs, which is what leaves the PR UNSTABLE. From the PR page that reads as "no checks" rather than "checks never started", so it is easy to miss.

I would hate to see this one go quiet when it is the last piece of the #12871 chain still outstanding. Happy to re-push or rebase if that helps get the checks running.

@kunsanglee

Copy link
Copy Markdown
Contributor Author

Confirmed, and thank you for checking a release build — that separates the two halves better than anything I can do from a branch.

On the checks: a re-push won't clear it. Each push just adds two more runs in the same action_required state, which is how 19 accumulated across the ten heads this branch has had.

What I can add is that this isn't specific to this PR. My other two open PRs here — #13253 and #11094 — show the identical pattern: every PR Checks and PR test LoC run sits at action_required, and all three heads report zero check runs. So it reads as the fork-PR approval gate rather than anything wrong with this branch. Someone with write access has to hit "Approve and run workflows"; my token only has pull.

Since my last comment the branch picked up four commits I never explained here:

  • fd71903 — your c777c6f, applied as-is with your authorship intact. The settled read replaced the poll in the Chinese positive control.
  • 84484e9 — says out loud what the re-homed cases actually pin, and closes a guard hole: a case committing the empty string skipped both the mid-gesture assertion and the commit, asserting nothing while still passing.
  • 1bd3262 — cuts six comments back to what their assertions reach. Deleting what each claimed to pin and re-running is how I found them; three survived deletion, so those comments were claiming more than the tests measured.
  • 0da3e74 — merge of main. fix(terminal): dispose a deferred IME chord instead of leaving it armed #15017 put the composing-chord deferral behind an owner with a 10 s ceiling, so the deferral here now goes through deferredChordSender; the release-recovered path keeps its own composing argument, since a chord answered for at keyup has no live event to read it from. fix(terminal): type Option-composed ASCII instead of reporting it as a chord #14743 moved MacOptionAsAlt out, so the duplicate definition went with it. Removing the arming still fails the three Korean cases with the byte twice, so the merge didn't quietly neuter what this PR does.

Conflict-free against afd76a4 as of now.

@nwparker

Copy link
Copy Markdown
Contributor

Closing as superseded: issue #12871 is fixed by merged PRs #14730 and #15017, which hold and dispose composing cursor chords correctly.

@ethznn

ethznn commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

#12871 is fixed — the syllable stays put now, and I can confirm that on v1.4.192.

The double send this branch was also carrying is still there, though. 가나 다라 마바사 with 사 in the preedit, one Option+←, and the cursor lands before 다라 rather than at the start of 마바사. deferredChordSender is in v1.4.192 and on main; imeChordSnapshot is in neither.

That half never had an issue of its own — it only existed inside this PR — which is why it read as superseded. Filed it as #17616 so it is tracked separately, and I am putting up a PR against current main that carries the commits here forward with authorship intact.

@kunsanglee — the design and the measurements are yours, so say the word if you would rather take it yourself and I will close mine.

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