Skip to content

Fix macOS modifier desync causing idle terminal input corruption - #2855

Merged
austinywang merged 3 commits into
mainfrom
issue-2853-terminal-input-corruption-idle
Apr 13, 2026
Merged

austinywang merged 3 commits into
mainfrom
issue-2853-terminal-input-corruption-idle

Conversation

@austinywang

@austinywang austinywang commented Apr 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • fix macOS flagsChanged handling so modifier-only events emit correct press/release edges
  • use explicit left and right device masks for Shift, Control, Option, and Command so releasing one side while the other stays held does not leave Ghostty desynced
  • keep modifier-only flagsChanged events off AppKit character APIs to avoid crashes on non-text events
  • add unit coverage for modifier edge detection used by the terminal bridge

Testing

  • ./scripts/reload.sh --tag issue-2853-terminal-input-corruption-idle
  • ./scripts/reload.sh --tag issue-2853-terminal-input-corruption-idle --launch

Fixes #2853

Summary by CodeRabbit

  • Bug Fixes

    • More accurate detection of modifier key press vs. release for Shift, Control, Option, Command and Caps Lock.
    • Stops synthesizing modifier events when IME marked text is present, improving IME compatibility and preventing spurious modifier input.
  • Tests

    • Added unit tests covering modifier key press/release behaviors for left/right keys and Caps Lock.

@vercel

vercel Bot commented Apr 13, 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 Apr 13, 2026 11:21pm

@coderabbitai

coderabbitai Bot commented Apr 13, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Added a helper to map macOS modifier keycodes and modifier-flag state to Ghostty PRESS/RELEASE (or nil). Updated flagsChanged handling to skip when IME marked text exists and to use the helper before sending synthesized Ghostty modifier events. Added tests for side-specific modifier behavior.

Changes

Cohort / File(s) Summary
Modifier action & handler
Sources/GhosttyTerminalView.swift
Added cmuxGhosttyModifierActionForFlagsChanged(keyCode:modifierFlagsRawValue:) returning ghostty_input_action_e?. Updated GhosttyNSView.flagsChanged(with:) to check hasMarkedText(), call the new helper, and only send Ghostty modifier PRESS/RELEASE when the helper returns an action.
Tests
cmuxTests/TerminalAndGhosttyTests.swift
Added GhosttyModifierFlagsChangedActionTests covering left/right Shift, right-side Control/Option/Command mapping based on NX_DEVICE*KEYMASK bits, Caps Lock via NSEvent flag, and non-modifier keycodes returning nil.

Sequence Diagram

sequenceDiagram
    actor User
    participant NSView as GhosttyNSView
    participant ModAction as cmuxGhosttyModifierActionForFlagsChanged
    participant Ghostty as Ghostty Input

    User->>NSView: flagsChanged(with: NSEvent)
    NSView->>NSView: hasMarkedText()?
    alt IME marked text exists
        NSView-->>Ghostty: skip modifier synthesis
    else No marked text
        NSView->>ModAction: keyCode + modifierFlagsRawValue
        ModAction->>ModAction: map keyCode to modifier type\ndetermine PRESS or RELEASE (or nil)
        ModAction-->>NSView: ghostty_input_action_e?
        alt action != nil
            NSView->>Ghostty: send key event (PRESS/RELEASE, codepoint=0)
        else action == nil
            NSView-->>Ghostty: no modifier event sent
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I hopped on keys both left and right,

I sniffed the flags in soft moonlight.
If IME hums, I sit and rest—
else press or lift, I do my best.
A carrot clap for tidy input zest! 🥕🐇

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% 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 PR description covers the main changes and testing approach but is missing a demo video and several checklist items required by the template. Add demo video link (if UI/behavior affected), mark all checklist items as completed, and confirm bot review requests were made after latest commit.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately and concisely summarizes the main change: fixing macOS modifier key handling that was causing terminal input corruption when idle.

