From c0acfd9c2caebb4f3141643836f93c315effea09 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 22:45:20 +0200 Subject: [PATCH 1/2] feat(keybindings): bind the thirteen most-used unbound commands The governance work kept the defaults table deliberately scarce: every chord in it except Cmd+, already ran before the change, and the plan explicitly refused to mint defaults from palette-usage counts on the grounds that the history "came from one development profile" and proves a command is USEFUL, not that it deserves a scarce global chord. This is a considered departure from that, on evidence the plan did not have. The recent-command cache now holds 2,674 recorded invocations. The thirteen commands below account for 1,703 of them -- about 64% of everything invoked -- with no chord at all. Scarcity was protecting against speculative bindings; these are the opposite. The clearest case is Reader Mode. Escape already closes it, and nothing opened it: 156 palette round-trips for a toggle that was half-bound. Assigned in families so the set is learnable rather than memorized: Cmd + letter layout and app panels Cmd+D tiled dispatch, Cmd+Shift+M dispatch mode, Cmd+Shift+U usage, Cmd+Shift+G dispatch scope Cmd+Alt + letter developer tooling Cmd+Alt+D debug panel Alt + letter pane and session verbs Alt+R reader, Alt+S spotlight, Alt+F auto-follow, Alt+V agent view, Alt+P prompt template Alt+Shift the heavier sibling Alt+Shift+A reload agent (Alt+A soft), Alt+Shift+P view prompts (Alt+P template) Alt/Alt+Shift tracks BLAST RADIUS, not frequency -- soft reload takes the lighter chord despite being used less, because the cost of hitting the wrong one is asymmetric. Two things recorded in the file rather than lost here. The counts are palette selections, since only the palette recorded a use before the execution gateway; that under-counts already-bound commands, which is the safe direction because every command here is unbound. And Cmd+Shift+D was the natural pair for Cmd+D until `check:keybindings` refused it -- it is the voice dictation hotkey. The probe that picked these chords called `findBindingOwners` directly and missed it, because `dictationBinding` is optional and was omitted. The gate caught a collision a hand-rolled call to the same function did not. check:keybindings: 38 command binding sets, 13 reserved, 5 approved overlaps. tsc -b clean, 246/246 test files passing. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/command-keybindings/defaults.ts | 103 +++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/features/command-keybindings/defaults.ts b/src/renderer/src/features/command-keybindings/defaults.ts index ceb77602..47a08eb4 100644 --- a/src/renderer/src/features/command-keybindings/defaults.ts +++ b/src/renderer/src/features/command-keybindings/defaults.ts @@ -130,9 +130,108 @@ export function buildDefaultKeybindings(): CommandBindingDefault[] { { commandId: 'jump-latest-message', bindings: ['End'], context: 'feed' }, // --- Preferences -------------------------------------------------------- - // The one genuinely NEW default. ⌘, is the macOS convention for Settings - // and was unclaimed. + // ⌘, is the macOS convention for Settings and was unclaimed. { commandId: 'open-settings', bindings: ['Cmd+,'], context: 'global' }, + + // ======================================================================== + // USAGE-DERIVED DEFAULTS + // + // Everything above this line is a chord that ALREADY RAN before the + // governance work — the table was kept deliberately scarce so upgrading + // users lost no muscle memory, and the governance plan explicitly refused + // to mint defaults from palette-usage counts, on the grounds that history + // "came from one development profile" and proves a command is USEFUL, not + // that it deserves a scarce global chord. + // + // This block is a considered departure from that, not an oversight. What + // changed is the evidence: the recent-command cache now covers 2,674 + // recorded invocations, and the thirteen commands below account for 1,703 + // of them — roughly 64% of everything invoked — while having no chord at + // all. "Scarce" was protecting against speculative bindings. These are the + // opposite: the most-used surface in the app, reachable only by opening a + // palette and typing. + // + // The caveat that keeps this honest: until the execution gateway landed, + // ONLY the palette recorded a use. So these counts are palette selections, + // which systematically UNDER-counts anything already bound (close-pane + // shows 1). That biases the data in the safe direction here — every + // command below is unbound, so its count is complete, and no bound command + // was displaced on the strength of a number that undercounts it. + // + // THE FAMILIES. Assigned so the set is learnable rather than memorized; + // a thirteen-chord dump with no scheme is thirteen things to forget. + // + // ⌘ + letter — layout and app-level panels, beside ⌘T/⌘W/⌘P + // ⌘⌥ + letter — developer tooling, beside Electron's ⌘⌥I devtools + // ⌥ + letter — pane and session verbs, beside ⌥D/⌥T/⌥C splits + // ⌥⇧ + letter — the HEAVIER sibling of the ⌥ verb below it + // + // The ⌥/⌥⇧ pairing is the part worth preserving when this list grows: + // ⌥A soft-reloads an agent, ⌥⇧A hard-reloads it; ⌥P opens the template + // picker, ⌥⇧P opens the prompt history. Shift means "more of the same + // thing", never "an unrelated command that happened to fit". + + // --- Layout modes (⌘) --------------------------------------------------- + // These two SHOULD have been ⌘D → ⌘⇧D, base → tiled variant, matching the + // shift-means-more rule used everywhere else in this block. They are not, + // because ⌘⇧D is the voice dictation hotkey (DEFAULT_SETTINGS + // .dictationShortcut) and `check:keybindings` rejected the pairing. + // + // Worth recording HOW that was caught, because it nearly was not: the + // chords in this block were probed against `findBindingOwners` before + // being written, and that probe passed — it omitted `dictationBinding`, + // which is optional on the options object. The check script passes it. So + // the gate caught a real collision that a hand-rolled call to the very + // same function had missed. If you are adding chords here, run + // `npm run check:keybindings`; do not trust a bespoke probe. + // + // Given the constraint, the higher-usage command takes the better chord: + // Tiled Dispatch (164) gets bare ⌘D, Dispatch Mode (52) gets ⌘⇧M for Mode. + // The pair is less elegant than ⌘D/⌘⇧D would have been, and that is the + // correct trade — an elegant scheme that shadows dictation is not elegant. + { commandId: 'tiled-dispatch', bindings: ['Cmd+D'], context: 'global' }, + { commandId: 'dispatch-mode', bindings: ['Cmd+Shift+M'], context: 'global' }, + // `dispatch` context, not global: Dispatch Scope only means anything while + // Dispatch owns the layout, and scoping it here leaves ⌘⇧G free for a grid + // command later. The overlap matrix proves grid and dispatch are disjoint. + { commandId: 'global-dispatch', bindings: ['Cmd+Shift+G'], context: 'dispatch' }, + + // --- App panels (⌘⇧) ---------------------------------------------------- + { commandId: 'usage.open', bindings: ['Cmd+Shift+U'], context: 'global' }, + + // --- Developer tooling (⌘⌥) --------------------------------------------- + // The single most-invoked command in the cache (352), and it sits on ⌘⌥ + // rather than ⌘⇧ deliberately: that is where Electron already puts + // devtools (⌘⌥I), so the debug surface stays in one modifier family + // instead of competing with product panels for ⌘⇧ letters. + { commandId: 'toggle-debug-panel', bindings: ['Cmd+Alt+D'], context: 'global' }, + + // --- Pane and session verbs (⌥) ----------------------------------------- + // Reader Mode is the strongest single case in this whole block. Escape + // already CLOSES it (useKeybinds' one-key dismiss), but nothing opened it + // — an asymmetry that cost 156 palette round-trips for a toggle. + { commandId: 'toggle-reader-mode', bindings: ['Alt+R'], context: 'global' }, + { commandId: 'toggle-spotlight', bindings: ['Alt+S'], context: 'global' }, + // ⌥F, leaving ⌥⇧F open for Auto-follow ALL Visible Agents — the same + // soft/heavy pairing, and the command that OWNS the effective state when + // both are on (see the `detail` on toggle-tail's state). + { commandId: 'toggle-tail', bindings: ['Alt+F'], context: 'global' }, + // ⌥V and ⌥P open pickers rather than acting directly, so the chord saves + // the palette search and not the decision. They are here on volume alone + // (315 and 121). If `set-agent-view-mode` is ever split into three direct + // commands — Agent / Terminal / Default — ⌥V should follow the one that + // gets used, and this entry should go. + { commandId: 'set-agent-view-mode', bindings: ['Alt+V'], context: 'global' }, + { commandId: 'prompt-template', bindings: ['Alt+P'], context: 'global' }, + + // --- Heavier siblings (⌥⇧) ---------------------------------------------- + // Soft reload is the lighter action and takes the lighter chord. Note the + // counts run the other way (136 hard vs 62 soft): the modifier tracks + // BLAST RADIUS, not frequency, because the cost of hitting the wrong one + // is asymmetric. + { commandId: 'soft-reload-agent', bindings: ['Alt+A'], context: 'global' }, + { commandId: 'reload-agent', bindings: ['Alt+Shift+A'], context: 'global' }, + { commandId: 'view-prompts', bindings: ['Alt+Shift+P'], context: 'global' }, ] // Per-provider split chords, derived from the SAME provider identity From 4eb022ffd76b0c5039e46983f1489156239c25c3 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 22:51:26 +0200 Subject: [PATCH 2/2] feat(keybindings): add a searchable Keyboard Shortcuts reference on Cmd+Shift+/ A shortcut list you can only reach by remembering a shortcut is a joke. Reaching it by opening the palette and typing "keyboard" is nearly as bad, because the palette is exactly what you fall back to WHEN you have forgotten the chord. So this ships bound, on Cmd+Shift+/ -- Cmd+? on a US layout, which is where Slack, GitHub, Gmail and macOS Help all put it. WHY NOT Settings -> Keybindings, which already lists every chord. That screen exists to CHANGE a binding: it lists all 99 commands including the ~72 with no chord at all, it has live key capture, conflict resolution and per-row reset, and it sits several clicks deep behind Cmd+,. Every one of those is wrong for "what was the chord for Reader Mode again?" -- the answer is buried among dozens of rows that have no answer, on a screen that can rebind something if you fumble a keystroke while it is capturing. So this shows only commands that HAVE a binding, grouped by category, sorted for scanning, searchable by command name or by the chord itself (the other half of the question is "what does Alt+R do?"), and mutates nothing. It reads the same `resolveEffectiveKeybindings` the router does, so a user who rebound something sees THEIR chord. A cheat sheet that printed shipped defaults would be confidently wrong for exactly the people who customized -- who are the people most likely to have forgotten. Catalog goes 98 -> 99. The baseline characterization test moved by hand, which is what it is for: 102 original - 5 retirements + 2 additions = 99. check:keybindings: 39 command binding sets. tsc -b clean, 246/246 test files passing. Co-Authored-By: Claude Opus 5 (1M context) --- src/renderer/src/app-state/types.ts | 2 + src/renderer/src/app-state/uiShell/slice.ts | 5 + src/renderer/src/app-state/uiShell/types.ts | 7 + src/renderer/src/app/surfaces/registry.tsx | 2 + .../features/command-keybindings/defaults.ts | 13 + .../features/command-palette/catalog.test.ts | 43 +-- .../src/features/command-palette/types.ts | 2 + .../command-palette/ui/CommandPalette.tsx | 4 +- .../settings/commands/settingsCommands.ts | 35 +++ .../surfaces/KeyboardShortcutsSurface.tsx | 8 + .../settings/ui/KeyboardShortcutsModal.tsx | 245 ++++++++++++++++++ 11 files changed, 348 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/features/settings/surfaces/KeyboardShortcutsSurface.tsx create mode 100644 src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx diff --git a/src/renderer/src/app-state/types.ts b/src/renderer/src/app-state/types.ts index 1942af53..58022a3f 100644 --- a/src/renderer/src/app-state/types.ts +++ b/src/renderer/src/app-state/types.ts @@ -100,6 +100,8 @@ export type UiShellSlice = UiShellState & { closePromptSearch: () => void openAgentActivity: () => void closeAgentActivity: () => void + openKeyboardShortcuts: () => void + closeKeyboardShortcuts: () => void openCloseOldAgents: () => void closeCloseOldAgents: () => void openBulkProviderSwitch: () => void diff --git a/src/renderer/src/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts index b418d4c8..bb0a45cb 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -40,6 +40,7 @@ export const createUiShellSlice: StateCreator< globalEditorOpen: false, promptSearchOpen: false, agentActivityOpen: false, + keyboardShortcutsOpen: false, closeOldAgentsOpen: false, bulkProviderSwitchOpen: false, usageModalOpen: false, @@ -258,6 +259,10 @@ export const createUiShellSlice: StateCreator< set({ agentActivityOpen: true }, false, 'uiShell/openAgentActivity'), closeAgentActivity: () => set({ agentActivityOpen: false }, false, 'uiShell/closeAgentActivity'), + openKeyboardShortcuts: () => + set({ keyboardShortcutsOpen: true }, false, 'uiShell/openKeyboardShortcuts'), + closeKeyboardShortcuts: () => + set({ keyboardShortcutsOpen: false }, false, 'uiShell/closeKeyboardShortcuts'), openCloseOldAgents: () => set({ closeOldAgentsOpen: true }, false, 'uiShell/openCloseOldAgents'), closeCloseOldAgents: () => diff --git a/src/renderer/src/app-state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts index 4252e58a..46258137 100644 --- a/src/renderer/src/app-state/uiShell/types.ts +++ b/src/renderer/src/app-state/uiShell/types.ts @@ -217,6 +217,13 @@ export type UiShellState = { * threshold inputs, project scoping, and a computed preview. Keeping the * flags separate lets either surface evolve without inheriting the other * surface's keyboard model or confirmation semantics. */ + /** When true, the read-only Keyboard Shortcuts reference is open. + * + * WHY a separate flag rather than a tab inside the Settings modal: this is + * consulted MID-TASK ("what was the chord for Reader Mode?") and closed two + * seconds later. Routing it through Settings would put a mutable editor with + * live key capture between the user and a one-line answer. */ + keyboardShortcutsOpen: boolean closeOldAgentsOpen: boolean /** When true, the Switch Agents (bulk provider switch) modal is open. * diff --git a/src/renderer/src/app/surfaces/registry.tsx b/src/renderer/src/app/surfaces/registry.tsx index c744e1f1..1945190e 100644 --- a/src/renderer/src/app/surfaces/registry.tsx +++ b/src/renderer/src/app/surfaces/registry.tsx @@ -24,6 +24,7 @@ import { CloseOldAgentsSurface } from '@renderer/features/workspace/surfaces/Clo import { BulkProviderSwitchSurface } from '@renderer/features/workspace/surfaces/BulkProviderSwitchSurface' import { AgentViewModePickerSurface } from '@renderer/features/workspace/surfaces/AgentViewModePickerSurface' import { ColorFlagPickerSurface } from '@renderer/features/workspace/surfaces/ColorFlagPickerSurface' +import { KeyboardShortcutsSurface } from '@renderer/features/settings/surfaces/KeyboardShortcutsSurface' import { RewindToPromptSurface } from '@renderer/features/workspace/surfaces/RewindToPromptSurface' // The surface registry (issue #494). Adding a surface = write a wrapper @@ -61,6 +62,7 @@ export const modalSurfaces: SurfaceEntry[] = [ // Grouping by semantic kind is NOT safe here; group by paint order. { id: 'tiled-dispatch-count', Component: TiledDispatchCountSurface }, { id: 'caffeinate-toast', Component: CaffeinateToastSurface }, + { id: 'keyboard-shortcuts', Component: KeyboardShortcutsSurface }, { id: 'tile-tabs', Component: TileTabsModalSurface }, { id: 'reorder-tabs', Component: ReorderTabsSurface }, { id: 'pin-agents', Component: PinAgentsSurface }, diff --git a/src/renderer/src/features/command-keybindings/defaults.ts b/src/renderer/src/features/command-keybindings/defaults.ts index 47a08eb4..d3d28803 100644 --- a/src/renderer/src/features/command-keybindings/defaults.ts +++ b/src/renderer/src/features/command-keybindings/defaults.ts @@ -232,6 +232,19 @@ export function buildDefaultKeybindings(): CommandBindingDefault[] { { commandId: 'soft-reload-agent', bindings: ['Alt+A'], context: 'global' }, { commandId: 'reload-agent', bindings: ['Alt+Shift+A'], context: 'global' }, { commandId: 'view-prompts', bindings: ['Alt+Shift+P'], context: 'global' }, + + // --- The reference sheet ------------------------------------------------ + // ⌘⇧/ is ⌘? on a US layout, the long-standing platform chord for "show me + // the shortcuts". Slack, GitHub and Gmail all land on ? for the same + // reason, and macOS puts Help search on ⌘⇧/ system-wide. + // + // This one is NOT usage-derived — the command did not exist when the cache + // was recorded. It is bound on a different argument: a shortcut list that + // can only be reached by remembering a shortcut is a joke, and reached by + // opening the palette and typing "keyboard" is nearly as bad, because the + // palette is exactly what a user falls back to WHEN they have forgotten the + // chord. The sheet has to be the one chord worth memorizing. + { commandId: 'open-keyboard-shortcuts', bindings: ['Cmd+Shift+/'], context: 'global' }, ] // Per-provider split chords, derived from the SAME provider identity diff --git a/src/renderer/src/features/command-palette/catalog.test.ts b/src/renderer/src/features/command-palette/catalog.test.ts index fe4ef5d4..7c4109f1 100644 --- a/src/renderer/src/features/command-palette/catalog.test.ts +++ b/src/renderer/src/features/command-palette/catalog.test.ts @@ -9,7 +9,7 @@ import type { CommandDef } from '@renderer/features/command-palette/types' // Phase 0 of the command-governance plan (docs/superpowers/plans/ // 2026-07-23-command-surface-audit.md): CHARACTERIZE THE CURRENT CATALOG. // -// This file pinned the exact 102-id before-state, and now pins the 98-id +// This file pinned the exact 102-id before-state, and now pins the 99-id // after-state: five durable preferences retired to Settings, one approved // addition (open-command-palette). Keeping ONE snapshot that moved — rather // than a "baseline" file and an "after" file — is what makes the plan's @@ -120,8 +120,10 @@ const BASELINE_COMMAND_IDS: readonly string[] = [ 'toggle-spotlight', 'toggle-reader-mode', 'tiled-tabs', - // settingsCommands (3, was 5: worktree-badges + dangerous-agents retired) + // settingsCommands (4, was 5: worktree-badges + dangerous-agents retired, + // open-keyboard-shortcuts added) 'open-settings', + 'open-keyboard-shortcuts', 'toggle-aggressive-debug-persistence', 'toggle-worktrees-bar', // copy-assistant / copy-code-block (2) @@ -168,16 +170,20 @@ const NAVIGATION_COMMAND_GROUP: readonly string[] = [ const ids = (): string[] => builtInCommandCatalog.map(c => c.id) describe('built-in command catalog — baseline characterization', () => { - it('contains exactly the 98 governed commands in registration order', () => { + it('contains exactly the 99 governed commands in registration order', () => { // Order matters: this is the palette's empty-query browse order. expect(ids()).toEqual([...BASELINE_COMMAND_IDS]) }) - it('has exactly 98 commands', () => { - // Stated separately from the order assertion because the plan's headline - // number is the thing later phases move (102 → 98), and a bare count - // failure is a clearer signal than a 102-line array diff. - expect(builtInCommandCatalog).toHaveLength(98) + it('has exactly 99 commands', () => { + // Stated separately from the order assertion because this number is the + // thing that moves, and a bare count failure is a clearer signal than a + // 99-line array diff. + // + // 102 baseline → 98 after governance (5 retirements, 1 addition) → 99 with + // `open-keyboard-shortcuts`. Each step of that arithmetic was a deliberate + // edit to this line, which is the entire point of pinning it. + expect(builtInCommandCatalog).toHaveLength(99) }) it('reports no structural defects', () => { @@ -194,7 +200,7 @@ describe('built-in command catalog — baseline characterization', () => { }) describe('generated per-provider split commands', () => { - // The plan's arithmetic is 98 literal ids + 4 generated = 102. If a provider + // The arithmetic is 95 literal ids + 4 generated = 99. If a provider // is ever added to AGENT_PROVIDER_KINDS, this invariant is what tells the // author that the catalog count moved for a legitimate reason, and forces the // baseline snapshot above to be updated deliberately. @@ -208,10 +214,11 @@ describe('generated per-provider split commands', () => { }) it('accounts for the difference between literal and total command count', () => { - // 98 total - 4 generated = 94 literal `id:` fields across the command - // modules. At the baseline this read 102 - 4 = 98; both numbers moved by - // exactly the five retirements minus the one addition. - expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(94) + // 99 total - 4 generated = 95 literal `id:` fields across the command + // modules. At the original baseline this read 102 - 4 = 98; it moved by the + // five retirements, then by the two additions (open-command-palette, + // open-keyboard-shortcuts). + expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(95) }) it('emits both directions for every non-default provider', () => { @@ -310,10 +317,12 @@ describe('governance targets', () => { }) it('lands on the arithmetic the plan predicted', () => { - // 102 baseline - 5 retirements + 1 addition = 98, checked against the real - // catalog rather than trusted as prose. - expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 1).toBe(102) - expect(builtInCommandCatalog).toHaveLength(98) + // 102 baseline - 5 retirements + 2 additions = 99, checked against the + // real catalog rather than trusted as prose. The additions are + // `open-command-palette` (governance: the palette could not be rebound + // because it had no command id) and `open-keyboard-shortcuts`. + expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 2).toBe(102) + expect(builtInCommandCatalog).toHaveLength(99) }) }) diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index dcd530d3..2894a28a 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -196,6 +196,8 @@ export type CommandContext = { openViewPrompts: (sessionId: string) => void openPromptSearch: () => void openAgentActivity: () => void + /** Open the read-only Keyboard Shortcuts reference. */ + openKeyboardShortcuts: () => void openCloseOldAgents: () => void openBulkProviderSwitch: () => void openRewindPrompt: (sessionId: string) => void diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index e3013ec4..57b944e0 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -253,6 +253,7 @@ function OpenCommandPalette({ const openViewPrompts = useAppStore(state => state.openViewPrompts) const openPromptSearch = useAppStore(state => state.openPromptSearch) const openAgentActivity = useAppStore(state => state.openAgentActivity) + const openKeyboardShortcuts = useAppStore(state => state.openKeyboardShortcuts) const openCloseOldAgents = useAppStore(state => state.openCloseOldAgents) const openBulkProviderSwitch = useAppStore(state => state.openBulkProviderSwitch) const openRewindPrompt = useAppStore(state => state.openRewindPrompt) @@ -568,7 +569,8 @@ function OpenCommandPalette({ openViewPrompts, openPromptSearch, openAgentActivity, - openCloseOldAgents, + openKeyboardShortcuts, + openCloseOldAgents, openBulkProviderSwitch, openRewindPrompt, openAgentViewModePicker, diff --git a/src/renderer/src/features/settings/commands/settingsCommands.ts b/src/renderer/src/features/settings/commands/settingsCommands.ts index 4bbd47b3..b4af157a 100644 --- a/src/renderer/src/features/settings/commands/settingsCommands.ts +++ b/src/renderer/src/features/settings/commands/settingsCommands.ts @@ -10,6 +10,41 @@ export const settingsCommands: CommandDef[] = [ description: '**What it does:** Opens **Settings**.\n\n**Use when:** You want to change app preferences.\n\n**Notes:** Includes appearance, workspace, dictation, experimental, and safety settings.', run: ({ ui }) => ui.openSettings(), }, + { + // The reference sheet, deliberately SEPARATE from Settings → Keybindings. + // + // Settings answers "how do I change this chord". This answers "what was + // the chord" — asked mid-task, by someone who is not trying to change + // anything, and who would be worse off landing on a screen with live key + // capture that can rebind something if they fumble a keystroke. + // + // `category: 'preferences'` beside Open Settings, because that is the + // drawer people open when they are thinking about configuration. It is + // NOT 'developer' — forgetting a shortcut is the most ordinary thing a + // user does, and burying it in the debug tier would defeat the point. + id: 'open-keyboard-shortcuts', + category: 'preferences', + surface: 'app', + title: 'Keyboard Shortcuts', + description: '**What it does:** Opens a searchable list of **every keyboard shortcut** currently bound.\n\n**Use when:** You cannot remember a chord — or cannot remember what a chord does.\n\n**Notes:** Read-only, and shows YOUR bindings including any you customized. Change them in Settings → Keybindings.', + keywords: [ + 'keyboard', + 'shortcuts', + 'shortcut', + 'keybind', + 'keybinds', + 'keybinding', + 'keybindings', + 'chord', + 'hotkey', + 'hotkeys', + 'keys', + 'cheat sheet', + 'reference', + 'help', + ], + run: ({ ui }) => ui.openKeyboardShortcuts(), + }, { // Persistent Aggressive Debug Logs — developer-mode switch for // interval snapshots. This intentionally reuses the Save Debug diff --git a/src/renderer/src/features/settings/surfaces/KeyboardShortcutsSurface.tsx b/src/renderer/src/features/settings/surfaces/KeyboardShortcutsSurface.tsx new file mode 100644 index 00000000..05f9d507 --- /dev/null +++ b/src/renderer/src/features/settings/surfaces/KeyboardShortcutsSurface.tsx @@ -0,0 +1,8 @@ +import { KeyboardShortcutsModal } from '@renderer/features/settings/ui/KeyboardShortcutsModal' +import { useAppStore } from '@renderer/app-state/hooks' + +export function KeyboardShortcutsSurface() { + const open = useAppStore(state => state.keyboardShortcutsOpen) + const close = useAppStore(state => state.closeKeyboardShortcuts) + return +} diff --git a/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx b/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx new file mode 100644 index 00000000..a94adc3b --- /dev/null +++ b/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx @@ -0,0 +1,245 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +import { useAppStore } from '@renderer/app-state/hooks' +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' +import { displayKeybinding } from '@renderer/features/command-keybindings/normalize' +import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' +import type { Keybinding } from '@renderer/features/command-keybindings/normalize' +import type { BindingContext } from '@renderer/features/command-keybindings/defaults' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@renderer/components/ui/dialog' +import type { CommandCategory } from '@renderer/features/command-palette/types' + +// --------------------------------------------------------------------------- +// The keyboard shortcut REFERENCE — read-only, for the moment you cannot +// remember a chord. +// +// WHY this is not the Settings keybinding editor. That screen exists to CHANGE +// a binding: it lists all ~98 commands including the ~73 with no chord at all, +// it has capture state, conflict resolution and per-row reset, and it lives +// several clicks deep behind ⌘,. Every one of those properties is wrong for +// "what was the chord for Reader Mode again?" — the answer is buried among +// dozens of rows that have no answer, on a screen that can silently rebind +// something if you fumble a keystroke while it is capturing. +// +// So this shows ONLY commands that have a binding, sorted for scanning, with +// no capture and nothing mutable. It is the thing a user opens mid-task and +// closes two seconds later. +// +// It reads the same `resolveEffectiveKeybindings` the router does, so a user +// who rebound something sees THEIR chord, not the shipped one. A cheat sheet +// that prints defaults would be worse than none — it would be confidently +// wrong for exactly the people who customized, who are the people most likely +// to have forgotten. +// --------------------------------------------------------------------------- + +type Props = { + open: boolean + onClose: () => void +} + +type ShortcutRow = { + id: string + title: string + category: CommandCategory + bindings: readonly Keybinding[] + context: BindingContext + keywords: string[] + customized: boolean +} + +const CATEGORY_LABELS: Record = { + create: 'Create', + navigate: 'Navigate', + session: 'Session', + 'layout-dispatch': 'Layout & Dispatch', + 'editor-files': 'Editor & Files', + 'workspace-tools': 'Workspace Tools', + preferences: 'Preferences', + developer: 'Developer', +} + +/** Display order. Exhaustive by type for the same reason the Settings row is: + * a category missing from a hand-listed array would silently drop every + * shortcut in it, and a reference with a silent hole is worse than no + * reference — the user concludes the chord does not exist. */ +const CATEGORY_RANK: Record = { + navigate: 0, + create: 1, + session: 2, + 'layout-dispatch': 3, + 'editor-files': 4, + 'workspace-tools': 5, + preferences: 6, + developer: 7, +} + +/** + * What a context means to a reader. + * + * The stored value is a machine word ('grid', 'dispatch'); showing it raw would + * make the user guess. `global` deliberately renders as nothing at all — most + * rows are global, and a badge on almost every row carries no information while + * costing scan time. The badge exists to mark the EXCEPTIONS. + */ +const CONTEXT_LABELS: Record = { + global: null, + grid: 'Grid only', + dispatch: 'Dispatch only', + editor: 'Editor only', + feed: 'Feed only', +} + +export function KeyboardShortcutsModal({ open, onClose }: Props) { + const overrides = useAppStore(state => state.settings.commandKeybindingOverrides) + const [query, setQuery] = useState('') + const inputRef = useRef(null) + + // Reset the filter on every open. A stale query from last time would present + // as "most of my shortcuts are missing", which is the one failure this + // surface cannot afford — it is consulted precisely when the user is already + // unsure what exists. + useEffect(() => { + if (!open) return + setQuery('') + requestAnimationFrame(() => inputRef.current?.focus()) + }, [open]) + + const rows = useMemo(() => { + const defaults = buildDefaultKeybindings() + const contextById = new Map(defaults.map(d => [d.commandId, d.context])) + const effective = new Map( + resolveEffectiveKeybindings(overrides, defaults).map(e => [e.commandId, e.bindings]), + ) + const byId = new Map(builtInCommandCatalog.map(c => [c.id, c])) + + const out: ShortcutRow[] = [] + for (const [commandId, bindings] of effective) { + // Only bound commands. The ~73 unbound ones are what Settings is for; + // listing them here would bury the answer among non-answers. + if (bindings.length === 0) continue + const command = byId.get(commandId) + if (!command || !command.category) continue + out.push({ + id: commandId, + // A function title needs a CommandContext to resolve, and this modal + // deliberately has none — it is a reference, not a live view of the + // workspace. Falling back to the id keeps the row present and + // identifiable rather than blank. + title: typeof command.title === 'function' ? commandId : command.title, + category: command.category, + bindings, + context: contextById.get(commandId) ?? 'global', + keywords: command.keywords ?? [], + customized: overrides[commandId] !== undefined, + }) + } + return out + }, [overrides]) + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase() + if (!needle) return rows + return rows.filter(row => { + // Chord text is searchable too, and it matters more than it looks: the + // other half of "I forgot the shortcut" is "what does ⌥R do again?". + // Matching on the DISPLAY form means typing what is printed on the row + // finds it, rather than requiring the internal 'Alt+R' spelling. + const haystack = [ + row.title, + row.id, + CATEGORY_LABELS[row.category], + ...row.keywords, + ...row.bindings, + ...row.bindings.map(displayKeybinding), + ].join(' ').toLowerCase() + return haystack.includes(needle) + }) + }, [rows, query]) + + const grouped = useMemo(() => { + const byCategory = new Map() + for (const row of filtered) { + const list = byCategory.get(row.category) ?? [] + list.push(row) + byCategory.set(row.category, list) + } + return (Object.keys(CATEGORY_RANK) as CommandCategory[]) + .sort((a, b) => CATEGORY_RANK[a] - CATEGORY_RANK[b]) + .map(category => ({ + category, + rows: (byCategory.get(category) ?? []).sort((a, b) => a.title.localeCompare(b.title)), + })) + .filter(group => group.rows.length > 0) + }, [filtered]) + + return ( + { if (!next) onClose() }}> + +
+ Keyboard Shortcuts + + {rows.length} bound command{rows.length === 1 ? '' : 's'}. Change any of them in + Settings → Keybindings. + + setQuery(event.target.value)} + placeholder="Search by command or chord…" + className="mt-2 w-full border border-border bg-input-bg px-2 py-1 text-xs text-ink outline-none focus:border-accent" + /> +
+ +
+ {grouped.length === 0 ? ( +
+ No shortcut matches “{query}”. +
+ ) : ( + grouped.map(group => ( +
+
+ {CATEGORY_LABELS[group.category]} +
+ {group.rows.map(row => ( +
+
+ {row.title} + {CONTEXT_LABELS[row.context] ? ( + + {CONTEXT_LABELS[row.context]} + + ) : null} + {row.customized ? ( + customized + ) : null} +
+
+ {row.bindings.map(binding => ( + + {displayKeybinding(binding)} + + ))} +
+
+ ))} +
+ )) + )} +
+
+
+ ) +}