Skip to content

Fix shortcut recorder capture for Cmd-D remaps - #3398

Merged
austinywang merged 4 commits into
mainfrom
issue-3397-shortcut-remap-cmd-d
May 2, 2026
Merged

austinywang merged 4 commits into
mainfrom
issue-3397-shortcut-remap-cmd-d

Conversation

@austinywang

@austinywang austinywang commented May 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #3397

Summary

  • Add a regression covering Cmd+D recording while a matching menu equivalent is installed.
  • Route active shortcut-recorder key events before app/window menu fallback.
  • Suppress menu key equivalents while shortcut recording is active.

Testing

  • Verified in a tagged Debug build (./scripts/reload.sh --tag issue-3397): Cmd+D is captured by the shortcut recorder while the matching menu equivalent is installed, and the menu action does not fire during recording.
  • git diff --check

Note

Medium Risk
Changes keyboard event routing and menu key-equivalent installation while shortcut recording is active, which can affect command handling across the app; scope is contained to shortcut recording/menu integration.

Overview
Fixes a regression where the shortcut recorder could fail to capture keystrokes (notably Cmd+D) when a matching menu key equivalent was installed.

This routes key events to an active ShortcutRecorderNSButton early in AppDelegate (local monitor, NSApplication.sendEvent, and window key-equivalent handling), and suppresses menu key equivalents during recording via a new KeyboardShortcutSettings.menuShortcut(for:) API.

Adds a DEBUG-only regression test ensuring Cmd+D is recorded and does not trigger the menu item action while recording.

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

Summary by CodeRabbit

  • Bug Fixes

    • Shortcuts no longer trigger menu actions while recording new shortcuts; recording keystrokes are consumed.
  • Improvements

    • Global event routing now prioritizes active shortcut recording to prevent accidental menu activations.
    • Terminal split commands updated to respect recording-aware shortcut handling.
  • Tests

    • Added a test verifying application-level events route to an active shortcut recorder and do not invoke menu equivalents.

The shortcut settings recorder needs to receive Cmd+D even when the same keystroke is currently installed as a menu key equivalent for split actions. This adds the AppKit event-boundary regression before changing routing code so CI can show the failure without the fix.

Constraint: Local tests are intentionally not run in this task per operator instruction

Confidence: high

Scope-risk: narrow

Tested: Not run locally

Not-tested: CI execution of the new regression
Shortcut recording must own the next key event even when that keystroke is currently registered as a SwiftUI or AppKit menu equivalent. The recorder now keeps a weak active-capture target, App-level and Window-level event routing dispatch capture candidates to it before menu fallback, and menu shortcut builders expose no key equivalents while recording is active.

Constraint: Cmd+D and Cmd+Shift+D are split menu defaults and may also exist as stale menu equivalents after user remapping

Rejected: Only suppress stale default shortcuts | current menu bindings also block recording and conflict feedback

Confidence: high

Scope-risk: moderate

Tested: git diff --check

Not-tested: Local XCTest/build run per instruction; CI should run the regression
@vercel

vercel Bot commented May 1, 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 1, 2026 10:32pm
cmux-staging Building Building Preview, Comment May 1, 2026 10:32pm

@coderabbitai

coderabbitai Bot commented May 1, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dacfd0b7-bcf3-4aee-944d-d40309967cbd

📥 Commits

Reviewing files that changed from the base of the PR and between 1e8c54e and 241a03b.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (3)
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/cmuxApp.swift
✅ Files skipped from review due to trivial changes (1)
  • Sources/GhosttyTerminalView.swift

📝 Walkthrough

Walkthrough

Keyboard shortcut handling was changed so active shortcut recorders intercept key events early; menu-displayed shortcuts are suppressed during recording via a new menuShortcut(for:) API and events are routed through ShortcutRecorderEventRouter to prevent menu activation while recording.

Changes

