Skip to content

fix(browser): keep IME Enter on composition path - #2108

Merged
austinywang merged 2 commits into
mainfrom
issue-1814-browser-ime-enter
Mar 25, 2026
Merged

austinywang merged 2 commits into
mainfrom
issue-1814-browser-ime-enter

Conversation

@austinywang

@austinywang austinywang commented Mar 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Testing

  • ./scripts/reload.sh --tag issue-1814-browser-ime-enter
  • did not run tests locally per repo policy

Demo Video

  • Video URL or attachment: not recorded

Review Trigger (Copy/Paste as PR comment)

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

Checklist

  • I tested the change locally
  • I added or updated tests for behavior changes
  • I updated docs/changelog if needed
  • I requested bot reviews after my latest commit (copy/paste block above or equivalent)
  • All code review bot comments are resolved
  • All human review comments are resolved

Summary by cubic

Keep Return and keypad Enter on the IME composition path while marked text is active, so confirming candidates doesn’t submit web forms in the embedded browser. Closes #1814.

  • Bug Fixes
    • Skip forwarding Return/Enter to the browser when the first responder has marked text.
    • Detect marked text via NSTextInputClient/NSTextView and pass firstResponderHasMarkedText into Return/Enter routing.
    • Add regression tests for IME Return and keypad Enter, plus unit tests for the routing helper.

Written for commit 7d0dc4e. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed an issue where the Return/Enter key was being incorrectly intercepted by the browser during IME (Input Method Editor) text composition, causing disruptions to international keyboard input and text input methods.

@vercel

vercel Bot commented Mar 25, 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 Mar 25, 2026 5:20am

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented Mar 25, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The changes add IME composition detection to prevent Return/Enter key dispatch to the browser first responder when marked text is active. A new helper function browserResponderHasMarkedText() detects the active composition state from NSResponder, and the key routing decision function now accepts a firstResponderHasMarkedText parameter to guard against dispatch during IME composition.

Changes

Cohort / File(s) Summary
Core IME Key Routing Logic
Sources/AppDelegate.swift
Added browserResponderHasMarkedText(_:) helper to detect IME composition state from NSResponder (supporting NSTextInputClient, NSTextField, and NSTextView cases). Extended shouldDispatchBrowserReturnViaFirstResponderKeyDown(_:firstResponderIsBrowser:firstResponderHasMarkedText:flags:) signature with new firstResponderHasMarkedText parameter. Updated NSWindow.cmux_performKeyEquivalent to compute and pass marked-text state into the routing decision.
IME Key Routing Tests
cmuxTests/BrowserConfigTests.swift
Added BrowserMarkedTextProbeTextView test probe to simulate marked-text state in tests. Introduced BrowserIMEKeyDownRoutingTests with integration tests verifying Return/Keypad Enter events are not consumed and are not forwarded to browser during marked-text composition. Extended BrowserReturnKeyDownRoutingTests with unit tests asserting routing function returns false when marked text is present.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A rabbit hops through IME's dance,
Where Enter keys once had their chance,
But now with marked-text guards in place,
Compositions finish at their pace,
No premature forms take the leap—
The browser's Return secrets keep! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: preventing IME Enter from being intercepted during composition.
Description check ✅ Passed The description includes all critical sections (Summary, Testing, and checklist) with appropriate details about the fix, linked issue, and testing approach.
Linked Issues check ✅ Passed The PR fully addresses issue #1814 by implementing the required hasMarkedText() guard and composition path preservation for Return/Enter during IME.
Out of Scope Changes check ✅ Passed All changes are directly related to the issue objective: detecting marked text state and preventing browser Return/Enter interception during IME composition.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ 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-1814-browser-ime-enter

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.

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 2 files

@greptile-apps

greptile-apps Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes issue #1814 where IME confirmation (Return/keypad Enter during marked-text composition) was being intercepted and force-dispatched to the browser's keyDown, causing web forms to submit prematurely instead of letting the input method commit the candidate text.

The fix adds a browserResponderHasMarkedText helper that inspects the current first responder for active marked text, then threads that flag through shouldDispatchBrowserReturnViaFirstResponderKeyDown as an early-exit guard. When marked text is active, performKeyEquivalent returns false so AppKit continues normal event dispatch and the IME receives the keystroke. The regression tests correctly follow the repo's two-commit policy (failing test in commit 118d5aea, fix in 7d0dc4ed).

  • browserResponderHasMarkedText checks NSTextInputClient first, then falls back to an NSTextField/field-editor path that appears unreachable in normal AppKit responder promotion (P2, harmless)
  • The two integration tests in BrowserIMEKeyDownRoutingTests share ≈35 lines of identical setup; a shared helper would reduce duplication (P2)

Confidence Score: 5/5

  • Safe to merge — targeted fix with correct logic, regression tests follow repo policy, no breaking changes to existing paths.
  • The change is narrow and well-scoped: one new helper function, one new guard, and corresponding tests. The two-commit regression test structure is correctly followed (test-first then fix). All existing browser Return routing behaviour is preserved for the non-IME case. The only findings are two P2 style observations that don't affect correctness.
  • No files require special attention.

