feat: configurable keyboard shortcuts#2321
Conversation
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/code/src/renderer/stores/keybindingsStore.ts:118-121
`updateKeybinding` does not deduplicate the result, so editing one binding to a value that already exists as another binding for the same shortcut silently produces `["ctrl+q", "ctrl+q"]`. The conflict-detection in the recording modal excludes the shortcut being edited (the `excludeId` skip), so the duplicate slips through undetected. The UI then renders two chips with identical `key` props, producing a React duplicate-key warning and potentially broken reconciliation.
```suggestion
const updated = base.map((k) => (k === oldKey ? newKey : k));
// Deduplicate in case newKey already exists elsewhere in the array.
const deduped = [...new Set(updated)];
set({
customKeybindings: { ...get().customKeybindings, [id]: deduped },
});
```
### Issue 2 of 2
apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts:326-365
`prompt-history-prev/next` shortcuts silently break when remapped to non-arrow-key combos. The outer `event.key === "ArrowUp"` / `event.key === "ArrowDown"` guards are still hardcoded, so `forcePrev`/`forceNext` can be `true` from the store but the handler never fires unless the physical key is an arrow. A user who remaps `prompt-history-prev` to e.g. `ctrl+k` gets a chip in the UI that saves correctly but does nothing when pressed.
Reviews (1): Last reviewed commit: "feat: make prompt-history shortcuts conf..." | Re-trigger Greptile |
5a62f10 to
aa353a1
Compare
6f42a26 to
e5fedd9
Compare
e5fedd9 to
4bc6dd3
Compare
|
Hi @adboio! This has been sitting for a few weeks now - would appreciate a look when you get a chance (or anyone else available), many thanks!! |
cb1c023 to
af1eacd
Compare
jonathanlab
left a comment
There was a problem hiding this comment.
Thanks a lot for this PR! I love how you can search by keystrokes. I've encountered some bugs while testing:
- Pressing Control on a MacOS keyboard while capturing a shortcut will convert it to CMD instead.
- A key combination can only start with the CMD/Control/Option key, not the shift key.
Other feedback:
- You can currently not see the combination you are recording until you are finished, which does not feel nice to use.
- The keycap component have a effect on press but this feels confusing as clicking the keycap does not always do something, since not all shortcuts are editable. Let's get rid of the effect. Instead, just offer a "pencil" icon next to editable shortcuts on hover.
- I'm missing a way to completely unassign a keybind.
- Besides resetting all shortcuts to default, you should also be able to reset individual shortcuts to their default value
- I do not think we need an entire modal for this operation. I think instead we can make the shortcut input box inline, when you click on an editable shortcut.
There was a problem hiding this comment.
This is now dead/duplicated, see KeyboardShortcutsSheet.tsx
There was a problem hiding this comment.
Yea, figured. Now using features/command/KeyboardShortcutsSheet.tsx. I should revert the changes applied here though
Or do we just remove the file completely (from this PR)?
There was a problem hiding this comment.
This should not live in primitives
| if (enableBashMode && isBashModeText(text)) { | ||
| // Bash mode requires immediate execution, can't be queued. | ||
| // Intentionally bypasses onBeforeSubmit — bash commands run inline and | ||
| // Intentionally bypasses onBeforeSubmit — bash commands run inline and |
There was a problem hiding this comment.
Oh, shoot! Windows encoding corruption from conflcits resolutions, thanks. Will amend
|
Oh! Thanks for all of that @jonathanlab Will be taking a look. To remove a keybinding or reset individual shortcuts to default, there's a context menu that pops upon right-clicking each binding. Lmk if that works though Thank you, will revert |
|
Oh, and while I have your attention @jonathanlab |
Users can now remap any of the 17 configurable shortcuts via Settings > Shortcuts (or the ⌘/ sheet). Custom bindings fully replace all defaults (including alternates) and multiple custom combos per action are supported. Bindings persist across sessions via electronStorage. - Add `configurable` flag + `DEFAULT_KEYBINDINGS` map to keyboard-shortcuts.ts - New `keybindingsStore` (persist + electronStorage) with array-based custom combos, conflict detection helper, and individual/bulk reset - New `useShortcut(id)` hook — reactive Zustand selector, feeds useHotkeys - New `Keycap` component extracted to avoid circular imports - New `ShortcutRecorder` component: click + to enter recording mode, captures keydown, shows conflict toast, per-binding × remove, per-shortcut ↩ reset - Update all useHotkeys call sites (GlobalEventHandlers, SpaceSwitcher, usePanelKeyboardShortcuts, ExternalAppsOpener) to use useShortcut() - KeyboardShortcutsSheet: configurable rows render ShortcutRecorder instead of static keycaps; "Reset all shortcuts" button shown when customisations exist Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218
Bare letter keys (e.g. just "k") would fire every time that character is typed anywhere in the app. Require at least mod/ctrl/alt to be held. Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218
24 tests covering resolveKey, addKeybinding, removeKeybinding, resetShortcut, resetAll, getKey, and findConflict — including conflict detection against comma-separated default alternates. Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218
- KeyboardShortcutsSheet header now reads the "shortcuts" key via useShortcut() so the trigger keycap updates when remapped - ExternalAppsOpener dropdown labels for open-in-editor and copy-path now derive from useShortcut() + formatHotkeyParts() instead of hardcoded Mac-only symbols test(e2e): add Playwright shortcut sheet tests Covers sheet open/close, category sections, hover controls, recording mode entry/cancellation, bare-key rejection, saving bindings, conflict detection, removing bindings, per-shortcut reset, and reset-all. Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218
Hardcoded Cmd glyphs were leaking onto Windows in the send-messages dropdown and the tiptap paste hint, and two handlers were gated on metaKey only so the corresponding shortcut never fired on Windows (mod+1..9 task switching, Cmd/Ctrl-click multi-select in the inbox). Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218
- Add prompt-history-prev/next to CONFIGURABLE_SHORTCUT_IDS and DEFAULT_KEYBINDINGS so they appear in the shortcuts sheet and can be rebound like any other shortcut - Add tiptapEventToCombo() — accepts shift-only combos (no Ctrl/Meta required) so shift+up/down can be matched against live bindings - Fix eventToCombo() to normalise Arrow-prefixed key names (ArrowUp to up) - Wire useTiptapEditor to resolve prompt-history keys from the store instead of hardcoding event.shiftKey - Fix paste hint toast to show the live paste-as-file binding instead of the hardcoded mod+shift+v string - Fix noStaticElementInteractions lint on recording modal backdrop - Rewrite E2E shortcut tests to match the current recording modal UI (chips + right-click context menu) rather than the old hover-button and inline-input design
- Deduplicate in updateKeybinding — conflict detection excludes the shortcut being edited so editing one binding to match another on the same shortcut could produce ["ctrl+q","ctrl+q"], duplicate React keys and broken chip reconciliation - Remove ArrowUp/Down gate around prompt-history navigation so custom non-arrow bindings (e.g. Ctrl+K) actually fire when pressed, not just when the physical key is an arrow - Remove obvious section-divider comments and redundant JSX labels (Header, Scrollable list, Sticky footer); keep non-obvious rationale comments (window-level capture, backdrop dismiss, canAddMore budget, dedup note, ArrowKey gate explanation)
The configurable shortcuts logic was built in primitives/ but the app always imported from features/command/. Copy the enhanced version (ShortcutRecorder rows, search bar, reset-all footer) into features/command/ and point ShortcutsSettings at that canonical location.
… disable-with-tooltip instead of hide - Remove divider between edit actions and destructive actions - Remove red color from Remove binding (non-destructive; easily reversible) - Move Reset to default before Remove binding so Remove is last - Show Add another binding as disabled-with-tooltip at max capacity instead of hiding it - Add sticky footer to shortcuts settings page
- Replace fullscreen ShortcutRecordingModal with inline InlineRecorder that captures keys directly in the chip row — no backdrop, no modal - Live partial preview: modifier-only presses show a pulsing partial combo (e.g. Ctrl… then Ctrl+Shift+K) before the final key is pressed - Pencil icon appears on chip hover; click chip or pencil to edit inline; click outside or Escape to cancel - Remove Keycap press animation — chips are purely visual, no confusing press effect on non-editable shortcuts - Fix eventToCombo Mac bug: Ctrl key on Mac was mapped to mod (⌘) instead of ctrl (⌃); same fix in tiptapEventToCombo and eventToSearchCombo - Allow shift+non-printable combos (shift+up/down) in the recorder so prompt-history defaults can be re-recorded; block shift+letter to avoid conflicts with normal typing - Move ShortcutRecorder from primitives/ into features/command/ - Delete dead primitives/KeyboardShortcutsSheet.tsx - Fix Windows encoding corruption in useTiptapEditor.ts comment
… pencil click, conflict copy
…ability, ellipsis visibility
e0ac7ca to
4788fea
Compare
Problem
Users cannot remap keyboard shortcuts to suit their workflow or resolve conflicts with OS/app bindings on their machine. Closes #300
Changes
Core store (
keybindingsStore.ts)Persisted Zustand store backed by
electronStorage— bindings survive app restarts. Stores custom overrides asPartial<Record<ConfigurableShortcutId, string[]>>.addKeybinding/updateKeybinding/removeKeybinding— per-binding CRUD, max 2 custom bindings per shortcutresetShortcut/resetAll— revert one or all shortcuts to defaultsresolveKey— falls back to the static default when no custom entry existssplitBindings— splits comma-separated binding strings while correctly handlingmod+,(the Settings shortcut key contains a literal comma — uses negative lookbehind regex(?<!\+),to distinguish separator commas from key commas)findConflict— checks the candidate key against all other configurable shortcuts (with live overrides applied) and all non-configurable static shortcuts; returns the conflicting description and whether it is a fixed shortcutDefinitions (
keyboard-shortcuts.ts)configurable: truewith an explicitCONFIGURABLE_SHORTCUT_IDSarray andDEFAULT_KEYBINDINGSrecordeventToCombo— converts aKeyboardEventto a normalised combo string (mod+shift+v); requires at least one non-shift modifier; normalisesArrowUp → upetc.tiptapEventToCombo— likeeventToCombobut also accepts shift-only combos, needed forprompt-history-prev/nextwhose defaults areshift+up/shift+down⌘⇧⌥↩↑↓, Windows showsCtrl+Shift+Alt+Enter+↑+↓. This also fixed a bug where hardcoded shortcut labels in the editor (e.g. the paste-as-file hint toast) were showing the Mac⌘symbol on Windows instead ofCtrlShortcut recorder UI (
ShortcutRecorder.tsx)ShortcutRecordingModal— a fullscreen capture overlay:keydownlistener (no element focus required)BindingChip— individual keycap with left-click to edit, right-click context menu:ShortcutRecorder— row-level component:mod+n,mod+t) into individual chipscanAddMorebased on custom count only — defaults don't consume the 2-binding budgetupdateKeybindingcopies them into the custom array before replacing only the targetShortcuts sheet (
KeyboardShortcutsSheet.tsx)ShortcutRecorder; non-configurable rows render staticShortcutKeyswith a "This shortcut cannot be customized" tooltip on hoverCtrl+Shiftto see all shortcuts using that prefix, pressCtrl+Shift+Nto isolate the exact binding; keycap chips display the captured combo in real time with a…pulse indicator for partial (modifier-only) capturesBackspaceor any printable character while in combo mode exits combo mode and switches to text searchEditor integration (
useTiptapEditor.ts)paste-as-filereads from the store dynamically; supports multiple bindingsprompt-history-prev/nextresolved from store viatiptapEventToCombo; replaces the old hardcodedevent.shiftKeycheckpaste-as-filebinding (was hardcodedmod+shift+v)Non-configurable shortcuts — decisions
mod+1-9)ctrl+1-9)mod+f)mod+b/i/u/e)How did you test this?
Unit tests — 30 tests in
keybindingsStore.test.tscovering:resolveKey,addKeybinding(dedup, max-limit enforcement),removeKeybinding,resetShortcut,resetAll,getKey,updateKeybinding(editing a default preserves siblings, editing custom replaces only target), andfindConflict(configurable defaults, custom overrides on other shortcuts, non-configurable fixed shortcuts,mod+,edge case, excluded shortcut own key not flagged as conflict). All 30 pass.Typecheck — clean (
tsc --noEmiton bothtsconfig.node.jsonandtsconfig.web.json).E2E tests (
shortcuts.spec.ts) — wrote a full E2E suite covering:Ctrl+Shiftnarrows to allCtrl+Shift+…shortcuts; pressing a final key (e.g.N) isolates the exact matchManual testing:
1. Open the Keyboard Shortcuts Sheet
Default shortcut:
Cmd+/(Mac) /Ctrl+/(Windows)2. The Sheet Layout — Configurable vs Fixed
Scroll through all four categories: General, Navigation, Panels & Tabs, Editor.
Configurable rows (20 shortcuts) show interactive keycap chips you can click.
Fixed rows (8 shortcuts) show static chips. Hover one — tooltip reads "This shortcut cannot be customized."
Fixed shortcuts and why:
Mod+1-9Ctrl+1-9Mod+FMod+B/I/U/Econfigurable-vs-fixed-shortcuts-posthog-code-configurable-shortcuts.mp4
3. Edit a Shortcut — Click to Edit
Cmd+K/Ctrl+K).Cmd+Shift+P. The modal shows the combo formatted with platform symbols (Mac:⌘⇧P, Windows:Ctrl+Shift+P).editing-a-shortcut-posthog-code-configurable-shortcuts.mp4.mp4
4. Conflict Detection — Amber Warning
Cmd+N(which is "New task").Edge case —
mod+,(Settings): The comma is also the separator character used internally. Try to bindCmd+,to something — it correctly detects it conflicts with "Open settings" despite the comma in the key name.Non-configurable conflict: Try to bind
Mod+B(Bold, which is a fixed Tiptap shortcut). The conflict detection catches this too — you will see it warns "Conflicts with 'Bold'."conflict-detection-posthog-code-configurable-shortcuts.mp4.mp4.mp4
5. Escape / Backdrop to Cancel
escape-to-cancel-posthog-code-configurable-shortcuts.mp4.mp4.mp4
6. Add a Second Binding (Up to 2 Custom Bindings)
Cmd+Shift+N. Press Enter.Cmd+N or Cmd+Shift+N(separated by "or").adding-a-second-binding-posthog-code-configurable-shortcuts.mp4.mp4
7. Default Shortcuts with Multiple Bindings
Some shortcuts ship with 2 default bindings (e.g. New task:
Cmd+NorCmd+T).Cmd+Nstores["mod+t"]in the custom array. The other default is preserved.Cmd+NandCmd+Ttogether.default-shortcuts-with-multiple-binding-posthog-code-configurable-shortcuts.mp4
8. Edit One Default Binding — Preserves the Other
Cmd+NandCmd+Tchips visible).Cmd+Nto edit it. RecordCmd+Shift+X. Press Enter.Cmd+Shift+X or Cmd+T— only the edited key changed, the sibling default (Cmd+T) is preserved.Uploading editing-one-default-binding-posthog-code-configurable-shortcuts.mp4…
9. Reset ALL Shortcuts
resetting-all-shortcuts-posthog-code-configurable-shortcuts.mp4
10. Paste-as-File Shortcut (Configurable, Live Hint)
In the Editor category, find "Paste as file attachment" (
Cmd+Shift+V/Ctrl+Shift+V).Ctrl+Shift+Vto paste as a file attachment instead." (shows the live current binding).Ctrl+Shift+G.Ctrl+Shift+Gto paste as a file attachment instead." ✅paste-as-file-shortcut-posthog-code-configurable-shortcuts.mp4
11. Persistence Across App Restarts
electronStorage(Electron user data directory).12. Search the Shortcuts Sheet
The search bar auto-focuses when the sheet opens — no click needed.
Text search:
Combo search:
Ctrl(Windows) /Cmd(Mac) — the input shows aCtrlkeycap chip and the list narrows to all shortcuts that useCtrl+….Shift— the chip updates toCtrl Shiftand the list narrows further toCtrl+Shift+…shortcuts.N— the chip showsCtrl Shift Nand only "New task" remains (if at default).Ctrl+/— only "Show keyboard shortcuts" is shown. TryCtrl+Shift+V— only "Paste as file attachment."Exit combo mode:
Backspace— chips clear and the input returns to empty text mode.t— combo chips disappear and text search activates with "t" already in the input.Empty state:
Backspaceto clear — the full list returns.Publish to changelog?
Probably? Users can now remap any of the 20 configurable shortcuts directly inside the app via the Keyboard Shortcuts sheet (
⌘//Ctrl+/) - and potentially any new additions to the app shortcuts can now be configurable