Cohort / File(s) Summary
Menu shortcut wiring
Sources/MenuBarExtraController.swift, Sources/GhosttyTerminalView.swift, Sources/cmuxApp.swift
Menu items and SwiftUI/AppKit menu helpers now use KeyboardShortcutSettings.menuShortcut(for:) instead of shortcut(for:), so displayed/applied menu shortcuts can be suppressed during active recording.
Recorder event routing
Sources/KeyboardShortcutRecorder.swift, Sources/KeyboardShortcutRecorder.swift (exported router)
Introduced a global dispatch path and ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(...) that tracks an active recorder and routes/consumes key events before normal handlers. Active recorder lifecycle (set/clear) added.
App-level event interception
Sources/AppDelegate.swift
Inserted early dispatch checks to ShortcutRecorderEventRouter in AppKit routing paths (local key monitor, NSApp.sendEvent, window key-equivalent) to consume events when recording.
Shortcut settings / activity
Sources/KeyboardShortcutSettings.swift, Sources/KeyboardShortcutSettingsFileStore.swift
Added menuShortcut(for:) which returns unbound while any recorder is active; file store now observes recorder activity notifications to refresh revisions.
Tests
cmuxTests/AppDelegateShortcutRoutingTests.swift
Added DEBUG-only test ensuring an active recorder owning first-responder receives Cmd+D via NSApp.sendEvent and the main-menu Cmd+D equivalent is not dispatched.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant App as NSApplication
    participant Router as ShortcutRecorderEventRouter
    participant Recorder as ShortcutRecorderNSButton
    participant Menu as Menu Handler

    User->>App: Press Cmd+D
    App->>Router: dispatchActiveRecordingEvent(event, preferredWindow)
    alt Router finds active recorder
        Router->>Recorder: handleRecordingEvent(event)
        Recorder->>Recorder: record/consume shortcut
        Recorder-->>Router: true (consumed)
        Router-->>App: true
        App->>App: stop propagation (do not route to menu)
    else No active recorder found
        Router-->>App: false
        App->>Menu: route event to menu equivalent
        Menu->>Menu: perform action
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I sat and watched the keys collide,

A router hopped and took their stride,
Now recorders catch each daring press,
No stray menu steals the mess,
Hooray — remaps breathe again, no stress!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing shortcut recorder capture for Cmd-D remaps, which directly addresses the core issue in the PR and linked issue #3397.
Description check ✅ Passed The description covers the main changes, testing methodology, and provides appropriate detail, though the demo video section is empty.
Linked Issues check ✅ Passed All coding requirements from issue #3397 are met: the PR enables shortcut recorder to capture Cmd+D/Cmd+Shift+D by routing events early in AppDelegate and suppressing menu key equivalents during recording.
Out of Scope Changes check ✅ Passed All changes are focused on fixing the shortcut recording regression; no out-of-scope modifications detected beyond the stated objectives.

✏️ 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-3397-shortcut-remap-cmd-d

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
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@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)
Sources/AppDelegate.swift (1)

10017-10022: ⚡ Quick win

Log recorder-intercepted key events before consuming them.

This early return skips the existing monitor/AppKit key logging, so recorder-owned events disappear from the unified debug event log. Please add a #if DEBUG cmuxDebugLog(...) here and mirror it in the matching early-return branches at Line 13518 and Line 13777.

As per coding guidelines "All debug events (keys, mouse, focus, splits, tabs) must be logged to the unified debug event log in DEBUG builds."

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

In `@Sources/AppDelegate.swift` around lines 10017 - 10022, The early return
inside the ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(...) branch
prevents debug logging of recorder-owned key events; add a DEBUG-only log call
before the return: wrap a cmuxDebugLog(...) call in `#if` DEBUG / `#endif` and log
the event (same format used by existing key-event logs) immediately before the
`return nil` in the dispatchActiveRecordingEvent branch, and apply identical
DEBUG-only cmuxDebugLog(...) additions to the two other early-return branches
that short-circuit recorder events (the matching branches referenced in the
review) so recorder-intercepted events still appear in the unified debug event
log.
🤖 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/AppDelegate.swift`:
- Around line 10017-10022: The DEBUG shortcut-monitor handler
debugHandleShortcutMonitorEvent(event:) bypasses the new pre-route and calls
handleCustomShortcut directly; update debugHandleShortcutMonitorEvent(event:) to
first call ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(event,
preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow) and return
early if it returns true, mirroring the production pre-route, before falling
back to handleCustomShortcut so socket/driven simulations exercise the
active-recorder path.

---

Nitpick comments:
In `@Sources/AppDelegate.swift`:
- Around line 10017-10022: The early return inside the
ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(...) branch prevents
debug logging of recorder-owned key events; add a DEBUG-only log call before the
return: wrap a cmuxDebugLog(...) call in `#if` DEBUG / `#endif` and log the event
(same format used by existing key-event logs) immediately before the `return
nil` in the dispatchActiveRecordingEvent branch, and apply identical DEBUG-only
cmuxDebugLog(...) additions to the two other early-return branches that
short-circuit recorder events (the matching branches referenced in the review)
so recorder-intercepted events still appear in the unified debug event log.
🪄 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: f712ca77-4719-4f49-9b16-1943d9b6fbc2

