Skip to content

Add per-app shortcut overrides#764

Open
t-h-tech wants to merge 2 commits into
FuJacob:mainfrom
t-h-tech:feat/per-app-shortcuts
Open

Add per-app shortcut overrides#764
t-h-tech wants to merge 2 commits into
FuJacob:mainfrom
t-h-tech:feat/per-app-shortcuts

Conversation

@t-h-tech

@t-h-tech t-h-tech commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Per-app shortcut overrides: let users set a different accept / full-accept shortcut per application — or disable Cotabby's accept key for specific apps. Bindings resolve at keystroke time against the frontmost app and fall back to the global binding when no override matches.

Validation

xcodebuild test -project Cotabby.xcodeproj -scheme Cotabby \
  -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
# ** TEST SUCCEEDED **   1652 tests, 0 failures
#   incl. ShortcutResolverTests, PerAppShortcutOverrideStoreTests, and the new
#   per-app cases in ShortcutConflictTests

swiftlint lint --config .swiftlint.yml --quiet
# exit 0

xcodegen generate && git diff --exit-code -- Cotabby.xcodeproj
# clean — committed project matches project.yml (5 new files wired in)

Linked issues

None.

Risk / rollout notes

  • Additive / opt-in. No behavior change unless a user adds an override. Storage is a new cotabbyPerAppShortcutOverrides UserDefaults key (JSON array), modeled exactly on the existing disabledAppRules pattern; the key is wired into load, the unconditional write-back, resetToDefaults, and allPreferenceDefaultsKeys.
  • InputMonitor is unchanged — resolution rides the existing accept-key provider closures, which are rewired in CotabbyAppEnvironment to resolve through ShortcutResolver against the live frontmost bundle id. Those four assignments were moved to just after focusModel is constructed (they capture it weakly).
  • Cotabby.xcodeproj/project.pbxproj was regenerated with xcodegen generate to wire in the 5 new files — no signing/team or other changes.
  • Includes the 2-line init build fix from Fix build: initialize fadeIn properties in SuggestionSettingsModel.init #763 (fadeInSuggestions / fadeInDurationSeconds) so this branch compiles against the currently-broken main. Once Fix build: initialize fadeIn properties in SuggestionSettingsModel.init #763 lands I'll rebase and those two lines drop out of this diff.

Greptile Summary

This PR adds per-app shortcut overrides for Cotabby's accept and full-accept keys: users can assign a different key per application or disable the accept key in specific apps, with a clean fallback to the global binding when no override is present. The architecture is well-layered — a new pure ShortcutResolver function owns all precedence logic, the input monitor's four independent key/modifier providers are replaced by two atomic binding-tuple providers, and a new cotabbyPerAppShortcutOverrides UserDefaults key mirrors the existing disabledAppRules persistence pattern.

  • ShortcutResolver is a side-effect-free static function, fully unit-testable without an event tap. The nil-means-inherit design for each binding's three fields (keyCode, modifiers, label) is enforced atomically at both the model layer (bindingsNormalized) and the sanitizer on load.
  • InputMonitor now receives binding tuples instead of separate key/modifier closures, eliminating the previous consistency window where keyCode and modifiers could resolve from two different overrides.
  • conflictingPerAppShortcutName contains one gap: it checks the stored per-app override fields for the other action, not the resolved (override-or-global) binding — a per-app accept key equal to the global full-accept key passes the check but is silently unreachable at event time.

Confidence Score: 4/5

Safe to merge with the conflict-checker fix applied — the rest of the feature is additive, well-tested, and opt-in.

The conflict checker in conflictingPerAppShortcutName validates new per-app keys only against stored per-app override fields, not the effective resolved binding. A per-app accept key that equals the global full-accept key (with no per-app full-accept override on the same row) passes validation unwarned, but is silently unreachable at event time because fullAcceptanceBindingProvider resolves to the global key and full-accept takes dispatch priority. All other new code — resolver, model mutations, store round-trip, input monitor refactor, and UI — is solid.

Cotabby/Models/SuggestionSettingsModel.swift — specifically conflictingPerAppShortcutName.

Important Files Changed

