Skip to content

Preserve context-separated shortcuts through recorder swaps - #3489

Merged
austinywang merged 2 commits into
mainfrom
issue-3486-shortcut-context-conflict
May 5, 2026
Merged

austinywang merged 2 commits into
mainfrom
issue-3486-shortcut-context-conflict

Conversation

@austinywang

@austinywang austinywang commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Routes the recorder's secondary validation and swap-persistence path through the same Action.conflicts (context-aware) matcher used for primary conflict detection, so a stale or invalid conflictsWithAction cannot unbind a shortcut whose context does not actually overlap.
  • Fixes issue Shortcut recorder incorrectly blocks re-assigning Cmd+R to context-specific action after unbinding #3486: Cmd+R must remain assignable to renameTab while browserReload keeps Cmd+R, because their shortcut contexts do not overlap.
  • Adds KeyboardShortcutContextTests coverage for the swap/persistence path so a stale conflict can no longer silently unbind a context-isolated action.

Test plan

  • CI: cmux unit tests (KeyboardShortcutContextTests) pass
  • Manual: in Settings → Shortcuts, set renameTab to Cmd+R and confirm browserReload is not unbound
  • Manual: confirm Cmd+R still reloads the browser when a browser pane is focused
  • Manual: confirm Cmd+R renames a tab when a terminal/non-browser pane is focused

🤖 Generated with Claude Code


Note

Medium Risk
Touches shortcut conflict detection and swap persistence; behavior changes could affect how existing shortcuts are accepted/rejected, but scope is limited and covered by new unit tests.

Overview
Fixes a bug where the shortcut recorder’s swap/secondary validation path could treat a stale conflictsWithAction as real and incorrectly unbind/reassign shortcuts even when their contexts don’t overlap.

swapShortcutConflict and the recorder validation presentation now re-check conflicts via Action.conflicts (context-aware), and tests add coverage for reassigning Cmd+R between renameTab and browserReload plus ensuring swap logic ignores non-overlapping contexts.

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


Summary by cubic

Preserves context-separated shortcuts during recorder validation, presentation, and swaps. Fixes #3486 by allowing Cmd+R to be assigned to renameTab without unbinding browserReload when their contexts don’t overlap.

  • Bug Fixes

    • Validation, UI presentation, and swap persistence now use the same context-aware Action.conflicts check to ignore non-overlapping contexts. Added tests for Cmd+R reassignment and the swap path across browser vs non-browser panels.
  • Refactors

    • Moved ShortcutRecorderValidationPresentation to KeyboardShortcutSettingsControls.swift and gated presentation with Action.conflicts to avoid showing conflict UI for non-overlapping contexts and meet the Swift file-length budget.

Written for commit c4ee643. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • More accurate detection of true keyboard shortcut conflicts and safer conflict resolution to avoid incorrect reassignments.
    • Validation UI now only offers a “Swap” option when a real conflict exists and preserves expected shortcut states.
  • Tests

    • Added tests covering reassignment, swap behavior, and non-conflicting shortcut scenarios.

The shortcut recorder already resolves conflicts through Action.conflicts, but the secondary validation and swap path could act on a conflictsWithAction value without rechecking the current shortcut context. This keeps recorder presentation and swap persistence behind the same context-aware matcher so browser-panel and non-browser-panel actions cannot be treated as conflicts by stale or invalid attempts.

Constraint: Issue 3486 requires Cmd+R to remain assignable to renameTab while browserReload keeps Cmd+R because their contexts do not overlap
Rejected: Only add the unbind/reassign regression test | leaves the swap/persistence path able to unbind browserReload if handed a stale conflict
Confidence: high
Scope-risk: narrow
Directive: Shortcut conflict UI and persistence must use Action.conflicts before presenting or swapping conflicts; do not bypass shortcutContext.overlaps()
Tested: ./scripts/reload.sh --tag issue-3486-shortcut-context-conflict --launch
Not-tested: Local XCTest execution per repo testing policy
@vercel

