Skip to content

Restore Zhuyin IME candidate marked-text handling - #3574

Merged
austinywang merged 8 commits into
mainfrom
issue-3571-zhuyin-ime-candidate
May 7, 2026
Merged

austinywang merged 8 commits into
mainfrom
issue-3571-zhuyin-ime-candidate

Conversation

@austinywang

@austinywang austinywang commented May 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • restore active marked-text selection ownership in GhosttyNSView NSTextInputClient
  • return active marked text substrings for IME candidate/composition queries while preserving terminal-selection fallback for dictation/accessibility
  • add CJK/Zhuyin marked-text regression coverage

Verification

  • ./scripts/reload.sh --tag issue-3571-zhuyin-ime-candidate
  • ./scripts/reload.sh --tag issue-3571-zhuyin-ime-candidate after commit

Notes

  • Local XCTest was not run per project testing policy.

Fixes #3571


Note

Medium Risk
Changes NSTextInputClient IME composition behavior and key event forwarding, which can regress text input for CJK/other input methods if edge cases are missed. Coverage is improved with new Zhuyin/CJK regression tests, reducing but not eliminating risk.

Overview
Fixes IME marked-text handling in GhosttyNSView during composition by tracking the active marked-text selection, clamping IME range queries to the preedit buffer, and serving attributedSubstring from marked text when present.

Updates keyDown to suppress forwarding a Ghostty key event when interpretKeyEvents only mutates IME composition state (marked text/selection) without committing text, preventing duplicate input (notably for Zhuyin).

Adds CJKIMEMarkedSelectionTests to cover marked selection/substrings and Zhuyin keyDown forwarding behavior, and removes the now-redundant selectedRange test from CJKIMEInputTests.

Reviewed by Cursor Bugbot for commit 50cb0ff. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Restores correct marked-text handling during IME composition so Zhuyin/CJK candidate windows get the right selection and substrings, and suppresses duplicate terminal key forwarding when composition changes without commit. Keeps terminal-selection fallback for dictation and accessibility. Fixes #3571.

  • Bug Fixes

    • In GhosttyNSView’s NSTextInputClient, selectedRange() returns the active marked-text selection during composition; falls back to terminal selection only when no marked text exists.
    • attributedSubstring(forProposedRange:) serves substrings from active marked text; ranges are clamped and state resets on unmarkText().
    • Prevent duplicate terminal dispatch by not forwarding keys when interpretKeyEvents mutates marked text or its selection unless insertText committed text.
  • Refactors

    • Moved marked-text range helpers and IME dispatch suppression into GhosttyNSView+IMEComposition.swift.
    • Added CJKIMEMarkedSelectionTests.swift; trimmed CJKIMEInputTests.swift and small compactions/lint fixes to satisfy file-budget rules.

Written for commit 50cb0ff. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Improved IME composition handling: selection inside composing text is now tracked and preferred, yielding more accurate cursor/selection behavior and substring responses during CJK IME input.
  • Tests
    • Added comprehensive tests covering marked-text selection, substring retrieval, unmarking behavior, and multiple IME scenarios to prevent regressions.

Zhuyin candidate UI asks the NSTextInputClient about the active marked range and text while composition is in progress. Dictation support had redirected selectedRange and substring queries to terminal selection snapshots, which left active IME preedit without its own selection/content answers. Keep terminal-selection behavior for non-IME accessibility and dictation, but let active marked text own selection and substring responses.

Constraint: Preserve dictation/accessibility terminal-selection fallback when no marked text is active

Rejected: Special-case Traditional Chinese input source IDs | the bug is in the generic marked-text client contract

Confidence: medium

Scope-risk: narrow

Directive: Do not route active marked-text selectedRange or attributedSubstring through terminal selection snapshots

Tested: ./scripts/reload.sh --tag issue-3571-zhuyin-ime-candidate