Filename Overview
Cotabby/Support/ShortcutResolver.swift New pure-function resolver; precedence logic, nil-bundle fallback, and disabled-sentinel pass-through are all correct and well-tested.
Cotabby/Models/PerAppShortcutOverride.swift New model; three-field atomicity invariant enforced via bindingsNormalized, isEmpty gates correctly on keyCode-only presence, and Codable conformance is clean.
Cotabby/Models/SuggestionSettingsModel.swift Mutation API and upsert/clear/remove logic are solid, but conflictingPerAppShortcutName only checks stored per-app override fields — not the effective resolved binding — causing silent unreachable overrides when the new key matches the other action's global fallback.
Cotabby/App/Core/CotabbyAppEnvironment.swift Correctly migrates from four individual providers to two tuple-returning binding providers; captures focusModel weakly so key code and modifiers always come from a single resolution pass.
Cotabby/Support/SuggestionSettingsStore.swift Load/sanitize/save round-trip mirrors the existing disabledAppRules pattern faithfully; bindingsNormalized is applied before the empty check, closing the phantom-row path flagged in earlier review.
Cotabby/Services/Input/InputMonitor.swift Four separate key/modifier providers consolidated into two binding-tuple providers; default values and event-classification logic unchanged.
Cotabby/UI/Settings/Panes/AppsPaneView.swift Per-app override UI is well-structured; single recordingTarget state correctly prevents multiple simultaneous recorders. The "Clear" button on per-app rows means "reset to global" while on global shortcuts it means "disable" — the help text clarifies, but the semantics differ.
Cotabby/UI/Settings/Components/KeybindRow.swift Clean extraction from ShortcutsPaneView; adds clearHelp parameter so callers can differentiate "disable" vs "reset to global" semantics in the tooltip.
CotabbyTests/ShortcutResolverTests.swift Good coverage of resolver precedence including nil bundle id, disabled sentinel, and per-action independence. No issues.
CotabbyTests/PerAppShortcutOverrideStoreTests.swift Round-trip, deduplication, normalization, empty-row sanitization, and partial-binding collapse all covered. Missing a test for the conflict-checker's global-inheritance gap.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User as User Keystroke
    participant IM as InputMonitor
    participant ABP as acceptanceBindingProvider
    participant FABP as fullAcceptanceBindingProvider
    participant SR as ShortcutResolver
    participant FM as FocusTrackingModel
    participant SSM as SuggestionSettingsModel

    User->>IM: keyDown event
    IM->>FABP: fullAcceptanceBindingProvider()
    FABP->>FM: snapshot.bundleIdentifier
    FM-->>FABP: "com.apple.notes"
    FABP->>SR: fullAcceptBinding(bundleId, overrides, globalKey...)
    SR->>SSM: perAppShortcutOverrides lookup
    SSM-->>SR: override row (or nil → fall back to global)
    SR-->>FABP: ResolvedBinding(keyCode, modifiers, label)
    FABP-->>IM: (keyCode, modifiers)
    IM->>ABP: acceptanceBindingProvider()
    ABP->>SR: acceptBinding(bundleId, overrides, globalKey...)
    SR-->>ABP: ResolvedBinding(keyCode, modifiers, label)
    ABP-->>IM: (keyCode, modifiers)
    IM->>IM: full-accept check (priority)
    alt keyCode matches fullAccept
        IM-->>User: .fullAcceptance
    else keyCode matches accept
        IM-->>User: .acceptance
    else
        IM-->>User: pass-through
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User as User Keystroke
    participant IM as InputMonitor
    participant ABP as acceptanceBindingProvider
    participant FABP as fullAcceptanceBindingProvider
    participant SR as ShortcutResolver
    participant FM as FocusTrackingModel
    participant SSM as SuggestionSettingsModel

    User->>IM: keyDown event
    IM->>FABP: fullAcceptanceBindingProvider()
    FABP->>FM: snapshot.bundleIdentifier
    FM-->>FABP: "com.apple.notes"
    FABP->>SR: fullAcceptBinding(bundleId, overrides, globalKey...)
    SR->>SSM: perAppShortcutOverrides lookup
    SSM-->>SR: override row (or nil → fall back to global)
    SR-->>FABP: ResolvedBinding(keyCode, modifiers, label)
    FABP-->>IM: (keyCode, modifiers)
    IM->>ABP: acceptanceBindingProvider()
    ABP->>SR: acceptBinding(bundleId, overrides, globalKey...)
    SR-->>ABP: ResolvedBinding(keyCode, modifiers, label)
    ABP-->>IM: (keyCode, modifiers)
    IM->>IM: full-accept check (priority)
    alt keyCode matches fullAccept
        IM-->>User: .fullAcceptance
    else keyCode matches accept
        IM-->>User: .acceptance
    else
        IM-->>User: pass-through
    end
Loading

