Add per-app shortcut overrides#764
Conversation
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.
- 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.
|
Thanks for the review! Pushed 3c545c8 addressing two of these, and a note on the third: #3 — sanitizer atomicity (fixed). Added #2 — resolver called 4× / consistency window (fixed). Replaced the four separate #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. |
|
Thanks for the work @t-h-tech!! Will review this soon. |
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
Linked issues
None.
Risk / rollout notes
cotabbyPerAppShortcutOverridesUserDefaults key (JSON array), modeled exactly on the existingdisabledAppRulespattern; the key is wired into load, the unconditional write-back,resetToDefaults, andallPreferenceDefaultsKeys.InputMonitoris unchanged — resolution rides the existing accept-key provider closures, which are rewired inCotabbyAppEnvironmentto resolve throughShortcutResolveragainst the live frontmost bundle id. Those four assignments were moved to just afterfocusModelis constructed (they capture it weakly).Cotabby.xcodeproj/project.pbxprojwas regenerated withxcodegen generateto wire in the 5 new files — no signing/team or other changes.initbuild fix from Fix build: initialize fadeIn properties in SuggestionSettingsModel.init #763 (fadeInSuggestions/fadeInDurationSeconds) so this branch compiles against the currently-brokenmain. 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
ShortcutResolverfunction owns all precedence logic, the input monitor's four independent key/modifier providers are replaced by two atomic binding-tuple providers, and a newcotabbyPerAppShortcutOverridesUserDefaults key mirrors the existingdisabledAppRulespersistence pattern.ShortcutResolveris a side-effect-free static function, fully unit-testable without an event tap. Thenil-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.InputMonitornow receives binding tuples instead of separate key/modifier closures, eliminating the previous consistency window where keyCode and modifiers could resolve from two different overrides.conflictingPerAppShortcutNamecontains 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
conflictingPerAppShortcutNamevalidates 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 becausefullAcceptanceBindingProviderresolves 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
bindingsNormalized,isEmptygates correctly on keyCode-only presence, andCodableconformance is clean.conflictingPerAppShortcutNameonly 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.focusModelweakly so key code and modifiers always come from a single resolution pass.disabledAppRulespattern faithfully;bindingsNormalizedis applied before the empty check, closing the phantom-row path flagged in earlier review.recordingTargetstate 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.clearHelpparameter so callers can differentiate "disable" vs "reset to global" semantics in the tooltip.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%%{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 endComments Outside Diff (1)
Cotabby/Models/SuggestionSettingsModel.swift, line 783-801 (link)acceptanceHintLabelandemojiPickerAcceptKeyLabelboth read the globalacceptanceKeyCode/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
ShortcutResolveragainst the current frontmost bundle id, the same way theInputMonitorproviders do.Reviews (2): Last reviewed commit: "Address review: atomic per-app bindings ..." | Re-trigger Greptile