feat: configurable keybinding system with agent presets#56
feat: configurable keybinding system with agent presets#56IShaalan wants to merge 16 commits intojohannesjo:mainfrom
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add cmdOrCtrl to Modifiers interface and update all platform:both app-layer bindings to use cmdOrCtrl instead of meta, so shortcuts work on Linux (Ctrl) as well as macOS (Cmd). Add a test to enforce this constraint going forward. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rsistence Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace ~20 hardcoded registerShortcut() calls in App.tsx with a registry-driven approach. Add registerFromRegistry() to shortcuts.ts that reads KeyBinding objects and maps action strings to handlers. Shortcuts are now registered via a reactive createEffect that reads resolvedBindings(), enabling automatic re-registration when the user changes keybinding presets or overrides. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace hardcoded if/else key handler in TerminalView with a registry-driven loop over terminal-layer bindings from resolvedBindings(). Adds Cmd+Backspace → \x15 (kill-line) as a new behavior from the defaults. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…flict detection Rewrite HelpDialog from a static shortcut cheat sheet into a full keybinding editor with preset selector, per-binding recording mode, conflict warnings (with override/swap/cancel), and per-binding reset functionality. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unbound bindings (e.g., column navigation in Claude Code preset) now appear in the keybinding editor as rows with a muted em-dash instead of disappearing entirely. This ensures consistent layout across presets and lets users click to rebind unbound shortcuts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e docs 1. Fix SolidJS reactivity: replace produce() with full object replacement in setUserOverride/clearUserOverride so memos recompute correctly 2. Add "dismiss" text link to migration banner alongside the x button 3. Remove accidentally committed PRD, gitignore docs/ directory Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User overrides are now stored per-preset instead of globally. Changing
a binding on Claude Code preset no longer affects Default, and vice
versa. Reset correctly clears only the active preset's overrides.
Persistence format updated to { preset, overridesByPreset: { ... } }
with backward compatibility for the old flat { userOverrides } format.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, pressing Escape while in recording mode stopped event propagation, so the Dialog's own Escape handler never fired. Now Escape cancels recording without stopping propagation, allowing the dialog to close in a single keypress. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Fix modifiersMatch to treat cmdOrCtrl and meta as equivalent on macOS (and cmdOrCtrl/ctrl on Linux) so conflict detection works across app and terminal layers 2. Extract terminal binding filter into a function — avoids module-level createMemo (SolidJS lint) while keeping the hot path clean 3. Extract shared matchesKeyEvent utility (src/lib/keybindings/match.ts) used by both shortcuts.ts and TerminalView.tsx, removing duplicate key-matching logic 4. Replace native confirm() with app's styled dialog in Reset All Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a configurable keybinding system (app + terminal layers) with preset support and a UI editor, aiming to reduce shortcut conflicts with terminal-based coding agents and persist user configuration to keybindings.json.
Changes:
- Adds a keybinding registry (defaults + presets) with resolution and conflict detection utilities.
- Replaces hardcoded app/terminal shortcut handling with registry-driven bindings and introduces keybinding persistence over IPC.
- Reworks the Help dialog into an interactive keybinding editor (recording mode, conflict UI, reset) and adds a migration banner.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/store/types.ts | Extends store typing for preset/override state and migration dismissal flag. |
| src/store/core.ts | Initializes keybinding preset/overrides/migration flag in the default store. |
| src/store/store.ts | Re-exports keybinding selectors/actions from the store. |
| src/store/persistence.ts | Persists keybindingMigrationDismissed in the main app state. |
| src/store/keybindings.ts | Adds renderer-side keybinding loading/saving, preset switching, overrides, conflict checks. |
| src/lib/shortcuts.ts | Adds registry-driven shortcut registration for app-layer bindings. |
| src/lib/keybindings/types.ts | Defines keybinding, modifiers, preset, and config types. |
| src/lib/keybindings/defaults.ts | Introduces the default binding registry (app + terminal). |
| src/lib/keybindings/presets.ts | Adds preset definitions (Default, Claude Code) and lookup. |
| src/lib/keybindings/resolve.ts | Implements binding resolution + conflict detection logic. |
| src/lib/keybindings/match.ts | Adds event matching logic for keybindings. |
| src/lib/keybindings/index.ts | Exports the keybinding library API. |
| src/lib/keybindings/tests/defaults.test.ts | Adds tests for default binding registry completeness/consistency. |
| src/lib/keybindings/tests/resolve.test.ts | Adds tests for resolution and conflict detection behavior. |
| src/components/TerminalView.tsx | Switches terminal key handling to use terminal-layer bindings from the registry. |
| src/components/HelpDialog.tsx | Replaces static help with an interactive keybinding editor and preset selector. |
| src/App.tsx | Registers app-layer shortcuts from the registry; adds migration banner and loads keybindings at startup. |
| electron/preload.cjs | Allows IPC channels for keybinding load/save. |
| electron/ipc/channels.ts | Adds IPC channel constants for keybindings. |
| electron/ipc/register.ts | Registers IPC handlers for keybinding load/save and determines storage directory. |
| electron/ipc/keybindings.ts | Implements keybindings.json read/write with backup support. |
| electron/ipc/tests/keybindings.test.ts | Adds Electron-side persistence tests for keybindings. |
| .gitignore | Broadens ignore rule from docs/plans to docs/. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function registerFromRegistry( | ||
| bindings: KeyBinding[], | ||
| handlers: Record<string, ActionHandler>, | ||
| ): () => void { | ||
| const cleanups: (() => void)[] = []; | ||
|
|
||
| for (const binding of bindings) { | ||
| if (binding.layer !== 'app') continue; | ||
| if (!binding.action) continue; | ||
|
|
||
| const handler = handlers[binding.action]; | ||
| if (!handler) continue; | ||
|
|
||
| const opts: Shortcut = { | ||
| key: binding.key, | ||
| global: binding.global, | ||
| dialogSafe: binding.dialogSafe, | ||
| handler, | ||
| }; | ||
|
|
||
| if (binding.modifiers.cmdOrCtrl) { | ||
| opts.cmdOrCtrl = true; | ||
| } | ||
| if (binding.modifiers.ctrl) { | ||
| opts.ctrl = true; | ||
| } | ||
| if (binding.modifiers.alt) { | ||
| opts.alt = true; | ||
| } | ||
| if (binding.modifiers.shift) { | ||
| opts.shift = true; | ||
| } |
There was a problem hiding this comment.
registerFromRegistry maps binding.modifiers.cmdOrCtrl into the existing shortcut system, but matches() currently treats cmdOrCtrl as (Ctrl || Meta) on all platforms. That diverges from the new keybinding semantics/UI labeling (Cmd on macOS, Ctrl on Linux) and can also invalidate conflict detection (Ctrl-only bindings on macOS can still trigger cmdOrCtrl shortcuts). Consider updating the shortcut matcher to resolve cmdOrCtrl by platform (Cmd on macOS / Ctrl elsewhere) and/or adding explicit meta support so app-layer behavior matches the keybinding registry.
There was a problem hiding this comment.
This is pre-existing behavior predating this PR — every hardcoded shortcut in the app relies on it. Changing it would affect all existing shortcuts, not just new keybinding-editor ones, and belongs in its own focused PR so the impact is reviewable in isolation.
Note that the conflict detection layer (modifiersMatch in resolve.ts) does apply platform-correct semantics, so editor warnings are accurate. Only the runtime matcher remains lenient.
| /** | ||
| * Checks for a keybinding conflict when assigning a proposed key+modifiers | ||
| * to the binding identified by `editingId`. | ||
| * | ||
| * Returns the conflicting binding, or null if no conflict exists. | ||
| * The binding being edited is excluded from the check (no self-conflict). | ||
| */ | ||
| export function findConflict( | ||
| resolved: KeyBinding[], | ||
| editingId: string, | ||
| proposed: Pick<KeyBinding, 'key' | 'modifiers'>, | ||
| ): KeyBinding | null { | ||
| for (const binding of resolved) { | ||
| if (binding.id === editingId) continue; | ||
| if ( | ||
| binding.key.toLowerCase() === proposed.key.toLowerCase() && | ||
| modifiersMatch(binding.modifiers, proposed.modifiers) | ||
| ) { | ||
| return binding; | ||
| } |
There was a problem hiding this comment.
findConflict treats any identical key+modifiers pair as a conflict across the entire resolved registry, but runtime handling is layer/scope-dependent (e.g., terminal bindings can safely reuse a combo with a non-global app binding because non-global app shortcuts don’t fire inside terminals). Consider scoping conflicts to (a) app↔app always, (b) terminal↔terminal always, and (c) terminal↔app only when the app binding is global (since TerminalView explicitly yields to global app shortcuts).
There was a problem hiding this comment.
Keeping strict detection for v1. The core concern is muscle memory, not editor layout — users don't consciously check "what's focused right now?" before pressing a key. If Cmd+B toggles the sidebar sometimes and sends an escape sequence other times, the same physical action produces different results depending on invisible state. No editor UI can eliminate that surprise.
This differs from context-aware shortcuts like Cmd+C, which always means "copy the thing I'm looking at" — the action is consistent, only the target changes. Layer-scoped detection would enable the same combo mapped to genuinely different actions, which breaks the predictability contract.
Verified against the base commit: only two pre-PR app-layer shortcuts are non-global (Cmd+B, Escape), and neither overlaps with any pre-existing terminal binding. The app has never relied on layer-scoped coexistence.
Runtime correctness is already handled separately — TerminalView.tsx yields to matchesGlobalShortcut before checking terminal bindings. findConflict only drives the editor warning; dispatch is unaffected.
1. Strict modifier matching (match.ts): matchesKeyEvent now normalizes
cmdOrCtrl into expected Meta/Ctrl booleans per platform, rejecting
extra modifiers. Cmd+Ctrl+K no longer matches Cmd+K on macOS.
2. Backend schema (electron/ipc/keybindings.ts): DEFAULT_CONFIG and
types now use overridesByPreset, matching what the renderer writes.
Legacy { userOverrides } format still accepted on load.
3. Migration banner dismissal (store/keybindings.ts): dismissMigrationBanner
now calls saveState() immediately so the flag is persisted even if
the app closes before the next autosave tick.
4. Accessibility (HelpDialog.tsx): keybinding badges have role=button,
tabIndex=0, aria-label, and Enter/Space handlers. Recording activation
is deferred via setTimeout and ignores e.repeat, preventing the
Enter that started recording from being captured as the new binding.
5. Type safety: new exported KeybindingOverride type replaces
Record<string, unknown>; all `as` casts removed from the store;
keybindingMigrationDismissed validated with typeof check on load.
6. Tests: keybindings.test.ts updated for overridesByPreset schema
with an added test verifying legacy userOverrides format still loads.
Addresses Copilot review comments on johannesjo#56.
Two comments intentionally not addressed:
- shortcuts.ts matches() cmdOrCtrl leniency: pre-existing behavior
predating this PR. The lenient matcher treats cmdOrCtrl as
(Ctrl || Meta) on all platforms. Changing it to platform-strict
would affect every existing shortcut in the app and belongs in its
own focused PR. Conflict detection in the editor (modifiersMatch
in resolve.ts) does apply platform-correct semantics, so editor
warnings are accurate.
- findConflict layer scoping: users don't consciously check focus
before pressing a key — muscle memory is tied to the keystroke.
Allowing the same combo mapped to genuinely different actions
(toggle sidebar vs send escape sequence) breaks the predictability
contract, unlike context-aware shortcuts like Cmd+C where the
action stays consistent. Verified against the base commit: no
pre-existing shortcut relies on layer-scoped coexistence.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code Review — New Issues (not in Copilot review)1.
|
|
Thank you very much! <3 |
johannesjo
left a comment
There was a problem hiding this comment.
Two correctness issues to fix:
createMemoat module scope — called outside a reactive owner insrc/store/keybindings.ts. SolidJS will warn in development and the computation is never disposed, leaking memory. Move it inside an owner (e.g. inside the store initializer) or use a lazy initializer pattern.- 'Override' permanently unbinds the displaced key — when resolving a conflict via 'Override', the displaced binding is set to
null(user-unbound) rather than reverted to its preset default. Users who accidentally choose 'Override' have no recovery path short of 'Reset All'. The displaced key should be reset to its preset default instead.
Details in the review comment.
Summary
keybindings.jsonProblem
Parallel Code's shortcuts conflict with coding agents running inside its terminals. Option+Left/Right (word movement), Cmd+B (background task), and Cmd+Backspace (kill line) are intercepted or ignored, with no way to discover or fix these conflicts.
Claude Code preset resolves
Test plan
🤖 Generated with Claude Code