✏️ 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-2853-terminal-input-corruption-idle

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 Apr 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes terminal input corruption after idle (issue #2853) by extracting cmuxGhosttyModifierActionForFlagsChanged to correctly determine press vs. release edges from flagsChanged events, and adds unit tests covering the new function. The right-side modifier detection uses device-specific bit masks (NX_DEVICERSHIFTKEYMASK, etc.) to distinguish a right-key release while the left counterpart is still held from a genuine press — preventing phantom held modifiers in Ghostty.

Confidence Score: 4/5

Safe to merge for the primary fix; a symmetric left-side modifier gap remains that could cause the same phantom-modifier issue in rare dual-modifier scenarios.

Both findings are P2: the left-side device-mask gap requires simultaneously holding both left and right of the same modifier then releasing the left one — an unusual input pattern — and the missing test is a companion to that gap. The core fix (right-side detection, correct PRESS/RELEASE edges) is correct and well-tested. Scoring 4 rather than 5 because the left-side logic issue is the exact same class of phantom-modifier bug the PR is intended to close, and it is straightforward to fix symmetrically.

Sources/GhosttyTerminalView.swift — the sidePressed switch (lines 79–91) needs left-side NX_DEVICE* mask cases for keys 0x38, 0x3B, 0x3A, and 0x37.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds cmuxGhosttyModifierActionForFlagsChanged (lines 53–94) to correctly emit PRESS/RELEASE for modifier-only events; right-side keys use device-specific NX_* masks, but left-side keys fall through to sidePressed = true which can incorrectly return PRESS when the left key is released while its right counterpart is still held.
cmuxTests/TerminalAndGhosttyTests.swift Adds GhosttyModifierFlagsChangedActionTests with 6 focused unit tests; covers right-shift device-mask distinction and caps lock, but omits the symmetric left-key-released-while-right-held scenario.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[flagsChanged event arrives\nkeyCode + modifierFlagsRawValue] --> B{keyCode is a\nknown modifier?}
    B -- No --> C[return nil\nskip Ghostty event]
    B -- Yes --> D{Device-independent\nmodifier flag active?}
    D -- No --> E[return GHOSTTY_ACTION_RELEASE]
    D -- Yes --> F{keyCode is a\nright-side key?\n0x3C 0x3E 0x3D 0x36}
    F -- No\nleft-side / caps lock --> G[sidePressed = true\ndefault branch]
    F -- Yes --> H{Device-specific\nNX_DEVICER*KEYMASK set?}
    H -- Yes --> I[sidePressed = true]
    H -- No --> J[sidePressed = false]
    G --> K{sidePressed?}
    I --> K
    J --> K
    K -- Yes --> L[return GHOSTTY_ACTION_PRESS]
    K -- No --> M[return GHOSTTY_ACTION_RELEASE]
    style G fill:#ffdddd,stroke:#ff8888
    style L fill:#ddffdd,stroke:#88cc88
    style M fill:#ddeeff,stroke:#88aaff
Loading

Reviews (1): Last reviewed commit: "Fix modifier release handling after idle" | Re-trigger Greptile

Comment thread Sources/GhosttyTerminalView.swift
Comment thread cmuxTests/TerminalAndGhosttyTests.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: 1

🧹 Nitpick comments (1)
cmuxTests/TerminalAndGhosttyTests.swift (1)

3901-3977: Consider adding right Option / right Control cases to round out side-specific coverage.

Given this helper is now the single decision point, adding explicit assertions for NX_DEVICERALTKEYMASK and NX_DEVICERCTLKEYMASK would make the suite more future-proof.

Based on learnings Repo: manaflow-ai/cmux — In cmuxTests/CJKIMEInputTests.swift, deterministic right-option behavior is validated via the direct NX_DEVICERALTKEYMASK path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/TerminalAndGhosttyTests.swift` around lines 3901 - 3977, Add two
additional test methods in GhosttyModifierFlagsChangedActionTests to cover
right-Option and right-Control side-specific behavior: create tests mirroring
testRightShiftRequiresRightSideDeviceMaskForPress and
testRightShiftWithoutRightSideDeviceMaskReturnsRelease but use keyCode values
for Option (right Option keycode) and Control (right Control keycode) and assert
that cmuxGhosttyModifierActionForFlagsChanged returns GHOSTTY_ACTION_PRESS when
modifierFlagsRawValue includes NSEvent.ModifierFlags.option.rawValue (or
.control.rawValue) combined with UInt(NX_DEVICERALTKEYMASK) /
UInt(NX_DEVICERCTLKEYMASK) respectively, and GHOSTTY_ACTION_RELEASE when the
device mask is omitted; use the same helper function name
cmuxGhosttyModifierActionForFlagsChanged to locate where behavior is decided.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 56-94: The function cmuxGhosttyModifierActionForFlagsChanged
currently treats left-side modifier keyCodes (0x38,0x3B,0x3A,0x37) as
sidePressed = true in the default branch, causing left-side releases to be
mis-detected when the opposite side remains held; update the second switch in
cmuxGhosttyModifierActionForFlagsChanged to explicitly check the left-side
device masks (use NX_DEVICELSHIFTKEYMASK for 0x38, NX_DEVICELCTLKEYMASK for
0x3B, NX_DEVICELALTKEYMASK for 0x3A, NX_DEVICELCMDKEYMASK for 0x37) the same way
right-side keys check NX_DEVICER*KEYMASK values, and add a regression test that
verifies releasing a left modifier while the right modifier remains held (and
vice versa) yields GHOSTTY_ACTION_RELEASE for the release event and
GHOSTTY_ACTION_PRESS for the held side.

---

Nitpick comments:
In `@cmuxTests/TerminalAndGhosttyTests.swift`:
- Around line 3901-3977: Add two additional test methods in
GhosttyModifierFlagsChangedActionTests to cover right-Option and right-Control
side-specific behavior: create tests mirroring
testRightShiftRequiresRightSideDeviceMaskForPress and
testRightShiftWithoutRightSideDeviceMaskReturnsRelease but use keyCode values
for Option (right Option keycode) and Control (right Control keycode) and assert
that cmuxGhosttyModifierActionForFlagsChanged returns GHOSTTY_ACTION_PRESS when
modifierFlagsRawValue includes NSEvent.ModifierFlags.option.rawValue (or
.control.rawValue) combined with UInt(NX_DEVICERALTKEYMASK) /
UInt(NX_DEVICERCTLKEYMASK) respectively, and GHOSTTY_ACTION_RELEASE when the
device mask is omitted; use the same helper function name
cmuxGhosttyModifierActionForFlagsChanged to locate where behavior is decided.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9513c489-d1c9-4af0-aefa-723a3997f103

📥 Commits

Reviewing files that changed from the base of the PR and between 165cf6e and fda8daa.

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

Comment thread Sources/GhosttyTerminalView.swift

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is kicking off a free cloud agent to fix these issues. This run is complimentary, but you can enable autofix for all future PRs in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit fda8daa. Configure here.

Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/GhosttyTerminalView.swift Outdated

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

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/GhosttyTerminalView.swift">

<violation number="1" location="Sources/GhosttyTerminalView.swift:90">
P1: Left-side modifier releases are misclassified as presses when the opposite-side modifier is still held.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/GhosttyTerminalView.swift
@austinywang austinywang changed the title Fix terminal input corruption after idle Fix macOS modifier desync causing idle terminal input corruption Apr 13, 2026
@austinywang

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback on this branch:

  • added explicit left-side device-mask handling so releasing left Shift/Control/Option/Command while the right-side peer remains held now emits a release instead of a phantom press
  • expanded modifier-edge tests to cover the left/right crossover case plus right Control and right Option
  • kept flagsChanged on a modifier-only event path so it no longer probes AppKit character APIs for non-text events

Latest branch tip: 151075f7.

@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)
cmuxTests/TerminalAndGhosttyTests.swift (1)

4002-4010: Add a right-Command release parity case.

Line [4002] verifies right-Command press, but there’s no symmetric release case when left Command is still held (aggregate .command remains set). Adding that protects against side-desync regressions on Command, similar to Shift coverage.

Proposed test addition
     func testRightCommandRequiresRightSideDeviceMaskForPress() {
         XCTAssertEqual(
             cmuxGhosttyModifierActionForFlagsChanged(
                 keyCode: 0x36,
                 modifierFlagsRawValue: NSEvent.ModifierFlags.command.rawValue | UInt(NX_DEVICERCMDKEYMASK)
             ),
             GHOSTTY_ACTION_PRESS
         )
     }
+
+    func testRightCommandWithoutRightSideDeviceMaskReturnsReleaseWhenLeftCommandHeld() {
+        XCTAssertEqual(
+            cmuxGhosttyModifierActionForFlagsChanged(
+                keyCode: 0x36,
+                modifierFlagsRawValue: NSEvent.ModifierFlags.command.rawValue | UInt(NX_DEVICELCMDKEYMASK)
+            ),
+            GHOSTTY_ACTION_RELEASE
+        )
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/TerminalAndGhosttyTests.swift` around lines 4002 - 4010, Add a
symmetric release test for right-Command: create a new test (e.g.,
testRightCommandRequiresRightSideDeviceMaskForRelease) that calls
cmuxGhosttyModifierActionForFlagsChanged(keyCode: 0x36, modifierFlagsRawValue:
NSEvent.ModifierFlags.command.rawValue) — i.e., aggregate .command remains set
but the NX_DEVICERCMDKEYMASK is not present — and assert the result equals
GHOSTTY_ACTION_RELEASE to mirror the existing press case
(cmuxGhosttyModifierActionForFlagsChanged and GHOSTTY_ACTION_PRESS) and guard
against side-desync regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmuxTests/TerminalAndGhosttyTests.swift`:
- Around line 4002-4010: Add a symmetric release test for right-Command: create
a new test (e.g., testRightCommandRequiresRightSideDeviceMaskForRelease) that
calls cmuxGhosttyModifierActionForFlagsChanged(keyCode: 0x36,
modifierFlagsRawValue: NSEvent.ModifierFlags.command.rawValue) — i.e., aggregate
.command remains set but the NX_DEVICERCMDKEYMASK is not present — and assert
the result equals GHOSTTY_ACTION_RELEASE to mirror the existing press case
(cmuxGhosttyModifierActionForFlagsChanged and GHOSTTY_ACTION_PRESS) and guard
against side-desync regressions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c8b8b928-c487-4a31-9e1a-f4b44b22a214

📥 Commits

Reviewing files that changed from the base of the PR and between fda8daa and ab46c55.

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

@austinywang
austinywang merged commit ec6aa7c into main Apr 13, 2026
18 checks passed
shaun0927 pushed a commit to shaun0927/cmux that referenced this pull request Apr 17, 2026
… composition

Closes manaflow-ai#2949

## Root Cause

manaflow-ai#2855 added the modifier press/release dispatch but wrapped it in
'if !hasMarkedText()'. macOS 'flagsChanged' is the only event that
fires for modifier transitions ('keyUp' does not fire for modifier-
only events), so when the user releases Shift / Control / Option /
Command during an IME composition the release never reaches Ghostty.
After 'unmarkText' commits the composition, Ghostty still believes
the suppressed modifier is in its previous state and the next
keystroke is mis-routed - the same class of corruption manaflow-ai#2855 was
meant to fix.

## Fix

Flip the seam introduced in the previous commit so the modifier
action is always forwarded; the IME composition state rides along
on 'CmuxFlagsChangedDecision.composing' (mapped onto
'ghostty_input_key_s.composing') so Ghostty can suppress side
effects on its side without cmux dropping the edge.

The text-bearing path elsewhere in the file already short-circuits
on 'hasMarkedText', so removing the guard here does not regress
text input.

## Tests

The matching XCTest cases land in the previous commit (test-first)
so CI shows the regression coverage red->green progression.
rodchristiansen pushed a commit to rodchristiansen/cmux that referenced this pull request Sep 2, 2026
…nal-input-corruption-idle

Fix macOS modifier desync causing idle terminal input corruption

This branch was successfully deployed

1 active deployment
Preview — 151075f7 Deployed Apr 13, 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.

Terminal input corrupts to raw escape sequences after idle

1 participant