vercel Bot commented May 4, 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 4, 2026 7:16am
cmux-staging Ready Ready Preview, Comment May 4, 2026 7:16am

@coderabbitai

coderabbitai Bot commented May 4, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds stricter conflict re-validation to shortcut swapping, gates validation UI presentation so conflict prompts only show when a real conflict exists, tightens swap-eligibility guards, and adds tests covering reassignment/unbinding and non-overlapping context behavior.

Changes

Shortcut conflict validation & presentation

Layer / File(s) Summary
Core Conflict Re-validation
Sources/KeyboardShortcutSettings.swift
swapShortcutConflict now re-checks that the conflictingAction actually conflicts with the proposed shortcut (using current configured shortcuts) and early-returns if it does not, avoiding computing/persisting replacements when irrelevant.
Validation Presentation Logic
Sources/KeyboardShortcutSettingsControls.swift
Introduces ShortcutRecorderValidationPresentation with shouldPresent that only builds a presentation for relevant rejection cases; for .conflictsWithAction it verifies the conflicting action's current shortcut actually conflicts with the proposed shortcut before presenting UI.
Swap Eligibility Guards
Sources/KeyboardShortcutSettingsControls.swift
canSwapConflict tightened to require .conflictsWithAction and a valid proposedShortcut and then verifies mutual conflict-resolution using resolved-recorded shortcuts before allowing a swap.
Tests
cmuxTests/KeyboardShortcutContextTests.swift
Adds tests: one for reassigning renameTab to commandR after unbinding without creating a .browserReload conflict; one ensuring swapShortcutConflict ignores non-overlapping shortcut contexts and does not produce a validation rejection.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 A keystroke hopped, then paused with care,
"Does it clash?" I asked the settings there.
If not, I skip the needless fray,
Else swap with caution, hop away—
Smooth shortcuts now, with tidy flair. ✨

🚥 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: ensuring that context-separated shortcuts are preserved during recorder swap operations to fix issue #3486.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description covers the core issue and includes detailed summary, test plan checklist, and multiple AI-generated summaries, but is missing explicit manual testing verification and a demo video.

✏️ 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-3486-shortcut-context-conflict

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.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/KeyboardShortcutSettings.swift (1)

708-733: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t make a stale swap dialog a no-op.

This revalidation is correct for preventing an incorrect unbind, but it also means an already-open swap prompt can silently discard the user’s shortcut if the conflict disappears before the button is pressed. Because the recorder is stopped before this call, there’s no fallback path to apply proposedShortcut to currentAction.

Consider moving the current-action persistence ahead of the conflict recheck, or have the caller handle the “no longer conflicts” case explicitly. This keeps the stale prompt from turning into a lost input path.

Also applies to: 2245-2289, 2348-2362

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