Comments Outside Diff (1)

  1. Cotabby/Models/SuggestionSettingsModel.swift, line 783-801 (link)

    P1 Ghost-text hint shows wrong key when a per-app override is active

    acceptanceHintLabel and emojiPickerAcceptKeyLabel both read the global acceptanceKeyCode / acceptanceKeyLabel, so a user who sets a different accept key for a specific app (or who uses the disabled-sentinel to suppress the accept key in that app) will still see the global key label in the ghost-text overlay. The worst case is the "disabled" override: the hint keeps showing e.g. "Tab" while Tab no longer does anything in that app, making Cotabby look broken.

    Both properties need to resolve through ShortcutResolver against the current frontmost bundle id, the same way the InputMonitor providers do.

    Fix in Codex Fix in Claude Code

Reviews (2): Last reviewed commit: "Address review: atomic per-app bindings ..." | Re-trigger Greptile

Let users override Cotabby's accept / full-accept shortcuts on a per-application
basis (or disable them for an app). Bindings are resolved at keystroke time
against the frontmost app, falling back to the global binding when no override
matches.

- New PerAppShortcutOverride model + ShortcutResolver (per-app -> global).
- Storage re-homed into the SuggestionSettingsData / SuggestionSettingsStore /
  facade pattern, mirroring disabledAppRules: a new "cotabbyPerAppShortcutOverrides"
  UserDefaults key, store-routed accessors, sanitize/sort, and conflict detection.
- Settings UI: a "Per-App Shortcuts" section in the Apps pane (app picker +
  KeybindRow); the shared KeybindRow is promoted out of ShortcutsPaneView into a
  reusable component.
- Resolver wired through CotabbyAppEnvironment's accept-key providers (moved below
  focusModel construction so they read the live frontmost snapshot); InputMonitor
  unchanged.

Adds ShortcutResolverTests, PerAppShortcutOverrideStoreTests, and per-app
ShortcutConflictTests.
Comment thread Cotabby/UI/Settings/Panes/AppsPaneView.swift
Comment thread Cotabby/App/Core/CotabbyAppEnvironment.swift Outdated
Comment thread Cotabby/Support/SuggestionSettingsStore.swift Outdated
- Sanitizer now collapses a partially-specified per-app binding (a key code
  without its modifiers/label) to "inherit global" on load, so a phantom row
  can't survive load yet silently never fire in ShortcutResolver. Adds
  PerAppShortcutOverride.bindingsNormalized + a store test.
- Replace the four separate accept/full-accept key+modifier provider closures
  with two combined providers returning (keyCode, modifiers), so each binding
  resolves once as a unit per keystroke — no redundant scans, and the key code
  and modifiers can't come from different resolutions. Updates InputMonitor,
  the CotabbyAppEnvironment wiring, and InputMonitorTests.
@t-h-tech

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Pushed 3c545c8 addressing two of these, and a note on the third:

#3 — sanitizer atomicity (fixed). Added PerAppShortcutOverride.bindingsNormalized, applied in sanitizedPerAppShortcutOverrides on load: any partially-specified binding (a key code without its modifiers/label) now collapses back to "inherit global" before the empty check, so a phantom row can't survive a load. Added test_sanitize_collapsesPartialBindingOnLoad. The setters always write all three fields together, so in practice this only hardens against a corrupted/hand-edited default — but you're right that the stored shape should match exactly what ShortcutResolver honors.

#2 — resolver called 4× / consistency window (fixed). Replaced the four separate acceptance*Provider / fullAcceptance*Provider closures with two combined providers — acceptanceBindingProvider / fullAcceptanceBindingProvider returning (keyCode, modifiers). acceptanceKind now resolves each binding once, as a unit (2 scans instead of 4, and the key code and modifiers can no longer come from different resolutions). Updated InputMonitor, the CotabbyAppEnvironment wiring, and the InputMonitorTests call sites.

#1 — seeding the accept key on "Add App…" (intentional, but happy to revisit). A per-app override row exists only to hold a concrete binding; an all-nil row is indistinguishable from "no override" and the sanitizer drops it on the next load. So "Add App…" seeds the current global accept key to give a concrete, editable starting point — without it, the freshly-added row would just vanish on reload. The downside you flagged is real: the row then stops inheriting future global accept-key changes, with no "inherits global" indicator. The clean fix would be to persist named-but-unbound rows and render an explicit "inherits global (click to set)" state — a slightly larger UX change I left out to keep this PR focused. Glad to add it here or as a follow-up if you'd prefer that behavior.

@FuJacob

FuJacob commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Thanks for the work @t-h-tech!! Will review this soon.

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.

2 participants