Skip to content

fix(terminal): let Linux IMEs keep the candidate key for a preedit they own - #22607

Merged
nwparker merged 6 commits into
mainfrom
nwparker/sta-8266-linux-claimed-preedit-selectors
Sep 24, 2026
Merged

nwparker merged 6 commits into
mainfrom
nwparker/sta-8266-linux-claimed-preedit-selectors

Conversation

@nwparker

@nwparker nwparker commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​515 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​515
Prod 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​156 $\color{#cf222e}{\Huge{\mathbf{−}}}$​6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​150

ELI5

You are typing Chinese in a terminal with Sogou. You type ni, then press the spacebar (or a number) to pick the character you want. A space — or that number — appeared in the terminal, and then the Chinese character showed up. Now the spacebar and the number go to the input method, where you aimed them, and only the Chinese character reaches the terminal.

What Changed

Before: ni + Space produced 你. hao + 3 produced 3好. With a phrase, nothing usable came out at all.

After: ni + Space produces 你. hao + 3 produces 好. Ordinary typing is untouched.

Mechanism. Every candidate-key guard Orca had was keyed on a Chromium composition session — compositionstart … compositionend. Sogou on fcitx never opens one: it draws the preedit and the candidate list in its own window and only ever hands the renderer the final insertText. So at the moment the user presses the selector, compositionActive and candidateKeyGuardActive are both false, the Space or digit is an ordinary printable key, and xterm sends it to the PTY.

The one existing escape hatch, the orphan-keyup window, arms off a letter keyup with no matching keydown and only ever claims digits. When fcitx delivers the letter keydowns as keyCode 229 / key 'Process' the keydown is seen, so that window never arms.

Review rounds since the first push

Three defects were found after the first push and are fixed on the branch; each has a test that fails without its fix.

  1. Claimed keys that edit or page a preedit disarmed the window. The arming predicate only matched Key* codes, but the IME also claims Backspace, the arrows and -/=, and Chromium keeps the original code on those 229/Process keydowns. Paging to a later candidate closed the window, so the digit that picked from that page reached the PTY. A claimed keydown now refreshes the deadline; only a claimed letter arms it, so a bare navigation key cannot open it from cold.

  2. The window outlived a composition session. An engine that does open a session still emits the claimed letter keydowns that arm this window, and both its commit and its cancel travel as insertCompositionText — the one input type the commit release ignores. compositionstart fires once per session, so every letter after the first re-armed it with nothing left to close it, and the user's next literal Space was swallowed. compositionend now releases it too. The engines this guard exists for emit neither event, so their path is untouched.

  3. A claimed selector extended the window instead of ending it. A selector the engine took itself reads key: 'Process', which the selector predicate cannot see, so it fell into the refresh path. It now asks the physical code and ends the window: a claimed Space, Digit0-9 or Numpad0-9 means the pick is over. This is what keeps a literal Space safe for an engine that opens a session and never closes it, which is a known defect in at least one shipped input framework.

The net contract is four release conditions with no overlap and no gap. Engines that run a composition session are owned by the composition-scoped guards and this window stands down at both session boundaries; engines that do not are owned by this window, emit neither event, and commit through insertText, which the commit release catches.

This adds the missing signal: a letter keydown the IME has already claimed (keyCode 229 / key 'Process' over a Key* code) opens a 1500 ms window in the existing Linux candidate state. While it is open, the next Space or candidate digit is treated as IME-owned — suppressed and preventDefault-ed, exactly as a composing selector already is. The window closes on the first selector it claims, on compositionstart (a real session owns it instead), on a non-preedit input (the round committed), on a key the IME did not claim, on blur, and on expiry.

Why

Two arming signals rather than one, because the frameworks disagree: fcitx5 on Wayland omits the 229 marker entirely, which is what the orphan-keyup window covers; fcitx on X11 emits it, which is what this covers. Neither can be inverted into a gate on its own, and the existing window was left untouched so its contract does not move.

Space is claimed here where the orphan-keyup window deliberately declines it. A claimed keydown is much stronger evidence of an open preedit than a bare orphaned keyup, and Space is the selector Sogou users reach for first — it is the half of the report that the digit-only window could never have fixed.

The alternative — guarding Space and digits whenever a Linux IME has been used recently — swallows real spaces in a shell. The alternative of rendering Sogou's preedit ourselves is a much larger change that does not fix the leak.

Hangul keeps its carve-out: 2-Set commits its syllable on Space and still owes the literal space, so hangulPreedit opts out of both selectors.

Linked Issue

Fixes #22442

Same root cause, and the same invariant: #16260 (single Chinese character invisible during composition — no composition session exists to render), #19180 (Sogou/fcitx5 on Deepin).

Visual Proof

N/A — terminal input routing, not a renderer layout change. The user-visible before/after is the PTY byte stream, which the e2e spec below asserts directly.

Recorded on this branch with a read-boundary reader on the PTY (Linux renderer policy, CDP-replayed Sogou key shapes):

step before after
claimed n i, then Space " " then "你" "你"
claimed h a, then 3 "3" then "好" "好"
plain ls 7 "l" "s" " " "7" "l" "s" " " "7"
literal Space after a commit " " " "
Space after the window expires " " " "
Hangul commit then Space "한" " " "한" " "

Testing

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

tests/e2e/terminal-linux-claimed-preedit-selector.spec.ts drives the real terminal under the Linux IME platform policy and asserts the exact PTY bytes. It fails on main (" 你3好 ls7" vs "你好 ls7") and passes here.

npx playwright test tests/e2e/terminal-linux-claimed-preedit-selector.spec.ts --project=electron-headless
  1 passed (11.3s)

pnpm vitest run src/renderer/src/components/terminal-pane
  Test Files  478 passed (478)
  Tests       4586 passed (4586)

pnpm tc:web — clean
pnpm run check:code-quality:changed — 0 new findings

Native fcitx/Sogou evidence is still owed, per docs/reference/ime-regression-checklist.md — CDP replays the renderer policy and the PTY bytes, not the input framework. I do not have an Ubuntu 20.04 + Sogou machine; I would like the reporters to confirm on theirs.

AI Disclosure

Claude Opus 5 was used to reproduce the leak, locate the root cause, write the fix, and write the tests.

Review

Please focus on:

  • whether claiming Space (not only digits) from the claimed-keydown window can swallow a real space — the release paths are compositionstart, non-preedit input, an unclaimed keydown, blur, one-shot, and 1500 ms;
  • whether hangulPreedit is the right opt-out for Space as well as digits;
  • whether the window should re-arm per selector instead of per claimed keydown for multi-page candidate lists.

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

Linux-only by construction (isLinux gate in xterm-bypass-policy); macOS and Windows keyboard paths are unreachable from the new code. No wire fields, no Git commands, no new permissions. SSH and folder workspaces are unaffected — the decision is renderer-side, ahead of the PTY, and identical for a local or remote host.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • Before/after attached (byte table), UI change N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • Typecheck, changed-code quality gate, targeted unit + e2e run locally; CI covers the rest

…ey own

Sogou on fcitx draws its preedit and candidate list in its own window and
opens no Chromium composition session, so every composition-scoped candidate
guard is idle when the user presses Space or a candidate digit. The selector
reached the PTY as literal text and the commit arrived after it.

The marker the input framework does still deliver is the claimed letter
keydown - `keyCode 229` / `key 'Process'` over the physical key. A bounded
window armed from that claims the next Space or digit, and stands down for a
real composition session, for the commit, and on expiry.

Co-authored-by: nxiaobai <48705519+nxiaobai@users.noreply.github.com>
Co-authored-by: Big-ios <18501866+Big-ios@users.noreply.github.com>
Co-authored-by: cporoske <14369468+cporoske@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 3cc0e4cc-bf09-40be-9544-2bec20976ba2

📥 Commits

Reviewing files that changed from the base of the PR and between 8bc3f58 and f01bdda.

📒 Files selected for processing (2)
  • src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts
  • src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-release.test.ts

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


📝 Walkthrough

Walkthrough

The change adds a Linux IME-owned preedit guard that recognizes unmodified claimed letter keydowns and briefly classifies a following Space or digit as a candidate selector. The terminal keyboard policy suppresses matching selectors and prevents their native default action, except during Hangul preedit. Unit tests cover guard behavior and exclusions. An end-to-end test verifies candidate selection and the resulting PTY input.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to f01bd

The selector guard appears ready for normal merge checks. Native fcitx/Sogou behavior has not yet been verified.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.19% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 21 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses the Linux Sogou Pinyin behavior in [#22442]. After an unmodified IME-claimed letter keydown (keyCode 229 or key: 'Process'), the 1500 ms guard claims Space and candidate digit sel…
Out of Scope Changes check ✅ Passed The source changes, state wiring, and tests all support the Linux IME candidate-selection behavior required by [#22442]. Guard lifecycle handling and Hangul handling support that behavior. No unrelate…
Title check ✅ Passed The title clearly and concisely describes the main change: allowing Linux IMEs to retain candidate-selector keys for an IME-owned preedit.
Description check ✅ Passed The description is complete and relevant. It includes the user impact, mechanism, rationale, linked issue, visual-proof rationale, tests, platform considerations, AI disclosure, review focus, and chec…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

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.

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

Important

The new window is released on any keydown that is neither a selector nor a Key[A-Z] letter, so a keyCode 229 / 'Process' keydown the IME did claim for candidate navigation or preedit editing (Arrow*, Backspace, Home/End) disarms it and re-exposes the Space/digit this fix captures. Details inline.

Reviewed changes

  • New claimed-keydown signal — terminal-ime-linux-candidate-state.ts arms a 1500 ms window from a keyCode 229 / 'Process' letter keydown and classifies the next Space or candidate digit as IME-owned for a preedit that opens no composition session.
  • Policy wiring — xterm-bypass-policy.ts adds linuxImeOwnedPreeditGuardActive to the suppression and preventDefault decisions via claimsImeOwnedPreeditSelector (Space included, hangulPreedit opted out); terminal-pane-pane-input.ts threads the classification through.
  • Tests — a unit spec for the state machine and an Electron e2e spec asserting the exact PTY bytes, which the PR states fails on main.

ℹ️ Answers to the review questions

The hangulPreedit opt-out for Space looks correct: on Linux isHangulPreedit() is only ever true off a compositionupdate preedit (terminal-ime-composition-tracker.ts:50-53, 95), and the recorded ibus-hangul flow in terminal-ime-hangul-terminating-digit.test.ts:74-80 always runs a composition session, so 2-Set's syllable-terminating Space/digit are owned by the composition guards rather than by this window. The per-claimed-preedit one-shot also seems right: a fresh letter keydown re-arms, and re-arming per selector would only widen the over-claim surface for a multi-page list.

Technical details
# Answers and the release-path asymmetry

## Affected sites
- `terminal-ime-linux-candidate-state.ts:197-202` — one-shot release on a claimed selector.
- `terminal-ime-linux-candidate-state.ts:261, 264-269` — `compositionstart` and non-`insertCompositionText` `input` release paths.

## Required outcome
- None for the questions above; the inline finding is the only change requested.

## Notes
- The commit `input` from the recorded ibus trace is `insertText`, which `releaseOnCommit` (line 264-269) honors, so the composition-session path self-heals and does not need a `compositionend` reset for that shape.
- Because a claimed selector both suppresses and `preventDefault`s and then arms the pending-release guard, the selector keyup is also covered — no follow-up needed there.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…ts it

The window released on any keydown that was not a letter the IME had
claimed, but the IME claims the keys that edit and page a preedit too —
Backspace, the arrows, `-`/`=` for the next candidate page — and Chromium
keeps the original `code` on those 229/Process keydowns. Paging to a later
candidate therefore disarmed the window and the digit that picked from that
page reached the PTY.

A claimed keydown now refreshes the deadline instead of clearing it. Arming
still requires a claimed letter, so a bare navigation key with no preedit
behind it cannot open the window from cold.
…ends

An engine that does run a composition session still emits the claimed
`keyCode 229` letter keydowns that arm this window, and both its commit and
its cancel travel as `insertCompositionText` — the one input type the commit
release deliberately ignores. Nothing else closed the window, so a cancelled
preedit left it open and the next literal Space was swallowed. Refreshing the
deadline on every claimed keydown had widened that tail.

`compositionend` now releases it, as `compositionstart` already did. The
engines this guard exists for emit neither event, so their path is unchanged.

The selector suite drives the state object directly and cannot see a missing
listener, so the release wiring gets its own DOM-level suite.
The regression the compositionend release fixes needs the whole session to
show up: compositionstart fires once, so every letter after the first re-arms
the window, the commit and the cancel both travel as insertCompositionText,
and the committing Space arrives claimed rather than bare. Only the end of the
session closes the window before the user's next literal Space.

Fails with the compositionend listener removed.

@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

The incremental delta (8bc3f58) resolves the prior review's inline finding that a claimed non-letter keydown disarmed the window.

  • Predicate split — extracted isImeClaimedKeydown (keyCode 229 / key 'Process', unmodified) and rebuilt isImeOwnedLetterKeydown on top of it, so "claimed" and "claimed over a letter" are separable.
  • Refresh instead of disarm — a claimed non-letter keydown (Backspace, arrows, -/=, PageUp/PageDown) now extends an already-live imeOwnedPreeditUntil; arming still requires a claimed Key[A-Z], so a cold navigation key cannot open the window.
  • Regression tests — new cases cover claimed editing/paging keys keeping the window open, multi-page browsing past one window length, and the cold-navigation negative, each with exact assertions that fail without the change.

Pullfrog  | View workflow run | Using DeepSeek Flash (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

The delta since the prior review adds one release path for composition-session IMEs and its wiring test.

  • Release on compositionend — installTerminalImeLinuxCandidateState now tears the claimed window down at both composition boundaries (releaseToComposition renamed releaseToCompositionSession). Rationale: an engine that runs a composition session still emits the claimed keydowns that arm the window, and both its commit and its cancel travel as insertCompositionText, which releaseOnCommit deliberately ignores; without the compositionend release the window outlived a cancelled preedit and swallowed the next literal Space.
  • Wiring test — terminal-ime-linux-claimed-preedit-release.test.ts (happy-dom) drives the installed DOM listeners rather than the bare state object: compositionstart/compositionend release, preedit-text vs. non-preedit-text input, disposal removing listeners, and the full Space-committing session shape. All six cases pass locally; the compositionend/disposal cases fail without the listener.

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

… took

A claimed selector means the engine picked the candidate itself, so the round
is over. It reads `key: 'Process'`, which the selector predicate cannot see,
so this asks the physical code instead.

Belt and braces for an engine that opens a composition session and never closes
it — a documented defect in at least one shipped input framework. Without a
`compositionend` the session-end release never runs, and the refresh added for
candidate paging would otherwise hold the window open across the commit and
swallow the user's next literal Space. Paging keys still refresh, so a long
candidate browse is unaffected.
Japanese and Zhuyin commit with Enter, not Space, and Escape cancels. Both
arrived claimed and fell into the refresh branch, so for an engine that opens a
composition session and never closes it they held the window across the commit
— the same defect the Space case was written to close, reached by a different
key. Arrows, `-`/`=`, Backspace and PageUp/PageDown still refresh, because those
page the candidate list rather than ending the round.

Physical codes rather than `key`: `key` reads `Process` on all of them, and
`Digit*` survives AZERTY, Dvorak and Colemak moving the digit row.

Also corrects the window comment, which still described the orphan-keyup
picking window. Since claimed keys refresh it, it is now a stuck-state valve
that only expires when the IME stops reporting.

@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

The incremental delta (00f2002) closes the residual leak for a composition-session IME that never fires compositionend: a selector the IME took itself now ends the window instead of extending it.

  • Claimed selector ends the window — a keyCode 229 / key 'Process' keydown whose physical code is Space, Digit[0-9] or Numpad[0-9] now clears imeOwnedPreeditUntil rather than refreshing it, so the next literal Space/digit is no longer swallowed when a session-running engine (one that skips compositionend) took the pick itself. The physical code is the only usable signal because key reads Process there.
  • Tests — terminal-ime-linux-claimed-preedit-selector.test.ts gains a "composition session that never ends" block: Space/Digit2/Numpad2 end the window, and Equal still pages it. The new ending cases fail without the ternary; all 24 cases across the two specs pass locally.

I checked whether ending on a claimed Shift+Space (fcitx's width toggle) regresses the picking window: it does not. The pre-existing unclaimed-key branch at line 234 already ends the window for any non-selector keydown, and isTerminalImeCandidateSelectionKeyEvent excludes shiftKey, so the claimed and unclaimed paths now agree. Numpad[0-9] with NumLock off (code: 'Numpad2', key: 'ArrowDown') is likewise treated as an ender on both paths, so the change is internally consistent rather than a new edge.

Pullfrog  | View workflow run | Using DeepSeek Flash (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

The incremental delta (f01bdda) generalizes the claimed-key ending from selectors to any commit or cancel key, completing the release contract for a session IME that never fires compositionend.

  • Claimed commit/cancel ends the window — the ender predicate is renamed PHYSICAL_PICK_ENDING_CODE and widened to Space/Enter/NumpadEnter/Escape/Digit[0-9]/Numpad[0-9], so a claimed Enter (commit) or Escape (cancel) now ends the pick instead of refreshing it. The prior shape still refreshes for the true navigation/paging keys (Backspace, arrows, -/=, PageUp/PageDown). Using the physical code is right: key reads Process on every claimed key, and the digit row moves under AZERTY/Dvorak/Colemak while Digit* does not.
  • Tests — the "composition session that never ends" it.each now covers all six ending codes; the new Enter/NumpadEnter/Escape cases fail without the widened predicate. All 27 cases across the two specs pass locally.

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

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]: Sogou Pinyin Input Issue in Codex on Ubuntu 20.04

1 participant