Skip to content

feat: configurable keybinding system with agent presets#56

Open
IShaalan wants to merge 16 commits intojohannesjo:mainfrom
IShaalan:feature/configurable-keybindings
Open

feat: configurable keybinding system with agent presets#56
IShaalan wants to merge 16 commits intojohannesjo:mainfrom
IShaalan:feature/configurable-keybindings

Conversation

@IShaalan
Copy link
Copy Markdown
Contributor

@IShaalan IShaalan commented Apr 4, 2026

Summary

  • Replace all hardcoded shortcuts with a configurable two-layer system (App + Terminal)
  • Ship agent presets (Default, Claude Code) that resolve keybinding conflicts with terminal-based coding agents
  • Transform the Help dialog into an interactive keybinding editor with recording mode, conflict detection, and reset
  • Add Cmd+Backspace terminal mapping for kill-line-backward
  • Persist configuration in a dedicated keybindings.json

Problem

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

Conflict Default Claude Code Preset
Option+Left/Right Column navigation Unbound (word movement passes through)
Cmd+B Toggle sidebar Reassigned to Cmd+Shift+B
Cmd+Backspace Nothing Kill line backward (Ctrl+U)

Test plan

  • Default preset: all existing shortcuts work identically
  • Claude Code preset: Option+Left/Right, Cmd+Shift+B, Cmd+Backspace all work
  • Rebinding with conflict detection (Override/Swap/Cancel)
  • Per-preset isolation: customizing one preset doesn't affect another
  • Reset (per-binding and Reset All)
  • Persistence survives app restart
  • Migration banner appears once, dismissible

🤖 Generated with Claude Code

IShaalan and others added 15 commits April 3, 2026 20:06
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +71 to +102
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;
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +121 to +140
/**
* 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;
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@johannesjo
Copy link
Copy Markdown
Owner

Code Review — New Issues (not in Copilot review)

1. createMemo called at module scope — SolidJS reactivity leak

src/store/keybindings.ts lines 103–118 create resolvedBindings and allBindings with createMemo at module load time, outside any reactive owner/root:

// Module top-level — no reactive owner
export const resolvedBindings = createMemo(() => { ... });
export const allBindings = createMemo(() => { ... });

SolidJS requires createMemo (and other reactive primitives) to be created inside a reactive owner. Outside one, Solid emits a console warning and the memo is never disposed, leaking the reactive computation for the app's lifetime. It works here because Electron's renderer effectively has a single long-lived root and there's only one import, but it's fragile: if these are ever imported in a test or SSR context outside a root they will throw.

Fix: wrap in createRoot or move into the store's initialization flow, or use a lazy accessor pattern:

let _resolvedBindings: Accessor<KeyBinding[]>;
export function resolvedBindings() {
  if (!_resolvedBindings) _resolvedBindings = createMemo(() => ...);
  return _resolvedBindings();
}

Or simply use a plain function that reads from the store (the store itself is reactive; callers inside components/effects will still track correctly).


2. SaveKeybindings IPC handler: no shape validation on write

electron/ipc/register.ts validates args.json is a string and calls JSON.parse(json) (inside saveKeybindings) to confirm it's valid JSON, but never validates that the parsed object is a sane keybinding config before writing it to disk. A renderer bug (or hypothetically, a compromised renderer) can persist arbitrary JSON to keybindings.json.

isValidShape() already exists in electron/ipc/keybindings.ts for the load path — it should also be applied on save:

ipcMain.handle(IPC.SaveKeybindings, (_e, args) => {
  assertString(args?.json, 'json');
  const parsed: unknown = JSON.parse(args.json);   // already done inside saveKeybindings
  if (!isValidShape(parsed)) throw new Error('Invalid keybinding config shape');
  saveKeybindings(getKeybindingsDir(), args.json);
});

3. resetAllBindings — confusing optional preset-switching behaviour

In src/store/keybindings.ts:

export function resetAllBindings(presetId?: string): void {
  const targetPreset = presetId ?? store.keybindingPreset;
  if (presetId) setStore('keybindingPreset', presetId);   // ← switches active preset!
  setStore('keybindingOverridesByPreset', { ... [targetPreset]: {} });
  persist().catch(console.error);
}

The only call site is handleResetAll which passes store.keybindingPreset — so today setStore('keybindingPreset', presetId) is always a no-op. But the API is misleadingly designed: a future caller passing a different preset ID would silently switch the active preset as a side-effect of resetting overrides. The parameter should either be removed (always operate on the active preset) or the function should be renamed/documented to make the side-effect explicit.


4. "Override" in conflict resolution permanently unbinds the conflicting key

In handleOverride (src/components/HelpDialog.tsx):

setUserOverride(info.conflicting.id, null);   // unbinds conflicting binding
setUserOverride(info.editingId, { key: ..., modifiers: ... });

Setting the conflicting binding's override to null writes a permanent "user explicitly unbound this" record. If the user later removes their own binding (via the reset ↺ button), the conflicting binding remains unbound — there's no way to recover it except "Reset All". This is an unintuitive UX trap for new users. Consider using clearUserOverride (which removes the override key entirely) instead of setUserOverride(id, null) when unbinding the displaced binding, so it reverts to its preset/default state rather than being explicitly erased:

clearUserOverride(info.conflicting.id);   // reverts to default, not "explicitly unbound"

@johannesjo
Copy link
Copy Markdown
Owner

Thank you very much! <3

Copy link
Copy Markdown
Owner

@johannesjo johannesjo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two correctness issues to fix:

  1. createMemo at module scope — called outside a reactive owner in src/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.
  2. '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.

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.

3 participants