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 ceb77602..d3d28803 100644 --- a/src/renderer/src/features/command-keybindings/defaults.ts +++ b/src/renderer/src/features/command-keybindings/defaults.ts @@ -130,9 +130,121 @@ 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' }, + + // --- 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)} + + ))} +
+
+ ))} +
+ )) + )} +
+
+
+ ) +}