From 75032d94d6ccb6eef8693b74c043c2f96828458b Mon Sep 17 00:00:00 2001 From: Sven Malvik Date: Sun, 14 Jun 2026 00:13:15 +0200 Subject: [PATCH 1/2] docs(commands): design spec for keyboard command registry (#721) Co-Authored-By: Claude Opus 4.8 --- ...-06-14-keyboard-command-registry-design.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-keyboard-command-registry-design.md diff --git a/docs/superpowers/specs/2026-06-14-keyboard-command-registry-design.md b/docs/superpowers/specs/2026-06-14-keyboard-command-registry-design.md new file mode 100644 index 00000000..b485b3d0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-keyboard-command-registry-design.md @@ -0,0 +1,186 @@ +# Keyboard command registry + command palette + shortcuts cheat-sheet + +Design for the first deliverable of issue #721 ("Add keyboard shortcuts for almost +every feature"). This is the **foundation** PR. Per-feature long-tail coverage and +user-customizable keybindings are explicitly deferred to follow-up PRs. + +## Goal + +A single source of truth for `command → keybinding`, consumed by **both** the native +menu and the renderer, plus two discoverability surfaces: + +1. A **command palette** (`Cmd+Shift+P`) that lists and runs *actions* (distinct from + the `Cmd+P` file Quick Open). +2. A **shortcuts cheat-sheet** overlay (`Cmd+Shift+/`) and a read-only **Settings tab** + listing the active bindings. + +Today every shortcut is wired in three places (menu accelerator in `app-menu.ts`, a +preload channel whitelist, and an ad-hoc `window.electronAPI.on(...)` handler). This +collapses that to *one catalog entry + one handler-map entry*. + +## Architecture (Approach A — catalog-driven native menu + one IPC channel) + +``` +src/shared/commands/catalog.ts COMMANDS: CommandDef[] (pure data, no run()) + └─ imported by ─┐ + main: app-menu.ts │ builds menu items from COMMANDS; click → send('command:run', id) + renderer: useCommands.ts ┘ Mapvoid>; on('command:run') → runCommand(id) + renderer: CommandPalette / ShortcutsCheatSheet / Settings tab read COMMANDS for display +``` + +- The command **metadata** (id, title, category, accelerator, menu placement) is static + data in `src/shared/commands/` — importable by main, renderer, and tests. +- The **`run()` logic lives only in the renderer**, because every action calls a function + that already exists in `App.tsx`'s assembled state (`dockState`, `overlays`, + `appEffects`, `dockLayout`, `toggleTheme`, `jumpToFavorite`, …). We do **not** add new + feature logic; we wire existing functions. +- **Dispatch is uniform:** the native menu owns the accelerator. Pressing the key (or + clicking the menu item) fires the menu item's `click` → `send('command:run', id)`. The + renderer's `useCommands` receives it and runs the handler. The command palette calls the + same `runCommand(id)` directly. There is **no renderer global-keydown dispatcher** — we + rely on native menu accelerators (which already work reliably over the embedded + xterm/Monaco, as the current `Cmd+Alt+1..6` prove). This is the lowest-risk path. + +### Why no renderer keydown dispatcher + +Native menu accelerators intercept globally *before* the focused web content (Monaco, +xterm). That is a feature here (app shortcuts should work even with the terminal focused) +but it means **every bound key is stolen from the editor/terminal**. Consequences for the +keymap (see de-confliction below): we must avoid keys Monaco needs (`Cmd+/` comment +toggle, `Cmd+Shift+O` go-to-symbol, `Ctrl+G` go-to-line) and the standard Electron roles. + +## Command catalog + +`CommandDef`: + +```ts +type CommandCategory = 'General' | 'Agents' | 'Source Control' | 'View' | 'Navigation' | 'Help' +type MenuSectionId = 'manifold' | 'edit' | 'view' | 'go' | 'agent' | 'scm' | 'help' + +interface CommandDef { + id: CommandId // stable string union, e.g. 'agents.next' + title: string // shown in menu, palette, cheat-sheet + category: CommandCategory // grouping in palette + cheat-sheet + accelerator?: string // Electron format, e.g. 'CmdOrCtrl+Shift+P'; omitted = palette/menu-only + menu?: { section: MenuSectionId; order: number } // omitted = palette-only (none in this PR) +} +``` + +A command without an `accelerator` is still fully keyboard-reachable through the palette +(type → Enter) — matching VS Code's model where high-frequency actions get a direct key +and the long tail lives in the palette. + +### Starter keymap (this PR) + +Existing bindings (migrated onto the catalog — behavior unchanged): + +| Command | id | Accel | Menu | Handler | +|---|---|---|---|---| +| Settings… | `general.settings` | `Cmd+,` | Manifold | `overlays.setShowSettings(true)` | +| Find in Files | `navigation.findInFiles` | `Cmd+Shift+F` | Edit | `appEffects.focusSearch('code')` | +| Toggle Projects/Agent/Editor/Files/Modified Files/Shell | `view.toggle.*` | `Cmd+Alt+1..6` | View | `dockLayout.togglePanel(id)` | +| Jump to Favorite 1..9 | `navigation.favorite.{1..9}` | `Cmd+1..9` | Go | `jumpToFavorite(i)` | +| Quick Open File… | `navigation.quickOpenFile` | `Cmd+P` | Go | `openQuickOpen()` (guards preserved) | +| About Manifold | `help.about` | — | Manifold | `overlays.setShowAbout(true)` | + +New commands (reuse existing functions only): + +| Command | id | Accel | Menu | Handler | +|---|---|---|---|---| +| Command Palette… | `general.commandPalette` | `Cmd+Shift+P` | Go | `overlays.setShowCommandPalette(true)` | +| Keyboard Shortcuts | `help.shortcuts` | `Cmd+Shift+/` | Help | `overlays.setShowShortcuts(true)` | +| New Agent | `agents.new` | `Cmd+N` | Agent | `overlays.handleNewAgentFromHeader()` | +| Next Agent | `agents.next` | `Cmd+Shift+]` | Agent | `cycleAgent(+1)` → `handleSelectSession` | +| Previous Agent | `agents.previous` | `Cmd+Shift+[` | Agent | `cycleAgent(-1)` → `handleSelectSession` | +| Delete Agent… | `agents.delete` | — | Agent | `requestDeleteAgent(activeSession, path)` | +| Commit… | `scm.commit` | `Cmd+Shift+C` | Source Control | `overlays.setActivePanel('commit')` | +| Create Pull Request… | `scm.createPR` | — | Source Control | `overlays.setActivePanel('pr')` | +| Focus Chat | `view.focusChat` | — | View | `onOpenModule('agent')` | +| Focus Terminal | `view.focusTerminal` | `Ctrl+`` ` `` | View | `onOpenModule('shell')` | +| Focus File Tree | `view.focusFiles` | `Cmd+Shift+E` | View | `onOpenModule('fileTree')` | +| Toggle Theme | `view.toggleTheme` | — | View | `toggleTheme()` | + +Context-insensitive commands (e.g. Next Agent with no project, Commit on a non-git +project) **no-op in their handler** rather than dynamically disabling menu items — far +simpler than rebuilding the native menu on every state change, and acceptable for v1. + +### De-confliction + +Chosen keys avoid: Electron roles (`Cmd+R` reload, `Cmd+Shift+R` force-reload, +`Ctrl+Cmd+F` fullscreen, `Cmd+0/=/-` zoom, `Cmd+W/M/H/Q`), Monaco bindings (`Cmd+/`, +`Cmd+Shift+O`, `Ctrl+G`), and all existing app bindings. **Note:** the issue suggested +`Cmd+/` for the cheat-sheet; we use `Cmd+Shift+/` (the macOS Help convention, `Cmd+?`) +because a native `Cmd+/` accelerator would globally steal Monaco's comment-toggle. + +### Deferred to follow-up PRs + +Rename agent (needs an inline prompt), interrupt/stop agent (no existing renderer +function), editor tab next/prev/close (`Cmd+W` collision + editor-focus gating), +switch repo/workspace by key, in-pane search, and **user-customizable keybindings**. +The Settings tab is read-only in this PR (the comment on the issue notes editing is a +later goal). + +## Components & files + +New: + +- `src/shared/commands/catalog.ts` — `CommandId`, `CommandDef`, `COMMANDS`, `CommandCategory`, `MenuSectionId`. +- `src/shared/commands/accelerator-label.ts` — `formatAccelerator('CmdOrCtrl+Shift+P') → '⌘⇧P'` (pure). +- `src/renderer/commands/command-handlers.ts` — `createCommandHandlers(ctx): Record void>` (pure-ish; testable with a mock ctx). +- `src/renderer/commands/agent-cycle.ts` — `cycleAgent(sessions, activeId, dir): AgentSession | null` (pure). +- `src/renderer/hooks/useCommands.ts` — memoizes handlers, subscribes `command:run`, returns `runCommand`. +- `src/renderer/components/command-palette/CommandPalette.tsx` — palette UI (reuses `createDialogStyles` + `fuzzyFilter`). +- `src/renderer/components/command-palette/ShortcutList.tsx` — shared grouped list of commands + formatted accelerators. +- `src/renderer/components/command-palette/ShortcutsCheatSheet.tsx` — overlay wrapper around `ShortcutList`. +- `src/renderer/components/modals/settings/ShortcutsSettingsSection.tsx` — Settings tab wrapper around `ShortcutList`. + +Modified: + +- `src/main/app/app-menu.ts` — generate accelerator menu items from `COMMANDS` grouped by `menu.section`; click → `send('command:run', id)`. Keep bespoke items (What's New, Check for Updates, Keep Mac Awake) and standard roles. +- `src/preload/index.ts` — add `command:run` to `ALLOWED_LISTEN_CHANNELS`; remove the now-unused migrated channels (`show-settings`, `show-about`, `view:toggle-panel`, `view:show-search`, `view:jump-favorite`). +- `src/renderer/hooks/useAppEffects.ts` — drop the three migrated `on(...)` effects; keep `focusSearch` (still used elsewhere). +- `src/renderer/hooks/useAppOverlays.ts` — drop the two migrated `on(...)` effects; add `showCommandPalette` / `showShortcuts` state + setters. +- `src/renderer/App.tsx` — remove the bespoke `Cmd+P` keydown; add `openQuickOpen()` (with the existing guards); build a `commandContext`; call `useCommands`; pass `runCommand` + palette/cheat-sheet visibility to `AppShell`. +- `src/renderer/AppShell.tsx` — mount `` and ``. +- `src/renderer/components/modals/settings/SettingsModalBody.tsx` — add the `shortcuts` tab. +- `docs/architecture/app.md` — document the catalog-driven menu (covers `src/main/app`); bump `updated:`. + +Every new/modified file stays under the 300-LOC project limit. + +## Data flow + +``` +key press / menu click + → Electron menu item click (main, app-menu.ts) + → webContents.send('command:run', id) + → preload (whitelisted) → window.electronAPI.on('command:run') (renderer, useCommands) + → runCommand(id) → handlers[id]() → existing App.tsx function + +command palette Enter + → CommandPalette onRun(id) → runCommand(id) → handlers[id]() +``` + +## Error handling + +- `runCommand(unknownId)` logs a `console.warn` and no-ops (forward-compat if main and + renderer catalogs ever skew across an update). +- Handlers that need context (active session/project) guard internally and no-op. +- `openQuickOpen()` preserves today's guards: no-op when there's no session or when an + `[role=dialog][aria-modal=true]` already owns the screen. + +## Testing (TDD) + +Pure units, written first: + +- `accelerator-label.test.ts` — symbol mapping (`Cmd`,`Shift`,`Alt`,`Ctrl`,`Enter`,arrows), ordering, `CmdOrCtrl`→`⌘`. +- `agent-cycle.test.ts` — forward/back wraparound, null active → first, single session, empty list. +- `catalog.test.ts` — ids unique; **no two commands share an accelerator**; every command has title+category; menu sections valid. +- `command-handlers.test.ts` — `createCommandHandlers(mockCtx)` maps each id to the right ctx call (e.g. `agents.next` → `ctx.nextAgent`); unknown ids absent. +- `app-menu.test.ts` (extend) — every `COMMANDS` accelerator appears in the built template; clicking a catalog item sends `command:run` with its id; destroyed-window guard still holds. + +Component test: + +- `CommandPalette.test.tsx` — renders commands, filters on type, `Enter` calls `onRun` with the highlighted id, `Escape` closes. + +Verification gate: `npm run typecheck:web`, `npm run typecheck:node`, the new + existing +vitest suites, and `bash scripts/wiki-lint.sh`. From 6fff9da5c8e773598e1a5fa3566111ea06d9f3f4 Mon Sep 17 00:00:00 2001 From: Sven Malvik Date: Sun, 14 Jun 2026 00:29:56 +0200 Subject: [PATCH 2/2] feat(commands): central command registry, palette & shortcuts cheat-sheet (#721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a single source of truth for command → keybinding, consumed by both the native menu and the renderer, replacing the scattered menu accelerators + ad-hoc keydown handlers + per-channel IPC. - src/shared/commands/catalog.ts: the command catalog (id, title, category, accelerator, menu placement) — pure data shared by main and renderer. - app-menu.ts generates every accelerator menu item from the catalog; each click fires one unified command:run IPC channel. - useCommands dispatches command:run via a tested id→handler map wired to the existing App.tsx functions; unknown ids no-op with a warning. - Cmd+Shift+P command palette (fuzzy) and Cmd+Shift+/ shortcuts cheat-sheet, plus a read-only Settings → Shortcuts tab. - Migrate existing bindings (Settings, Find in Files, panel toggles, favorites, Quick Open) onto the catalog and add new commands: new/next/prev/delete agent, commit, create PR, focus chat/terminal/files, toggle theme. Keys de-conflicted against Electron roles, Monaco (Cmd+/, Cmd+Shift+O, Ctrl+G) and macOS conventions. Cmd+/ avoided (Monaco comment-toggle); cheat-sheet uses Cmd+Shift+/. Editor tab nav, rename/interrupt agent and custom keybindings are deferred to follow-ups per the design spec. Docs: update app.md and preload.md for the catalog-driven menu + command:run. Co-Authored-By: Claude Opus 4.8 --- docs/architecture/app.md | 22 ++-- docs/architecture/preload.md | 6 +- src/main/app/app-menu.test.ts | 35 +++++- src/main/app/app-menu.ts | 84 ++++--------- src/preload/index.ts | 6 +- src/renderer/App.tsx | 66 ++++++++--- src/renderer/AppShell.test.tsx | 1 + src/renderer/AppShell.tsx | 6 + src/renderer/commands/agent-cycle.test.ts | 39 +++++++ src/renderer/commands/agent-cycle.ts | 17 +++ .../commands/command-handlers.test.ts | 69 +++++++++++ src/renderer/commands/command-handlers.ts | 73 ++++++++++++ .../command-palette/CommandPalette.test.tsx | 40 +++++++ .../command-palette/CommandPalette.tsx | 110 ++++++++++++++++++ .../command-palette/ShortcutList.tsx | 38 ++++++ .../command-palette/ShortcutsCheatSheet.tsx | 38 ++++++ .../modals/settings/SettingsModalBody.tsx | 5 +- .../settings/ShortcutsSettingsSection.tsx | 19 +++ src/renderer/hooks/useAppEffects.test.ts | 11 -- src/renderer/hooks/useAppEffects.ts | 15 +-- src/renderer/hooks/useAppOverlays.ts | 22 ++-- src/renderer/hooks/useCommands.ts | 33 ++++++ src/shared/commands/accelerator-label.test.ts | 29 +++++ src/shared/commands/accelerator-label.ts | 52 +++++++++ src/shared/commands/catalog.test.ts | 36 ++++++ src/shared/commands/catalog.ts | 94 +++++++++++++++ 26 files changed, 833 insertions(+), 133 deletions(-) create mode 100644 src/renderer/commands/agent-cycle.test.ts create mode 100644 src/renderer/commands/agent-cycle.ts create mode 100644 src/renderer/commands/command-handlers.test.ts create mode 100644 src/renderer/commands/command-handlers.ts create mode 100644 src/renderer/components/command-palette/CommandPalette.test.tsx create mode 100644 src/renderer/components/command-palette/CommandPalette.tsx create mode 100644 src/renderer/components/command-palette/ShortcutList.tsx create mode 100644 src/renderer/components/command-palette/ShortcutsCheatSheet.tsx create mode 100644 src/renderer/components/modals/settings/ShortcutsSettingsSection.tsx create mode 100644 src/renderer/hooks/useCommands.ts create mode 100644 src/shared/commands/accelerator-label.test.ts create mode 100644 src/shared/commands/accelerator-label.ts create mode 100644 src/shared/commands/catalog.test.ts create mode 100644 src/shared/commands/catalog.ts diff --git a/docs/architecture/app.md b/docs/architecture/app.md index c68bc319..415fa427 100644 --- a/docs/architecture/app.md +++ b/docs/architecture/app.md @@ -1,7 +1,7 @@ --- description: How the Electron main process boots — shell PATH, dev profile, module wiring, app lifecycle, window creation, menus, auto-updater, mode switching, and the live-preview dev server. covers: [src/main/app] -updated: 2026-06-12 +updated: 2026-06-14 owner: see .github/CODEOWNERS --- @@ -21,7 +21,7 @@ dev-server manager that powers live preview of generated (simple-mode) apps. - `src/main/app/index.ts` — process entry. Runs side-effect fixups at import time, instantiates all managers, builds `ipcDeps`, and calls `registerAppLifecycle`. - `src/main/app/app-lifecycle.ts` — `registerAppLifecycle()`: `whenReady` → renderer server + window + updater; `activate`/`window-all-closed`/`before-quit` handlers. - `src/main/app/window-factory.ts` — `createWindow()` / `rebuildAppMenu()`: the `BrowserWindow`, webview hardening, renderer loading, one-time IPC registration. -- `src/main/app/app-menu.ts` — `buildAppMenu()`: the macOS application menu; every custom item is an IPC `send` to the renderer. +- `src/main/app/app-menu.ts` — `buildAppMenu()`: the macOS application menu, generated from the shared command catalog (`src/shared/commands/catalog.ts`); catalog items fire one `command:run` IPC, bespoke items keep their own channels. - `src/main/app/ipc-handlers.ts` — `registerIpcHandlers()`: fans out to every `register*Handlers(deps)` module plus a handful of inline `app:*`/`updater:*`/`release-notes:*`/`font:*` handlers. - `src/main/app/auto-updater.ts` — `setupAutoUpdater()`, `checkForUpdates()`, release-notes fetch/caching; wraps `electron-updater`. - `src/main/app/dev-server-manager.ts` — `DevServerManager`: simple-mode dev server lifecycle, print-mode follow-up turns, and slash-command probing. @@ -78,13 +78,17 @@ from `ELECTRON_RENDERER_URL` (dev: electron-vite; prod: the loopback server), fa diverted to the system browser via `setWindowOpenHandler` + `will-navigate`. The application menu is set last. -**App menu.** `buildAppMenu()` (`app-menu.ts:8`) is almost entirely a router: every custom -item (`About`, `What's New`, `Settings…`, panel toggles, `Find in Files`, `Jump to Favorite N`) -sends to a renderer channel via a local `send` helper that guards with `isDestroyed()` — the -captured window survives a macOS Cmd+W as a destroyed (non-null) object, so optional chaining -alone would still throw on `.webContents`; the rest are built-in roles. -The only stateful item is the `Keep Mac Awake` checkbox, whose `checked` state is passed in and -re-rendered via `rebuildAppMenu()` when toggled (`index.ts:143`). +**App menu.** `buildAppMenu()` (`app-menu.ts:9`) is almost entirely a router built from the +shared **command catalog** (`src/shared/commands/catalog.ts`) — the single source of truth for +`command → keybinding` shared with the renderer. `commandItems(section)` (`app-menu.ts:19`) +turns every catalog command tagged for that menu section into an item whose `accelerator` is the +catalog `accelerator` and whose click fires the one unified `command:run` IPC channel with the +command id; the renderer's `useCommands` hook dispatches it (see `src/renderer/commands/`). A few +bespoke items (`What's New`, `Check for Updates`, the stateful `Keep Mac Awake` checkbox) keep +their own channels, and the rest are built-in roles. The local `send` helper guards with +`isDestroyed()` — the captured window survives a macOS Cmd+W as a destroyed (non-null) object, so +optional chaining alone would still throw on `.webContents`. `Keep Mac Awake`'s `checked` state is +passed in and re-rendered via `rebuildAppMenu()` when toggled (`index.ts:143`). **Live preview / simple mode.** `DevServerManager` (`dev-server-manager.ts:16`) backs simple-mode "apps". `startDevServerSession()` evicts any existing sessions for the project diff --git a/docs/architecture/preload.md b/docs/architecture/preload.md index 8caa93a9..f13a54d0 100644 --- a/docs/architecture/preload.md +++ b/docs/architecture/preload.md @@ -1,7 +1,7 @@ --- description: The contextBridge preload that exposes a single whitelisted window.electronAPI surface to the renderer and keeps Node/fs out of the web context. covers: [src/preload] -updated: 2026-06-12 +updated: 2026-06-14 owner: see .github/CODEOWNERS --- @@ -29,7 +29,7 @@ The module imports only `contextBridge`, `ipcRenderer`, and `webUtils` from `ele - `ALLOWED_INVOKE_CHANNELS` (`src/preload/index.ts:3`) — 135 request/response channels, the `:` names the main-process IPC handlers register (`projects:*`, `agent:*`, `files:*`, `diff:*`, `git:*`, `settings:*`, `memory:*`, `search:*`, `workspace:*`, `simple:*`, `plugins:*`, and more). - `ALLOWED_SEND_CHANNELS` (`src/preload/index.ts:140`) — exactly one fire-and-forget channel, `theme:changed`. -- `ALLOWED_LISTEN_CHANNELS` (`src/preload/index.ts:144`) — 31 main → renderer push channels (`agent:output`, `agent:status`, `agent:sessions-changed`, `files:changed`, `settings:changed`, `updater:status`, `plugins:webview-*`, `simple:chat-message`, etc.). +- `ALLOWED_LISTEN_CHANNELS` (`src/preload/index.ts:144`) — 27 main → renderer push channels (`agent:output`, `agent:status`, `agent:sessions-changed`, `files:changed`, `settings:changed`, `updater:status`, `command:run`, `plugins:webview-*`, `simple:chat-message`, etc.). `command:run` is the single channel the native menu uses to invoke any command in the shared catalog (`src/shared/commands/catalog.ts`); the renderer's `useCommands` hook dispatches it. Each array is paired with a TypeScript literal-union type derived from it (`InvokeChannel`/`SendChannel`/`ListenChannel`, `src/preload/index.ts:178`) and a @@ -72,7 +72,7 @@ non-localhost `src` (`src/main/app/window-factory.ts:77`). - **Renderer** (`src/renderer`): every call into main goes through `window.electronAPI.invoke(...)`, every subscription through `window.electronAPI.on(...)` (e.g. `App.tsx:144` invokes `git:ahead-behind`; `PluginViewPanel.tsx:31` listens on `plugins:webview-html`). Renderer tests stub `window.electronAPI` directly. - **Main IPC handlers** (`src/main/ipc/*`): the other end of every `invoke` channel. `ipcMain.handle('files:read', …)`, `agent:spawn` → `SessionManager.createSession`, etc. The whitelist names must match the handler registrations one-for-one. -- **Main → renderer pushes**: handlers and managers call `webContents.send('agent:output', …)`, `webContents.send('settings:changed', …)`, `app-menu.ts` sends `show-about`/`show-settings`, etc. Those channels must appear in `ALLOWED_LISTEN_CHANNELS` or the renderer's `on` silently ignores them. +- **Main → renderer pushes**: handlers and managers call `webContents.send('agent:output', …)`, `webContents.send('settings:changed', …)`, `app-menu.ts` sends `command:run` for catalog commands (plus bespoke `show-update-log`/`show-update-check`), etc. Those channels must appear in `ALLOWED_LISTEN_CHANNELS` or the renderer's `on` silently ignores them. - **Window factory** (`src/main/app/window-factory.ts:67`): sets `preload`, `contextIsolation`, `nodeIntegration` — the configuration that makes this bridge the *only* path between the two worlds. - **Session subsystem** (`docs/architecture/session.md`): the `agent:*` invoke channels and the `agent:output`/`agent:status`/`agent:sessions-changed` listen channels are how the renderer drives and observes agent sessions. diff --git a/src/main/app/app-menu.test.ts b/src/main/app/app-menu.test.ts index 9c81dc15..a7dc2f98 100644 --- a/src/main/app/app-menu.test.ts +++ b/src/main/app/app-menu.test.ts @@ -13,6 +13,7 @@ vi.mock('electron', () => ({ })) import { buildAppMenu } from './app-menu' +import { COMMANDS } from '../../shared/commands/catalog' type Item = Electron.MenuItemConstructorOptions function flatten(items: Item[]): Item[] { @@ -24,6 +25,10 @@ function flatten(items: Item[]): Item[] { return out } +function findByAccelerator(accelerator: string): Item | undefined { + return flatten(lastTemplate).find((item) => item.accelerator === accelerator) +} + function clickAll(): void { for (const item of flatten(lastTemplate)) { if (typeof item.click === 'function') { @@ -49,8 +54,11 @@ describe('buildAppMenu', () => { const { win, send } = makeWindow(false) buildAppMenu(win, options) clickAll() - expect(send).toHaveBeenCalledWith('show-about') - expect(send).toHaveBeenCalledWith('show-settings') + // Catalog commands fire the unified command:run channel… + expect(send).toHaveBeenCalledWith('command:run', 'general.settings') + expect(send).toHaveBeenCalledWith('command:run', 'help.about') + // …while bespoke items keep their own channels. + expect(send).toHaveBeenCalledWith('show-update-log') expect(send.mock.calls.length).toBeGreaterThan(0) }) @@ -60,4 +68,27 @@ describe('buildAppMenu', () => { expect(() => clickAll()).not.toThrow() expect(send).not.toHaveBeenCalled() }) + + it('binds every catalog accelerator in the menu template', () => { + const { win } = makeWindow(false) + buildAppMenu(win, options) + const present = new Set(flatten(lastTemplate).map((i) => i.accelerator).filter(Boolean)) + for (const command of COMMANDS) { + if (command.accelerator) expect(present.has(command.accelerator)).toBe(true) + } + }) + + it('routes each catalog menu item to command:run with its id', () => { + const { win, send } = makeWindow(false) + buildAppMenu(win, options) + for (const command of COMMANDS) { + if (!command.accelerator || !command.menu) continue + const item = findByAccelerator(command.accelerator) + expect(item, `menu item for ${command.id}`).toBeDefined() + send.mockClear() + // @ts-expect-error click signature is broad; we only exercise the closure. + item?.click?.() + expect(send).toHaveBeenCalledWith('command:run', command.id) + } + }) }) diff --git a/src/main/app/app-menu.ts b/src/main/app/app-menu.ts index 7ee3a1ff..50d794a2 100644 --- a/src/main/app/app-menu.ts +++ b/src/main/app/app-menu.ts @@ -1,4 +1,5 @@ import { BrowserWindow, Menu } from 'electron' +import { COMMANDS, type MenuSectionId } from '../../shared/commands/catalog' export interface AppMenuOptions { keepAwake: boolean @@ -12,28 +13,28 @@ export function buildAppMenu(mainWindow: BrowserWindow, options: AppMenuOptions) const send = (channel: string, ...args: unknown[]): void => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...args) } + + // Every command in the catalog renders as a menu item that fires the single + // `command:run` IPC channel — the renderer's useCommands hook dispatches it. + const commandItems = (section: MenuSectionId): Electron.MenuItemConstructorOptions[] => + COMMANDS.filter((c) => c.menu?.section === section) + .slice() + .sort((a, b) => (a.menu?.order ?? 0) - (b.menu?.order ?? 0)) + .map((c) => ({ + label: c.title, + accelerator: c.accelerator, + click: () => send('command:run', c.id), + })) + const menuTemplate: Electron.MenuItemConstructorOptions[] = [ { label: 'Manifold', submenu: [ - { - label: 'About Manifold', - click: () => send('show-about'), - }, + ...commandItems('manifold').filter((i) => i.label === 'About Manifold'), { type: 'separator' }, - { - label: "What's New", - click: () => send('show-update-log'), - }, - { - label: 'Check for Updates...', - click: () => send('show-update-check'), - }, - { - label: 'Settings…', - accelerator: 'CmdOrCtrl+,', - click: () => send('show-settings'), - }, + { label: "What's New", click: () => send('show-update-log') }, + { label: 'Check for Updates...', click: () => send('show-update-check') }, + ...commandItems('manifold').filter((i) => i.label !== 'About Manifold'), { type: 'separator' }, { label: 'Keep Mac Awake', @@ -62,46 +63,13 @@ export function buildAppMenu(mainWindow: BrowserWindow, options: AppMenuOptions) { role: 'paste' }, { role: 'selectAll' }, { type: 'separator' }, - { - label: 'Find in Files', - accelerator: 'CmdOrCtrl+Shift+F', - click: () => send('view:show-search'), - }, + ...commandItems('edit'), ], }, { label: 'View', submenu: [ - { - label: 'Toggle Projects', - accelerator: 'CmdOrCtrl+Alt+1', - click: () => send('view:toggle-panel', 'projects'), - }, - { - label: 'Toggle Agent', - accelerator: 'CmdOrCtrl+Alt+2', - click: () => send('view:toggle-panel', 'agent'), - }, - { - label: 'Toggle Editor', - accelerator: 'CmdOrCtrl+Alt+3', - click: () => send('view:toggle-panel', 'editor'), - }, - { - label: 'Toggle Files', - accelerator: 'CmdOrCtrl+Alt+4', - click: () => send('view:toggle-panel', 'fileTree'), - }, - { - label: 'Toggle Modified Files', - accelerator: 'CmdOrCtrl+Alt+5', - click: () => send('view:toggle-panel', 'modifiedFiles'), - }, - { - label: 'Toggle Shell', - accelerator: 'CmdOrCtrl+Alt+6', - click: () => send('view:toggle-panel', 'shell'), - }, + ...commandItems('view'), { type: 'separator' }, { role: 'reload' }, { role: 'forceReload' }, @@ -114,14 +82,9 @@ export function buildAppMenu(mainWindow: BrowserWindow, options: AppMenuOptions) { role: 'togglefullscreen' }, ], }, - { - label: 'Go', - submenu: Array.from({ length: 9 }, (_, i) => ({ - label: `Jump to Favorite ${i + 1}`, - accelerator: `CmdOrCtrl+${i + 1}`, - click: () => send('view:jump-favorite', i), - })), - }, + { label: 'Go', submenu: commandItems('go') }, + { label: 'Agent', submenu: commandItems('agent') }, + { label: 'Source Control', submenu: commandItems('scm') }, { label: 'Window', submenu: [ @@ -131,6 +94,7 @@ export function buildAppMenu(mainWindow: BrowserWindow, options: AppMenuOptions) { role: 'front' }, ], }, + { role: 'help', submenu: commandItems('help') }, ] return Menu.buildFromTemplate(menuTemplate) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 0f671635..cbaa7158 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -148,14 +148,10 @@ const ALLOWED_LISTEN_CHANNELS = [ 'files:tree-changed', 'settings:changed', 'agent:conflicts', - 'show-about', - 'show-settings', + 'command:run', 'show-update-log', 'show-update-check', 'updater:status', - 'view:toggle-panel', - 'view:show-search', - 'view:jump-favorite', 'preview:url-detected', 'app:auto-spawn', 'provisioning:progress', diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b5f64fa3..f4b6f516 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -30,6 +30,10 @@ import { useSidebarHandleCycle } from './hooks/useSidebarHandleCycle' import { useAgentSiblingDockTabs } from './hooks/useAgentSiblingDockTabs' import { getPrimarySession } from './hooks/agent-siblings' import { useAppEffects } from './hooks/useAppEffects' +import { useCommands } from './hooks/useCommands' +import { cycleAgent } from './commands/agent-cycle' +import type { CommandContext } from './commands/command-handlers' +import type { DockPanelId } from './hooks/dock-layout/useDockLayout' import type { DockAppState } from './components/editor/editor-shell/dock-panel-types' import { buildRootLabels } from './components/editor/file-tree/file-tree-labels' import { useWorkspaces } from './hooks/useWorkspaces' @@ -114,7 +118,6 @@ export function App(): React.JSX.Element { const appEffects = useAppEffects({ activeSessionId, dockLayout, settings, setActiveProject, spawnAgent, refreshOpenFiles: codeView.refreshOpenFiles, refreshDiff, - jumpToFavorite, }) const { additionalDirs, additionalTrees, additionalBranches, refreshTree: refreshAdditionalTree } = useAdditionalDirs(effectiveSessionId, activeSession?.additionalDirs) const { tree, changes: watcherChanges, refreshTree: refreshPrimaryTree, deleteFile, renameFile, createFile, createDir, importPaths, pasteImage, pasteClipboardImage, movePath, revealInFinder, openInTerminal } = useFileWatcher(effectiveSessionId, appEffects.handleFilesChanged, activeDraft?.projectId ?? null) @@ -228,25 +231,18 @@ export function App(): React.JSX.Element { }) }, [activeProject?.id, activeProject?.name, activeProject?.path, activeSession?.id, activeSession?.status, activeSession?.branchName]) - // Mirror the active session id into a ref so the (mount-only) Cmd+P handler - // reads the current value without re-registering the listener. + // Mirror the active session id into a ref so the openQuickOpen command reads + // the current value without being rebuilt on every session change. const quickOpenSessionRef = useRef(effectiveSessionId) quickOpenSessionRef.current = effectiveSessionId - // Global Cmd+P opens Quick Open from anywhere (VS Code-style), including while - // focus is in an input/editor — intentional: this is the primary file-nav gesture. - useEffect(() => { - const onKeyDown = (event: KeyboardEvent): void => { - if (event.metaKey && !event.shiftKey && !event.altKey && !event.ctrlKey && (event.key === 'p' || event.key === 'P')) { - // No worktree to search, or a modal owns the screen — don't open behind it. - if (!quickOpenSessionRef.current) return - if (document.querySelector('[role="dialog"][aria-modal="true"]')) return - event.preventDefault() - setQuickOpenVisible(true) - } - } - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) + // The Quick Open File command (Cmd+P) opens from anywhere — including while + // focus is in an input/editor — but not when there's no worktree to search or + // when a modal already owns the screen. + const openQuickOpen = useCallback((): void => { + if (!quickOpenSessionRef.current) return + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return + setQuickOpenVisible(true) }, []) const activeProjectIsGit = isGitProject(activeProject) @@ -367,9 +363,45 @@ export function App(): React.JSX.Element { onReorderFavorites: reorderFavorites, onActivateFavorite: activateFavorite, } + // Wire the command catalog to the functions assembled above. Handlers no-op + // when their context is absent (e.g. Next Agent with no project) rather than + // disabling the menu item — see the command-registry design spec. + const commandContext = useMemo(() => ({ + openSettings: () => overlays.setShowSettings(true), + openCommandPalette: () => overlays.setShowCommandPalette(true), + openShortcuts: () => overlays.setShowShortcuts(true), + openAbout: () => overlays.setShowAbout(true), + openQuickOpen, + findInFiles: () => appEffects.focusSearch('code'), + jumpToFavorite, + newAgent: overlays.handleNewAgentFromHeader, + nextAgent: () => { + const next = cycleAgent(activeProjectSessions, activeSessionId, 1) + if (next) overlays.handleSelectSession(next.id, next.projectId) + }, + previousAgent: () => { + const prev = cycleAgent(activeProjectSessions, activeSessionId, -1) + if (prev) overlays.handleSelectSession(prev.id, prev.projectId) + }, + deleteActiveAgent: () => { + if (activeSession && activeProject) overlays.requestDeleteAgent(activeSession, activeProject.path) + }, + commit: () => overlays.setActivePanel('commit'), + createPR: () => overlays.setActivePanel('pr'), + togglePanel: (panelId) => dockLayout.togglePanel(panelId as DockPanelId), + openModule: (panelId) => { + const id = panelId as DockPanelId + if (dockLayout.isPanelVisible(id)) dockLayout.focusPanel(id) + else dockLayout.togglePanel(id) + }, + toggleTheme, + }), [overlays, openQuickOpen, appEffects, jumpToFavorite, activeProjectSessions, activeSessionId, activeSession, activeProject, dockLayout, toggleTheme]) + const { runCommand } = useCommands(commandContext) + return ( <> = {}): AppShellProps { onToggleTheme: vi.fn(), themeFamily: 'manifold', onSelectThemeFamily: vi.fn(), + runCommand: vi.fn(), ...overrides, } } diff --git a/src/renderer/AppShell.tsx b/src/renderer/AppShell.tsx index 8bf469a1..e45b2f04 100644 --- a/src/renderer/AppShell.tsx +++ b/src/renderer/AppShell.tsx @@ -26,6 +26,8 @@ import { TitleBar } from './components/TitleBar' import { DeleteAgentDialog } from './components/sidebar/DeleteAgentDialog' import { useLoadPluginContributions } from './plugins/use-contributions' import { PluginUiHost } from './components/plugin-ui/PluginUiHost' +import { CommandPalette } from './components/command-palette/CommandPalette' +import { ShortcutsCheatSheet } from './components/command-palette/ShortcutsCheatSheet' export interface AppShellProps { themeClass: string @@ -74,6 +76,7 @@ export interface AppShellProps { onToggleTheme: () => void themeFamily: 'manifold' | 'garfield' | 'neon' | 'royal' | 'jade' | 'platinum' onSelectThemeFamily: (family: 'manifold' | 'garfield' | 'neon' | 'royal' | 'jade' | 'platinum') => void + runCommand: (id: string) => void } export function AppShell(p: AppShellProps): React.JSX.Element { @@ -174,6 +177,9 @@ export function AppShell(p: AppShellProps): React.JSX.Element { /> p.overlays.setShowSettings(false)} onPreviewTheme={p.setPreviewThemeId} /> + p.overlays.setShowCommandPalette(false)} /> + p.overlays.setShowShortcuts(false)} /> p.overlays.setShowAbout(false)} onViewReleaseNotes={p.updateLog.openReleaseNotes} /> { + it('returns null for an empty list', () => { + expect(cycleAgent([], null, 1)).toBeNull() + expect(cycleAgent([], 'a', -1)).toBeNull() + }) + + it('steps forward and wraps around', () => { + expect(cycleAgent([a, b, c], 'a', 1)).toBe(b) + expect(cycleAgent([a, b, c], 'c', 1)).toBe(a) + }) + + it('steps backward and wraps around', () => { + expect(cycleAgent([a, b, c], 'b', -1)).toBe(a) + expect(cycleAgent([a, b, c], 'a', -1)).toBe(c) + }) + + it('starts at the first agent going forward when nothing is active', () => { + expect(cycleAgent([a, b, c], null, 1)).toBe(a) + }) + + it('starts at the last agent going backward when nothing is active', () => { + expect(cycleAgent([a, b, c], null, -1)).toBe(c) + }) + + it('treats an unknown active id like no active agent', () => { + expect(cycleAgent([a, b, c], 'gone', 1)).toBe(a) + }) + + it('stays on the only agent', () => { + expect(cycleAgent([a], 'a', 1)).toBe(a) + }) +}) diff --git a/src/renderer/commands/agent-cycle.ts b/src/renderer/commands/agent-cycle.ts new file mode 100644 index 00000000..27d4c2d7 --- /dev/null +++ b/src/renderer/commands/agent-cycle.ts @@ -0,0 +1,17 @@ +/** + * Pick the next agent when cycling forward (+1) or backward (-1) through an + * ordered session list, wrapping at the ends. With no (or an unknown) active + * agent, forward starts at the first and backward at the last. Returns null + * only when the list is empty. + */ +export function cycleAgent( + sessions: T[], + activeId: string | null, + direction: 1 | -1, +): T | null { + if (sessions.length === 0) return null + const current = sessions.findIndex((s) => s.id === activeId) + if (current === -1) return direction === 1 ? sessions[0] : sessions[sessions.length - 1] + const next = (current + direction + sessions.length) % sessions.length + return sessions[next] +} diff --git a/src/renderer/commands/command-handlers.test.ts b/src/renderer/commands/command-handlers.test.ts new file mode 100644 index 00000000..7f13c9bc --- /dev/null +++ b/src/renderer/commands/command-handlers.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest' +import { createCommandHandlers, type CommandContext } from './command-handlers' +import { COMMANDS } from '../../shared/commands/catalog' + +function mockContext(): CommandContext { + return { + openSettings: vi.fn(), + openCommandPalette: vi.fn(), + openShortcuts: vi.fn(), + openAbout: vi.fn(), + openQuickOpen: vi.fn(), + findInFiles: vi.fn(), + jumpToFavorite: vi.fn(), + newAgent: vi.fn(), + nextAgent: vi.fn(), + previousAgent: vi.fn(), + deleteActiveAgent: vi.fn(), + commit: vi.fn(), + createPR: vi.fn(), + togglePanel: vi.fn(), + openModule: vi.fn(), + toggleTheme: vi.fn(), + } +} + +describe('createCommandHandlers', () => { + it('provides a handler for every catalog command', () => { + const handlers = createCommandHandlers(mockContext()) + for (const command of COMMANDS) { + expect(typeof handlers[command.id]).toBe('function') + } + }) + + it('routes simple commands to their context method', () => { + const ctx = mockContext() + const handlers = createCommandHandlers(ctx) + handlers['general.settings']() + handlers['general.commandPalette']() + handlers['agents.next']() + handlers['scm.commit']() + expect(ctx.openSettings).toHaveBeenCalledOnce() + expect(ctx.openCommandPalette).toHaveBeenCalledOnce() + expect(ctx.nextAgent).toHaveBeenCalledOnce() + expect(ctx.commit).toHaveBeenCalledOnce() + }) + + it('maps favorites to a zero-based index', () => { + const ctx = mockContext() + const handlers = createCommandHandlers(ctx) + handlers['navigation.favorite.3']() + expect(ctx.jumpToFavorite).toHaveBeenCalledWith(2) + }) + + it('maps panel-toggle commands to their dock panel id', () => { + const ctx = mockContext() + const handlers = createCommandHandlers(ctx) + handlers['view.toggle.shell']() + expect(ctx.togglePanel).toHaveBeenCalledWith('shell') + }) + + it('maps focus commands to openModule for the right panel', () => { + const ctx = mockContext() + const handlers = createCommandHandlers(ctx) + handlers['view.focusTerminal']() + handlers['view.focusFiles']() + expect(ctx.openModule).toHaveBeenCalledWith('shell') + expect(ctx.openModule).toHaveBeenCalledWith('fileTree') + }) +}) diff --git a/src/renderer/commands/command-handlers.ts b/src/renderer/commands/command-handlers.ts new file mode 100644 index 00000000..5ca1cb0a --- /dev/null +++ b/src/renderer/commands/command-handlers.ts @@ -0,0 +1,73 @@ +import { COMMANDS, PANEL_TOGGLE_IDS, type CommandId } from '../../shared/commands/catalog' + +/** + * The renderer functions every command needs. All already exist in App.tsx's + * assembled state — command handlers only wire them, adding no feature logic. + * Context-sensitive handlers (next agent, commit, …) no-op inside these + * callbacks when there is no active session/project. + */ +export interface CommandContext { + openSettings: () => void + openCommandPalette: () => void + openShortcuts: () => void + openAbout: () => void + openQuickOpen: () => void + findInFiles: () => void + jumpToFavorite: (index: number) => void + newAgent: () => void + nextAgent: () => void + previousAgent: () => void + deleteActiveAgent: () => void + commit: () => void + createPR: () => void + togglePanel: (panelId: string) => void + openModule: (panelId: string) => void + toggleTheme: () => void +} + +const FOCUS_PANEL_IDS: Record = { + 'view.focusChat': 'agent', + 'view.focusTerminal': 'shell', + 'view.focusFiles': 'fileTree', +} + +/** Build the id → handler map consumed by useCommands and the command palette. */ +export function createCommandHandlers(ctx: CommandContext): Record void> { + const handlers: Record void> = { + 'general.settings': ctx.openSettings, + 'general.commandPalette': ctx.openCommandPalette, + 'help.shortcuts': ctx.openShortcuts, + 'help.about': ctx.openAbout, + 'navigation.quickOpenFile': ctx.openQuickOpen, + 'navigation.findInFiles': ctx.findInFiles, + 'agents.new': ctx.newAgent, + 'agents.next': ctx.nextAgent, + 'agents.previous': ctx.previousAgent, + 'agents.delete': ctx.deleteActiveAgent, + 'scm.commit': ctx.commit, + 'scm.createPR': ctx.createPR, + 'view.toggleTheme': ctx.toggleTheme, + } + + for (const command of COMMANDS) { + const favorite = command.id.match(/^navigation\.favorite\.(\d+)$/) + if (favorite) { + const index = Number(favorite[1]) - 1 + handlers[command.id] = () => ctx.jumpToFavorite(index) + continue + } + const panelId = PANEL_TOGGLE_IDS[command.id] + if (panelId) { + handlers[command.id] = () => ctx.togglePanel(panelId) + continue + } + const focusPanel = FOCUS_PANEL_IDS[command.id] + if (focusPanel) { + handlers[command.id] = () => ctx.openModule(focusPanel) + } + } + + return handlers +} + +export type { CommandId } diff --git a/src/renderer/components/command-palette/CommandPalette.test.tsx b/src/renderer/components/command-palette/CommandPalette.test.tsx new file mode 100644 index 00000000..738218aa --- /dev/null +++ b/src/renderer/components/command-palette/CommandPalette.test.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { CommandPalette } from './CommandPalette' +import { COMMANDS } from '../../../shared/commands/catalog' + +const commitTitle = COMMANDS.find((c) => c.id === 'scm.commit')!.title + +describe('CommandPalette', () => { + it('renders nothing when not visible', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeNull() + }) + + it('lists commands and runs the highlighted one on Enter', () => { + const onRun = vi.fn() + render() + const input = screen.getByPlaceholderText('Type a command…') + fireEvent.change(input, { target: { value: 'Toggle Theme' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onRun).toHaveBeenCalledWith('view.toggleTheme') + }) + + it('filters the list by the typed query', () => { + render() + const input = screen.getByPlaceholderText('Type a command…') + fireEvent.change(input, { target: { value: 'Commit' } }) + expect(screen.getByText(commitTitle)).toBeInTheDocument() + expect(screen.queryByText('Toggle Theme')).not.toBeInTheDocument() + }) + + it('closes on Escape', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByPlaceholderText('Type a command…'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/components/command-palette/CommandPalette.tsx b/src/renderer/components/command-palette/CommandPalette.tsx new file mode 100644 index 00000000..1435e272 --- /dev/null +++ b/src/renderer/components/command-palette/CommandPalette.tsx @@ -0,0 +1,110 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { COMMANDS, type CommandDef } from '../../../shared/commands/catalog' +import { formatAccelerator } from '../../../shared/commands/accelerator-label' +import { fuzzyScore } from '../editor/quick-open/fuzzy-match' +import { createDialogStyles } from '../workbench-style-primitives' +import { useAutoFocus } from '../../hooks/useAutoFocus' + +const styles = createDialogStyles('560px') + +const extra: Record = { + input: { ...styles.input, width: '100%', boxSizing: 'border-box' }, + list: { maxHeight: '320px', overflowY: 'auto', margin: '0 calc(-1 * var(--space-lg))', borderTop: '1px solid var(--border)' }, + item: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-md)', padding: 'var(--space-sm) var(--space-lg)', cursor: 'pointer' }, + itemActive: { background: 'var(--selection-bg, color-mix(in srgb, var(--accent), transparent 85%))' }, + itemText: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 }, + itemLabel: { fontSize: 'var(--type-ui)', color: 'var(--text-primary)' }, + itemCategory: { fontSize: 'var(--type-ui-caption)', color: 'var(--text-muted)' }, + accel: { fontSize: 'var(--type-ui-small)', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }, + empty: { padding: 'var(--space-lg)', fontSize: 'var(--type-ui-small)', color: 'var(--text-muted)', textAlign: 'center' }, +} + +interface CommandPaletteProps { + visible: boolean + onRun: (id: string) => void + onClose: () => void +} + +export function CommandPalette({ visible, onRun, onClose }: CommandPaletteProps): React.JSX.Element | null { + const [filter, setFilter] = useState('') + const [activeIndex, setActiveIndex] = useState(0) + const inputRef = useRef(null) + const overlayRef = useRef(null) + useAutoFocus(visible, inputRef) + + const filtered = useMemo(() => { + const q = filter.trim() + if (!q) return [...COMMANDS] + return COMMANDS + .map((c) => ({ c, score: fuzzyScore(q, `${c.title} ${c.category}`) })) + .filter((x): x is { c: CommandDef; score: number } => x.score !== null) + .sort((a, b) => b.score - a.score) + .map((x) => x.c) + }, [filter]) + + const clampedIndex = Math.min(activeIndex, Math.max(0, filtered.length - 1)) + + const run = useCallback((command: CommandDef | undefined): void => { + if (!command) return + onRun(command.id) + onClose() + }, [onRun, onClose]) + + const onKeyDown = useCallback((e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { onClose(); return } + if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex((i) => Math.min(i + 1, filtered.length - 1)); return } + if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex((i) => Math.max(i - 1, 0)); return } + if (e.key === 'Enter') { e.preventDefault(); run(filtered[clampedIndex]) } + }, [filtered, clampedIndex, run, onClose]) + + if (!visible) return null + + return ( +
{ if (e.target === overlayRef.current) onClose() }} + role="dialog" + aria-modal="true" + aria-label="Command Palette" + > +
e.stopPropagation()}> +
+ { setFilter(e.target.value); setActiveIndex(0) }} + onKeyDown={onKeyDown} + aria-label="Filter commands" + autoComplete="off" + /> +
+ {filtered.length === 0 ? ( +
No matching commands
+ ) : ( + filtered.map((command, idx) => ( +
setActiveIndex(idx)} + onClick={() => run(command)} + > + + {command.title} + {command.category} + + {command.accelerator && {formatAccelerator(command.accelerator)}} +
+ )) + )} +
+
+
+
+ ) +} diff --git a/src/renderer/components/command-palette/ShortcutList.tsx b/src/renderer/components/command-palette/ShortcutList.tsx new file mode 100644 index 00000000..893e6bb3 --- /dev/null +++ b/src/renderer/components/command-palette/ShortcutList.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { COMMANDS, COMMAND_CATEGORIES } from '../../../shared/commands/catalog' +import { formatAccelerator } from '../../../shared/commands/accelerator-label' + +const styles: Record = { + group: { marginBottom: 'var(--space-lg)' }, + groupTitle: { fontSize: 'var(--type-ui-caption)', textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: 'var(--space-xs)' }, + row: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-md)', padding: '4px 0' }, + label: { fontSize: 'var(--type-ui)', color: 'var(--text-primary)' }, + accel: { fontSize: 'var(--type-ui-small)', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }, + palette: { fontSize: 'var(--type-ui-caption)', color: 'var(--text-muted)', whiteSpace: 'nowrap' }, +} + +/** Read-only list of every command grouped by category, with its keybinding. + * Shared by the cheat-sheet overlay and the Settings → Shortcuts tab. */ +export function ShortcutList(): React.JSX.Element { + return ( +
+ {COMMAND_CATEGORIES.map((category) => { + const items = COMMANDS.filter((c) => c.category === category) + if (items.length === 0) return null + return ( +
+
{category}
+ {items.map((command) => ( +
+ {command.title} + {command.accelerator + ? {formatAccelerator(command.accelerator)} + : Command Palette} +
+ ))} +
+ ) + })} +
+ ) +} diff --git a/src/renderer/components/command-palette/ShortcutsCheatSheet.tsx b/src/renderer/components/command-palette/ShortcutsCheatSheet.tsx new file mode 100644 index 00000000..881618ce --- /dev/null +++ b/src/renderer/components/command-palette/ShortcutsCheatSheet.tsx @@ -0,0 +1,38 @@ +import React, { useRef } from 'react' +import { createDialogStyles } from '../workbench-style-primitives' +import { ShortcutList } from './ShortcutList' + +const styles = createDialogStyles('560px') + +interface ShortcutsCheatSheetProps { + visible: boolean + onClose: () => void +} + +/** Keyboard-shortcuts help overlay (Cmd+Shift+/). Lists the active bindings. */ +export function ShortcutsCheatSheet({ visible, onClose }: ShortcutsCheatSheetProps): React.JSX.Element | null { + const overlayRef = useRef(null) + if (!visible) return null + return ( +
{ if (e.target === overlayRef.current) onClose() }} + onKeyDown={(e) => { if (e.key === 'Escape') onClose() }} + role="dialog" + aria-modal="true" + aria-label="Keyboard Shortcuts" + tabIndex={-1} + > +
e.stopPropagation()}> +
+ Keyboard Shortcuts + +
+
+ +
+
+
+ ) +} diff --git a/src/renderer/components/modals/settings/SettingsModalBody.tsx b/src/renderer/components/modals/settings/SettingsModalBody.tsx index 48c2e4f9..c1cb746e 100644 --- a/src/renderer/components/modals/settings/SettingsModalBody.tsx +++ b/src/renderer/components/modals/settings/SettingsModalBody.tsx @@ -10,12 +10,14 @@ import { ProvisioningSettingsSection } from './ProvisioningSettingsSection' import { TranscriptionSettingsSection } from './TranscriptionSettingsSection' import { SectionCard, SectionHeader } from './SettingsSectionLayout' import { PluginSettingsSection } from './PluginSettingsSection' +import { ShortcutsSettingsSection } from './ShortcutsSettingsSection' -export type SettingsTabId = 'general' | 'editor' | 'search-ai' | 'provisioning' | 'transcription' | 'plugins' +export type SettingsTabId = 'general' | 'editor' | 'shortcuts' | 'search-ai' | 'provisioning' | 'transcription' | 'plugins' const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = [ { id: 'general', label: 'General' }, { id: 'editor', label: 'Editor' }, + { id: 'shortcuts', label: 'Shortcuts' }, { id: 'search-ai', label: 'Search AI' }, { id: 'provisioning', label: 'Provisioning' }, { id: 'transcription', label: 'Transcription' }, @@ -97,6 +99,7 @@ export function SettingsModalBody(props: Props): React.JSX.Element { {props.activeTab === 'editor' && ( )} + {props.activeTab === 'shortcuts' && } {props.activeTab === 'search-ai' && ( <> diff --git a/src/renderer/components/modals/settings/ShortcutsSettingsSection.tsx b/src/renderer/components/modals/settings/ShortcutsSettingsSection.tsx new file mode 100644 index 00000000..18f7bad3 --- /dev/null +++ b/src/renderer/components/modals/settings/ShortcutsSettingsSection.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { SectionCard, SectionHeader } from './SettingsSectionLayout' +import { ShortcutList } from '../../command-palette/ShortcutList' + +/** Read-only Settings tab listing every command's keybinding. Editing bindings + * is deferred to a follow-up PR (see issue #721). */ +export function ShortcutsSettingsSection(): React.JSX.Element { + return ( + <> + + + + + + ) +} diff --git a/src/renderer/hooks/useAppEffects.test.ts b/src/renderer/hooks/useAppEffects.test.ts index 0b0d6b3a..f50f60c5 100644 --- a/src/renderer/hooks/useAppEffects.test.ts +++ b/src/renderer/hooks/useAppEffects.test.ts @@ -48,7 +48,6 @@ function createInput(activeSessionId: string | null = 'session-1') { spawnAgent: vi.fn(), refreshOpenFiles: vi.fn().mockResolvedValue(undefined), refreshDiff: vi.fn().mockResolvedValue(undefined), - jumpToFavorite: vi.fn(), }, } } @@ -166,16 +165,6 @@ describe('useAppEffects', () => { expect(input.refreshOpenFiles).not.toHaveBeenCalled() }) - it('jumps to the favorite at the index from view:jump-favorite', () => { - const { input } = createInput() - renderHook(() => useAppEffects({ ...input })) - - act(() => { - emit('view:jump-favorite', 2) - }) - expect(input.jumpToFavorite).toHaveBeenCalledWith(2) - }) - it('opens the sibling panel on plugins:reveal-session', () => { const { input } = createInput() renderHook(() => useAppEffects({ ...input })) diff --git a/src/renderer/hooks/useAppEffects.ts b/src/renderer/hooks/useAppEffects.ts index 9ab50155..8e8a46ff 100644 --- a/src/renderer/hooks/useAppEffects.ts +++ b/src/renderer/hooks/useAppEffects.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { SearchMode } from '../../shared/search-types' -import type { DockPanelId, UseDockLayoutResult } from './dock-layout/useDockLayout' +import type { UseDockLayoutResult } from './dock-layout/useDockLayout' import type { SpawnAgentOptions } from '../../shared/types' import type { PendingLaunchAction } from '../../shared/mode-switch-types' @@ -12,7 +12,6 @@ interface AppEffectsInput { spawnAgent: (options: SpawnAgentOptions) => Promise refreshOpenFiles: () => Promise refreshDiff: () => Promise - jumpToFavorite: (index: number) => void } export interface AppEffectsResult { @@ -69,18 +68,6 @@ export function useAppEffects(input: AppEffectsInput): AppEffectsResult { }, 150) }, [input.refreshOpenFiles]) - useEffect(() => window.electronAPI.on('view:toggle-panel', (panelId: unknown) => { - input.dockLayout.togglePanel(panelId as DockPanelId) - }), [input.dockLayout.togglePanel]) - - useEffect(() => window.electronAPI.on('view:show-search', () => { - focusSearch('code') - }), [focusSearch]) - - useEffect(() => window.electronAPI.on('view:jump-favorite', (index: unknown) => { - input.jumpToFavorite(index as number) - }), [input.jumpToFavorite]) - // A plugin asked the app to surface an agent session's panel (manifold.agents // AgentSession.reveal — e.g. the watch plugin's "Open agent" button). useEffect(() => window.electronAPI.on('plugins:reveal-session', (...args: unknown[]) => { diff --git a/src/renderer/hooks/useAppOverlays.ts b/src/renderer/hooks/useAppOverlays.ts index 4230173c..f2c358cc 100644 --- a/src/renderer/hooks/useAppOverlays.ts +++ b/src/renderer/hooks/useAppOverlays.ts @@ -13,6 +13,10 @@ export interface UseAppOverlaysResult { setShowSettings: (show: boolean) => void showAbout: boolean setShowAbout: (show: boolean) => void + showCommandPalette: boolean + setShowCommandPalette: (show: boolean) => void + showShortcuts: boolean + setShowShortcuts: (show: boolean) => void appVersion: string handleCommit: (message: string) => Promise handleClosePanel: () => void @@ -42,6 +46,8 @@ export function useAppOverlays( const [activePanel, setActivePanel] = useState<'commit' | 'pr' | 'conflicts' | null>(null) const [showSettings, setShowSettings] = useState(false) const [showAbout, setShowAbout] = useState(false) + const [showCommandPalette, setShowCommandPalette] = useState(false) + const [showShortcuts, setShowShortcuts] = useState(false) const [appVersion, setAppVersion] = useState('') const [newAgentFocusTrigger, setNewAgentFocusTrigger] = useState(0) const [pendingDelete, setPendingDelete] = useState(null) @@ -119,16 +125,6 @@ export function useAppOverlays( void window.electronAPI.invoke('app:version').then((v) => setAppVersion(v as string)) }, []) - useEffect(() => { - const unsub = window.electronAPI.on('show-about', () => setShowAbout(true)) - return unsub - }, []) - - useEffect(() => { - const unsub = window.electronAPI.on('show-settings', () => setShowSettings(true)) - return unsub - }, []) - return useMemo(() => ({ activePanel, setActivePanel, @@ -138,6 +134,10 @@ export function useAppOverlays( setShowSettings, showAbout, setShowAbout, + showCommandPalette, + setShowCommandPalette, + showShortcuts, + setShowShortcuts, appVersion, handleCommit, handleClosePanel, @@ -152,7 +152,7 @@ export function useAppOverlays( confirmDeleteAgent, }), [ activePanel, handleNewAgentFromHeader, newAgentFocusTrigger, - showSettings, showAbout, appVersion, handleCommit, handleClosePanel, + showSettings, showAbout, showCommandPalette, showShortcuts, appVersion, handleCommit, handleClosePanel, handleLaunchAgent, handleSelectSession, handleSaveSettings, handleSetupComplete, pendingDelete, deletingSessionId, requestDeleteAgent, cancelDeleteAgent, confirmDeleteAgent, ]) diff --git a/src/renderer/hooks/useCommands.ts b/src/renderer/hooks/useCommands.ts new file mode 100644 index 00000000..9b69b6c0 --- /dev/null +++ b/src/renderer/hooks/useCommands.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { createCommandHandlers, type CommandContext } from '../commands/command-handlers' + +export interface UseCommandsResult { + /** Run a command by id — used by the native menu (via IPC) and the palette. */ + runCommand: (id: string) => void +} + +/** + * Central command dispatcher. Builds the id → handler map from the catalog and + * subscribes to the single `command:run` IPC channel the native menu fires. The + * command palette calls `runCommand` directly. Unknown ids no-op with a warning + * so a main/renderer catalog skew across an update can't crash the renderer. + */ +export function useCommands(context: CommandContext): UseCommandsResult { + const handlersRef = useRef void>>({}) + handlersRef.current = useMemo(() => createCommandHandlers(context), [context]) + + const runCommand = useCallback((id: string): void => { + const handler = handlersRef.current[id] + if (!handler) { + console.warn(`[useCommands] no handler for command: ${id}`) + return + } + handler() + }, []) + + useEffect(() => window.electronAPI.on('command:run', (id: unknown) => { + if (typeof id === 'string') runCommand(id) + }), [runCommand]) + + return useMemo(() => ({ runCommand }), [runCommand]) +} diff --git a/src/shared/commands/accelerator-label.test.ts b/src/shared/commands/accelerator-label.test.ts new file mode 100644 index 00000000..776c42ce --- /dev/null +++ b/src/shared/commands/accelerator-label.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { formatAccelerator } from './accelerator-label' + +describe('formatAccelerator', () => { + it('renders Cmd as the ⌘ glyph', () => { + expect(formatAccelerator('CmdOrCtrl+,')).toBe('⌘,') + }) + + it('orders modifiers in macOS canonical order ⌃⌥⇧⌘', () => { + // Input order is Cmd, Shift but display must be ⇧⌘ (Command last). + expect(formatAccelerator('CmdOrCtrl+Shift+P')).toBe('⇧⌘P') + expect(formatAccelerator('CmdOrCtrl+Alt+3')).toBe('⌥⌘3') + }) + + it('renders a bare Ctrl as ⌃', () => { + expect(formatAccelerator('Ctrl+`')).toBe('⌃`') + }) + + it('keeps punctuation keys verbatim', () => { + expect(formatAccelerator('CmdOrCtrl+Shift+/')).toBe('⇧⌘/') + expect(formatAccelerator('CmdOrCtrl+Shift+]')).toBe('⇧⌘]') + expect(formatAccelerator('CmdOrCtrl+Shift+E')).toBe('⇧⌘E') + }) + + it('maps named keys to their glyphs', () => { + expect(formatAccelerator('CmdOrCtrl+Enter')).toBe('⌘↩') + expect(formatAccelerator('CmdOrCtrl+Shift+Left')).toBe('⇧⌘←') + }) +}) diff --git a/src/shared/commands/accelerator-label.ts b/src/shared/commands/accelerator-label.ts new file mode 100644 index 00000000..99b54566 --- /dev/null +++ b/src/shared/commands/accelerator-label.ts @@ -0,0 +1,52 @@ +/** + * Render an Electron accelerator string (e.g. 'CmdOrCtrl+Shift+P') as the macOS + * glyph form (e.g. '⇧⌘P') for display in the command palette and cheat-sheet. + * Modifiers are emitted in Apple's canonical order: ⌃ ⌥ ⇧ ⌘. + */ +const MODIFIER_GLYPHS: Record = { + ctrl: '⌃', + control: '⌃', + alt: '⌥', + option: '⌥', + shift: '⇧', + cmd: '⌘', + command: '⌘', + cmdorctrl: '⌘', + super: '⌘', +} + +const MODIFIER_ORDER = ['⌃', '⌥', '⇧', '⌘'] + +const KEY_GLYPHS: Record = { + enter: '↩', + return: '↩', + tab: '⇥', + space: '␣', + backspace: '⌫', + delete: '⌦', + escape: '⎋', + esc: '⎋', + left: '←', + arrowleft: '←', + right: '→', + arrowright: '→', + up: '↑', + arrowup: '↑', + down: '↓', + arrowdown: '↓', +} + +export function formatAccelerator(accelerator: string): string { + const mods: string[] = [] + let key = '' + for (const token of accelerator.split('+')) { + const glyph = MODIFIER_GLYPHS[token.toLowerCase()] + if (glyph) { + if (!mods.includes(glyph)) mods.push(glyph) + } else { + key = KEY_GLYPHS[token.toLowerCase()] ?? (token.length === 1 ? token.toUpperCase() : token) + } + } + mods.sort((a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b)) + return mods.join('') + key +} diff --git a/src/shared/commands/catalog.test.ts b/src/shared/commands/catalog.test.ts new file mode 100644 index 00000000..4089275b --- /dev/null +++ b/src/shared/commands/catalog.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { COMMANDS, COMMAND_CATEGORIES, MENU_SECTIONS } from './catalog' + +describe('command catalog', () => { + it('has unique command ids', () => { + const ids = COMMANDS.map((c) => c.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('never binds the same accelerator to two commands', () => { + const accels = COMMANDS.map((c) => c.accelerator).filter((a): a is string => !!a) + const dupes = accels.filter((a, i) => accels.indexOf(a) !== i) + expect(dupes).toEqual([]) + }) + + it('gives every command a non-empty title and a known category', () => { + for (const c of COMMANDS) { + expect(c.title.trim().length).toBeGreaterThan(0) + expect(COMMAND_CATEGORIES).toContain(c.category) + } + }) + + it('places menu commands in a known section with an order', () => { + for (const c of COMMANDS) { + if (!c.menu) continue + expect(MENU_SECTIONS).toContain(c.menu.section) + expect(typeof c.menu.order).toBe('number') + } + }) + + it('exposes the command palette and the shortcuts cheat-sheet', () => { + const ids = COMMANDS.map((c) => c.id) + expect(ids).toContain('general.commandPalette') + expect(ids).toContain('help.shortcuts') + }) +}) diff --git a/src/shared/commands/catalog.ts b/src/shared/commands/catalog.ts new file mode 100644 index 00000000..11101f18 --- /dev/null +++ b/src/shared/commands/catalog.ts @@ -0,0 +1,94 @@ +/** + * The single source of truth for Manifold's commands. Both the native menu + * (main process, src/main/app/app-menu.ts) and the renderer (useCommands, the + * command palette, and the shortcuts cheat-sheet) are generated from this list. + * + * Pure data only — no behavior. The renderer maps each `id` to a handler in + * src/renderer/commands/command-handlers.ts; the menu maps each `id` to a + * `command:run` IPC send. Keys are de-conflicted against Electron roles, Monaco + * bindings (Cmd+/, Cmd+Shift+O, Ctrl+G) and macOS conventions — see the design + * spec docs/superpowers/specs/2026-06-14-keyboard-command-registry-design.md. + */ + +export const COMMAND_CATEGORIES = [ + 'General', + 'Navigation', + 'Agents', + 'Source Control', + 'View', + 'Help', +] as const +export type CommandCategory = (typeof COMMAND_CATEGORIES)[number] + +export const MENU_SECTIONS = ['manifold', 'edit', 'view', 'go', 'agent', 'scm', 'help'] as const +export type MenuSectionId = (typeof MENU_SECTIONS)[number] + +export interface CommandDef { + id: string + title: string + category: CommandCategory + /** Electron accelerator string; omitted commands are reachable via the palette. */ + accelerator?: string + /** Where the command appears in the native menu; omitted = palette-only. */ + menu?: { section: MenuSectionId; order: number } +} + +const RAW_COMMANDS = [ + // General + { id: 'general.settings', title: 'Settings…', category: 'General', accelerator: 'CmdOrCtrl+,', menu: { section: 'manifold', order: 10 } }, + { id: 'general.commandPalette', title: 'Command Palette…', category: 'General', accelerator: 'CmdOrCtrl+Shift+P', menu: { section: 'go', order: 20 } }, + + // Navigation + { id: 'navigation.quickOpenFile', title: 'Quick Open File…', category: 'Navigation', accelerator: 'CmdOrCtrl+P', menu: { section: 'go', order: 10 } }, + { id: 'navigation.findInFiles', title: 'Find in Files', category: 'Navigation', accelerator: 'CmdOrCtrl+Shift+F', menu: { section: 'edit', order: 10 } }, + { id: 'navigation.favorite.1', title: 'Jump to Favorite 1', category: 'Navigation', accelerator: 'CmdOrCtrl+1', menu: { section: 'go', order: 31 } }, + { id: 'navigation.favorite.2', title: 'Jump to Favorite 2', category: 'Navigation', accelerator: 'CmdOrCtrl+2', menu: { section: 'go', order: 32 } }, + { id: 'navigation.favorite.3', title: 'Jump to Favorite 3', category: 'Navigation', accelerator: 'CmdOrCtrl+3', menu: { section: 'go', order: 33 } }, + { id: 'navigation.favorite.4', title: 'Jump to Favorite 4', category: 'Navigation', accelerator: 'CmdOrCtrl+4', menu: { section: 'go', order: 34 } }, + { id: 'navigation.favorite.5', title: 'Jump to Favorite 5', category: 'Navigation', accelerator: 'CmdOrCtrl+5', menu: { section: 'go', order: 35 } }, + { id: 'navigation.favorite.6', title: 'Jump to Favorite 6', category: 'Navigation', accelerator: 'CmdOrCtrl+6', menu: { section: 'go', order: 36 } }, + { id: 'navigation.favorite.7', title: 'Jump to Favorite 7', category: 'Navigation', accelerator: 'CmdOrCtrl+7', menu: { section: 'go', order: 37 } }, + { id: 'navigation.favorite.8', title: 'Jump to Favorite 8', category: 'Navigation', accelerator: 'CmdOrCtrl+8', menu: { section: 'go', order: 38 } }, + { id: 'navigation.favorite.9', title: 'Jump to Favorite 9', category: 'Navigation', accelerator: 'CmdOrCtrl+9', menu: { section: 'go', order: 39 } }, + + // Agents + { id: 'agents.new', title: 'New Agent', category: 'Agents', accelerator: 'CmdOrCtrl+N', menu: { section: 'agent', order: 10 } }, + { id: 'agents.next', title: 'Next Agent', category: 'Agents', accelerator: 'CmdOrCtrl+Shift+]', menu: { section: 'agent', order: 20 } }, + { id: 'agents.previous', title: 'Previous Agent', category: 'Agents', accelerator: 'CmdOrCtrl+Shift+[', menu: { section: 'agent', order: 30 } }, + { id: 'agents.delete', title: 'Delete Agent…', category: 'Agents', menu: { section: 'agent', order: 40 } }, + + // Source Control + { id: 'scm.commit', title: 'Commit…', category: 'Source Control', accelerator: 'CmdOrCtrl+Shift+C', menu: { section: 'scm', order: 10 } }, + { id: 'scm.createPR', title: 'Create Pull Request…', category: 'Source Control', menu: { section: 'scm', order: 20 } }, + + // View + { id: 'view.toggle.projects', title: 'Toggle Projects', category: 'View', accelerator: 'CmdOrCtrl+Alt+1', menu: { section: 'view', order: 10 } }, + { id: 'view.toggle.agent', title: 'Toggle Agent', category: 'View', accelerator: 'CmdOrCtrl+Alt+2', menu: { section: 'view', order: 11 } }, + { id: 'view.toggle.editor', title: 'Toggle Editor', category: 'View', accelerator: 'CmdOrCtrl+Alt+3', menu: { section: 'view', order: 12 } }, + { id: 'view.toggle.fileTree', title: 'Toggle Files', category: 'View', accelerator: 'CmdOrCtrl+Alt+4', menu: { section: 'view', order: 13 } }, + { id: 'view.toggle.modifiedFiles', title: 'Toggle Modified Files', category: 'View', accelerator: 'CmdOrCtrl+Alt+5', menu: { section: 'view', order: 14 } }, + { id: 'view.toggle.shell', title: 'Toggle Shell', category: 'View', accelerator: 'CmdOrCtrl+Alt+6', menu: { section: 'view', order: 15 } }, + { id: 'view.focusChat', title: 'Focus Chat', category: 'View', menu: { section: 'view', order: 20 } }, + { id: 'view.focusTerminal', title: 'Focus Terminal', category: 'View', accelerator: 'Ctrl+`', menu: { section: 'view', order: 21 } }, + { id: 'view.focusFiles', title: 'Focus File Tree', category: 'View', accelerator: 'CmdOrCtrl+Shift+E', menu: { section: 'view', order: 22 } }, + { id: 'view.toggleTheme', title: 'Toggle Theme', category: 'View', menu: { section: 'view', order: 30 } }, + + // Help + { id: 'help.shortcuts', title: 'Keyboard Shortcuts', category: 'Help', accelerator: 'CmdOrCtrl+Shift+/', menu: { section: 'help', order: 10 } }, + { id: 'help.about', title: 'About Manifold', category: 'Help', menu: { section: 'manifold', order: 1 } }, +] as const satisfies readonly CommandDef[] + +/** Derive the id union from the literal tuple, then expose the list widened to + * CommandDef so consumers see `accelerator`/`menu` as optional fields. */ +export type CommandId = (typeof RAW_COMMANDS)[number]['id'] +export const COMMANDS: readonly CommandDef[] = RAW_COMMANDS + +/** Panel-toggle commands map 1:1 to dock panel ids (renderer side). */ +export const PANEL_TOGGLE_IDS: Record = { + 'view.toggle.projects': 'projects', + 'view.toggle.agent': 'agent', + 'view.toggle.editor': 'editor', + 'view.toggle.fileTree': 'fileTree', + 'view.toggle.modifiedFiles': 'modifiedFiles', + 'view.toggle.shell': 'shell', +}