In `@Sources/KeyboardShortcutSettings.swift` around lines 708 - 733, The current
guard that revalidates the conflict can turn a stale swap dialog into a no-op
and silently discard the user's proposedShortcut; to fix this either persist the
currentAction immediately or explicitly handle the "no longer conflicts" path:
call persistShortcut(resolvedCurrentShortcut, for: currentAction) (and
postDidChangeNotification(action: currentAction)) before performing
conflictingAction.conflicts(...), or add an explicit else branch after the
conflicts check that applies proposedShortcut to currentAction (using
storedShortcutForReplacement and persistShortcut) when the conflict no longer
exists; update the logic around
conflictingAction.conflicts(with:proposedShortcut, proposedAction:currentAction,
configuredShortcut:shortcut(for:conflictingAction)),
storedShortcutForReplacement(...), persistShortcut(...), and
postDidChangeNotification(...) accordingly so a closed/stale swap prompt cannot
drop the user input.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@Sources/KeyboardShortcutSettings.swift`:
- Around line 708-733: The current guard that revalidates the conflict can turn
a stale swap dialog into a no-op and silently discard the user's
proposedShortcut; to fix this either persist the currentAction immediately or
explicitly handle the "no longer conflicts" path: call
persistShortcut(resolvedCurrentShortcut, for: currentAction) (and
postDidChangeNotification(action: currentAction)) before performing
conflictingAction.conflicts(...), or add an explicit else branch after the
conflicts check that applies proposedShortcut to currentAction (using
storedShortcutForReplacement and persistShortcut) when the conflict no longer
exists; update the logic around
conflictingAction.conflicts(with:proposedShortcut, proposedAction:currentAction,
configuredShortcut:shortcut(for:conflictingAction)),
storedShortcutForReplacement(...), persistShortcut(...), and
postDidChangeNotification(...) accordingly so a closed/stale swap prompt cannot
drop the user input.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 005722d5-0f32-4963-b06f-788005959a94

📥 Commits

Reviewing files that changed from the base of the PR and between 1c5892e and 4fd069a.

📒 Files selected for processing (2)
  • Sources/KeyboardShortcutSettings.swift
  • cmuxTests/KeyboardShortcutContextTests.swift

@greptile-apps

greptile-apps Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes issue #3486 by threading all three shortcut recorder paths (swapShortcutConflict, ShortcutRecorderValidationPresentation init, and canSwapConflict) through the existing context-aware Action.conflicts matcher, so a stale .conflictsWithAction rejection from a non-overlapping context (e.g. browserReload vs renameTab) can no longer unbind or suppress a legitimately assignable shortcut. Two integration tests are added to cover the swap and validation-presentation paths.

Confidence Score: 4/5

Safe to merge; all findings are P2 — the core fix is logically sound and well-targeted.

The context-aware guard is correctly placed in all three affected sites and the new tests exercise the key regression scenario. Two P2 style observations: shouldPresent introduces a subtle behavior change (nil proposedShortcut now suppresses the dialog entirely rather than showing it with canSwap=false), and canSwapConflict contains a redundant conflicts check. No P0/P1 issues found.

Sources/KeyboardShortcutSettings.swift — specifically the shouldPresent nil-proposedShortcut branch and the duplicated guard in canSwapConflict.

Important Files Changed

Filename Overview
Sources/KeyboardShortcutSettings.swift Adds context-aware guard to swapShortcutConflict and introduces shouldPresent to filter out false conflict UI — logic is correct, but shouldPresent silently drops the conflict presentation when proposedShortcut is nil (new behavior vs. pre-fix show-with-canSwap=false), and canSwapConflict now contains a redundant conflicts check.
cmuxTests/KeyboardShortcutContextTests.swift Adds two new integration tests covering the swap path and the validation presentation; testSwapPathIgnoresNonOverlappingShortcutContexts uses { $0.defaultShortcut } as shortcutForAction instead of the live lookup, making it slightly less representative of the production code path.

Sequence Diagram

sequenceDiagram
    participant UI as Recorder UI
    participant VP as ShortcutRecorderValidationPresentation
    participant KSS as KeyboardShortcutSettings
    participant AC as Action.conflicts()

    UI->>VP: init(attempt, action, currentShortcut, shortcutForAction)
    VP->>AC: shouldPresent — conflictingAction.conflicts(proposedShortcut, proposedAction, configuredShortcut)
    alt contexts do NOT overlap (new guard)
        AC-->>VP: false → return nil
        VP-->>UI: nil (no conflict UI shown)
    else contexts overlap
        AC-->>VP: true → continue
        VP->>AC: canSwapConflict — conflictingAction.conflicts(...) [defensive re-check]
        AC-->>VP: true → evaluate resolvedRecordedShortcutIgnoringConflicts
        VP-->>UI: ShortcutRecorderValidationPresentation(canSwap: true/false)
        UI->>KSS: swapShortcutConflict(proposedShortcut, currentAction, conflictingAction, previousShortcut)
        KSS->>AC: conflictingAction.conflicts(proposedShortcut, proposedAction, shortcut(for: conflictingAction))
        alt contexts do NOT overlap (new guard)
            AC-->>KSS: false → early return (no unbind)
        else contexts overlap
            AC-->>KSS: true → persist both shortcuts
        end
    end
Loading

Reviews (1): Last reviewed commit: "Preserve context-separated shortcuts thr..." | Re-trigger Greptile

Comment thread Sources/KeyboardShortcutSettings.swift Outdated
Comment on lines +2280 to +2283
guard let proposedShortcut = attempt.proposedShortcut else {
return false
}

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 shouldPresent silently suppresses conflict UI when proposedShortcut is nil

When attempt.reason is .conflictsWithAction but attempt.proposedShortcut is nil, the new shouldPresent guard returns false, causing the init to return nil and hide the conflict UI entirely. Before this PR, the presentation would still be created (with canSwap = false) because canSwapConflict was the only nil-guard. If a .conflictsWithAction rejection is ever raised without a proposedShortcut — even for actions with overlapping contexts — the conflict warning would now be silently dropped instead of shown to the user. Consider returning true in the nil-proposedShortcut branch to preserve the pre-existing behavior for the overlapping-context case.

Comment thread Sources/KeyboardShortcutSettings.swift Outdated
Comment on lines +2348 to +2354
guard conflictingAction.conflicts(
with: proposedShortcut,
proposedAction: action,
configuredShortcut: shortcutForAction(conflictingAction)
) else {
return false
}

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 Redundant conflicts check already guaranteed by shouldPresent

canSwapConflict is only reachable after shouldPresent has already verified that conflictingAction.conflicts(...) returns true (otherwise the init returns nil). The duplicated guard here is harmless but adds noise. If canSwapConflict is ever intended to be called independently, a comment explaining the defensive duplication would help; otherwise, this guard can safely be removed.

),
action: .renameTab,
currentShortcut: .unbound,
shortcutForAction: { $0.defaultShortcut }

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 Test uses defaultShortcut closure instead of live shortcut(for:) lookup

ShortcutRecorderValidationPresentation is initialized with shortcutForAction: { $0.defaultShortcut }, but production callers pass KeyboardShortcutSettings.shortcut(for:). In this particular test the two are equivalent (all shortcuts are at defaults), but using the live lookup would make the test a more faithful regression guard. If a future change introduces a non-default initial state for some action, the test could silently pass while the real UI diverges.

Suggested change
shortcutForAction: { $0.defaultShortcut }
shortcutForAction: KeyboardShortcutSettings.shortcut(for:)

@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 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4fd069a. Configure here.

Comment thread Sources/KeyboardShortcutSettings.swift Outdated
configuredShortcut: shortcutForAction(conflictingAction)
) else {
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant conflict check in canSwapConflict

Low Severity

The conflictingAction.conflicts(with:proposedAction:configuredShortcut:) guard in canSwapConflict is unreachable because shouldPresent already checks the identical condition with the same shortcutForAction closure earlier in init, returning nil before canSwapConflict is ever called. Since canSwapConflict is private static with a single call site, this duplicated check can never fail and risks diverging if only one copy is updated in the future.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4fd069a. Configure here.

The shortcut recorder presentation helper belongs with the settings control that renders it, and moving it out of the large settings model keeps the new context-separation regression from tripping the CI file-length guard without widening the budget.

Constraint: workflow-guard-tests enforces .github/swift-file-length-budget.tsv for tracked Swift files

Rejected: Increase the file-length budget | the change can be represented as a mechanical relocation

Confidence: high

Scope-risk: narrow

Directive: Keep future recorder UI presentation helpers out of KeyboardShortcutSettings.swift unless they are model state

Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv

Tested: ./scripts/reload.sh --tag fix-pr-ci-shortcut-budget

Not-tested: Local XCTest suites per project testing policy
@austinywang
austinywang merged commit 83b6117 into main May 5, 2026
21 checks passed

This branch was successfully deployed

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

Shortcut recorder incorrectly blocks re-assigning Cmd+R to context-specific action after unbinding

1 participant