📥 Commits

Reviewing files that changed from the base of the PR and between 04aa56c and 1e8c54e.

📒 Files selected for processing (8)
  • Sources/App/MenuBarExtraController.swift
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/KeyboardShortcutRecorder.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • Sources/cmuxApp.swift
  • cmuxTests/AppDelegateShortcutRoutingTests.swift

Comment thread Sources/AppDelegate.swift
Comment on lines +10017 to +10022
if ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(
event,
preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
) {
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the DEBUG shortcut-monitor hook aligned with this new pre-route.

debugHandleShortcutMonitorEvent(event:) at Line 12101 still jumps straight to handleCustomShortcut, so DEBUG/socket-driven simulations no longer exercise the active-recorder path that production now takes.

🔧 Suggested follow-up
     func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool {
         if event.type == .systemDefined {
             return false
         }
+        if ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(
+            event,
+            preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
+        ) {
+            return true
+        }
         if event.type == .keyDown {
             return handleCustomShortcut(event: event)
         }
         handleBrowserOmnibarSelectionRepeatLifecycleEvent(event)
         return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(
event,
preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
) {
return nil
}
func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool {
if event.type == .systemDefined {
return false
}
if ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(
event,
preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
) {
return true
}
if event.type == .keyDown {
return handleCustomShortcut(event: event)
}
handleBrowserOmnibarSelectionRepeatLifecycleEvent(event)
return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 10017 - 10022, The DEBUG
shortcut-monitor handler debugHandleShortcutMonitorEvent(event:) bypasses the
new pre-route and calls handleCustomShortcut directly; update
debugHandleShortcutMonitorEvent(event:) to first call
ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(event, preferredWindow:
event.window ?? NSApp.keyWindow ?? NSApp.mainWindow) and return early if it
returns true, mirroring the production pre-route, before falling back to
handleCustomShortcut so socket/driven simulations exercise the active-recorder
path.

@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where pressing Cmd+D (or any key mapped to both a user-configured shortcut and a menu key equivalent) while a shortcut recorder is active would trigger the menu action instead of being captured by the recorder. The fix has two complementary layers: dispatchActiveRecordingEvent is injected early into the NSApplication.sendEvent, AppDelegate event monitor, and NSWindow.sendEvent override chains to intercept key events before the menu system sees them; and menuShortcut(for:) is introduced so that all menu construction call sites return .unbound while any recorder is active, backed by a new recorderCancellable in KeyboardShortcutSettingsObserver to trigger SwiftUI menu rebuilds on recorder state changes.

Confidence Score: 4/5

Safe to merge; only P2 style findings with no correctness impact on the core fix.

The event-routing logic is sound: the three-layer dispatchActiveRecordingEvent interception fires before super.sendEvent, preventing double-processing by consuming events before local monitors or menu key equivalents are evaluated. The menuShortcut suppression is a correct secondary defence. Only P2 findings remain: the test #else branch will hard-fail rather than skip in non-DEBUG builds, and consumeRecordingEvent silently discards the return value of handleRecordingEvent without documenting the invariant that it always returns nil.

Sources/KeyboardShortcutRecorder.swift (consumeRecordingEvent invariant) and cmuxTests/AppDelegateShortcutRoutingTests.swift (#else XCTFail)

Important Files Changed

Filename Overview
Sources/KeyboardShortcutRecorder.swift Adds activeRecorder static weak var, dispatchActiveRecordingEvent dispatch path, consumeRecordingEvent, and the ShortcutRecorderEventRouter wrapper enum for routing key events to the active recorder before the menu system sees them.
Sources/AppDelegate.swift Three call sites of ShortcutRecorderEventRouter.dispatchActiveRecordingEvent injected: one in the AppDelegate local event monitor, one in NSApplication.sendEvent override, and one in NSWindow.sendEvent override, providing belt-and-suspenders early interception.
Sources/KeyboardShortcutSettings.swift Adds menuShortcut(for:) that returns .unbound when any recorder is active, suppressing menu key equivalents during shortcut recording.
Sources/KeyboardShortcutSettingsFileStore.swift Renames cancellable to settingsCancellable and adds recorderCancellable so SwiftUI menus re-render (clearing key equivalents) when recorder activity starts or stops.
Sources/cmuxApp.swift Switches menuShortcut(for:) helper from KeyboardShortcutSettings.shortcut to KeyboardShortcutSettings.menuShortcut, ensuring SwiftUI menu commands drop key equivalents during recording.
Sources/GhosttyTerminalView.swift Switches two context-menu shortcut lookups from shortcut(for:) to menuShortcut(for:) to suppress split key equivalents during recording.
Sources/App/MenuBarExtraController.swift Switches two menu-bar extra shortcut lookups from shortcut(for:) to menuShortcut(for:) for consistency with the suppression approach.
cmuxTests/AppDelegateShortcutRoutingTests.swift Adds regression test for Cmd+D capture while a matching menu equivalent is installed; uses #if DEBUG guard with XCTFail in the #else branch.

Sequence Diagram

sequenceDiagram
    participant User
    participant NSApp as NSApplication.sendEvent (override)
    participant AppMon as AppDelegate event monitor
    participant WinSend as NSWindow.sendEvent (override)
    participant Menu as NSMenu (key equivalents)
    participant Recorder as ShortcutRecorderNSButton

    Note over Recorder: User clicks recorder → startRecording()<br/>activeRecorder = self<br/>isAnyRecorderActive = true<br/>menuShortcut → .unbound (menus rebuilt)

    User->>NSApp: keyDown Cmd+D
    NSApp->>NSApp: dispatchActiveRecordingEvent?
    alt recorder active
        NSApp->>Recorder: consumeRecordingEvent(event)
        Recorder->>Recorder: handleRecordingEvent → accept shortcut
        Recorder->>Recorder: stopRecording() → activeRecorder = nil, isAnyRecorderActive = false
        Recorder-->>NSApp: true (consumed)
        NSApp-->>User: return (super never called, menu never fires)
    else recorder not active
        NSApp->>NSApp: super.sendEvent
        NSApp->>AppMon: local monitor fires
        AppMon->>AppMon: dispatchActiveRecordingEvent → false
        AppMon-->>NSApp: pass event through
        NSApp->>WinSend: route to window
        WinSend->>WinSend: dispatchActiveRecordingEvent → false
        WinSend->>Menu: performKeyEquivalent
        Menu-->>User: menu action fires
    end
Loading

Reviews (1): Last reviewed commit: "Let shortcut recording preempt menu equi..." | Re-trigger Greptile

Comment on lines +4986 to +4988

guard let event = makeKeyDownEvent(
key: "d",

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 #else branch unconditionally fails the test

The #else arm calls XCTFail(...), which means running this test in a non-DEBUG configuration (e.g., a Release test scheme) will always report a hard failure instead of skipping. Using throw XCTSkip would be safer:

Suggested change
guard let event = makeKeyDownEvent(
key: "d",
#else
throw XCTSkip("Shortcut recorder debug hooks are only available in DEBUG")
#endif

Comment on lines +446 to +450
private func consumeRecordingEvent(_ event: NSEvent) -> Bool {
guard isRecording else { return false }
_ = handleRecordingEvent(event)
return true
}

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 consumeRecordingEvent silently discards handleRecordingEvent's return value

handleRecordingEvent currently always returns nil (it consumes every event unconditionally), so returning true here is correct. However the method contract is not enforced: if handleRecordingEvent ever returned a non-nil event in a future branch, consumeRecordingEvent would still return true, incorrectly telling callers the event was consumed when it wasn't. A short comment or assertion would make the invariant explicit:

Suggested change
private func consumeRecordingEvent(_ event: NSEvent) -> Bool {
guard isRecording else { return false }
_ = handleRecordingEvent(event)
return true
}
private func consumeRecordingEvent(_ event: NSEvent) -> Bool {
guard isRecording else { return false }
let unconsumed = handleRecordingEvent(event)
// handleRecordingEvent always returns nil (consumes every event).
assert(unconsumed == nil, "Expected handleRecordingEvent to consume the event")
return true
}

@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 8 files

You’re at about 99% of the daily review limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

austinywang and others added 2 commits May 1, 2026 15:09
Absorb growth from AppDelegate event routing changes and the
AppDelegateShortcutRoutingTests regression covering Cmd+D recording
while a matching menu equivalent is installed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolve Swift file length budget by regenerating from current actuals;
both branches grew separate files so the union must reflect both.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 241a03ba Deployed May 1, 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.

Cannot remap Cmd+D or Cmd+Shift+D after changing them once (latest nightly)

1 participant