Not-tested: Manual Zhuyin candidate window dogfood; local XCTest per project testing policy
@vercel

vercel Bot commented May 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 6, 2026 11:59pm
cmux-staging Building Building Preview, Comment May 6, 2026 11:59pm

@coderabbitai

coderabbitai Bot commented May 5, 2026 •

Copy link
Copy Markdown

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

Adds explicit IME composition selection tracking and clamping helpers; updates NSTextInputClient paths in GhosttyTerminalView to prefer and maintain a marked-text selection during composition; introduces GhosttyNSView helpers for normalizing/clamping marked ranges; adds unit tests and Xcode project entries.

Changes

IME Composition Selection Management

Layer / File(s) Summary
Data / State
Sources/GhosttyTerminalView.swift
Adds private var markedSelectedRange to track selection inside marked (composing) text.
Helpers / Validation
Sources/GhosttyNSView+IMEComposition.swift
Adds normalizedMarkedSelectionRange(_:, markedLength:) and clampedMarkedTextRange(_:, markedLength:) to validate and clamp marked-text ranges.
Core NSTextInputClient behavior
Sources/GhosttyTerminalView.swift
selectedRange() prefers markedSelectedRange when markedText exists (with DEBUG assert). setMarkedText(_:) computes/stores normalized marked selection; unmarkText() clears it. attributedSubstring(forProposedRange:actualRange:) returns composing substring using clamped range when marked text is present.
Key event / IME wiring
Sources/GhosttyTerminalView.swift
KeyDown flow captures IME state before handling, adds accumulatedText handling, and invokes IME-aware forwarding suppression while preserving non-composing behavior.
Project / Targets
GhosttyTabs.xcodeproj/project.pbxproj
Adds GhosttyNSView+IMEComposition.swift and CJKIMEMarkedSelectionTests.swift entries to project and appropriate build phases/targets.
Tests
cmuxTests/CJKIMEMarkedSelectionTests.swift, cmuxTests/CJKIMEInputTests.swift
Adds CJKIMEMarkedSelectionTests.swift with IME composition tests (selection mirroring, substring extraction, unmarking, Zhuyin scenarios). Updates CJKIMEInputTests.swift to assert validAttributesForMarkedText() is empty.

Sequence Diagram(s)

sequenceDiagram
    participant App as App (Event loop)
    participant View as GhosttyNSView / GhosttyTerminalView
    participant IME as macOS IME (NSTextInputClient)
    participant Core as Terminal Core (input forwarding)

    App->>View: keyDown(event)
    View->>IME: query markedText / markedSelectedRange (capture before)
    IME-->>View: markedText / composition state
    View->>View: update markedSelectedRange (normalize/clamp)
    alt composing exists
        View->>View: attributedSubstring(forProposedRange) → composing substring
        View->>Core: suppress or forward accumulated text based on IME handling
        Core-->>View: acknowledgement
    else no composition
        View->>Core: forward key event / insertText
        Core-->>View: acknowledgement
    end
    View->>IME: unmarkText() when composition ends
    IME-->>View: cleared state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#1671: Also modifies IME-related input handling in GhosttyTerminalView.swift and adds IME-focused tests.
  • manaflow-ai/cmux#2529: Adjusts GhosttyTerminalView.swift’s IME-aware keyDown/send-text flow and suppression logic during composition.
  • manaflow-ai/cmux#1410: Changes NSTextInputClient selection/marked-text handling in GhosttyNSView/GhosttyTerminalView.

Poem

🐰
I twitch my whiskers at the preedit line,
I guard the mark where composing cursors shine.
Zhuyin hops and kana hum along,
I keep the caret steady, sure, and strong.
Hoppity hop — the IME sings its song.

