Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/renderer/src/app-state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export type UiShellSlice = UiShellState & {
closePromptSearch: () => void
openAgentActivity: () => void
closeAgentActivity: () => void
openKeyboardShortcuts: () => void
closeKeyboardShortcuts: () => void
openCloseOldAgents: () => void
closeCloseOldAgents: () => void
openBulkProviderSwitch: () => void
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/src/app-state/uiShell/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const createUiShellSlice: StateCreator<
globalEditorOpen: false,
promptSearchOpen: false,
agentActivityOpen: false,
keyboardShortcutsOpen: false,
closeOldAgentsOpen: false,
bulkProviderSwitchOpen: false,
usageModalOpen: false,
Expand Down Expand Up @@ -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: () =>
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/src/app-state/uiShell/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/app/surfaces/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand Down
116 changes: 114 additions & 2 deletions src/renderer/src/features/command-keybindings/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 26 additions & 17 deletions src/renderer/src/features/command-palette/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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.
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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)
})
})

Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/features/command-palette/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -568,7 +569,8 @@ function OpenCommandPalette({
openViewPrompts,
openPromptSearch,
openAgentActivity,
openCloseOldAgents,
openKeyboardShortcuts,
openCloseOldAgents,
openBulkProviderSwitch,
openRewindPrompt,
openAgentViewModePicker,
Expand Down
35 changes: 35 additions & 0 deletions src/renderer/src/features/settings/commands/settingsCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <KeyboardShortcutsModal open={open} onClose={close} />
}
Loading