Important Files Changed

Filename Overview
Sources/AppDelegate.swift Adds browserResponderHasMarkedText helper and threads firstResponderHasMarkedText through shouldDispatchBrowserReturnViaFirstResponderKeyDown so Return/keypad Enter are not force-dispatched to the browser responder during IME composition. Logic is correct; the NSTextField branch in the helper is potentially unreachable in practice (P2).
cmuxTests/BrowserConfigTests.swift Adds BrowserMarkedTextProbeTextView stub, two integration tests in BrowserIMEKeyDownRoutingTests, and two unit tests in BrowserReturnKeyDownRoutingTests. Regression test commit policy (test-first, then fix) is correctly followed. Some duplication in the integration test setup (P2).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["NSWindow.performKeyEquivalent(event)"] --> B["Compute firstResponderWebView"]
    A --> C["Compute firstResponderHasMarkedText\nbrowserResponderHasMarkedText(firstResponder)"]
    C --> C1{"responder as? NSTextInputClient"}
    C1 -- yes --> C2["return textInputClient.hasMarkedText()"]
    C1 -- no --> C3{"responder as? NSTextField\n& currentEditor as? NSTextView"}
    C3 -- yes --> C4["return editor.hasMarkedText()"]
    C3 -- no --> C5["return false"]

    B --> D["shouldDispatchBrowserReturnViaFirstResponderKeyDown(...)"]
    C --> D
    D --> E{"firstResponderIsBrowser?"}
    E -- no --> F["return false (no-op)"]
    E -- yes --> G{"firstResponderHasMarkedText?"}
    G -- yes --> H["return false → IME keeps event\n(fix for issue #1814)"]
    G -- no --> I{"keyCode == 36 or 76?"}
    I -- no --> F
    I -- yes --> J{"plain/Shift Return?"}
    J -- yes --> K["firstResponder.keyDown(event)\nreturn true (form submit)"]
    J -- no --> F
Loading

Reviews (1): Last reviewed commit: "fix(browser): keep IME Enter on composit..." | Re-trigger Greptile

Comment thread Sources/AppDelegate.swift
Comment on lines +1516 to +1519
if let textField = responder as? NSTextField,
let editor = textField.currentEditor() as? NSTextView {
return editor.hasMarkedText()
}

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.

P2 NSTextField branch appears unreachable in normal AppKit flow

In standard AppKit event dispatch, when a user is actively editing an NSTextField, the field editor (NSTextView) — not the NSTextField itself — becomes the window's first responder. Since NSTextView conforms to NSTextInputClient, it is already handled by the first branch above. The NSTextField branch here would only be reached if NSTextField is the first responder while currentEditor() is simultaneously non-nil, which does not occur in the normal AppKit responder promotion sequence.

This is harmless dead code, but if the intent is to guard against some custom NSTextField subclass scenario, a brief comment explaining the edge case would help future readers understand why both paths exist.

Comment on lines +2475 to 2576
func testWindowPerformKeyEquivalentDoesNotForwardReturnDuringMarkedTextComposition() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()

let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container

let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
container.addSubview(webView)

let responder = BrowserMarkedTextProbeTextView(frame: NSRect(x: 0, y: 0, width: 32, height: 20))
responder.hasMarkedTextForTesting = true
webView.addSubview(responder)

window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }

XCTAssertTrue(window.makeFirstResponder(responder))
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "\r",
charactersIgnoringModifiers: "\r",
isARepeat: false,
keyCode: 36
) else {
XCTFail("Failed to construct Return event")
return
}

let consumed = window.performKeyEquivalent(with: event)

XCTAssertFalse(consumed, "Return should stay in the IME path while marked text is active")
XCTAssertTrue(responder.hasMarkedText(), "Marked text should still be active until the input method commits it")
XCTAssertEqual(responder.keyDownEvents.count, 0, "Return should not be force-forwarded to the browser responder during IME composition")
}

@MainActor
func testWindowPerformKeyEquivalentDoesNotForwardKeypadEnterDuringMarkedTextComposition() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()

let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container

let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
container.addSubview(webView)

let responder = BrowserMarkedTextProbeTextView(frame: NSRect(x: 0, y: 0, width: 32, height: 20))
responder.hasMarkedTextForTesting = true
webView.addSubview(responder)

window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }

XCTAssertTrue(window.makeFirstResponder(responder))
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "\r",
charactersIgnoringModifiers: "\r",
isARepeat: false,
keyCode: 76
) else {
XCTFail("Failed to construct keypad Enter event")
return
}

let consumed = window.performKeyEquivalent(with: event)

XCTAssertFalse(consumed, "Keypad Enter should stay in the IME path while marked text is active")
XCTAssertTrue(responder.hasMarkedText(), "Marked text should still be active until the input method commits it")
XCTAssertEqual(responder.keyDownEvents.count, 0, "Keypad Enter should not be force-forwarded to the browser responder during IME composition")
}
}