🚥 Pre-merge checks | ✅ 12 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (12 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Restore Zhuyin IME candidate marked-text handling' directly describes the main change and aligns with the primary objective to fix Traditional Chinese Zhuyin IME candidate window functionality.
Linked Issues check ✅ Passed The code changes comprehensively address issue #3571 requirements: restore marked-text selection ownership [GhosttyTerminalView.swift, GhosttyNSView+IMEComposition.swift], implement clamped substring queries [GhosttyTerminalView.swift], suppress duplicate key forwarding [GhosttyTerminalView.swift, GhosttyNSView+IMEComposition.swift], and add Zhuyin/CJK regression tests [CJKIMEMarkedSelectionTests.swift].
Out of Scope Changes check ✅ Passed All code changes are directly related to IME composition handling and addressing the Zhuyin/CJK regression in issue #3571; no unrelated or out-of-scope modifications detected.
Cmux Swift Actor Isolation ✅ Passed Pure helper methods with Sendable parameters/returns. No shared mutable state access, no implicit MainActor models, no isolation violations. UI type patterns preserved.
Cmux Swift Blocking Runtime ✅ Passed Production code introduces IME state tracking only, no blocking synchronization, semaphores, locks, or delays. Test code uses allowed deterministic RunLoop scaffolding. Compliant with all rules.
Cmux Swift Concurrency ✅ Passed No legacy async patterns introduced. Production code adds synchronous IME helpers only. Test file uses allowed RunLoop synchronization for UI setup.
Cmux Swift @Concurrent ✅ Passed PR contains no Swift async/await functions, @concurrent annotations, or nonisolated async patterns. All changes are synchronous helper methods. No concurrent violations detected.
Cmux Swift File And Package Boundaries ✅ Passed Small focused extension (66 lines) for AppKit IME glue. Single responsibility, no mixed concerns. Test file allowed. Minimal incidental changes to existing large files.
Cmux Swift Logging ✅ Passed No logging violations found. GhosttyNSView+IMEComposition.swift contains no print/NSLog statements. GhosttyTerminalView changes use only DEBUG-guarded instrumentation.
Cmux Swiftui State Layout ✅ Passed AppKit NSView IME handling modification. Private state without @Published. No SwiftUI state, render mutations, or layout issues. Allowed: AppKit bridge view.
Cmux Architecture Rethink ✅ Passed PR introduces proper NSTextInputClient fix with clear state ownership. Adds markedSelectedRange synchronized with markedText. No timing repairs, observers, locks, or split lifecycle ownership issues.
Description check ✅ Passed The pull request provides comprehensive context covering changes, verification steps, and testing notes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-3571-zhuyin-ime-candidate

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 and usage tips.

@greptile-apps

greptile-apps Bot commented May 5, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR restores correct Zhuyin/CJK IME behavior by tracking the active marked-text selection in a new markedSelectedRange field and returning it from selectedRange() during composition, while falling back to the terminal snapshot for dictation/accessibility. Key forwarding is now suppressed when interpretKeyEvents mutates IME state without committing text, preventing duplicate terminal input during composition.

  • selectedRange() now returns markedSelectedRange when marked text is active, satisfying candidate window queries that depend on the cursor position inside the preedit buffer; it falls back to the terminal selection snapshot only when no composition is in progress.
  • attributedSubstring(forProposedRange:) serves clamped substrings from the marked-text buffer during composition, enabling Zhuyin candidate windows to retrieve the correct phonetic components.
  • shouldSuppressGhosttyKeyForwardingAfterIMEHandling (extracted to GhosttyNSView+IMEComposition.swift) suppresses Ghostty key dispatch when interpretKeyEvents changed marked text or its selection without producing committed text, eliminating the duplicate-input regression from [Bug] Traditional Chinese (Zhuyin/Bopomofo) IME candidate window missing after v0.64.0 update #3571.

Confidence Score: 5/5

Safe to merge — targeted NSTextInputClient corrections with no timing dependencies and clear state ownership throughout.

All three markedSelectedRange mutation sites keep the field in sync with markedText, so the invariant enforced by the #if DEBUG assertion is always maintained. The suppression logic correctly distinguishes AppKit-consumed keys from committed insertions and passthrough keys across all analyzed edge cases.

No files require special attention.

Important Files Changed

Filename Overview
Sources/GhosttyNSView+IMEComposition.swift New 66-line file with well-scoped IME helpers: range-clamping utilities and key-forwarding suppression logic; single clear responsibility, DEBUG-guarded test shim, no issues.
Sources/GhosttyTerminalView.swift Small, targeted changes to NSTextInputClient callbacks: adds markedSelectedRange field, wires it through setMarkedText/unmarkText, updates selectedRange() and attributedSubstring to serve from the marked buffer; all mutation sites keep the field in sync with markedText.
cmuxTests/CJKIMEMarkedSelectionTests.swift New 251-line test suite covering marked-text selection tracking, substring retrieval, unmark cleanup, Zhuyin scenarios, and forwarding-suppression logic.
cmuxTests/CJKIMEInputTests.swift Removes overlapping testSelectedRangeReturnsEmptyRangeWithoutSelection test (moved to CJKIMEMarkedSelectionTests); section header updated accordingly.
GhosttyTabs.xcodeproj/project.pbxproj Correctly registers both new files in their respective targets; UUID entries are consistent across all four required pbxproj sections.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[keyDown event received] --> B[snapshot markedStateBefore]
    B --> C[interpretKeyEvents]
    C --> D{keyboard layout changed?}
    D -- Yes --> E[syncPreedit + return]
    D -- No --> F[syncPreedit]
    F --> G{accumulatedText non-empty?}
    G -- Yes --> H[forward committed text to Ghostty]
    G -- No --> I{IME state changed?}
    I -- Yes --> J[suppress Ghostty key forwarding]
    I -- No --> K{active or had marked text?}
    K -- Yes --> L[forward key with composing=true]
    K -- No --> M[forward key normally]
Loading

Reviews (5): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread Sources/GhosttyTerminalView.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmuxTests/CJKIMEInputTests.swift`:
- Around line 367-440: The file exceeds the Swift file-length budget because the
new tests (testSelectedRangeReturnsEmptyRangeWithoutSelectionOrMarkedText,
testSelectedRangeTracksMarkedTextSelection,
testSelectedRangeReturnsEmptyRangeAfterCompositionEnds,
testAttributedSubstringReturnsMarkedTextSegment,
testTraditionalChineseZhuyinMarkedTextSelectionAndSubstring) appended to the
large cmuxTests/CJKIMEInputTests.swift push it over the limit; fix by moving
these test functions (which reference GhosttyNSView and its selectedRange(),
setMarkedText(...), unmarkText(), and
attributedSubstring(forProposedRange:actualRange:)) into a new test file (e.g.,
cmuxTests/CJKIMEMarkedSelectionTests.swift) and ensure the new file has the
appropriate import/test class scaffolding and test target so CI file-length
check passes.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 12828-12853: Extract the two IME helper methods
normalizedMarkedSelectionRange(_:) and clampedMarkedTextRange(_:) from
GhosttyTerminalView.swift into a new Swift file by creating an extension on
GhosttyTerminalView (e.g., GhosttyNSView+IMEComposition) and paste the methods
verbatim (keeping their signatures, private access, and any references to
markedText); remove the original implementations from GhosttyTerminalView.swift
so the file shrinks under CI budget, ensure the new file imports
Foundation/AppKit as needed and is included in the same target so compilation
and behavior remain unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f5986c26-e58e-4e48-8a06-a60401c50ab1

📥 Commits

Reviewing files that changed from the base of the PR and between d7dae39 and 096655f.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/CJKIMEInputTests.swift

Comment thread cmuxTests/CJKIMEInputTests.swift Outdated
Comment thread Sources/GhosttyTerminalView.swift Outdated
The Zhuyin marked-text regression tests and helper logic were valid, but they pushed already-large files over the review budget. Move the marked-text range helper into a focused GhosttyNSView extension and keep the new IME coverage in a dedicated test file so the behavior stays covered without expanding the overloaded CJK test file.

Constraint: CodeRabbit file budget review requires both GhosttyTerminalView.swift and CJKIMEInputTests.swift to stay under their configured limits

Rejected: Leave the helpers inline | it keeps the behavioral fix correct but fails the repository review budget

Confidence: high

Scope-risk: narrow

Directive: Keep active marked-text range helpers near IME composition code instead of re-expanding GhosttyTerminalView.swift

Tested: ./scripts/reload.sh --tag issue-3571-zhuyin-ime-candidate

Not-tested: Local XCTest per project testing policy

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmuxTests/CJKIMEMarkedSelectionTests.swift`:
- Line 10: The CJKIMEMarkedSelectionTests class is missing an explicit deinit
which triggers the required_deinit SwiftLint rule; add a deinit { } to the final
class CJKIMEMarkedSelectionTests to satisfy the linter (place the deinit inside
the class body, keeping it empty if no teardown is needed) so CI linting no
longer fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ccc34947-c2ac-4531-9b39-6839c8a7d88b

📥 Commits

Reviewing files that changed from the base of the PR and between 096655f and ff641de.

📒 Files selected for processing (5)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/GhosttyNSView+IMEComposition.swift
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/CJKIMEInputTests.swift
  • cmuxTests/CJKIMEMarkedSelectionTests.swift

Comment thread cmuxTests/CJKIMEMarkedSelectionTests.swift
The dedicated Zhuyin marked-selection test class needs the repo's explicit deinit convention even though it has no teardown work. Add the empty deinit so review and lint gates stay focused on the IME behavior rather than class-shape warnings.

Constraint: SwiftLint required_deinit applies to XCTest classes in this target

Rejected: Suppress the lint rule locally | the existing test style expects explicit deinit declarations

Confidence: high

Scope-risk: narrow

Tested: ./scripts/reload.sh --tag issue-3571-zhuyin-ime-candidate

Not-tested: Local XCTest per project testing policy
Zhuyin can update marked text during interpretKeyEvents without committing insertText. Treat marked-text or marked-selection changes as AppKit consuming the key so Ghostty does not receive a second key event while the IME candidate flow owns composition state.

Constraint: NSTextInputClient marked-text transitions are synchronous AppKit ownership boundaries

Rejected: Suppress only Traditional Chinese source IDs | the invariant applies to any IME that mutates marked text without committing text

Confidence: high

Scope-risk: narrow

Directive: Do not forward Ghostty key events after interpretKeyEvents mutates marked text unless insertText committed payload exists

Tested: git diff --check

Not-tested: local xcodebuild/tests unavailable by repo and user policy; CI will validate after push
Bring the PR branch onto the current main tip so CI validates the IME candidate fix against the merge target.

Constraint: origin/main advanced from v0.64.2 to v0.64.3 while this PR was open

Confidence: high

Scope-risk: narrow

Tested: merge completed by git

Not-tested: CI pending after push
After merging current main, GhosttyTerminalView gained upstream lines and the IME bridge was close to the enforced large-file budget. Compact the new assertion and range normalization call without changing the marked-text ownership behavior.

Constraint: CodeRabbit/CI enforce line budgets on large Swift files

Rejected: Move more NSTextInputClient logic out of the main file | larger migration than needed for this review round

Confidence: high

Scope-risk: narrow

Tested: git diff --check

Not-tested: local xcodebuild/tests unavailable by repo and user policy; CI will validate after push

This branch was successfully deployed

1 active deployment
Preview – cmux — 50cb0ff6 Deployed May 6, 2026 by vercel[bot]
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] Traditional Chinese (Zhuyin/Bopomofo) IME candidate window missing after v0.64.0 update

1 participant