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 accept/full-accept shortcut overrides: users can assign a different key per application or disable acceptance entirely in a specific app. Bindings are resolved at keystroke time through a new
ShortcutResolveragainst the frontmost bundle id, falling back to the global binding when no override exists.ShortcutResolver(new pure function) andPerAppShortcutOverride(new model) introduce the data model and resolution logic; both are well-tested and the nil-means-inherit design cleanly separates "no override" from "disabled".SuggestionSettingsStoregains load/save/sanitize for overrides following the existingdisabledAppRulesdiscipline;bindingsNormalizedis correctly called before theisEmptyguard, closing the phantom-row window.InputMonitorproviders are consolidated from four closures into two paired-binding closures, eliminating the prior per-keystroke split-read race and halving the number of resolver scans per event.Confidence Score: 4/5
Safe to merge for the common case; one conflict-detection gap lets a per-app override silently shadow the effective global binding for the other action, causing the wrong acceptance event to fire.
The resolver, model, store, and InputMonitor wiring are solid. The gap is in conflictingPerAppShortcutName: it only inspects explicitly stored per-app override fields for the other action, never falling back to the global binding when no per-app override is present. A user who records a per-app accept key equal to the global full-accept key (or vice versa) gets no warning, and at event time the wrong action fires because full-accept always takes priority.
Cotabby/Models/SuggestionSettingsModel.swift — the conflictingPerAppShortcutName function and its corresponding test in CotabbyTests/ShortcutConflictTests.swift need a case covering global-fallback collision.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant User as User Keystroke participant IM as InputMonitor participant Env as CotabbyAppEnvironment participant SR as ShortcutResolver participant FM as FocusTrackingModel participant SSM as SuggestionSettingsModel User->>IM: key event (keyCode, flags) IM->>Env: acceptanceBindingProvider() Env->>FM: snapshot.bundleIdentifier FM-->>Env: com.example.app Env->>SSM: perAppShortcutOverrides SSM-->>Env: [PerAppShortcutOverride] Env->>SR: "acceptBinding(bundleId, overrides, globalKey*)" SR-->>Env: ResolvedBinding(keyCode, modifiers, label) Env-->>IM: (keyCode, modifiers) IM->>Env: fullAcceptanceBindingProvider() Env->>SR: "fullAcceptBinding(bundleId, overrides, globalKey*)" SR-->>Env: ResolvedBinding(keyCode, modifiers, label) Env-->>IM: (keyCode, modifiers) IM->>IM: compare event vs resolved bindings IM-->>User: .acceptance / .fullAcceptance / nil%%{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 Env as CotabbyAppEnvironment participant SR as ShortcutResolver participant FM as FocusTrackingModel participant SSM as SuggestionSettingsModel User->>IM: key event (keyCode, flags) IM->>Env: acceptanceBindingProvider() Env->>FM: snapshot.bundleIdentifier FM-->>Env: com.example.app Env->>SSM: perAppShortcutOverrides SSM-->>Env: [PerAppShortcutOverride] Env->>SR: "acceptBinding(bundleId, overrides, globalKey*)" SR-->>Env: ResolvedBinding(keyCode, modifiers, label) Env-->>IM: (keyCode, modifiers) IM->>Env: fullAcceptanceBindingProvider() Env->>SR: "fullAcceptBinding(bundleId, overrides, globalKey*)" SR-->>Env: ResolvedBinding(keyCode, modifiers, label) Env-->>IM: (keyCode, modifiers) IM->>IM: compare event vs resolved bindings IM-->>User: .acceptance / .fullAcceptance / nilComments 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 (3): Last reviewed commit: "Merge origin/main into feat/per-app-shor..." | Re-trigger Greptile