final class BrowserReturnKeyDownRoutingTests: XCTestCase {
func testRoutesForReturnWhenBrowserFirstResponder() {
XCTAssertTrue(

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.

P2 Duplicated integration test setup — consider a shared helper

testWindowPerformKeyEquivalentDoesNotForwardReturnDuringMarkedTextComposition and testWindowPerformKeyEquivalentDoesNotForwardKeypadEnterDuringMarkedTextComposition share identical window/webView/responder setup (≈35 lines each), differing only in keyCode (36 vs 76) and the assertion messages. Extracting the shared scaffolding into a private helper would make it straightforward to add coverage for future key codes without duplicating boilerplate:

private func runIMEEnterTest(keyCode: UInt16, label: String) { … }

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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

🧹 Nitpick comments (1)
Sources/AppDelegate.swift (1)

1524-1528: Make firstResponderHasMarkedText mandatory.

Line 1527 makes the new safeguard opt-in. cmuxTests/BrowserConfigTests.swift:2577-2611 already has callers omitting this argument, so future production call sites could silently fall back to the pre-fix behavior.

♻️ Proposed change
 func shouldDispatchBrowserReturnViaFirstResponderKeyDown(
     keyCode: UInt16,
     firstResponderIsBrowser: Bool,
-    firstResponderHasMarkedText: Bool = false,
+    firstResponderHasMarkedText: Bool,
     flags: NSEvent.ModifierFlags
 ) -> Bool {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 1524 - 1528, The function
shouldDispatchBrowserReturnViaFirstResponderKeyDown currently makes
firstResponderHasMarkedText optional by providing a default; remove the default
so callers must explicitly pass firstResponderHasMarkedText to opt into the
safeguard — update the function signature in
shouldDispatchBrowserReturnViaFirstResponderKeyDown to remove the "= false"
default and then update all call sites (including tests like
cmuxTests/BrowserConfigTests.swift) to pass an explicit Bool for
firstResponderHasMarkedText.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/AppDelegate.swift`:
- Around line 1524-1528: The function
shouldDispatchBrowserReturnViaFirstResponderKeyDown currently makes
firstResponderHasMarkedText optional by providing a default; remove the default
so callers must explicitly pass firstResponderHasMarkedText to opt into the
safeguard — update the function signature in
shouldDispatchBrowserReturnViaFirstResponderKeyDown to remove the "= false"
default and then update all call sites (including tests like
cmuxTests/BrowserConfigTests.swift) to pass an explicit Bool for
firstResponderHasMarkedText.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e2b1d14b-0535-46f7-ae7e-3a400949c058

📥 Commits

Reviewing files that changed from the base of the PR and between 960006e and 7d0dc4e.

📒 Files selected for processing (2)
  • Sources/AppDelegate.swift
  • cmuxTests/BrowserConfigTests.swift

@austinywang
austinywang merged commit 321f8c1 into main Mar 25, 2026
18 checks passed
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
* test: cover browser IME Enter composition routing

* fix(browser): keep IME Enter on composition path
@notoriouslab

Copy link
Copy Markdown

@austinywang

首先感謝你們針對中文輸入法的改善努力,不過上次的修復還是有一點問題:

Still reproducible after #2108

Environment

  • macOS
  • Input method: 注音 (Zhuyin / Bopomofo)
  • Browser: CMUX built-in browser
  • Tested on: claude.ai

What still happens

After #2108 was merged, the issue persists. Pressing Enter after 注音 candidate selection still submits the web form instead of committing the composition.

Why the fix likely still has a race condition

#2108 guards against dispatch by calling hasMarkedText() on the AppKit first responder inside performKeyEquivalent. However, by the time performKeyEquivalent is invoked for the Return key, WKWebView appears to have already committed and cleared the marked text — so hasMarkedText() returns false, the guard is bypassed, and Enter is forwarded to the browser as a form submission.

This is the same timing issue as the web-layer isComposing guard: the event ordering in WKWebView places Enter after marked text is cleared, making synchronous checks unreliable.

Confirmed workaround (JS layer, tested)

Injecting the following via DevTools Console fully resolves the issue:

(function() {
  let composing = false;
  document.addEventListener('compositionstart', () => composing = true, true);
  document.addEventListener('compositionend', () => {
    setTimeout(() => composing = false, 30);
  }, true);
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && composing) {
      e.preventDefault();
      e.stopImmediatePropagation();
    }
  }, true);
})();

The key insight is the 30ms delay on compositionend — this compensates for WKWebView clearing marked text before the keydown event is processed.

Suggested fix direction

The same delay-based approach could be applied at the native layer: instead of checking hasMarkedText() synchronously at performKeyEquivalent time, maintain a short-lived isComposing flag that is set on insertText:replacementRange: / composition start and cleared with a brief delay after setMarkedText: receives an empty string. This would mirror the JS workaround's behavior without relying on a synchronous hasMarkedText() call that may already be stale.

This branch was successfully deployed

1 active deployment
Preview — 7d0dc4ed Deployed Mar 25, 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.

Built-in browser: Bopomofo IME Enter key submits webpage instead of committing composition

2 participants