From 7c105ed50538bd304200ab2a47088aae0dbdffb9 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Jul 2026 23:52:04 +0200 Subject: [PATCH 01/33] docs: plan command surface governance --- .../plans/2026-07-23-command-surface-audit.md | 885 ++++++++++++++++++ 1 file changed, 885 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-23-command-surface-audit.md diff --git a/docs/superpowers/plans/2026-07-23-command-surface-audit.md b/docs/superpowers/plans/2026-07-23-command-surface-audit.md new file mode 100644 index 00000000..688b55ac --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-command-surface-audit.md @@ -0,0 +1,885 @@ +# Command Surface Governance — Audit and Implementation Plan + +Status: **Awaiting user review. No implementation has started.** + +Date: 2026-07-23 + +Feature branch: `feat/command-governance` + +Feature worktree: `.worktrees/command-governance` + +Planned PR title: `Govern command visibility, state, and settings ownership` + +Baseline: `main` at `670f4c2d00d5585f81bb1f5677a1deda70b08a44` + +Research provenance: Agent Code Workflow MCP run +`run_e219348d-cdc8-49f7-893b-23858601e68d` (eight parallel audits plus one +independent synthesis pass), followed by direct local verification against the +runtime command exports. + +## Goal and checkpoint + +Audit every command that Agent Code registers, decide which commands need +context/capability/safety gates, normalize command-state badges, decide whether +each behavior belongs in Commands, Settings, or both, and establish an +implementation sequence that can be shipped without changing all invocation +paths in one flag day. + +This commit is deliberately the review checkpoint. It contains the audit and +implementation plan only. The branch is intended to hold the eventual full +implementation, but no source or test changes should begin until this document +has been reviewed. + +## Executive result + +The catalog has **102 concrete static commands**, not 93, 98, or 100: + +- 98 literal `id` fields exist in command modules; +- the pane module's two provider templates expand across Codex and OpenCode, + adding four concrete runtime IDs; and +- `agent-index:` rows are transient search destinations generated + outside the static catalog and are not counted as commands. + +All 102 static commands currently have effective `pickerVisibility: 'default'`. +The type already supports `advanced`, `experimental`, and `debug`, but no +command uses any of them. Debug tooling, destructive maintenance, niche MCP +controls, and daily navigation therefore all enter the picker at the same tier. + +The deepest defect is not one bad `when` predicate. The code has a **picker +resolver**, not a command-admission authority: + +```text +static command definitions + -> surface gate + -> command.when + -> rendered-view policy + -> picker visibility / user override + -> picker rows + |-> picker click/Enter runs command + |-> native menu also looks up in this already-filtered list + +keybindings ---------------------------> call workspace actions directly +programmatic callers ------------------> may call actions or command.run directly +``` + +That shape creates two opposite failures: + +1. A cosmetic command-visibility override can remove a native File-menu action, + even though the type contract says picker visibility must never disable a + capability. +2. A shortcut or programmatic caller can bypass surface, feature, debug, and + provider predicates that exist only while constructing picker rows. + +The implementation should therefore separate four concerns that are currently +collapsed into a boolean filter: + +| Concern | Question | Must affect | +|---|---|---| +| Applicability | Does this concept make sense on this layout/surface? | Picker and native menu presentation. | +| Availability/admission | Can this exact invocation succeed now? | Every invocation source, immediately before mutation. | +| Discoverability | Should this applicable command be listed by default? | Picker only. Never authorization. | +| Authorization/safety | Has the required user intent/confirmation been established? | The final mutation boundary, not just the initiating UI. | + +## Decisions proposed by this plan + +| Area | Proposed decision | Why | +|---|---|---| +| Catalog | Create a context-free, validated command catalog and preserve its ordered 102-ID snapshot. | Registration order is user-visible, while duplicate IDs, omitted modules, generated commands, and native-menu IDs are currently unchecked. | +| Execution | Add one command execution gateway with invocation source and a fresh admission check. | Picker, native menu, keybindings, and programmatic calls currently have different guarantees. | +| Visibility | Make visibility strictly picker-only; classify developer commands as `debug` and niche supported operations as `advanced`. | The tiers already exist but are unused, and native-menu execution currently depends on them accidentally. | +| State | Replace `{label, tone}` with semantic toggle/value/status states plus unavailable/loading/error and target identity. | Color and arbitrary text cannot distinguish boolean truth, values, inherited state, async work, or unsupported commands. | +| Settings ownership | Remove `dangerous-agents` from Commands; keep Settings as the durable home for five benign quick preferences; preserve Settings-default + per-session-command for MCP and agent view. | Safety posture changes need Settings context and confirmation. Session overrides are intentionally different from global defaults. | +| Providers | Gate features by declared capability, not `isAgentProviderKind()`. | OpenCode currently receives Rewind, Duplicate, Resume, Switch Provider, and Copy Resume affordances that are empty, rejected, unsupported, or unverified. | +| Targeting | Resolve and pin one target for state + execution, then revalidate identity/capability before mutation. | The shared grid/Dispatch resolver is sound, but repeated resolution can display state for A and mutate B after focus changes. | +| Destructive actions | Confirm active/running or cascading closes, re-enumerate bulk-close targets at commit, and keep ownership checks in main. | Undo does not recover live terminal state, unsent drafts, or partial cascades. A stale preview must not authorize a later changed target set. | +| Rollout | Characterize first, then separate catalog/admission, then migrate high-risk families, then change UX defaults. | Structural changes and behavior changes need independent rollback boundaries. | + +## What is already correct and must be preserved + +- `commandTargetSessionId` correctly uses the selected related child in Grid, + the strict focused row/lane in Dispatch, and no fallback for an empty or + stale tiled lane. +- Rendered-view policies are distinct from layout surfaces and should remain so. +- The built-in MCP provider table correctly allows all injectable domains on + Codex, excludes every built-in domain from OpenCode, and excludes Workflow + MCP from Claude because Claude already supplies the native workflow feature. +- Settings MCP rows are defaults for **new sessions**; their command counterparts + are **per-session** overrides. Those are complementary, not duplicate owners. +- Main-process session termination already proves `{sessionId, kind, cwd}` + ownership atomically. UI confirmation must supplement that check, not replace + it. +- `reply-to-selection` is the strongest current example of revalidating its + target-specific transient input at execution time. +- The removed `toggle-dispatch-terminal` command is the correct precedent for + moving a durable preference into Settings when a transient command created + misleading persistence expectations. + +## Canonical source map + +| Registry contribution | Runtime count | Source | +|---|---:|---| +| Tabs | 6 | `features/workspace/commands/tabCommands.ts` | +| Panes, placement, navigation, generated provider splits | 28 | `features/workspace/commands/paneCommands.ts` | +| Layout and Dispatch | 9 | `features/workspace/commands/layoutCommands.ts` | +| Global Editor and AI Workspace | 10 | `features/global-editor/commands/globalEditorCommands.ts` | +| Session/provider/MCP/debug | 29 | `features/workspace/commands/sessionCommands.ts` | +| Dispatch color flag | 1 | `features/workspace/commands/dispatchColorFlagCommands.ts` | +| Spotlight / Reader / Tiled Tabs | 3 | Their feature-owned command modules. | +| Settings plus nested dangerous command | 5 | `features/settings/commands/{settings,dangerous}Commands.ts` | +| Copy Assistant / Copy Code Block | 2 | Their feature-owned command modules. | +| Prompt templates / Reply to Selection | 4 | Their feature-owned command modules. | +| Agent Status / Remote | 2 | Their feature-owned command modules. | +| Usage | 3 | `features/usage/commands/usageCommands.ts` | +| **Total** | **102** | Concatenated by `features/command-palette/registry.ts`. | + +## Audit legend + +- **Gate** describes current picker applicability beyond the command's surface. + `none` means there is no command-specific `when`; surface and visibility still + apply. +- **State** is the current badge, not the proposed semantic state. +- **Scope/risk** combines persistence scope with meaningful side effects. +- **Home** is `C` (Command), `S` (Settings), or `B` (both, with explicitly + different or Settings-primary scopes). +- **Tier** is the proposed default picker visibility. A hidden tier remains + runnable only through an invocation path that independently passes admission. +- `target` in a proposed gate means resolve a concrete target and carry that + identity into execution; it does not mean “rerun whichever target is focused.” + +## Exhaustive 102-command audit + +| ID / current title | Surface | Current gate; state | Scope / safety | Proposed category · tier · home | Required change | +|---|---|---|---|---|---| +| `new-tab` — New Tab | app | none; — | Workspace create / spawns agent | Create · default · C | Keep; native menu must resolve outside picker visibility; spawn boundary deduplicates/rate-limits. | +| `close-tab` — Close Tab | app | none; — | Workspace + process teardown / destructive cascade | Layout & Dispatch · default · C | Require active tab; pin tab; confirm when running or cascade count >1; revalidate before close. | +| `next-tab` — Next Tab | app | none; — | Focus only | Navigate · default · C | Gate/disable when fewer than two tabs; keep action no-op safe. | +| `prev-tab` — Previous Tab | app | none; — | Focus only | Navigate · default · C | Same as `next-tab`. | +| `reorder-tabs` — Reorder Tabs | app | more than one tab; — | Workspace order | Navigate · default · C | Recheck count and tab identities at modal commit; native menu ignores picker visibility. | +| `resume-session` — Resume Session | app | none; — | Provider session create/replace | Session · default · C | Require focused CWD plus a provider with saved-session listing; use provider chooser/friendly unavailable state instead of OpenCode/terminal fallback. | +| `new-agent` — New Agent… | app | active non-tiled tab; — | Workspace/process create | Create · default · C | Rename to New Pane… or stop offering Terminal; require at least one launchable provider/runtime and deduplicate commit. | +| `split-vertical` — Split Pane Right | grid | none; — | Workspace/process create | Create · default · C | One semantic contract across sources: reject in Dispatch or route the key to a distinct direction-free creation command; require default-provider launchability. | +| `split-horizontal` — Split Pane Down | grid | none; — | Workspace/process create | Create · default · C | Same as `split-vertical`. | +| `close-pane` — Close Pane | session | none; — | Process teardown / destructive | Session · default · C | Rename/contextualize as Close Focused Session; require/pin target and confirm running/cascading closes. | +| `bury-pane` — Bury Pane | session | none; — | Workspace placement; process remains live | Layout & Dispatch · advanced · C | Rename Bury Session; require a grid-owned buriable target or implement detached-to-buried; revalidate. | +| `linked-agent` — Linked Agent… | session | target is any agent provider; — | Workspace/process create | Create · advanced · C | Fix Claude/Codex-only copy; validate child-provider launchability and parent identity at commit. | +| `attach-detached-to-grid` — Attach Detached Session to Grid… | dispatch | target is detached; — | Workspace placement | Layout & Dispatch · advanced · C | Keep; preserve strict detached target and validate placement at commit. | +| `pin-agents` — Pin Agents… | dispatch | none; — | Workspace pins | Layout & Dispatch · default · C | Keep empty-state modal; validate selected IDs at commit. | +| `unpin-agent` — Unpin Agent | dispatch | target is pinned; — | Workspace pins | Layout & Dispatch · default · C | Repeat pinned membership for the pinned target before mutation. | +| `attach-all-detached-for-tab` — Attach All Dispatch Sessions for Tab | app | applicable tab has detached sessions; — | Batch workspace placement | Layout & Dispatch · advanced · C | Re-resolve tab and detached set at execution; report partial failure. | +| `detach-to-dispatch` — Detach Session to Dispatch | session | target is grid-owned; — | Workspace placement | Layout & Dispatch · advanced · C | Repeat owner and last-leaf invariants at mutation; disclose exact target. | +| `terminal-horizontal` — New Terminal Right | grid | none; — | Workspace/process create | Create · default · C | Align Dispatch shortcut semantics with command identity; require terminal runtime readiness. | +| `terminal-vertical` — New Terminal Below | grid | none; — | Workspace/process create | Create · default · C | Same as `terminal-horizontal`. | +| `codex-vertical` — New Codex Right | grid | generated; — | Workspace/process create | Create · default · C | Align Dispatch shortcut semantics; require Codex setup/binary launchability at execution. | +| `codex-horizontal` — New Codex Below | grid | generated; — | Workspace/process create | Create · default · C | Same as `codex-vertical`. | +| `opencode-vertical` — New OpenCode Right | grid | generated; — | Workspace/process create | Create · default · C | Require OpenCode setup/binary launchability; no invisible Dispatch behavior. | +| `opencode-horizontal` — New OpenCode Below | grid | generated; — | Workspace/process create | Create · default · C | Same as `opencode-vertical`. | +| `nav-left` — Focus Pane Left | grid | none; — | Focus only | Navigate · default · C | Keybinding and command share the grid-mode admission rule. | +| `nav-right` — Focus Pane Right | grid | none; — | Focus only | Navigate · default · C | Same as `nav-left`. | +| `nav-up` — Focus Pane Up | grid | none; — | Focus only | Navigate · default · C | Separate Dispatch-row navigation identity from grid command/shortcut. | +| `nav-down` — Focus Pane Down | grid | none; — | Focus only | Navigate · default · C | Same as `nav-up`. | +| `undo-close` — Undo Close | app | none; — | Bounded in-memory recovery → workspace/process | Session · default · C | Expose undo-stack availability/reason; pin the recorded entry and revalidate restore ownership. | +| `revive-pane` — Revive Buried Pane | app | any buried session; — | Workspace/process wake | Layout & Dispatch · advanced · C | Rename Revive Buried Session…; gate from the same scoped list the picker displays. | +| `kill-buried-pane` — Kill Buried Pane… | app | any buried session; — | Process teardown / destructive, no undo | Layout & Dispatch · advanced · C | Rename session; add second confirmation/running warning; validate selected record immediately before kill. | +| `toggle-tail` — Tail | session | nonterminal + rendered feed; `Off`/`On`/`On (all)` | Target runtime only | Session · default · C | Rename Auto-follow Focused Agent; semantic `mixed/inherited` state; repeat kind/render gate for pinned target. | +| `toggle-tail-all` — Tail All | app | none; `On`/`Off` | Transient workspace UI policy | Layout & Dispatch · advanced · C | Rename Auto-follow All Visible Agents; retain explicit zero-target stance; semantic boolean state. | +| `jump-latest-message` — Jump to Latest Message | session | nonterminal + rendered feed; — | Scroll only | Navigate · default · C | Re-resolve/pin feed owner before scroll. | +| `copy-last-assistant` — Copy Last Response | session | nonterminal; — | Clipboard | Session · default · C | Gate/disable when no assistant response; repeat target/provider extraction capability. | +| `dispatch-mode` — Dispatch Mode | app | none; `Off`/`Project`/`Global` | Workspace layout | Layout & Dispatch · default · C | Keep; formalize enum/value state and admission inside enter/exit actions. | +| `global-dispatch` — Global Dispatch | dispatch | none; `On`/`Off` | Workspace Dispatch scope | Layout & Dispatch · default · C | Rename Dispatch Scope; state `Project`/`Global`; repeat Dispatch-active admission. | +| `tiled-dispatch` — Tiled Dispatch | app | none; — | Workspace layout | Layout & Dispatch · default · C | Add `Off` or lane-count state; require a project/tab; validate tile count at commit. | +| `normalize-layout` — Normalize Layout | grid | none; — | Workspace layout rewrite | Layout & Dispatch · advanced · C | Repeat grid/active-tab admission at mutation. | +| `hard-normalize-layout` — Hard Normalize Layout | grid | none; — | Strong workspace layout rewrite | Layout & Dispatch · advanced · C | Mark advanced; repeat grid/active-tab admission. | +| `rotate-layout` — Rotate Layout | grid | none; — | Workspace layout rewrite | Layout & Dispatch · advanced · C | Repeat grid/active-tab admission. | +| `toggle-status-mode` — Status Mode | app | none; `On`/`Off` | Persisted app preference | Preferences · default · B | Settings is durable primary home; keep fast command; semantic persisted boolean state. | +| `toggle-performance-panel` — Performance Stats | debug | none; `On`/`Off` | Transient diagnostic UI | Developer · debug · C | Default-hide as debug; add developer/runtime admission if production access is not intended. | +| `toggle-caffeinate` — Caffeinate | app | none; `Off`/`On`/`Unsupported` | Main/OS process | Workspace Tools · default · C | Model loading/error/unsupported; disable or hide unsupported; main revalidates platform and single ownership. | +| `toggle-global-editor` — Global Editor | app | none; `On`/`Off` | Transient surface | Editor & Files · default · C | Add declared shortcut metadata; require project only when opening if empty editor is invalid. | +| `save-editor-file` — Save Editor File | editor | editor open; — | Filesystem write | Editor & Files · default · C | Native menu bypasses picker visibility; receiving editor revalidates visible document, dirty/version state, root and path containment. | +| `save-all-editor-files` — Save All Editor Files | editor | editor open; — | Batch filesystem write | Editor & Files · default · C | Same as save, plus partial-result contract; disable when no dirty files. | +| `quick-open-file` — Quick Open File | editor | focused CWD; — | Read/navigation | Editor & Files · default · C | Keep; revalidate root/CWD when selection opens. | +| `search-in-files` — Search in Files | editor | focused CWD; — | Read/search | Editor & Files · default · C | Keep; expose search backend unavailable/error if applicable. | +| `toggle-editor-fullscreen` — Editor Fullscreen | editor | editor open; `On`/`Off` | Feature runtime | Editor & Files · default · C | Repeat editor-open admission; semantic boolean state. | +| `open-ai-workspace` — Open AI Workspace | editor | none; — | Main-owned workspace reference | Editor & Files · advanced · C | Gate on API/storage readiness or retain an explicit empty/unavailable view. | +| `create-ai-workspace` — Create AI Workspace | editor | none; — | Main-owned create | Editor & Files · advanced · C | Validate name/root/storage readiness at commit. | +| `clear-ai-workspace` — Clear AI Workspace | editor | second-Enter flow later; — | Deletes app metadata, not files | Editor & Files · advanced · C | Keep target-bound arming; revalidate selected workspace ID and dirty attachments at mutation. | +| `toggle-file-tree` — File Tree | editor | editor open; `On`/`Off` | Feature-local persisted view | Editor & Files · default · C | Use panel vocabulary `Open`/`Closed`; repeat editor-open admission. | +| `view-prompts` — View Prompts | session | agent provider; — | Read-only modal | Session · default · C | Provider-neutral copy; repeat target/transcript-history capability. | +| `rewind-to-prompt` — Rewind to Prompt… | session | agent provider + provider session ID + feed lease; — | Provider transcript/session rewrite | Session · advanced · C | Require explicit rewind capability; hide/disable OpenCode; pin target through selection and commit. | +| `undo-rewind` — Undo Rewind | session | matching rewind runtime state; — | Provider/session recovery | Session · advanced · C | Preserve strict undo identity and repeat at action boundary. | +| `open-agent-activity` — Agent Activity… | app | none; — | Read-only modal | Workspace Tools · default · C | Rename Open Agent Activity…; explicit empty state is acceptable. | +| `close-old-agents` — Close Old Agents… | app | none; — | Batch process teardown / destructive | Workspace Tools · advanced · C | Re-enumerate activity, running state, ownership, and cascade set immediately after confirmation; report partial results. | +| `switch-agents-provider` — Switch Agents to Another Provider… | app | none; — | Batch provider replacement | Session · advanced · C | Use switch compatibility graph and launchability; preview exact targets; transactional/partial-result contract. | +| `search-conversation-prompts` — Search Conversation Prompts | app | none; — | Read-only transcript search | Workspace Tools · advanced · C | Gate on at least one indexed provider or show explicit empty state; add ellipsis if more input follows. | +| `enable-built-in-mcp-ping` — Built-in MCP Ping | session | dev debug + provider domain support; `On`/`Off` | Session process replacement / diagnostic capability | Developer · debug · C | Repeat dev flag and provider support at execution; pin target; pending/error state. | +| `enable-ai-workspace-mcp` — AI Workspace MCP | session | provider domain support; `On`/`Off` | Session process replacement / capability | Session · advanced · B | Preserve new-session Settings default + per-session command; pin target; pending/error state. | +| `enable-orchestration-mcp` — Orchestration MCP | session | provider domain support; `On`/`Off` | Session process replacement / capability | Session · advanced · B | Same; Claude/Codex only. | +| `enable-agent-transcripts-mcp` — Agent Transcripts MCP | session | provider domain support; `On`/`Off` | Session process replacement / read capability | Session · advanced · B | Same; disclose per-session scope. | +| `enable-agent-management-mcp` — Agent Management MCP | session | provider domain support; `On`/`Off` | Session process replacement / cross-agent capability | Session · advanced · B | Same; disclose authority and pin target; pending/error state. | +| `enable-workflow-mcp` — Workflow MCP | session | provider domain support; `On`/`Off` | Session process replacement / orchestration capability | Session · advanced · B | Preserve **Codex-only** policy; never expose on Claude (native workflow) or OpenCode. | +| `reload-agent` — Reload Agent | session | resumable provider/runtime; provider label | Process replacement | Session · default · C | Treat provider as context value, not toggle; pin target; repeat resume/launch capability; pending/error. | +| `soft-reload-agent` — Soft Reload Agent | session | agent + rendered feed; provider label | Renderer/feed reset | Session · advanced · C | Repeat target/render capability; present provider as context value. | +| `set-agent-view-mode` — Set Agent View Mode... | session | target agent; `Default`/`Agent`/`Terminal` | Persisted per-session override | Session · advanced · B | Rename Agent View for This Session…; typographic ellipsis; show effective default detail; validate provider at selection commit. | +| `copy-resume-command` — Copy Resume Command | session | provider session ID; provider label | Clipboard | Session · advanced · C | Require verified external-resume-command capability; hide OpenCode until its CLI form is verified. | +| `duplicate-agent` — Duplicate Agent | session | agent + provider session ID; — | Provider transcript + process create | Create · advanced · C | Require duplicate/transcript-projection capability; hide OpenCode; pin source; deduplicate spawn. | +| `switch-provider` — Switch Provider | session | any agent provider; provider label | Provider transcript/process replacement | Session · advanced · C | Drive from explicit switch graph; hide OpenCode as source today; validate destination launchability; pending/error. | +| `toggle-git-bar` — Git Bar | app | none; `On`/`Off` | Transient UI | Workspace Tools · default · C | Gate opening on repository/CWD, allow closing unconditionally; use `Open`/`Closed`. | +| `toggle-debug-panel` — Debug Panel | debug | none; `On`/`Off` | Transient diagnostic UI | Developer · debug · C | Default-hide; require target if pane-scoped; use `Open`/`Closed`. | +| `toggle-feed-debug-panel` — Feed Debug Panel | debug | none; `On`/`Off` | Transient diagnostic UI | Developer · debug · C | Default-hide; require feed target when opening. | +| `toggle-proxy-debug-panel` — Proxy Debug Panel | debug | none; `On`/`Off` | Transient diagnostic UI | Developer · debug · C | Default-hide; gate on proxy/runtime availability when opening. | +| `save-debug-logs` — Save Debug Logs | debug | active tab only; — | Sensitive filesystem write | Developer · debug · C | Require concrete target; repeat debug/backend readiness; redaction/size/error contract. | +| `toggle-session-recording` — Toggle Session Recording | debug | recording feature + agent target; — | Sensitive recording journal | Developer · debug · C | Rename Session Recording; add On/Off/Loading/Error; repeat feature/provider/target admission in renderer and main. | +| `attach-recording-note` — Attach Recording Note | debug | recording feature + agent target; — | Recording journal write | Developer · debug · C | Add ellipsis; require active recording and pinned target at reservation/commit. | +| `toggle-rendering-debug-mode` — Rendering Debug Mode | debug | none; `On`/`Off` | Transient invasive diagnostic UI | Developer · debug · C | Default-hide; require rendered target when enabling; retain danger semantic state. | +| `toggle-html-debug-panel` — HTML Debug Panel | debug | none; `On`/`Off` | Transient sensitive snapshot UI | Developer · debug · C | Default-hide; require rendered target when opening; use panel vocabulary. | +| `toggle-dev-debug-panel` — Dev Debug Panel | debug | dev debug enabled; `On`/`Off` | Transient developer UI | Developer · debug · C | Default-hide; repeat dev capability at execution; use panel vocabulary. | +| `dispatch.color-flag.set` — Set color flag | session | concrete target; — | Persisted session-keyed visual metadata | Layout & Dispatch · advanced · C | Rename Set Color Flag…; add current color/value state; confirm whether terminals are valid targets. | +| `toggle-spotlight` — Spotlight | app | none; `On`/`Off` | Workspace view | Navigate · default · C | Require target only when entering; allow exit unconditionally; include target identity if state is target-specific. | +| `toggle-reader-mode` — Reader Mode | session | agent provider; `On`/`Off` | Workspace view | Navigate · default · C | Repeat provider/render target admission; clarify whether `On` belongs to current target or any open reader. | +| `tiled-tabs` — Tiled Tabs | app | none; `On`/`Off` | Workspace view | Navigate · default · C | Require enough selectable tabs when entering; allow closing unconditionally. | +| `open-settings` — Open Settings | app | none; — | Navigation | Preferences · default · C | Update stale category copy; picker visibility must not affect any future native Settings menu item. | +| `toggle-aggressive-debug-persistence` — Persistent Aggressive Debug Logs | debug | none; `On`/`Off` | Persisted app preference + disk cost | Developer · debug · B | Settings is primary explanatory home; keep debug-tier quick override; require backend readiness and explicit persistence/cost copy. | +| `toggle-worktrees-bar` — Worktrees | app | none; `Open`/`Closed` | Transient UI | Workspace Tools · default · C | Gate opening on repo/worktree capability; closing always allowed; rename Worktrees Panel if needed. | +| `toggle-worktree-badges` — Worktree Badges | app | none; `On`/`Off` | Persisted app preference | Preferences · default · B | Settings primary; keep quick visual command; semantic persisted boolean state. | +| `dangerous-agents` — Dangerous Agents | app | none; `On`/`Off` | Persisted safety posture + fleet reload / destructive | Preferences · remove · S | Remove command; Settings confirmation previews affected agents, pins desired value, and reports/rolls back partial reload. Fix contradictory copy. | +| `copy-assistant-message` — Copy Assistant Message… | session | agent + rendered-feed lease; — | Clipboard/read | Session · advanced · C | Repeat provider/render policy before entering picker; pin target through selection. | +| `copy-code-block` — Copy Code Block… | session | agent + rendered-feed lease; — | Clipboard/read | Session · advanced · C | Pin/revalidate target before resolving selected DOM block. | +| `manage-prompt-templates` — Manage Prompt Templates… | app | none; — | Persisted template collection | Workspace Tools · advanced · C | Keep; Settings need not duplicate management UI; validate edits at commit. | +| `prompt-template` — Prompt Template… | session | nonterminal + opens rendered feed; — | Composer draft | Session · default · C | Pin/revalidate target when selected template is inserted. | +| `save-composer-as-prompt-template` — Save Composer as Prompt Template… | session | agent + nonempty draft + rendered feed; — | Persisted template create | Workspace Tools · advanced · C | Snapshot draft deliberately or recheck at form commit; disclose global template scope. | +| `reply-to-selection` — Reply to Selection | session | matching stashed selection; snippet value | Composer draft | Session · default · C | Keep current target-bound revalidation; render snippet as context value, not status. | +| `show-agent-status` — Agent Status | session | agent target; `On`/`Off` | Transient contextual panel | Workspace Tools · default · C | Rename Show Agent Status for Focused Agent or treat panel as `Open`/`Closed`; tolerate/revalidate target loss. | +| `toggle-remote-panel` — Remote Control | app | none; — | Transient panel; network config persists elsewhere | Workspace Tools · experimental · C | Add panel state; opening may be universal, but enable/listen/pair actions require explicit in-panel network disclosure and runtime readiness. | +| `usage.open` — Usage | app | none; — | Read-only modal | Workspace Tools · default · C | Rename Open Usage; provider fetch failures remain isolated in modal. | +| `usage.toggle-header` — Usage in Header | app | none; `On`/`Off` | Persisted app preference | Preferences · default · B | Settings primary; keep quick visual command; semantic persisted boolean state. | +| `usage.cycle-header-level` — Usage Header Detail | app | none; raw lowercase level; also enables header | Persisted app preference | Preferences · advanced · B | Make command pure cycling to match Settings; show user-facing value and `Header off` detail instead of silently enabling. | + +## Cross-cutting defects + +### Missing or inconsistent admission gates + +| Severity | Defect | Affected surface | Required invariant | +|---|---|---|---| +| High | Native menu resolves from the picker-filtered registry, so a `commandVisibilityOverrides[id] = false` preference silently disables File-menu actions. | `new-tab`, `resume-session`, `save-editor-file`, `save-all-editor-files`, `reorder-tabs`, `close-tab` | Native menu resolves a full catalog entry, skips picker visibility, and receives an explicit unavailable reason when contextual admission fails. | +| High | `isAgentProviderKind` is treated as a feature capability. | OpenCode Resume, Rewind, Duplicate, Switch Provider, Copy Resume | Provider membership distinguishes agents from terminals only. Feature gates consume explicit provider/transcript/switch capabilities. | +| Medium | Surface and `when` predicates are picker-time only. Several shortcuts intentionally call the same action under different semantics. | Grid splits/navigation; debug/recording commands; editor save; Reader | The same command ID has one semantic contract across picker, native menu, keybinding, and programmatic dispatch. Every side-effect boundary rechecks admission. | +| Medium | Target-sensitive commands independently resolve in `when`, `getState`, and `run`. | MCP toggles, reload/switch/duplicate, recording, close/bury, reader/copy | One invocation pins the target shown in state. Async/destructive execution either revalidates that exact target or rejects; it never silently retargets. | +| Medium | Unsupported or empty operations remain ordinary executable rows. | Caffeinate, Resume on OpenCode, Bury on detached sessions, no-target close/bury, Undo Close with no history | Discovery-worthy commands may be disabled with a reason; mode-irrelevant commands remain hidden; direct actions with no transition are unavailable. | +| Medium | Debug is a surface label, not a visibility or capability policy. | Performance, debug panels, logs, recording, rendering/HTML/dev debug, aggressive persistence, Ping MCP | All diagnostic commands declare `pickerVisibility: 'debug'`; developer-only actions also require a live developer capability at execution. | +| Medium | Provider spawn commands are generated from registered kinds, not current launchability. | Generic/provider splits, New Agent, Linked/Duplicate | Admission includes setup/binary/platform readiness and the spawn boundary repeats it. | +| Low | Modal entry often uses broad inventory while the modal uses a narrower scoped list. | Revive/Kill Buried, bulk switch/close, transcript searches | Gate and modal source the same resolver; commit re-enumerates or validates selected identities. | + +### State and badge inconsistencies + +`CommandState` is currently `{label: string, tone?: neutral | accent | danger}`. +The renderer uppercases every label into the same chip. This cannot express +whether the text is a boolean, a selected value, contextual information, +effective/inherited truth, unavailable state, or async progress. + +| Problem | Commands | Required presentation | +|---|---|---| +| Missing state on real toggles/modes | `tiled-dispatch`, `toggle-session-recording`, `toggle-remote-panel`, `dispatch.color-flag.set` | Lane count or Off; recording On/Off/Loading/Error; panel Open/Closed; selected color/value. | +| Boolean vocabulary drift | Worktrees uses Open/Closed; panels mostly use On/Off; Dispatch Scope uses On/Off | Preferences/capabilities: On/Off. Panels: Open/Closed. Enum modes: user-facing option labels. | +| Context values masquerade as state | provider badges on Reload/Soft Reload/Copy Resume/Switch Provider; selection snippet on Reply | `kind: 'value'`, neutral context styling, never interpreted as enabled/disabled. | +| Effective state differs from owned state | Tail shows `On (all)` while invoking Tail cannot turn that effective state off | `mixed`/`inherited` with detail `On via Auto-follow All`; action description names the local setting it changes. | +| Persisted preference leads runtime | Dangerous Agents says On before fleet replacement completes | Loading with affected count, Mixed on partial application, Error on failure, On only after policy is applied. | +| Async replacement has no lifecycle | MCP toggles, reload, switch, duplicate, recording | Pending state begins before work; success/failure is observable after palette reopen; duplicate execution is single-flight or rejected. | +| Unavailable is rendered as neutral value | Caffeinate `Unsupported`; Usage detail while header is off | First-class unavailable state and reason; inactive enum value includes `Header off` detail. | +| Target scope is invisible | Reader, Spotlight, Agent Status, MCP toggles, session view mode | Badge/detail names `This session` or carries target identity for exact execution binding. | + +Proposed state contract: + +```ts +type CommandTarget = + | { kind: 'none' } + | { kind: 'app' } + | { kind: 'project'; id: string } + | { kind: 'session'; id: string } + | { kind: 'document'; id: string } + +type CommandState = + | { + kind: 'toggle' + value: 'on' | 'off' | 'mixed' + detail?: string + truth: 'persisted' | 'runtime' | 'effective' + } + | { + kind: 'value' + label: string + detail?: string + truth: 'persisted' | 'runtime' | 'effective' + } + | { + kind: 'status' + value: 'loading' | 'unavailable' | 'error' + detail: string + } + +type CommandAvailability = + | { available: true } + | { available: false; reason: string; presentation: 'hide' | 'disable' } + +type ResolvedCommandInvocation = { + commandId: CommandId + target: CommandTarget + availability: CommandAvailability + state: CommandState | null + execute: () => void | Promise +} +``` + +The exact union may evolve during implementation, but four constraints are not +negotiable: tone is derived from semantics; unavailable is not a fake state +label; targeted state and execution share one identity; and async failures are +handled rather than fire-and-forgotten. + +### Settings versus Commands + +The placement rule is behavioral, not aesthetic: + +| Behavior | Product home | +|---|---| +| Immediate action, navigation, modal/workflow entry, or temporary view | Command. | +| Persistent application preference with no useful momentary override | Settings only. | +| Persistent benign preference users reasonably flip while working | Settings primary plus a quick command, sharing one setter/source of truth. | +| Global default plus a meaningful per-session override | Both, with scope explicitly named. | +| Safety posture, credentials, network exposure, or expensive persistent diagnostics | Settings with explanatory context and confirmation; a command exists only if its safety flow is equivalent. | + +| Settings control | Command | Decision | Required correction | +|---|---|---|---| +| Status Mode | `toggle-status-mode` | Both; Settings primary | Add scope metadata and semantic persisted state. | +| Worktree Badges | `toggle-worktree-badges` | Both; Settings primary | Same. | +| Usage in Header | `usage.toggle-header` | Both; Settings primary | Same. | +| Usage Header Detail | `usage.cycle-header-level` | Both; Settings primary | Stop implicitly enabling the header; show friendly option label and inactive detail. | +| Persistent Aggressive Debug Logs | `toggle-aggressive-debug-persistence` | Both; Settings primary, command debug-hidden | Disclose persistence/disk cost and verify backend readiness. | +| Dangerous Agents By Default | `dangerous-agents` | **Settings only** | Remove command; confirm enablement with affected-agent preview and explicit partial-failure behavior. | +| Agent View Mode | `set-agent-view-mode` | Both with different scope | Settings = app default; command = this session. Rename the command to disclose that. | +| Default built-in MCP rows | five configurable MCP commands | Both with different scope | Settings = new sessions; command = this session. Keep Workflow Codex-only. | +| Default Workspace Mode | Dispatch/Tiled Dispatch commands | Not duplicates | Settings controls initial/default policy; commands control the current workspace. | +| Attach Project Terminal to Dispatch | no command | Settings only, already correct | Preserve as the migration precedent. | + +No data migration is needed to remove `dangerous-agents`: its canonical Settings +field remains. A stale `commandVisibilityOverrides['dangerous-agents']` entry is +harmless, but the implementation should define an explicit retired-built-in ID +policy instead of opportunistically deleting unknown extension/provider IDs. + +Any new or changed persisted Settings field still requires: + +1. a default; +2. coercion of old/malformed values; +3. a persistence-version increment; +4. an idempotent migration test; and +5. one canonical setter used by Settings and any surviving command. + +### Destructive and privileged behavior + +| Severity | Operation | Existing protection | Required addition | +|---|---|---|---| +| High | Close Pane / Close Tab / tab close button / shortcuts | Dispatch-aware targeting, main ownership proof, bounded Undo Close | Confirm a running target or any multi-session cascade; bind confirmation to exact expanded IDs; handle partial kill failure. | +| High | Close Old Agents | Preview modal and user click; running agents excluded in the snapshot | Re-enumerate immediately after confirmation, including activity/running/ownership/cascade changes; serialize and report partial completion. | +| High | Dangerous Agents | Persisted setting and main ownership proof during reload | Settings-only enable confirmation, affected count, preflight, single-flight, rollback or explicit Mixed state. Fix copy that currently says existing agents are unaffected even though they reload. | +| Medium | Kill Buried Session | Separate picker mode and main ownership proof | Second confirmation/running warning; target-bound grant; preserve record on backend rejection. | +| Medium | MCP enable/disable, Reload, Provider Switch, Rewind | Provider/domain checks and feature-specific modals | Pin target, explain process replacement/authority change, prevent duplicate execution, surface kill/spawn partial failure. | +| Medium | Agent Management MCP close tool (outside the 102-command catalog) | Tool prose requires an explicit user request; scope/self/cascade checks | Prose is not an enforceable grant. Add a short-lived user-issued caller/target authorization or renderer confirmation checked at mutation time. | +| Medium | Save / Save All | Explicit save gesture; editor conflict handling | Receiving editor revalidates root, target/version, symlink containment and dirty state for native-menu/programmatic invocation. | +| Low | Debug logs/recording/persistent diagnostics | Debug feature gates and explicit file picker in some paths | Repeat gates in renderer/main, bound retention/size, redact secrets, expose disk-full/cancellation errors. | +| Low | Remote networking controls | Command only opens panel | Keep enabling/listening/pairing behind an explicit in-panel gesture with bind/tunnel disclosure and main-process policy. | + +Confirmation is not a blanket modal before every close. The proposed default is: + +- idle single-session close remains immediate and undoable; +- running/streaming sessions require confirmation; +- any close that expands to linked children, detached tab-owned sessions, or + multiple targets requires an exact count/list confirmation; and +- bulk flows revalidate after confirmation so the grant cannot drift onto new + work. + +## Provider policy + +The command system should consume three explicit authorities: + +1. the provider registry for spawn, live resume, saved-session listing, + rendered-feed, attachments, and verified external CLI capabilities; +2. the transcript-adapter/switch registry for rewind, duplicate, prompt + projection, and allowed provider-switch edges; and +3. the MCP domain table for injected domains and native-feature conflicts. + +| Capability / command family | Claude | Codex | OpenCode | Terminal | Authority | +|---|:---:|:---:|:---:|:---:|---| +| Spawn / named creation | Yes | Yes | Yes | Yes | Provider/runtime launchability. | +| Saved-session Resume picker | Yes | Yes | **No today** | No | Main provider `listSessions` capability. | +| View Prompts / rendered feed actions | Yes | Yes | Yes where feed/history exists | No | Renderer/history capability. | +| Rewind / Duplicate | Yes | Yes | **No today** | No | Transcript adapter capability. | +| Switch Provider | Claude→Codex | Codex→Claude | **No edge today** | No | Explicit switch compatibility graph. | +| Copy Resume Command | Verified | Verified | **Unverified; hide** | No | Optional verified external-resume command. | +| Ping / AI Workspace / Orchestration / Transcript / Agent Management MCP | Yes | Yes | No | No | MCP provider-domain table. | +| Workflow MCP | **No — native Claude workflow** | Yes | No | No | MCP domain table + native-feature conflict. | +| Reader / Tail / Copy rendered content | Yes | Yes | Yes when rendered feed exists | No | Rendered-view policy. | +| Lifecycle/placement | Yes | Yes | Yes | Some lifecycle/placement actions | Workspace ownership/placement policy. | + +Adding a provider must fail compilation or a catalog capability test until all +relevant declarations are supplied. It must not automatically inherit broad +agent features merely because it joined `AGENT_PROVIDER_KINDS`. + +## Target and invocation invariant + +| Visible context | Required command target | +|---|---| +| Grid parent pane | Focused grid session. | +| Selected linked/orchestration child shown inside a grid parent | The visible child, not the physical parent leaf. | +| Classic Dispatch | Strict visible focused row. | +| Tiled Dispatch | Strict selected lane row. | +| Empty or stale tiled lane | Unavailable; never fallback to another row. | +| Detached Dispatch session | That detached session when the command supports detached ownership. | +| Buried session | Only an explicit buried-picker selection; ordinary focused-session commands never wake it implicitly. | + +For target-sensitive commands, resolution produces `{target, state, execute}` in +one snapshot. A later focus change does not retarget the operation. Immediately +before mutation, execution verifies that the pinned target still exists, still +belongs to the expected project/placement, and still has the required provider +and runtime capability. Failure produces an unavailable/error result, not a +fallback. + +## Command and Settings taxonomy + +`surface` remains a machine applicability dimension. It must not also serve as +the user-facing category. Add a required `category`: + +| Command category | Objective rule | +|---|---| +| Create | Creates a tab, pane, session, terminal, agent, or durable template. | +| Navigate | Changes focus or opens a transient reading/navigation surface. | +| Session | Acts on exactly one resolved agent/session. | +| Layout & Dispatch | Changes placement, arrangement, membership, pins, or Dispatch scope. | +| Editor & Files | Primarily acts on a document, file, editor, or AI Workspace reference. | +| Workspace Tools | Opens project/workspace inspection and management tools. | +| Preferences | Mirrors a persisted app preference or opens Settings. | +| Developer | Diagnostics, recording, raw inspection, or support artifacts. | + +Destructive and experimental are metadata, not categories. The Settings +information architecture should likewise separate functional ownership from +maturity: + +1. Appearance +2. Workspace & Layout +3. Interface +4. Agents & Tools +5. Commands & Shortcuts +6. Voice & Dictation +7. Updates +8. Developer +9. Reset & Data + +Every Settings row should expose machine-readable scope/apply/storage metadata, +rendered as small badges: + +```ts +scope: 'app' | 'project' | 'session-default' | 'fresh-install' +apply: 'immediate' | 'new-session' | 'reload-live-sessions' | 'restart-required' +storage: 'settings' | 'workspace' | 'setup' | 'keychain' | 'external-files' +status?: 'experimental' | 'dangerous' | 'developer' +``` + +This also fixes misleading umbrella copy such as “Application defaults,” the +overloaded Workspace/Experimental categories, and Reset Settings' unclear +relationship to separately owned credentials, update policy, and convention +files. + +## Naming and discoverability cleanup + +| Current | Proposed | Reason | +|---|---|---| +| `Set color flag` | `Set Color Flag…` | Title case and additional input. | +| `Toggle Session Recording` | `Session Recording` | Stable toggle title; state lives in badge. | +| `Set Agent View Mode...` | `Agent View for This Session…` | Typographic ellipsis plus explicit scope. | +| `Tail` / `Tail All` | `Auto-follow Focused Agent` / `Auto-follow All Visible Agents` | Names the behavior and target; retain Tail as keyword. | +| `Close Pane` | `Close Focused Session` or contextual Pane/Dispatch label | The command can close a Dispatch row, not only a grid pane. | +| Bury/Revive/Kill “Pane” | Use “Session” | The live object persists without a pane. | +| `New Agent…` offering Terminal | `New Pane…` or remove Terminal choice | Title matches the objects the flow can create. | +| `Global Dispatch` + On/Off | `Dispatch Scope` + Project/Global | The behavior selects a scope, not a boolean. | +| raw usage levels | `Minimal`, `Providers`, `All Limits`, `Detailed` | Match Settings labels and title case. | + +The command-visibility Settings row must stop being a flat title-only catalog. +It should group by command category and make nested command titles, descriptions, +keywords, shortcuts, tier, scope, and risk searchable. It must disclose when a +hidden command has no shortcut or alternate UI; “still executable” is not the +same as practically discoverable. + +## Phased implementation plan + +The phases below are intended as separately reviewable commits on this branch. +Each phase has a behavior boundary and rollback point. Do not combine catalog +extraction, provider policy, destructive confirmation, Settings migrations, and +visual defaults in one commit. + +### Phase 0 — Characterize the current catalog + +Files: + +- add `src/renderer/src/features/command-palette/catalog.ts` as a read-only + export of the current ordered definitions; +- add `src/renderer/src/features/command-palette/catalog.test.ts` and an ordered + catalog snapshot; +- add/expand `src/main/menu/appMenu.test.ts`; +- expand Settings persistence/registry tests. + +Work: + +1. Move only the concatenation into `catalog.ts`; keep runtime filtering and + behavior unchanged. +2. Validate all definitions before contextual filtering: unique ID, nonempty + static catalog label/description, known surface/tier, and a run handler. +3. Assert the exact ordered **102-ID** snapshot and the generated provider + invariant `(providers - default) × {vertical, horizontal}`. +4. Assert every native-menu ID exists in the executable catalog and every + catalog ID appears in command-visibility Settings. +5. Capture the current shortcut metadata and dynamic-title fallback so later UX + changes are explicit snapshot updates. + +Rollback boundary: delete the new catalog API/tests and restore the existing +array; no product behavior will have changed. + +### Phase 1 — Separate discoverability from admission + +Files: + +- `features/command-palette/types.ts` +- `features/command-palette/catalog.ts` +- `features/command-palette/registry.ts` +- new `features/command-palette/pickerVisibility.ts` +- new `features/command-palette/executeCommand.ts` +- `features/command-palette/ui/CommandPalette.tsx` +- `main/menu/appMenu.ts` and preload menu types/tests + +Work: + +1. Extract one context-free picker-visibility resolver used by the picker and + Settings. Visibility remains presentation-only. +2. Replace the non-exhaustive `surfaceAvailable` fallthrough with an exhaustive + switch and `assertNever`. +3. Resolve native-menu IDs from the full catalog, apply contextual admission, + and deliberately skip picker visibility. +4. Introduce `dispatchCommand({source, id})` for `palette`, `native-menu`, + `keybinding`, and `programmatic` sources. Centralize fresh admission, + promise rejection/error reporting, history policy, and single-flight hooks. +5. Route picker and native menu through it first. Keep legacy direct keybinding + actions temporarily, covered by parity tests, rather than claiming a false + flag-day migration. + +Rollback boundary: the old `buildCommandRegistry` remains available behind an +adapter until menu/picker parity tests pass. + +### Phase 2 — Add typed availability, targets, safety, and categories + +Files: + +- `features/command-palette/types.ts` +- `features/command-palette/catalog.ts` +- `features/command-palette/registry.ts` +- `workspace/hook/selectors/commandTargetSessionId.ts` +- command modules as their metadata is populated + +Work: + +1. Add required `category`, declared `pickerVisibility`, target kind, and safety + metadata to `CommandDef`. +2. Add unavailable reasons with `hide` versus `disable` presentation. +3. Add target-aware resolved invocations without making app/editor commands + invent session targets. +4. Pin one session ID for high-risk commands first: close/bury, MCP toggles, + reload/switch/duplicate/rewind, recording, and filesystem writes. +5. Add a mutation-boundary admission helper that consumes the same capability + predicate as presentation but re-reads authoritative state. + +Rollback boundary: definitions can use a legacy adapter during migration; no +high-risk command switches until its target/admission matrix test exists. + +### Phase 3 — Apply visibility and taxonomy policy + +Files: + +- all feature-owned command modules +- `features/settings/lib/settingsRegistry.ts` +- `features/settings/ui/SettingsList.tsx` +- command ranking/search metadata tests + +Work: + +1. Mark diagnostic commands `debug` and the table's niche operations + `advanced`; keep daily reversible actions `default`; mark Remote Control + `experimental` until its runtime/support policy is final. +2. Populate the required command categories from the exhaustive table. +3. Group command-visibility Settings by category and search nested command + metadata, not merely the parent setting row. +4. Display title, description, shortcut, tier, target/scope, and safety status. +5. Give dynamic-title commands a friendly stable catalog label rather than a + raw ID. +6. Preserve explicit user visibility overrides when declared defaults change. + +Rollback boundary: metadata-only commit; reverting restores the current flat, +all-visible picker without touching execution authority. + +### Phase 4 — Make provider capability policy authoritative + +Files: + +- `src/providers/registry.renderer.capabilities.ts` +- `src/providers/registry.main.ts` +- provider identity files +- `src/main/providerSwitch/transcriptEngine.ts` +- provider switch compatibility code +- `src/mcp/shared/types.ts` +- `sessionCommands.ts`, `paneCommands.ts`, and Resume UI + +Work: + +1. Declare saved-session listing, transcript projection/rewind/duplicate, + provider-switch edges, verified external resume command, rendered-feed, and + launchability capabilities. +2. Gate Resume, Rewind, Duplicate, Switch Provider, and Copy Resume through + those authorities. OpenCode is unavailable for those unsupported operations + until its adapters are real. +3. Generate provider names in copy from the same capability set used by gates. +4. Add setup/binary/runtime readiness to provider creation commands. +5. Preserve the existing MCP matrix exactly: Workflow MCP remains Codex-only, + Claude keeps its native workflow surface, and OpenCode gets no injected + built-in MCP domains. + +Rollback boundary: capability declarations land with characterization tests; +each command family switches to them in a separate commit. + +### Phase 5 — Replace arbitrary badge text with semantic state + +Files: + +- `features/command-palette/types.ts` +- command badge UI inside `CommandPalette.tsx` +- a shared badge renderer usable by picker/details/Settings +- stateful command modules and async operation stores + +Work: + +1. Introduce toggle/value/status state variants and derive tone, icon, checkmark, + and accessible text from the variant. +2. Convert simple booleans first, then enum/context values, then + mixed/inherited Tail state. +3. Add missing state for Tiled Dispatch, Remote Panel, Session Recording, and + Color Flag. +4. Model Caffeinate loading/unsupported/error instead of treating null as Off. +5. Persist operation state for MCP/reload/switch/recording so pending and error + survive palette close/reopen. +6. Include target/scope detail where a global panel state and a session target + could otherwise be confused. + +Rollback boundary: keep a temporary adapter from old `{label, tone}` states; +remove it only after every `getState` definition is migrated and snapshot-tested. + +### Phase 6 — Move ownership and harden destructive flows + +Files: + +- `features/settings/commands/{settings,dangerous}Commands.ts` +- `features/settings/lib/settingsRegistry.ts` +- `features/usage/commands/usageCommands.ts` +- `features/workspace/ui/CloseOldAgentsModal.tsx` +- pane/tab/session mutation actions and confirmation UI +- Agent Management MCP bridge/contracts if the adjacent grant fix is accepted + +Work: + +1. Remove `dangerous-agents` from the command catalog. Keep its canonical + Settings field and add an enable confirmation with the exact live reload set. +2. Make dangerous-agent reload single-flight and expose success, Mixed partial + application, failure, and rollback semantics. Correct contradictory copy. +3. Keep Status Mode, Worktree Badges, Usage Header, Usage Detail, and Aggressive + Debug Persistence as Settings-primary quick commands; make Usage Detail a + pure cycle. +4. Add running/cascade confirmation for tab/session close from every source, + including buttons and shortcuts, with a target-bound grant. +5. Re-enumerate Close Old Agents after confirmation and before every kill. +6. Add a second confirmation for Kill Buried Session. +7. If included in this PR, replace Agent Management MCP close's prose-only + permission with an enforceable short-lived caller/target grant or renderer + confirmation. Preserve its project/self/cascade checks. + +Rollback boundary: Settings ownership, ordinary close confirmation, bulk close, +and MCP grants are separate commits because they affect different authorities. + +### Phase 7 — Naming, Settings information architecture, and cleanup + +Files: + +- command modules named in the audit table +- `docs/command-style.md` +- Settings registry/page/list components +- Settings coercion/version migration only if metadata is persisted + +Work: + +1. Apply stable title/ellipsis/target vocabulary corrections. +2. Add Settings scope/apply/storage/status metadata and render concise badges. +3. Split Settings categories as proposed; clarify Reset Settings' actual scope. +4. Add versioned command-ID aliases for any renamed IDs. Prefer title-only + changes so user visibility overrides and keybindings retain stable IDs. +5. Remove the temporary state adapter and legacy execution paths only after + catalog, invocation, and UI matrices are green. + +Rollback boundary: visual/copy/category changes are isolated from command ID and +persistence changes. + +## Required test matrices + +### Catalog integrity + +- Exact ordered 102-ID snapshot and explicit count. +- Unique IDs; required metadata; valid surface/category/tier/safety values. +- Generated provider split parity. +- Native-menu IDs are a subset of executable catalog IDs. +- Settings command catalog equals the command catalog, excluding explicitly + retired IDs and transient agent-index destinations. +- Descriptions are validated before contextual filtering, including hidden + commands. + +### Presentation and invocation + +Cross representative commands over: + +| Axis | Values | +|---|---| +| Layout | Grid, project Dispatch, global Dispatch, tiled Dispatch. | +| Surface | app, grid, dispatch, session, editor, debug. | +| Context predicate | absent, true, false. | +| Declared tier | default, advanced, experimental, debug. | +| User override | absent, true, false. | +| Reveal-all | false, true. | +| Invocation source | picker, native menu, keybinding, programmatic. | +| Render policy | Agent, Terminal, Hybrid with/without lease. | +| Target | none, grid parent, selected child, detached row, stale/empty lane. | + +Assertions: + +- hidden affects picker only; +- native menu diagnoses unavailable versus unknown IDs; +- keybindings cannot bypass capability/safety admission; +- excluded commands do not evaluate state; +- async rejection is user-visible and never unhandled; and +- registry/picker order stays stable. + +### Provider/capability + +- Claude/Codex/OpenCode/Terminal matrix exactly matches the table above. +- OpenCode Resume/Rewind/Duplicate/Switch/Copy Resume are unavailable until a + declared implementation capability exists. +- Claude never receives Workflow MCP; Codex does; OpenCode/Terminal do not. +- Adding an agent provider fails capability parity until every required policy + decision is supplied. +- Provider setup/binary unavailability blocks spawn in picker and at mutation. + +### Target and state + +- Grid parent, selected related child, classic Dispatch row, tiled lane, stale + lane, detached session, buried record. +- Focus change between palette render and invocation never silently retargets. +- Target closes/provider changes after confirmation: invocation rejects safely. +- Boolean/value/status rendering, accessibility, and details agree in picker, + preview panel, and Settings. +- Tail inherited state; Caffeinate loading/error; recording and MCP + pending/success/failure; dangerous fleet Mixed state. + +### Safety and persistence + +- Close via palette, keybinding, tab button, native menu/programmatic path. +- Running target, linked descendants, last-pane detached ownership, rapid + duplicate invocation, backend kill failure, and partial cascade. +- Close Old Agents activity begins after preview; ownership/cascade changes; + partial failure/retry. +- Settings coercion for absent, malformed, same-version-missing, unknown, + renamed, and retired visibility keys. +- Defaults changing while explicit user overrides remain stable. +- Reset behavior across renderer Settings versus main-owned setup/keychain/file + state is explicit and tested. + +## Acceptance matrix + +| Requirement | Acceptance evidence | +|---|---| +| Complete audit | Catalog test reports exactly 102 ordered static IDs and all appear in this document/Settings metadata. | +| Visibility is not authorization | Hiding each native File-menu command does not disable its menu item; direct invocation still fails any real contextual/capability gate. | +| Debug clutter is gated | Debug-tier commands are absent by default, revealable/overridable, and remain protected by live capability checks where required. | +| Consistent state | No stateful command returns arbitrary tone semantics; panels, toggles, values, unavailable, loading, mixed, and error states render predictably and accessibly. | +| Correct provider policy | Unsupported OpenCode operations do not appear as executable; Claude Workflow MCP remains blocked; provider additions require explicit capabilities. | +| Stable target | State and mutation refer to the same pinned target across focus, Dispatch lane, provider, and lifecycle changes. | +| Settings ownership | Dangerous Agents is Settings-only with confirmation; benign quick preferences share one source; MCP/view defaults and session overrides disclose scope. | +| Destructive safety | Running/cascading closes and all bulk closes are target-bound, freshly validated, and expose partial failure without silent retargeting. | +| Invocation parity | Palette, menu, shortcut, and programmatic paths share admission/error handling while retaining source-specific UI behavior. | +| Migration safety | Persistence-version/coercion/alias tests pass; command IDs remain stable unless an explicit idempotent alias migration exists. | +| Discoverability | Command Settings is grouped and searchable by nested titles, keywords, description, shortcut, tier, scope, and risk. | + +## Validation commands + +During implementation, run focused tests after each phase and the full contract +before declaring the PR complete: + +```bash +npm run test:unit +npm run test:renderer +npm run test:system +npm run typecheck +npm test +npm run test:package +npm run check +``` + +Manual packaged-app checks must cover: + +1. hide each native File-menu command and invoke it from the menu; +2. Grid/project Dispatch/global Dispatch/tiled Dispatch targeting; +3. Claude/Codex/OpenCode/Terminal command availability; +4. Caffeinate on supported and unsupported platforms; +5. debug reveal/override behavior; +6. focus change while an MCP/reload/switch operation is pending; +7. running and cascading close confirmation from button and shortcut paths; +8. Settings restart persistence and new-session versus current-session scope; +9. Claude does not receive Workflow MCP while Codex does; and +10. assistive-technology text for every semantic badge state. + +## Product decisions requested at review + +The source establishes the defects above, but these UX choices genuinely need +product confirmation before implementation: + +1. **Unavailable presentation.** Recommendation: hide mode-irrelevant commands; + show discovery-worthy unsupported commands disabled with a reason when the + user searches for them. +2. **Persistent quick preferences.** Recommendation: remove only Dangerous + Agents; keep Status Mode, Worktree Badges, Usage Header/Detail, and Aggressive + Debug Persistence as Settings-primary commands (the last one debug-hidden). +3. **Close confirmation threshold.** Recommendation: confirm running/streaming + targets and any cascade/multi-target close; keep an idle single close + immediate and undoable. +4. **Directional shortcuts in Dispatch.** Recommendation: one command ID must + have one meaning. Dispatch keybindings should invoke a distinct direction-free + creation/navigation command rather than bypassing a hidden grid command. +5. **Remote Control maturity.** Recommendation: classify the panel + `experimental` until packaged runtime/network readiness and disclosure are + complete; keep actual enable/listen/pair actions separately authorized. +6. **Agent Management MCP close grant.** Recommendation: include the enforceable + explicit-user-intent grant in this governance PR because prose-only safety is + not a real gate. It is listed separately because it is an MCP tool, not one + of the 102 palette commands. + +## Non-goals + +- Do not remove session-level MCP toggles; Settings owns defaults for new + sessions, while commands intentionally own current-session overrides. +- Do not enable Workflow MCP for Claude; it duplicates a native feature. +- Do not make picker visibility a security or capability boundary. +- Do not replace main-process ownership checks with renderer-only confirmation. +- Do not persist transient panels/layout stances merely because they have state + badges. +- Do not force every keybinding through the new gateway in one change; migrate + high-risk bindings first with parity tests. +- Do not delete unknown command visibility keys indiscriminately; future + extension/provider commands may be temporarily absent. +- Do not implement any item in this document before the review checkpoint is + approved. From 9b28e7f4fe7750b84236890014fce83145460714 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 24 Jul 2026 00:01:12 +0200 Subject: [PATCH 02/33] docs: record command ownership decisions --- .../plans/2026-07-23-command-surface-audit.md | 155 +++++++++++++----- 1 file changed, 110 insertions(+), 45 deletions(-) diff --git a/docs/superpowers/plans/2026-07-23-command-surface-audit.md b/docs/superpowers/plans/2026-07-23-command-surface-audit.md index 688b55ac..12e83ad5 100644 --- a/docs/superpowers/plans/2026-07-23-command-surface-audit.md +++ b/docs/superpowers/plans/2026-07-23-command-surface-audit.md @@ -88,7 +88,8 @@ collapsed into a boolean filter: | Execution | Add one command execution gateway with invocation source and a fresh admission check. | Picker, native menu, keybindings, and programmatic calls currently have different guarantees. | | Visibility | Make visibility strictly picker-only; classify developer commands as `debug` and niche supported operations as `advanced`. | The tiers already exist but are unused, and native-menu execution currently depends on them accidentally. | | State | Replace `{label, tone}` with semantic toggle/value/status states plus unavailable/loading/error and target identity. | Color and arbitrary text cannot distinguish boolean truth, values, inherited state, async work, or unsupported commands. | -| Settings ownership | Remove `dangerous-agents` from Commands; keep Settings as the durable home for five benign quick preferences; preserve Settings-default + per-session-command for MCP and agent view. | Safety posture changes need Settings context and confirmation. Session overrides are intentionally different from global defaults. | +| Settings ownership | Retire `toggle-status-mode`, `toggle-worktree-badges`, `usage.toggle-header`, `usage.cycle-header-level`, and `dangerous-agents` from Commands; keep their durable controls in Settings. Preserve Settings-default + per-session-command for MCP and agent view, preserve current-workspace layout commands, and keep only Aggressive Debug Persistence as a Settings-primary quick command. | A persisted preference with no meaningful momentary scope should have one product home. Session MCP/view overrides and current-workspace layout actions are intentionally different from global defaults. | +| Dispatch project terminal | Remove the entire opt-in auto-created project-terminal side-column feature, including its Settings field, renderer/action plumbing, persistence shape, and stale migration comments. | It is not merely a misplaced command: the product behavior itself is being retired. Ordinary user-created terminals and their normal Dispatch rows remain unrelated and must keep working. | | Providers | Gate features by declared capability, not `isAgentProviderKind()`. | OpenCode currently receives Rewind, Duplicate, Resume, Switch Provider, and Copy Resume affordances that are empty, rejected, unsupported, or unverified. | | Targeting | Resolve and pin one target for state + execution, then revalidate identity/capability before mutation. | The shared grid/Dispatch resolver is sound, but repeated resolution can display state for A and mutate B after focus changes. | | Destructive actions | Confirm active/running or cascading closes, re-enumerate bulk-close targets at commit, and keep ownership checks in main. | Undo does not recover live terminal state, unsent drafts, or partial cascades. A stale preview must not authorize a later changed target set. | @@ -110,9 +111,30 @@ collapsed into a boolean filter: it. - `reply-to-selection` is the strongest current example of revalidating its target-specific transient input at execution time. -- The removed `toggle-dispatch-terminal` command is the correct precedent for - moving a durable preference into Settings when a transient command created - misleading persistence expectations. +- User-created terminal sessions already participate in ordinary workspace and + Dispatch row rendering. That independent terminal behavior must survive the + removal of the auto-created project-terminal companion column. + +## Product decisions recorded after initial review + +These decisions supersede the corresponding recommendations in the initial +audit: + +1. Retire these five built-in command IDs while retaining their canonical + Settings controls: `toggle-status-mode`, `toggle-worktree-badges`, + `usage.toggle-header`, `usage.cycle-header-level`, and `dangerous-agents`. + The two Usage IDs are both listed deliberately: the repeated Usage note in + review is treated as removing both the header toggle and the detail cycler. +2. Keep `toggle-aggressive-debug-persistence` as the sole Settings-primary + quick preference command from this group, hidden in the debug tier. +3. Keep every supported per-session MCP command. Settings chooses defaults for + future sessions; Commands changes the current session. Workflow MCP remains + Codex-only and blocked for Claude because Claude has the native equivalent. +4. Keep Dispatch and Tiled Dispatch commands. Settings chooses a default + workspace mode; those commands act on the current workspace. +5. Remove the complete **Attach Project Terminal to Dispatch** feature. This + means the opt-in, auto-created companion terminal and its dedicated Dispatch + side column—not user-created terminal sessions or ordinary terminal rows. ## Canonical source map @@ -190,7 +212,7 @@ collapsed into a boolean filter: | `normalize-layout` — Normalize Layout | grid | none; — | Workspace layout rewrite | Layout & Dispatch · advanced · C | Repeat grid/active-tab admission at mutation. | | `hard-normalize-layout` — Hard Normalize Layout | grid | none; — | Strong workspace layout rewrite | Layout & Dispatch · advanced · C | Mark advanced; repeat grid/active-tab admission. | | `rotate-layout` — Rotate Layout | grid | none; — | Workspace layout rewrite | Layout & Dispatch · advanced · C | Repeat grid/active-tab admission. | -| `toggle-status-mode` — Status Mode | app | none; `On`/`Off` | Persisted app preference | Preferences · default · B | Settings is durable primary home; keep fast command; semantic persisted boolean state. | +| `toggle-status-mode` — Status Mode | app | none; `On`/`Off` | Persisted app preference | Preferences · remove · S | Retire the command and any visibility override for its built-in ID; retain Status Mode and `showStatusMode` in Settings as the only product control. | | `toggle-performance-panel` — Performance Stats | debug | none; `On`/`Off` | Transient diagnostic UI | Developer · debug · C | Default-hide as debug; add developer/runtime admission if production access is not intended. | | `toggle-caffeinate` — Caffeinate | app | none; `Off`/`On`/`Unsupported` | Main/OS process | Workspace Tools · default · C | Model loading/error/unsupported; disable or hide unsupported; main revalidates platform and single ownership. | | `toggle-global-editor` — Global Editor | app | none; `On`/`Off` | Transient surface | Editor & Files · default · C | Add declared shortcut metadata; require project only when opening if empty editor is invalid. | @@ -239,7 +261,7 @@ collapsed into a boolean filter: | `open-settings` — Open Settings | app | none; — | Navigation | Preferences · default · C | Update stale category copy; picker visibility must not affect any future native Settings menu item. | | `toggle-aggressive-debug-persistence` — Persistent Aggressive Debug Logs | debug | none; `On`/`Off` | Persisted app preference + disk cost | Developer · debug · B | Settings is primary explanatory home; keep debug-tier quick override; require backend readiness and explicit persistence/cost copy. | | `toggle-worktrees-bar` — Worktrees | app | none; `Open`/`Closed` | Transient UI | Workspace Tools · default · C | Gate opening on repo/worktree capability; closing always allowed; rename Worktrees Panel if needed. | -| `toggle-worktree-badges` — Worktree Badges | app | none; `On`/`Off` | Persisted app preference | Preferences · default · B | Settings primary; keep quick visual command; semantic persisted boolean state. | +| `toggle-worktree-badges` — Worktree Badges | app | none; `On`/`Off` | Persisted app preference | Preferences · remove · S | Retire the command and any visibility override for its built-in ID; retain Worktree Badges and `showWorktreeBadges` in Settings as the only product control. | | `dangerous-agents` — Dangerous Agents | app | none; `On`/`Off` | Persisted safety posture + fleet reload / destructive | Preferences · remove · S | Remove command; Settings confirmation previews affected agents, pins desired value, and reports/rolls back partial reload. Fix contradictory copy. | | `copy-assistant-message` — Copy Assistant Message… | session | agent + rendered-feed lease; — | Clipboard/read | Session · advanced · C | Repeat provider/render policy before entering picker; pin target through selection. | | `copy-code-block` — Copy Code Block… | session | agent + rendered-feed lease; — | Clipboard/read | Session · advanced · C | Pin/revalidate target before resolving selected DOM block. | @@ -250,8 +272,8 @@ collapsed into a boolean filter: | `show-agent-status` — Agent Status | session | agent target; `On`/`Off` | Transient contextual panel | Workspace Tools · default · C | Rename Show Agent Status for Focused Agent or treat panel as `Open`/`Closed`; tolerate/revalidate target loss. | | `toggle-remote-panel` — Remote Control | app | none; — | Transient panel; network config persists elsewhere | Workspace Tools · experimental · C | Add panel state; opening may be universal, but enable/listen/pair actions require explicit in-panel network disclosure and runtime readiness. | | `usage.open` — Usage | app | none; — | Read-only modal | Workspace Tools · default · C | Rename Open Usage; provider fetch failures remain isolated in modal. | -| `usage.toggle-header` — Usage in Header | app | none; `On`/`Off` | Persisted app preference | Preferences · default · B | Settings primary; keep quick visual command; semantic persisted boolean state. | -| `usage.cycle-header-level` — Usage Header Detail | app | none; raw lowercase level; also enables header | Persisted app preference | Preferences · advanced · B | Make command pure cycling to match Settings; show user-facing value and `Header off` detail instead of silently enabling. | +| `usage.toggle-header` — Usage in Header | app | none; `On`/`Off` | Persisted app preference | Preferences · remove · S | Retire the command and any visibility override for its built-in ID; retain `usageHeaderEnabled` and its Settings toggle. | +| `usage.cycle-header-level` — Usage Header Detail | app | none; raw lowercase level; also enables header | Persisted app preference | Preferences · remove · S | Retire the compound command instead of repairing it; retain `usageHeaderLevel` as a friendly Settings select that never implicitly enables the header. | ## Cross-cutting defects @@ -283,7 +305,7 @@ effective/inherited truth, unavailable state, or async progress. | Effective state differs from owned state | Tail shows `On (all)` while invoking Tail cannot turn that effective state off | `mixed`/`inherited` with detail `On via Auto-follow All`; action description names the local setting it changes. | | Persisted preference leads runtime | Dangerous Agents says On before fleet replacement completes | Loading with affected count, Mixed on partial application, Error on failure, On only after policy is applied. | | Async replacement has no lifecycle | MCP toggles, reload, switch, duplicate, recording | Pending state begins before work; success/failure is observable after palette reopen; duplicate execution is single-flight or rejected. | -| Unavailable is rendered as neutral value | Caffeinate `Unsupported`; Usage detail while header is off | First-class unavailable state and reason; inactive enum value includes `Header off` detail. | +| Unavailable is rendered as neutral value | Caffeinate `Unsupported`; Usage detail command while header is off | First-class unavailable state and reason for surviving commands. Retiring `usage.cycle-header-level` removes the command-specific inconsistency; Settings still shows the selected detail without turning the header on. | | Target scope is invisible | Reader, Spotlight, Agent Status, MCP toggles, session view mode | Badge/detail names `This session` or carries target identity for exact execution binding. | Proposed state contract: @@ -341,27 +363,35 @@ The placement rule is behavioral, not aesthetic: |---|---| | Immediate action, navigation, modal/workflow entry, or temporary view | Command. | | Persistent application preference with no useful momentary override | Settings only. | -| Persistent benign preference users reasonably flip while working | Settings primary plus a quick command, sharing one setter/source of truth. | +| Persistent preference with no distinct momentary scope | Settings only. A quick command is an explicit exception, not the default. | | Global default plus a meaningful per-session override | Both, with scope explicitly named. | | Safety posture, credentials, network exposure, or expensive persistent diagnostics | Settings with explanatory context and confirmation; a command exists only if its safety flow is equivalent. | | Settings control | Command | Decision | Required correction | |---|---|---|---| -| Status Mode | `toggle-status-mode` | Both; Settings primary | Add scope metadata and semantic persisted state. | -| Worktree Badges | `toggle-worktree-badges` | Both; Settings primary | Same. | -| Usage in Header | `usage.toggle-header` | Both; Settings primary | Same. | -| Usage Header Detail | `usage.cycle-header-level` | Both; Settings primary | Stop implicitly enabling the header; show friendly option label and inactive detail. | +| Status Mode | `toggle-status-mode` | **Settings only** | Remove the command; retain the existing setting and canonical field. | +| Worktree Badges | `toggle-worktree-badges` | **Settings only** | Remove the command; retain the existing setting and canonical field. | +| Usage in Header | `usage.toggle-header` | **Settings only** | Remove the command; retain the existing setting and canonical field. | +| Usage Header Detail | `usage.cycle-header-level` | **Settings only** | Remove the command; keep a friendly Settings select that does not implicitly enable the header. | | Persistent Aggressive Debug Logs | `toggle-aggressive-debug-persistence` | Both; Settings primary, command debug-hidden | Disclose persistence/disk cost and verify backend readiness. | | Dangerous Agents By Default | `dangerous-agents` | **Settings only** | Remove command; confirm enablement with affected-agent preview and explicit partial-failure behavior. | | Agent View Mode | `set-agent-view-mode` | Both with different scope | Settings = app default; command = this session. Rename the command to disclose that. | | Default built-in MCP rows | five configurable MCP commands | Both with different scope | Settings = new sessions; command = this session. Keep Workflow Codex-only. | | Default Workspace Mode | Dispatch/Tiled Dispatch commands | Not duplicates | Settings controls initial/default policy; commands control the current workspace. | -| Attach Project Terminal to Dispatch | no command | Settings only, already correct | Preserve as the migration precedent. | +| Attach Project Terminal to Dispatch | no command | **Remove feature** | Remove the Settings row, `dispatchProjectTerminal` field/default/coercion, auto-create action/export, dedicated Dispatch side column/effect/imports, and stale comments/tests. Preserve normal terminal creation and ordinary terminal rows. | -No data migration is needed to remove `dangerous-agents`: its canonical Settings -field remains. A stale `commandVisibilityOverrides['dangerous-agents']` entry is -harmless, but the implementation should define an explicit retired-built-in ID -policy instead of opportunistically deleting unknown extension/provider IDs. +No Settings-value migration is needed for the five retired commands because +their canonical fields remain. Their built-in IDs must be removed from +`commandVisibilityOverrides` through an explicit retired-built-in ID policy; +unknown extension/provider IDs must not be deleted opportunistically. + +Removing Attach Project Terminal is a persisted-shape change. Because +`coerceSettings()` currently spreads the parsed object before applying explicit +fields, simply deleting the typed property would allow an old +`dispatchProjectTerminal` key to survive at runtime and be reserialized. The +implementation must explicitly omit that retired key, increment the persisted +store version, and prove with an idempotent migration test that old true/false +values are ignored and do not return after the next save. Any new or changed persisted Settings field still requires: @@ -681,29 +711,49 @@ Files: - `features/settings/commands/{settings,dangerous}Commands.ts` - `features/settings/lib/settingsRegistry.ts` - `features/usage/commands/usageCommands.ts` +- `app-state/settings/{types,persistence}.ts` and store/settings migration tests +- `workspace/dispatch/DispatchLayout.tsx` +- `workspace/hook/actions/dispatch.ts` and `workspace/hook/index.ts` +- stale project-terminal comments in workspace types, bootstrap, and layout + commands - `features/workspace/ui/CloseOldAgentsModal.tsx` - pane/tab/session mutation actions and confirmation UI - Agent Management MCP bridge/contracts if the adjacent grant fix is accepted Work: -1. Remove `dangerous-agents` from the command catalog. Keep its canonical - Settings field and add an enable confirmation with the exact live reload set. -2. Make dangerous-agent reload single-flight and expose success, Mixed partial +1. Retire `toggle-status-mode`, `toggle-worktree-badges`, + `usage.toggle-header`, `usage.cycle-header-level`, and `dangerous-agents` + from the command catalog. Keep their five canonical Settings controls, and + explicitly clean only those retired built-in IDs from visibility overrides. +2. Update the ordered catalog snapshot with an intentional five-ID retirement + delta. The current 102-command audit remains the before-state; any separately + approved new command ID is itemized rather than hidden inside the count. +3. Add the dangerous-agent enable confirmation with the exact live reload set. + Make reload single-flight and expose success, Mixed partial application, failure, and rollback semantics. Correct contradictory copy. -3. Keep Status Mode, Worktree Badges, Usage Header, Usage Detail, and Aggressive - Debug Persistence as Settings-primary quick commands; make Usage Detail a - pure cycle. -4. Add running/cascade confirmation for tab/session close from every source, +4. Keep only Aggressive Debug Persistence as a Settings-primary quick command + from this preference group and classify it as debug-hidden. +5. Remove Attach Project Terminal to Dispatch end to end: + `dispatch-project-terminal` from the Settings registry; + `Settings.dispatchProjectTerminal`, its default and coercion; the + `DispatchLayout` auto-create effect and dedicated terminal column; and the + now-single-purpose `ensureDispatchTerminal` action, interface, hook export, + deduplication state, imports, and historical comments. Explicitly omit the + old key during versioned coercion so it cannot be spread back into storage. + Do not remove normal terminal creation, tile-tree rendering, or terminal rows + in Dispatch. +6. Add running/cascade confirmation for tab/session close from every source, including buttons and shortcuts, with a target-bound grant. -5. Re-enumerate Close Old Agents after confirmation and before every kill. -6. Add a second confirmation for Kill Buried Session. -7. If included in this PR, replace Agent Management MCP close's prose-only +7. Re-enumerate Close Old Agents after confirmation and before every kill. +8. Add a second confirmation for Kill Buried Session. +9. If included in this PR, replace Agent Management MCP close's prose-only permission with an enforceable short-lived caller/target grant or renderer confirmation. Preserve its project/self/cascade checks. -Rollback boundary: Settings ownership, ordinary close confirmation, bulk close, -and MCP grants are separate commits because they affect different authorities. +Rollback boundary: command retirement, project-terminal feature removal, +ordinary close confirmation, bulk close, and MCP grants are separate commits +because they affect different authorities and persistence shapes. ### Phase 7 — Naming, Settings information architecture, and cleanup @@ -731,7 +781,9 @@ persistence changes. ### Catalog integrity -- Exact ordered 102-ID snapshot and explicit count. +- First characterize the exact ordered 102-ID before-state. After Phase 6, + update it with an explicit negative assertion for all five retired IDs and an + exact `102 - 5 + separately approved additions` count. - Unique IDs; required metadata; valid surface/category/tier/safety values. - Generated provider split parity. - Native-menu IDs are a subset of executable catalog IDs. @@ -795,6 +847,12 @@ Assertions: partial failure/retry. - Settings coercion for absent, malformed, same-version-missing, unknown, renamed, and retired visibility keys. +- The four ordinary preference values and Dangerous Agents remain Settings- + controlled and survive restart, while all five retired command IDs stay + absent from the catalog, search, and command-visibility Settings. +- Legacy `dispatchProjectTerminal` true/false values are discarded + idempotently and are not reserialized; Dispatch never auto-creates or mounts + the companion side column; user-created terminals still render normally. - Defaults changing while explicit user overrides remain stable. - Reset behavior across renderer Settings versus main-owned setup/keychain/file state is explicit and tested. @@ -803,13 +861,14 @@ Assertions: | Requirement | Acceptance evidence | |---|---| -| Complete audit | Catalog test reports exactly 102 ordered static IDs and all appear in this document/Settings metadata. | +| Complete audit | The characterization test records all 102 audited IDs; the final catalog snapshot shows the explicit five-ID retirement delta plus only separately approved additions. | | Visibility is not authorization | Hiding each native File-menu command does not disable its menu item; direct invocation still fails any real contextual/capability gate. | | Debug clutter is gated | Debug-tier commands are absent by default, revealable/overridable, and remain protected by live capability checks where required. | | Consistent state | No stateful command returns arbitrary tone semantics; panels, toggles, values, unavailable, loading, mixed, and error states render predictably and accessibly. | | Correct provider policy | Unsupported OpenCode operations do not appear as executable; Claude Workflow MCP remains blocked; provider additions require explicit capabilities. | | Stable target | State and mutation refer to the same pinned target across focus, Dispatch lane, provider, and lifecycle changes. | -| Settings ownership | Dangerous Agents is Settings-only with confirmation; benign quick preferences share one source; MCP/view defaults and session overrides disclose scope. | +| Settings ownership | Status Mode, Worktree Badges, both Usage preferences, and Dangerous Agents are Settings-only; their five command IDs are absent. Aggressive Debug Persistence remains debug-hidden. MCP/view defaults versus session overrides and workspace defaults versus current-workspace commands disclose their distinct scope. | +| Project terminal retirement | Attach Project Terminal to Dispatch is absent from Settings, persisted state, action/hook contracts, and Dispatch rendering. Old persisted keys are dropped, while ordinary terminal sessions and rows still work. | | Destructive safety | Running/cascading closes and all bulk closes are target-bound, freshly validated, and expose partial failure without silent retargeting. | | Invocation parity | Palette, menu, shortcut, and programmatic paths share admission/error handling while retaining source-specific UI behavior. | | Migration safety | Persistence-version/coercion/alias tests pass; command IDs remain stable unless an explicit idempotent alias migration exists. | @@ -839,31 +898,33 @@ Manual packaged-app checks must cover: 5. debug reveal/override behavior; 6. focus change while an MCP/reload/switch operation is pending; 7. running and cascading close confirmation from button and shortcut paths; -8. Settings restart persistence and new-session versus current-session scope; -9. Claude does not receive Workflow MCP while Codex does; and -10. assistive-technology text for every semantic badge state. +8. the five retired commands are absent while their Settings controls still + persist and Aggressive Debug Persistence remains debug-only; +9. old `dispatchProjectTerminal` storage does not create a terminal or side + column, while an ordinary user-created terminal still works in Dispatch; +10. Settings restart persistence and new-session versus current-session scope; +11. Claude does not receive Workflow MCP while Codex does; and +12. assistive-technology text for every semantic badge state. ## Product decisions requested at review The source establishes the defects above, but these UX choices genuinely need -product confirmation before implementation: +product confirmation before implementation. Settings ownership and Dispatch +project-terminal removal are recorded decisions above and are no longer open: 1. **Unavailable presentation.** Recommendation: hide mode-irrelevant commands; show discovery-worthy unsupported commands disabled with a reason when the user searches for them. -2. **Persistent quick preferences.** Recommendation: remove only Dangerous - Agents; keep Status Mode, Worktree Badges, Usage Header/Detail, and Aggressive - Debug Persistence as Settings-primary commands (the last one debug-hidden). -3. **Close confirmation threshold.** Recommendation: confirm running/streaming +2. **Close confirmation threshold.** Recommendation: confirm running/streaming targets and any cascade/multi-target close; keep an idle single close immediate and undoable. -4. **Directional shortcuts in Dispatch.** Recommendation: one command ID must +3. **Directional shortcuts in Dispatch.** Recommendation: one command ID must have one meaning. Dispatch keybindings should invoke a distinct direction-free creation/navigation command rather than bypassing a hidden grid command. -5. **Remote Control maturity.** Recommendation: classify the panel +4. **Remote Control maturity.** Recommendation: classify the panel `experimental` until packaged runtime/network readiness and disclosure are complete; keep actual enable/listen/pair actions separately authorized. -6. **Agent Management MCP close grant.** Recommendation: include the enforceable +5. **Agent Management MCP close grant.** Recommendation: include the enforceable explicit-user-intent grant in this governance PR because prose-only safety is not a real gate. It is listed separately because it is an MCP tool, not one of the 102 palette commands. @@ -873,6 +934,10 @@ product confirmation before implementation: - Do not remove session-level MCP toggles; Settings owns defaults for new sessions, while commands intentionally own current-session overrides. - Do not enable Workflow MCP for Claude; it duplicates a native feature. +- Do not remove Dispatch or Tiled Dispatch commands; they act on the current + workspace rather than duplicating the persisted default. +- Do not remove normal terminal creation, terminal sessions, or terminal rows + when removing the auto-created Dispatch project-terminal companion feature. - Do not make picker visibility a security or capability boundary. - Do not replace main-process ownership checks with renderer-only confirmation. - Do not persist transient panels/layout stances merely because they have state From 9625164cf748b81d0de6f1c848fb05848d736342 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 24 Jul 2026 00:03:41 +0200 Subject: [PATCH 03/33] docs: add default-off navigation command group --- .../plans/2026-07-23-command-surface-audit.md | 105 +++++++++++++++--- 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/plans/2026-07-23-command-surface-audit.md b/docs/superpowers/plans/2026-07-23-command-surface-audit.md index 12e83ad5..916ea0a2 100644 --- a/docs/superpowers/plans/2026-07-23-command-surface-audit.md +++ b/docs/superpowers/plans/2026-07-23-command-surface-audit.md @@ -89,6 +89,7 @@ collapsed into a boolean filter: | Visibility | Make visibility strictly picker-only; classify developer commands as `debug` and niche supported operations as `advanced`. | The tiers already exist but are unused, and native-menu execution currently depends on them accidentally. | | State | Replace `{label, tone}` with semantic toggle/value/status states plus unavailable/loading/error and target identity. | Color and arbitrary text cannot distinguish boolean truth, values, inherited state, async work, or unsupported commands. | | Settings ownership | Retire `toggle-status-mode`, `toggle-worktree-badges`, `usage.toggle-header`, `usage.cycle-header-level`, and `dangerous-agents` from Commands; keep their durable controls in Settings. Preserve Settings-default + per-session-command for MCP and agent view, preserve current-workspace layout commands, and keep only Aggressive Debug Persistence as a Settings-primary quick command. | A persisted preference with no meaningful momentary scope should have one product home. Session MCP/view overrides and current-workspace layout actions are intentionally different from global defaults. | +| Navigation command group | Put `next-tab`, `prev-tab`, and the four directional pane-focus commands in a closed **Navigation Commands** group that is off by default. Add a Settings-only **Enable Navigation Commands** toggle. | These relative-focus entries duplicate high-frequency shortcuts and add picker noise. The setting controls command-picker discoverability as a family; it must not turn off the underlying keyboard navigation actions. | | Dispatch project terminal | Remove the entire opt-in auto-created project-terminal side-column feature, including its Settings field, renderer/action plumbing, persistence shape, and stale migration comments. | It is not merely a misplaced command: the product behavior itself is being retired. Ordinary user-created terminals and their normal Dispatch rows remain unrelated and must keep working. | | Providers | Gate features by declared capability, not `isAgentProviderKind()`. | OpenCode currently receives Rewind, Duplicate, Resume, Switch Provider, and Copy Resume affordances that are empty, rejected, unsupported, or unverified. | | Targeting | Resolve and pin one target for state + execution, then revalidate identity/capability before mutation. | The shared grid/Dispatch resolver is sound, but repeated resolution can display state for A and mutate B after focus changes. | @@ -135,6 +136,11 @@ audit: 5. Remove the complete **Attach Project Terminal to Dispatch** feature. This means the opt-in, auto-created companion terminal and its dedicated Dispatch side column—not user-created terminal sessions or ordinary terminal rows. +6. Add the Settings-only **Enable Navigation Commands** preference, default off. + Its membership is exactly `next-tab`, `prev-tab`, `nav-left`, `nav-right`, + `nav-up`, and `nav-down`. Off removes those six from the command picker; it + does not disable Command-[ / Command-], Option-H/J/K/L, arrow variants, or + their underlying workspace actions. ## Canonical source map @@ -165,6 +171,10 @@ audit: different or Settings-primary scopes). - **Tier** is the proposed default picker visibility. A hidden tier remains runnable only through an invocation path that independently passes admission. +- `default (group off)` means the definition uses the ordinary default tier + once **Enable Navigation Commands** is on, but the group gate hides it from + the picker on a fresh install. The group gate is discoverability only and + never substitutes for contextual admission. - `target` in a proposed gate means resolve a concrete target and carry that identity into execution; it does not mean “rerun whichever target is focused.” @@ -174,8 +184,8 @@ audit: |---|---|---|---|---|---| | `new-tab` — New Tab | app | none; — | Workspace create / spawns agent | Create · default · C | Keep; native menu must resolve outside picker visibility; spawn boundary deduplicates/rate-limits. | | `close-tab` — Close Tab | app | none; — | Workspace + process teardown / destructive cascade | Layout & Dispatch · default · C | Require active tab; pin tab; confirm when running or cascade count >1; revalidate before close. | -| `next-tab` — Next Tab | app | none; — | Focus only | Navigate · default · C | Gate/disable when fewer than two tabs; keep action no-op safe. | -| `prev-tab` — Previous Tab | app | none; — | Focus only | Navigate · default · C | Same as `next-tab`. | +| `next-tab` — Next Tab | app | none; — | Focus only | Navigate · default (group off) · C | Add to the closed Navigation Commands group; hide from picker until the group setting is enabled; gate/disable when fewer than two tabs; keep shortcut action independent and no-op safe. | +| `prev-tab` — Previous Tab | app | none; — | Focus only | Navigate · default (group off) · C | Same group/default behavior and contextual admission as `next-tab`; preserve the direct shortcut. | | `reorder-tabs` — Reorder Tabs | app | more than one tab; — | Workspace order | Navigate · default · C | Recheck count and tab identities at modal commit; native menu ignores picker visibility. | | `resume-session` — Resume Session | app | none; — | Provider session create/replace | Session · default · C | Require focused CWD plus a provider with saved-session listing; use provider chooser/friendly unavailable state instead of OpenCode/terminal fallback. | | `new-agent` — New Agent… | app | active non-tiled tab; — | Workspace/process create | Create · default · C | Rename to New Pane… or stop offering Terminal; require at least one launchable provider/runtime and deduplicate commit. | @@ -195,10 +205,10 @@ audit: | `codex-horizontal` — New Codex Below | grid | generated; — | Workspace/process create | Create · default · C | Same as `codex-vertical`. | | `opencode-vertical` — New OpenCode Right | grid | generated; — | Workspace/process create | Create · default · C | Require OpenCode setup/binary launchability; no invisible Dispatch behavior. | | `opencode-horizontal` — New OpenCode Below | grid | generated; — | Workspace/process create | Create · default · C | Same as `opencode-vertical`. | -| `nav-left` — Focus Pane Left | grid | none; — | Focus only | Navigate · default · C | Keybinding and command share the grid-mode admission rule. | -| `nav-right` — Focus Pane Right | grid | none; — | Focus only | Navigate · default · C | Same as `nav-left`. | -| `nav-up` — Focus Pane Up | grid | none; — | Focus only | Navigate · default · C | Separate Dispatch-row navigation identity from grid command/shortcut. | -| `nav-down` — Focus Pane Down | grid | none; — | Focus only | Navigate · default · C | Same as `nav-up`. | +| `nav-left` — Focus Pane Left | grid | none; — | Focus only | Navigate · default (group off) · C | Add to Navigation Commands; hide from picker until enabled. Preserve shortcut navigation; command admission remains grid-only. | +| `nav-right` — Focus Pane Right | grid | none; — | Focus only | Navigate · default (group off) · C | Same as `nav-left`. | +| `nav-up` — Focus Pane Up | grid | none; — | Focus only | Navigate · default (group off) · C | Add to Navigation Commands; preserve direct Grid/Dispatch shortcut behavior while keeping the command itself grid-only. | +| `nav-down` — Focus Pane Down | grid | none; — | Focus only | Navigate · default (group off) · C | Same as `nav-up`. | | `undo-close` — Undo Close | app | none; — | Bounded in-memory recovery → workspace/process | Session · default · C | Expose undo-stack availability/reason; pin the recorded entry and revalidate restore ownership. | | `revive-pane` — Revive Buried Pane | app | any buried session; — | Workspace/process wake | Layout & Dispatch · advanced · C | Rename Revive Buried Session…; gate from the same scoped list the picker displays. | | `kill-buried-pane` — Kill Buried Pane… | app | any buried session; — | Process teardown / destructive, no undo | Layout & Dispatch · advanced · C | Rename session; add second confirmation/running warning; validate selected record immediately before kill. | @@ -378,6 +388,7 @@ The placement rule is behavioral, not aesthetic: | Agent View Mode | `set-agent-view-mode` | Both with different scope | Settings = app default; command = this session. Rename the command to disclose that. | | Default built-in MCP rows | five configurable MCP commands | Both with different scope | Settings = new sessions; command = this session. Keep Workflow Codex-only. | | Default Workspace Mode | Dispatch/Tiled Dispatch commands | Not duplicates | Settings controls initial/default policy; commands control the current workspace. | +| Enable Navigation Commands | six-member Navigation Commands group | **Settings-only group gate, off by default** | Off hides the six relative-focus commands from the picker as a family. On makes them picker-eligible, after which normal applicability and per-command visibility overrides still apply. Shortcuts and workspace actions are unaffected. | | Attach Project Terminal to Dispatch | no command | **Remove feature** | Remove the Settings row, `dispatchProjectTerminal` field/default/coercion, auto-create action/export, dedicated Dispatch side column/effect/imports, and stale comments/tests. Preserve normal terminal creation and ordinary terminal rows. | No Settings-value migration is needed for the five retired commands because @@ -393,6 +404,23 @@ implementation must explicitly omit that retired key, increment the persisted store version, and prove with an idempotent migration test that old true/false values are ignored and do not return after the next save. +`navigationCommandsEnabled` is a new persisted boolean with a strict +`parsed.navigationCommandsEnabled === true` coercion so fresh installs, +malformed values, and older stores are off. It is a family-level picker gate, +not another `commandVisibilityOverrides` entry and not an execution permission. +Precedence is deliberate: + +1. group off → all six members are absent from the picker; +2. group on → surface/applicability checks run normally; then +3. an explicit per-command visibility override may hide or show an individual + member relative to its declared default. + +The group remains present in the context-free catalog in both states, so native +lookup, diagnostics, stable IDs, and catalog validation do not depend on a +persisted UI preference. Direct command-ID execution still performs real +contextual admission, and the existing keyboard handlers continue to call the +navigation actions regardless of the group setting. + Any new or changed persisted Settings field still requires: 1. a default; @@ -490,6 +518,21 @@ Destructive and experimental are metadata, not categories. The Settings information architecture should likewise separate functional ownership from maturity: +`commandGroup` is a second, optional dimension for a closed family controlled +as one product unit. The first group is: + +| Command group | Exact stable-ID membership | Default | Owner | +|---|---|---|---| +| Navigation Commands | `next-tab`, `prev-tab`, `nav-left`, `nav-right`, `nav-up`, `nav-down` | Disabled in picker | Settings → Commands & Shortcuts → Enable Navigation Commands | + +Do not infer group membership from the broad `Navigate` category: Jump to +Latest Message, Spotlight, Reader Mode, Tiled Tabs, and Reorder Tabs remain +ordinary commands. The closed ID list prevents a future navigation-adjacent +feature from silently becoming default-hidden merely because its category was +reused. + +The Settings category list remains: + 1. Appearance 2. Workspace & Layout 3. Interface @@ -612,7 +655,7 @@ Files: Work: 1. Add required `category`, declared `pickerVisibility`, target kind, and safety - metadata to `CommandDef`. + metadata to `CommandDef`, plus optional closed `commandGroup` metadata. 2. Add unavailable reasons with `hide` versus `disable` presentation. 3. Add target-aware resolved invocations without making app/editor commands invent session targets. @@ -629,8 +672,11 @@ high-risk command switches until its target/admission matrix test exists. Files: - all feature-owned command modules +- `features/workspace/commands/{tab,pane}Commands.ts` - `features/settings/lib/settingsRegistry.ts` - `features/settings/ui/SettingsList.tsx` +- `app-state/settings/{types,persistence}.ts`, the persisted store version, and + migration tests - command ranking/search metadata tests Work: @@ -645,9 +691,19 @@ Work: 5. Give dynamic-title commands a friendly stable catalog label rather than a raw ID. 6. Preserve explicit user visibility overrides when declared defaults change. - -Rollback boundary: metadata-only commit; reverting restores the current flat, -all-visible picker without touching execution authority. +7. Declare the exact six-member Navigation Commands group and add the + Settings-only **Enable Navigation Commands** toggle with + `navigationCommandsEnabled: false` as the default. Resolve the group gate + before per-command picker visibility, but never feed it into execution + authority or keyboard handling. +8. Show the group and its off-by-default explanation in Commands & Shortcuts. + When disabled, do not present six individually actionable switches that + appear able to override the parent; when enabled, expose their ordinary + per-command visibility controls. + +Rollback boundary: group metadata/taxonomy can land separately from the +versioned Navigation Commands preference. Reverting the preference restores the +current all-visible picker without touching navigation execution authority. ### Phase 4 — Make provider capability policy authoritative @@ -786,9 +842,13 @@ persistence changes. exact `102 - 5 + separately approved additions` count. - Unique IDs; required metadata; valid surface/category/tier/safety values. - Generated provider split parity. +- Navigation Commands has exactly the six recorded stable IDs; no other + `Navigate` command inherits group behavior by category. - Native-menu IDs are a subset of executable catalog IDs. -- Settings command catalog equals the command catalog, excluding explicitly - retired IDs and transient agent-index destinations. +- Settings command metadata derives from the same catalog, excluding explicitly + retired IDs and transient agent-index destinations; a disabled command group + may collapse its member controls behind the parent setting without forking the + source list. - Descriptions are validated before contextual filtering, including hidden commands. @@ -803,6 +863,7 @@ Cross representative commands over: | Context predicate | absent, true, false. | | Declared tier | default, advanced, experimental, debug. | | User override | absent, true, false. | +| Navigation Commands group | disabled, enabled. | | Reveal-all | false, true. | | Invocation source | picker, native menu, keybinding, programmatic. | | Render policy | Agent, Terminal, Hybrid with/without lease. | @@ -811,6 +872,9 @@ Cross representative commands over: Assertions: - hidden affects picker only; +- with Navigation Commands off, exactly the six group members are absent from + the picker while their shortcuts still navigate; with it on, applicability + and individual visibility overrides resume in the documented precedence; - native menu diagnoses unavailable versus unknown IDs; - keybindings cannot bypass capability/safety admission; - excluded commands do not evaluate state; @@ -850,6 +914,9 @@ Assertions: - The four ordinary preference values and Dangerous Agents remain Settings- controlled and survive restart, while all five retired command IDs stay absent from the catalog, search, and command-visibility Settings. +- `navigationCommandsEnabled` defaults/coerces to false for missing or malformed + data, persists true when chosen, resets to false, and never changes shortcut + behavior or direct action availability. - Legacy `dispatchProjectTerminal` true/false values are discarded idempotently and are not reserialized; Dispatch never auto-creates or mounts the companion side column; user-created terminals still render normally. @@ -868,6 +935,7 @@ Assertions: | Correct provider policy | Unsupported OpenCode operations do not appear as executable; Claude Workflow MCP remains blocked; provider additions require explicit capabilities. | | Stable target | State and mutation refer to the same pinned target across focus, Dispatch lane, provider, and lifecycle changes. | | Settings ownership | Status Mode, Worktree Badges, both Usage preferences, and Dangerous Agents are Settings-only; their five command IDs are absent. Aggressive Debug Persistence remains debug-hidden. MCP/view defaults versus session overrides and workspace defaults versus current-workspace commands disclose their distinct scope. | +| Navigation command group | Enable Navigation Commands is off by default and controls picker membership for exactly Next/Previous Tab plus Focus Pane Left/Right/Up/Down. Enabling restores normal per-command visibility; disabling never breaks their shortcuts or underlying actions. | | Project terminal retirement | Attach Project Terminal to Dispatch is absent from Settings, persisted state, action/hook contracts, and Dispatch rendering. Old persisted keys are dropped, while ordinary terminal sessions and rows still work. | | Destructive safety | Running/cascading closes and all bulk closes are target-bound, freshly validated, and expose partial failure without silent retargeting. | | Invocation parity | Palette, menu, shortcut, and programmatic paths share admission/error handling while retaining source-specific UI behavior. | @@ -900,11 +968,14 @@ Manual packaged-app checks must cover: 7. running and cascading close confirmation from button and shortcut paths; 8. the five retired commands are absent while their Settings controls still persist and Aggressive Debug Persistence remains debug-only; -9. old `dispatchProjectTerminal` storage does not create a terminal or side +9. Navigation Commands are absent from the picker on a fresh/reset profile, + their shortcuts still work, and enabling the setting reveals only the six + recorded members subject to layout applicability; +10. old `dispatchProjectTerminal` storage does not create a terminal or side column, while an ordinary user-created terminal still works in Dispatch; -10. Settings restart persistence and new-session versus current-session scope; -11. Claude does not receive Workflow MCP while Codex does; and -12. assistive-technology text for every semantic badge state. +11. Settings restart persistence and new-session versus current-session scope; +12. Claude does not receive Workflow MCP while Codex does; and +13. assistive-technology text for every semantic badge state. ## Product decisions requested at review @@ -936,6 +1007,8 @@ project-terminal removal are recorded decisions above and are no longer open: - Do not enable Workflow MCP for Claude; it duplicates a native feature. - Do not remove Dispatch or Tiled Dispatch commands; they act on the current workspace rather than duplicating the persisted default. +- Do not disable tab/pane keyboard navigation with Enable Navigation Commands; + the setting controls the six command-picker wrappers, not navigation itself. - Do not remove normal terminal creation, terminal sessions, or terminal rows when removing the auto-created Dispatch project-terminal companion feature. - Do not make picker visibility a security or capability boundary. From c75fcbb0ac089f4830542f280701424a122cacbb Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 15:55:51 +0200 Subject: [PATCH 04/33] docs: plan configurable command keybindings --- .../plans/2026-07-23-command-surface-audit.md | 459 ++++++++++++++++-- 1 file changed, 407 insertions(+), 52 deletions(-) diff --git a/docs/superpowers/plans/2026-07-23-command-surface-audit.md b/docs/superpowers/plans/2026-07-23-command-surface-audit.md index 916ea0a2..6a5e52e9 100644 --- a/docs/superpowers/plans/2026-07-23-command-surface-audit.md +++ b/docs/superpowers/plans/2026-07-23-command-surface-audit.md @@ -21,9 +21,9 @@ runtime command exports. Audit every command that Agent Code registers, decide which commands need context/capability/safety gates, normalize command-state badges, decide whether -each behavior belongs in Commands, Settings, or both, and establish an -implementation sequence that can be shipped without changing all invocation -paths in one flag day. +each behavior belongs in Commands, Settings, or both, make every built-in +command's keybindings configurable from Settings, and establish an +implementation sequence with explicit rollback boundaries. This commit is deliberately the review checkpoint. It contains the audit and implementation plan only. The branch is intended to hold the eventual full @@ -86,6 +86,9 @@ collapsed into a boolean filter: |---|---|---| | Catalog | Create a context-free, validated command catalog and preserve its ordered 102-ID snapshot. | Registration order is user-visible, while duplicate IDs, omitted modules, generated commands, and native-menu IDs are currently unchecked. | | Execution | Add one command execution gateway with invocation source and a fresh admission check. | Picker, native menu, keybindings, and programmatic calls currently have different guarantees. | +| Keybindings | Give every built-in command a Settings row with zero, one, or multiple configurable bindings; replace display-only shortcut strings and hard-coded command handlers with one canonical effective-binding map. | The palette currently displays metadata that is independent from the keyboard code that actually runs, so the two can drift and user edits would otherwise be cosmetic. | +| Binding conflicts | Use one normalizer/collision engine in Settings and `npm run check:keybindings`; reject overlapping bindings and offer only an explicit atomic Replace action. | Registration order must never decide which command wins, and a repository script that disagrees with runtime validation would create a false guarantee. | +| Default bindings | Preserve the existing command-backed muscle-memory set and add conventional Command-, for Settings; leave all other commands assignable but unbound. | Raw personalized history measures palette selection only and overweights this development setup, so it is evidence for usefulness but not a safe allocator of scarce global chords. | | Visibility | Make visibility strictly picker-only; classify developer commands as `debug` and niche supported operations as `advanced`. | The tiers already exist but are unused, and native-menu execution currently depends on them accidentally. | | State | Replace `{label, tone}` with semantic toggle/value/status states plus unavailable/loading/error and target identity. | Color and arbitrary text cannot distinguish boolean truth, values, inherited state, async work, or unsupported commands. | | Settings ownership | Retire `toggle-status-mode`, `toggle-worktree-badges`, `usage.toggle-header`, `usage.cycle-header-level`, and `dangerous-agents` from Commands; keep their durable controls in Settings. Preserve Settings-default + per-session-command for MCP and agent view, preserve current-workspace layout commands, and keep only Aggressive Debug Persistence as a Settings-primary quick command. | A persisted preference with no meaningful momentary scope should have one product home. Session MCP/view overrides and current-workspace layout actions are intentionally different from global defaults. | @@ -94,7 +97,7 @@ collapsed into a boolean filter: | Providers | Gate features by declared capability, not `isAgentProviderKind()`. | OpenCode currently receives Rewind, Duplicate, Resume, Switch Provider, and Copy Resume affordances that are empty, rejected, unsupported, or unverified. | | Targeting | Resolve and pin one target for state + execution, then revalidate identity/capability before mutation. | The shared grid/Dispatch resolver is sound, but repeated resolution can display state for A and mutate B after focus changes. | | Destructive actions | Confirm active/running or cascading closes, re-enumerate bulk-close targets at commit, and keep ownership checks in main. | Undo does not recover live terminal state, unsent drafts, or partial cascades. A stale preview must not authorize a later changed target set. | -| Rollout | Characterize first, then separate catalog/admission, then migrate high-risk families, then change UX defaults. | Structural changes and behavior changes need independent rollback boundaries. | +| Rollout | Characterize first, separate catalog/admission, establish the final built-in catalog, centralize configurable bindings, then migrate high-risk families and UX defaults. | Structural changes and behavior changes need independent rollback boundaries. | ## What is already correct and must be preserved @@ -141,6 +144,24 @@ audit: `nav-up`, and `nav-down`. Off removes those six from the command picker; it does not disable Command-[ / Command-], Option-H/J/K/L, arrow variants, or their underlying workspace actions. +7. Add built-in command keybinding control to Settings. Support multiple + bindings per command, explicit unbinding, per-command reset, and reset-all. + Shipped defaults are the current command-backed shortcuts plus Command-, for + Settings; do not assign new defaults merely from personalized picker counts. +8. Keep bindings single-step in this implementation. Multi-step sequences are + a follow-up, not an implicit parser feature. +9. Record successful admitted command executions from picker, keybinding, and + native-menu sources with the source attached. The existing picker-only + history is retained/migrated but must no longer be described as total + command usage. +10. Exclude extension-contributed commands and keybindings from this audit and + implementation. In particular, Timer commands belong to an extension and + do not affect the built-in command count, default set, Settings controls, + or static collision script. +11. Keep Dispatch row/lane arrow and Vim gestures as contextual reserved + interactions, not extra Navigation Commands. The Navigation Commands group + remains exactly the six approved IDs; mutually exclusive Grid/Dispatch + ownership is explicit in the binding-context matrix. ## Canonical source map @@ -184,8 +205,8 @@ audit: |---|---|---|---|---|---| | `new-tab` — New Tab | app | none; — | Workspace create / spawns agent | Create · default · C | Keep; native menu must resolve outside picker visibility; spawn boundary deduplicates/rate-limits. | | `close-tab` — Close Tab | app | none; — | Workspace + process teardown / destructive cascade | Layout & Dispatch · default · C | Require active tab; pin tab; confirm when running or cascade count >1; revalidate before close. | -| `next-tab` — Next Tab | app | none; — | Focus only | Navigate · default (group off) · C | Add to the closed Navigation Commands group; hide from picker until the group setting is enabled; gate/disable when fewer than two tabs; keep shortcut action independent and no-op safe. | -| `prev-tab` — Previous Tab | app | none; — | Focus only | Navigate · default (group off) · C | Same group/default behavior and contextual admission as `next-tab`; preserve the direct shortcut. | +| `next-tab` — Next Tab | app | none; — | Focus only | Navigate · default (group off) · C | Add to the closed Navigation Commands group; hide from picker until the group setting is enabled; gate/disable when fewer than two tabs; keep its effective binding independent from picker visibility and no-op safe. | +| `prev-tab` — Previous Tab | app | none; — | Focus only | Navigate · default (group off) · C | Same group/default behavior and contextual admission as `next-tab`; preserve its shipped binding through the configurable router. | | `reorder-tabs` — Reorder Tabs | app | more than one tab; — | Workspace order | Navigate · default · C | Recheck count and tab identities at modal commit; native menu ignores picker visibility. | | `resume-session` — Resume Session | app | none; — | Provider session create/replace | Session · default · C | Require focused CWD plus a provider with saved-session listing; use provider chooser/friendly unavailable state instead of OpenCode/terminal fallback. | | `new-agent` — New Agent… | app | active non-tiled tab; — | Workspace/process create | Create · default · C | Rename to New Pane… or stop offering Terminal; require at least one launchable provider/runtime and deduplicate commit. | @@ -205,9 +226,9 @@ audit: | `codex-horizontal` — New Codex Below | grid | generated; — | Workspace/process create | Create · default · C | Same as `codex-vertical`. | | `opencode-vertical` — New OpenCode Right | grid | generated; — | Workspace/process create | Create · default · C | Require OpenCode setup/binary launchability; no invisible Dispatch behavior. | | `opencode-horizontal` — New OpenCode Below | grid | generated; — | Workspace/process create | Create · default · C | Same as `opencode-vertical`. | -| `nav-left` — Focus Pane Left | grid | none; — | Focus only | Navigate · default (group off) · C | Add to Navigation Commands; hide from picker until enabled. Preserve shortcut navigation; command admission remains grid-only. | +| `nav-left` — Focus Pane Left | grid | none; — | Focus only | Navigate · default (group off) · C | Add to Navigation Commands; hide from picker until enabled. Preserve effective binding navigation; command admission remains grid-only. | | `nav-right` — Focus Pane Right | grid | none; — | Focus only | Navigate · default (group off) · C | Same as `nav-left`. | -| `nav-up` — Focus Pane Up | grid | none; — | Focus only | Navigate · default (group off) · C | Add to Navigation Commands; preserve direct Grid/Dispatch shortcut behavior while keeping the command itself grid-only. | +| `nav-up` — Focus Pane Up | grid | none; — | Focus only | Navigate · default (group off) · C | Add to Navigation Commands; keep the command grid-only and model the same physical Dispatch gesture as a mutually exclusive reserved interaction. | | `nav-down` — Focus Pane Down | grid | none; — | Focus only | Navigate · default (group off) · C | Same as `nav-up`. | | `undo-close` — Undo Close | app | none; — | Bounded in-memory recovery → workspace/process | Session · default · C | Expose undo-stack availability/reason; pin the recorded entry and revalidate restore ownership. | | `revive-pane` — Revive Buried Pane | app | any buried session; — | Workspace/process wake | Layout & Dispatch · advanced · C | Rename Revive Buried Session…; gate from the same scoped list the picker displays. | @@ -225,7 +246,7 @@ audit: | `toggle-status-mode` — Status Mode | app | none; `On`/`Off` | Persisted app preference | Preferences · remove · S | Retire the command and any visibility override for its built-in ID; retain Status Mode and `showStatusMode` in Settings as the only product control. | | `toggle-performance-panel` — Performance Stats | debug | none; `On`/`Off` | Transient diagnostic UI | Developer · debug · C | Default-hide as debug; add developer/runtime admission if production access is not intended. | | `toggle-caffeinate` — Caffeinate | app | none; `Off`/`On`/`Unsupported` | Main/OS process | Workspace Tools · default · C | Model loading/error/unsupported; disable or hide unsupported; main revalidates platform and single ownership. | -| `toggle-global-editor` — Global Editor | app | none; `On`/`Off` | Transient surface | Editor & Files · default · C | Add declared shortcut metadata; require project only when opening if empty editor is invalid. | +| `toggle-global-editor` — Global Editor | app | none; `On`/`Off` | Transient surface | Editor & Files · default · C | Add the existing Command-Shift-E behavior to canonical `defaultKeybindings`; require project only when opening if empty editor is invalid. | | `save-editor-file` — Save Editor File | editor | editor open; — | Filesystem write | Editor & Files · default · C | Native menu bypasses picker visibility; receiving editor revalidates visible document, dirty/version state, root and path containment. | | `save-all-editor-files` — Save All Editor Files | editor | editor open; — | Batch filesystem write | Editor & Files · default · C | Same as save, plus partial-result contract; disable when no dirty files. | | `quick-open-file` — Quick Open File | editor | focused CWD; — | Read/navigation | Editor & Files · default · C | Keep; revalidate root/CWD when selection opens. | @@ -268,7 +289,7 @@ audit: | `toggle-spotlight` — Spotlight | app | none; `On`/`Off` | Workspace view | Navigate · default · C | Require target only when entering; allow exit unconditionally; include target identity if state is target-specific. | | `toggle-reader-mode` — Reader Mode | session | agent provider; `On`/`Off` | Workspace view | Navigate · default · C | Repeat provider/render target admission; clarify whether `On` belongs to current target or any open reader. | | `tiled-tabs` — Tiled Tabs | app | none; `On`/`Off` | Workspace view | Navigate · default · C | Require enough selectable tabs when entering; allow closing unconditionally. | -| `open-settings` — Open Settings | app | none; — | Navigation | Preferences · default · C | Update stale category copy; picker visibility must not affect any future native Settings menu item. | +| `open-settings` — Open Settings | app | none; — | Navigation | Preferences · default · C | Add conventional Command-, to canonical defaults; update stale category copy; picker visibility must not affect any future native Settings menu item. | | `toggle-aggressive-debug-persistence` — Persistent Aggressive Debug Logs | debug | none; `On`/`Off` | Persisted app preference + disk cost | Developer · debug · B | Settings is primary explanatory home; keep debug-tier quick override; require backend readiness and explicit persistence/cost copy. | | `toggle-worktrees-bar` — Worktrees | app | none; `Open`/`Closed` | Transient UI | Workspace Tools · default · C | Gate opening on repo/worktree capability; closing always allowed; rename Worktrees Panel if needed. | | `toggle-worktree-badges` — Worktree Badges | app | none; `On`/`Off` | Persisted app preference | Preferences · remove · S | Retire the command and any visibility override for its built-in ID; retain Worktree Badges and `showWorktreeBadges` in Settings as the only product control. | @@ -574,10 +595,220 @@ files. The command-visibility Settings row must stop being a flat title-only catalog. It should group by command category and make nested command titles, descriptions, -keywords, shortcuts, tier, scope, and risk searchable. It must disclose when a +keywords, effective bindings, tier, scope, and risk searchable. It must disclose when a hidden command has no shortcut or alternate UI; “still executable” is not the same as practically discoverable. +## Built-in command keybinding design + +### Scope boundary + +This work owns **first-party built-in commands only**. Timer is an extension, +so its commands and any other extension-contributed commands are deliberately +absent from: + +- the 102-command before-state audit and final built-in catalog count; +- the shipped default-binding set; +- the built-in command controls in Settings; and +- the repository's static built-in collision check. + +Extension keybinding contribution and override UX is a later extension-platform +concern. That later work must compose extension bindings after first-party and +reserved bindings so an extension can never shadow an Agent Code command, but +this PR must not invent or partially ship that policy. + +“Keybind control of all commands” means every surviving built-in command has a +Settings row and may have zero, one, or multiple bindings. It does **not** mean +turning every keyboard interaction into a command. Escape dismissal, modal and +picker navigation, composer editing/history, numbered tab/Dispatch selection, +split resizing, and editor-native file-tab behavior remain contextual +interactions. They still enter the shared reservation/collision model so a user +cannot accidentally assign a command on top of them. + +### Canonical source of truth and persistence + +The current `CommandDef.shortcut?: string` is presentation-only while +`useKeybinds.ts` separately implements the real behavior. Replace that split +authority with canonical machine-readable defaults: + +```ts +type Keybinding = string // normalized single-step grammar, e.g. Cmd+Shift+P + +type CommandDef = { + // existing command metadata + defaultKeybindings?: readonly Keybinding[] +} + +type Settings = { + commandKeybindingOverrides: Record +} +``` + +The persisted map is sparse: + +- absent command ID means “inherit shipped defaults”; +- an empty array means “explicitly unbound”; +- a nonempty array completely replaces that command's shipped defaults; and +- Reset removes the override rather than copying today's default into storage. + +This distinction lets a future release improve untouched defaults without +overwriting a user's deliberate binding or unbinding. Coercion normalizes, +deduplicates, and rejects malformed entries without deleting unknown IDs +indiscriminately; an extension/provider may be temporarily absent. Retired +first-party IDs are pruned only through an explicit idempotent migration. + +The palette, Settings, search metadata, accessibility text, and runtime router +all derive from the **effective** binding set. Delete independent display-only +shortcut strings once parity tests prove the migration. A shortcut shown in the +palette must therefore be the shortcut the current profile will actually run. + +Bindings are single-step in this PR. The parser must reject sequence syntax +rather than accept a value the runtime cannot execute. Multi-step chords such +as Command-K then R require a future prefix/timeout/accessibility design. + +### Settings interaction + +Commands & Shortcuts becomes a searchable, category-grouped built-in catalog. +Every row shows the stable command title, effective bindings, picker visibility, +scope/tier/safety metadata, and one of these binding states: + +- one or more binding chips with Remove; +- **Not assigned** when the effective array is empty; +- Add/Edit binding through keyboard capture; +- Reset this command; and +- a page-level Reset All Bindings. + +The existing dictation hotkey capture code can supply physical-key +normalization and capture behavior, but the command editor needs a generic +multi-binding control. It must not inherit dictation-specific defaults, +modifier-only policy, or native helper semantics. + +When a captured binding overlaps another owner, saving is blocked and the UI +names the command or reserved interaction that owns it. The only override path +is an explicit **Replace** action that atomically removes the old command +binding and installs the new one. Registration order, last-write-wins, and +silent precedence are forbidden. + +Picker visibility remains independent. A hidden/default-off Navigation Command +can still have and execute a binding; an unbound command remains executable +from the picker/menu/programmatic path if admission allows it. + +### Shipped default set + +Defaults are intentionally scarce. Preserve the current command-backed muscle +memory, fill metadata gaps for bindings that already execute, and add only the +conventional Settings binding. Personalized history is currently palette-only +and development-profile-specific, so high counts do not automatically claim a +global chord. The table uses user-facing macOS notation; the normalizer owns the +canonical persisted token spelling. + +| Group | Built-in command | Shipped binding(s) | +|---|---|---| +| Command access | New host command `open-command-palette` | Command-Shift-P | +| Tabs | `new-tab` | Command-T | +| Tabs | `close-tab` | Command-Shift-W | +| Tabs | `undo-close` | Command-Shift-T | +| Tabs | `resume-session` | Command-Shift-R | +| Tabs | `prev-tab` / `next-tab` | Command-[ / Command-] | +| Pane lifecycle | `close-pane` | Command-W; Option-W | +| Creation | `split-vertical` / `split-horizontal` | Option-D / Option-Shift-D | +| Creation | `terminal-horizontal` / `terminal-vertical` | Option-T / Option-Shift-T | +| Creation | generated non-default-provider right/below commands | Preserve each provider's declared Option-key pair. | +| Navigation | `nav-left` | Option-H; Option-Left | +| Navigation | `nav-right` | Option-L; Option-Right | +| Navigation | `nav-up` | Option-K; Option-Up | +| Navigation | `nav-down` | Option-J; Option-Down | +| Editor | `toggle-global-editor` | Command-Shift-E | +| Editor | `quick-open-file` | Command-P | +| Editor | `search-in-files` | Command-Shift-F | +| Editor | `toggle-editor-fullscreen` | Command-Option-E | +| Editor | `save-editor-file` | Command-S in editor context | +| Feed | `jump-latest-message` | End outside text editing | +| Preferences | `open-settings` | Command-, | + +`open-command-palette` is a first-party host command so its binding is no +longer special hard-coded state. It appears in keybinding Settings but is +structurally omitted from its own picker results. This is one separately +approved new ID: after retiring the five recorded preference commands, the +expected built-in catalog count is `102 - 5 + 1 = 98`, absent another explicit +addition approved during implementation. + +Commands not listed above ship unbound but remain assignable. In particular, +Debug Panel's high personalized count does not make it a universal default, +and Usage, Dispatch, Reload Agent, and provider operations receive no invented +shortcut in this PR. + +### Collision and reservation authority + +Add one context-free normalization/collision library consumed by Settings, +runtime routing, tests, and `scripts/check-command-keybindings.mts`. Each +binding owner declares a closed binding context so validation can distinguish +genuinely disjoint behavior from accidental duplication. User assignments do +not choose their own context; the command contract supplies it. + +Ordinary command bindings must be unique across overlapping contexts. Reuse is +allowed only when the closed overlap matrix proves the contexts mutually +exclusive and the pair is explicitly characterized—for example, Option-K can +focus a grid pane while the same physical gesture moves the Dispatch selection. +There is no generic “different surface means safe” escape hatch because editor +focus and app-wide commands overlap grid/Dispatch surfaces. + +The reservation registry includes at least: + +- Command-1…9 and Command-Option-1…9 indexed tab/Dispatch behavior; +- Dispatch's two-digit selection grammar and tiled-resize continuation; +- Option-based split resizing and Fn-translated directional resizing; +- Escape, modal/picker/composer ownership, and other interaction-local keys; +- Electron native roles and app-menu accelerators, including reload, zoom, + fullscreen, and standard editing roles; +- editor-native file-tab close behavior and any remaining Monaco-owned keys; +- the current user-configured dictation binding; and +- all effective built-in command bindings. + +The static script checks shipped defaults and the default dictation binding. +Settings/runtime validation additionally checks the current profile's +dictation binding and overrides. Command-backed editor shortcuts such as Save +must derive from the effective command binding; leaving a hard-coded Monaco +Command-S behind after the user rebinds Save would violate the feature. + +`npm run check:keybindings`, included in `npm run check`, fails on: + +- invalid/non-normalized single-step syntax; +- duplicate bindings within one command; +- duplicate built-in command IDs or bindings for missing/retired IDs; +- overlapping default-command bindings; +- unapproved overlap with reserved/native/editor/dictation gestures; +- defaults that lack an executable built-in command; and +- reintroduction of independent shortcut-display metadata. + +Normalization tests must use physical `KeyboardEvent.code` for Option-letter +bindings on macOS, where `event.key` is a produced symbol, and must cover the +supported Cmd/Ctrl/Option/Shift, named-key, punctuation, and function-key +grammar. + +### Runtime routing and history + +Preserve the capture-phase interaction-ownership gates before any configurable +command lookup: app-owned modals, editor inputs, composer surfaces, and +fullscreen ownership must still receive or suppress the same keys. After that +gate, resolve an effective binding to a stable command ID and invoke it through +the command execution gateway. The gateway repeats admission, pins/revalidates +targets, applies confirmations, reports async errors, and tags the invocation +source as `keybinding`. + +All command-backed cases currently duplicated in `useKeybinds.ts`, Monaco, or +special UI callbacks move to this route. Contextual interactions listed above +remain in a smaller interaction router backed by the reservation registry; they +do not become misleading picker commands merely to make the file shorter. + +Personalized command history records successful, admitted user invocations +from picker, keybinding, and native menu with the source attached. Rejected, +unavailable, canceled-confirmation, and failed async invocations do not count. +Legacy history remains usable as picker-origin data, but the product must stop +describing its current counts as all command usage. Programmatic/background +dispatch does not influence personalized ranking unless it carries an explicit +user-invocation source. + ## Phased implementation plan The phases below are intended as separately reviewable commits on this branch. @@ -593,6 +824,8 @@ Files: export of the current ordered definitions; - add `src/renderer/src/features/command-palette/catalog.test.ts` and an ordered catalog snapshot; +- add characterization tests around `workspace/tile-tree/useKeybinds.ts`, + Monaco-owned command shortcuts, dictation capture, and native accelerators; - add/expand `src/main/menu/appMenu.test.ts`; - expand Settings persistence/registry tests. @@ -606,8 +839,15 @@ Work: invariant `(providers - default) × {vertical, horizontal}`. 4. Assert every native-menu ID exists in the executable catalog and every catalog ID appears in command-visibility Settings. -5. Capture the current shortcut metadata and dynamic-title fallback so later UX - changes are explicit snapshot updates. +5. Capture both shortcut metadata and **actual** command-backed handlers, + including aliases missing from metadata (Option-Arrows and Option-W), + metadata gaps (Global Editor), and editor-owned Save. +6. Inventory contextual/reserved interactions separately: modal/composer keys, + Escape, numbered selection, tiled/resize continuations, Electron roles, + editor-native actions, and dictation. Record their overlap contexts before + moving any handler. +7. Capture the dynamic-title fallback so later UX changes are explicit snapshot + updates. Rollback boundary: delete the new catalog API/tests and restore the existing array; no product behavior will have changed. @@ -634,10 +874,12 @@ Work: and deliberately skip picker visibility. 4. Introduce `dispatchCommand({source, id})` for `palette`, `native-menu`, `keybinding`, and `programmatic` sources. Centralize fresh admission, - promise rejection/error reporting, history policy, and single-flight hooks. -5. Route picker and native menu through it first. Keep legacy direct keybinding - actions temporarily, covered by parity tests, rather than claiming a false - flag-day migration. + promise rejection/error reporting, successful-completion outcomes, history + policy, and single-flight hooks. +5. Route picker and native menu through it first. Record successful user + invocations with their source; do not count unavailable, canceled, rejected, + or failed runs. Keep legacy direct keybinding actions temporarily behind + parity tests until Phase 4 migrates all command-backed cases. Rollback boundary: the old `buildCommandRegistry` remains available behind an adapter until menu/picker parity tests pass. @@ -687,7 +929,8 @@ Work: 2. Populate the required command categories from the exhaustive table. 3. Group command-visibility Settings by category and search nested command metadata, not merely the parent setting row. -4. Display title, description, shortcut, tier, target/scope, and safety status. +4. Display title, description, effective bindings, tier, target/scope, and + safety status. 5. Give dynamic-title commands a friendly stable catalog label rather than a raw ID. 6. Preserve explicit user visibility overrides when declared defaults change. @@ -705,7 +948,69 @@ Rollback boundary: group metadata/taxonomy can land separately from the versioned Navigation Commands preference. Reverting the preference restores the current all-visible picker without touching navigation execution authority. -### Phase 4 — Make provider capability policy authoritative +### Phase 4 — Centralize and configure built-in command keybindings + +Files: + +- `features/command-palette/{types,catalog,executeCommand}.ts` +- new `features/command-keybindings/{types,normalize,defaults,reservations,resolve}.ts` +- `features/settings/lib/settingsRegistry.ts` +- `features/settings/ui/SettingsList.tsx` and a generic multi-binding capture UI +- `app-state/settings/{types,persistence}.ts` and store-version migrations +- `workspace/tile-tree/useKeybinds.ts` +- `features/global-editor/commands/globalEditorCommands.ts` +- `features/settings/commands/{settings,dangerous}Commands.ts` +- `features/usage/commands/usageCommands.ts` +- `features/editor/ui/MonacoFileEditor.tsx` +- command modules that currently declare `shortcut` +- command palette shortcut rendering/search and invocation-history storage +- new `scripts/check-command-keybindings.mts` and `package.json` + +Work: + +1. Add the canonical single-step `Keybinding` grammar, physical-key + normalization, closed binding contexts, and shared overlap checker. +2. Establish the final binding catalog before generating Settings rows: retire + the five approved Settings-owned command IDs, prune only their explicit + built-in visibility/binding override keys, add `open-command-palette`, and + snapshot the expected 98 built-in IDs. +3. Replace `CommandDef.shortcut` with `defaultKeybindings`; preserve every + characterized command-backed default/alias, add Command-, to `open-settings`, + and add the `open-command-palette` host command with Command-Shift-P, + structurally omitted from its own picker results. +4. Add sparse `commandKeybindingOverrides` persistence: absence inherits, + `[]` explicitly unbinds, and a nonempty array replaces defaults. Add + malformed/legacy/unknown/retired-ID coercion tests and Reset semantics. +5. Build the searchable Settings editor for every surviving built-in command: + multiple bindings, Not assigned, Add/Edit/Remove, per-command Reset, Reset + All, named conflict errors, and explicit atomic Replace. +6. Declare contextual/native/editor/dictation reservations and validate both + shipped defaults and current-profile effective bindings with the same + library. Do not expose binding contexts as a user-controlled loophole. +7. Preserve capture-phase interaction ownership, then route every + command-backed handler through the execution gateway. Remove duplicated + hard-coded command cases from `useKeybinds`, Monaco, and special callbacks + only after parity tests pass. Keep non-command interactions in the smaller + reserved interaction router. +8. Derive palette display/search/accessibility text from effective bindings; + delete independent shortcut labels and assert they cannot return. +9. Expand personalized history to successful picker, native-menu, and + keybinding invocations with source metadata. Preserve legacy picker counts + without pretending they include shortcut use. +10. Add `npm run check:keybindings`, include it in `npm run check`, and fail on + invalid grammar, duplicate/overlapping defaults, stale IDs, reserved/native + collisions, missing command owners, and display-authority drift. +11. Assert that Timer and every extension-contributed command remain outside + the catalog controls, shipped defaults, and static built-in validator. + +Rollback boundary: the five retirements plus one addition, normalization/default +declarations, Settings persistence, Settings UI, runtime migration, history +expansion, and the repository script land as separate commits. The legacy +handler remains behind characterization tests until the effective router has +parity; once all command-backed cases have migrated, remove it rather than +preserving two authorities indefinitely. + +### Phase 5 — Make provider capability policy authoritative Files: @@ -734,7 +1039,7 @@ Work: Rollback boundary: capability declarations land with characterization tests; each command family switches to them in a separate commit. -### Phase 5 — Replace arbitrary badge text with semantic state +### Phase 6 — Replace arbitrary badge text with semantic state Files: @@ -760,7 +1065,7 @@ Work: Rollback boundary: keep a temporary adapter from old `{label, tone}` states; remove it only after every `getState` definition is migrated and snapshot-tested. -### Phase 6 — Move ownership and harden destructive flows +### Phase 7 — Move ownership and harden destructive flows Files: @@ -778,19 +1083,13 @@ Files: Work: -1. Retire `toggle-status-mode`, `toggle-worktree-badges`, - `usage.toggle-header`, `usage.cycle-header-level`, and `dangerous-agents` - from the command catalog. Keep their five canonical Settings controls, and - explicitly clean only those retired built-in IDs from visibility overrides. -2. Update the ordered catalog snapshot with an intentional five-ID retirement - delta. The current 102-command audit remains the before-state; any separately - approved new command ID is itemized rather than hidden inside the count. -3. Add the dangerous-agent enable confirmation with the exact live reload set. +1. Keep the five retired preferences in their canonical Settings controls and + add the dangerous-agent enable confirmation with the exact live reload set. Make reload single-flight and expose success, Mixed partial application, failure, and rollback semantics. Correct contradictory copy. -4. Keep only Aggressive Debug Persistence as a Settings-primary quick command +2. Keep only Aggressive Debug Persistence as a Settings-primary quick command from this preference group and classify it as debug-hidden. -5. Remove Attach Project Terminal to Dispatch end to end: +3. Remove Attach Project Terminal to Dispatch end to end: `dispatch-project-terminal` from the Settings registry; `Settings.dispatchProjectTerminal`, its default and coercion; the `DispatchLayout` auto-create effect and dedicated terminal column; and the @@ -799,11 +1098,11 @@ Work: old key during versioned coercion so it cannot be spread back into storage. Do not remove normal terminal creation, tile-tree rendering, or terminal rows in Dispatch. -6. Add running/cascade confirmation for tab/session close from every source, +4. Add running/cascade confirmation for tab/session close from every source, including buttons and shortcuts, with a target-bound grant. -7. Re-enumerate Close Old Agents after confirmation and before every kill. -8. Add a second confirmation for Kill Buried Session. -9. If included in this PR, replace Agent Management MCP close's prose-only +5. Re-enumerate Close Old Agents after confirmation and before every kill. +6. Add a second confirmation for Kill Buried Session. +7. If included in this PR, replace Agent Management MCP close's prose-only permission with an enforceable short-lived caller/target grant or renderer confirmation. Preserve its project/self/cascade checks. @@ -811,7 +1110,7 @@ Rollback boundary: command retirement, project-terminal feature removal, ordinary close confirmation, bulk close, and MCP grants are separate commits because they affect different authorities and persistence shapes. -### Phase 7 — Naming, Settings information architecture, and cleanup +### Phase 8 — Naming, Settings information architecture, and cleanup Files: @@ -837,9 +1136,10 @@ persistence changes. ### Catalog integrity -- First characterize the exact ordered 102-ID before-state. After Phase 6, - update it with an explicit negative assertion for all five retired IDs and an - exact `102 - 5 + separately approved additions` count. +- First characterize the exact ordered 102-ID before-state. During Phase 4, + update it with an explicit negative assertion for all five retired IDs, a + positive assertion for `open-command-palette`, and the expected exact count + `102 - 5 + 1 = 98` unless another addition is separately approved. - Unique IDs; required metadata; valid surface/category/tier/safety values. - Generated provider split parity. - Navigation Commands has exactly the six recorded stable IDs; no other @@ -881,6 +1181,41 @@ Assertions: - async rejection is user-visible and never unhandled; and - registry/picker order stays stable. +### Keybinding configuration + +- Settings lists every surviving built-in command exactly once, including + commands with no shipped binding and `open-command-palette`, while excluding + transient agent-index rows and all extension commands such as Timer. +- The exact shipped-default snapshot matches the approved table: existing + command-backed defaults/aliases plus Command-, for Settings, with every other + command unbound. +- Missing override inherits defaults; `[]` stays explicitly unbound; one or + multiple custom bindings replace defaults; per-command Reset and Reset All + remove sparse overrides. +- Malformed bindings are rejected/coerced deterministically; unknown keys are + preserved defensively; only explicitly retired built-in IDs are pruned. +- Duplicate bindings within one command, overlapping command defaults, and + reserved/native/editor/dictation collisions fail with the owning action + named. Explicit Replace updates both affected commands atomically. +- The repository script and Settings/runtime validator consume the same + normalizer, reservation registry, and overlap matrix. +- Option-letter matching uses physical codes on macOS; punctuation, named keys, + Cmd/Ctrl/Option/Shift, and function keys round-trip through capture, + persistence, matching, and display. +- Effective bindings, not shipped defaults, render in the palette and Settings. + Hiding a command from the picker never disables its binding, and unbinding it + never disables palette/menu execution. +- Capture-phase modal/editor/composer ownership remains intact. Contextual + interactions still win only in their declared contexts and never cause a + command to execute twice. +- Rebinding editor Save removes the old command-backed Monaco binding; no stale + Command-S path survives. Editor-native close remains a declared reservation. +- Successful picker, native-menu, and keybinding invocations increment history + under the correct source. Unavailable, canceled, rejected, failed, and + background/programmatic work does not inflate personalized ranking. +- The system accepts only single-step bindings; sequence/prefix syntax is + rejected with an actionable message. + ### Provider/capability - Claude/Codex/OpenCode/Terminal matrix exactly matches the table above. @@ -917,6 +1252,9 @@ Assertions: - `navigationCommandsEnabled` defaults/coerces to false for missing or malformed data, persists true when chosen, resets to false, and never changes shortcut behavior or direct action availability. +- `commandKeybindingOverrides` distinguishes missing from explicit empty, + survives restart, resets sparsely, rejects malformed bindings, and preserves + explicit choices when a later release changes shipped defaults. - Legacy `dispatchProjectTerminal` true/false values are discarded idempotently and are not reserialized; Dispatch never auto-creates or mounts the companion side column; user-created terminals still render normally. @@ -928,7 +1266,7 @@ Assertions: | Requirement | Acceptance evidence | |---|---| -| Complete audit | The characterization test records all 102 audited IDs; the final catalog snapshot shows the explicit five-ID retirement delta plus only separately approved additions. | +| Complete audit | The characterization test records all 102 audited before-state IDs; the final catalog snapshot proves five explicit retirements, the approved `open-command-palette` addition, and the expected 98-ID result unless another addition is separately approved. | | Visibility is not authorization | Hiding each native File-menu command does not disable its menu item; direct invocation still fails any real contextual/capability gate. | | Debug clutter is gated | Debug-tier commands are absent by default, revealable/overridable, and remain protected by live capability checks where required. | | Consistent state | No stateful command returns arbitrary tone semantics; panels, toggles, values, unavailable, loading, mixed, and error states render predictably and accessibly. | @@ -936,11 +1274,15 @@ Assertions: | Stable target | State and mutation refer to the same pinned target across focus, Dispatch lane, provider, and lifecycle changes. | | Settings ownership | Status Mode, Worktree Badges, both Usage preferences, and Dangerous Agents are Settings-only; their five command IDs are absent. Aggressive Debug Persistence remains debug-hidden. MCP/view defaults versus session overrides and workspace defaults versus current-workspace commands disclose their distinct scope. | | Navigation command group | Enable Navigation Commands is off by default and controls picker membership for exactly Next/Previous Tab plus Focus Pane Left/Right/Up/Down. Enabling restores normal per-command visibility; disabling never breaks their shortcuts or underlying actions. | +| Built-in binding control | Every surviving built-in command appears in Settings with zero/one/multiple bindings, explicit unbinding, per-command Reset, and Reset All. Timer and all other extension commands are absent. | +| Binding integrity | Settings and `npm run check:keybindings` share one normalizer/reservation authority; overlapping bindings are blocked with a named owner and only explicit atomic Replace can move a binding. No display-only shortcut metadata remains. | +| Curated defaults | The shipped snapshot preserves the characterized command bindings and aliases, adds Command-, for Settings, leaves all other commands unbound, and supports single-step bindings only. | | Project terminal retirement | Attach Project Terminal to Dispatch is absent from Settings, persisted state, action/hook contracts, and Dispatch rendering. Old persisted keys are dropped, while ordinary terminal sessions and rows still work. | | Destructive safety | Running/cascading closes and all bulk closes are target-bound, freshly validated, and expose partial failure without silent retargeting. | | Invocation parity | Palette, menu, shortcut, and programmatic paths share admission/error handling while retaining source-specific UI behavior. | | Migration safety | Persistence-version/coercion/alias tests pass; command IDs remain stable unless an explicit idempotent alias migration exists. | -| Discoverability | Command Settings is grouped and searchable by nested titles, keywords, description, shortcut, tier, scope, and risk. | +| Discoverability | Command Settings is grouped and searchable by nested titles, keywords, description, effective bindings, tier, scope, and risk. | +| Representative history | Successful picker, menu, and keybinding executions are recorded with source; rejected/failed/background work is excluded and legacy counts are identified as picker-origin. | ## Validation commands @@ -954,6 +1296,7 @@ npm run test:system npm run typecheck npm test npm run test:package +npm run check:keybindings npm run check ``` @@ -974,14 +1317,24 @@ Manual packaged-app checks must cover: 10. old `dispatchProjectTerminal` storage does not create a terminal or side column, while an ordinary user-created terminal still works in Dispatch; 11. Settings restart persistence and new-session versus current-session scope; -12. Claude does not receive Workflow MCP while Codex does; and -13. assistive-technology text for every semantic badge state. +12. Claude does not receive Workflow MCP while Codex does; +13. every built-in command can be assigned, multiply bound, unbound, and reset; +14. conflict capture names command, contextual, native, editor, and current + dictation owners, with explicit Replace as the only override path; +15. rebinding navigation, Global Editor, Save, command palette, and Settings + changes actual behavior and the displayed effective binding without leaving + the former chord active; +16. Timer/extension commands do not appear in built-in binding Settings or + defaults; and +17. assistive-technology text for every semantic badge and binding state. ## Product decisions requested at review The source establishes the defects above, but these UX choices genuinely need -product confirmation before implementation. Settings ownership and Dispatch -project-terminal removal are recorded decisions above and are no longer open: +product confirmation before implementation. Settings ownership, Dispatch +project-terminal removal, Navigation membership/contextual gestures, and the +built-in keybinding product shape are recorded decisions above and are no +longer open: 1. **Unavailable presentation.** Recommendation: hide mode-irrelevant commands; show discovery-worthy unsupported commands disabled with a reason when the @@ -989,13 +1342,10 @@ project-terminal removal are recorded decisions above and are no longer open: 2. **Close confirmation threshold.** Recommendation: confirm running/streaming targets and any cascade/multi-target close; keep an idle single close immediate and undoable. -3. **Directional shortcuts in Dispatch.** Recommendation: one command ID must - have one meaning. Dispatch keybindings should invoke a distinct direction-free - creation/navigation command rather than bypassing a hidden grid command. -4. **Remote Control maturity.** Recommendation: classify the panel +3. **Remote Control maturity.** Recommendation: classify the panel `experimental` until packaged runtime/network readiness and disclosure are complete; keep actual enable/listen/pair actions separately authorized. -5. **Agent Management MCP close grant.** Recommendation: include the enforceable +4. **Agent Management MCP close grant.** Recommendation: include the enforceable explicit-user-intent grant in this governance PR because prose-only safety is not a real gate. It is listed separately because it is an MCP tool, not one of the 102 palette commands. @@ -1015,8 +1365,13 @@ project-terminal removal are recorded decisions above and are no longer open: - Do not replace main-process ownership checks with renderer-only confirmation. - Do not persist transient panels/layout stances merely because they have state badges. -- Do not force every keybinding through the new gateway in one change; migrate - high-risk bindings first with parity tests. +- Do not turn Escape, modal/picker/composer input, numbered selection, resize, + or editor-native file-tab actions into fake commands. Declare them as + contextual reservations while routing every actual built-in command binding + through the gateway. +- Do not add multi-step key sequences in this PR. +- Do not include Timer or any other extension-contributed command in built-in + binding Settings, defaults, catalog counts, or the static validator. - Do not delete unknown command visibility keys indiscriminately; future extension/provider commands may be temporarily absent. - Do not implement any item in this document before the review checkpoint is From ea78a56bf9fd544fb158c2a8b536996f440a8d74 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 16:15:27 +0200 Subject: [PATCH 05/33] refactor(commands): extract the context-free catalog and pin the baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. Characterization only — no product behavior changes. The ordered command array moves out of registry.ts into catalog.ts. The split exists because registry.ts answers "what should the picker show right now", which needs a live CommandContext, while four consumers need "what commands EXIST" and have no context to offer: the native menu, Settings, catalog validation, and diagnostics. Those consumers previously reached through buildCommandRegistry and inherited picker filtering as a side effect — the defect the plan names "a picker resolver, not a command-admission authority". A catalog must never be defined by what presentation happened to keep. Native-menu command ids move to a shared constant so the main-process menu and the renderer catalog can be checked against each other. appMenu's dispatchCommand is now typed to that union, so a retired or mistyped id fails the build instead of shipping a menu item that clicks into nothing. The two test files record the before-state the later phases move: - catalog.test.ts pins the exact ordered 102 ids (registration order is the palette's browse order, so a count-only test would pass through an import reshuffle), reconciles 98 literal + 4 generated provider splits, proves all 102 currently resolve to the 'default' tier, and asserts the plan's 102 - 5 + 1 = 98 arithmetic against the real catalog rather than against prose. findCatalogDefects is exercised with broken fixtures so it cannot decay into a function that returns [] for everything. - keybindingBaseline.test.ts records the shortcut authority split. CommandDef.shortcut is display-only; the behavior lives in useKeybinds, Monaco addAction, and a hard-coded palette callback. Seven chords do real work that no palette row advertises: Cmd+Shift+E, Cmd+Shift+P, Option+W, and the four Option+Arrow nav aliases. Save is the sharp case — declared as a command shortcut but implemented twice in the editor, so rebinding it later must remove both or the old chord survives. The declared column is derived from the catalog and compared against the hand-read table, so editing any shortcut: field forces the drift table to be updated in the same commit. Verified: tsc -b clean, 230 files / 1340 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/menu/appMenu.ts | 10 +- .../features/command-palette/catalog.test.ts | 366 ++++++++++++++++++ .../src/features/command-palette/catalog.ts | 142 +++++++ .../keybindingBaseline.test.ts | 360 +++++++++++++++++ .../src/features/command-palette/registry.ts | 48 +-- src/shared/commands/nativeMenuCommandIds.ts | 31 ++ 6 files changed, 912 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/features/command-palette/catalog.test.ts create mode 100644 src/renderer/src/features/command-palette/catalog.ts create mode 100644 src/renderer/src/features/command-palette/keybindingBaseline.test.ts create mode 100644 src/shared/commands/nativeMenuCommandIds.ts diff --git a/src/main/menu/appMenu.ts b/src/main/menu/appMenu.ts index 8952e009..ebed2aa3 100644 --- a/src/main/menu/appMenu.ts +++ b/src/main/menu/appMenu.ts @@ -2,6 +2,7 @@ import { Menu } from 'electron' import type { MenuItemConstructorOptions } from 'electron' import { sendToMainWindow, zoomMainWindow } from '@main/window/mainWindow.js' +import type { NativeMenuCommandId } from '@shared/commands/nativeMenuCommandIds.js' // macOS application menu (issue #148). // @@ -36,8 +37,13 @@ import { sendToMainWindow, zoomMainWindow } from '@main/window/mainWindow.js' // is mouse-driven discoverability, not a second keybinding surface. /** Emit a renderer command id over the menu:command channel. The renderer's - * live CommandContext resolves and runs it. */ -function dispatchCommand(commandId: string): void { + * live CommandContext resolves and runs it. + * + * Typed to `NativeMenuCommandId` rather than `string` so a typo or a retired + * command fails the build here, instead of shipping a menu item that clicks + * into nothing. The id list is shared with the renderer, where + * `catalog.test.ts` proves every entry still names a real command. */ +function dispatchCommand(commandId: NativeMenuCommandId): void { sendToMainWindow('menu:command', commandId) } diff --git a/src/renderer/src/features/command-palette/catalog.test.ts b/src/renderer/src/features/command-palette/catalog.test.ts new file mode 100644 index 00000000..c798acfc --- /dev/null +++ b/src/renderer/src/features/command-palette/catalog.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it } from 'vitest' + +import { builtInCommandCatalog, findCatalogDefects } from '@renderer/features/command-palette/catalog' +import { NATIVE_MENU_COMMAND_IDS } from '@shared/commands/nativeMenuCommandIds' +import { AGENT_PROVIDER_KINDS, DEFAULT_PROVIDER } from '@shared/types/providerKind' +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. +// +// Everything here describes what the catalog is TODAY, before any governance +// behavior changes. That is the whole point: the later phases retire five +// commands, add one, reclassify tiers, and rewire how ids resolve. Without a +// pinned before-state, "did we change exactly what we meant to change" is +// unanswerable, and the plan's central count (102 → 98) is an assertion nobody +// can check. +// +// WHY a literal ordered snapshot rather than just a count: registration order +// is the palette's empty-query browse order, and users navigate that list by +// position. A count-only test passes while an import reshuffle silently moves +// every row. The plan calls order out explicitly, so the test pins order. +// +// When a later phase legitimately changes this list, the diff should be the +// reviewable artifact — update the snapshot in the SAME commit as the change, +// never as a follow-up "fix the test" commit. +// --------------------------------------------------------------------------- + +/** The exact ordered catalog as of the governance baseline (main @ 670f4c2d). */ +const BASELINE_COMMAND_IDS: readonly string[] = [ + // tabCommands (6) + 'new-tab', + 'close-tab', + 'next-tab', + 'prev-tab', + 'reorder-tabs', + 'resume-session', + // paneCommands (28: 24 literal + 4 generated provider splits) + 'new-agent', + 'split-vertical', + 'split-horizontal', + 'close-pane', + 'bury-pane', + 'linked-agent', + 'attach-detached-to-grid', + 'pin-agents', + 'unpin-agent', + 'attach-all-detached-for-tab', + 'detach-to-dispatch', + 'terminal-horizontal', + 'terminal-vertical', + 'codex-vertical', + 'codex-horizontal', + 'opencode-vertical', + 'opencode-horizontal', + 'nav-left', + 'nav-right', + 'nav-up', + 'nav-down', + 'undo-close', + 'revive-pane', + 'kill-buried-pane', + 'toggle-tail', + 'toggle-tail-all', + 'jump-latest-message', + 'copy-last-assistant', + // layoutCommands (9) + 'dispatch-mode', + 'global-dispatch', + 'tiled-dispatch', + 'normalize-layout', + 'hard-normalize-layout', + 'rotate-layout', + 'toggle-status-mode', + 'toggle-performance-panel', + 'toggle-caffeinate', + // globalEditorCommands (10) + 'toggle-global-editor', + 'save-editor-file', + 'save-all-editor-files', + 'quick-open-file', + 'search-in-files', + 'toggle-editor-fullscreen', + 'open-ai-workspace', + 'create-ai-workspace', + 'clear-ai-workspace', + 'toggle-file-tree', + // sessionCommands (29) + 'view-prompts', + 'rewind-to-prompt', + 'undo-rewind', + 'open-agent-activity', + 'close-old-agents', + 'switch-agents-provider', + 'search-conversation-prompts', + 'enable-built-in-mcp-ping', + 'enable-ai-workspace-mcp', + 'enable-orchestration-mcp', + 'enable-agent-transcripts-mcp', + 'enable-agent-management-mcp', + 'enable-workflow-mcp', + 'reload-agent', + 'soft-reload-agent', + 'set-agent-view-mode', + 'copy-resume-command', + 'duplicate-agent', + 'switch-provider', + 'toggle-git-bar', + 'toggle-debug-panel', + 'toggle-feed-debug-panel', + 'toggle-proxy-debug-panel', + 'save-debug-logs', + 'toggle-session-recording', + 'attach-recording-note', + 'toggle-rendering-debug-mode', + 'toggle-html-debug-panel', + 'toggle-dev-debug-panel', + // dispatchColorFlagCommands (1) + 'dispatch.color-flag.set', + // spotlight / reader / tile-tabs (3) + 'toggle-spotlight', + 'toggle-reader-mode', + 'tiled-tabs', + // settingsCommands + dangerousCommands (5) + 'open-settings', + 'toggle-aggressive-debug-persistence', + 'toggle-worktrees-bar', + 'toggle-worktree-badges', + 'dangerous-agents', + // copy-assistant / copy-code-block (2) + 'copy-assistant-message', + 'copy-code-block', + // prompt templates + reply to selection (4) + 'manage-prompt-templates', + 'prompt-template', + 'save-composer-as-prompt-template', + 'reply-to-selection', + // agent status / remote (2) + 'show-agent-status', + 'toggle-remote-panel', + // usage (3) + 'usage.open', + 'usage.toggle-header', + 'usage.cycle-header-level', +] + +/** The five ids the plan retires in Phase 4, kept here so the retirement is a + * one-line diff against a named list rather than five scattered edits. */ +const PLANNED_RETIREMENTS: readonly string[] = [ + 'toggle-status-mode', + 'toggle-worktree-badges', + 'usage.toggle-header', + 'usage.cycle-header-level', + 'dangerous-agents', +] + +/** The exact six-member Navigation Commands group (plan decision 6). Closed by + * id, deliberately NOT derived from the broader `Navigate` category — the plan + * is explicit that a future navigation-adjacent command must not inherit + * default-hidden behavior merely by reusing a category. */ +const NAVIGATION_COMMAND_GROUP: readonly string[] = [ + 'next-tab', + 'prev-tab', + 'nav-left', + 'nav-right', + 'nav-up', + 'nav-down', +] + +const ids = (): string[] => builtInCommandCatalog.map(c => c.id) + +describe('built-in command catalog — baseline characterization', () => { + it('contains exactly the 102 baseline commands in registration order', () => { + // Order matters: this is the palette's empty-query browse order. + expect(ids()).toEqual([...BASELINE_COMMAND_IDS]) + }) + + it('has exactly 102 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(102) + }) + + it('reports no structural defects', () => { + expect(findCatalogDefects(builtInCommandCatalog)).toEqual([]) + }) + + it('has no duplicate ids', () => { + // Redundant with findCatalogDefects, kept because a duplicate id is the one + // defect that silently HALVES a command's reachability (the second + // definition wins in some lookups and loses in others) rather than + // producing a visible error. + expect(new Set(ids()).size).toBe(builtInCommandCatalog.length) + }) +}) + +describe('generated per-provider split commands', () => { + // The plan's arithmetic is 98 literal ids + 4 generated = 102. 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. + const nonDefaultProviders = AGENT_PROVIDER_KINDS.filter(k => k !== DEFAULT_PROVIDER) + + it('generates exactly (providers - default) x {vertical, horizontal}', () => { + const generated = ids().filter(id => + nonDefaultProviders.some(kind => id === `${kind}-vertical` || id === `${kind}-horizontal`), + ) + expect(generated).toHaveLength(nonDefaultProviders.length * 2) + }) + + it('accounts for the difference between literal and total command count', () => { + // 102 total - 4 generated = 98 literal `id:` fields in the command modules. + // This is the exact reconciliation the plan performs, asserted rather than + // asserted-in-prose. + expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(98) + }) + + it('emits both directions for every non-default provider', () => { + for (const kind of nonDefaultProviders) { + expect(ids()).toContain(`${kind}-vertical`) + expect(ids()).toContain(`${kind}-horizontal`) + } + }) + + it('does not generate split commands for the default provider', () => { + // The generic split-vertical/-horizontal commands already spawn the default + // provider; a named duplicate would be two palette rows doing one thing. + expect(ids()).not.toContain(`${DEFAULT_PROVIDER}-vertical`) + expect(ids()).not.toContain(`${DEFAULT_PROVIDER}-horizontal`) + }) +}) + +describe('picker visibility baseline', () => { + it('resolves every command to the default tier today', () => { + // The plan's most consequential finding: `advanced`/`experimental`/`debug` + // all EXIST in the type and NONE are used, so debug tooling, destructive + // maintenance and daily navigation enter the picker at the same tier. + // + // This assertion is expected to FAIL in Phase 3, which is the point — it is + // the tripwire proving tier classification actually landed rather than + // being declared in a doc. + const tiers = builtInCommandCatalog.map(c => c.pickerVisibility ?? 'default') + expect(new Set(tiers)).toEqual(new Set(['default'])) + }) +}) + +describe('native menu contract', () => { + it('dispatches only ids that exist in the catalog', () => { + // The high-severity defect in the plan: the menu resolves through the + // picker-filtered registry, so a cosmetic visibility override can disable a + // File-menu item. Fixing that (Phase 1) requires the ids to be resolvable + // against the full catalog — which first requires them to BE in it. + const catalogIds = new Set(ids()) + const missing = NATIVE_MENU_COMMAND_IDS.filter(id => !catalogIds.has(id)) + expect(missing).toEqual([]) + }) + + it('covers the six File-menu actions recorded in the audit', () => { + expect([...NATIVE_MENU_COMMAND_IDS]).toEqual([ + 'new-tab', + 'resume-session', + 'save-editor-file', + 'save-all-editor-files', + 'reorder-tabs', + 'close-tab', + ]) + }) +}) + +describe('planned governance targets still exist in the baseline', () => { + // These assertions exist so the later phases have something to invert. If a + // retirement lands, the corresponding line here flips to `not.toContain` in + // the same commit — making the retirement visible in the test diff instead of + // only in the implementation diff. + + it('still contains all five commands planned for retirement', () => { + const catalogIds = new Set(ids()) + for (const id of PLANNED_RETIREMENTS) { + expect(catalogIds.has(id)).toBe(true) + } + }) + + it('does not yet contain the planned open-command-palette command', () => { + // Cmd+Shift+P is hard-coded in useKeybinds today and is not a command at + // all, which is why its binding cannot be configured. Phase 4 adds it. + expect(ids()).not.toContain('open-command-palette') + }) + + it('contains every member of the closed Navigation Commands group', () => { + const catalogIds = new Set(ids()) + for (const id of NAVIGATION_COMMAND_GROUP) { + expect(catalogIds.has(id)).toBe(true) + } + }) + + it('projects the post-Phase-4 catalog size as 98', () => { + // 102 - 5 retirements + 1 addition = 98. Encoded so the plan's headline + // arithmetic is checked against the real catalog rather than trusted. + expect(builtInCommandCatalog.length - PLANNED_RETIREMENTS.length + 1).toBe(98) + }) +}) + +describe('findCatalogDefects', () => { + // The validator is only load-bearing if it actually catches things. Feeding + // it deliberately broken fixtures is what stops it from degrading into a + // function that returns [] for everything and a suite that proves nothing. + + const ok: CommandDef = { + id: 'x.ok', + surface: 'app', + title: 'Ok', + description: 'fine', + run: () => {}, + } + + it('accepts a well-formed command', () => { + expect(findCatalogDefects([ok])).toEqual([]) + }) + + it('reports duplicate ids with both positions', () => { + const defects = findCatalogDefects([ok, ok]) + expect(defects).toHaveLength(1) + expect(defects[0]).toContain('duplicate id "x.ok"') + }) + + it('reports an empty description', () => { + const defects = findCatalogDefects([{ ...ok, description: ' ' }]) + expect(defects).toEqual(['command "x.ok" has an empty description']) + }) + + it('reports an empty static title but tolerates a function title', () => { + expect(findCatalogDefects([{ ...ok, title: ' ' }])).toEqual([ + 'command "x.ok" has an empty title', + ]) + expect(findCatalogDefects([{ ...ok, title: () => 'dynamic' }])).toEqual([]) + }) + + it('reports an unknown surface', () => { + const defects = findCatalogDefects([ + { ...ok, surface: 'nope' as CommandDef['surface'] }, + ]) + expect(defects).toEqual(['command "x.ok" has unknown surface "nope"']) + }) + + it('reports an unknown picker visibility tier', () => { + const defects = findCatalogDefects([ + { ...ok, pickerVisibility: 'secret' as CommandDef['pickerVisibility'] }, + ]) + expect(defects).toEqual(['command "x.ok" has unknown pickerVisibility "secret"']) + }) + + it('reports a missing run handler', () => { + const defects = findCatalogDefects([ + { ...ok, run: undefined as unknown as CommandDef['run'] }, + ]) + expect(defects).toEqual(['command "x.ok" has no run handler']) + }) + + it('accumulates every defect rather than stopping at the first', () => { + const defects = findCatalogDefects([ + { ...ok, id: 'x.bad', description: '', surface: 'nope' as CommandDef['surface'] }, + ]) + expect(defects).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/features/command-palette/catalog.ts b/src/renderer/src/features/command-palette/catalog.ts new file mode 100644 index 00000000..6d11b96f --- /dev/null +++ b/src/renderer/src/features/command-palette/catalog.ts @@ -0,0 +1,142 @@ +import { layoutCommands } from '@renderer/features/workspace/commands/layoutCommands' +import { globalEditorCommands } from '@renderer/features/global-editor/commands/globalEditorCommands' +import { paneCommands } from '@renderer/features/workspace/commands/paneCommands' +import { sessionCommands } from '@renderer/features/workspace/commands/sessionCommands' +import { tabCommands } from '@renderer/features/workspace/commands/tabCommands' +import { settingsCommands } from '@renderer/features/settings/commands/settingsCommands' +import { spotlightCommands } from '@renderer/features/spotlight/commands/spotlightCommands' +import { tileTabsCommands } from '@renderer/features/tile-tabs/commands/tileTabsCommands' +import { readerCommands } from '@renderer/features/reader/commands/readerCommands' +import { copyAssistantCommands } from '@renderer/features/copy-assistant/commands/copyAssistantCommands' +import { copyCodeBlockCommands } from '@renderer/features/copy-code-block/commands/copyCodeBlockCommands' +import { promptTemplateCommands } from '@renderer/features/prompt-templates/commands/promptTemplateCommands' +import { replyToSelectionCommands } from '@renderer/features/reply-to-selection/commands/replyToSelectionCommands' +import { agentStatusCommands } from '@renderer/features/agent-status/commands/agentStatusCommands' +import { dispatchColorFlagCommands } from '@renderer/features/workspace/commands/dispatchColorFlagCommands' +import { remoteCommands } from '@renderer/features/remote/commands/remoteCommands' +import { usageCommands } from '@renderer/features/usage/commands/usageCommands' +import type { CommandDef } from '@renderer/features/command-palette/types' + +/** + * The context-free catalog: every built-in command Agent Code ships, in + * registration order, with NO contextual filtering applied. + * + * WHY this is a module of its own rather than the array that used to live at + * the top of `registry.ts`: + * + * `registry.ts` answers "what should the picker show right now", which requires + * a live `CommandContext` (a focused session, the ui callback bag, the flags + * snapshot). Four other consumers need a different question answered — "what + * commands EXIST" — and none of them has a context to offer: + * + * - the native menu, which dispatches stable ids from the main process; + * - Settings, which lists every command the user may show/hide or bind; + * - catalog validation, which must see hidden and inapplicable commands too; + * - diagnostics, which resolve an id long after the invocation that used it. + * + * Those consumers previously reached through `buildCommandRegistry`, which + * meant they inherited picker filtering as a side effect. That is precisely the + * defect the governance plan calls "a picker resolver, not a command-admission + * authority": a cosmetic `commandVisibilityOverrides[id] = false` could remove + * a native File-menu action, because the menu resolved ids from an + * already-filtered list. Splitting the catalog out is the structural + * precondition for fixing that — presentation can filter a catalog, but a + * catalog must never be defined by what presentation happened to keep. + * + * INVARIANT: this array is context-free and side-effect-free at module scope. + * Nothing here may read the workspace store, the settings store, or the DOM. + * The generated per-provider entries below are the one computed part, and they + * derive only from the static provider registry. + * + * INVARIANT: registration order is user-visible — it is the palette's + * empty-query browse order, and people navigate that list by position. Reorder + * only deliberately; `catalog.test.ts` pins the exact order so an accidental + * import shuffle fails loudly instead of silently reshuffling the UI. + */ +export const builtInCommandCatalog: readonly CommandDef[] = Object.freeze([ + ...tabCommands, + ...paneCommands, + ...layoutCommands, + // Registered right after layoutCommands so the moved editor commands + // keep (approximately) their old registry order — registry order is the + // palette's empty-query browse order, and gratuitous reshuffles read as + // regressions to users who navigate by position. + ...globalEditorCommands, + ...sessionCommands, + ...dispatchColorFlagCommands, + ...spotlightCommands, + ...readerCommands, + ...tileTabsCommands, + ...settingsCommands, + ...copyAssistantCommands, + ...copyCodeBlockCommands, + ...promptTemplateCommands, + // Grouped with the prompt-template commands because it is the other + // composer-insertion command — registry order is the palette's + // empty-query browse order, so like things stay adjacent. + ...replyToSelectionCommands, + ...agentStatusCommands, + ...remoteCommands, + ...usageCommands, +]) + +/** + * Structural problems found in a catalog, as human-readable sentences. + * + * WHY this returns a list instead of throwing on the first problem: the caller + * is either a test (which should report every defect in one run, not make the + * author replay the suite once per typo) or a future dev-mode boot check. Both + * want the complete picture. It is also deliberately a pure function over an + * arbitrary array rather than a check of the module-level catalog, so a test + * can feed it a deliberately broken fixture and assert the diagnosis. + * + * WHY validation lives here rather than inside `buildCommandRegistry`'s map: + * the registry only ever sees commands that survived contextual filtering, so + * a missing description on a Dispatch-only command went unnoticed in Grid and + * threw at the moment a user switched modes. Validating the context-free + * catalog moves that failure to a test, before it can reach anyone. + */ +export function findCatalogDefects(commands: readonly CommandDef[]): string[] { + const defects: string[] = [] + const seen = new Map() + + commands.forEach((command, index) => { + const previous = seen.get(command.id) + if (previous !== undefined) { + defects.push( + `duplicate id "${command.id}" at index ${index} (first seen at ${previous})`, + ) + } else { + seen.set(command.id, index) + } + + if (!command.id.trim()) defects.push(`command at index ${index} has an empty id`) + if (!command.description.trim()) defects.push(`command "${command.id}" has an empty description`) + if (typeof command.run !== 'function') defects.push(`command "${command.id}" has no run handler`) + + // A function title is legitimate (state-dependent labels flip their text), + // but an empty STATIC title would render a blank palette row and a blank + // Settings row with no way to tell which command it is. + if (typeof command.title === 'string' && !command.title.trim()) { + defects.push(`command "${command.id}" has an empty title`) + } + + if (!VALID_SURFACES.has(command.surface)) { + defects.push(`command "${command.id}" has unknown surface "${command.surface}"`) + } + + const tier = command.pickerVisibility ?? 'default' + if (!VALID_TIERS.has(tier)) { + defects.push(`command "${command.id}" has unknown pickerVisibility "${tier}"`) + } + }) + + return defects +} + +// Kept as runtime sets rather than relying on the compile-time union alone: +// the catalog is the boundary a future extension-contributed or +// provider-generated command crosses, and those are built from strings that +// TypeScript cannot check at the point of construction. +const VALID_SURFACES = new Set(['app', 'grid', 'dispatch', 'session', 'editor', 'debug']) +const VALID_TIERS = new Set(['default', 'advanced', 'experimental', 'debug']) diff --git a/src/renderer/src/features/command-palette/keybindingBaseline.test.ts b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts new file mode 100644 index 00000000..0c64b0c6 --- /dev/null +++ b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it } from 'vitest' + +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' + +// --------------------------------------------------------------------------- +// Phase 0 characterization: THE SHORTCUT AUTHORITY SPLIT. +// +// `CommandDef.shortcut` is a display-only string. The behavior it describes is +// implemented somewhere else entirely — `workspace/tile-tree/useKeybinds.ts` +// for workspace chords, Monaco `addAction` + the EditorWorkbench bubble handler +// for editor chords, and a hard-coded `onCommandPalette?.()` callback for the +// palette itself. Nothing keeps the two in sync. +// +// That split is the reason the governance plan's keybinding phase exists: a +// user-editable binding would be cosmetic, because editing the string the +// palette renders would not touch the code that actually runs. Phase 4 replaces +// `shortcut` with `defaultKeybindings` and routes every command-backed handler +// through the execution gateway. +// +// This file pins the split BEFORE that migration, in three parts: +// 1. exactly which commands declare display metadata today; +// 2. the recorded drift — declared-but-not-implemented, implemented-but-not- +// declared, and chords with no command at all; +// 3. the invariant that (1) and (2) agree, so the drift table cannot silently +// rot when someone edits a `shortcut:` field. +// +// WHY the "actual behavior" column is a hand-authored table rather than +// something derived from the source: deriving it would mean parsing +// useKeybinds.ts's branch structure, which is a bespoke framework the repo's +// conventions reject, and which would still miss Monaco's registration. The +// table was read off the implementation directly (see the per-entry notes) and +// is cross-checked against the catalog by the last test, so an edit on either +// side breaks the suite. +// --------------------------------------------------------------------------- + +/** Where the chord that runs a command is actually implemented today. */ +type BindingOwner = + /** `useKeybinds.ts` — the workspace capture-phase router. */ + | 'useKeybinds' + /** Monaco `addAction` and/or the EditorWorkbench bubble-phase handler. */ + | 'editor' + /** Nothing runs it; the metadata string is decoration. */ + | 'nothing' + +type BindingBaseline = { + commandId: string + /** The `shortcut` string the command declares, or null if it declares none. */ + declared: string | null + /** Chords that really run this command's behavior today. May be empty. */ + effective: readonly string[] + owner: BindingOwner + note: string +} + +/** + * The complete recorded state of command-backed keyboard behavior at the + * governance baseline. Only commands that either declare a shortcut OR have a + * real chord appear here; a command with neither is unbound and uninteresting. + */ +const BINDING_BASELINE: readonly BindingBaseline[] = [ + // --- Tabs: declared and implemented, in agreement. ----------------------- + { + commandId: 'new-tab', + declared: '⌘T', + effective: ['⌘T'], + owner: 'useKeybinds', + note: 'cmd && k === "t" && !shift. Agrees with metadata.', + }, + { + commandId: 'close-tab', + declared: '⌘⇧W', + effective: ['⌘⇧W'], + owner: 'useKeybinds', + note: 'cmd && w && shift → closeTab(activeTab.id).', + }, + { + commandId: 'next-tab', + declared: '⌘]', + effective: ['⌘]'], + owner: 'useKeybinds', + note: 'cmd && k === "]" → workspace.nextTab().', + }, + { + commandId: 'prev-tab', + declared: '⌘[', + effective: ['⌘['], + owner: 'useKeybinds', + note: 'cmd && k === "[" → workspace.prevTab().', + }, + { + commandId: 'resume-session', + declared: '⌘⇧R', + effective: ['⌘⇧R'], + owner: 'useKeybinds', + note: 'cmd && r && shift → onResumeRequest(focused cwd).', + }, + { + commandId: 'undo-close', + declared: '⌘⇧T', + effective: ['⌘⇧T'], + owner: 'useKeybinds', + note: 'cmd && shift && t → workspace.undoClose().', + }, + + // --- Panes: one UNDECLARED ALIAS. --------------------------------------- + { + commandId: 'close-pane', + declared: '⌘W', + effective: ['⌘W', '⌥W'], + owner: 'useKeybinds', + note: + 'DRIFT (undeclared alias): cmd+w AND alt+w (code === "KeyW") both call ' + + 'workspace.closeFocused(). Only ⌘W is advertised, so ⌥W is a destructive ' + + 'chord that no UI surface discloses.', + }, + { + commandId: 'split-vertical', + declared: '⌥D', + effective: ['⌥D'], + owner: 'useKeybinds', + note: 'code === "KeyD" && !shift. Physical code, because ⌥D types "∂" on macOS.', + }, + { + commandId: 'split-horizontal', + declared: '⌥⇧D', + effective: ['⌥⇧D'], + owner: 'useKeybinds', + note: 'code === "KeyD" && shift.', + }, + { + commandId: 'terminal-horizontal', + declared: '⌥T', + effective: ['⌥T'], + owner: 'useKeybinds', + note: 'code === "KeyT" && !shift → splitFocused("vertical", "terminal").', + }, + { + commandId: 'terminal-vertical', + declared: '⌥⇧T', + effective: ['⌥⇧T'], + owner: 'useKeybinds', + note: 'code === "KeyT" && shift → splitFocused("horizontal", "terminal").', + }, + { + commandId: 'codex-vertical', + declared: '⌥C', + effective: ['⌥C'], + owner: 'useKeybinds', + note: + 'Generated from the provider identity descriptor (splitShortcutKey: "C"). ' + + 'The keybind side loops AGENT_PROVIDER_KINDS and matches code === `Key${chord}`, ' + + 'so metadata and behavior share one source — the only chord family that does.', + }, + { + commandId: 'codex-horizontal', + declared: '⌥⇧C', + effective: ['⌥⇧C'], + owner: 'useKeybinds', + note: 'Same provider loop, shift branch.', + }, + + // --- Navigation: FOUR UNDECLARED ARROW ALIASES. ------------------------- + { + commandId: 'nav-left', + declared: '⌥H', + effective: ['⌥H', '⌥←'], + owner: 'useKeybinds', + note: 'DRIFT (undeclared alias): `code === "KeyH" || k === "ArrowLeft"`.', + }, + { + commandId: 'nav-right', + declared: '⌥L', + effective: ['⌥L', '⌥→'], + owner: 'useKeybinds', + note: 'DRIFT (undeclared alias): `code === "KeyL" || k === "ArrowRight"`.', + }, + { + commandId: 'nav-up', + declared: '⌥K', + effective: ['⌥K', '⌥↑'], + owner: 'useKeybinds', + note: 'DRIFT (undeclared alias): `code === "KeyK" || k === "ArrowUp"`.', + }, + { + commandId: 'nav-down', + declared: '⌥J', + effective: ['⌥J', '⌥↓'], + owner: 'useKeybinds', + note: 'DRIFT (undeclared alias): `code === "KeyJ" || k === "ArrowDown"`.', + }, + + // --- Feed --------------------------------------------------------------- + { + commandId: 'jump-latest-message', + declared: 'End', + effective: ['End'], + owner: 'useKeybinds', + note: + 'Bare End with no modifiers, guarded by !isTextEditingTarget and a ' + + 'rendered-surface check. Agrees with metadata.', + }, + + // --- Editor: a METADATA GAP and an EDITOR-OWNED chord. ------------------ + { + commandId: 'toggle-global-editor', + declared: null, + effective: ['⌘⇧E'], + owner: 'useKeybinds', + note: + 'DRIFT (metadata gap): cmd+shift+e calls toggleGlobalEditor() directly, but ' + + 'the command declares no shortcut, so the palette shows this row with no ' + + 'chord even though one exists. Phase 4 adds ⌘⇧E to defaultKeybindings.', + }, + { + commandId: 'toggle-editor-fullscreen', + declared: '⌥⌘E', + effective: ['⌥⌘E'], + owner: 'useKeybinds', + note: 'cmd && alt && code === "KeyE". Opens the editor first when closed.', + }, + { + commandId: 'quick-open-file', + declared: '⌘P', + effective: ['⌘P'], + owner: 'useKeybinds', + note: 'cmd && !shift && !alt && p. Opens the editor first when closed.', + }, + { + commandId: 'search-in-files', + declared: '⌘⇧F', + effective: ['⌘⇧F'], + owner: 'useKeybinds', + note: 'cmd && shift && f. Opens the editor first when closed.', + }, + { + commandId: 'save-editor-file', + declared: '⌘S', + effective: ['⌘S'], + owner: 'editor', + note: + 'DRIFT (not command-backed): useKeybinds deliberately RETURNS for ' + + 'cmd+s in editor chrome. The chord is implemented twice elsewhere — ' + + 'Monaco `editor.addAction({keybindings: [CtrlCmd | KeyS]})` inside the ' + + 'text area, and EditorWorkbench.onKeyDown for tabs/Explorer/splitters. ' + + 'Neither consults the command. This is the case the plan singles out: ' + + 'rebinding Save in Settings must remove BOTH, or the old chord survives.', + }, + { + commandId: 'save-all-editor-files', + declared: null, + effective: [], + owner: 'nothing', + note: 'No chord today. Reachable from the palette and the File menu only.', + }, +] + +/** Chords that run real behavior but belong to NO command — they cannot be + * rebound today because there is no id to attach a binding to. */ +const UNOWNED_COMMAND_CHORDS = [ + { + chord: '⌘⇧P', + behavior: 'Opens the command palette', + note: + 'useKeybinds calls the hard-coded `onCommandPalette?.()` callback. There is ' + + 'no command id, so this chord is absent from the catalog AND unconfigurable. ' + + 'Phase 4 introduces `open-command-palette` precisely to give it an owner — ' + + 'the single approved catalog addition (102 - 5 + 1 = 98).', + }, +] as const + +const catalogById = new Map(builtInCommandCatalog.map(c => [c.id, c])) + +describe('shortcut metadata baseline', () => { + it('every baseline entry names a command that exists', () => { + const missing = BINDING_BASELINE.filter(e => !catalogById.has(e.commandId)) + expect(missing.map(e => e.commandId)).toEqual([]) + }) + + it('records the declared shortcut of every command that has one', () => { + // THE LOAD-BEARING ASSERTION. The declared column is derived from the real + // catalog, so adding, removing, or editing a `shortcut:` field in any + // command module fails here and forces the drift table to be updated in the + // same commit. Without this the table would be a comment that rots. + // + // Compared sorted by id, NOT in registration order: the table above is + // grouped by theme (tabs, panes, navigation, editor) because that is what + // makes the drift readable, while the catalog is in palette-browse order. + // Registration order is already pinned by catalog.test.ts, so re-asserting + // it here would only couple this table's layout to an unrelated concern. + const byId = (a: { commandId: string }, b: { commandId: string }) => + a.commandId < b.commandId ? -1 : a.commandId > b.commandId ? 1 : 0 + + const declaredInCatalog = builtInCommandCatalog + .filter(c => c.shortcut !== undefined) + .map(c => ({ commandId: c.id, declared: c.shortcut ?? null })) + .sort(byId) + + const declaredInBaseline = BINDING_BASELINE + .filter(e => e.declared !== null) + .map(e => ({ commandId: e.commandId, declared: e.declared })) + .sort(byId) + + expect(declaredInBaseline).toEqual(declaredInCatalog) + }) + + it('counts 22 commands declaring display-only shortcut metadata', () => { + // 80 of the 102 commands ship no chord at all. Scarcity is deliberate and + // the plan preserves it: defaults are muscle memory, not an allocation of + // every free chord. + expect(builtInCommandCatalog.filter(c => c.shortcut !== undefined)).toHaveLength(22) + }) +}) + +describe('recorded authority drift', () => { + it('lists exactly the six commands whose real chords exceed their metadata', () => { + // Undeclared aliases: the palette under-reports what the keyboard does. + const underReported = BINDING_BASELINE.filter( + e => e.declared !== null && e.effective.length > 1, + ) + expect(underReported.map(e => e.commandId).sort()).toEqual([ + 'close-pane', + 'nav-down', + 'nav-left', + 'nav-right', + 'nav-up', + ]) + }) + + it('lists the command whose chord exists with no metadata at all', () => { + const gaps = BINDING_BASELINE.filter(e => e.declared === null && e.effective.length > 0) + expect(gaps.map(e => e.commandId)).toEqual(['toggle-global-editor']) + }) + + it('lists the command whose chord is owned by the editor, not the command', () => { + const editorOwned = BINDING_BASELINE.filter(e => e.owner === 'editor') + expect(editorOwned.map(e => e.commandId)).toEqual(['save-editor-file']) + }) + + it('records the palette chord as having no command owner', () => { + expect(UNOWNED_COMMAND_CHORDS.map(c => c.chord)).toEqual(['⌘⇧P']) + expect(builtInCommandCatalog.map(c => c.id)).not.toContain('open-command-palette') + }) + + it('proves declared metadata is not sufficient to know what a key does', () => { + // The one-sentence summary of why Phase 4 exists, as an executable claim: + // the set of chords the app really runs for commands is strictly larger + // than the set the palette advertises. + const declaredChords = new Set( + BINDING_BASELINE.flatMap(e => (e.declared === null ? [] : [e.declared])), + ) + const effectiveChords = new Set(BINDING_BASELINE.flatMap(e => e.effective)) + for (const chord of UNOWNED_COMMAND_CHORDS) effectiveChords.add(chord.chord) + + // Seven chords do real work that no palette row advertises: the Global + // Editor toggle (declared nowhere), the palette itself (owned by no + // command), the undisclosed ⌥W close, and the four arrow aliases. + const undisclosed = [...effectiveChords].filter(c => !declaredChords.has(c)).sort() + expect(undisclosed).toEqual(['⌘⇧E', '⌘⇧P', '⌥W', '⌥←', '⌥↑', '⌥→', '⌥↓']) + }) +}) diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index ffb21561..46197b90 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -1,20 +1,4 @@ -import { layoutCommands } from '@renderer/features/workspace/commands/layoutCommands' -import { globalEditorCommands } from '@renderer/features/global-editor/commands/globalEditorCommands' -import { paneCommands } from '@renderer/features/workspace/commands/paneCommands' -import { sessionCommands } from '@renderer/features/workspace/commands/sessionCommands' -import { tabCommands } from '@renderer/features/workspace/commands/tabCommands' -import { settingsCommands } from '@renderer/features/settings/commands/settingsCommands' -import { spotlightCommands } from '@renderer/features/spotlight/commands/spotlightCommands' -import { tileTabsCommands } from '@renderer/features/tile-tabs/commands/tileTabsCommands' -import { readerCommands } from '@renderer/features/reader/commands/readerCommands' -import { copyAssistantCommands } from '@renderer/features/copy-assistant/commands/copyAssistantCommands' -import { copyCodeBlockCommands } from '@renderer/features/copy-code-block/commands/copyCodeBlockCommands' -import { promptTemplateCommands } from '@renderer/features/prompt-templates/commands/promptTemplateCommands' -import { replyToSelectionCommands } from '@renderer/features/reply-to-selection/commands/replyToSelectionCommands' -import { agentStatusCommands } from '@renderer/features/agent-status/commands/agentStatusCommands' -import { dispatchColorFlagCommands } from '@renderer/features/workspace/commands/dispatchColorFlagCommands' -import { remoteCommands } from '@renderer/features/remote/commands/remoteCommands' -import { usageCommands } from '@renderer/features/usage/commands/usageCommands' +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' import { commandAllowedByRenderedViewPolicy } from '@renderer/workspace/agentDisplayMode' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' import type { @@ -25,32 +9,10 @@ import type { ResolvedCommand, } from '@renderer/features/command-palette/types' -const commandDefs: CommandDef[] = [ - ...tabCommands, - ...paneCommands, - ...layoutCommands, - // Registered right after layoutCommands so the moved editor commands - // keep (approximately) their old registry order — registry order is the - // palette's empty-query browse order, and gratuitous reshuffles read as - // regressions to users who navigate by position. - ...globalEditorCommands, - ...sessionCommands, - ...dispatchColorFlagCommands, - ...spotlightCommands, - ...readerCommands, - ...tileTabsCommands, - ...settingsCommands, - ...copyAssistantCommands, - ...copyCodeBlockCommands, - ...promptTemplateCommands, - // Grouped with the prompt-template commands because it is the other - // composer-insertion command — registry order is the palette's - // empty-query browse order, so like things stay adjacent. - ...replyToSelectionCommands, - ...agentStatusCommands, - ...remoteCommands, - ...usageCommands, -] +// The ordered command list now lives in `catalog.ts`, which is context-free by +// contract. This module keeps only the question that NEEDS a context: what +// should the picker show right now. See catalog.ts for why the split exists. +const commandDefs: readonly CommandDef[] = builtInCommandCatalog /** * Mode gate applied BEFORE each command's own `when`. diff --git a/src/shared/commands/nativeMenuCommandIds.ts b/src/shared/commands/nativeMenuCommandIds.ts new file mode 100644 index 00000000..c1787803 --- /dev/null +++ b/src/shared/commands/nativeMenuCommandIds.ts @@ -0,0 +1,31 @@ +/** + * The command ids the macOS application menu dispatches over `menu:command`. + * + * WHY this is a shared constant instead of string literals inline in + * `appMenu.ts`: the ids are a CONTRACT between two processes that cannot see + * each other's code. Main emits a bare string; the renderer looks it up in its + * command catalog. Nothing checked that the string on one side still names a + * command on the other, so renaming or retiring a command silently produced a + * menu item that does nothing — no error, no log, just a dead click. + * + * Exporting the list lets `catalog.test.ts` assert the subset relationship in + * the renderer suite, where the catalog is importable. `appMenu.ts` itself + * cannot be imported from a test without pulling in `electron`, so putting the + * ids where BOTH sides can reach them is what makes the invariant testable at + * all. + * + * This is characterization only: it records the six ids the menu dispatches + * today. The governance plan's Phase 1 changes HOW they resolve (full catalog, + * contextual admission, deliberately skipping picker visibility) — this + * constant is what that resolution will be driven from. + */ +export const NATIVE_MENU_COMMAND_IDS = [ + 'new-tab', + 'resume-session', + 'save-editor-file', + 'save-all-editor-files', + 'reorder-tabs', + 'close-tab', +] as const + +export type NativeMenuCommandId = (typeof NATIVE_MENU_COMMAND_IDS)[number] From d0baf77aabff94bdb03fdca7e7dcfe9551f540a1 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 16:23:53 +0200 Subject: [PATCH 06/33] feat(commands): add the execution gateway and split admission from visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. Before this, the four ways to run a command had four different sets of guarantees. The palette applied surface + when + renderedView + picker visibility. The native menu looked its id up in that ALREADY-FILTERED list. Keybindings called workspace actions directly, evaluating none of the predicates. Programmatic callers did whatever they liked. Two opposite failures fall out of that, and both are fixed here: 1. A cosmetic preference could remove a capability. Setting commandVisibilityOverrides['new-tab'] = false — a "don't clutter my palette" toggle — made commands.find() return undefined and the File > New Tab menu item silently do nothing. executeCommand.test.ts now asserts both halves: the command leaves the picker registry AND still runs from the menu and from a keybinding. 2. A chord could bypass every capability check, because surface/when were only ever evaluated while building picker rows. New modules: - pickerVisibility.ts — the context-free visibility resolver, shared verbatim with Settings, which has no CommandContext to offer and should never synthesize one. Presentation only, by construction: the word "visibility" does not appear anywhere in the execution path. - executeCommand.ts — dispatchCommand({id, source, ctx}) resolving against the FULL catalog, plus dispatchResolvedRow for the palette, whose list mixes catalog commands with transient agent-index rows that no id lookup can resolve. Both funnel through one guarded core so the two entry points cannot drift the way the palette and menu did. The gateway owns four things that were previously per-call-site or absent: fresh admission at dispatch time, error capture (every old site was `void command.run(ctx)`, so a rejected promise vanished with no user-facing signal), single-flight per command id, and history policy. History now records AFTER a successful admitted run rather than before `run`, so a command that turns out to be unavailable or that throws no longer climbs the user's ranking for failing. Entries carry their source; absence means picker-origin, which is what all pre-existing data is, since keybindings could never reach the recorder at all. That is also why the old counts must stop being described as total command usage. registry.ts keeps only the question that needs a context. Its surface gate is now an exhaustive switch with assertNever: the previous `return true` fallthrough classified an unknown surface as "available everywhere", the permissive direction, so a surface added to the union but forgotten here would have silently reintroduced the #228 bug class instead of failing the build. Verified: tsc -b clean, 232 files / 1382 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../command-palette/executeCommand.test.ts | 383 ++++++++++++++++++ .../command-palette/executeCommand.ts | 278 +++++++++++++ .../lib/recentCommandHistory.ts | 59 ++- .../command-palette/pickerVisibility.test.ts | 98 +++++ .../command-palette/pickerVisibility.ts | 71 ++++ .../src/features/command-palette/registry.ts | 123 ++++-- .../command-palette/ui/CommandPalette.tsx | 66 +-- 7 files changed, 1006 insertions(+), 72 deletions(-) create mode 100644 src/renderer/src/features/command-palette/executeCommand.test.ts create mode 100644 src/renderer/src/features/command-palette/executeCommand.ts create mode 100644 src/renderer/src/features/command-palette/pickerVisibility.test.ts create mode 100644 src/renderer/src/features/command-palette/pickerVisibility.ts diff --git a/src/renderer/src/features/command-palette/executeCommand.test.ts b/src/renderer/src/features/command-palette/executeCommand.test.ts new file mode 100644 index 00000000..551fa283 --- /dev/null +++ b/src/renderer/src/features/command-palette/executeCommand.test.ts @@ -0,0 +1,383 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// The gateway records history through this module. Mocked rather than exercised +// for real because the real implementation writes to `window.localStorage`, +// which does not exist in the node project — it would silently swallow every +// call in its own try/catch and the history-policy assertions below (the whole +// point of the exercise) would pass vacuously. +vi.mock('@renderer/features/command-palette/lib/recentCommandHistory', () => ({ + recordCommandUse: vi.fn(), + loadRecentHistory: vi.fn(() => []), + buildHistoryScoreMap: vi.fn(() => new Map()), +})) + +import { + __resetInFlightForTests, + canDispatchCommand, + dispatchCommand, + dispatchResolvedRow, +} from '@renderer/features/command-palette/executeCommand' +import { buildCommandRegistry } from '@renderer/features/command-palette/registry' +import { recordCommandUse } from '@renderer/features/command-palette/lib/recentCommandHistory' +import type { CommandContext } from '@renderer/features/command-palette/types' + +// --------------------------------------------------------------------------- +// Phase 1: the command execution gateway. +// +// The assertions here are the executable form of the plan's central claim — +// that applicability, admission, discoverability, and authorization are four +// different questions that were collapsed into one boolean filter. The most +// important test in this file is "a command hidden from the picker still runs +// from the native menu", because that inversion (a cosmetic preference +// removing a capability) is the highest-severity defect in the audit. +// --------------------------------------------------------------------------- + +/** Which ui callback a command reached for. */ +let uiCalls: string[] = [] + +/** + * Narrow test harness for `CommandContext`. + * + * WHY the ui bag is a Proxy rather than ~50 explicit stubs: none of those + * callbacks' identities affect admission or dispatch policy, which is what + * this file tests. What DOES matter is whether a command's `run` was reached + * at all, so the proxy records the name and no-ops. Writing them out would add + * fifty lines of noise that must be maintained every time a command is added, + * and would still assert nothing extra. + * + * `flags`, by contrast, are written out explicitly: individual flags are real + * inputs to the admission decision, so a default that drifts silently would + * change what these tests mean. + */ +function makeContext(overrides?: { + flags?: Partial + workspace?: Partial +}): CommandContext { + const ui = new Proxy({} as CommandContext['ui'], { + get: (_target, prop: string) => (..._args: unknown[]) => { + uiCalls.push(prop) + }, + }) + + const workspace = { + // An empty workspace: no tabs, no dispatch mode, no sessions. This makes + // `commandTargetSessionId` return null, which short-circuits the + // rendered-view gate — deliberate, so these tests isolate the surface/when + // half of admission rather than re-testing render policy. + state: { + tabs: [], + activeTabId: null, + sessions: {}, + dispatchMode: null, + detachedSessions: {}, + buried: [], + pinnedSessionIds: [], + }, + getRuntime: () => undefined, + runtimes: {}, + activeTab: undefined, + ...overrides?.workspace, + } as unknown as CommandContext['workspace'] + + return { + workspace, + ui, + flags: { + statusModeEnabled: true, + worktreeBadgesEnabled: true, + usageHeaderEnabled: true, + usageHeaderLevel: 'all', + dangerousAgentsEnabled: false, + aggressiveDebugPersistenceEnabled: false, + gitBarOpen: false, + worktreesBarOpen: false, + debugPanelOpen: false, + feedDebugPanelOpen: false, + proxyDebugPanelOpen: false, + htmlDebugPanelOpen: false, + renderingDebugMode: false, + tailAllMode: false, + devDebugEnabled: false, + sessionRecordingEnabled: false, + devDebugPanelOpen: false, + agentStatusPanelOpen: false, + performancePanelOpen: false, + caffeinateActive: false, + caffeinateSupported: true, + globalEditorOpen: false, + focusedCwd: null, + fileTreeVisible: false, + editorFullscreen: false, + dispatchModeEnabled: false, + globalDispatchEnabled: false, + agentViewMode: 'agent', + commandVisibilityOverrides: {}, + showHiddenCommands: false, + ...overrides?.flags, + }, + } as CommandContext +} + +beforeEach(() => { + uiCalls = [] + vi.mocked(recordCommandUse).mockClear() +}) + +afterEach(() => { + // Single-flight state is module-scoped, so a test that leaves an id parked + // would poison every later test in unpredictable file order. The testing + // standard is explicit that cleanup must run after failure too, hence + // afterEach rather than a tail call inside each test. + __resetInFlightForTests() +}) + +describe('visibility is not authorization', () => { + // THE headline defect. `new-tab` is used because it has no `when` guard, so + // the only thing that could stop it is the visibility override — isolating + // exactly the behavior under test. + + it('excludes a hidden command from the picker registry', () => { + const ctx = makeContext({ flags: { commandVisibilityOverrides: { 'new-tab': false } } }) + expect(buildCommandRegistry(ctx).map(c => c.id)).not.toContain('new-tab') + }) + + it('still runs that hidden command from the native menu', async () => { + // Before the gateway this returned undefined from `commands.find(...)` and + // the File → New Tab menu item did nothing, silently. A user who tidied + // their palette lost a capability with no way to connect cause to effect. + const ctx = makeContext({ flags: { commandVisibilityOverrides: { 'new-tab': false } } }) + const outcome = await dispatchCommand({ id: 'new-tab', source: 'native-menu', ctx }) + + expect(outcome.status).toBe('ran') + expect(uiCalls).toContain('openNewTabPicker') + }) + + it('still runs that hidden command from a keybinding', async () => { + const ctx = makeContext({ flags: { commandVisibilityOverrides: { 'new-tab': false } } }) + const outcome = await dispatchCommand({ id: 'new-tab', source: 'keybinding', ctx }) + expect(outcome.status).toBe('ran') + }) + + it('reports a hidden command as dispatchable', () => { + const ctx = makeContext({ flags: { commandVisibilityOverrides: { 'new-tab': false } } }) + expect(canDispatchCommand('new-tab', ctx)).toBe(true) + }) +}) + +describe('admission cannot be bypassed by source', () => { + // The opposite failure: before the gateway, keybindings called workspace + // actions directly and never evaluated surface/when at all. + + it('refuses a command whose `when` guard is false, from every source', async () => { + // `reorder-tabs` requires more than one tab; the fixture workspace has none. + const ctx = makeContext() + for (const source of ['palette', 'native-menu', 'keybinding', 'programmatic'] as const) { + const outcome = await dispatchCommand({ id: 'reorder-tabs', source, ctx }) + expect(outcome.status).toBe('unavailable') + } + expect(uiCalls).toEqual([]) + }) + + it('refuses a grid command while Dispatch Mode owns the layout', async () => { + // The #228 class: `split-vertical` is surface 'grid' and is a silent no-op + // in Dispatch. Silent no-ops are what the explicit outcome replaces. + const ctx = makeContext({ flags: { dispatchModeEnabled: true } }) + const outcome = await dispatchCommand({ id: 'split-vertical', source: 'keybinding', ctx }) + expect(outcome.status).toBe('unavailable') + }) + + it('admits that same grid command outside Dispatch Mode', async () => { + const ctx = makeContext({ flags: { dispatchModeEnabled: false } }) + expect(canDispatchCommand('split-vertical', ctx)).toBe(true) + }) + + it('distinguishes an unknown id from an unavailable one', async () => { + // A menu or caller bug and a contextual refusal are different problems and + // must be diagnosable apart — the plan calls this out for the native menu + // specifically. + const ctx = makeContext() + const outcome = await dispatchCommand({ id: 'no-such-command', source: 'native-menu', ctx }) + expect(outcome).toEqual({ + status: 'unknown', + id: 'no-such-command', + source: 'native-menu', + }) + }) +}) + +describe('failure handling', () => { + const boom = new Error('boom') + + it('captures a synchronous throw instead of propagating it', async () => { + const reportError = vi.fn() + const outcome = await dispatchResolvedRow({ + row: { id: 'x.sync', title: 'Sync', run: () => { throw boom } }, + source: 'palette', + ctx: makeContext(), + reportError, + }) + + expect(outcome).toMatchObject({ status: 'failed', id: 'x.sync', error: boom }) + expect(reportError).toHaveBeenCalledWith('Command failed: Sync', boom) + }) + + it('captures an async rejection instead of leaving it unhandled', async () => { + // Every old call site was `void command.run(ctx)`. A rejected promise there + // produced an unhandled rejection and no user-facing signal whatsoever. + const reportError = vi.fn() + const outcome = await dispatchResolvedRow({ + row: { id: 'x.async', title: 'Async', run: () => Promise.reject(boom) }, + source: 'palette', + ctx: makeContext(), + reportError, + }) + + expect(outcome).toMatchObject({ status: 'failed', error: boom }) + expect(reportError).toHaveBeenCalledOnce() + }) + + it('still resolves when no error reporter is supplied', async () => { + // The gateway must never reject: a caller that forgets to await must not be + // able to create an unhandled rejection. + await expect( + dispatchResolvedRow({ + row: { id: 'x.noreport', title: 'NoReport', run: () => Promise.reject(boom) }, + source: 'programmatic', + ctx: makeContext(), + }), + ).resolves.toMatchObject({ status: 'failed' }) + }) +}) + +describe('single-flight', () => { + it('drops a duplicate invocation while the first is still running', async () => { + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + const row = { id: 'x.slow', title: 'Slow', run: () => gate } + const ctx = makeContext() + + const first = dispatchResolvedRow({ row, source: 'palette', ctx }) + const second = await dispatchResolvedRow({ row, source: 'palette', ctx }) + + // The second invocation is rejected while the first is in flight — this is + // what stops a double-tap from spawning two agents or killing twice. + expect(second.status).toBe('in-flight') + + release() + expect((await first).status).toBe('ran') + }) + + it('allows the same command again once the first completes', async () => { + const row = { id: 'x.fast', title: 'Fast', run: () => {} } + const ctx = makeContext() + expect((await dispatchResolvedRow({ row, source: 'palette', ctx })).status).toBe('ran') + expect((await dispatchResolvedRow({ row, source: 'palette', ctx })).status).toBe('ran') + }) + + it('clears its in-flight entry even when the command fails', async () => { + // A failure that left the id parked would make the command permanently + // undispatchable until reload — a worse bug than the one single-flight + // fixes, so the `finally` that prevents it is asserted directly. + const row = { id: 'x.throws', title: 'Throws', run: () => { throw new Error('x') } } + const ctx = makeContext() + expect((await dispatchResolvedRow({ row, source: 'palette', ctx })).status).toBe('failed') + expect((await dispatchResolvedRow({ row, source: 'palette', ctx })).status).toBe('failed') + }) +}) + +describe('history policy', () => { + it('records a successful user invocation with its source', async () => { + const ctx = makeContext() + await dispatchCommand({ id: 'new-tab', source: 'native-menu', ctx }) + expect(recordCommandUse).toHaveBeenCalledWith('new-tab', 'native-menu') + }) + + it.each(['palette', 'native-menu', 'keybinding'] as const)( + 'records %s invocations', + async source => { + await dispatchCommand({ id: 'new-tab', source, ctx: makeContext() }) + expect(recordCommandUse).toHaveBeenCalledWith('new-tab', source) + }, + ) + + it('never records programmatic invocations', async () => { + // Background machinery must not be able to reshape the user's palette + // ranking toward commands they never chose. + await dispatchCommand({ id: 'new-tab', source: 'programmatic', ctx: makeContext() }) + expect(recordCommandUse).not.toHaveBeenCalled() + }) + + it('does not record an unavailable command', async () => { + await dispatchCommand({ id: 'reorder-tabs', source: 'palette', ctx: makeContext() }) + expect(recordCommandUse).not.toHaveBeenCalled() + }) + + it('does not record an unknown command', async () => { + await dispatchCommand({ id: 'nope', source: 'palette', ctx: makeContext() }) + expect(recordCommandUse).not.toHaveBeenCalled() + }) + + it('does not record a failed command', async () => { + // Recording before `run` — the old behavior — meant a command that threw + // still climbed the ranking. Failing at something is not using it. + await dispatchResolvedRow({ + row: { id: 'x.fail', title: 'Fail', run: () => { throw new Error('e') } }, + source: 'palette', + ctx: makeContext(), + }) + expect(recordCommandUse).not.toHaveBeenCalled() + }) + + it('does not record a dropped duplicate invocation', async () => { + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + const row = { id: 'x.dup', title: 'Dup', run: () => gate } + const ctx = makeContext() + + const first = dispatchResolvedRow({ row, source: 'palette', ctx }) + await dispatchResolvedRow({ row, source: 'palette', ctx }) + release() + await first + + // Exactly one recorded use for two invocations, because only one ran. + expect(recordCommandUse).toHaveBeenCalledOnce() + }) + + it('does not record transient agent-index destinations', async () => { + // Their ids embed a session id that can never rank a future palette open, + // so recording them would evict real commands from a bounded history. + await dispatchResolvedRow({ + row: { id: 'agent-index:abc123', title: 'Go to A', run: () => {} }, + source: 'palette', + ctx: makeContext(), + }) + expect(recordCommandUse).not.toHaveBeenCalled() + }) +}) + +describe('dispatchResolvedRow admission', () => { + it('re-checks admission when the row is a catalog command', async () => { + // The palette's memoized list can be a frame behind the workspace, so the + // row the user pressed Enter on is not proof the command still applies. + const ctx = makeContext() + const outcome = await dispatchResolvedRow({ + row: { id: 'reorder-tabs', title: 'Reorder Tabs', run: () => { uiCalls.push('ran') } }, + source: 'palette', + ctx, + }) + expect(outcome.status).toBe('unavailable') + expect(uiCalls).not.toContain('ran') + }) + + it('runs a row that has no catalog definition', async () => { + // Transient destinations have no CommandDef to admit against; skipping + // admission for them must not skip the other guarantees. + const outcome = await dispatchResolvedRow({ + row: { id: 'agent-index:xyz', title: 'Go to B', run: () => { uiCalls.push('ran') } }, + source: 'palette', + ctx: makeContext(), + }) + expect(outcome.status).toBe('ran') + expect(uiCalls).toContain('ran') + }) +}) diff --git a/src/renderer/src/features/command-palette/executeCommand.ts b/src/renderer/src/features/command-palette/executeCommand.ts new file mode 100644 index 00000000..431cd422 --- /dev/null +++ b/src/renderer/src/features/command-palette/executeCommand.ts @@ -0,0 +1,278 @@ +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { commandApplicable } from '@renderer/features/command-palette/registry' +import { recordCommandUse } from '@renderer/features/command-palette/lib/recentCommandHistory' +import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' + +// --------------------------------------------------------------------------- +// THE COMMAND EXECUTION GATEWAY (governance plan, Phase 1). +// +// Before this module, the four ways to run a command had four different sets +// of guarantees: +// +// palette → surface + when + renderedView + picker visibility, then run +// native menu → looked the id up in the ALREADY-FILTERED picker list, so a +// cosmetic visibility preference could disable a File-menu +// item, and an inapplicable id silently did nothing +// keybinding → called workspace actions directly, skipping every predicate +// programmatic → whatever the caller happened to do +// +// Two opposite failures fall out of that: a presentation preference could +// remove a capability, and a keyboard chord could bypass every capability +// check. Both are symptoms of the same root cause — the app had a picker +// resolver where it needed an admission authority. +// +// This module is that authority. Every invocation states its source, gets a +// FRESH admission check against current state, and produces an explicit +// outcome instead of a silent no-op. Picker visibility is deliberately absent +// from the entire file: discoverability cannot gate execution, by construction. +// +// WHAT THIS IS NOT: it is not a safety boundary for destructive work. Admission +// says "this invocation is sensible now"; it cannot say "the target still +// exists at the moment we mutate it". Per-command revalidation at the mutation +// boundary is Phase 2/7 work and is not made redundant by anything here. +// --------------------------------------------------------------------------- + +/** + * Where an invocation came from. Carried through the whole dispatch so history + * policy and diagnostics can distinguish deliberate user action from machinery. + */ +export type CommandInvocationSource = + /** The user picked a row in the command palette. */ + | 'palette' + /** The user clicked an item in the native application menu. */ + | 'native-menu' + /** The user pressed a configured chord. */ + | 'keybinding' + /** App code, a background flow, or a test. Never a user signal. */ + | 'programmatic' + +/** + * Sources that represent a deliberate human action. Only these may influence + * personalized ranking — otherwise a background caller could quietly promote a + * command the user has never chosen to the top of their palette. + */ +const USER_SOURCES: ReadonlySet = new Set([ + 'palette', + 'native-menu', + 'keybinding', +]) + +export type CommandDispatchOutcome = + /** Admitted and completed without throwing. The only outcome that counts as use. */ + | { status: 'ran'; id: string; source: CommandInvocationSource } + /** No command with this id exists in the catalog. A menu/caller bug. */ + | { status: 'unknown'; id: string; source: CommandInvocationSource } + /** The command exists but does not apply right now. */ + | { status: 'unavailable'; id: string; source: CommandInvocationSource } + /** The command threw synchronously or its promise rejected. */ + | { status: 'failed'; id: string; source: CommandInvocationSource; error: unknown } + /** An identical invocation was already in flight; this one was dropped. */ + | { status: 'in-flight'; id: string; source: CommandInvocationSource } + +export type DispatchCommandOptions = { + id: string + source: CommandInvocationSource + ctx: CommandContext + /** + * Surfaces a failure to the user. Optional because the gateway must remain + * usable from a plain node test; when absent a failure is still REPORTED in + * the return value, it just is not shown. + * + * WHY injected rather than importing a toast module: this file is pure and + * React-free so admission and history policy can be tested without a DOM. + * Reaching for `useGlobalToast` here would drag the renderer tree into every + * test that wants to assert "a keybinding cannot bypass a capability gate". + */ + reportError?: (message: string, error: unknown) => void +} + +/** + * A row the palette already holds, which may or may not correspond to a + * catalog command. Structurally the part of `ResolvedCommand` this module + * needs — typed structurally rather than importing `ResolvedCommand` so the + * gateway does not depend on the palette's presentation shape. + */ +export type DispatchableRow = { + id: string + title: string + run: (ctx: CommandContext) => void | Promise +} + +/** + * In-flight ids, for single-flight protection. + * + * WHY module scope rather than per-context: identity here is the COMMAND, not + * the component that dispatched it. A palette Enter and a native-menu click + * naming the same id are the same operation and must collide; scoping to a + * React tree would let them race — which is exactly how a double-spawn or a + * double-kill happens. + * + * Only genuinely async commands can ever be blocked: a synchronous `run` + * completes before the entry is read again, so this never interferes with + * repeat-tapping a cheap toggle. + */ +const inFlight = new Set() + +/** Test seam: the in-flight set is module state, so a failing test must be + * able to leave it clean for the next one. Not used by product code. */ +export function __resetInFlightForTests(): void { + inFlight.clear() +} + +/** + * Run a command from any source, with one set of guarantees for all of them. + * + * Always resolves; never rejects. A caller that forgets to await must not be + * able to produce an unhandled rejection — the audit found fire-and-forget + * `void command.run(ctx)` at every call site, which is how an async failure + * became invisible. + */ +export async function dispatchCommand( + options: DispatchCommandOptions, +): Promise { + const { id, source, ctx, reportError } = options + + // Resolve against the FULL catalog, never the picker-filtered registry. + // This one line is the fix for the native-menu defect: hiding a command from + // the palette can no longer make its menu item unresolvable. + const command = findCommand(id) + if (!command) { + return { status: 'unknown', id, source } + } + + // Fresh admission, evaluated now rather than when a list was last rendered. + // The palette's rows can be a frame stale, a menu click arrives arbitrarily + // late, and a chord bypassed these checks entirely before this existed. + if (!commandApplicable(command, ctx)) { + return { status: 'unavailable', id, source } + } + + return runGuarded({ + id, + source, + ctx, + reportError, + label: commandLabel(command, ctx), + run: command.run, + }) +} + +/** + * Dispatch a row the caller already holds. + * + * Exists for the palette, whose list mixes catalog commands with + * `agent-index:` rows — transient "go to this agent" destinations + * generated per keystroke, outside the static catalog, that no id lookup can + * resolve. + * + * When the row IS a catalog command it gets the full treatment, including a + * fresh admission check: the palette's memoized list can be a frame behind the + * workspace, so the row the user pressed Enter on is not proof the command + * still applies. When it is not, admission is skipped — there is no + * `CommandDef` to consult — but error handling, single-flight, and history + * policy still apply, so a transient row cannot become a hole in the + * guarantees. + */ +export async function dispatchResolvedRow(options: { + row: DispatchableRow + source: CommandInvocationSource + ctx: CommandContext + reportError?: (message: string, error: unknown) => void +}): Promise { + const { row, source, ctx, reportError } = options + const command = findCommand(row.id) + + if (command && !commandApplicable(command, ctx)) { + return { status: 'unavailable', id: row.id, source } + } + + return runGuarded({ + id: row.id, + source, + ctx, + reportError, + label: row.title, + run: row.run, + }) +} + +/** + * The shared execution core: single-flight, error capture, history policy. + * + * Both public entry points funnel through here so there is exactly one place + * that decides what "successfully ran" means. Two entry points with two copies + * of this logic is how the palette and the menu drifted apart in the first + * place. + */ +async function runGuarded(options: { + id: string + source: CommandInvocationSource + ctx: CommandContext + reportError?: (message: string, error: unknown) => void + label: string + run: (ctx: CommandContext) => void | Promise +}): Promise { + const { id, source, ctx, reportError, label, run } = options + + if (inFlight.has(id)) { + return { status: 'in-flight', id, source } + } + inFlight.add(id) + + try { + // `await` covers both shapes: a sync `run` that throws rejects the + // implicit promise, and an async one rejects normally. One catch handles + // both, which is what makes "async rejection is never unhandled" true + // rather than aspirational. + await run(ctx) + } catch (error) { + reportError?.(`Command failed: ${label}`, error) + return { status: 'failed', id, source, error } + } finally { + inFlight.delete(id) + } + + // History is recorded ONLY here — after a successful, admitted, user-driven + // run. Unavailable, unknown, in-flight, and failed invocations deliberately + // do not count: personalized ranking should reflect what the user actually + // accomplished, not what they attempted. Programmatic dispatch never counts + // at all, so background machinery cannot reshape the palette. + // + // Transient agent-index rows are excluded too: their ids embed a session id + // that can never rank a future palette open, so recording them would evict + // real commands from a bounded history for no benefit. + if (USER_SOURCES.has(source) && !isTransientRowId(id)) { + recordCommandUse(id, source as 'palette' | 'native-menu' | 'keybinding') + } + + return { status: 'ran', id, source } +} + +/** Transient palette destinations, generated per keystroke outside the + * catalog. Kept as a prefix check here (rather than importing the palette's + * helper) so the gateway does not depend on the presentation layer. */ +function isTransientRowId(id: string): boolean { + return id.startsWith('agent-index:') +} + +/** + * Is this id runnable right now? Answers WITHOUT running it, for a caller that + * needs to render an enabled/disabled affordance — notably the native menu, + * which should be able to explain "unavailable" rather than click into nothing. + * + * Deliberately ignores picker visibility, for the same reason dispatch does. + */ +export function canDispatchCommand(id: string, ctx: CommandContext): boolean { + const command = findCommand(id) + return command ? commandApplicable(command, ctx) : false +} + +function findCommand(id: string): CommandDef | undefined { + return builtInCommandCatalog.find(candidate => candidate.id === id) +} + +/** Human-readable label for an error message. Function titles need the context + * they were declared against, so resolve them rather than printing a raw id. */ +function commandLabel(command: CommandDef, ctx: CommandContext): string { + return typeof command.title === 'function' ? command.title(ctx) : command.title +} diff --git a/src/renderer/src/features/command-palette/lib/recentCommandHistory.ts b/src/renderer/src/features/command-palette/lib/recentCommandHistory.ts index 62e8990f..04e1de22 100644 --- a/src/renderer/src/features/command-palette/lib/recentCommandHistory.ts +++ b/src/renderer/src/features/command-palette/lib/recentCommandHistory.ts @@ -20,8 +20,33 @@ export type RecentCommandEntry = { id: string count: number lastUsedAt: number + /** + * Which invocation source last recorded this entry. + * + * WHY it was added: until the execution gateway existed, ONLY the palette + * could record a use — keybindings called workspace actions directly and + * never reached this module. So the stored counts were never "command + * usage"; they were "palette selections", and describing them as the former + * overstated what the data means. Now that menu and keybinding invocations + * also record, the entry has to say which kind it was, or the two eras of + * data become indistinguishable. + * + * Optional on purpose: every entry written before this field existed is + * picker-origin by definition, so absence is meaningful rather than missing. + * `normalizeEntries` leaves it undefined instead of inventing 'palette', to + * keep "we know this was the palette" distinguishable from "this predates + * the question". + */ + lastSource?: RecentCommandSource } +/** Mirrors CommandInvocationSource's user-driven members. Duplicated as a + * literal union rather than imported so this disposable local cache does not + * take a dependency on the command-execution layer. */ +export type RecentCommandSource = 'palette' | 'native-menu' | 'keybinding' + +const RECENT_COMMAND_SOURCES: readonly string[] = ['palette', 'native-menu', 'keybinding'] + // Bound 1: how many entries we keep. The palette has on the order of // tens of commands, so 50 comfortably covers "everything the user // actually touches" while keeping the JSON blob tiny and the score @@ -62,6 +87,15 @@ function normalizeEntries(value: unknown): RecentCommandEntry[] { id: record.id, count: record.count, lastUsedAt: record.lastUsedAt, + // Same normalize-on-read policy as the numeric fields: an unrecognized + // source is dropped to undefined rather than preserved, so a + // hand-edited or future-version value can never reach a consumer that + // switches on the union. Dropping it costs nothing — the field is a + // provenance label, not part of the ranking maths. + ...(typeof record.lastSource === 'string' + && RECENT_COMMAND_SOURCES.includes(record.lastSource) + ? { lastSource: record.lastSource as RecentCommandSource } + : {}), }] }) } @@ -99,12 +133,21 @@ function saveRecentHistory(entries: RecentCommandEntry[]): void { } } -export function recordCommandUse(id: string): void { +export function recordCommandUse(id: string, source: RecentCommandSource = 'palette'): void { // Read-modify-write. This is deliberately fire-and-forget from the - // caller's perspective: executeCommand calls this right before - // command.run(), so any throw here would block the actual command. - // The whole body is wrapped so even an unexpected failure (e.g. - // JSON.stringify on something exotic) can't propagate. + // caller's perspective, and the whole body is wrapped so even an unexpected + // failure (e.g. JSON.stringify on something exotic) can't propagate. + // History is a nicety; command execution is not. + // + // The gateway (executeCommand.ts) owns WHEN this is called — after a + // successful, admitted, user-driven run only. It is deliberately no longer + // called before `run`, as it was when the palette was the sole caller: a + // command that turned out to be unavailable, or that threw, would otherwise + // climb the user's ranking for failing. + // + // `source` defaults to 'palette' so the one legacy call shape stays valid, + // and because the palette was historically the only thing that could reach + // this function at all. try { const entries = loadRecentHistory() const now = Date.now() @@ -112,13 +155,13 @@ export function recordCommandUse(id: string): void { if (existing) { existing.count += 1 existing.lastUsedAt = now + existing.lastSource = source } else { - entries.push({ id, count: 1, lastUsedAt: now }) + entries.push({ id, count: 1, lastUsedAt: now, lastSource: source }) } saveRecentHistory(pruneEntries(entries)) } catch { - // Swallow — see WHY above. History is a nicety, command execution - // is not. + // Swallow — see WHY above. } } diff --git a/src/renderer/src/features/command-palette/pickerVisibility.test.ts b/src/renderer/src/features/command-palette/pickerVisibility.test.ts new file mode 100644 index 00000000..4b31846b --- /dev/null +++ b/src/renderer/src/features/command-palette/pickerVisibility.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' + +import { + declaredTier, + isVisibleInPicker, +} from '@renderer/features/command-palette/pickerVisibility' +import type { CommandPickerVisibility } from '@renderer/features/command-palette/types' + +const cmd = (id: string, tier?: CommandPickerVisibility) => ({ id, pickerVisibility: tier }) + +const policy = (overrides?: { + overrides?: Record | undefined + showHiddenCommands?: boolean +}) => ({ + overrides: overrides?.overrides ?? {}, + showHiddenCommands: overrides?.showHiddenCommands ?? false, +}) + +describe('declaredTier', () => { + it('treats an absent tier as default', () => { + // The `absent ≡ 'default'` rule is applied in exactly one place so callers + // cannot re-implement the fallback and drift apart. Both the registry and + // the Settings metadata list route through here for that reason. + expect(declaredTier({ pickerVisibility: undefined })).toBe('default') + }) + + it.each(['default', 'advanced', 'experimental', 'debug'] as const)( + 'passes %s through unchanged', + tier => { + expect(declaredTier({ pickerVisibility: tier })).toBe(tier) + }, + ) +}) + +describe('isVisibleInPicker', () => { + it('shows a default-tier command', () => { + expect(isVisibleInPicker(cmd('a'), policy())).toBe(true) + }) + + it.each(['advanced', 'experimental', 'debug'] as const)( + 'hides a %s-tier command', + tier => { + expect(isVisibleInPicker(cmd('a', tier), policy())).toBe(false) + }, + ) + + describe('per-command override', () => { + it('forces a hidden tier into the picker', () => { + expect(isVisibleInPicker(cmd('a', 'debug'), policy({ overrides: { a: true } }))).toBe(true) + }) + + it('forces a default-tier command out of the picker', () => { + expect(isVisibleInPicker(cmd('a'), policy({ overrides: { a: false } }))).toBe(false) + }) + + it('ignores an override belonging to a different command', () => { + expect(isVisibleInPicker(cmd('a'), policy({ overrides: { b: false } }))).toBe(true) + }) + }) + + describe('showHiddenCommands escape hatch', () => { + it('reveals every tier', () => { + expect(isVisibleInPicker(cmd('a', 'debug'), policy({ showHiddenCommands: true }))).toBe(true) + }) + + it('outranks an explicit false override', () => { + // Documented precedence: the escape hatch is checked first, so "show me + // everything" genuinely means everything rather than "everything except + // what I previously hid" — which would make the affordance useless for + // finding a command you hid and now want back. + const result = isVisibleInPicker( + cmd('a'), + policy({ overrides: { a: false }, showHiddenCommands: true }), + ) + expect(result).toBe(true) + }) + }) + + describe('defensive coercion', () => { + it('does not throw when the override map is undefined', () => { + // This runs inside the palette's first-render useMemo. A bare `[id]` + // index on undefined throws and takes the whole app to a black screen — + // the exact #249 launch regression. A persisted-settings shape predating + // the field must degrade to "no override", never crash. + expect(() => isVisibleInPicker(cmd('a'), policy({ overrides: undefined }))).not.toThrow() + expect(isVisibleInPicker(cmd('a'), policy({ overrides: undefined }))).toBe(true) + }) + + it('falls back to the declared tier for a non-boolean override value', () => { + // Coercion should already have removed these, but the registry's + // `typeof === 'boolean'` check is what makes that guarantee local rather + // than a trust relationship with a persistence layer three modules away. + const overrides = { a: 'yes' as unknown as boolean } + expect(isVisibleInPicker(cmd('a', 'debug'), policy({ overrides }))).toBe(false) + expect(isVisibleInPicker(cmd('a'), policy({ overrides }))).toBe(true) + }) + }) +}) diff --git a/src/renderer/src/features/command-palette/pickerVisibility.ts b/src/renderer/src/features/command-palette/pickerVisibility.ts new file mode 100644 index 00000000..46072746 --- /dev/null +++ b/src/renderer/src/features/command-palette/pickerVisibility.ts @@ -0,0 +1,71 @@ +import type { CommandDef, CommandPickerVisibility } from '@renderer/features/command-palette/types' + +/** + * Everything the visibility decision needs, and nothing else. + * + * WHY this is a narrow struct rather than the full `CommandContext`: the + * Settings screen must render the same decision for every command WITHOUT a + * live context (it has no focused session and no ui callback bag). Taking the + * whole context would force Settings to synthesize a fake one, and a fake + * context is exactly how presentation logic starts quietly depending on + * workspace state it has no business reading. + */ +export type PickerVisibilityPolicy = { + /** Sparse per-command overrides from `Settings.commandVisibilityOverrides`. */ + overrides: Record | undefined + /** Global "reveal everything" escape hatch. */ + showHiddenCommands: boolean +} + +/** + * Does this command belong in the command-picker LIST? + * + * ============================ READ THIS ================================= + * This function answers a PRESENTATION question and nothing else. It is not + * an authorization boundary, and wiring it into any execution path is a + * regression, not a feature. + * + * The governance audit found the app had already broken that rule by accident: + * the native File menu resolved its command ids against the picker-FILTERED + * registry, so setting `commandVisibilityOverrides['new-tab'] = false` — a + * purely cosmetic "don't clutter my palette" preference — silently disabled the + * File → New Tab menu item. A user tidying their palette lost a capability, with + * no error and no way to connect cause to effect. + * + * The rule, stated positively: a command hidden here remains fully executable + * by keybinding, native menu, and programmatic dispatch. Hiding controls list + * noise. Whether an invocation may proceed is decided by contextual admission + * (`commandApplicable`) and, at the mutation boundary, by the command itself. + * ======================================================================== + * + * Resolution order (most specific wins): + * 1. `showHiddenCommands` → everything visible (global escape hatch); + * 2. an explicit per-command override (`true`/`false`) → that value; + * 3. otherwise the declared tier, where absence means `'default'`. Only + * `'default'` is shown; `advanced`/`experimental`/`debug` are hidden. + */ +export function isVisibleInPicker( + command: Pick, + policy: PickerVisibilityPolicy, +): boolean { + if (policy.showHiddenCommands) return true + + // Optional-chain defensively: this runs inside the palette's first-render + // useMemo, so if `overrides` is ever undefined (a persisted-settings shape + // predating the field that slipped past coercion), a bare `[id]` index throws + // and takes the WHOLE app to a black screen — the exact #249 launch + // regression. A missing override map must degrade to "no override", never + // crash the registry build. + const override = policy.overrides?.[command.id] + if (typeof override === 'boolean') return override + + return declaredTier(command) === 'default' +} + +/** The command's declared tier, with the documented `absent ≡ 'default'` rule + * applied in ONE place so callers never re-implement the fallback and drift. */ +export function declaredTier( + command: Pick, +): CommandPickerVisibility { + return command.pickerVisibility ?? 'default' +} diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index 46197b90..e591ff56 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -1,4 +1,5 @@ import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { declaredTier, isVisibleInPicker } from '@renderer/features/command-palette/pickerVisibility' import { commandAllowedByRenderedViewPolicy } from '@renderer/workspace/agentDisplayMode' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' import type { @@ -27,47 +28,95 @@ const commandDefs: readonly CommandDef[] = builtInCommandCatalog * point of issue #228: a command's module no longer has to remember to * re-implement "...and hide me in the wrong mode." It declares a * surface; the registry enforces it uniformly. + * + * WHY an exhaustive switch instead of the two ifs plus `return true` this + * replaces: the fallthrough silently classified any UNKNOWN surface as + * "available everywhere". That is the permissive direction — a new surface + * added to the union but forgotten here would not fail the build, it would + * quietly show its commands in every mode, which is precisely the #228 bug + * class the surface field was introduced to kill. With `assertNever`, adding + * a surface without deciding its mode policy is a compile error. */ function surfaceAvailable(surface: CommandSurface, ctx: CommandContext): boolean { - if (surface === 'grid') return !ctx.flags.dispatchModeEnabled - if (surface === 'dispatch') return ctx.flags.dispatchModeEnabled - return true + switch (surface) { + case 'grid': + return !ctx.flags.dispatchModeEnabled + case 'dispatch': + return ctx.flags.dispatchModeEnabled + case 'app': + case 'session': + case 'editor': + case 'debug': + // Mode-independent by design. `editor` and `debug` carry their own + // `when` guards for overlay-open / feature-enabled checks; listing them + // explicitly rather than defaulting keeps that a stated decision. + return true + default: + return assertNever(surface) + } +} + +/** + * Exhaustiveness guard. Returns `false` at runtime rather than throwing: + * a surface this build does not understand should hide the command, not take + * down the palette. The compile error is the real enforcement; this is the + * belt-and-braces behavior for a value that reached us across a boundary + * TypeScript could not check (a persisted blob, a generated command). + */ +function assertNever(value: never): false { + void value + return false } /** - * Picker-visibility gate, applied AFTER `surfaceAvailable` and the - * command's own `when` (so a hidden command that wouldn't apply anyway - * never reaches this check — order keeps the cheap mode/data filters - * first and the policy decision last). + * CONTEXTUAL ADMISSION: may this command run right now, given the workspace? + * + * This is the predicate every invocation source must agree on — palette, + * native menu, keybinding, and programmatic dispatch alike. It deliberately + * combines exactly three things and excludes a fourth: * - * CRUCIAL: this gate affects ONLY whether a command appears in the - * command-picker list. It does NOT touch `command.run()` and is NOT - * consulted by any keybinding or programmatic invocation path. A - * command hidden here is still fully executable by its shortcut — issue - * #249 is about list noise, not capability. Wiring keybindings to this - * function would be a regression, not a feature. + * ✓ surface — does the concept apply to this layout at all + * ✓ when — is the data it needs actually present + * ✓ renderedView — does the target's view mode support it + * ✗ pickerVisibility — PRESENTATION ONLY. Never consulted here. * - * Effective visibility resolution (most specific wins): - * 1. `showHiddenCommands` → everything visible (global escape hatch). - * 2. an explicit per-command override (`true`/`false`) → that value. - * 3. otherwise the command's declared `pickerVisibility`, where the - * field's absence means `'default'`. Only `'default'` is shown; - * every other tier (`advanced`/`experimental`/`debug`) is hidden - * unless an override or the escape hatch says otherwise. + * That last exclusion is the whole point of splitting this out. The audit + * found the native menu resolving ids from the picker-filtered registry, which + * made a cosmetic "hide from my palette" preference disable a File-menu + * action. Admission and discoverability are now different functions with + * different inputs, so it is no longer possible to accidentally spend one as + * the other. + * + * NOTE this is necessary, not sufficient. It answers "is this invocation + * sensible now", evaluated at dispatch time against fresh state. It does NOT + * replace the checks a destructive command must make at its own mutation + * boundary — a target can still disappear between admission and commit. Those + * per-command revalidations are Phase 2/7 work. */ -function commandVisible(command: CommandDef, ctx: CommandContext): boolean { - if (ctx.flags.showHiddenCommands) return true - - // Optional-chain defensively: this gate runs inside the first render's - // useMemo, so if `commandVisibilityOverrides` is ever undefined (e.g. a - // persisted-settings shape that predates the field and slipped past - // coercion), a bare `[id]` index throws and takes the WHOLE app to a black - // screen — exactly the #249 launch regression. A missing override map must - // degrade to "no per-command override", never crash the registry build. - const override = ctx.flags.commandVisibilityOverrides?.[command.id] - if (typeof override === 'boolean') return override +export function commandApplicable(command: CommandDef, ctx: CommandContext): boolean { + return ( + surfaceAvailable(command.surface, ctx) && + (command.when ? command.when(ctx) : true) && + renderedViewAvailable(command, ctx) + ) +} - return (command.pickerVisibility ?? 'default') === 'default' +/** + * Picker-visibility gate, applied AFTER admission (so a hidden command that + * wouldn't apply anyway never reaches this check — order keeps the cheap + * mode/data filters first and the policy decision last). + * + * The resolution rules themselves now live in `pickerVisibility.ts`, shared + * verbatim with the Settings screen. This wrapper only adapts the live + * `CommandContext` into that module's context-free policy struct: Settings has + * no context to offer, and giving it a synthetic one is how presentation logic + * starts depending on workspace state it should not read. + */ +function commandVisible(command: CommandDef, ctx: CommandContext): boolean { + return isVisibleInPicker(command, { + overrides: ctx.flags.commandVisibilityOverrides, + showHiddenCommands: ctx.flags.showHiddenCommands, + }) } function renderedViewAvailable(command: CommandDef, ctx: CommandContext): boolean { @@ -85,13 +134,7 @@ function renderedViewAvailable(command: CommandDef, ctx: CommandContext): boolea export function buildCommandRegistry(ctx: CommandContext): ResolvedCommand[] { return commandDefs - .filter( - command => - surfaceAvailable(command.surface, ctx) && - (command.when ? command.when(ctx) : true) && - renderedViewAvailable(command, ctx) && - commandVisible(command, ctx), - ) + .filter(command => commandApplicable(command, ctx) && commandVisible(command, ctx)) .map(command => { const description = command.description.trim() if (!description) { @@ -142,6 +185,6 @@ export function listPickerCommandMeta(): PickerCommandMeta[] { return commandDefs.map(command => ({ id: command.id, title: typeof command.title === 'function' ? command.id : command.title, - pickerVisibility: command.pickerVisibility ?? 'default', + pickerVisibility: declaredTier(command), })) } diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index c26716de..5c21d55d 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -11,14 +11,15 @@ import { } from '@renderer/components/ui/dialog' import { buildCommandRegistry } from '@renderer/features/command-palette/registry' import { - buildAgentIndexCommand, - isAgentIndexCommand, -} from '@renderer/features/command-palette/lib/agentIndexCommand' + dispatchCommand, + dispatchResolvedRow, +} from '@renderer/features/command-palette/executeCommand' +import { buildAgentIndexCommand } from '@renderer/features/command-palette/lib/agentIndexCommand' import { buildHistoryScoreMap, loadRecentHistory, - recordCommandUse, } from '@renderer/features/command-palette/lib/recentCommandHistory' +import { useGlobalToast } from '@renderer/ui/GlobalToast' import { rankCommands } from '@renderer/features/command-palette/lib/rankCommands' import { body, @@ -179,6 +180,10 @@ function OpenCommandPalette({ // are untouched, and the future provider-enumeration rewrite (#394 §7) // rebuilds command CONTENT, not this assembly. const workspace = useWorkspaceContext() + // Injected into the execution gateway so an async command failure is + // visible. Before the gateway, every call site was `void command.run(ctx)` + // and a rejected promise vanished with no user-facing signal at all. + const { showToast } = useGlobalToast() const onClose = useAppStore(state => state.closeCommandPalette) const settings = useAppStore(state => state.settings) const setSettings = useAppStore(state => state.setSettings) @@ -840,23 +845,28 @@ function OpenCommandPalette({ const executeCommand = useCallback( (command: ResolvedCommand) => { - // Record the use BEFORE running. This is the single funnel every - // command execution passes through (keyboard Enter and click both - // route here), so it's the one correct place to update history. - // recordCommandUse never throws, so it can't block command.run. - // Agent coordinates are transient workspace destinations, not reusable - // registry commands. Recording `agent-index:` would fill the - // recent-command history with launch-local ids that can never rank a - // future palette open, while crowding out real commands the user repeats. - if (!isAgentIndexCommand(command)) recordCommandUse(command.id) + // Every palette execution — keyboard Enter and click alike — funnels + // through the shared gateway. History is no longer recorded here: the + // gateway records it AFTER a successful run, so a command that turns out + // to be unavailable or that throws no longer climbs the user's ranking + // for failing. It also owns transient-row exclusion, single-flight, and + // surfacing async failures the old `void command.run(...)` swallowed. + const dispatch = () => + void dispatchResolvedRow({ + row: command, + source: 'palette', + ctx: commandContext, + reportError: message => showToast(message, 6000), + }) + if (command.keepPaletteOpen) { - void command.run(commandContext) + dispatch() return } onClose() - void command.run(commandContext) + dispatch() }, - [commandContext, onClose], + [commandContext, onClose, showToast], ) // Native menu → command dispatch (issue #148). @@ -864,19 +874,27 @@ function OpenCommandPalette({ // The macOS File menu lives in main, but its actions are renderer commands // that need the live CommandContext (workspace store + UI callbacks) to run. // Main can't run them; it only knows the command's string id. So main emits - // the id over `menu:command` and we resolve + run it here, where `commands` - // (the resolved registry) and `commandContext` are in scope. + // the id over `menu:command` and we resolve + run it here. // - // The lightweight outer bridge owns the always-on IPC listener. It mounts - // this implementation only long enough to resolve against the SAME registry - // the visible palette uses, so native and palette execution cannot drift. + // WHY this resolves through the gateway instead of `commands.find(...)`: + // `commands` is the PICKER-FILTERED registry. Looking an id up there meant a + // cosmetic `commandVisibilityOverrides[id] = false` — a "don't clutter my + // palette" preference — made the File-menu item resolve to undefined and do + // nothing. Silently. That is the plan's highest-severity finding: picker + // visibility was acting as an authorization boundary it explicitly must not + // be. The gateway resolves from the full catalog and applies contextual + // admission only, so hiding a command can no longer disable its menu item. useLayoutEffect(() => { if (!pendingMenuCommand) return - const command = commands.find(candidate => candidate.id === pendingMenuCommand.id) - if (command) void command.run(commandContext) + void dispatchCommand({ + id: pendingMenuCommand.id, + source: 'native-menu', + ctx: commandContext, + reportError: message => showToast(message, 6000), + }) onMenuCommandHandled() if (pendingMenuCommand.closeAfterRun) onClose() - }, [commandContext, commands, onClose, onMenuCommandHandled, pendingMenuCommand]) + }, [commandContext, onClose, onMenuCommandHandled, pendingMenuCommand, showToast]) const executeResume = useCallback( (session: SessionInfo) => { From 69197b79a2b2e8ba72f8a30fcb3e71c976c207a2 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 16:38:04 +0200 Subject: [PATCH 07/33] feat(commands): add typed targets, availability and safety metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. Adds the type model the later phases populate, plus the resolver that makes one invocation read state exactly once. The audit's "target scope is invisible" and "target-sensitive commands independently resolve in when/getState/run" findings are one bug seen twice. reload-agent asked "which session?" three separate times — to decide whether to appear, to compute its badge, and to actually reload. Nothing forced those answers to agree, so focus moving between palette render and Enter could show session A's provider badge and reload B. resolveCommandInvocation returns target, availability, state and execute from a single read; execute closes over the resolved target, so a later focus change cannot retarget an invocation that already pinned identity. targetKind DEFAULTS FROM SURFACE rather than being declared on all ~30 session commands. `surface: 'session'` already means "acts on the current command-target session in both modes" — that is the field's documented definition — so repeating it would be thirty edits that can drift out of agreement with the line above them, and a new session command that forgot it would silently resolve to no target and skip pinning. Everything else defaults to 'none' and must opt in: a command that should have pinned and didn't is a visible bug, while one that pins a target it shouldn't is an invisible one. Availability ordering implements the recorded product decision on unavailable presentation: 1. surface mismatch HIDES — a grid-spatial command in Dispatch points at a layout the user cannot see, and explaining that on every mode switch is noise; 2. an explicit unavailableReason wins next, letting a command upgrade a silent hide into an explained disable — the discovery-worthy case, where someone searching for Rewind on OpenCode deserves a greyed row that says why rather than silence; 3. a generic when/rendered-view failure hides, unexplained. targetStillValid is the mutation-boundary check admission cannot cover: a destructive command may await a confirmation modal or a kill round-trip, and the target can vanish in that window. It returns false rather than falling back to whatever is focused, because a confirmation the user granted for agent A must never authorize killing B. The CommandContext fixture duplicated in executeCommand.test.ts moves to a shared harness — two copies with different default flags make two tests mean different things while looking identical. Verified: tsc -b clean, 233 files / 1400 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../command-palette/executeCommand.test.ts | 86 +------- .../src/features/command-palette/registry.ts | 13 ++ .../command-palette/resolveInvocation.test.ts | 198 ++++++++++++++++++ .../command-palette/resolveInvocation.ts | 193 +++++++++++++++++ .../testing/commandContextHarness.ts | 132 ++++++++++++ .../src/features/command-palette/types.ts | 147 +++++++++++++ 6 files changed, 688 insertions(+), 81 deletions(-) create mode 100644 src/renderer/src/features/command-palette/resolveInvocation.test.ts create mode 100644 src/renderer/src/features/command-palette/resolveInvocation.ts create mode 100644 src/renderer/src/features/command-palette/testing/commandContextHarness.ts diff --git a/src/renderer/src/features/command-palette/executeCommand.test.ts b/src/renderer/src/features/command-palette/executeCommand.test.ts index 551fa283..75b4ea25 100644 --- a/src/renderer/src/features/command-palette/executeCommand.test.ts +++ b/src/renderer/src/features/command-palette/executeCommand.test.ts @@ -19,6 +19,7 @@ import { } from '@renderer/features/command-palette/executeCommand' import { buildCommandRegistry } from '@renderer/features/command-palette/registry' import { recordCommandUse } from '@renderer/features/command-palette/lib/recentCommandHistory' +import { makeTestCommandContext } from '@renderer/features/command-palette/testing/commandContextHarness' import type { CommandContext } from '@renderer/features/command-palette/types' // --------------------------------------------------------------------------- @@ -35,87 +36,10 @@ import type { CommandContext } from '@renderer/features/command-palette/types' /** Which ui callback a command reached for. */ let uiCalls: string[] = [] -/** - * Narrow test harness for `CommandContext`. - * - * WHY the ui bag is a Proxy rather than ~50 explicit stubs: none of those - * callbacks' identities affect admission or dispatch policy, which is what - * this file tests. What DOES matter is whether a command's `run` was reached - * at all, so the proxy records the name and no-ops. Writing them out would add - * fifty lines of noise that must be maintained every time a command is added, - * and would still assert nothing extra. - * - * `flags`, by contrast, are written out explicitly: individual flags are real - * inputs to the admission decision, so a default that drifts silently would - * change what these tests mean. - */ -function makeContext(overrides?: { - flags?: Partial - workspace?: Partial -}): CommandContext { - const ui = new Proxy({} as CommandContext['ui'], { - get: (_target, prop: string) => (..._args: unknown[]) => { - uiCalls.push(prop) - }, - }) - - const workspace = { - // An empty workspace: no tabs, no dispatch mode, no sessions. This makes - // `commandTargetSessionId` return null, which short-circuits the - // rendered-view gate — deliberate, so these tests isolate the surface/when - // half of admission rather than re-testing render policy. - state: { - tabs: [], - activeTabId: null, - sessions: {}, - dispatchMode: null, - detachedSessions: {}, - buried: [], - pinnedSessionIds: [], - }, - getRuntime: () => undefined, - runtimes: {}, - activeTab: undefined, - ...overrides?.workspace, - } as unknown as CommandContext['workspace'] - - return { - workspace, - ui, - flags: { - statusModeEnabled: true, - worktreeBadgesEnabled: true, - usageHeaderEnabled: true, - usageHeaderLevel: 'all', - dangerousAgentsEnabled: false, - aggressiveDebugPersistenceEnabled: false, - gitBarOpen: false, - worktreesBarOpen: false, - debugPanelOpen: false, - feedDebugPanelOpen: false, - proxyDebugPanelOpen: false, - htmlDebugPanelOpen: false, - renderingDebugMode: false, - tailAllMode: false, - devDebugEnabled: false, - sessionRecordingEnabled: false, - devDebugPanelOpen: false, - agentStatusPanelOpen: false, - performancePanelOpen: false, - caffeinateActive: false, - caffeinateSupported: true, - globalEditorOpen: false, - focusedCwd: null, - fileTreeVisible: false, - editorFullscreen: false, - dispatchModeEnabled: false, - globalDispatchEnabled: false, - agentViewMode: 'agent', - commandVisibilityOverrides: {}, - showHiddenCommands: false, - ...overrides?.flags, - }, - } as CommandContext +/** Thin wrapper over the shared harness so every test in this file records + * into the same `uiCalls` array without repeating the wiring. */ +function makeContext(overrides?: { flags?: Partial }): CommandContext { + return makeTestCommandContext({ flags: overrides?.flags, uiCalls }) } beforeEach(() => { diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index e591ff56..5c4db2a1 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -101,6 +101,19 @@ export function commandApplicable(command: CommandDef, ctx: CommandContext): boo ) } +/** + * Just the mode half of admission, exposed so the availability resolver can + * tell a MODE MISMATCH apart from a capability refusal. + * + * They get different presentations — mode mismatch hides, capability refusal + * can explain itself — so the two must be distinguishable. Collapsing them + * into one boolean is what forced every refusal to look identical (and + * therefore silent) before Phase 2. + */ +export function surfaceApplicable(command: CommandDef, ctx: CommandContext): boolean { + return surfaceAvailable(command.surface, ctx) +} + /** * Picker-visibility gate, applied AFTER admission (so a hidden command that * wouldn't apply anyway never reaches this check — order keeps the cheap diff --git a/src/renderer/src/features/command-palette/resolveInvocation.test.ts b/src/renderer/src/features/command-palette/resolveInvocation.test.ts new file mode 100644 index 00000000..9d11eb1c --- /dev/null +++ b/src/renderer/src/features/command-palette/resolveInvocation.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest' + +import { + resolveCommandAvailability, + resolveCommandInvocation, + resolveCommandTarget, + targetStillValid, +} from '@renderer/features/command-palette/resolveInvocation' +import { makeTestCommandContext } from '@renderer/features/command-palette/testing/commandContextHarness' +import type { CommandDef } from '@renderer/features/command-palette/types' + +const base: CommandDef = { + id: 'x.test', + surface: 'app', + title: 'Test', + description: 'test', + run: () => {}, +} + +describe('resolveCommandTarget', () => { + it('resolves no target for a command that declares none', () => { + // Inventing a target is worse than having none: it produces a confident + // wrong answer, which is how an app-wide panel toggle started rendering + // session-scoped state. + expect(resolveCommandTarget(base, makeTestCommandContext())).toEqual({ kind: 'none' }) + }) + + it('defaults a session-surface command to a session target', () => { + const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) + const command: CommandDef = { ...base, surface: 'session' } + expect(resolveCommandTarget(command, ctx)).toEqual({ kind: 'session', id: 's1' }) + }) + + it('resolves to none when a session command has no focused session', () => { + // An empty or stale tiled lane returns null from the target resolver BY + // DESIGN. That must surface as "no target", never as a fallback to some + // other row the user is not looking at. + const command: CommandDef = { ...base, surface: 'session' } + expect(resolveCommandTarget(command, makeTestCommandContext())).toEqual({ kind: 'none' }) + }) + + it('does not give an app command a session target even when one is focused', () => { + const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) + expect(resolveCommandTarget(base, ctx)).toEqual({ kind: 'none' }) + }) + + it('resolves an explicit project target from the focused cwd', () => { + const ctx = makeTestCommandContext({ focusedCwd: '/repo' }) + const command: CommandDef = { ...base, targetKind: 'project' } + expect(resolveCommandTarget(command, ctx)).toEqual({ kind: 'project', id: '/repo' }) + }) + + it('lets an explicit targetKind override the surface default', () => { + const command: CommandDef = { ...base, surface: 'session', targetKind: 'app' } + const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) + expect(resolveCommandTarget(command, ctx)).toEqual({ kind: 'app' }) + }) +}) + +describe('resolveCommandAvailability', () => { + it('hides a mode-irrelevant command without explaining', () => { + // Product decision: a grid-spatial command in Dispatch points at a layout + // the user cannot see. Explaining that on every mode switch is noise. + const command: CommandDef = { ...base, surface: 'grid' } + const ctx = makeTestCommandContext({ flags: { dispatchModeEnabled: true } }) + expect(resolveCommandAvailability(command, ctx)).toEqual({ + available: false, + reason: 'Not applicable in this layout', + presentation: 'hide', + }) + }) + + it('lets a command upgrade a silent hide into an explained disable', () => { + // The discovery-worthy case: someone searching for a provider-unsupported + // command deserves a greyed row that says why, not silence. + const command: CommandDef = { + ...base, + when: () => false, + unavailableReason: () => ({ + reason: 'OpenCode has no transcript adapter', + presentation: 'disable', + }), + } + expect(resolveCommandAvailability(command, makeTestCommandContext())).toEqual({ + available: false, + reason: 'OpenCode has no transcript adapter', + presentation: 'disable', + }) + }) + + it('hides a generic when-failure that does not explain itself', () => { + const command: CommandDef = { ...base, when: () => false } + expect(resolveCommandAvailability(command, makeTestCommandContext())).toEqual({ + available: false, + reason: 'Not available right now', + presentation: 'hide', + }) + }) + + it('reports availability when nothing objects', () => { + expect(resolveCommandAvailability(base, makeTestCommandContext())).toEqual({ available: true }) + }) + + it('checks the surface before consulting an explicit reason', () => { + // Order is the product decision: a command must not be able to force + // itself visible in a layout where its concept does not exist. + const command: CommandDef = { + ...base, + surface: 'grid', + unavailableReason: () => ({ reason: 'should not win', presentation: 'disable' }), + } + const ctx = makeTestCommandContext({ flags: { dispatchModeEnabled: true } }) + expect(resolveCommandAvailability(command, ctx)).toMatchObject({ presentation: 'hide' }) + }) +}) + +describe('resolveCommandInvocation', () => { + it('resolves target, availability, state and execute from one read', () => { + const command: CommandDef = { + ...base, + surface: 'session', + getState: () => ({ label: 'On' }), + } + const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) + const invocation = resolveCommandInvocation(command, ctx) + + expect(invocation.commandId).toBe('x.test') + expect(invocation.target).toEqual({ kind: 'session', id: 's1' }) + expect(invocation.availability).toEqual({ available: true }) + expect(invocation.state).toEqual({ label: 'On' }) + }) + + it('does not evaluate state for an unavailable command', () => { + // Evaluating getState for an excluded command was wasted work at best and + // at worst read a target admission had just rejected. + let evaluated = false + const command: CommandDef = { + ...base, + when: () => false, + getState: () => { + evaluated = true + return { label: 'x' } + }, + } + const invocation = resolveCommandInvocation(command, makeTestCommandContext()) + expect(invocation.state).toBeNull() + expect(evaluated).toBe(false) + }) + + it('pins the target so a later focus change cannot retarget it', () => { + // THE point of the phase. `execute` closes over the context the target was + // resolved from, so moving focus afterwards cannot redirect the mutation. + const seen: string[] = [] + const command: CommandDef = { + ...base, + surface: 'session', + run: c => { + seen.push(String(c.workspace.state.activeTabId)) + }, + } + const ctx = makeTestCommandContext({ focusedSessionId: 's1', activeTabId: 'tab-a' }) + const invocation = resolveCommandInvocation(command, ctx) + + // Focus moves after resolution but before execution. + const laterCtx = makeTestCommandContext({ focusedSessionId: 's2', activeTabId: 'tab-b' }) + void laterCtx + + void invocation.execute() + expect(seen).toEqual(['tab-a']) + }) +}) + +describe('targetStillValid', () => { + it('accepts a session that still exists', () => { + const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) + expect(targetStillValid({ kind: 'session', id: 's1' }, ctx)).toBe(true) + }) + + it('rejects a session that disappeared between pinning and mutation', () => { + // The gap admission cannot cover: a destructive command may await a + // confirmation modal or a kill round-trip, and the target can vanish in + // that window. Rejecting is mandatory — falling back to whatever is focused + // would let a confirmation granted for agent A authorize killing B. + const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) + expect(targetStillValid({ kind: 'session', id: 'gone' }, ctx)).toBe(false) + }) + + it('rejects a project target once focus moved to another project', () => { + const ctx = makeTestCommandContext({ focusedCwd: '/other' }) + expect(targetStillValid({ kind: 'project', id: '/repo' }, ctx)).toBe(false) + }) + + it('accepts targets with no identity to invalidate', () => { + const ctx = makeTestCommandContext() + expect(targetStillValid({ kind: 'app' }, ctx)).toBe(true) + expect(targetStillValid({ kind: 'none' }, ctx)).toBe(true) + }) +}) diff --git a/src/renderer/src/features/command-palette/resolveInvocation.ts b/src/renderer/src/features/command-palette/resolveInvocation.ts new file mode 100644 index 00000000..9668a69e --- /dev/null +++ b/src/renderer/src/features/command-palette/resolveInvocation.ts @@ -0,0 +1,193 @@ +import { commandApplicable, surfaceApplicable } from '@renderer/features/command-palette/registry' +import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' +import type { + CommandAvailability, + CommandContext, + CommandDef, + CommandTarget, + ResolvedCommandInvocation, +} from '@renderer/features/command-palette/types' + +// --------------------------------------------------------------------------- +// Phase 2: one invocation, one read of state. +// +// The audit's "target scope is invisible" and "target-sensitive commands +// independently resolve in when/getState/run" findings are the same bug seen +// from two angles. A command like `reload-agent` asked "which session?" three +// separate times — once to decide whether to appear, once to compute its +// badge, and once to actually reload. Nothing forced those three answers to +// agree, so a focus change between palette render and Enter could show you +// session A's provider badge and reload session B. +// +// This module resolves target, availability, state and execute TOGETHER, from +// a single read. They cannot disagree because they were never computed apart. +// --------------------------------------------------------------------------- + +/** + * What kind of target a command needs, defaulting from its surface. + * + * WHY derived rather than declared on all 30-odd session commands: `surface: + * 'session'` ALREADY means "acts on the current command-target session, in + * both Grid and Dispatch" — that is the field's documented definition. Making + * every such command repeat the same `targetKind: 'session'` line would be + * thirty edits that can drift out of agreement with the surface they sit next + * to, and a new session command that forgot the line would silently resolve to + * no target and skip pinning entirely. + * + * Everything else defaults to `'none'` and must OPT IN. That asymmetry is the + * safe direction: a command that should have pinned a target and didn't is a + * visible bug (it does nothing), while a command that pins a target it should + * not have is an invisible one (it acts on the wrong thing). + */ +function declaredTargetKind(command: CommandDef): NonNullable { + if (command.targetKind) return command.targetKind + return command.surface === 'session' ? 'session' : 'none' +} + +/** + * Pin the concrete thing this invocation acts on. + * + * A command that declares no `targetKind` resolves to `{kind: 'none'}` rather + * than being handed whatever session happens to be focused. That asymmetry is + * deliberate: the audit found app-scoped panel toggles reading the focused + * session and rendering session-scoped state for a workspace-wide setting. + * Inventing a target is worse than having none, because it produces a + * confident wrong answer. + */ +export function resolveCommandTarget( + command: CommandDef, + ctx: CommandContext, +): CommandTarget { + switch (declaredTargetKind(command)) { + case 'session': { + // The Dispatch-aware resolver is already the single source of truth for + // "which session is the user visibly commanding". Note it returns null + // for an empty or stale tiled lane BY DESIGN — that is an explicit "no + // target", never a fallback to some other row. + const id = commandTargetSessionId(ctx.workspace) + return id ? { kind: 'session', id } : { kind: 'none' } + } + case 'project': { + const id = ctx.flags.focusedCwd + return id ? { kind: 'project', id } : { kind: 'none' } + } + case 'app': + return { kind: 'app' } + case 'document': + // Declared for completeness (the plan's target union includes it), but + // no command uses it yet and the Global Editor does not expose a stable + // document identity to the command layer. Resolving to 'none' rather + // than guessing keeps the honest answer honest; wire it when the first + // consumer needs it AND the editor can supply an id. + return { kind: 'none' } + case 'none': + default: + return { kind: 'none' } + } +} + +/** + * May this invocation proceed, and if not, should the user see why? + * + * Order is the product decision, not an implementation detail: + * + * 1. Surface mismatch HIDES. A grid-spatial command in Dispatch points at a + * layout the user cannot see; explaining that on every mode switch would + * be noise, not help. + * 2. An explicit `unavailableReason` wins next, so a command can UPGRADE a + * silent hide into an explained disable. This is the "discovery-worthy" + * case: someone searching for Rewind on OpenCode deserves a greyed row + * saying why, not silence they cannot interpret. + * 3. A generic `when` / rendered-view failure hides, unexplained. Commands + * that deserve better opt in via step 2. + */ +export function resolveCommandAvailability( + command: CommandDef, + ctx: CommandContext, +): CommandAvailability { + if (!surfaceApplicable(command, ctx)) { + return { + available: false, + reason: 'Not applicable in this layout', + presentation: 'hide', + } + } + + const declared = command.unavailableReason?.(ctx) + if (declared) { + return { available: false, reason: declared.reason, presentation: declared.presentation } + } + + if (!commandApplicable(command, ctx)) { + return { + available: false, + reason: 'Not available right now', + presentation: 'hide', + } + } + + return { available: true } +} + +/** + * Resolve everything about one invocation at once. + * + * `execute` closes over the TARGET RESOLVED HERE, not over "whatever is + * focused when you call me". That closure is the mechanism that makes the + * pinning real — a later focus change cannot retarget an invocation that + * already captured its identity. + */ +export function resolveCommandInvocation( + command: CommandDef, + ctx: CommandContext, +): ResolvedCommandInvocation { + const target = resolveCommandTarget(command, ctx) + const availability = resolveCommandAvailability(command, ctx) + + return { + commandId: command.id, + target, + availability, + // State is computed only for a command that can actually run. Evaluating + // `getState` for an excluded command was wasted work at best, and at worst + // read a target that admission had just rejected. + state: availability.available && command.getState ? command.getState(ctx) : null, + execute: () => command.run(ctx), + } +} + +/** + * MUTATION-BOUNDARY revalidation: is the pinned target still the thing we + * think it is, right now? + * + * WHY this exists separately from admission: admission runs at dispatch. A + * destructive command may await a confirmation modal, a provider probe, or a + * kill round-trip before it mutates — and the target can vanish, move project, + * or change provider in that window. Admission cannot cover that gap; only a + * check at the moment of mutation can. + * + * Returns false rather than throwing, and callers must treat false as "reject + * this operation", never as "fall back to the currently focused session". + * Silently retargeting is the exact failure this is here to prevent: a + * confirmation the user granted for agent A must never authorize killing B. + */ +export function targetStillValid(target: CommandTarget, ctx: CommandContext): boolean { + switch (target.kind) { + case 'session': + // Re-read from the authoritative store rather than trusting a captured + // snapshot. Presence in `sessions` is the minimum bar; per-command + // checks (still grid-owned? still this provider? still running?) belong + // with the command, which knows what its own invariant is. + return Boolean(ctx.workspace.state.sessions[target.id]) + case 'project': + return ctx.flags.focusedCwd === target.id + case 'document': + // No document identity is resolvable yet (see resolveCommandTarget), so + // a document target can never have been pinned in the first place. + return false + case 'app': + case 'none': + // Nothing identity-bearing to invalidate. + return true + } +} diff --git a/src/renderer/src/features/command-palette/testing/commandContextHarness.ts b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts new file mode 100644 index 00000000..e80de354 --- /dev/null +++ b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts @@ -0,0 +1,132 @@ +import type { CommandContext } from '@renderer/features/command-palette/types' + +// --------------------------------------------------------------------------- +// Narrow test harness for `CommandContext`. +// +// WHY a harness exists at all: `CommandContext` is a large PUBLIC input type — +// a workspace store, ~50 ui callbacks, and ~30 flags. Command admission, +// target resolution, and dispatch policy all consume it, so testing any of +// them means constructing one. Building it inline per test file produced a +// 90-line fixture duplicated across suites, which drifts: two copies with +// different default flags make two tests mean different things while looking +// identical. +// +// WHY it is not a mock of private state: everything here is a value the +// production API already takes as an argument. The harness assembles a +// legitimate context; it does not reach past any encapsulation boundary. The +// seam disappears the day `CommandContext` gets a real builder in product +// code — nothing here would need to survive that. +// +// Lives under src/ rather than testing/support/ because it is feature-local +// (only the command-palette suites use it) and because src/renderer/** is +// already inside the authoritative web tsconfig include, so the harness is +// type-checked by the same gate as the code it describes. It is imported only +// by *.test.ts files and is tree-shaken out of the app bundle. +// --------------------------------------------------------------------------- + +export type CommandContextHarnessOptions = { + /** Session id the Dispatch-aware target resolver should report. Also + * registered in `state.sessions` so revalidation finds it. */ + focusedSessionId?: string + /** Project path exposed through `flags.focusedCwd`. */ + focusedCwd?: string + activeTabId?: string + flags?: Partial + /** Receives the name of every ui callback a command reaches for. */ + uiCalls?: string[] +} + +/** + * Build a context that behaves like an empty workspace unless told otherwise. + * + * Defaults are chosen so admission is DECIDED BY THE TEST rather than by + * ambient fixture state: no tabs, no dispatch mode, agent view mode, no + * visibility overrides. A test that wants a gate to fail must say so. + */ +export function makeTestCommandContext( + options: CommandContextHarnessOptions = {}, +): CommandContext { + const uiCalls = options.uiCalls ?? [] + + // WHY the ui bag is a Proxy rather than ~50 explicit stubs: none of those + // callbacks' identities affect admission, targeting, or dispatch policy — + // what matters is whether a command's `run` was reached at all. Fifty + // hand-written no-ops would need maintaining every time a command is added + // and would assert nothing extra. + const ui = new Proxy({} as CommandContext['ui'], { + get: (_target, prop: string) => (..._args: unknown[]) => { + uiCalls.push(prop) + }, + }) + + const sessions = options.focusedSessionId + ? { [options.focusedSessionId]: { kind: 'claude', cwd: '/repo' } } + : {} + + // A single tab owning the focused session is the minimum shape that makes + // `commandTargetSessionIdForState` return it: that selector reads the active + // tab's focusedSessionId when Dispatch is off. Anything less and every + // session-targeted test would silently resolve to "no target" and pass for + // the wrong reason. + const tabs = options.focusedSessionId + ? [{ + id: options.activeTabId ?? 'tab-1', + focusedSessionId: options.focusedSessionId, + root: { type: 'leaf', sessionId: options.focusedSessionId }, + }] + : [] + + const workspace = { + state: { + tabs, + activeTabId: options.activeTabId ?? (tabs.length > 0 ? tabs[0].id : null), + sessions, + dispatchMode: null, + detachedSessions: {}, + buried: [], + pinnedSessionIds: [], + relatedAgents: {}, + }, + getRuntime: () => undefined, + runtimes: {}, + activeTab: tabs[0], + } as unknown as CommandContext['workspace'] + + return { + workspace, + ui, + flags: { + statusModeEnabled: true, + worktreeBadgesEnabled: true, + usageHeaderEnabled: true, + usageHeaderLevel: 'all', + dangerousAgentsEnabled: false, + aggressiveDebugPersistenceEnabled: false, + gitBarOpen: false, + worktreesBarOpen: false, + debugPanelOpen: false, + feedDebugPanelOpen: false, + proxyDebugPanelOpen: false, + htmlDebugPanelOpen: false, + renderingDebugMode: false, + tailAllMode: false, + devDebugEnabled: false, + sessionRecordingEnabled: false, + devDebugPanelOpen: false, + agentStatusPanelOpen: false, + performancePanelOpen: false, + caffeinateActive: false, + caffeinateSupported: true, + globalEditorOpen: false, + focusedCwd: options.focusedCwd ?? null, + fileTreeVisible: false, + editorFullscreen: false, + dispatchModeEnabled: false, + globalDispatchEnabled: false, + agentViewMode: 'agent', + commandVisibilityOverrides: {}, + showHiddenCommands: false, + ...options.flags, + }, + } as CommandContext +} diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index c5df256f..3f6907da 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -8,6 +8,114 @@ export type CommandState = { tone?: 'neutral' | 'accent' | 'danger' } +/** + * The user-facing grouping of a command. + * + * WHY this is separate from `surface`: `surface` is a MACHINE applicability + * dimension — it answers "does this concept exist in the current layout" and + * drives mode gating. It was also being read as a category by the Settings + * list, which conflated two unrelated questions: `debug` is simultaneously "a + * kind of tooling" and "hidden in Dispatch? no". Once one field means both, you + * cannot reclassify a command's presentation without changing when it applies. + * + * The categories below are defined by an OBJECTIVE rule, not by vibes, so two + * people classifying the same command land in the same place: + */ +export type CommandCategory = + /** Creates a tab, pane, session, terminal, agent, or durable template. */ + | 'create' + /** Changes focus, or opens a transient reading/navigation surface. */ + | 'navigate' + /** Acts on exactly one resolved agent/session. */ + | 'session' + /** Changes placement, arrangement, membership, pins, or Dispatch scope. */ + | 'layout-dispatch' + /** Primarily acts on a document, file, editor, or AI Workspace reference. */ + | 'editor-files' + /** Opens project/workspace inspection and management tools. */ + | 'workspace-tools' + /** Mirrors a persisted app preference, or opens Settings. */ + | 'preferences' + /** Diagnostics, recording, raw inspection, or support artifacts. */ + | 'developer' + +/** + * A closed family of commands controlled as ONE product unit. + * + * Deliberately not inferred from `category`: the Navigation Commands group is + * exactly six ids, while the `navigate` category also contains Jump to Latest + * Message, Spotlight, Reader Mode, Tiled Tabs and Reorder Tabs, which stay + * ordinary commands. Deriving membership from the category would make a future + * navigation-adjacent feature silently default-hidden just for reusing a label. + */ +export type CommandGroup = 'navigation' + +/** + * What a command acts on, resolved to a CONCRETE identity at invocation time. + * + * WHY identity and not just a kind: the audit found target-sensitive commands + * independently resolving the focused session in `when`, in `getState`, and + * again in `run`. Between the palette rendering a row and the user pressing + * Enter, focus can move — so the badge could describe session A while the + * mutation landed on session B. Pinning one identity per invocation is what + * makes "the thing I saw is the thing I changed" true. + */ +export type CommandTarget = + /** Needs no target (and must not invent one). */ + | { kind: 'none' } + /** Acts on the application as a whole. */ + | { kind: 'app' } + | { kind: 'project'; id: string } + | { kind: 'session'; id: string } + | { kind: 'document'; id: string } + +/** What a command declares it needs, before resolution finds a concrete one. */ +export type CommandTargetKind = CommandTarget['kind'] + +/** + * Whether an invocation may proceed, and how to present a refusal. + * + * The `presentation` split is a product decision, recorded here because it is + * not self-evident: a command that is IRRELEVANT to the current mode should + * vanish (a grid-spatial "Focus Pane Left" in Dispatch points at nothing the + * user can see), while a command that is relevant but UNSUPPORTED should stay + * visible and disabled with its reason. Hiding the second kind is what makes + * users ask "why is Rewind missing on OpenCode" and find no answer anywhere; + * a greyed row that says so teaches them the thing they actually wanted to + * know. + */ +export type CommandAvailability = + | { available: true } + | { available: false; reason: string; presentation: 'hide' | 'disable' } + +/** + * Risk classification. Metadata, NOT a category — a destructive command still + * belongs to Session or Layout for grouping purposes, and folding risk into + * the category axis would force a "Destructive" bucket that cuts across every + * functional group and helps nobody find anything. + */ +export type CommandRisk = + /** Reversible, or has no side effect beyond view state. */ + | 'safe' + /** Ends processes, deletes data, or cascades. Requires confirmation policy. */ + | 'destructive' + +/** + * One invocation, resolved: what it will act on, whether it may proceed, what + * to display, and how to run it — all derived from a SINGLE read of state. + * + * The whole point is that these four facts cannot disagree with each other, + * because they were computed together rather than by four independent lookups + * at four different moments. + */ +export type ResolvedCommandInvocation = { + commandId: string + target: CommandTarget + availability: CommandAvailability + state: CommandState | null + execute: () => void | Promise +} + /** * Which workspace surface a command belongs to. * @@ -265,6 +373,39 @@ export type CommandDef = { * that depend on rendered feed DOM or feed scroll ownership, while Hybrid can * allow feature commands that acquire a temporary rendered-view lease. */ renderedViewPolicy?: RenderedViewPolicy + /** + * User-facing grouping. Optional during the governance migration and made + * REQUIRED once every command declares one (Phase 3) — flipping it to + * required before the 102 assignments exist would break the build between + * two commits that are each meant to be independently revertable. + */ + category?: CommandCategory + /** Closed family this command belongs to, controlled as one product unit. */ + commandGroup?: CommandGroup + /** + * What this command acts on. Absent means `'none'`. + * + * Declaring it is what lets the resolver pin ONE identity per invocation + * instead of letting `when`, `getState` and `run` each resolve the focused + * session independently and potentially disagree. App- and editor-scoped + * commands must NOT declare a session target merely because a session + * happens to be focused — inventing a target is how a global panel toggle + * starts looking session-scoped. + */ + targetKind?: CommandTargetKind + /** Risk classification, used by confirmation policy. Absent means `'safe'`. */ + risk?: CommandRisk + /** + * Why this command cannot run right now, when that is worth SAYING rather + * than silently hiding. + * + * Returning `null` means "no objection" and lets ordinary admission decide. + * A command only needs this when the refusal is worth explaining: an + * unsupported provider capability, an empty undo stack, an unsupported + * platform. Mode-irrelevance never needs it — the surface gate already hides + * those, and explaining them would add noise to every mode switch. + */ + unavailableReason?: (ctx: CommandContext) => CommandUnavailable | null shortcut?: string keywords?: string[] keepPaletteOpen?: boolean @@ -273,6 +414,12 @@ export type CommandDef = { run: (ctx: CommandContext) => void | Promise } +/** The unavailable half of `CommandAvailability`, as a command declares it. */ +export type CommandUnavailable = { + reason: string + presentation: 'hide' | 'disable' +} + export type ResolvedCommand = { id: string title: string From 288b8e122cf74c54e8ce90c45997d7f21089b402 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 16:43:39 +0200 Subject: [PATCH 08/33] feat(commands): classify tiers and categories, add Navigation Commands group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. All 102 commands now declare a user-facing `category`, and the tiers that existed in the type but were never used are populated. The Phase 0 tripwire — "every command resolves to the default tier" — is inverted here rather than deleted, so the before-state stays legible in the diff and the assertion is proven to have fired exactly once, on purpose. Tier outcomes: ten debug-surface diagnostics leave the picker entirely, niche-but-supported operations (rewind, duplicate, bury, the layout rewrites, the per-session MCP toggles) become `advanced`, daily reversible actions stay `default`, and Remote Control becomes the single `experimental` member per the recorded decision — opening the panel is harmless, but binding a port and pairing a device is not, and those stay separately authorized inside the panel regardless of tier. A test enforces that every debug-SURFACE command also carries the developer CATEGORY. The audit's rule is that debug was being used as a visibility policy when it is a surface label; if the two axes disagree about what a command is, Settings groups it away from its siblings. Navigation Commands ships as a closed six-member group — Next/Previous Tab and Focus Pane Left/Right/Up/Down — gated by a new Settings-only `navigationCommandsEnabled`, default off. Three things about that gate are load-bearing and tested: - It is DISCOVERABILITY only. Off removes six rows from a list. It does not touch Cmd+[ / Cmd+], Option+HJKL, the arrow variants, the underlying workspace actions, or dispatch by menu/keybinding/ programmatic call. A preference that silently disabled keyboard navigation would be a functional regression wearing a preference's clothes. - Membership is a closed id list, NOT derived from the `navigate` category. Jump to Latest Message, Spotlight, Reader Mode, Tiled Tabs and Reorder Tabs are all `navigate` and stay ordinary; deriving membership would make a future navigation-adjacent feature default-hidden for reusing a label. - The group gate outranks a per-command override. Otherwise Settings would show six switches that appear able to contradict their parent — the disabled-parent/enabled-child pattern users cannot predict. Coercion is strict `=== true`, so a fresh install, a malformed value and any store predating the field all resolve to off. `!== false` would have flipped six new rows into every existing user's palette on upgrade. The 98 literal annotations were applied by a one-shot codemod from the audit table; the 4 generated provider splits were done by hand because their ids are template literals. Verified by tsc and by tests that assert total category coverage rather than by trusting the script. Verified: tsc -b clean, 234 files / 1427 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app-state/settings/persistence.ts | 7 + src/renderer/src/app-state/settings/types.ts | 29 +++ .../commands/agentStatusCommands.ts | 1 + .../features/command-palette/catalog.test.ts | 36 ++- .../command-palette/pickerVisibility.test.ts | 5 + .../command-palette/pickerVisibility.ts | 19 +- .../src/features/command-palette/registry.ts | 1 + .../features/command-palette/taxonomy.test.ts | 216 ++++++++++++++++++ .../testing/commandContextHarness.ts | 7 + .../src/features/command-palette/types.ts | 10 + .../command-palette/ui/CommandPalette.tsx | 3 + .../commands/copyAssistantCommands.ts | 2 + .../commands/copyCodeBlockCommands.ts | 2 + .../commands/globalEditorCommands.ts | 13 ++ .../commands/promptTemplateCommands.ts | 5 + .../reader/commands/readerCommands.ts | 1 + .../remote/commands/remoteCommands.ts | 2 + .../commands/replyToSelectionCommands.ts | 1 + .../settings/commands/dangerousCommands.ts | 1 + .../settings/commands/settingsCommands.ts | 5 + .../features/settings/lib/settingsRegistry.ts | 27 +++ .../spotlight/commands/spotlightCommands.ts | 1 + .../tile-tabs/commands/tileTabsCommands.ts | 1 + .../features/usage/commands/usageCommands.ts | 3 + .../commands/dispatchColorFlagCommands.ts | 2 + .../workspace/commands/layoutCommands.ts | 13 ++ .../workspace/commands/paneCommands.ts | 41 ++++ .../workspace/commands/sessionCommands.ts | 54 +++++ .../workspace/commands/tabCommands.ts | 8 + 29 files changed, 504 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/features/command-palette/taxonomy.test.ts diff --git a/src/renderer/src/app-state/settings/persistence.ts b/src/renderer/src/app-state/settings/persistence.ts index a33444af..adc31266 100644 --- a/src/renderer/src/app-state/settings/persistence.ts +++ b/src/renderer/src/app-state/settings/persistence.ts @@ -127,6 +127,13 @@ export function coerceSettings(value: unknown): Settings { commandVisibilityOverrides: coerceCommandVisibilityOverrides( parsed.commandVisibilityOverrides, ), + // Strict `=== true` so a fresh install, a malformed value, and any store + // written before this field existed all resolve to OFF. Anything looser + // (`!== false`) would flip the default on for every existing user, which + // is the opposite of the intent: the group is hidden because six extra + // rows help almost nobody, and silently revealing them on upgrade would + // be a regression nobody asked for. + navigationCommandsEnabled: parsed.navigationCommandsEnabled === true, } } diff --git a/src/renderer/src/app-state/settings/types.ts b/src/renderer/src/app-state/settings/types.ts index b1de94b0..7340d95b 100644 --- a/src/renderer/src/app-state/settings/types.ts +++ b/src/renderer/src/app-state/settings/types.ts @@ -392,6 +392,31 @@ export type Settings = { * registry, the single picker-list chokepoint. It NEVER affects * `run()` or keybindings — hiding is list-only. */ commandVisibilityOverrides: Record + /** + * Whether the closed **Navigation Commands** family appears in the command + * picker. Off by default. + * + * Membership is exactly six ids — Next/Previous Tab and Focus Pane + * Left/Right/Up/Down — declared on the commands themselves via + * `commandGroup: 'navigation'`. They duplicate chords most users already + * have in muscle memory (Cmd+[ / Cmd+], Option+HJKL and the arrow variants), + * so six picker rows earn their space for almost nobody while adding noise + * to every fuzzy search for "tab" or "pane". + * + * CRITICAL, and the reason this is not just another + * `commandVisibilityOverrides` entry: this is a DISCOVERABILITY gate over a + * whole family, not an execution permission. Turning it off removes six rows + * from a list. It does not disable Cmd+[, Option+K, the arrow variants, or + * the underlying workspace navigation actions, and it does not stop the ids + * being dispatched by keybinding, native menu, or programmatic call. A + * setting that silently disabled keyboard navigation would be a functional + * regression wearing a preference's clothes. + * + * The group also stays present in the context-free catalog in BOTH states, + * so catalog validation, native lookup, diagnostics and stable ids never + * depend on a persisted UI preference. + */ + navigationCommandsEnabled: boolean /** Ambient provider-quota indicator in the SettingsBar header row. * On by default: quota headroom is a planning input for dispatching * agent fleets, and the whole point of the feature is ambient @@ -448,6 +473,10 @@ export const DEFAULT_SETTINGS: Settings = { // command. This keeps the whole feature purely additive — fresh // installs and existing users see the exact same picker they do today. commandVisibilityOverrides: {}, + // Off on a fresh install: the six members duplicate shortcuts users already + // have, so the default that costs nothing is the one that keeps them out of + // the picker. Their keyboard behavior is unaffected either way. + navigationCommandsEnabled: false, usageHeaderEnabled: true, usageHeaderLevel: 'all', } diff --git a/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts b/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts index 7e33fb6b..526585c3 100644 --- a/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts +++ b/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts @@ -14,6 +14,7 @@ function focusedAgentSessionId(ctx: CommandContext): string | null { export const agentStatusCommands: CommandDef[] = [ { id: 'show-agent-status', + category: 'workspace-tools', surface: 'session', title: 'Agent Status', description: '**What it does:** Shows or hides a compact **Agent Status** panel for the focused Claude or Codex agent.\n\n**Use when:** You need identity, placement, runtime status, MCP domains, or orchestration/link metadata without opening raw debug panels.\n\n**Notes:** Follows the current command target, including focused Dispatch rows.', diff --git a/src/renderer/src/features/command-palette/catalog.test.ts b/src/renderer/src/features/command-palette/catalog.test.ts index c798acfc..b8150304 100644 --- a/src/renderer/src/features/command-palette/catalog.test.ts +++ b/src/renderer/src/features/command-palette/catalog.test.ts @@ -231,17 +231,35 @@ describe('generated per-provider split commands', () => { }) }) -describe('picker visibility baseline', () => { - it('resolves every command to the default tier today', () => { - // The plan's most consequential finding: `advanced`/`experimental`/`debug` - // all EXIST in the type and NONE are used, so debug tooling, destructive - // maintenance and daily navigation enter the picker at the same tier. +describe('picker visibility', () => { + it('uses more than one tier', () => { + // HISTORICAL NOTE, kept because the inversion is the evidence: + // at the Phase 0 baseline this assertion read `toEqual(new Set(['default']))` + // — all 102 commands resolved to one tier, so debug tooling, destructive + // maintenance and daily navigation entered the picker at the same level, + // even though `advanced`/`experimental`/`debug` already existed in the type + // and went unused. // - // This assertion is expected to FAIL in Phase 3, which is the point — it is - // the tripwire proving tier classification actually landed rather than - // being declared in a doc. + // Phase 3 classified them. Inverting the assertion here rather than + // deleting it keeps the before-state legible in the diff: the tripwire + // fired exactly once, on purpose. const tiers = builtInCommandCatalog.map(c => c.pickerVisibility ?? 'default') - expect(new Set(tiers)).toEqual(new Set(['default'])) + expect(new Set(tiers).size).toBeGreaterThan(1) + }) + + it('keeps the tier distribution deliberate rather than incidental', () => { + // Detailed per-tier rules live in taxonomy.test.ts. This one guards the + // shape: every tier that is used must have a real population, so a typo + // cannot create a tier with a single accidental member. + const counts = new Map() + for (const command of builtInCommandCatalog) { + const tier = command.pickerVisibility ?? 'default' + counts.set(tier, (counts.get(tier) ?? 0) + 1) + } + expect(counts.get('default')).toBeGreaterThan(20) + expect(counts.get('advanced')).toBeGreaterThan(10) + expect(counts.get('debug')).toBeGreaterThan(5) + expect(counts.get('experimental')).toBe(1) }) }) diff --git a/src/renderer/src/features/command-palette/pickerVisibility.test.ts b/src/renderer/src/features/command-palette/pickerVisibility.test.ts index 4b31846b..5f24719f 100644 --- a/src/renderer/src/features/command-palette/pickerVisibility.test.ts +++ b/src/renderer/src/features/command-palette/pickerVisibility.test.ts @@ -11,9 +11,14 @@ const cmd = (id: string, tier?: CommandPickerVisibility) => ({ id, pickerVisibil const policy = (overrides?: { overrides?: Record | undefined showHiddenCommands?: boolean + navigationCommandsEnabled?: boolean }) => ({ overrides: overrides?.overrides ?? {}, showHiddenCommands: overrides?.showHiddenCommands ?? false, + // Defaults ON here, opposite to the product default, so the group gate never + // silently participates in a test that is about tiers or overrides. Group + // behavior gets its own describe block below, where the flag is explicit. + navigationCommandsEnabled: overrides?.navigationCommandsEnabled ?? true, }) describe('declaredTier', () => { diff --git a/src/renderer/src/features/command-palette/pickerVisibility.ts b/src/renderer/src/features/command-palette/pickerVisibility.ts index 46072746..1f97c0c0 100644 --- a/src/renderer/src/features/command-palette/pickerVisibility.ts +++ b/src/renderer/src/features/command-palette/pickerVisibility.ts @@ -15,6 +15,8 @@ export type PickerVisibilityPolicy = { overrides: Record | undefined /** Global "reveal everything" escape hatch. */ showHiddenCommands: boolean + /** Mirrors `Settings.navigationCommandsEnabled`. */ + navigationCommandsEnabled: boolean } /** @@ -40,16 +42,27 @@ export type PickerVisibilityPolicy = { * * Resolution order (most specific wins): * 1. `showHiddenCommands` → everything visible (global escape hatch); - * 2. an explicit per-command override (`true`/`false`) → that value; - * 3. otherwise the declared tier, where absence means `'default'`. Only + * 2. a disabled command GROUP → every member absent, as a family; + * 3. an explicit per-command override (`true`/`false`) → that value; + * 4. otherwise the declared tier, where absence means `'default'`. Only * `'default'` is shown; `advanced`/`experimental`/`debug` are hidden. + * + * WHY the group gate outranks the per-command override (step 2 before 3): the + * group is a single product switch over a closed family. If an override could + * pull one member back into the picker while the family is off, Settings would + * be showing six individually actionable switches that appear able to + * contradict their own parent — the classic "disabled parent, enabled child" + * UI that leaves users unable to predict what they will get. Turning the group + * on restores ordinary per-command control immediately. */ export function isVisibleInPicker( - command: Pick, + command: Pick, policy: PickerVisibilityPolicy, ): boolean { if (policy.showHiddenCommands) return true + if (command.commandGroup === 'navigation' && !policy.navigationCommandsEnabled) return false + // Optional-chain defensively: this runs inside the palette's first-render // useMemo, so if `overrides` is ever undefined (a persisted-settings shape // predating the field that slipped past coercion), a bare `[id]` index throws diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index 5c4db2a1..18ce3266 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -129,6 +129,7 @@ function commandVisible(command: CommandDef, ctx: CommandContext): boolean { return isVisibleInPicker(command, { overrides: ctx.flags.commandVisibilityOverrides, showHiddenCommands: ctx.flags.showHiddenCommands, + navigationCommandsEnabled: ctx.flags.navigationCommandsEnabled, }) } diff --git a/src/renderer/src/features/command-palette/taxonomy.test.ts b/src/renderer/src/features/command-palette/taxonomy.test.ts new file mode 100644 index 00000000..6d767527 --- /dev/null +++ b/src/renderer/src/features/command-palette/taxonomy.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest' + +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { buildCommandRegistry } from '@renderer/features/command-palette/registry' +import { isVisibleInPicker } from '@renderer/features/command-palette/pickerVisibility' +import { makeTestCommandContext } from '@renderer/features/command-palette/testing/commandContextHarness' +import { coerceSettings } from '@renderer/app-state/settings/persistence' + +// --------------------------------------------------------------------------- +// Phase 3: tiers, categories, and the Navigation Commands group. +// +// The catalog baseline test asserted that ALL 102 commands resolved to the +// `default` tier — the audit's headline finding, that debug tooling and daily +// navigation entered the picker at the same level. That assertion is now +// inverted: this file proves the classification actually landed, rather than +// having been declared in a document. +// --------------------------------------------------------------------------- + +const NAVIGATION_GROUP_IDS = [ + 'next-tab', + 'prev-tab', + 'nav-left', + 'nav-right', + 'nav-up', + 'nav-down', +] as const + +const byId = (id: string) => { + const found = builtInCommandCatalog.find(c => c.id === id) + if (!found) throw new Error(`no command ${id}`) + return found +} + +describe('category coverage', () => { + it('assigns a category to every command', () => { + // `category` is still optional in the type during the migration, so this + // test is what actually enforces total coverage. It flips to a compile + // error once the field is required; until then, an unclassified command + // would silently fall out of every grouped Settings view. + const missing = builtInCommandCatalog.filter(c => !c.category).map(c => c.id) + expect(missing).toEqual([]) + }) + + it('uses only known categories', () => { + const known = new Set([ + 'create', + 'navigate', + 'session', + 'layout-dispatch', + 'editor-files', + 'workspace-tools', + 'preferences', + 'developer', + ]) + const unknown = builtInCommandCatalog + .filter(c => c.category && !known.has(c.category)) + .map(c => `${c.id}:${c.category}`) + expect(unknown).toEqual([]) + }) + + it('classifies every diagnostic command as developer', () => { + // The audit's rule: "debug is a surface label, not a visibility or + // capability policy". Every command on the debug SURFACE must also carry + // the developer CATEGORY, or the two axes disagree about what the command + // is and Settings groups it away from its siblings. + const mismatched = builtInCommandCatalog + .filter(c => c.surface === 'debug' && c.category !== 'developer') + .map(c => c.id) + expect(mismatched).toEqual([]) + }) +}) + +describe('tier classification', () => { + it('no longer places every command in the default tier', () => { + // The direct inversion of the Phase 0 baseline assertion. + const tiers = new Set(builtInCommandCatalog.map(c => c.pickerVisibility ?? 'default')) + expect(tiers.size).toBeGreaterThan(1) + }) + + it('hides every debug-surface command from the picker by default', () => { + // Ten diagnostic commands used to enter the palette at the same tier as + // New Tab. This is the single biggest reduction in list noise in the plan. + const visible = builtInCommandCatalog + .filter(c => c.surface === 'debug') + .filter(c => (c.pickerVisibility ?? 'default') === 'default') + .map(c => c.id) + expect(visible).toEqual([]) + }) + + it('classifies Remote Control as experimental', () => { + // Recorded product decision: the panel stays experimental until packaged + // runtime/network readiness and disclosure are complete. Opening a panel is + // harmless; binding a port and pairing a device is not, and those actions + // stay separately authorized inside the panel regardless of this tier. + expect(byId('toggle-remote-panel').pickerVisibility).toBe('experimental') + }) + + it('keeps daily reversible actions in the default tier', () => { + for (const id of ['new-tab', 'close-tab', 'toggle-git-bar', 'reload-agent']) { + expect(byId(id).pickerVisibility ?? 'default').toBe('default') + } + }) + + it('marks niche supported operations advanced rather than hiding them entirely', () => { + // `advanced` is not `debug`: these are supported operations a power user + // wants, just not ones that should crowd a fuzzy search. + for (const id of ['rewind-to-prompt', 'duplicate-agent', 'normalize-layout', 'bury-pane']) { + expect(byId(id).pickerVisibility).toBe('advanced') + } + }) +}) + +describe('Navigation Commands group', () => { + it('has exactly the six recorded members', () => { + const members = builtInCommandCatalog + .filter(c => c.commandGroup === 'navigation') + .map(c => c.id) + expect(members.sort()).toEqual([...NAVIGATION_GROUP_IDS].sort()) + }) + + it('does not sweep in other navigate-category commands', () => { + // The closed-membership rule. Jump to Latest Message, Spotlight, Reader + // Mode, Tiled Tabs and Reorder Tabs are all `navigate` and must stay + // ordinary commands — deriving membership from the category would make a + // future navigation-adjacent feature default-hidden just for reusing a + // label. + for (const id of [ + 'jump-latest-message', + 'toggle-spotlight', + 'toggle-reader-mode', + 'tiled-tabs', + 'reorder-tabs', + ]) { + expect(byId(id).category).toBe('navigate') + expect(byId(id).commandGroup).toBeUndefined() + } + }) + + it('removes all six from the picker when the group is off', () => { + const ctx = makeTestCommandContext({ flags: { navigationCommandsEnabled: false } }) + const visible = new Set(buildCommandRegistry(ctx).map(c => c.id)) + for (const id of NAVIGATION_GROUP_IDS) expect(visible.has(id)).toBe(false) + }) + + it('restores them when the group is on', () => { + // nav-* are grid-surface, so Dispatch must be off for them to be + // applicable at all — otherwise this would pass for the wrong reason. + const ctx = makeTestCommandContext({ + flags: { navigationCommandsEnabled: true, dispatchModeEnabled: false }, + }) + const visible = new Set(buildCommandRegistry(ctx).map(c => c.id)) + for (const id of NAVIGATION_GROUP_IDS) expect(visible.has(id)).toBe(true) + }) + + it('outranks a per-command override while the group is off', () => { + // Documented precedence. If an override could pull one member back while + // the family is off, Settings would show six switches that appear able to + // contradict their own parent. + const hidden = isVisibleInPicker(byId('nav-left'), { + overrides: { 'nav-left': true }, + showHiddenCommands: false, + navigationCommandsEnabled: false, + }) + expect(hidden).toBe(false) + }) + + it('yields to a per-command override once the group is on', () => { + const shown = isVisibleInPicker(byId('nav-left'), { + overrides: { 'nav-left': false }, + showHiddenCommands: false, + navigationCommandsEnabled: true, + }) + expect(shown).toBe(false) + }) + + it('keeps the members in the context-free catalog in both states', () => { + // Catalog membership must never depend on a persisted UI preference, or + // native lookup, diagnostics, and id stability start varying by setting. + const ids = builtInCommandCatalog.map(c => c.id) + for (const id of NAVIGATION_GROUP_IDS) expect(ids).toContain(id) + }) +}) + +describe('navigationCommandsEnabled persistence', () => { + it('defaults to false on a fresh install', () => { + expect(coerceSettings({}).navigationCommandsEnabled).toBe(false) + }) + + it('defaults to false for a store written before the field existed', () => { + // The upgrade path. `!== false` would have flipped six new rows into every + // existing user's palette on update, which is the opposite of the intent. + expect(coerceSettings({ showStatusMode: true }).navigationCommandsEnabled).toBe(false) + }) + + it.each([null, undefined, 0, 1, 'true', {}, []])( + 'coerces the malformed value %p to false', + value => { + const settings = coerceSettings({ navigationCommandsEnabled: value }) + expect(settings.navigationCommandsEnabled).toBe(false) + }, + ) + + it('persists an explicit true', () => { + expect(coerceSettings({ navigationCommandsEnabled: true }).navigationCommandsEnabled).toBe(true) + }) + + it('is idempotent across repeated coercion', () => { + // Coercion runs on every launch through `merge`, not only on version + // change, so a value that drifted on the second pass would drift on every + // subsequent boot. + const once = coerceSettings({ navigationCommandsEnabled: true }) + const twice = coerceSettings(once) + expect(twice.navigationCommandsEnabled).toBe(true) + expect(coerceSettings(coerceSettings({})).navigationCommandsEnabled).toBe(false) + }) +}) diff --git a/src/renderer/src/features/command-palette/testing/commandContextHarness.ts b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts index e80de354..0e54c9c5 100644 --- a/src/renderer/src/features/command-palette/testing/commandContextHarness.ts +++ b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts @@ -125,6 +125,13 @@ export function makeTestCommandContext( globalDispatchEnabled: false, agentViewMode: 'agent', commandVisibilityOverrides: {}, + // ON by default here, opposite to the product default. The harness serves + // suites about admission, targeting and dispatch; leaving the group gate + // off would make six real commands invisible in tests that never mention + // it, and a test failing for a reason it does not name is worse than a + // default that differs from production. Group behavior has its own tests + // that set this explicitly. + navigationCommandsEnabled: true, showHiddenCommands: false, ...options.flags, }, diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index 3f6907da..cfdbbe5c 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -329,6 +329,16 @@ export type CommandContext = { * importing the settings store. */ commandVisibilityOverrides: Record + /** + * Whether the closed Navigation Commands family is picker-eligible. + * Mirrors `Settings.navigationCommandsEnabled`. + * + * Threaded through flags like the override map so the registry's single + * visibility chokepoint can consult it without importing the settings + * store. It is a DISCOVERABILITY gate only — see the setting's docstring + * for why it must never reach execution or keyboard handling. + */ + navigationCommandsEnabled: boolean /** * Global escape hatch: when true, the picker shows EVERY applicable * command regardless of declared visibility or per-command override. diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index 5c21d55d..924caf84 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -258,6 +258,7 @@ function OpenCommandPalette({ const agentViewMode = settings.agentViewMode const commandVisibilityOverrides = settings.commandVisibilityOverrides + const navigationCommandsEnabled = settings.navigationCommandsEnabled const showHiddenCommands = SHOW_HIDDEN_COMMANDS const statusModeEnabled = settings.showStatusMode const worktreeBadgesEnabled = settings.showWorktreeBadges @@ -571,6 +572,7 @@ function OpenCommandPalette({ globalDispatchEnabled, agentViewMode, commandVisibilityOverrides, + navigationCommandsEnabled, showHiddenCommands, }, }), @@ -658,6 +660,7 @@ function OpenCommandPalette({ globalDispatchEnabled, agentViewMode, commandVisibilityOverrides, + navigationCommandsEnabled, showHiddenCommands, ], ) diff --git a/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts b/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts index 6e728038..d7ef62f5 100644 --- a/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts +++ b/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts @@ -11,6 +11,8 @@ import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/comma export const copyAssistantCommands: CommandDef[] = [ { id: 'copy-assistant-message', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Copy Assistant Message…', description: '**What it does:** Opens a picker to copy a specific **assistant message**.\n\n**Use when:** You need an older response, not just the latest one.\n\n**Notes:** Use arrows, **Enter**, and **Esc** after opening.', diff --git a/src/renderer/src/features/copy-code-block/commands/copyCodeBlockCommands.ts b/src/renderer/src/features/copy-code-block/commands/copyCodeBlockCommands.ts index 59afdc61..9abec47f 100644 --- a/src/renderer/src/features/copy-code-block/commands/copyCodeBlockCommands.ts +++ b/src/renderer/src/features/copy-code-block/commands/copyCodeBlockCommands.ts @@ -33,6 +33,8 @@ function afterRenderedPaneCommit(callback: () => void): void { export const copyCodeBlockCommands: CommandDef[] = [ { id: 'copy-code-block', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Copy Code Block…', description: '**What it does:** Opens a picker to copy a specific **code block** from the focused pane.\n\n**Use when:** You want one fenced block — a command, a snippet, a generated file — without copying the whole message.\n\n**Notes:** Use arrows to move between blocks, **Enter** to copy, **Esc** to cancel. Starts on the most recent block.', diff --git a/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts b/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts index d2a507ae..620ade80 100644 --- a/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts +++ b/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts @@ -25,6 +25,7 @@ import { cancelAllPendingGlobalEditorFileOpens } from '@renderer/features/global export const globalEditorCommands: CommandDef[] = [ { id: 'toggle-global-editor', + category: 'editor-files', // `app`: the Global Editor overlay WRAPS whatever workspace layout // is active (grid, Dispatch, tiled) rather than replacing it, so // toggling it is meaningful in every mode. @@ -43,6 +44,7 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'save-editor-file', + category: 'editor-files', surface: 'editor', title: 'Save Editor File', description: @@ -54,6 +56,7 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'save-all-editor-files', + category: 'editor-files', surface: 'editor', title: 'Save All Editor Files', description: @@ -64,6 +67,7 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'quick-open-file', + category: 'editor-files', surface: 'editor', title: 'Quick Open File', description: @@ -86,6 +90,7 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'search-in-files', + category: 'editor-files', surface: 'editor', title: 'Search in Files', description: @@ -108,6 +113,7 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'toggle-editor-fullscreen', + category: 'editor-files', surface: 'editor', title: 'Editor Fullscreen', description: @@ -123,6 +129,8 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'open-ai-workspace', + category: 'editor-files', + pickerVisibility: 'advanced', surface: 'editor', title: 'Open AI Workspace', description: @@ -133,6 +141,8 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'create-ai-workspace', + category: 'editor-files', + pickerVisibility: 'advanced', surface: 'editor', title: 'Create AI Workspace', description: @@ -143,6 +153,8 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'clear-ai-workspace', + category: 'editor-files', + pickerVisibility: 'advanced', surface: 'editor', title: 'Clear AI Workspace', description: @@ -167,6 +179,7 @@ export const globalEditorCommands: CommandDef[] = [ // happens, they assume it broke. Gating it via `when` makes // the command appear only in contexts where it's actionable. id: 'toggle-file-tree', + category: 'editor-files', // `editor`: not mode-gated (the editor overlay is orthogonal to // grid/Dispatch); the `when: globalEditorOpen` guard below still // hides it until the overlay is actually mounted. diff --git a/src/renderer/src/features/prompt-templates/commands/promptTemplateCommands.ts b/src/renderer/src/features/prompt-templates/commands/promptTemplateCommands.ts index e0cb11d4..13154f20 100644 --- a/src/renderer/src/features/prompt-templates/commands/promptTemplateCommands.ts +++ b/src/renderer/src/features/prompt-templates/commands/promptTemplateCommands.ts @@ -13,6 +13,8 @@ function focusedAgentSessionId(workspace: Workspace): string | null { export const promptTemplateCommands: CommandDef[] = [ { id: 'manage-prompt-templates', + category: 'workspace-tools', + pickerVisibility: 'advanced', surface: 'app', title: 'Manage Prompt Templates…', description: '**What it does:** Opens the **prompt template manager** for creating, editing, duplicating, and deleting reusable prompts.\n\n**Use when:** You want to organize or author custom templates.\n\n**Notes:** Built-ins stay read-only; duplicate them to customize.', @@ -22,6 +24,7 @@ export const promptTemplateCommands: CommandDef[] = [ }, { id: 'prompt-template', + category: 'session', surface: 'session', title: 'Prompt Template…', description: '**What it does:** Inserts a saved **prompt template** into the focused composer.\n\n**Use when:** You want reusable prompt text without retyping it.\n\n**Notes:** Agent panes only.', @@ -33,6 +36,8 @@ export const promptTemplateCommands: CommandDef[] = [ }, { id: 'save-composer-as-prompt-template', + category: 'workspace-tools', + pickerVisibility: 'advanced', surface: 'session', title: 'Save Composer as Prompt Template…', description: '**What it does:** Saves current composer text as a **custom prompt template**.\n\n**Use when:** You wrote a prompt you expect to reuse.\n\n**Notes:** Only appears when the composer has text.', diff --git a/src/renderer/src/features/reader/commands/readerCommands.ts b/src/renderer/src/features/reader/commands/readerCommands.ts index 0b2feb0a..86ec43fc 100644 --- a/src/renderer/src/features/reader/commands/readerCommands.ts +++ b/src/renderer/src/features/reader/commands/readerCommands.ts @@ -5,6 +5,7 @@ import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/comma export const readerCommands: CommandDef[] = [ { id: 'toggle-reader-mode', + category: 'navigate', surface: 'session', title: 'Reader Mode', description: '**What it does:** Toggles a cleaner **reading view** for the current agent.\n\n**Use when:** You want to read long agent output comfortably.\n\n**Notes:** Uses the focused command target.', diff --git a/src/renderer/src/features/remote/commands/remoteCommands.ts b/src/renderer/src/features/remote/commands/remoteCommands.ts index 74ea68dd..1649abe6 100644 --- a/src/renderer/src/features/remote/commands/remoteCommands.ts +++ b/src/renderer/src/features/remote/commands/remoteCommands.ts @@ -3,6 +3,8 @@ import type { CommandDef } from '@renderer/features/command-palette/types' export const remoteCommands: CommandDef[] = [ { id: 'toggle-remote-panel', + category: 'workspace-tools', + pickerVisibility: 'experimental', surface: 'app', title: 'Remote Control', description: diff --git a/src/renderer/src/features/reply-to-selection/commands/replyToSelectionCommands.ts b/src/renderer/src/features/reply-to-selection/commands/replyToSelectionCommands.ts index d7b96933..6f2dabd8 100644 --- a/src/renderer/src/features/reply-to-selection/commands/replyToSelectionCommands.ts +++ b/src/renderer/src/features/reply-to-selection/commands/replyToSelectionCommands.ts @@ -33,6 +33,7 @@ function validPendingSelection(workspace: Workspace): PendingSelection | null { export const replyToSelectionCommands: CommandDef[] = [ { id: 'reply-to-selection', + category: 'session', surface: 'session', // Stable noun phrase per docs/command-style.md rule 1 — the captured // text is surfaced through `getState` as a badge (rule 3), not baked diff --git a/src/renderer/src/features/settings/commands/dangerousCommands.ts b/src/renderer/src/features/settings/commands/dangerousCommands.ts index b1725700..8fa196c8 100644 --- a/src/renderer/src/features/settings/commands/dangerousCommands.ts +++ b/src/renderer/src/features/settings/commands/dangerousCommands.ts @@ -21,6 +21,7 @@ async function toggleDangerousAgents(ctx: CommandContext): Promise { export const dangerousCommands: CommandDef[] = [ { id: 'dangerous-agents', + category: 'preferences', surface: 'app', title: 'Dangerous Agents', description: '**What it does:** Toggles **dangerous agent mode** for future agents.\n\n**Use when:** You explicitly want agents to run with fewer safety restrictions.\n\n**Notes:** Affects new agent sessions, not existing ones.', diff --git a/src/renderer/src/features/settings/commands/settingsCommands.ts b/src/renderer/src/features/settings/commands/settingsCommands.ts index 5d2db064..24332767 100644 --- a/src/renderer/src/features/settings/commands/settingsCommands.ts +++ b/src/renderer/src/features/settings/commands/settingsCommands.ts @@ -4,6 +4,7 @@ import { dangerousCommands } from '@renderer/features/settings/commands/dangerou export const settingsCommands: CommandDef[] = [ { id: 'open-settings', + category: 'preferences', surface: 'app', title: 'Open Settings', 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.', @@ -16,6 +17,8 @@ export const settingsCommands: CommandDef[] = [ // day: the user wants crash/close breadcrumbs and complete // bundles, not per-frame debug overhead. id: 'toggle-aggressive-debug-persistence', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Persistent Aggressive Debug Logs', description: '**What it does:** Periodically saves **debug bundles** for active agents.\n\n**Use when:** You are chasing crashes or disappearing state.\n\n**Notes:** Can create many or large debug files.', @@ -39,6 +42,7 @@ export const settingsCommands: CommandDef[] = [ }, { id: 'toggle-worktrees-bar', + category: 'workspace-tools', surface: 'app', title: 'Worktrees', description: '**What it does:** Shows or hides the **Worktrees** panel.\n\n**Use when:** You want branch and worktree activity for the focused project.\n\n**Notes:** Useful for multi-agent git cleanup.', @@ -51,6 +55,7 @@ export const settingsCommands: CommandDef[] = [ }, { id: 'toggle-worktree-badges', + category: 'preferences', surface: 'app', title: 'Worktree Badges', description: '**What it does:** Toggles **worktree badges** on agent rows.\n\n**Use when:** You want branch context visible in panes and **Dispatch**.\n\n**Notes:** Visual-only setting.', diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index 940bb2fc..a4510b2c 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -528,6 +528,33 @@ export function getSettingsRegistry(): SettingDefinition[] { onToggle: (ctx, value) => updateDefaultBuiltInMcpDomain(ctx, 'workflows', value), }, }, + { + id: 'navigation-commands', + category: 'commands', + title: 'Navigation Commands', + description: + 'Show Next/Previous Tab and Focus Pane Left/Right/Up/Down in the command picker. Their keyboard shortcuts always work, whether this is on or off.', + keywords: [ + 'navigation', + 'nav', + 'focus', + 'pane', + 'tab', + 'next', + 'previous', + 'group', + ], + control: { + type: 'toggle', + // Deliberately phrased as "show in the picker", not "enable + // navigation". The audit's naming rule is that a control must name what + // it actually changes: this one adds or removes six rows from a list, + // and a user who read it as "turn navigation off" would be wrong in a + // way that costs them their arrow keys. + getValue: settings => settings.navigationCommandsEnabled, + onToggle: (ctx, value) => ctx.onChange({ navigationCommandsEnabled: value }), + }, + }, { id: 'command-picker-visibility', category: 'commands', diff --git a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts index 80c638b9..640eaf71 100644 --- a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts +++ b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts @@ -3,6 +3,7 @@ import type { CommandDef } from '@renderer/features/command-palette/types' export const spotlightCommands: CommandDef[] = [ { id: 'toggle-spotlight', + category: 'navigate', // `app`, not `grid`: toggleSpotlight resolves its target through the // Dispatch-aware focus path, so single-pane spotlight works whether // the user is commanding a grid pane or a Dispatch row. diff --git a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts index e645e2f1..3c306f81 100644 --- a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts +++ b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts @@ -3,6 +3,7 @@ import type { CommandDef } from '@renderer/features/command-palette/types' export const tileTabsCommands: CommandDef[] = [ { id: 'tiled-tabs', + category: 'navigate', // `app`: Tiled Tabs is a top-level layout mode. Entering it clears // dispatchMode (they are mutually exclusive), so the command must // stay reachable from inside Dispatch to switch away from it. diff --git a/src/renderer/src/features/usage/commands/usageCommands.ts b/src/renderer/src/features/usage/commands/usageCommands.ts index 034a97d5..137c8b2c 100644 --- a/src/renderer/src/features/usage/commands/usageCommands.ts +++ b/src/renderer/src/features/usage/commands/usageCommands.ts @@ -3,6 +3,7 @@ import type { CommandDef } from '@renderer/features/command-palette/types' export const usageCommands: CommandDef[] = [ { id: 'usage.open', + category: 'workspace-tools', title: 'Usage', description: 'Open provider usage for Claude and Codex.', surface: 'app', @@ -14,6 +15,7 @@ export const usageCommands: CommandDef[] = [ }, { id: 'usage.toggle-header', + category: 'preferences', surface: 'app', title: 'Usage in Header', description: @@ -27,6 +29,7 @@ export const usageCommands: CommandDef[] = [ }, { id: 'usage.cycle-header-level', + category: 'preferences', surface: 'app', title: 'Usage Header Detail', description: diff --git a/src/renderer/src/features/workspace/commands/dispatchColorFlagCommands.ts b/src/renderer/src/features/workspace/commands/dispatchColorFlagCommands.ts index 6f6e2458..8ca7f677 100644 --- a/src/renderer/src/features/workspace/commands/dispatchColorFlagCommands.ts +++ b/src/renderer/src/features/workspace/commands/dispatchColorFlagCommands.ts @@ -17,6 +17,8 @@ import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/comma export const dispatchColorFlagCommands: CommandDef[] = [ { id: 'dispatch.color-flag.set', + category: 'layout-dispatch', + pickerVisibility: 'advanced', surface: 'session', title: 'Set color flag', description: diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts index bbb2b6b3..737031c5 100644 --- a/src/renderer/src/features/workspace/commands/layoutCommands.ts +++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts @@ -3,6 +3,7 @@ import type { CommandDef } from '@renderer/features/command-palette/types' export const layoutCommands: CommandDef[] = [ { id: 'dispatch-mode', + category: 'layout-dispatch', // `app`, not `dispatch`: this is the toggle that ENTERS and EXITS // Dispatch, so it must be visible in both modes — surface-gating it // to `dispatch` would make it impossible to turn Dispatch on. @@ -26,6 +27,7 @@ export const layoutCommands: CommandDef[] = [ }, { id: 'global-dispatch', + category: 'layout-dispatch', // `dispatch` surface replaces the old `when: dispatchModeEnabled` // guard — the registry's surface gate already hides this whenever // Dispatch is off, so the explicit `when` was redundant. @@ -43,6 +45,7 @@ export const layoutCommands: CommandDef[] = [ }, { id: 'tiled-dispatch', + category: 'layout-dispatch', // `app`, like the Dispatch toggle: Tiled Dispatch enters (and is the // adjust-count path for) the multi-lane Dispatch layout, so it should be // reachable from the grid as well as from Dispatch. @@ -61,6 +64,8 @@ export const layoutCommands: CommandDef[] = [ // Search settings → "Attach Project Terminal to Dispatch" to toggle. { id: 'normalize-layout', + category: 'layout-dispatch', + pickerVisibility: 'advanced', // `grid`: this rebalances `tab.root` split ratios. Dispatch does not // render the grid, so in Dispatch this was a silent no-op (issue // #228). Surface-gating hides it there instead of running invisibly. @@ -71,6 +76,8 @@ export const layoutCommands: CommandDef[] = [ }, { id: 'hard-normalize-layout', + category: 'layout-dispatch', + pickerVisibility: 'advanced', surface: 'grid', title: 'Hard Normalize Layout', description: '**What it does:** Rebuilds pane sizing into a cleaner even layout.\n\n**Use when:** The layout is messy and needs a stronger reset.\n\n**Notes:** More aggressive than **Normalize Layout**.', @@ -78,6 +85,8 @@ export const layoutCommands: CommandDef[] = [ }, { id: 'rotate-layout', + category: 'layout-dispatch', + pickerVisibility: 'advanced', surface: 'grid', title: 'Rotate Layout', description: '**What it does:** Rotates split directions in the current layout.\n\n**Use when:** The same panes would work better in a different orientation.\n\n**Notes:** Keeps the sessions, changes the arrangement.', @@ -85,6 +94,7 @@ export const layoutCommands: CommandDef[] = [ }, { id: 'toggle-status-mode', + category: 'preferences', surface: 'app', title: 'Status Mode', description: '**What it does:** Toggles status coloring for active agents.\n\n**Use when:** You want running or working agents to stand out.\n\n**Notes:** This is a visual setting only.', @@ -96,6 +106,8 @@ export const layoutCommands: CommandDef[] = [ }, { id: 'toggle-performance-panel', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Performance Stats', description: '**What it does:** Shows or hides the performance stats panel.\n\n**Use when:** You want render, pane, or runtime performance details.\n\n**Notes:** Mostly useful while debugging the app.', @@ -108,6 +120,7 @@ export const layoutCommands: CommandDef[] = [ }, { id: 'toggle-caffeinate', + category: 'workspace-tools', surface: 'app', title: 'Caffeinate', description: '**What it does:** Toggles a macOS `caffeinate` process so long-running agent work can prevent idle/system sleep.\n\n**Use when:** You want Agent Code to keep the machine awake while agents run.\n\n**Notes:** macOS lid-close behavior is hardware and power-state dependent; this command does not guarantee work keeps running after the lid is closed.', diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index c89c7dfa..6d62868b 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -19,6 +19,7 @@ import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' export const paneCommands: CommandDef[] = [ { id: 'new-agent', + category: 'create', // `app`: this is the universal creation entry point. It opens the // placement picker, which is Dispatch-aware — in Dispatch it makes a // detached agent, in the grid it makes a pane. Because it adapts, @@ -48,6 +49,7 @@ export const paneCommands: CommandDef[] = [ // Power-user keybinds (⌥D etc.) still fire in Dispatch; only the // misleading palette rows are gated. id: 'split-vertical', + category: 'create', surface: 'grid', title: 'Split Pane Right', description: '**What it does:** Creates a **new agent pane on the right**.\n\n**Use when:** You want side-by-side work in the grid.\n\n**Notes:** In **Dispatch**, this creates a detached agent instead.', @@ -56,6 +58,7 @@ export const paneCommands: CommandDef[] = [ }, { id: 'split-horizontal', + category: 'create', surface: 'grid', title: 'Split Pane Down', description: '**What it does:** Creates a **new agent pane below**.\n\n**Use when:** You want a stacked grid layout.\n\n**Notes:** In **Dispatch**, this creates a detached agent instead.', @@ -64,6 +67,7 @@ export const paneCommands: CommandDef[] = [ }, { id: 'close-pane', + category: 'session', // `session`: `closeFocused` resolves its target through the // Dispatch-aware path, so this closes a grid pane in the grid and // the highlighted row in Dispatch — meaningful in both modes. @@ -75,6 +79,8 @@ export const paneCommands: CommandDef[] = [ }, { id: 'bury-pane', + category: 'layout-dispatch', + pickerVisibility: 'advanced', surface: 'session', title: 'Bury Pane', description: '**What it does:** Hides the pane but keeps the **session alive**.\n\n**Use when:** You want it out of the layout without killing it.\n\n**Notes:** Buried panes can be revived later.', @@ -82,6 +88,8 @@ export const paneCommands: CommandDef[] = [ }, { id: 'linked-agent', + category: 'create', + pickerVisibility: 'advanced', surface: 'session', title: 'Linked Agent…', description: '**What it does:** Starts a new Claude or Codex agent linked to the currently targeted agent.\n\n**Use when:** You want a one-off helper, like a review agent, visually nested under the parent.\n\n**Notes:** The linked agent is a normal Dispatch agent. It renders directly under the parent and closes automatically when the parent closes.', @@ -107,6 +115,8 @@ export const paneCommands: CommandDef[] = [ // detached session (grid-focused rows in the dispatch list don't // need attaching — they're already attached). id: 'attach-detached-to-grid', + category: 'layout-dispatch', + pickerVisibility: 'advanced', // `dispatch` surface: the old `when` opened with // `if (!workspace.dispatchMode) return false`. That mode check now // lives in the registry's surface gate, so `when` only carries the @@ -144,6 +154,7 @@ export const paneCommands: CommandDef[] = [ // tells the user there's nothing to pin yet, which is more // discoverable than hiding the entry altogether. id: 'pin-agents', + category: 'layout-dispatch', // `dispatch` surface replaces the old `when: Boolean(dispatchMode)` // guard — pins are a Dispatch-list concept and the registry gate // now hides this in the grid. @@ -167,6 +178,7 @@ export const paneCommands: CommandDef[] = [ // non-pinned row, which silently no-ops in the reducer — bad // affordance. id: 'unpin-agent', + category: 'layout-dispatch', // `dispatch` surface carries the mode gate; `when` keeps only the // data condition (the focused row is currently pinned). surface: 'dispatch', @@ -196,6 +208,8 @@ export const paneCommands: CommandDef[] = [ // aware resolver so global Dispatch can target the focused row's // tab (which may differ from `activeTabId`). id: 'attach-all-detached-for-tab', + category: 'layout-dispatch', + pickerVisibility: 'advanced', // `app`, NOT `dispatch`: this command deliberately works in both // modes (see the comment above) — detached agents outlive Dispatch, // and the recovery flow is "from the grid, bring my parked agents @@ -223,6 +237,8 @@ export const paneCommands: CommandDef[] = [ // case; this `when` check gates on an actual grid leaf so the command // does not show for a session that is already detached. id: 'detach-to-dispatch', + category: 'layout-dispatch', + pickerVisibility: 'advanced', // `session`: works in both modes against the Dispatch-aware target // (the `when` below requires that target to be a real grid leaf). surface: 'session', @@ -250,6 +266,7 @@ export const paneCommands: CommandDef[] = [ }, { id: 'terminal-horizontal', + category: 'create', // `grid`: unlike split-vertical, a terminal split DOES still insert // into a grid tree from Dispatch, but splitFocused now resolves the target // from the focused Dispatch row/lane before inserting (#366). The result @@ -265,6 +282,7 @@ export const paneCommands: CommandDef[] = [ }, { id: 'terminal-vertical', + category: 'create', surface: 'grid', title: 'New Terminal Below', description: '**What it does:** Opens a **terminal below**.\n\n**Use when:** You need a shell under the current pane.\n\n**Notes:** From **Dispatch**, the terminal attaches to the focused row or lane’s project grid.', @@ -286,6 +304,10 @@ export const paneCommands: CommandDef[] = [ { id: `${kind}-vertical`, surface: 'grid' as const, + // Same category/tier as the generic and terminal splits they sit + // beside: creating a named-provider pane is not a more advanced act + // than creating a default one, it just names the provider. + category: 'create' as const, title: `New ${caps.shortLabel} Right`, description: `**What it does:** Opens a **${caps.shortLabel} agent on the right**.\n\n**Use when:** You want ${caps.shortLabel} beside the current agent.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`, ...(chord ? { shortcut: `⌥${chord}` } : {}), @@ -295,6 +317,7 @@ export const paneCommands: CommandDef[] = [ { id: `${kind}-horizontal`, surface: 'grid' as const, + category: 'create' as const, title: `New ${caps.shortLabel} Below`, description: `**What it does:** Opens a **${caps.shortLabel} agent below**.\n\n**Use when:** You want ${caps.shortLabel} in a stacked layout.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`, ...(chord ? { shortcut: `⌥⇧${chord}` } : {}), @@ -313,6 +336,8 @@ export const paneCommands: CommandDef[] = [ // SILENT NO-OP (issue #228). Dispatch row navigation is ⌥↑/⌥↓ (and, // after this change, ⌥J/⌥K) — handled directly in useKeybinds. id: 'nav-left', + category: 'navigate', + commandGroup: 'navigation', surface: 'grid', title: 'Focus Pane Left', description: '**What it does:** Focuses the pane to the **left**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.', @@ -321,6 +346,8 @@ export const paneCommands: CommandDef[] = [ }, { id: 'nav-right', + category: 'navigate', + commandGroup: 'navigation', surface: 'grid', title: 'Focus Pane Right', description: '**What it does:** Focuses the pane to the **right**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.', @@ -329,6 +356,8 @@ export const paneCommands: CommandDef[] = [ }, { id: 'nav-up', + category: 'navigate', + commandGroup: 'navigation', surface: 'grid', title: 'Focus Pane Up', description: '**What it does:** Focuses the pane **above**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.', @@ -337,6 +366,8 @@ export const paneCommands: CommandDef[] = [ }, { id: 'nav-down', + category: 'navigate', + commandGroup: 'navigation', surface: 'grid', title: 'Focus Pane Down', description: '**What it does:** Focuses the pane **below**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.', @@ -345,6 +376,7 @@ export const paneCommands: CommandDef[] = [ }, { id: 'undo-close', + category: 'session', surface: 'app', title: 'Undo Close', description: '**What it does:** Restores the most recent closed **pane or tab** from a small recent-close history.\n\n**Use when:** You closed something by mistake, or repeat it to walk back through earlier closes.\n\n**Notes:** Also restores detached **Dispatch** agents captured with a closed tab.', @@ -353,6 +385,8 @@ export const paneCommands: CommandDef[] = [ }, { id: 'revive-pane', + category: 'layout-dispatch', + pickerVisibility: 'advanced', // `app`: buried panes are mode-independent state, and a revived // session re-enters the grid tree — which also makes it a Dispatch // row — so the command is meaningful from either mode. @@ -365,6 +399,8 @@ export const paneCommands: CommandDef[] = [ }, { id: 'kill-buried-pane', + category: 'layout-dispatch', + pickerVisibility: 'advanced', surface: 'app', title: 'Kill Buried Pane…', description: '**What it does:** Permanently kills a **buried session**.\n\n**Use when:** You no longer need hidden background work.\n\n**Notes:** This is destructive.', @@ -375,6 +411,7 @@ export const paneCommands: CommandDef[] = [ }, { id: 'toggle-tail', + category: 'session', surface: 'session', title: 'Tail', description: '**What it does:** Toggles feed **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection.', @@ -417,6 +454,8 @@ export const paneCommands: CommandDef[] = [ }, { id: 'toggle-tail-all', + category: 'layout-dispatch', + pickerVisibility: 'advanced', // WHY 'app' and not 'session': this acts on the workspace, not on the // resolved command target. Same reasoning recorded for // `switch-agents-provider` — the user is acting across the workspace, not @@ -443,6 +482,7 @@ export const paneCommands: CommandDef[] = [ }, { id: 'jump-latest-message', + category: 'navigate', surface: 'session', title: 'Jump to Latest Message', description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only.', @@ -460,6 +500,7 @@ export const paneCommands: CommandDef[] = [ }, { id: 'copy-last-assistant', + category: 'session', surface: 'session', title: 'Copy Last Response', description: '**What it does:** Copies the **latest assistant response**.\n\n**Use when:** You want the most recent answer quickly.\n\n**Notes:** No picker; copies immediately.', diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 0271e03e..818c2faf 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -66,6 +66,7 @@ function agentViewOverrideLabel( export const sessionCommands: CommandDef[] = [ { id: 'view-prompts', + category: 'session', surface: 'session', title: 'View Prompts', description: '**What it does:** Opens prompt history for the focused **agent**.\n\n**Use when:** You want to inspect previous user prompts.\n\n**Notes:** Claude and Codex agents only.', @@ -98,6 +99,8 @@ export const sessionCommands: CommandDef[] = [ // itself re-checks and surfaces a toast if the pane is // mid-stream. id: 'rewind-to-prompt', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Rewind to Prompt…', description: '**What it does:** Rewinds the focused **agent session** to an earlier prompt.\n\n**Use when:** You want to branch from a previous point.\n\n**Notes:** The original transcript file is not edited.', @@ -141,6 +144,8 @@ export const sessionCommands: CommandDef[] = [ // points at the rewound provider id; submit-start clearing removes it before // the user can create branch work that an undo would hide. id: 'undo-rewind', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Undo Rewind', description: '**What it does:** Restores the focused **agent session** to the provider transcript it used before the last rewind.\n\n**Use when:** You rewound to the wrong prompt and have not submitted new work from the rewound branch.\n\n**Notes:** Runtime-only. Available until the next submit, pane close, or reload.', @@ -183,6 +188,7 @@ export const sessionCommands: CommandDef[] = [ // derives "last active" from existing transcript data, so it // needs nothing to be focused. id: 'open-agent-activity', + category: 'workspace-tools', surface: 'app', title: 'Agent Activity…', description: '**What it does:** Opens an overview of **agent activity** across the workspace.\n\n**Use when:** You want to triage active, idle, or stale agents.\n\n**Notes:** Useful for cleanup during long multi-agent sessions.', @@ -213,6 +219,8 @@ export const sessionCommands: CommandDef[] = [ // "cleanup the mess from anywhere" use case harder. The modal itself // handles the empty workspace case with a preview empty state. id: 'close-old-agents', + category: 'workspace-tools', + pickerVisibility: 'advanced', surface: 'app', title: 'Close Old Agents…', description: '**What it does:** Opens a batch cleanup modal for **Claude and Codex agents** inactive longer than a chosen time.\n\n**Use when:** You want to close stale agents across all projects or selected projects.\n\n**Notes:** Defaults to 4 hours and excludes currently-running agents unless you opt in.', @@ -246,6 +254,8 @@ export const sessionCommands: CommandDef[] = [ // deliberately no command for the return and no keybind: it's a low- // frequency operation, and a second command/keybind would be clutter. id: 'switch-agents-provider', + category: 'session', + pickerVisibility: 'advanced', surface: 'app', title: 'Switch Agents to Another Provider…', description: '**What it does:** Opens a modal to move a batch of **Claude/Codex agents** to the other provider at once, and to return the most recent batch.\n\n**Use when:** You hit a usage limit on one provider and want to move agents to the other (then back later).\n\n**Notes:** History is translated; the most recent batch is remembered so you can send it back from the same modal.', @@ -277,6 +287,8 @@ export const sessionCommands: CommandDef[] = [ // the whole point is to find a session when you don't know which // pane to focus first. id: 'search-conversation-prompts', + category: 'workspace-tools', + pickerVisibility: 'advanced', surface: 'app', title: 'Search Conversation Prompts', description: '**What it does:** Searches saved conversations by **prompt text**.\n\n**Use when:** You remember what you asked, but not where it was.\n\n**Notes:** Searches sessions on disk, not only visible panes.', @@ -298,6 +310,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'enable-built-in-mcp-ping', + category: 'developer', + pickerVisibility: 'debug', // `session`, not `debug`: Ping is diagnostic, but the command still // reloads the focused Claude/Codex session and must follow Dispatch // row focus exactly like the other built-in MCP toggles. The @@ -352,6 +366,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'enable-ai-workspace-mcp', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'AI Workspace MCP', description: '**What it does:** Reloads the focused **Claude or Codex agent** with Agent Code AI Workspace MCP tools on or off.\n\n**Use when:** You want this agent to create curated cross-worktree file review workspaces.\n\n**Notes:** Orchestration agents can use this domain, but it remains a separate MCP capability.', @@ -400,6 +416,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'enable-orchestration-mcp', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Orchestration MCP', description: '**What it does:** Reloads the focused **Claude or Codex agent** with Agent Code orchestration MCP tools on or off.\n\n**Use when:** You want this agent to create and coordinate distinct orchestration child agents.\n\n**Notes:** Orchestration agents are separate from manual Linked Agents.', @@ -448,6 +466,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'enable-agent-transcripts-mcp', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Agent Transcripts MCP', description: '**What it does:** Reloads the focused **Claude or Codex agent** with Agent Code transcript-consumption MCP tools on or off.\n\n**Use when:** You want this agent to read a specific Claude/Codex JSONL transcript file through filtered projections instead of manual shell parsing.\n\n**Notes:** The tool accepts an explicit file path and returns bounded normalized transcript context; it does not discover transcripts for the agent.', @@ -496,6 +516,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'enable-agent-management-mcp', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Agent Management MCP', description: '**What it does:** Reloads the focused **Claude or Codex agent** with project-wide Agent Code management tools on or off.\n\n**Use when:** You want this agent to inventory, inspect, prompt, or—only after an explicit user request—close other agents in its project.\n\n**Notes:** Read operations include visible, detached, and buried agents without waking them. Closing has extra authorization and cascade guards.', @@ -545,6 +567,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'enable-workflow-mcp', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Workflow MCP', description: '**What it does:** Reloads the focused **Codex agent** with Agent Code workflow MCP tools on or off.\n\n**Use when:** You want Codex to discover, start, inspect, cancel, or resume portable multi-agent workflows.\n\n**Notes:** Claude is intentionally excluded because it has a native workflow feature. Workflow execution is app-owned and survives renderer reloads; changing MCP capabilities still requires replacing the provider process.', @@ -596,6 +620,7 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'reload-agent', + category: 'session', surface: 'session', title: 'Reload Agent', description: '**What it does:** Restarts the focused **Claude or Codex agent**.\n\n**Use when:** The agent is stuck, exited, or needs reconnecting.\n\n**Notes:** Requires a resumable provider session.', @@ -620,6 +645,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'soft-reload-agent', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Soft Reload Agent', description: '**What it does:** Refreshes the focused **agent view** without restarting its backend process.\n\n**Use when:** The feed or rendering state looks stale, duplicated, or corrupted while the agent is still working.\n\n**Notes:** Keeps the same session, draft, pane placement, and running process.', @@ -661,6 +688,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'set-agent-view-mode', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Set Agent View Mode...', description: '**What it does:** Overrides the focused agent pane to use Agent rendering, Terminal rendering, or the global default.\n\n**Use when:** One session needs the raw provider terminal while the rest of the app keeps its normal view mode.\n\n**Notes:** Persists with the session. Hybrid remains a global/default setting, not a per-session override.', @@ -688,6 +717,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'copy-resume-command', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Copy Resume Command', description: '**What it does:** Copies a shell command to **resume this session**.\n\n**Use when:** You want to continue the agent outside the app.\n\n**Notes:** Produces a Claude or Codex CLI command.', @@ -733,6 +764,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'duplicate-agent', + category: 'create', + pickerVisibility: 'advanced', surface: 'session', title: 'Duplicate Agent', description: '**What it does:** Clones the focused **agent session** into a new pane.\n\n**Use when:** You want a parallel branch of the same conversation.\n\n**Notes:** In **Dispatch**, the clone is created as a detached agent.', @@ -811,6 +844,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'switch-provider', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', title: 'Switch Provider', description: '**What it does:** Switches the focused agent between **Claude** and **Codex**.\n\n**Use when:** You want to continue the same work with another provider.\n\n**Notes:** Saved sessions are translated; empty panes are replaced with a fresh pane of the other provider.', @@ -836,6 +871,7 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'toggle-git-bar', + category: 'workspace-tools', surface: 'app', title: 'Git Bar', description: '**What it does:** Shows or hides the **Git** side panel.\n\n**Use when:** You want repository status for the focused project.\n\n**Notes:** Uses the focused command target’s working directory.', @@ -847,6 +883,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'toggle-debug-panel', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Debug Panel', description: '**What it does:** Shows or hides the focused pane’s **debug panel**.\n\n**Use when:** You need low-level pane or runtime state.\n\n**Notes:** Developer-oriented.', @@ -858,6 +896,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'toggle-feed-debug-panel', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Feed Debug Panel', description: '**What it does:** Shows or hides the **feed debug log** panel.\n\n**Use when:** You want render and feed timeline logs.\n\n**Notes:** Developer-oriented.', @@ -870,6 +910,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'toggle-proxy-debug-panel', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Proxy Debug Panel', description: '**What it does:** Shows or hides **proxy/SSE debug** details.\n\n**Use when:** You are debugging streamed provider events.\n\n**Notes:** Most useful when proxy streaming is enabled.', @@ -893,6 +935,8 @@ export const sessionCommands: CommandDef[] = [ // Wide keyword net because the user might remember "save", "dump", // "export", "snapshot", or the name of any one panel. id: 'save-debug-logs', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Save Debug Logs', description: '**What it does:** Saves a **debug bundle** for the focused pane.\n\n**Use when:** You need a snapshot to inspect or share later.\n\n**Notes:** Copies the saved bundle path after writing it.', @@ -933,6 +977,8 @@ export const sessionCommands: CommandDef[] = [ // whenever the feature is available; the agent-kind guard below keeps it off // terminal panes the recorder can't capture. id: 'toggle-session-recording', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Toggle Session Recording', description: '**What it does:** Starts or stops **continuous recording** of the focused pane\'s rendering-input stream (replayable in the test suite).\n\n**Use when:** Right before reproducing a rendering bug you want captured as a fixture.\n\n**Notes:** Command-driven — nothing records until you start it. Each recording is its own folder under `session-recordings/`.', @@ -967,6 +1013,8 @@ export const sessionCommands: CommandDef[] = [ // toasts "no active recording" rather than pre-computing per-session // recorder state into the palette flags on every keystroke. id: 'attach-recording-note', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Attach Recording Note', description: '**What it does:** Drops a **timestamped note** into the focused pane\'s live session recording.\n\n**Use when:** You see a rendering bug during a recorded soak and want to mark the exact moment.\n\n**Notes:** Reserves the tick instantly, then prompts for text. Only available when session recording is enabled.', @@ -988,6 +1036,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'toggle-rendering-debug-mode', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Rendering Debug Mode', description: '**What it does:** Lets you click rendered feed elements to inspect their exact input, routing provenance, and HTML.\n\n**Use when:** A row is missing, duplicated, misleading, or formatted incorrectly.\n\n**Notes:** Clicks are intercepted while active; toggle the mode off to restore normal interaction.', @@ -1000,6 +1050,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'toggle-html-debug-panel', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'HTML Debug Panel', description: '**What it does:** Shows or hides rendered **HTML/DOM** inspection.\n\n**Use when:** You need to inspect the exact pane markup.\n\n**Notes:** Developer-oriented.', @@ -1016,6 +1068,8 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'toggle-dev-debug-panel', + category: 'developer', + pickerVisibility: 'debug', surface: 'debug', title: 'Dev Debug Panel', description: '**What it does:** Shows or hides the temporary **Dev Debug Panel** module host.\n\n**Use when:** You need a bug-specific workbench for focused runtime state, regex probes, IPC experiments, or other short-lived diagnostics.\n\n**Notes:** Only appears when `AGENT_CODE_DEV_DEBUG=1` is set.', diff --git a/src/renderer/src/features/workspace/commands/tabCommands.ts b/src/renderer/src/features/workspace/commands/tabCommands.ts index ca536be6..3cf3482c 100644 --- a/src/renderer/src/features/workspace/commands/tabCommands.ts +++ b/src/renderer/src/features/workspace/commands/tabCommands.ts @@ -3,6 +3,7 @@ import type { CommandDef } from '@renderer/features/command-palette/types' export const tabCommands: CommandDef[] = [ { id: 'new-tab', + category: 'create', surface: 'app', title: 'New Tab', description: '**What it does:** Creates a **new tab** from a folder you choose.\n\n**Use when:** You want a separate project or workspace context.\n\n**Notes:** Starts a fresh agent in that folder.', @@ -11,6 +12,7 @@ export const tabCommands: CommandDef[] = [ }, { id: 'close-tab', + category: 'layout-dispatch', surface: 'app', title: 'Close Tab', description: '**What it does:** Closes the **current tab** and its sessions.\n\n**Use when:** You are done with a whole project tab.\n\n**Notes:** Use **Undo Close** if you closed it by mistake.', @@ -21,6 +23,8 @@ export const tabCommands: CommandDef[] = [ }, { id: 'next-tab', + category: 'navigate', + commandGroup: 'navigation', surface: 'app', title: 'Next Tab', description: '**What it does:** Moves focus to the **next tab**.\n\n**Use when:** You want quick tab navigation.\n\n**Notes:** Works from the normal workspace surfaces.', @@ -29,6 +33,8 @@ export const tabCommands: CommandDef[] = [ }, { id: 'prev-tab', + category: 'navigate', + commandGroup: 'navigation', surface: 'app', title: 'Previous Tab', description: '**What it does:** Moves focus to the **previous tab**.\n\n**Use when:** You want quick tab navigation.\n\n**Notes:** Works from the normal workspace surfaces.', @@ -37,6 +43,7 @@ export const tabCommands: CommandDef[] = [ }, { id: 'reorder-tabs', + category: 'navigate', surface: 'app', title: 'Reorder Tabs', description: '**What it does:** Opens a picker to rearrange **tab order**.\n\n**Use when:** Your tabs are in the wrong order.\n\n**Notes:** Changes apply after you confirm the modal.', @@ -46,6 +53,7 @@ export const tabCommands: CommandDef[] = [ }, { id: 'resume-session', + category: 'session', surface: 'app', title: 'Resume Session', description: '**What it does:** Opens the **resume session** flow.\n\n**Use when:** You want to continue an old Claude or Codex session.\n\n**Notes:** Uses the focused project folder as the default.', From 558a504688c5da0fb64670e5c92179984d485c5b Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 16:52:13 +0200 Subject: [PATCH 09/33] feat(keybindings): add the canonical binding grammar, defaults and collision gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 (foundation) of docs/superpowers/plans/2026-07-23-command-surface-audit.md. This lands the shared library every later consumer needs — normalizer, shipped defaults, reservation registry, collision engine, sparse persistence — plus the repository gate. Routing useKeybinds through it and the Settings editor UI follow; nothing here changes runtime key behavior yet. THE macOS PROBLEM is why the grammar looks the way it does. Option is the text-composition modifier: ⌥D produces "∂", ⌥C produces "ç". So event.key for an Option chord is a glyph, and matching on it fails for every Option binding — which is most of this app's pane grammar. We store `Alt+D` (readable, layout-stable, what the user sees) and match it against event.code === 'KeyD'. Storing the code would put "Alt+KeyD" in a file a human might read; storing event.key would put "Alt+∂" there, which is both unreadable and wrong on another layout. Numpad deliberately does not collapse onto the number row. Modifier order is fixed on the way in rather than compared order-insensitively, which makes string equality the entire collision engine instead of a rule every call site has to remember. Binding CONTEXTS exist so the checker can tell disjoint behavior from an accidental duplicate. ⌥K really does focus a grid pane AND move the Dispatch selection — one gesture, two meanings, never simultaneous. The overlap matrix is a closed list of disjoint pairs (only grid/dispatch today), so adding a context forces someone to state its relationships rather than inherit a permissive default. The user never picks a context; the command contract supplies it, because a user-editable context would be an escape hatch for declaring any conflict safe. Running the engine against the real shipped set immediately surfaced a genuine overlap the plan predicted: Cmd+W is claimed by close-pane globally and by editor-native close-file. It is legitimate — useKeybinds bails out for editor-owned targets before close-pane can fire, and an empty workbench must still swallow Cmd+W or Electron closes the window — so it is recorded as the one APPROVED overlap, keyed by the exact owner pair and carrying its reason. Approving "Cmd+W is fine" would bless a third owner appearing later; approving the pair does not. Defaults are deliberately scarce: every entry either already ran, or is Cmd+, for Settings (the one new chord, and the platform convention). Personalized palette counts explicitly do NOT mint defaults — that data is picker-only and from one profile, so it is evidence a command is useful, not a mandate to spend a scarce global chord. The aliases the palette never advertised (Alt+W, the four Alt+Arrow nav pairs) are now declared, because the point is that the shown binding and the running binding become one fact. Persistence is sparse with three distinct states — absent inherits, `[]` is an explicit unbind, non-empty replaces. Absent and empty must not collapse: absent means "never touched", so a future release may improve the default; empty means "deliberately removed", and resurrecting that chord would override a decision the user made on purpose. Writing an override equal to the shipped default deletes the entry instead of storing a redundant copy, and Reset removes rather than pins. Unknown ids are preserved, since one may belong to a temporarily uninstalled extension. check:keybindings consumes the same normalizer, registry and matrix as the runtime — a script with a parallel notion of "valid" would be green in CI and broken in the app, which is worse than no script. Verified: tsc -b clean, check:keybindings passes, 237 files / 1511 tests. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 3 +- scripts/check-command-keybindings.mts | 153 ++++++++++ .../src/app-state/settings/persistence.ts | 4 + src/renderer/src/app-state/settings/types.ts | 19 ++ .../features/command-keybindings/defaults.ts | 166 +++++++++++ .../command-keybindings/normalize.test.ts | 204 +++++++++++++ .../features/command-keybindings/normalize.ts | 279 ++++++++++++++++++ .../command-keybindings/reservations.test.ts | 218 ++++++++++++++ .../command-keybindings/reservations.ts | 271 +++++++++++++++++ .../command-keybindings/resolve.test.ts | 165 +++++++++++ .../features/command-keybindings/resolve.ts | 159 ++++++++++ 11 files changed, 1640 insertions(+), 1 deletion(-) create mode 100644 scripts/check-command-keybindings.mts create mode 100644 src/renderer/src/features/command-keybindings/defaults.ts create mode 100644 src/renderer/src/features/command-keybindings/normalize.test.ts create mode 100644 src/renderer/src/features/command-keybindings/normalize.ts create mode 100644 src/renderer/src/features/command-keybindings/reservations.test.ts create mode 100644 src/renderer/src/features/command-keybindings/reservations.ts create mode 100644 src/renderer/src/features/command-keybindings/resolve.test.ts create mode 100644 src/renderer/src/features/command-keybindings/resolve.ts diff --git a/package.json b/package.json index de9096b0..e3f5c1f2 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "test:coverage": "npm run workflow-mcp:build && NODE_ENV=test vitest run --coverage", "test:package": "npm run build && node scripts/verify-build-output.mjs", "test:contract": "node scripts/check-test-contract.mjs", - "check": "npm run test:contract && npm run typecheck && npm test && npm run test:package", + "check:keybindings": "tsx --tsconfig tsconfig.web.json scripts/check-command-keybindings.mts", + "check": "npm run test:contract && npm run check:keybindings && npm run typecheck && npm test && npm run test:package", "audit:production": "npm audit --omit=dev --audit-level=high", "verify:package:mac": "node scripts/verify-packaged-mac.mjs", "smoke:package:mac": "node scripts/smoke-packaged-mac.mjs", diff --git a/scripts/check-command-keybindings.mts b/scripts/check-command-keybindings.mts new file mode 100644 index 00000000..a73a5e61 --- /dev/null +++ b/scripts/check-command-keybindings.mts @@ -0,0 +1,153 @@ +/** + * Static gate for built-in command keybindings. Runs inside `npm run check`. + * + * WHY a repository script when Settings already validates at edit time: the two + * answer different questions. Settings validates the CURRENT PROFILE — one + * user's overrides plus their dictation hotkey. This validates what we SHIP, + * on a machine with no profile at all, so a bad default cannot reach a release + * merely because no reviewer happened to be running a clean install. + * + * CRITICAL: it consumes the exact same normalizer, reservation registry, and + * overlap matrix as the runtime. A script with its own parallel notion of + * "valid" would create a false guarantee — green in CI, broken in the app — + * which is worse than having no script. If you find yourself adding a rule + * here, add it to the shared library and call it from here. + * + * Run: npx tsx --tsconfig tsconfig.web.json scripts/check-command-keybindings.mts + */ +import { builtInCommandCatalog } from '../src/renderer/src/features/command-palette/catalog' +import { + buildDefaultKeybindings, + contextsOverlap, +} from '../src/renderer/src/features/command-keybindings/defaults' +import { + RESERVED_INTERACTIONS, + findBindingCollisions, + listApprovedOverlaps, +} from '../src/renderer/src/features/command-keybindings/reservations' +import { normalizeKeybinding } from '../src/renderer/src/features/command-keybindings/normalize' +import { DEFAULT_DICTATION_HOTKEY } from '../src/renderer/src/lib/hotkeyBinding' + +const failures: string[] = [] +const fail = (message: string) => failures.push(message) + +const defaults = buildDefaultKeybindings() +const catalogIds = new Set(builtInCommandCatalog.map(command => command.id)) + +// --- 1. Grammar ------------------------------------------------------------- +// Every shipped binding must parse AND already be in canonical form. Accepting +// a non-normalized spelling here would mean the string in the source and the +// string used for collision comparison differ, so two chords that are really +// the same could both be "free". +for (const entry of defaults) { + for (const binding of entry.bindings) { + try { + const normalized = normalizeKeybinding(binding) + if (normalized !== binding) { + fail(`${entry.commandId}: "${binding}" is not canonical (expected "${normalized}")`) + } + } catch (error) { + fail(`${entry.commandId}: "${binding}" is not a valid binding — ${(error as Error).message}`) + } + } +} + +// --- 2. Duplicates within one command --------------------------------------- +for (const entry of defaults) { + const seen = new Set() + for (const binding of entry.bindings) { + if (seen.has(binding)) fail(`${entry.commandId}: duplicate binding "${binding}"`) + seen.add(binding) + } +} + +// --- 3. One default entry per command --------------------------------------- +const entriesByCommand = new Map() +for (const entry of defaults) { + entriesByCommand.set(entry.commandId, (entriesByCommand.get(entry.commandId) ?? 0) + 1) +} +for (const [commandId, count] of entriesByCommand) { + if (count > 1) fail(`${commandId}: declared ${count} times in the default set`) +} + +// --- 4. Defaults must name a real, executable command ----------------------- +// A default for an id nobody ships is a chord the user can never trigger and +// never discover — and, worse, one that silently reserves the key against +// every other command. +for (const entry of defaults) { + // `open-command-palette` is the one approved catalog addition and is expected + // to be absent until its command lands; it is checked separately below. + if (entry.commandId === 'open-command-palette') continue + if (!catalogIds.has(entry.commandId)) { + fail(`${entry.commandId}: has a shipped binding but is not in the command catalog`) + } +} + +// --- 5. Collisions ---------------------------------------------------------- +// Checks the shipped defaults against reserved interactions, native/menu roles, +// editor-native keys, and the DEFAULT dictation hotkey. Settings additionally +// checks the user's current dictation binding and overrides; same function, +// different inputs. +const collisions = findBindingCollisions({ + commandDefaults: defaults, + reserved: RESERVED_INTERACTIONS, + dictationBinding: DEFAULT_DICTATION_HOTKEY, +}) +for (const collision of collisions) { + const owners = collision.owners.map(owner => `${owner.id} [${owner.context}]`).join(' vs ') + fail(`${collision.binding}: unapproved overlap — ${owners}`) +} + +// --- 6. Approved overlaps must stay justified ------------------------------- +// An approved overlap is a promise that some handler resolves the ambiguity +// before either owner runs. An entry with no stated reason is a promise nobody +// can check, and a stale one is a promise that has quietly become false. +for (const approved of listApprovedOverlaps()) { + if (approved.reason.trim().length < 40) { + fail(`${approved.binding}: approved overlap has no substantive reason`) + } + if (approved.owners.length < 2) { + fail(`${approved.binding}: approved overlap names fewer than two owners`) + } +} + +// --- 7. Display metadata must not come back --------------------------------- +// The audit's core keybinding finding was that `CommandDef.shortcut` was a +// display string with no relationship to the code that ran. Once the migration +// removes it, a reintroduced `shortcut` would rebuild exactly that drift, so +// the check is structural rather than advisory. +// +// Until the migration completes, this reports the remaining count instead of +// failing — a hard failure here would block the very commits that remove them. +const withLegacyShortcut = builtInCommandCatalog.filter(command => command.shortcut !== undefined) +if (withLegacyShortcut.length > 0) { + console.log( + `note: ${withLegacyShortcut.length} command(s) still declare display-only \`shortcut\` metadata. ` + + 'These are superseded by defaultKeybindings and are removed as the migration completes.', + ) +} + +// --- 8. Context sanity ------------------------------------------------------ +// A default whose context overlaps nothing would be unreachable by the checker +// and could hide a real clash. +for (const entry of defaults) { + if (!contextsOverlap(entry.context, entry.context)) { + fail(`${entry.commandId}: context "${entry.context}" does not overlap itself`) + } +} + +if (failures.length > 0) { + console.error('\ncheck:keybindings FAILED\n') + for (const failure of failures) console.error(` - ${failure}`) + console.error( + `\n${failures.length} problem(s). Bindings are shared state: two owners for one chord means ` + + 'the winner is decided by registration order, which is an accident rather than a policy.\n', + ) + process.exit(1) +} + +console.log( + `check:keybindings OK — ${defaults.length} command binding sets, ` + + `${RESERVED_INTERACTIONS.length} reserved interactions, ` + + `${listApprovedOverlaps().length} approved overlap(s).`, +) diff --git a/src/renderer/src/app-state/settings/persistence.ts b/src/renderer/src/app-state/settings/persistence.ts index adc31266..12e92afc 100644 --- a/src/renderer/src/app-state/settings/persistence.ts +++ b/src/renderer/src/app-state/settings/persistence.ts @@ -25,6 +25,7 @@ import type { } from '@renderer/app-state/settings/types' import { coerceCustomAppearanceJson } from '@renderer/app-state/settings/customAppearance' import { coerceDispatchColorFlags } from '@renderer/app-state/settings/dispatchColorFlags' +import { coerceCommandKeybindingOverrides } from '@renderer/features/command-keybindings/resolve' import { coerceHotkeyBinding } from '@renderer/lib/hotkeyBinding' import { coerceSavedPromptTemplates } from '@renderer/features/prompt-templates/savedPromptTemplates' import { normalizeConfigurableBuiltInMcpDomains } from '@mcp/shared/types' @@ -134,6 +135,9 @@ export function coerceSettings(value: unknown): Settings { // rows help almost nobody, and silently revealing them on upgrade would // be a regression nobody asked for. navigationCommandsEnabled: parsed.navigationCommandsEnabled === true, + commandKeybindingOverrides: coerceCommandKeybindingOverrides( + parsed.commandKeybindingOverrides, + ), } } diff --git a/src/renderer/src/app-state/settings/types.ts b/src/renderer/src/app-state/settings/types.ts index 7340d95b..5ce329a6 100644 --- a/src/renderer/src/app-state/settings/types.ts +++ b/src/renderer/src/app-state/settings/types.ts @@ -417,6 +417,21 @@ export type Settings = { * depend on a persisted UI preference. */ navigationCommandsEnabled: boolean + /** + * Per-command keyboard binding overrides, keyed by stable command id. + * + * SPARSE, with three meaningful states — absent inherits the shipped + * defaults, `[]` is an explicit unbind, and a non-empty array replaces the + * defaults entirely. See `command-keybindings/resolve.ts` for why absent and + * empty must not collapse into one thing (briefly: a future release may + * improve an untouched default, but must not resurrect a chord the user + * deliberately removed). + * + * Unknown ids are preserved rather than pruned: an id that names nothing in + * this build may belong to an extension that is temporarily uninstalled or a + * command a downgrade removed. + */ + commandKeybindingOverrides: Record /** Ambient provider-quota indicator in the SettingsBar header row. * On by default: quota headroom is a planning input for dispatching * agent fleets, and the whole point of the feature is ambient @@ -477,6 +492,10 @@ export const DEFAULT_SETTINGS: Settings = { // have, so the default that costs nothing is the one that keeps them out of // the picker. Their keyboard behavior is unaffected either way. navigationCommandsEnabled: false, + // Empty: every command inherits its shipped default until the user edits one. + // Seeding this with today's defaults would pin every command to this + // release's chords and make future default improvements invisible. + commandKeybindingOverrides: {}, usageHeaderEnabled: true, usageHeaderLevel: 'all', } diff --git a/src/renderer/src/features/command-keybindings/defaults.ts b/src/renderer/src/features/command-keybindings/defaults.ts new file mode 100644 index 00000000..ceb77602 --- /dev/null +++ b/src/renderer/src/features/command-keybindings/defaults.ts @@ -0,0 +1,166 @@ +import { AGENT_PROVIDER_KINDS, DEFAULT_PROVIDER } from '@shared/types/providerKind' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' +import { normalizeKeybinding } from '@renderer/features/command-keybindings/normalize' +import type { Keybinding } from '@renderer/features/command-keybindings/normalize' + +/** + * Where a binding is allowed to fire. + * + * WHY contexts exist at all: without them the collision checker cannot tell + * genuinely disjoint behavior from an accidental duplicate. ⌥K really does + * focus a grid pane AND move the Dispatch selection — those never coexist, so + * it is one gesture with two meanings, not a conflict. But ⌘S in the editor and + * a hypothetical app-wide ⌘S DO coexist, so that is a real clash. + * + * The user never picks a context. The command contract supplies it. Exposing it + * as a user-editable field would be an escape hatch for declaring any conflict + * safe, which is exactly the "different surface means it's fine" reasoning the + * plan forbids. + */ +export type BindingContext = + /** Fires anywhere the workspace router runs. */ + | 'global' + /** Only while the tile grid owns the layout. */ + | 'grid' + /** Only while Dispatch owns the layout. */ + | 'dispatch' + /** Only while Global Editor chrome owns focus. */ + | 'editor' + /** Only while a rendered feed is focused and the target is not text editing. */ + | 'feed' + +/** + * The CLOSED overlap matrix. Two bindings may share a chord only if their + * contexts appear here as non-overlapping. + * + * Deliberately an allow-list of DISJOINT pairs rather than a rule engine: the + * only genuinely mutually exclusive pair today is grid/dispatch, because they + * are two states of one layout switch. Everything else can be simultaneously + * live — the editor overlays the workspace, the feed sits inside a pane, and + * global is by definition everywhere. Encoding that as data rather than as a + * predicate means adding a new context forces someone to state its + * relationships explicitly instead of inheriting a permissive default. + */ +const DISJOINT_CONTEXT_PAIRS: ReadonlyArray = [ + ['grid', 'dispatch'], +] + +export function contextsOverlap(a: BindingContext, b: BindingContext): boolean { + if (a === b) return true + return !DISJOINT_CONTEXT_PAIRS.some( + ([x, y]) => (x === a && y === b) || (x === b && y === a), + ) +} + +export type CommandBindingDefault = { + commandId: string + bindings: readonly Keybinding[] + context: BindingContext +} + +/** + * The shipped default binding set. + * + * INTENTIONALLY SCARCE. This preserves the muscle memory that already exists, + * fills the metadata gaps the Phase 0 baseline recorded, and adds exactly one + * new chord (⌘, for Settings, the platform convention). It does NOT mint + * defaults from personalized palette-usage counts: that history is + * picker-only and came from one development profile, so it is evidence that a + * command is USEFUL, not a mandate to spend a scarce global chord on it. + * + * Every entry here is either (a) a chord that already ran before this change, + * or (b) ⌘, — so upgrading users lose nothing and gain one conventional key. + * The aliases the palette never advertised (⌥W, the ⌥Arrow navigation pairs) + * are declared explicitly, because the whole point is that the displayed + * binding and the running binding are now the same fact. + */ +export function buildDefaultKeybindings(): CommandBindingDefault[] { + const defaults: CommandBindingDefault[] = [ + // --- Command access ----------------------------------------------------- + // Previously hard-coded as `onCommandPalette?.()` with no command id, which + // is why it could not be rebound. Phase 4 gives it an owner. + { commandId: 'open-command-palette', bindings: ['Cmd+Shift+P'], context: 'global' }, + + // --- Tabs --------------------------------------------------------------- + { commandId: 'new-tab', bindings: ['Cmd+T'], context: 'global' }, + { commandId: 'close-tab', bindings: ['Cmd+Shift+W'], context: 'global' }, + { commandId: 'undo-close', bindings: ['Cmd+Shift+T'], context: 'global' }, + { commandId: 'resume-session', bindings: ['Cmd+Shift+R'], context: 'global' }, + { commandId: 'prev-tab', bindings: ['Cmd+['], context: 'global' }, + { commandId: 'next-tab', bindings: ['Cmd+]'], context: 'global' }, + + // --- Pane lifecycle ----------------------------------------------------- + // ⌥W was live but never advertised: an undisclosed destructive chord. It is + // preserved (removing a working binding is a regression) but now declared, + // so Settings can show it and a user can unbind it. + { commandId: 'close-pane', bindings: ['Cmd+W', 'Alt+W'], context: 'global' }, + + // --- Creation ----------------------------------------------------------- + { commandId: 'split-vertical', bindings: ['Alt+D'], context: 'grid' }, + { commandId: 'split-horizontal', bindings: ['Alt+Shift+D'], context: 'grid' }, + { commandId: 'terminal-horizontal', bindings: ['Alt+T'], context: 'grid' }, + { commandId: 'terminal-vertical', bindings: ['Alt+Shift+T'], context: 'grid' }, + + // --- Navigation --------------------------------------------------------- + // The four ⌥Arrow aliases were live and undeclared. Note these are `grid` + // context: the same physical gestures move the Dispatch selection, which is + // a separate reserved interaction, and the overlap matrix proves the two + // can never both be live. + { commandId: 'nav-left', bindings: ['Alt+H', 'Alt+Left'], context: 'grid' }, + { commandId: 'nav-right', bindings: ['Alt+L', 'Alt+Right'], context: 'grid' }, + { commandId: 'nav-up', bindings: ['Alt+K', 'Alt+Up'], context: 'grid' }, + { commandId: 'nav-down', bindings: ['Alt+J', 'Alt+Down'], context: 'grid' }, + + // --- Editor ------------------------------------------------------------- + // ⌘⇧E ran with no declared metadata at all — the palette showed this row + // with no chord even though one existed. + { commandId: 'toggle-global-editor', bindings: ['Cmd+Shift+E'], context: 'global' }, + { commandId: 'quick-open-file', bindings: ['Cmd+P'], context: 'global' }, + { commandId: 'search-in-files', bindings: ['Cmd+Shift+F'], context: 'global' }, + { commandId: 'toggle-editor-fullscreen', bindings: ['Cmd+Alt+E'], context: 'global' }, + // Editor context specifically. Today this chord is implemented TWICE in the + // editor (Monaco addAction + the EditorWorkbench bubble handler) and never + // reaches the command. Declaring it here is what makes rebinding possible; + // removing those two hard-coded paths is what makes rebinding real. + { commandId: 'save-editor-file', bindings: ['Cmd+S'], context: 'editor' }, + + // --- Feed --------------------------------------------------------------- + // Bare End, guarded by "not text editing" — hence its own context rather + // than 'global', or it would collide with End inside any composer. + { commandId: 'jump-latest-message', bindings: ['End'], context: 'feed' }, + + // --- Preferences -------------------------------------------------------- + // The one genuinely NEW default. ⌘, is the macOS convention for Settings + // and was unclaimed. + { commandId: 'open-settings', bindings: ['Cmd+,'], context: 'global' }, + ] + + // Per-provider split chords, derived from the SAME provider identity + // descriptors that generate the commands themselves (paneCommands.ts). This + // is the one chord family that already had a single source of truth, and + // keeping it derived means adding a provider cannot leave metadata and + // behavior disagreeing. Codex declares 'C' → ⌥C/⌥⇧C; OpenCode declares no + // key and therefore ships unbound, which is a deliberate scarcity choice + // rather than an oversight. + for (const kind of AGENT_PROVIDER_KINDS) { + if (kind === DEFAULT_PROVIDER) continue + const chord = getRendererProviderCapabilities(kind).splitShortcutKey + if (!chord) continue + defaults.push( + { commandId: `${kind}-vertical`, bindings: [`Alt+${chord}`], context: 'grid' }, + { commandId: `${kind}-horizontal`, bindings: [`Alt+Shift+${chord}`], context: 'grid' }, + ) + } + + // Normalize on the way out so an authoring typo here fails at module load in + // the test suite rather than producing a binding that silently never matches. + return defaults.map(entry => ({ + ...entry, + bindings: entry.bindings.map(normalizeKeybinding), + })) +} + +/** Lookup of shipped defaults by command id. */ +export function defaultKeybindingsByCommand(): Map { + return new Map(buildDefaultKeybindings().map(entry => [entry.commandId, entry])) +} diff --git a/src/renderer/src/features/command-keybindings/normalize.test.ts b/src/renderer/src/features/command-keybindings/normalize.test.ts new file mode 100644 index 00000000..4e38e210 --- /dev/null +++ b/src/renderer/src/features/command-keybindings/normalize.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' + +import { + KeybindingSyntaxError, + displayKeybinding, + eventMatchesKeybinding, + keybindingFromEvent, + normalizeKeybinding, + parseKeybinding, + tryNormalizeKeybinding, +} from '@renderer/features/command-keybindings/normalize' + +/** Minimal KeyboardEvent stand-in. The router reads exactly these fields, so a + * literal is a more honest input than a constructed DOM event that happy-dom + * would populate with values a real macOS keypress never produces. */ +const evt = (partial: Partial): KeyboardEvent => + ({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + code: '', + key: '', + ...partial, + }) as KeyboardEvent + +describe('parseKeybinding', () => { + it('normalizes modifier order regardless of how it was written', () => { + // Fixed order is what lets string equality BE the collision engine. An + // order-insensitive comparison would have to be remembered at every call + // site, and forgotten at one of them. + expect(normalizeKeybinding('Shift+Cmd+P')).toBe('Cmd+Shift+P') + expect(normalizeKeybinding('Alt+Cmd+E')).toBe('Cmd+Alt+E') + }) + + it.each([ + ['cmd+p', 'Cmd+P'], + ['Command+P', 'Cmd+P'], + ['Meta+P', 'Cmd+P'], + ['⌘P'.replace('⌘', '⌘+'), 'Cmd+P'], + ['opt+d', 'Alt+D'], + ['option+d', 'Alt+D'], + ['⌥+d', 'Alt+D'], + ['ctrl+a', 'Ctrl+A'], + ])('accepts the alias %s', (input, expected) => { + expect(normalizeKeybinding(input)).toBe(expected) + }) + + it('uppercases letters and leaves digits alone', () => { + expect(normalizeKeybinding('cmd+w')).toBe('Cmd+W') + expect(normalizeKeybinding('cmd+1')).toBe('Cmd+1') + }) + + it('normalizes named keys and both arrow spellings', () => { + expect(normalizeKeybinding('alt+left')).toBe('Alt+Left') + expect(normalizeKeybinding('Alt+ArrowLeft')).toBe('Alt+Left') + expect(normalizeKeybinding('end')).toBe('End') + expect(normalizeKeybinding('alt+pageup')).toBe('Alt+PageUp') + }) + + it('accepts punctuation and function keys', () => { + expect(normalizeKeybinding('cmd+[')).toBe('Cmd+[') + expect(normalizeKeybinding('cmd+,')).toBe('Cmd+,') + expect(normalizeKeybinding('f5')).toBe('F5') + expect(normalizeKeybinding('cmd+F12')).toBe('Cmd+F12') + }) + + it('is idempotent', () => { + // Coercion runs on every settings load, so a value that changed on the + // second pass would drift on every boot. + const once = normalizeKeybinding('shift+cmd+p') + expect(normalizeKeybinding(once)).toBe(once) + }) + + describe('rejections', () => { + it('rejects a multi-step sequence with an actionable message', () => { + // Accepting `Cmd+K R` and running only the first step would be worse than + // refusing it: Settings would display a binding the runtime cannot honour. + expect(() => parseKeybinding('Cmd+K R')).toThrow(KeybindingSyntaxError) + expect(() => parseKeybinding('Cmd+K R')).toThrow(/Multi-step sequences are not supported/) + }) + + it('rejects a modifier-only binding', () => { + // Can never fire: a bare modifier keydown has no key to match. Dictation + // allows modifier-only holds, which is precisely why the command editor + // must not inherit dictation's capture policy. + expect(() => parseKeybinding('Cmd')).toThrow(/has no key/) + expect(() => parseKeybinding('Cmd+Shift')).toThrow(/has no key/) + }) + + it('rejects two keys in one binding', () => { + expect(() => parseKeybinding('Cmd+A+B')).toThrow(/more than one key/) + }) + + it('rejects a duplicate modifier', () => { + expect(() => parseKeybinding('Cmd+Cmd+P')).toThrow(/Duplicate modifier/) + }) + + it('rejects an unrecognized key', () => { + expect(() => parseKeybinding('Cmd+Frobnicate')).toThrow(/Unrecognized key/) + }) + + it('rejects an empty binding', () => { + expect(() => parseKeybinding(' ')).toThrow(/empty/) + }) + }) + + describe('tryNormalizeKeybinding', () => { + it('returns null instead of throwing, for coercion paths', () => { + // Settings boot must survive a corrupt blob; a throw here would black-screen + // the app on a value nobody can see to fix. + expect(tryNormalizeKeybinding('Cmd+K R')).toBeNull() + expect(tryNormalizeKeybinding(42)).toBeNull() + expect(tryNormalizeKeybinding(null)).toBeNull() + expect(tryNormalizeKeybinding('cmd+p')).toBe('Cmd+P') + }) + }) +}) + +describe('keybindingFromEvent', () => { + it('reads a letter from the physical code, not the produced character', () => { + // THE macOS case. Option is the text-composition modifier: ⌥D produces "∂". + // Matching on `event.key` fails for every Option binding, which is most of + // this app's pane grammar. + const optionD = evt({ altKey: true, code: 'KeyD', key: '∂' }) + expect(keybindingFromEvent(optionD)).toBe('Alt+D') + }) + + it.each([ + ['ç', 'KeyC', 'Alt+C'], + ['˚', 'KeyK', 'Alt+K'], + ['¬', 'KeyL', 'Alt+L'], + ['˙', 'KeyH', 'Alt+H'], + ['†', 'KeyT', 'Alt+T'], + ])('resolves the Option glyph %s to %s', (key, code, expected) => { + expect(keybindingFromEvent(evt({ altKey: true, code, key }))).toBe(expected) + }) + + it('reads punctuation from the code too', () => { + // ⌥- produces an en dash on macOS. + expect(keybindingFromEvent(evt({ altKey: true, code: 'Minus', key: '–' }))).toBe('Alt+-') + expect(keybindingFromEvent(evt({ metaKey: true, code: 'BracketLeft', key: '[' }))).toBe('Cmd+[') + }) + + it('reads named keys from event.key', () => { + expect(keybindingFromEvent(evt({ key: 'ArrowLeft', altKey: true }))).toBe('Alt+Left') + expect(keybindingFromEvent(evt({ key: 'End' }))).toBe('End') + expect(keybindingFromEvent(evt({ key: 'Escape' }))).toBe('Escape') + }) + + it('does not collapse the numpad onto the number row', () => { + // Different physical keys. Collapsing them would make a numpad press fire a + // binding the user assigned to the number row. + expect(keybindingFromEvent(evt({ metaKey: true, code: 'Digit1', key: '1' }))).toBe('Cmd+1') + expect(keybindingFromEvent(evt({ metaKey: true, code: 'Numpad1', key: '1' }))).toBeNull() + }) + + it('returns null for a bare modifier press', () => { + // Not an error — the user is mid-chord and the router has nothing to match. + expect(keybindingFromEvent(evt({ key: 'Meta', metaKey: true }))).toBeNull() + expect(keybindingFromEvent(evt({ key: 'Shift', shiftKey: true }))).toBeNull() + }) + + it('emits modifiers in canonical order', () => { + const e = evt({ metaKey: true, shiftKey: true, altKey: true, code: 'KeyP', key: 'π' }) + expect(keybindingFromEvent(e)).toBe('Cmd+Alt+Shift+P') + }) +}) + +describe('eventMatchesKeybinding', () => { + it('matches an Option chord through its produced glyph', () => { + expect(eventMatchesKeybinding(evt({ altKey: true, code: 'KeyD', key: '∂' }), 'Alt+D')).toBe(true) + }) + + it('does not match when a modifier differs', () => { + const e = evt({ altKey: true, shiftKey: true, code: 'KeyD', key: 'Î' }) + expect(eventMatchesKeybinding(e, 'Alt+D')).toBe(false) + expect(eventMatchesKeybinding(e, 'Alt+Shift+D')).toBe(true) + }) +}) + +describe('displayKeybinding', () => { + it('renders macOS symbols', () => { + expect(displayKeybinding('Cmd+Shift+P')).toBe('⌘⇧P') + expect(displayKeybinding('Alt+D')).toBe('⌥D') + expect(displayKeybinding('Cmd+Alt+E')).toBe('⌘⌥E') + }) + + it('renders arrows and named keys as symbols where one exists', () => { + expect(displayKeybinding('Alt+Left')).toBe('⌥←') + expect(displayKeybinding('Escape')).toBe('⎋') + }) + + it('keeps word keys readable', () => { + expect(displayKeybinding('Alt+PageUp')).toBe('⌥PageUp') + expect(displayKeybinding('End')).toBe('End') + }) + + it('renders a malformed value as itself rather than throwing', () => { + // This runs in a render path; throwing would blank a Settings page over a + // value the user cannot see to fix. + expect(displayKeybinding('not a binding')).toBe('not a binding') + }) +}) diff --git a/src/renderer/src/features/command-keybindings/normalize.ts b/src/renderer/src/features/command-keybindings/normalize.ts new file mode 100644 index 00000000..ef9b565b --- /dev/null +++ b/src/renderer/src/features/command-keybindings/normalize.ts @@ -0,0 +1,279 @@ +// --------------------------------------------------------------------------- +// The keybinding grammar (governance plan, Phase 4). +// +// ONE canonical string form, used by persistence, the collision checker, the +// runtime router, the Settings editor, and the repository script. The audit's +// finding was that `CommandDef.shortcut` was a DISPLAY string with no +// relationship to `useKeybinds.ts`, which implemented the real behavior — so +// the palette could advertise ⌘S for a command the editor actually ran, and a +// user editing the displayed string would change nothing. A single parsed form +// consumed by everything is what makes a user's edit real. +// +// THE macOS PROBLEM, and why the canonical form stores a LETTER but matching +// uses a PHYSICAL CODE: +// +// On macOS, Option is the text-composition modifier. ⌥D produces "∂", ⌥C +// produces "ç", ⌥K produces "˚". So `event.key` for an Option chord is a +// glyph, not the letter the user pressed, and matching on it fails for every +// Option binding — which is most of this app's pane grammar. `event.code` is +// the physical key ("KeyD") and is stable regardless of modifier or layout. +// +// We therefore store `Alt+D` (readable, stable, what the user sees) and match +// it against `event.code === 'KeyD'`. Storing the code itself would put +// "Alt+KeyD" in a settings file a human might read, and storing `event.key` +// would put "Alt+∂" there — which is both unreadable AND wrong on a different +// keyboard layout. +// --------------------------------------------------------------------------- + +/** A normalized single-step binding, e.g. `Cmd+Shift+P` or `Alt+D`. */ +export type Keybinding = string + +/** + * Modifier order is FIXED, not sorted at comparison time. + * + * Two bindings are equal iff their canonical strings are equal, which makes + * `Set`/`Map` the collision engine's whole implementation. That only works if + * normalization is total — hence a fixed order applied on the way in, rather + * than an order-insensitive comparison applied at every call site (which is + * the version people forget in one place). + */ +const MODIFIER_ORDER = ['Cmd', 'Ctrl', 'Alt', 'Shift'] as const +type Modifier = (typeof MODIFIER_ORDER)[number] + +/** Accepted spellings on the way IN. Output is always the canonical name. */ +const MODIFIER_ALIASES: Record = { + cmd: 'Cmd', command: 'Cmd', meta: 'Cmd', super: 'Cmd', '⌘': 'Cmd', + ctrl: 'Ctrl', control: 'Ctrl', '⌃': 'Ctrl', + alt: 'Alt', opt: 'Alt', option: 'Alt', '⌥': 'Alt', + shift: 'Shift', '⇧': 'Shift', +} + +/** + * Named non-character keys. The canonical name is the KEY, the value is the + * `KeyboardEvent.key` it matches. + * + * Arrow keys are stored as `Left`/`Right`/`Up`/`Down` rather than + * `ArrowLeft`/… because the short form is what a Settings row should show and + * what a person would type. The mapping to the DOM's spelling happens here, + * once. + */ +const NAMED_KEYS: Record = { + Left: 'ArrowLeft', Right: 'ArrowRight', Up: 'ArrowUp', Down: 'ArrowDown', + Enter: 'Enter', Escape: 'Escape', Space: ' ', Tab: 'Tab', + Backspace: 'Backspace', Delete: 'Delete', + Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown', +} + +/** + * Punctuation, by canonical name → physical code. + * + * Same reason as letters: `event.key` for `Alt+-` is "–" (en dash) on macOS. + * The code (`Minus`) is not. + */ +const PUNCTUATION_CODES: Record = { + '[': 'BracketLeft', ']': 'BracketRight', '-': 'Minus', '=': 'Equal', + ',': 'Comma', '.': 'Period', '/': 'Slash', '\\': 'Backslash', + ';': 'Semicolon', "'": 'Quote', '`': 'Backquote', +} + +export type ParsedKeybinding = { + modifiers: readonly Modifier[] + /** Canonical key token: `A`-`Z`, `0`-`9`, a NAMED_KEYS name, `F1`-`F20`, or punctuation. */ + key: string +} + +export class KeybindingSyntaxError extends Error {} + +/** + * Parse a user- or config-supplied string into canonical form. + * + * Throws rather than returning null so the Settings editor and the repository + * script can surface the SAME actionable message. A binding that silently + * coerced to something else is how a user ends up with a chord they did not + * choose and cannot find. + */ +export function parseKeybinding(input: string): ParsedKeybinding { + const raw = input.trim() + if (!raw) throw new KeybindingSyntaxError('Binding is empty') + + // Multi-step sequences are explicitly out of scope for this PR. Rejecting + // them is deliberate: accepting `Cmd+K R` and then only ever running the + // first step would be worse than refusing it, because the Settings row would + // display a binding the runtime cannot honour. + if (/\s/.test(raw)) { + throw new KeybindingSyntaxError( + `Multi-step sequences are not supported yet (got "${raw}"). Use a single chord.`, + ) + } + + // Split on '+' but keep a trailing literal '+' usable as a key: "Cmd++". + const parts = raw.split('+') + if (parts.length > 1 && parts[parts.length - 1] === '') { + parts.pop() + parts[parts.length - 1] = '+' + } + + const modifiers: Modifier[] = [] + let key: string | null = null + + for (const part of parts) { + if (!part) throw new KeybindingSyntaxError(`Malformed binding "${raw}"`) + const modifier = MODIFIER_ALIASES[part.toLowerCase()] + if (modifier) { + if (modifiers.includes(modifier)) { + throw new KeybindingSyntaxError(`Duplicate modifier "${modifier}" in "${raw}"`) + } + modifiers.push(modifier) + continue + } + if (key !== null) { + throw new KeybindingSyntaxError(`Binding "${raw}" has more than one key`) + } + key = canonicalKey(part, raw) + } + + if (key === null) { + // A modifier-only binding can never fire as a command chord: the router + // reads keydown, and a bare modifier keydown has no key to match. Dictation + // deliberately allows modifier-only holds, which is exactly why the command + // editor must NOT inherit dictation's capture policy. + throw new KeybindingSyntaxError(`Binding "${raw}" has no key`) + } + + return { + modifiers: MODIFIER_ORDER.filter(m => modifiers.includes(m)), + key, + } +} + +function canonicalKey(part: string, raw: string): string { + if (/^[a-zA-Z]$/.test(part)) return part.toUpperCase() + if (/^[0-9]$/.test(part)) return part + if (/^[fF](?:[1-9]|1[0-9]|20)$/.test(part)) return `F${part.slice(1)}` + if (part in PUNCTUATION_CODES) return part + if (part === '+') return '+' + + const named = Object.keys(NAMED_KEYS).find(name => name.toLowerCase() === part.toLowerCase()) + if (named) return named + + // Accept the DOM spelling of arrows too, so a value copied out of devtools + // or an older config normalizes rather than erroring. + const domArrow = /^Arrow(Left|Right|Up|Down)$/i.exec(part) + if (domArrow) return capitalize(domArrow[1]) + + throw new KeybindingSyntaxError(`Unrecognized key "${part}" in "${raw}"`) +} + +const capitalize = (s: string) => s[0].toUpperCase() + s.slice(1).toLowerCase() + +/** Canonical string form. Round-trips: `format(parse(x))` is idempotent. */ +export function formatKeybinding(parsed: ParsedKeybinding): Keybinding { + return [...parsed.modifiers, parsed.key].join('+') +} + +/** Parse + reformat in one step, for coercing persisted or authored values. */ +export function normalizeKeybinding(input: string): Keybinding { + return formatKeybinding(parseKeybinding(input)) +} + +/** Non-throwing variant for coercion paths that must not break settings boot. */ +export function tryNormalizeKeybinding(input: unknown): Keybinding | null { + if (typeof input !== 'string') return null + try { + return normalizeKeybinding(input) + } catch { + return null + } +} + +/** + * The canonical binding a keyboard event represents, or null if it is only a + * modifier press. + * + * Reads `event.code` FIRST for letters, digits and punctuation — the whole + * macOS Option problem — and falls back to `event.key` for named keys, whose + * `key` value is already stable and whose codes are verbose and layout-tied. + */ +export function keybindingFromEvent(event: KeyboardEvent): Keybinding | null { + const modifiers: Modifier[] = [] + if (event.metaKey) modifiers.push('Cmd') + if (event.ctrlKey) modifiers.push('Ctrl') + if (event.altKey) modifiers.push('Alt') + if (event.shiftKey) modifiers.push('Shift') + + const key = keyTokenFromEvent(event) + if (key === null) return null + + return formatKeybinding({ modifiers: MODIFIER_ORDER.filter(m => modifiers.includes(m)), key }) +} + +function keyTokenFromEvent(event: KeyboardEvent): string | null { + const code = event.code + + const letter = /^Key([A-Z])$/.exec(code) + if (letter) return letter[1] + + // `Digit1` is the number row; `Numpad1` deliberately does NOT normalize to + // the same token. They are different physical keys, and collapsing them would + // make a numpad press fire a binding the user assigned to the number row. + const digit = /^Digit([0-9])$/.exec(code) + if (digit) return digit[1] + + const fn = /^F([1-9]|1[0-9]|20)$/.exec(code) + if (fn) return `F${fn[1]}` + + for (const [name, punctuationCode] of Object.entries(PUNCTUATION_CODES)) { + if (code === punctuationCode) return name + } + + for (const [name, domKey] of Object.entries(NAMED_KEYS)) { + if (event.key === domKey) return name + } + + // A bare modifier keydown. Not an error — the router simply has nothing to + // match yet, and the user is probably mid-chord. + if (['Meta', 'Control', 'Alt', 'Shift'].includes(event.key)) return null + + return null +} + +/** Does this event fire the given canonical binding? */ +export function eventMatchesKeybinding(event: KeyboardEvent, binding: Keybinding): boolean { + const eventBinding = keybindingFromEvent(event) + return eventBinding !== null && eventBinding === binding +} + +const DISPLAY_MODIFIERS: Record = { + Cmd: '⌘', Ctrl: '⌃', Alt: '⌥', Shift: '⇧', +} + +const DISPLAY_KEYS: Record = { + Left: '←', Right: '→', Up: '↑', Down: '↓', + Enter: '↩', Escape: '⎋', Space: '␣', Tab: '⇥', + Backspace: '⌫', Delete: '⌦', +} + +/** + * macOS-style display form, e.g. `⌘⇧P`. + * + * Presentation ONLY — never persisted, never compared. The audit's whole + * finding was that a display string had become a second, drifting source of + * truth; this function must stay a pure projection of the canonical form so + * that cannot happen again. + * + * Modifier symbols are concatenated without separators because that is the + * platform convention (⌘⇧P, not ⌘+⇧+P), but the KEY keeps a separator when it + * is a word ("⌥PageUp") so it stays readable. + */ +export function displayKeybinding(binding: Keybinding): string { + let parsed: ParsedKeybinding + try { + parsed = parseKeybinding(binding) + } catch { + // A malformed persisted value should render as itself rather than throwing + // inside a render path and blanking a Settings page. + return binding + } + const modifiers = parsed.modifiers.map(m => DISPLAY_MODIFIERS[m]).join('') + return `${modifiers}${DISPLAY_KEYS[parsed.key] ?? parsed.key}` +} diff --git a/src/renderer/src/features/command-keybindings/reservations.test.ts b/src/renderer/src/features/command-keybindings/reservations.test.ts new file mode 100644 index 00000000..af5548ae --- /dev/null +++ b/src/renderer/src/features/command-keybindings/reservations.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest' + +import { + buildDefaultKeybindings, + contextsOverlap, +} from '@renderer/features/command-keybindings/defaults' +import { + RESERVED_INTERACTIONS, + findBindingCollisions, + findBindingOwners, + listApprovedOverlaps, +} from '@renderer/features/command-keybindings/reservations' + +describe('context overlap matrix', () => { + it('treats grid and dispatch as mutually exclusive', () => { + // Two states of one layout switch. ⌥K focusing a grid pane and ⌥K moving + // the Dispatch selection is one gesture with two meanings, not a conflict. + expect(contextsOverlap('grid', 'dispatch')).toBe(false) + }) + + it('treats every other pair as potentially simultaneous', () => { + // No generic "different surface means safe" escape hatch: the editor + // overlays the workspace, the feed sits inside a pane, and global is by + // definition everywhere. + expect(contextsOverlap('global', 'editor')).toBe(true) + expect(contextsOverlap('global', 'grid')).toBe(true) + expect(contextsOverlap('editor', 'feed')).toBe(true) + expect(contextsOverlap('feed', 'grid')).toBe(true) + }) + + it('treats a context as overlapping itself', () => { + expect(contextsOverlap('grid', 'grid')).toBe(true) + }) +}) + +describe('shipped defaults', () => { + const defaults = buildDefaultKeybindings() + + it('parses every shipped binding', () => { + // buildDefaultKeybindings normalizes on the way out, so an authoring typo + // throws at module load here rather than producing a binding that silently + // never matches any key the user can press. + expect(() => buildDefaultKeybindings()).not.toThrow() + }) + + it('declares no duplicate binding within a single command', () => { + for (const entry of defaults) { + expect(new Set(entry.bindings).size).toBe(entry.bindings.length) + } + }) + + it('preserves the previously undisclosed aliases', () => { + // These ran before this change but were never advertised. Dropping them + // would be a silent regression for anyone with the muscle memory; declaring + // them is what lets Settings finally show and unbind them. + const byId = new Map(defaults.map(d => [d.commandId, d])) + expect(byId.get('close-pane')?.bindings).toContain('Alt+W') + expect(byId.get('nav-left')?.bindings).toContain('Alt+Left') + expect(byId.get('nav-up')?.bindings).toContain('Alt+Up') + }) + + it('fills the Global Editor metadata gap', () => { + const byId = new Map(defaults.map(d => [d.commandId, d])) + expect(byId.get('toggle-global-editor')?.bindings).toEqual(['Cmd+Shift+E']) + }) + + it('adds exactly one genuinely new chord', () => { + // ⌘, for Settings is the only default that did not already run. Everything + // else is preserved muscle memory, so upgrading users lose nothing. + const byId = new Map(defaults.map(d => [d.commandId, d])) + expect(byId.get('open-settings')?.bindings).toEqual(['Cmd+,']) + }) + + it('derives per-provider chords from the provider registry', () => { + // The one chord family that already had a single source of truth. Keeping + // it derived means adding a provider cannot leave metadata and behavior + // disagreeing. + const byId = new Map(defaults.map(d => [d.commandId, d])) + expect(byId.get('codex-vertical')?.bindings).toEqual(['Alt+C']) + expect(byId.get('codex-horizontal')?.bindings).toEqual(['Alt+Shift+C']) + // OpenCode declares no splitShortcutKey, so it ships unbound. Deliberate + // scarcity, not an oversight. + expect(byId.has('opencode-vertical')).toBe(false) + }) + + it('scopes editor and feed bindings to their own contexts', () => { + const byId = new Map(defaults.map(d => [d.commandId, d])) + // Cmd+S must not be global or it would fire outside the editor. + expect(byId.get('save-editor-file')?.context).toBe('editor') + // Bare End must not be global or it would collide with End in any composer. + expect(byId.get('jump-latest-message')?.context).toBe('feed') + }) +}) + +describe('findBindingCollisions', () => { + it('reports a clash between two overlapping-context owners', () => { + const collisions = findBindingCollisions({ + commandDefaults: [ + { commandId: 'a', bindings: ['Cmd+J'], context: 'global' }, + { commandId: 'b', bindings: ['Cmd+J'], context: 'editor' }, + ], + reserved: [], + }) + expect(collisions).toHaveLength(1) + expect(collisions[0].binding).toBe('Cmd+J') + expect(collisions[0].owners.map(o => o.id).sort()).toEqual(['a', 'b']) + }) + + it('allows the same chord in mutually exclusive contexts', () => { + // The grid/dispatch case, which is legal by the overlap matrix. + const collisions = findBindingCollisions({ + commandDefaults: [ + { commandId: 'grid-thing', bindings: ['Alt+K'], context: 'grid' }, + { commandId: 'dispatch-thing', bindings: ['Alt+K'], context: 'dispatch' }, + ], + reserved: [], + }) + expect(collisions).toEqual([]) + }) + + it('reports a clash with a reserved interaction', () => { + const collisions = findBindingCollisions({ + commandDefaults: [{ commandId: 'a', bindings: ['Escape'], context: 'global' }], + }) + expect(collisions.map(c => c.binding)).toContain('Escape') + }) + + it('reports a clash with the current dictation hotkey', () => { + const collisions = findBindingCollisions({ + commandDefaults: [{ commandId: 'a', bindings: ['Cmd+Alt+D'], context: 'global' }], + reserved: [], + dictationBinding: 'Cmd+Alt+D', + }) + expect(collisions).toHaveLength(1) + expect(collisions[0].owners.map(o => o.id)).toContain('Voice dictation hotkey') + }) + + it('finds no unapproved collision among the shipped defaults', () => { + // THE gate. If this fails, two shipped chords fight and the winner is + // decided by registration order — which is an accident, not a policy. + const collisions = findBindingCollisions({ + commandDefaults: buildDefaultKeybindings(), + reserved: RESERVED_INTERACTIONS, + }) + expect(collisions).toEqual([]) + }) + + it('suppresses only the exact approved owner pair', () => { + // Cmd+W is a genuine overlap (close-pane globally vs editor-native close + // file) that the app resolves by focus precedence, so it is approved. That + // approval must not generalize into "Cmd+W is free" — a third owner + // appearing later has to be reported. + const collisions = findBindingCollisions({ + commandDefaults: [ + { commandId: 'close-pane', bindings: ['Cmd+W'], context: 'global' }, + { commandId: 'interloper', bindings: ['Cmd+W'], context: 'global' }, + ], + reserved: RESERVED_INTERACTIONS.filter(r => r.owner === 'Editor-native close file'), + }) + expect(collisions.map(c => c.binding)).toEqual(['Cmd+W']) + }) +}) + +describe('approved overlaps', () => { + it('documents a reason for every approved overlap', () => { + // An approved overlap is a promise that some handler resolves the ambiguity + // before either owner runs. An entry with no stated reason is a promise + // nobody can check. + for (const approved of listApprovedOverlaps()) { + expect(approved.reason.length).toBeGreaterThan(40) + expect(approved.owners.length).toBeGreaterThanOrEqual(2) + } + }) +}) + +describe('findBindingOwners', () => { + const defaults = buildDefaultKeybindings() + + it('names the command that owns a chord', () => { + // "That shortcut is taken" is not actionable. "⌘T is used by new-tab" is. + const owners = findBindingOwners({ + binding: 'Cmd+T', + context: 'global', + commandDefaults: defaults, + }) + expect(owners.map(o => o.id)).toContain('new-tab') + }) + + it('names a reserved interaction', () => { + const owners = findBindingOwners({ + binding: 'Escape', + context: 'global', + commandDefaults: defaults, + }) + expect(owners.map(o => o.id)).toContain('Dismiss modal, picker, Spotlight, Reader, or fullscreen') + }) + + it('does not report a command as conflicting with itself', () => { + // Re-saving an unchanged binding must not be blocked. + const owners = findBindingOwners({ + binding: 'Cmd+T', + context: 'global', + commandDefaults: defaults, + excludeCommandId: 'new-tab', + }) + expect(owners.map(o => o.id)).not.toContain('new-tab') + }) + + it('reports nothing for a free chord', () => { + expect( + findBindingOwners({ + binding: 'Cmd+Shift+Alt+Y', + context: 'global', + commandDefaults: defaults, + }), + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/features/command-keybindings/reservations.ts b/src/renderer/src/features/command-keybindings/reservations.ts new file mode 100644 index 00000000..40408c9d --- /dev/null +++ b/src/renderer/src/features/command-keybindings/reservations.ts @@ -0,0 +1,271 @@ +import { contextsOverlap } from '@renderer/features/command-keybindings/defaults' +import type { BindingContext, CommandBindingDefault } from '@renderer/features/command-keybindings/defaults' +import type { Keybinding } from '@renderer/features/command-keybindings/normalize' + +// --------------------------------------------------------------------------- +// Reserved interactions and the collision engine. +// +// "Keybind control of all commands" does NOT mean turning every keyboard +// interaction into a command. Escape dismissal, modal and picker navigation, +// composer editing, numbered tab/Dispatch selection, split resizing, and +// editor-native tab behavior stay contextual interactions — they have no +// meaningful palette row, and inventing one for each would produce dozens of +// fake commands whose only purpose is to make a file shorter. +// +// But they still have to participate in collision checking, or a user could +// assign a command on top of Escape and silently break every modal in the app. +// So they are declared here as RESERVATIONS: things that own a chord without +// being commands. +// --------------------------------------------------------------------------- + +export type ReservedInteraction = { + /** Chords this interaction owns. */ + bindings: readonly Keybinding[] + context: BindingContext + /** Shown to the user when their capture collides with this. */ + owner: string +} + +/** + * Every non-command owner of a chord. + * + * WHY these are enumerated rather than discovered: they live in handlers spread + * across useKeybinds, Monaco, Electron's native roles, and individual modals. + * Nothing can derive them. An incomplete list here is the failure mode to + * watch — a chord that is really taken but absent from this table would be + * offered to the user as free, and the resulting conflict would be silent. + */ +export const RESERVED_INTERACTIONS: readonly ReservedInteraction[] = [ + { + // Indexed tab activation, and Dispatch's two-digit row grammar which + // reuses the same digits while Dispatch owns the layout. + bindings: ['Cmd+1', 'Cmd+2', 'Cmd+3', 'Cmd+4', 'Cmd+5', 'Cmd+6', 'Cmd+7', 'Cmd+8', 'Cmd+9'], + context: 'global', + owner: 'Numbered tab / Dispatch row selection', + }, + { + bindings: [ + 'Cmd+Alt+1', 'Cmd+Alt+2', 'Cmd+Alt+3', 'Cmd+Alt+4', 'Cmd+Alt+5', + 'Cmd+Alt+6', 'Cmd+Alt+7', 'Cmd+Alt+8', 'Cmd+Alt+9', + ], + context: 'global', + owner: 'Numbered tab activation (Dispatch-safe variant)', + }, + { + // Dispatch row/lane movement. Mutually exclusive with the grid navigation + // COMMANDS that share these chords — that disjointness is exactly what the + // overlap matrix encodes, and why this is legal rather than a conflict. + bindings: ['Alt+Up', 'Alt+Down', 'Alt+Left', 'Alt+Right', 'Alt+J', 'Alt+K', 'Alt+H', 'Alt+L'], + context: 'dispatch', + owner: 'Dispatch row and lane selection', + }, + { + bindings: ['Alt+=', 'Alt+-'], + context: 'global', + owner: 'Split resize', + }, + { + // Fn+Option+Arrow arrives as Option + Home/End/PageUp/PageDown, because + // macOS translates Fn before the event reaches the app. + bindings: ['Alt+Home', 'Alt+End', 'Alt+PageUp', 'Alt+PageDown'], + context: 'global', + owner: 'Directional split resize', + }, + { + bindings: ['Escape'], + context: 'global', + owner: 'Dismiss modal, picker, Spotlight, Reader, or fullscreen', + }, + { + // Standard Electron menu roles. Assigning a command on top of one of these + // does not merely conflict — the native menu wins, so the command would + // appear bound and simply never fire. + bindings: ['Cmd+Q', 'Cmd+H', 'Cmd+M', 'Cmd+R', 'Cmd+0', 'Cmd+Alt+I'], + context: 'global', + owner: 'Native application menu', + }, + { + bindings: ['Cmd+C', 'Cmd+V', 'Cmd+X', 'Cmd+A', 'Cmd+Z', 'Cmd+Shift+Z'], + context: 'global', + owner: 'Native editing commands', + }, + { + // Monaco owns Cmd+W for closing a file tab while editor chrome has focus. + // Note Cmd+W is ALSO a close-pane command binding in 'global' context — + // those contexts overlap, so this is a real conflict that the app resolves + // by precedence today. Declared so the checker reports it rather than + // pretending the chord is free. + bindings: ['Cmd+W'], + context: 'editor', + owner: 'Editor-native close file', + }, +] + +export type BindingOwnerRef = { + kind: 'command' | 'reserved' + /** Command id, or the reserved interaction's owner label. */ + id: string + context: BindingContext +} + +/** + * Overlaps that are DELIBERATE and already resolved by a documented precedence + * rule, rather than by registration order. + * + * The plan permits reuse only when the pair is "explicitly characterized", so + * this is that characterization — one entry, with the reason it is safe. It is + * intentionally awkward to add to: an approved overlap is a promise that some + * handler resolves the ambiguity before either owner runs, and that promise has + * to be true. + * + * WHY the entries are keyed by owner PAIR and not just by chord: approving + * "⌘W is fine" would silently bless a third owner appearing later. Approving + * "⌘W between these two specific owners" does not. + */ +const APPROVED_OVERLAPS: ReadonlyArray<{ + binding: Keybinding + owners: readonly string[] + reason: string +}> = [ + { + binding: 'Cmd+W', + owners: ['close-pane', 'Editor-native close file'], + // Resolved by focus, deterministically and before either owner runs: + // useKeybinds returns early for cmd+s/w/[/] whenever the event target is + // inside [data-global-editor-input-owner], so the workspace's close-pane + // handler cannot fire while editor chrome has focus. EditorWorkbench then + // consumes it in bubble phase (and Monaco does inside the text area). + // + // This is a real product decision, not an accident: an empty workbench must + // still swallow ⌘W, because otherwise Electron's native menu receives the + // unhandled chord and closes the whole window. + reason: + 'Editor chrome consumes Cmd+W before the workspace router sees it ' + + '(useKeybinds bails out for editor-owned targets), so exactly one owner ' + + 'is ever live for a given focus.', + }, +] + +function isApprovedOverlap(binding: Keybinding, owners: readonly BindingOwnerRef[]): boolean { + const ids = owners.map(o => o.id).sort() + return APPROVED_OVERLAPS.some( + approved => + approved.binding === binding && + approved.owners.length === ids.length && + [...approved.owners].sort().every((id, index) => id === ids[index]), + ) +} + +/** The approved overlaps, exposed so Settings and the repository script can + * explain why a chord that looks doubly-claimed is intentional. */ +export function listApprovedOverlaps(): typeof APPROVED_OVERLAPS { + return APPROVED_OVERLAPS +} + +export type BindingCollision = { + binding: Keybinding + owners: readonly BindingOwnerRef[] +} + +/** + * Find every chord claimed by two owners whose contexts can be live at once. + * + * Registration order, last-write-wins and silent precedence are all forbidden + * by the plan: whichever owner "wins" today is an accident of file order, and + * an accident is not a policy. This returns the clash so a human decides. + * + * `dictationBinding` is threaded in rather than imported because the static + * repository script checks the SHIPPED default while Settings must check the + * CURRENT PROFILE's value — same function, two different inputs. Importing the + * settings store here would make the script impossible to run. + */ +export function findBindingCollisions(options: { + commandDefaults: readonly CommandBindingDefault[] + reserved?: readonly ReservedInteraction[] + dictationBinding?: Keybinding | null +}): BindingCollision[] { + const reserved = options.reserved ?? RESERVED_INTERACTIONS + const claims = new Map() + + const claim = (binding: Keybinding, owner: BindingOwnerRef) => { + const existing = claims.get(binding) + if (existing) existing.push(owner) + else claims.set(binding, [owner]) + } + + for (const entry of options.commandDefaults) { + for (const binding of entry.bindings) { + claim(binding, { kind: 'command', id: entry.commandId, context: entry.context }) + } + } + for (const entry of reserved) { + for (const binding of entry.bindings) { + claim(binding, { kind: 'reserved', id: entry.owner, context: entry.context }) + } + } + if (options.dictationBinding) { + claim(options.dictationBinding, { + kind: 'reserved', + id: 'Voice dictation hotkey', + context: 'global', + }) + } + + const collisions: BindingCollision[] = [] + for (const [binding, owners] of claims) { + if (owners.length < 2) continue + // Only report pairs whose contexts can actually be live simultaneously. + // ⌥K focusing a grid pane and ⌥K moving the Dispatch selection is one + // gesture with two meanings, not a bug. + const overlapping = owners.filter((owner, index) => + owners.some((other, otherIndex) => + index !== otherIndex && contextsOverlap(owner.context, other.context), + ), + ) + if (overlapping.length < 2) continue + // A deliberate, documented overlap is not a defect. Anything else is. + if (isApprovedOverlap(binding, overlapping)) continue + collisions.push({ binding, owners: overlapping }) + } + + return collisions.sort((a, b) => a.binding.localeCompare(b.binding)) +} + +/** + * Who already owns this chord, for the Settings capture UI. + * + * Returns every overlapping owner so the error can NAME them. "That shortcut is + * taken" is not actionable; "⌘S is used by Save Editor File" is. + */ +export function findBindingOwners(options: { + binding: Keybinding + context: BindingContext + commandDefaults: readonly CommandBindingDefault[] + reserved?: readonly ReservedInteraction[] + dictationBinding?: Keybinding | null + /** Ignore this command's own claims, so re-saving an unchanged binding is not + * reported as conflicting with itself. */ + excludeCommandId?: string +}): BindingOwnerRef[] { + const reserved = options.reserved ?? RESERVED_INTERACTIONS + const owners: BindingOwnerRef[] = [] + + for (const entry of options.commandDefaults) { + if (entry.commandId === options.excludeCommandId) continue + if (!entry.bindings.includes(options.binding)) continue + if (!contextsOverlap(entry.context, options.context)) continue + owners.push({ kind: 'command', id: entry.commandId, context: entry.context }) + } + + for (const entry of reserved) { + if (!entry.bindings.includes(options.binding)) continue + if (!contextsOverlap(entry.context, options.context)) continue + owners.push({ kind: 'reserved', id: entry.owner, context: entry.context }) + } + + if (options.dictationBinding === options.binding) { + owners.push({ kind: 'reserved', id: 'Voice dictation hotkey', context: 'global' }) + } + + return owners +} diff --git a/src/renderer/src/features/command-keybindings/resolve.test.ts b/src/renderer/src/features/command-keybindings/resolve.test.ts new file mode 100644 index 00000000..12c8eb0f --- /dev/null +++ b/src/renderer/src/features/command-keybindings/resolve.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' + +import { + coerceCommandKeybindingOverrides, + effectiveBindingsFor, + resetCommandKeybindings, + resolveEffectiveKeybindings, + setCommandKeybindings, +} from '@renderer/features/command-keybindings/resolve' +import { coerceSettings } from '@renderer/app-state/settings/persistence' +import type { CommandBindingDefault } from '@renderer/features/command-keybindings/defaults' + +const DEFAULTS: CommandBindingDefault[] = [ + { commandId: 'a', bindings: ['Cmd+A'], context: 'global' }, + { commandId: 'b', bindings: ['Cmd+B', 'Alt+B'], context: 'grid' }, +] + +describe('sparse override semantics', () => { + // The three states are distinct and all load-bearing. These four tests are + // the contract that lets a future release improve an untouched default + // without resurrecting a chord the user deliberately removed. + + it('inherits the shipped default when absent', () => { + expect(effectiveBindingsFor('a', {}, DEFAULTS)).toEqual(['Cmd+A']) + }) + + it('treats an empty array as an explicit unbind', () => { + // NOT the same as absent. The user removed this on purpose, so a release + // that later hands the command a new default must not override that. + expect(effectiveBindingsFor('a', { a: [] }, DEFAULTS)).toEqual([]) + }) + + it('replaces the defaults entirely with a non-empty override', () => { + // Replace, not merge: a user who sets one binding does not silently keep + // the shipped one alongside it. + expect(effectiveBindingsFor('b', { b: ['Cmd+Z'] }, DEFAULTS)).toEqual(['Cmd+Z']) + }) + + it('keeps absent and empty distinguishable through resolution', () => { + const resolved = resolveEffectiveKeybindings({ a: [] }, DEFAULTS) + const a = resolved.find(e => e.commandId === 'a') + const b = resolved.find(e => e.commandId === 'b') + expect(a?.customized).toBe(true) + expect(b?.customized).toBe(false) + }) + + it('supports binding a command that ships with no default', () => { + const resolved = resolveEffectiveKeybindings({ 'never-shipped': ['Cmd+Q'] }, DEFAULTS) + const entry = resolved.find(e => e.commandId === 'never-shipped') + expect(entry?.bindings).toEqual(['Cmd+Q']) + // 'global' is the strictest context for collision purposes — it overlaps + // everything, so the checker flags any clash rather than missing one. + expect(entry?.context).toBe('global') + }) +}) + +describe('setCommandKeybindings', () => { + it('stores a deviation from the shipped default', () => { + expect(setCommandKeybindings({}, 'a', ['Cmd+Z'], DEFAULTS)).toEqual({ a: ['Cmd+Z'] }) + }) + + it('stores an explicit unbind', () => { + expect(setCommandKeybindings({}, 'a', [], DEFAULTS)).toEqual({ a: [] }) + }) + + it('removes the entry when the value equals the shipped default', () => { + // Otherwise a user who toggled a binding off and back on would be pinned to + // this release's default forever — the same bug the absent/empty + // distinction exists to prevent. + expect(setCommandKeybindings({ a: [] }, 'a', ['Cmd+A'], DEFAULTS)).toEqual({}) + }) + + it('treats binding order as significant', () => { + // Two bindings for one command are an ordered display list, so a reorder is + // a real edit rather than a no-op. + const next = setCommandKeybindings({}, 'b', ['Alt+B', 'Cmd+B'], DEFAULTS) + expect(next.b).toEqual(['Alt+B', 'Cmd+B']) + }) + + it('does not mutate the input map', () => { + const before = { a: ['Cmd+Z'] } + setCommandKeybindings(before, 'a', [], DEFAULTS) + expect(before).toEqual({ a: ['Cmd+Z'] }) + }) +}) + +describe('resetCommandKeybindings', () => { + it('returns the command to inheriting', () => { + // Reset means "go back to inheriting", not "pin me to whatever the default + // happens to be this week". + const next = resetCommandKeybindings({ a: ['Cmd+Z'] }, 'a') + expect(next).toEqual({}) + expect(effectiveBindingsFor('a', next, DEFAULTS)).toEqual(['Cmd+A']) + }) +}) + +describe('coerceCommandKeybindingOverrides', () => { + it('normalizes stored bindings', () => { + expect(coerceCommandKeybindingOverrides({ a: ['shift+cmd+p'] })).toEqual({ + a: ['Cmd+Shift+P'], + }) + }) + + it('drops a malformed binding but keeps the parseable siblings', () => { + // A user with three bindings and one corrupt value keeps the two that + // still parse, rather than losing the command entirely. + expect(coerceCommandKeybindingOverrides({ a: ['cmd+p', 'Cmd+K R', 'alt+d'] })).toEqual({ + a: ['Cmd+P', 'Alt+D'], + }) + }) + + it('deduplicates bindings that normalize to the same chord', () => { + expect(coerceCommandKeybindingOverrides({ a: ['cmd+p', 'Cmd+P', '⌘+p'] })).toEqual({ + a: ['Cmd+P'], + }) + }) + + it('collapses an all-malformed array to an explicit unbind', () => { + // Conservative reading: we know the user edited this command, so silently + // restoring the shipped chord could re-add a binding they had removed. + expect(coerceCommandKeybindingOverrides({ a: ['nonsense'] })).toEqual({ a: [] }) + }) + + it('preserves unknown command ids', () => { + // May belong to an extension that is temporarily uninstalled, or a command + // a downgrade removed. Pruning opportunistically discards a deliberate + // binding the moment someone runs an older build. + expect(coerceCommandKeybindingOverrides({ 'ext.thing': ['cmd+9'] })).toEqual({ + 'ext.thing': ['Cmd+9'], + }) + }) + + it.each([null, undefined, 42, 'x', []])('returns {} for the malformed root %p', value => { + expect(coerceCommandKeybindingOverrides(value)).toEqual({}) + }) + + it('ignores a non-array entry', () => { + expect(coerceCommandKeybindingOverrides({ a: 'Cmd+P' })).toEqual({}) + }) +}) + +describe('settings persistence', () => { + it('defaults to an empty map', () => { + // Seeding with today's defaults would pin every command to this release's + // chords and make future default improvements invisible. + expect(coerceSettings({}).commandKeybindingOverrides).toEqual({}) + }) + + it('round-trips and normalizes a persisted override', () => { + const settings = coerceSettings({ commandKeybindingOverrides: { 'new-tab': ['cmd+t'] } }) + expect(settings.commandKeybindingOverrides).toEqual({ 'new-tab': ['Cmd+T'] }) + }) + + it('is idempotent across repeated coercion', () => { + // Coercion runs on every launch through `merge`, not only on version + // change, so drift on the second pass means drift on every boot. + const once = coerceSettings({ commandKeybindingOverrides: { a: ['shift+cmd+p'] } }) + const twice = coerceSettings(once) + expect(twice.commandKeybindingOverrides).toEqual(once.commandKeybindingOverrides) + }) + + it('survives a store written before the field existed', () => { + expect(coerceSettings({ showStatusMode: true }).commandKeybindingOverrides).toEqual({}) + }) +}) diff --git a/src/renderer/src/features/command-keybindings/resolve.ts b/src/renderer/src/features/command-keybindings/resolve.ts new file mode 100644 index 00000000..d87e23bd --- /dev/null +++ b/src/renderer/src/features/command-keybindings/resolve.ts @@ -0,0 +1,159 @@ +import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' +import { tryNormalizeKeybinding } from '@renderer/features/command-keybindings/normalize' +import type { CommandBindingDefault } from '@renderer/features/command-keybindings/defaults' +import type { Keybinding } from '@renderer/features/command-keybindings/normalize' + +/** + * Persisted per-command binding overrides. SPARSE by design. + * + * The three states are distinct and all load-bearing: + * + * absent → inherit whatever this release ships + * `[]` → EXPLICITLY UNBOUND; ship nothing, now or later + * non-empty → replaces the shipped defaults entirely + * + * WHY "absent" and "empty" must not collapse into one thing: they answer + * different questions. Absent means "I never touched this", so a future release + * that improves the default is free to change it. Empty means "I deliberately + * removed this chord", and a release that then handed the command a new default + * would be overriding a decision the user made on purpose. Storing today's + * default on first edit — the obvious shortcut — destroys that distinction for + * every command the user ever opens. + * + * WHY Reset removes the entry rather than writing the current default into it: + * same reason, from the other direction. Reset means "go back to inheriting", + * not "pin me to whatever the default happens to be this week". + */ +export type CommandKeybindingOverrides = Record + +/** + * Coerce a persisted override map. + * + * Unknown command ids are PRESERVED, not pruned. An id that names nothing today + * may belong to an extension that is temporarily uninstalled, a provider whose + * commands are not generated in this build, or a command a downgrade removed. + * Deleting those opportunistically would silently discard a user's deliberate + * binding the moment they ran an older build. Only explicitly retired + * first-party ids get removed, and only through a named migration. + */ +export function coerceCommandKeybindingOverrides(value: unknown): CommandKeybindingOverrides { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + + const result: CommandKeybindingOverrides = {} + for (const [commandId, entry] of Object.entries(value as Record)) { + if (!Array.isArray(entry)) continue + + const bindings: Keybinding[] = [] + for (const candidate of entry) { + const normalized = tryNormalizeKeybinding(candidate) + // Drop malformed entries rather than the whole command: a user with three + // bindings and one corrupt value keeps the two that still parse. + if (!normalized) continue + if (!bindings.includes(normalized)) bindings.push(normalized) + } + + // An array that contained ONLY malformed values becomes `[]`, which means + // "explicitly unbound". That is the conservative reading: we know the user + // edited this command, and silently restoring the shipped chord would + // re-add a binding they had removed. + result[commandId] = bindings + } + return result +} + +export type EffectiveBinding = { + commandId: string + bindings: readonly Keybinding[] + context: CommandBindingDefault['context'] + /** True when the user has an override for this command. */ + customized: boolean +} + +/** + * The bindings that will ACTUALLY run, per command. + * + * This is the single source consumed by the palette's display, the Settings + * rows, search metadata, accessibility text, and the runtime router. The + * audit's core keybinding finding was that display and behavior were two + * independent facts that could disagree; funnelling all five consumers through + * one function is what makes "the shortcut shown is the shortcut that runs" + * structurally true rather than a thing someone has to remember. + */ +export function resolveEffectiveKeybindings( + overrides: CommandKeybindingOverrides, + defaults: readonly CommandBindingDefault[] = buildDefaultKeybindings(), +): EffectiveBinding[] { + const byCommand = new Map() + + for (const entry of defaults) { + const override = overrides[entry.commandId] + byCommand.set(entry.commandId, { + commandId: entry.commandId, + bindings: override ?? entry.bindings, + context: entry.context, + customized: override !== undefined, + }) + } + + // A user can bind a command that ships with NO default — most of the catalog. + // Those have no entry to start from, so they are added here. Context defaults + // to 'global' because a command with no declared default also has no declared + // context, and global is the strictest choice for collision purposes: it + // overlaps everything, so the checker will flag any clash rather than miss one. + for (const [commandId, bindings] of Object.entries(overrides)) { + if (byCommand.has(commandId)) continue + byCommand.set(commandId, { commandId, bindings, context: 'global', customized: true }) + } + + return [...byCommand.values()] +} + +/** Effective bindings for one command, for a palette row or Settings row. */ +export function effectiveBindingsFor( + commandId: string, + overrides: CommandKeybindingOverrides, + defaults?: readonly CommandBindingDefault[], +): readonly Keybinding[] { + const found = resolveEffectiveKeybindings(overrides, defaults).find( + entry => entry.commandId === commandId, + ) + return found?.bindings ?? [] +} + +/** + * Apply a Settings edit, keeping the map sparse. + * + * Writing an override that equals the shipped default REMOVES the entry rather + * than storing a redundant copy — otherwise a user who toggled a binding off + * and back on would be silently pinned to this release's default forever, + * which is the same bug the absent/empty distinction exists to prevent. + */ +export function setCommandKeybindings( + overrides: CommandKeybindingOverrides, + commandId: string, + bindings: readonly Keybinding[], + defaults: readonly CommandBindingDefault[] = buildDefaultKeybindings(), +): CommandKeybindingOverrides { + const next = { ...overrides } + const shipped = defaults.find(entry => entry.commandId === commandId)?.bindings ?? [] + + if (sameBindings(shipped, bindings)) delete next[commandId] + else next[commandId] = [...bindings] + + return next +} + +/** Reset one command to inheriting the shipped default. */ +export function resetCommandKeybindings( + overrides: CommandKeybindingOverrides, + commandId: string, +): CommandKeybindingOverrides { + const next = { ...overrides } + delete next[commandId] + return next +} + +/** Order matters: two bindings for one command are an ordered display list. */ +function sameBindings(a: readonly Keybinding[], b: readonly Keybinding[]): boolean { + return a.length === b.length && a.every((binding, index) => binding === b[index]) +} From d566caa1626fea523ac1f553e9f93e98a5113274 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 16:58:53 +0200 Subject: [PATCH 10/33] feat(commands): retire five preference commands, add open-command-palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the plan's headline arithmetic: 102 - 5 + 1 = 98. RETIRED, with their Settings controls untouched: toggle-status-mode, toggle-worktree-badges, usage.toggle-header, usage.cycle-header-level, dangerous-agents The placement rule is behavioral, not aesthetic: a persisted preference with no meaningful momentary scope has one product home. There is no "just for this session" Status Mode, so a command duplicating it gives the same setting two owners and two places to look when it is wrong. Two are worth recording individually. usage.cycle-header-level was retired rather than repaired. It had three defects at once: it rendered the raw lowercase enum as its badge ("providers"), it cycled forward through four states with no way back, and it IMPLICITLY ENABLED the header as a side effect of changing the detail level — so a user who had deliberately hidden the header could not adjust its detail without silently un-hiding it. That is one command doing two things, one undisclosed. dangerous-agents is not a duplicated preference but a SAFETY POSTURE whose enablement reloads every live agent. A palette row flipping it with one keystroke and no preview is the wrong affordance, and its copy even claimed existing agents were unaffected while the runner reloaded them. Settings owns it, where a confirmation can show the exact affected set. No value migration is needed because every canonical field survives — asserted directly, since if one stopped surviving coercion this would be a data-loss change rather than an information-architecture one. Stale per-command entries ARE pruned, through an explicit retired-id list rather than "delete any id not in the catalog". An id naming nothing today may belong to an uninstalled extension, a provider this build does not generate, or a command a downgrade removed; pruning by absence would discard a user's deliberate settings the first time they ran an older build. Both the visibility and keybinding override maps get the same policy, and a test proves an unknown extension id survives. ADDED: open-command-palette, the single approved addition. Cmd+Shift+P ran through a hard-coded onCommandPalette?.() callback and named no command, which is exactly why it could not be rebound, listed in Settings, or collision-checked. It is a full catalog member but never renders as a palette row — structurally, not via a visibility tier, so no override or reveal-all can put "open the palette" inside the palette. The Phase 0 assertions written inverted on purpose ("still contains", "does not yet contain") are flipped here rather than deleted, so the retirement is visible in the test diff and not only in the source diff. Verified: tsc -b clean, check:keybindings passes, 237 files / 1520 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app-state/settings/persistence.ts | 44 ++++++++- .../features/command-palette/catalog.test.ts | 80 ++++++++--------- .../src/features/command-palette/catalog.ts | 5 ++ .../commands/paletteCommands.ts | 48 ++++++++++ .../keybindingBaseline.test.ts | 9 +- .../src/features/command-palette/registry.ts | 2 + .../features/command-palette/taxonomy.test.ts | 90 +++++++++++++++++++ .../src/features/command-palette/types.ts | 3 + .../command-palette/ui/CommandPalette.tsx | 6 ++ .../settings/commands/dangerousCommands.ts | 34 ------- .../settings/commands/settingsCommands.ts | 26 +++--- .../features/usage/commands/usageCommands.ts | 42 +++------ .../workspace/commands/layoutCommands.ts | 18 ++-- 13 files changed, 273 insertions(+), 134 deletions(-) create mode 100644 src/renderer/src/features/command-palette/commands/paletteCommands.ts delete mode 100644 src/renderer/src/features/settings/commands/dangerousCommands.ts diff --git a/src/renderer/src/app-state/settings/persistence.ts b/src/renderer/src/app-state/settings/persistence.ts index 12e92afc..1f9e327b 100644 --- a/src/renderer/src/app-state/settings/persistence.ts +++ b/src/renderer/src/app-state/settings/persistence.ts @@ -135,8 +135,8 @@ export function coerceSettings(value: unknown): Settings { // rows help almost nobody, and silently revealing them on upgrade would // be a regression nobody asked for. navigationCommandsEnabled: parsed.navigationCommandsEnabled === true, - commandKeybindingOverrides: coerceCommandKeybindingOverrides( - parsed.commandKeybindingOverrides, + commandKeybindingOverrides: pruneRetiredKeybindingOverrides( + coerceCommandKeybindingOverrides(parsed.commandKeybindingOverrides), ), } } @@ -205,11 +205,51 @@ function resolvePersistedMode( return DEFAULT_SETTINGS.mode } +/** + * First-party command ids this release deliberately removed. + * + * Their persisted preference entries are pruned so a stale visibility override + * or keybinding cannot linger forever against an id nothing will ever resolve. + * + * WHY an explicit list instead of "delete any id not in the catalog": an id + * that names nothing TODAY is not necessarily dead. It may belong to an + * extension that is temporarily uninstalled, a provider whose commands this + * build does not generate, or a command a downgrade removed. Pruning by + * absence would silently discard a user's deliberate settings the first time + * they ran an older build or disabled an extension. Only ids we KNOW are gone + * for good are removed, and adding one is a deliberate act recorded here. + * + * Retired in the command-governance change: five durable preferences that had + * both a command and a Settings control. The Settings controls (and their + * canonical fields) are untouched, so no value migration is needed — only the + * now-meaningless per-command preference entries go. + */ +const RETIRED_BUILT_IN_COMMAND_IDS: ReadonlySet = new Set([ + 'toggle-status-mode', + 'toggle-worktree-badges', + 'usage.toggle-header', + 'usage.cycle-header-level', + 'dangerous-agents', +]) + function coerceCommandVisibilityOverrides(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return {} const result: Record = {} for (const [key, entry] of Object.entries(value as Record)) { + if (RETIRED_BUILT_IN_COMMAND_IDS.has(key)) continue if (typeof entry === 'boolean') result[key] = entry } return result } + +/** Same retired-id policy, applied to persisted keybinding overrides. */ +function pruneRetiredKeybindingOverrides( + overrides: Record, +): Record { + const result: Record = {} + for (const [commandId, bindings] of Object.entries(overrides)) { + if (RETIRED_BUILT_IN_COMMAND_IDS.has(commandId)) continue + result[commandId] = bindings + } + return result +} diff --git a/src/renderer/src/features/command-palette/catalog.test.ts b/src/renderer/src/features/command-palette/catalog.test.ts index b8150304..fe4ef5d4 100644 --- a/src/renderer/src/features/command-palette/catalog.test.ts +++ b/src/renderer/src/features/command-palette/catalog.test.ts @@ -9,12 +9,12 @@ 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. // -// Everything here describes what the catalog is TODAY, before any governance -// behavior changes. That is the whole point: the later phases retire five -// commands, add one, reclassify tiers, and rewire how ids resolve. Without a -// pinned before-state, "did we change exactly what we meant to change" is -// unanswerable, and the plan's central count (102 → 98) is an assertion nobody -// can check. +// This file pinned the exact 102-id before-state, and now pins the 98-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 +// headline count an assertion anyone can check against running code instead of +// prose. // // WHY a literal ordered snapshot rather than just a count: registration order // is the palette's empty-query browse order, and users navigate that list by @@ -26,7 +26,7 @@ import type { CommandDef } from '@renderer/features/command-palette/types' // never as a follow-up "fix the test" commit. // --------------------------------------------------------------------------- -/** The exact ordered catalog as of the governance baseline (main @ 670f4c2d). */ +/** The exact ordered catalog after governance (was 102 at main @ 670f4c2d). */ const BASELINE_COMMAND_IDS: readonly string[] = [ // tabCommands (6) 'new-tab', @@ -64,14 +64,13 @@ const BASELINE_COMMAND_IDS: readonly string[] = [ 'toggle-tail-all', 'jump-latest-message', 'copy-last-assistant', - // layoutCommands (9) + // layoutCommands (8, was 9: toggle-status-mode retired) 'dispatch-mode', 'global-dispatch', 'tiled-dispatch', 'normalize-layout', 'hard-normalize-layout', 'rotate-layout', - 'toggle-status-mode', 'toggle-performance-panel', 'toggle-caffeinate', // globalEditorCommands (10) @@ -121,12 +120,10 @@ const BASELINE_COMMAND_IDS: readonly string[] = [ 'toggle-spotlight', 'toggle-reader-mode', 'tiled-tabs', - // settingsCommands + dangerousCommands (5) + // settingsCommands (3, was 5: worktree-badges + dangerous-agents retired) 'open-settings', 'toggle-aggressive-debug-persistence', 'toggle-worktrees-bar', - 'toggle-worktree-badges', - 'dangerous-agents', // copy-assistant / copy-code-block (2) 'copy-assistant-message', 'copy-code-block', @@ -138,15 +135,16 @@ const BASELINE_COMMAND_IDS: readonly string[] = [ // agent status / remote (2) 'show-agent-status', 'toggle-remote-panel', - // usage (3) + // usage (1, was 3: both header preferences retired) 'usage.open', - 'usage.toggle-header', - 'usage.cycle-header-level', + // paletteCommands (1) — the single approved addition + 'open-command-palette', ] -/** The five ids the plan retires in Phase 4, kept here so the retirement is a - * one-line diff against a named list rather than five scattered edits. */ -const PLANNED_RETIREMENTS: readonly string[] = [ +/** The five durable preferences retired from Commands to Settings. Their + * canonical settings fields are untouched, so no value migration is needed — + * only the now-meaningless per-command preference entries are pruned. */ +const RETIRED_COMMAND_IDS: readonly string[] = [ 'toggle-status-mode', 'toggle-worktree-badges', 'usage.toggle-header', @@ -170,16 +168,16 @@ 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 102 baseline commands in registration order', () => { + it('contains exactly the 98 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 102 commands', () => { + 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(102) + expect(builtInCommandCatalog).toHaveLength(98) }) it('reports no structural defects', () => { @@ -210,10 +208,10 @@ describe('generated per-provider split commands', () => { }) it('accounts for the difference between literal and total command count', () => { - // 102 total - 4 generated = 98 literal `id:` fields in the command modules. - // This is the exact reconciliation the plan performs, asserted rather than - // asserted-in-prose. - expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(98) + // 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) }) it('emits both directions for every non-default provider', () => { @@ -286,23 +284,22 @@ describe('native menu contract', () => { }) }) -describe('planned governance targets still exist in the baseline', () => { - // These assertions exist so the later phases have something to invert. If a - // retirement lands, the corresponding line here flips to `not.toContain` in - // the same commit — making the retirement visible in the test diff instead of - // only in the implementation diff. +describe('governance targets', () => { + // These assertions were written inverted at the baseline ("still contains", + // "does not yet contain") so the retirement would be visible in the TEST + // diff, not only in the implementation diff. This is that flip. - it('still contains all five commands planned for retirement', () => { + it('no longer contains any of the five retired commands', () => { const catalogIds = new Set(ids()) - for (const id of PLANNED_RETIREMENTS) { - expect(catalogIds.has(id)).toBe(true) + for (const id of RETIRED_COMMAND_IDS) { + expect(catalogIds.has(id)).toBe(false) } }) - it('does not yet contain the planned open-command-palette command', () => { - // Cmd+Shift+P is hard-coded in useKeybinds today and is not a command at - // all, which is why its binding cannot be configured. Phase 4 adds it. - expect(ids()).not.toContain('open-command-palette') + it('contains the one approved addition', () => { + // Cmd+Shift+P was hard-coded in useKeybinds and named no command at all, + // which is exactly why it could not be rebound or collision-checked. + expect(ids()).toContain('open-command-palette') }) it('contains every member of the closed Navigation Commands group', () => { @@ -312,10 +309,11 @@ describe('planned governance targets still exist in the baseline', () => { } }) - it('projects the post-Phase-4 catalog size as 98', () => { - // 102 - 5 retirements + 1 addition = 98. Encoded so the plan's headline - // arithmetic is checked against the real catalog rather than trusted. - expect(builtInCommandCatalog.length - PLANNED_RETIREMENTS.length + 1).toBe(98) + 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) }) }) diff --git a/src/renderer/src/features/command-palette/catalog.ts b/src/renderer/src/features/command-palette/catalog.ts index 6d11b96f..1d713065 100644 --- a/src/renderer/src/features/command-palette/catalog.ts +++ b/src/renderer/src/features/command-palette/catalog.ts @@ -15,6 +15,7 @@ import { agentStatusCommands } from '@renderer/features/agent-status/commands/ag import { dispatchColorFlagCommands } from '@renderer/features/workspace/commands/dispatchColorFlagCommands' import { remoteCommands } from '@renderer/features/remote/commands/remoteCommands' import { usageCommands } from '@renderer/features/usage/commands/usageCommands' +import { paletteCommands } from '@renderer/features/command-palette/commands/paletteCommands' import type { CommandDef } from '@renderer/features/command-palette/types' /** @@ -78,6 +79,10 @@ export const builtInCommandCatalog: readonly CommandDef[] = Object.freeze([ ...agentStatusCommands, ...remoteCommands, ...usageCommands, + // Last: it is never rendered as a palette row (see + // PALETTE_SELF_EXCLUDED_COMMAND_IDS), so its position cannot shift anything + // the user browses. Appending also keeps every existing row's index stable. + ...paletteCommands, ]) /** diff --git a/src/renderer/src/features/command-palette/commands/paletteCommands.ts b/src/renderer/src/features/command-palette/commands/paletteCommands.ts new file mode 100644 index 00000000..22b60113 --- /dev/null +++ b/src/renderer/src/features/command-palette/commands/paletteCommands.ts @@ -0,0 +1,48 @@ +import type { CommandDef } from '@renderer/features/command-palette/types' + +/** + * The command palette's own command. + * + * WHY this exists, when opening the palette obviously worked before: it did + * not work as a COMMAND. `useKeybinds` called a hard-coded `onCommandPalette?.()` + * callback for ⌘⇧P, and no catalog entry named that behavior. The consequence + * is the one the governance plan cares about — a chord with no command id + * cannot be rebound, cannot appear in a keybinding Settings list, and cannot be + * checked for collisions, because there is nothing to attach any of that to. + * + * Giving it an owner is the single approved catalog addition in this plan: + * 102 baseline − 5 retired preferences + 1 = 98. + * + * WHY it is structurally omitted from its own picker results (see + * `catalog.ts` consumers): a row that closes the list it lives in is a + * confusing no-op. The user is already looking at the palette; offering to + * open it is at best noise and at worst a way to toggle the surface shut by + * accident. It stays fully present in the CATALOG — so Settings can bind it, + * the checker can reserve its chord, and the native menu could dispatch it — + * and is filtered only at the point of rendering palette rows. + */ +export const paletteCommands: CommandDef[] = [ + { + id: 'open-command-palette', + category: 'navigate', + surface: 'app', + title: 'Command Palette', + description: + '**What it does:** Opens the **command palette**.\n\n**Use when:** You want to search every available command.\n\n**Notes:** This row is hidden inside the palette itself — you are already here.', + keywords: ['palette', 'command', 'search', 'run'], + run: ({ ui }) => ui.openCommandPalette(), + }, +] + +/** + * Commands that never appear as palette ROWS, even though they are ordinary + * catalog members everywhere else. + * + * Kept as an explicit exported set rather than a `pickerVisibility` tier: the + * hidden tiers are a USER preference (revealable by override or the reveal-all + * escape hatch), whereas this is a structural property of the surface. No + * override should be able to put "open the palette" inside the palette. + */ +export const PALETTE_SELF_EXCLUDED_COMMAND_IDS: ReadonlySet = new Set([ + 'open-command-palette', +]) diff --git a/src/renderer/src/features/command-palette/keybindingBaseline.test.ts b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts index 0c64b0c6..d185de5e 100644 --- a/src/renderer/src/features/command-palette/keybindingBaseline.test.ts +++ b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts @@ -336,9 +336,14 @@ describe('recorded authority drift', () => { expect(editorOwned.map(e => e.commandId)).toEqual(['save-editor-file']) }) - it('records the palette chord as having no command owner', () => { + it('records the palette chord that used to have no command owner', () => { + // At the baseline ⌘⇧P ran through a hard-coded `onCommandPalette?.()` + // callback and named no command, which is precisely why it could not be + // rebound, listed in Settings, or collision-checked. expect(UNOWNED_COMMAND_CHORDS.map(c => c.chord)).toEqual(['⌘⇧P']) - expect(builtInCommandCatalog.map(c => c.id)).not.toContain('open-command-palette') + // It has an owner now. The assertion is inverted rather than deleted so the + // before-state stays legible: this is the defect, and this is the fix. + expect(builtInCommandCatalog.map(c => c.id)).toContain('open-command-palette') }) it('proves declared metadata is not sufficient to know what a key does', () => { diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index 18ce3266..1cf5329b 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -1,4 +1,5 @@ import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { PALETTE_SELF_EXCLUDED_COMMAND_IDS } from '@renderer/features/command-palette/commands/paletteCommands' import { declaredTier, isVisibleInPicker } from '@renderer/features/command-palette/pickerVisibility' import { commandAllowedByRenderedViewPolicy } from '@renderer/workspace/agentDisplayMode' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' @@ -148,6 +149,7 @@ function renderedViewAvailable(command: CommandDef, ctx: CommandContext): boolea export function buildCommandRegistry(ctx: CommandContext): ResolvedCommand[] { return commandDefs + .filter(command => !PALETTE_SELF_EXCLUDED_COMMAND_IDS.has(command.id)) .filter(command => commandApplicable(command, ctx) && commandVisible(command, ctx)) .map(command => { const description = command.description.trim() diff --git a/src/renderer/src/features/command-palette/taxonomy.test.ts b/src/renderer/src/features/command-palette/taxonomy.test.ts index 6d767527..a2d5835b 100644 --- a/src/renderer/src/features/command-palette/taxonomy.test.ts +++ b/src/renderer/src/features/command-palette/taxonomy.test.ts @@ -214,3 +214,93 @@ describe('navigationCommandsEnabled persistence', () => { expect(coerceSettings(coerceSettings({})).navigationCommandsEnabled).toBe(false) }) }) + +describe('retired preference commands', () => { + // Recorded product decision: five durable preferences with no meaningful + // momentary scope move to a single product home. The commands go; the + // Settings controls and their canonical fields stay, which is why no + // value migration is needed. + const RETIRED = [ + 'toggle-status-mode', + 'toggle-worktree-badges', + 'usage.toggle-header', + 'usage.cycle-header-level', + 'dangerous-agents', + ] + + it('removes all five from the catalog', () => { + const ids = new Set(builtInCommandCatalog.map(c => c.id)) + for (const id of RETIRED) expect(ids.has(id)).toBe(false) + }) + + it('keeps every canonical settings field intact', () => { + // The whole justification for "no migration needed". If any of these + // stopped surviving coercion, retiring the command would silently reset a + // user's preference — which would make this a data-loss change rather than + // an information-architecture one. + const settings = coerceSettings({ + showStatusMode: false, + showWorktreeBadges: false, + usageHeaderEnabled: false, + usageHeaderLevel: 'minimal', + dangerousAgentsEnabled: true, + }) + expect(settings.showStatusMode).toBe(false) + expect(settings.showWorktreeBadges).toBe(false) + expect(settings.usageHeaderEnabled).toBe(false) + expect(settings.usageHeaderLevel).toBe('minimal') + expect(settings.dangerousAgentsEnabled).toBe(true) + }) + + it('prunes stale visibility overrides for the retired ids', () => { + const settings = coerceSettings({ + commandVisibilityOverrides: { 'dangerous-agents': false, 'new-tab': false }, + }) + expect(settings.commandVisibilityOverrides).toEqual({ 'new-tab': false }) + }) + + it('prunes stale keybinding overrides for the retired ids', () => { + const settings = coerceSettings({ + commandKeybindingOverrides: { 'toggle-status-mode': ['Cmd+9'], 'new-tab': ['Cmd+9'] }, + }) + expect(settings.commandKeybindingOverrides).toEqual({ 'new-tab': ['Cmd+9'] }) + }) + + it('does not prune an id merely because it is absent from this build', () => { + // An id naming nothing today may belong to an uninstalled extension or a + // command a downgrade removed. Pruning by absence would discard a user's + // deliberate settings the first time they ran an older build. + const settings = coerceSettings({ + commandVisibilityOverrides: { 'ext.timer.start': false }, + commandKeybindingOverrides: { 'ext.timer.start': ['Cmd+9'] }, + }) + expect(settings.commandVisibilityOverrides).toEqual({ 'ext.timer.start': false }) + expect(settings.commandKeybindingOverrides).toEqual({ 'ext.timer.start': ['Cmd+9'] }) + }) +}) + +describe('open-command-palette', () => { + it('is in the catalog so it can be bound and collision-checked', () => { + expect(builtInCommandCatalog.map(c => c.id)).toContain('open-command-palette') + }) + + it('never renders as a palette row', () => { + // Structural, not a visibility tier: no user override should be able to put + // "open the palette" inside the palette, where it is at best noise and at + // worst a way to close the surface by accident. + const ctx = makeTestCommandContext() + expect(buildCommandRegistry(ctx).map(c => c.id)).not.toContain('open-command-palette') + }) + + it('stays hidden from palette rows even with reveal-all on', () => { + const ctx = makeTestCommandContext({ flags: { showHiddenCommands: true } }) + expect(buildCommandRegistry(ctx).map(c => c.id)).not.toContain('open-command-palette') + }) + + it('stays hidden from palette rows even with an explicit visibility override', () => { + const ctx = makeTestCommandContext({ + flags: { commandVisibilityOverrides: { 'open-command-palette': true } }, + }) + expect(buildCommandRegistry(ctx).map(c => c.id)).not.toContain('open-command-palette') + }) +}) diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index cfdbbe5c..ec031cc9 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -199,6 +199,9 @@ export type CommandContext = { openTileTabs: () => void openReorderTabs: () => void openSettings: () => void + /** Open the command palette. Exists so ⌘⇧P has a command to name instead + * of a hard-coded callback that nothing could rebind or collision-check. */ + openCommandPalette: () => void openViewPrompts: (sessionId: string) => void openPromptSearch: () => void openAgentActivity: () => void diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index 924caf84..a9b48bc6 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -197,6 +197,7 @@ function OpenCommandPalette({ }, [openTileTabsModal, workspace.activeTab, workspace.tileTabs]) const onReorderTabsRequest = useAppStore(state => state.openReorderTabs) const onSettingsRequest = useAppStore(state => state.openSettingsPage) + const openPaletteAction = useAppStore(state => state.openCommandPalette) const openViewPrompts = useAppStore(state => state.openViewPrompts) const openPromptSearch = useAppStore(state => state.openPromptSearch) const openAgentActivity = useAppStore(state => state.openAgentActivity) @@ -492,6 +493,11 @@ function OpenCommandPalette({ openTileTabs: onTileTabsRequest, openReorderTabs: onReorderTabsRequest, openSettings: onSettingsRequest, + // Reachable through the gateway (keybinding, native menu, programmatic) + // but never rendered as a palette row — see + // PALETTE_SELF_EXCLUDED_COMMAND_IDS for why that exclusion is + // structural rather than a visibility tier. + openCommandPalette: openPaletteAction, openViewPrompts, openPromptSearch, openAgentActivity, diff --git a/src/renderer/src/features/settings/commands/dangerousCommands.ts b/src/renderer/src/features/settings/commands/dangerousCommands.ts deleted file mode 100644 index 8fa196c8..00000000 --- a/src/renderer/src/features/settings/commands/dangerousCommands.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' - -// WHY this lives in dangerousCommands.ts and not in a sibling -// dangerousActions module: there used to be a `dangerousActions` file -// that exported three runners (enable / disable / toggle) plus a shared -// helper, but only `toggleDangerousAgents` was ever wired into a -// CommandDef. Keeping the toggle next to its sole consumer collapses -// two files into one without losing the non-obvious sequencing -// constraint below: we MUST flip the flag, close the palette, then -// await reloadAgentSessions in that exact order. Reversing them races -// a still-visible palette against the reload and the new flag value -// never reaches the next-spawned agent session. -async function toggleDangerousAgents(ctx: CommandContext): Promise { - const next = !ctx.flags.dangerousAgentsEnabled - if (ctx.flags.dangerousAgentsEnabled === next) return - ctx.ui.setDangerousAgentsEnabled(next) - ctx.ui.closePalette() - await ctx.workspace.reloadAgentSessions(next) -} - -export const dangerousCommands: CommandDef[] = [ - { - id: 'dangerous-agents', - category: 'preferences', - surface: 'app', - title: 'Dangerous Agents', - description: '**What it does:** Toggles **dangerous agent mode** for future agents.\n\n**Use when:** You explicitly want agents to run with fewer safety restrictions.\n\n**Notes:** Affects new agent sessions, not existing ones.', - getState: ({ flags }) => ({ - label: flags.dangerousAgentsEnabled ? 'On' : 'Off', - tone: flags.dangerousAgentsEnabled ? 'danger' : 'neutral', - }), - run: toggleDangerousAgents, - }, -] diff --git a/src/renderer/src/features/settings/commands/settingsCommands.ts b/src/renderer/src/features/settings/commands/settingsCommands.ts index 24332767..d3b5ede5 100644 --- a/src/renderer/src/features/settings/commands/settingsCommands.ts +++ b/src/renderer/src/features/settings/commands/settingsCommands.ts @@ -1,5 +1,4 @@ import type { CommandDef } from '@renderer/features/command-palette/types' -import { dangerousCommands } from '@renderer/features/settings/commands/dangerousCommands' export const settingsCommands: CommandDef[] = [ { @@ -53,18 +52,15 @@ export const settingsCommands: CommandDef[] = [ }), run: ({ ui }) => ui.toggleWorktreesBar(), }, - { - id: 'toggle-worktree-badges', - category: 'preferences', - surface: 'app', - title: 'Worktree Badges', - description: '**What it does:** Toggles **worktree badges** on agent rows.\n\n**Use when:** You want branch context visible in panes and **Dispatch**.\n\n**Notes:** Visual-only setting.', - keywords: ['branch', 'git', 'worktree', 'badge', 'agent'], - getState: ({ flags }) => ({ - label: flags.worktreeBadgesEnabled ? 'On' : 'Off', - tone: flags.worktreeBadgesEnabled ? 'accent' : 'neutral', - }), - run: ({ ui }) => ui.toggleWorktreeBadges(), - }, - ...dangerousCommands, + // RETIRED: `toggle-worktree-badges`. Same reasoning as Status Mode — a + // durable visual preference with no momentary scope belongs in Settings + // only (Interface → Worktree Badges, backed by `showWorktreeBadges`). + // + // RETIRED: `dangerous-agents`, which used to arrive here via + // `...dangerousCommands`. That one is not merely a duplicated preference: it + // is a SAFETY POSTURE whose enablement reloads every live agent. A palette + // row that flips it with one keystroke and no preview is the wrong affordance + // for that, and its old copy even claimed existing agents were unaffected + // while the runner reloaded them. Settings owns it, where a confirmation can + // show the exact set of agents about to be replaced. ] diff --git a/src/renderer/src/features/usage/commands/usageCommands.ts b/src/renderer/src/features/usage/commands/usageCommands.ts index 137c8b2c..fdb7d39a 100644 --- a/src/renderer/src/features/usage/commands/usageCommands.ts +++ b/src/renderer/src/features/usage/commands/usageCommands.ts @@ -13,32 +13,18 @@ export const usageCommands: CommandDef[] = [ ui.closePalette() }, }, - { - id: 'usage.toggle-header', - category: 'preferences', - surface: 'app', - title: 'Usage in Header', - description: - '**What it does:** Shows or hides **provider usage** in the header bar.\n\n**Use when:** You want quota headroom visible while planning agent work.\n\n**Notes:** Click the header indicator to open the full Usage modal.', - keywords: ['usage', 'quota', 'header', 'limits', 'tokens', 'claude', 'codex'], - getState: ({ flags }) => ({ - label: flags.usageHeaderEnabled ? 'On' : 'Off', - tone: flags.usageHeaderEnabled ? 'accent' : 'neutral', - }), - run: ({ ui }) => ui.toggleUsageHeader(), - }, - { - id: 'usage.cycle-header-level', - category: 'preferences', - surface: 'app', - title: 'Usage Header Detail', - description: - '**What it does:** Cycles the header usage indicator through **minimal → providers → all → detailed**.\n\n**Use when:** You want more or less quota detail in the header.\n\n**Notes:** Also enables the header indicator if it is currently hidden.', - keywords: ['usage', 'quota', 'level', 'detail', 'cycle', 'header'], - getState: ({ flags }) => ({ - label: flags.usageHeaderLevel, - tone: flags.usageHeaderEnabled ? 'accent' : 'neutral', - }), - run: ({ ui }) => ui.cycleUsageHeaderLevel(), - }, + // RETIRED: `usage.toggle-header` and `usage.cycle-header-level`. + // + // Both were durable app preferences with no momentary scope, so Settings is + // their single home (`usageHeaderEnabled` and `usageHeaderLevel`). + // + // The detail cycler is worth recording separately, because it was retired + // rather than repaired. It had three defects at once: it rendered the RAW + // lowercase enum value as its badge ("providers"), it cycled forward through + // four states with no way back, and — the real problem — it IMPLICITLY + // ENABLED the header as a side effect of changing the detail level. A user + // who had deliberately hidden the header could not adjust its detail without + // silently un-hiding it. That is one command doing two things, one of them + // undisclosed. Settings shows a labelled select that changes exactly the + // level and never touches the enabled flag. ] diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts index 737031c5..d56acf82 100644 --- a/src/renderer/src/features/workspace/commands/layoutCommands.ts +++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts @@ -92,18 +92,12 @@ export const layoutCommands: CommandDef[] = [ description: '**What it does:** Rotates split directions in the current layout.\n\n**Use when:** The same panes would work better in a different orientation.\n\n**Notes:** Keeps the sessions, changes the arrangement.', run: ({ workspace }) => workspace.rotateLayout(), }, - { - id: 'toggle-status-mode', - category: 'preferences', - surface: 'app', - title: 'Status Mode', - description: '**What it does:** Toggles status coloring for active agents.\n\n**Use when:** You want running or working agents to stand out.\n\n**Notes:** This is a visual setting only.', - getState: ({ flags }) => ({ - label: flags.statusModeEnabled ? 'On' : 'Off', - tone: flags.statusModeEnabled ? 'accent' : 'neutral', - }), - run: ({ ui }) => ui.toggleStatusMode(), - }, + // RETIRED: `toggle-status-mode`. Status Mode is a persisted app preference + // with no meaningful momentary scope — there is no "just for this session" + // version of it — so it has one product home, and that home is Settings + // (Appearance → Status Mode, backed by `showStatusMode`). A command that + // duplicates a durable preference gives the same setting two owners and two + // places to look when it is wrong. { id: 'toggle-performance-panel', category: 'developer', From 1f16bbe8cd8fff07eb93c58e031e3b3b90cac05e Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 17:45:37 +0200 Subject: [PATCH 11/33] fix(commands): resolve review findings from the two-agent PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two orchestrated reviewers (Claude + Codex) audited the branch. Their confirmed findings, fixed here. HIGH — Settings contradicted the palette for all six Navigation Commands. Phase 1 claimed pickerVisibility.ts was "shared verbatim with Settings". It was not: settingsRegistry kept a private second copy that knew only about overrides and the declared tier, so it never learned about the group gate. On a fresh install Settings rendered all six navigation switches ON while the palette omitted them, and toggling one wrote an override that still changed nothing because the group gate outranks it — precisely the "switches that appear able to override their parent" shape the precedence rule exists to prevent. Settings now calls the shared resolver, and PickerCommandMeta carries commandGroup so it can. HIGH — the dictation hotkey was compared unnormalized. hotkeyBinding rewrites Alt to Option for display, so a user's Option+D persists as "Option+D" while the command grammar canonicalizes the identical physical chord to "Alt+D". Raw string comparison found no collision between two bindings that register the same key. Once routing lands that means Option-D both starts dictation and runs a command. HIGH — the reservation registry was materially incomplete, which is the failure mode its own docstring warns about. Electron installs accelerators IMPLICITLY from roles, and appMenu.ts only names roles, so the chords appear nowhere in this repo and had to come from Electron's role table rather than from reading the source. Missing: Cmd+W (Close Window), Cmd+Shift+R (forceReload), Cmd+Alt+H, Cmd+Ctrl+F, Cmd+Alt+Shift+V, Cmd+= / Cmd+- (zoom). Also missing: Cmd+[ / Cmd+] editor indentation, the stateful Cmd+Arrow tiled-resize continuation, editor file-tab keys, and feed picker navigation. Adding them surfaced three previously invisible dual-ownerships (Cmd+[, Cmd+], End) plus Cmd+Shift+R, all pre-existing and all resolved by focus precedence. Recorded as approved overlaps with reasons rather than silently tolerated — Cmd+Shift+R in particular looks fine until someone unbinds resume-session and discovers it now force-reloads the app mid-session. MEDIUM — an all-malformed override array became a permanent unbind. The old rule collapsed it to [], which means "explicitly unbound" and outranks the shipped default forever. The reasoning was self-defeating: a real unbind already writes [], so the rule only ever fired on values the user did not author that way. Run an older build whose parser lacks a newer key token and the binding was destroyed unrecoverably. Corruption now drops the key so the command inherits again; a genuine [] still round-trips. MEDIUM — setCommandKeybindings deleted an entry equal to today's default, contradicting the plan's "preserves explicit choices when a later release changes shipped defaults". A user deliberately keeping a command on its current chord would have it silently moved by a future release. Explicit edits are now recorded; resetCommandKeybindings remains the way back. MEDIUM — commands without a shipped default were forced to 'global' context, which forbids customization the overlap matrix explicitly permits (assigning grid-only Alt+K when only Dispatch owns it). Context is now injectable. It is INJECTED rather than derived here because importing the catalog into resolve.ts creates a real initialization cycle through the provider capability registry — caught by three test files failing to load with a TDZ error, not by tsc. MEDIUM — normalize accepted Cmd++, which nothing can ever fire (plus emits code Equal with shift, canonicalizing to Cmd+Shift+=), and let numpad keys collapse onto main-keyboard tokens through the event.key fallback, defeating the Digit/Numpad separation directly above it. MEDIUM — check:keybindings permanently exempted open-command-palette from catalog ownership with a comment claiming it was "checked separately below"; there was no such check, and the command has since landed. It also never called findCatalogDefects, so duplicate catalog ids passed. HIGH (honesty) — the target-pinning test was vacuous: it built a second context and discarded it (`void laterCtx`), so nothing changed and the assertion only proved execute() forwards the object it was handed. It now mutates the workspace state between resolve and execute. The module docstring claimed pinning prevents retargeting; it does not, because CommandDef.run takes no target parameter and every session command still resolves its own. The comment now states that scope honestly and a test asserts it, rather than overclaiming what Phase 7 will deliver. Also: store version 8 -> 9 for the two new persisted fields. Verified: tsc -b clean, check:keybindings OK (12 reservations, 5 approved overlaps), 237 files / 1524 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check-command-keybindings.mts | 17 +- src/renderer/src/app-state/store.ts | 2 +- .../features/command-keybindings/normalize.ts | 21 ++- .../command-keybindings/reservations.ts | 149 ++++++++++++++++-- .../command-keybindings/resolve.test.ts | 40 +++-- .../features/command-keybindings/resolve.ts | 81 +++++++--- .../src/features/command-palette/registry.ts | 23 ++- .../command-palette/resolveInvocation.test.ts | 61 +++++-- .../command-palette/resolveInvocation.ts | 20 ++- .../features/settings/lib/settingsRegistry.ts | 45 ++++-- 10 files changed, 366 insertions(+), 93 deletions(-) diff --git a/scripts/check-command-keybindings.mts b/scripts/check-command-keybindings.mts index a73a5e61..7f9398e0 100644 --- a/scripts/check-command-keybindings.mts +++ b/scripts/check-command-keybindings.mts @@ -15,7 +15,10 @@ * * Run: npx tsx --tsconfig tsconfig.web.json scripts/check-command-keybindings.mts */ -import { builtInCommandCatalog } from '../src/renderer/src/features/command-palette/catalog' +import { + builtInCommandCatalog, + findCatalogDefects, +} from '../src/renderer/src/features/command-palette/catalog' import { buildDefaultKeybindings, contextsOverlap, @@ -75,14 +78,20 @@ for (const [commandId, count] of entriesByCommand) { // never discover — and, worse, one that silently reserves the key against // every other command. for (const entry of defaults) { - // `open-command-palette` is the one approved catalog addition and is expected - // to be absent until its command lands; it is checked separately below. - if (entry.commandId === 'open-command-palette') continue if (!catalogIds.has(entry.commandId)) { fail(`${entry.commandId}: has a shipped binding but is not in the command catalog`) } } +// --- 4b. Catalog integrity ------------------------------------------------- +// Delegated to the shared validator rather than reimplemented. Without it this +// gate happily passed a catalog with duplicate ids, which makes command lookup +// ambiguous — and a binding pointing at an ambiguous id is worse than one +// pointing at nothing. +for (const defect of findCatalogDefects(builtInCommandCatalog)) { + fail(`catalog: ${defect}`) +} + // --- 5. Collisions ---------------------------------------------------------- // Checks the shipped defaults against reserved interactions, native/menu roles, // editor-native keys, and the DEFAULT dictation hotkey. Settings additionally diff --git a/src/renderer/src/app-state/store.ts b/src/renderer/src/app-state/store.ts index acda968e..1dd42e80 100644 --- a/src/renderer/src/app-state/store.ts +++ b/src/renderer/src/app-state/store.ts @@ -60,7 +60,7 @@ export const useAppStore = create()( // real product default and downstream session initialization reads it // synchronously, so older blobs must be backfilled before workspace // bootstrap can create or recover an agent. - version: 8, + version: 9, storage: createJSONStorage(() => localStorage), partialize: state => ({ settings: state.settings }), merge: (persisted, current) => { diff --git a/src/renderer/src/features/command-keybindings/normalize.ts b/src/renderer/src/features/command-keybindings/normalize.ts index ef9b565b..010bcccd 100644 --- a/src/renderer/src/features/command-keybindings/normalize.ts +++ b/src/renderer/src/features/command-keybindings/normalize.ts @@ -106,12 +106,14 @@ export function parseKeybinding(input: string): ParsedKeybinding { ) } - // Split on '+' but keep a trailing literal '+' usable as a key: "Cmd++". + // WHY a trailing '+' is NOT rescued as a literal key: an earlier version + // accepted "Cmd++" so a user could bind the plus key. Nothing can ever fire + // it. Pressing plus on a normal keyboard emits code 'Equal' with shiftKey, + // which this grammar canonicalizes to 'Cmd+Shift+=' — so "Cmd++" was a + // binding the Settings UI would happily store and display while the runtime + // could never match it. That is the same class of defect the multi-step + // rejection above exists to prevent, so it is rejected the same way. const parts = raw.split('+') - if (parts.length > 1 && parts[parts.length - 1] === '') { - parts.pop() - parts[parts.length - 1] = '+' - } const modifiers: Modifier[] = [] let key: string | null = null @@ -151,7 +153,6 @@ function canonicalKey(part: string, raw: string): string { if (/^[0-9]$/.test(part)) return part if (/^[fF](?:[1-9]|1[0-9]|20)$/.test(part)) return `F${part.slice(1)}` if (part in PUNCTUATION_CODES) return part - if (part === '+') return '+' const named = Object.keys(NAMED_KEYS).find(name => name.toLowerCase() === part.toLowerCase()) if (named) return named @@ -226,6 +227,14 @@ function keyTokenFromEvent(event: KeyboardEvent): string | null { if (code === punctuationCode) return name } + // The named-key fallback reads `event.key`, which is where numpad keys leak + // in: NumpadEnter reports key 'Enter', and Numpad1 with NumLock off reports + // 'End'. Both would collapse onto the main-keyboard token and fire a binding + // the user assigned to a different physical key — the exact conflation the + // Digit/Numpad separation above exists to prevent. Codes are authoritative, + // so anything explicitly on the numpad is refused before the fallback runs. + if (code.startsWith('Numpad')) return null + for (const [name, domKey] of Object.entries(NAMED_KEYS)) { if (event.key === domKey) return name } diff --git a/src/renderer/src/features/command-keybindings/reservations.ts b/src/renderer/src/features/command-keybindings/reservations.ts index 40408c9d..b84b6b5d 100644 --- a/src/renderer/src/features/command-keybindings/reservations.ts +++ b/src/renderer/src/features/command-keybindings/reservations.ts @@ -1,5 +1,6 @@ import { contextsOverlap } from '@renderer/features/command-keybindings/defaults' import type { BindingContext, CommandBindingDefault } from '@renderer/features/command-keybindings/defaults' +import { tryNormalizeKeybinding } from '@renderer/features/command-keybindings/normalize' import type { Keybinding } from '@renderer/features/command-keybindings/normalize' // --------------------------------------------------------------------------- @@ -80,24 +81,78 @@ export const RESERVED_INTERACTIONS: readonly ReservedInteraction[] = [ // Standard Electron menu roles. Assigning a command on top of one of these // does not merely conflict — the native menu wins, so the command would // appear bound and simply never fire. - bindings: ['Cmd+Q', 'Cmd+H', 'Cmd+M', 'Cmd+R', 'Cmd+0', 'Cmd+Alt+I'], + // + // These are the accelerators Electron installs IMPLICITLY from a role. + // `appMenu.ts` only names roles (`appMenu`, `editMenu`, `close`, + // `forceReload`, `togglefullscreen`, `windowMenu`), so the chords never + // appear as literals anywhere in this repository and have to be + // transcribed from Electron's role table. An earlier version of this list + // was transcribed from the visible source instead and was therefore + // materially short — it reported Cmd+- and Cmd+Shift+R as free. + bindings: [ + 'Cmd+Q', 'Cmd+H', 'Cmd+M', 'Cmd+R', 'Cmd+0', 'Cmd+Alt+I', + // role: 'close' — Close Window. Note this makes Cmd+W THREE-way owned + // (close-pane, editor-native close file, Close Window), which the + // approved-overlap entry below now records explicitly. + 'Cmd+W', + // role: 'forceReload' + 'Cmd+Shift+R', + // role: 'appMenu' → Hide Others + 'Cmd+Alt+H', + // role: 'togglefullscreen' + 'Cmd+Ctrl+F', + // Explicit zoom accelerators in appMenu.ts's View submenu. Electron + // spells zoom-in 'CommandOrControl+Plus'; the physical key is '=', which + // is the token this grammar uses. + 'Cmd+=', 'Cmd+-', + ], context: 'global', owner: 'Native application menu', }, { - bindings: ['Cmd+C', 'Cmd+V', 'Cmd+X', 'Cmd+A', 'Cmd+Z', 'Cmd+Shift+Z'], + bindings: [ + 'Cmd+C', 'Cmd+V', 'Cmd+X', 'Cmd+A', 'Cmd+Z', 'Cmd+Shift+Z', + // role: 'editMenu' → Paste and Match Style + 'Cmd+Alt+Shift+V', + ], context: 'global', owner: 'Native editing commands', }, { - // Monaco owns Cmd+W for closing a file tab while editor chrome has focus. - // Note Cmd+W is ALSO a close-pane command binding in 'global' context — - // those contexts overlap, so this is a real conflict that the app resolves - // by precedence today. Declared so the checker reports it rather than - // pretending the chord is free. - bindings: ['Cmd+W'], + // Tiled-tab resize CONTINUATION. After Cmd+N focuses a tiled tab, arrows + // held under Cmd resize it (useKeybinds' pendingTiledResizeIndex). Stateful + // and therefore easy to miss when transcribing owners: the chord only does + // anything in the window between Cmd+N and releasing Cmd, but during that + // window it beats anything else bound to the same keys. + bindings: ['Cmd+Left', 'Cmd+Right', 'Cmd+Up', 'Cmd+Down'], + context: 'global', + owner: 'Tiled tab resize (after numbered selection)', + }, + { + // Feed picker navigation. While the Copy Assistant or Copy Code Block + // picker owns input, these four keys move/confirm/cancel the selection. + bindings: ['Up', 'Down', 'Enter'], + context: 'feed', + owner: 'Assistant / code-block picker navigation', + }, + { + // Editor chrome owns all FOUR of these while focus is inside + // [data-global-editor-input-owner] — useKeybinds bails for + // ['s','w','[',']'] there. Only Cmd+W was declared originally, which left + // Cmd+[ / Cmd+] (Monaco outdent/indent) reported as free the moment a user + // rebound next-tab/prev-tab away from them. Binding a global command to a + // freed bracket would produce a chord that silently does nothing whenever + // the editor has focus — the exact silent-conflict failure this file's + // docstring warns about. + bindings: ['Cmd+W', 'Cmd+[', 'Cmd+]'], + context: 'editor', + owner: 'Editor-native close file and indentation', + }, + { + // Editor file-tab strip: Delete closes, arrows/Home/End move between tabs. + bindings: ['Delete', 'Left', 'Right', 'Home', 'End'], context: 'editor', - owner: 'Editor-native close file', + owner: 'Editor file-tab navigation', }, ] @@ -129,7 +184,7 @@ const APPROVED_OVERLAPS: ReadonlyArray<{ }> = [ { binding: 'Cmd+W', - owners: ['close-pane', 'Editor-native close file'], + owners: ['close-pane', 'Editor-native close file and indentation', 'Native application menu'], // Resolved by focus, deterministically and before either owner runs: // useKeybinds returns early for cmd+s/w/[/] whenever the event target is // inside [data-global-editor-input-owner], so the workspace's close-pane @@ -141,8 +196,50 @@ const APPROVED_OVERLAPS: ReadonlyArray<{ // unhandled chord and closes the whole window. reason: 'Editor chrome consumes Cmd+W before the workspace router sees it ' - + '(useKeybinds bails out for editor-owned targets), so exactly one owner ' - + 'is ever live for a given focus.', + + '(useKeybinds bails out for editor-owned targets), and the renderer ' + + 'preventDefaults it so Electron\'s Close Window role never receives it. ' + + 'Exactly one owner is live for a given focus.', + }, + { + binding: 'Cmd+[', + owners: ['prev-tab', 'Editor-native close file and indentation'], + reason: + 'useKeybinds returns early for Cmd+[ whenever the event target sits ' + + 'inside [data-global-editor-input-owner], so Monaco outdent and tab ' + + 'navigation are never both live for one focus.', + }, + { + binding: 'Cmd+]', + owners: ['next-tab', 'Editor-native close file and indentation'], + reason: + 'useKeybinds returns early for Cmd+] whenever the event target sits ' + + 'inside [data-global-editor-input-owner], so Monaco indent and tab ' + + 'navigation are never both live for one focus.', + }, + { + binding: 'End', + owners: ['jump-latest-message', 'Editor file-tab navigation'], + reason: + 'Jump to Latest Message requires a focused rendered feed and a target ' + + 'that is not text-editing, while editor tab navigation requires focus ' + + 'inside editor chrome. The two preconditions cannot hold at once.', + }, + { + binding: 'Cmd+Shift+R', + owners: ['resume-session', 'Native application menu'], + // Pre-existing in the app, not introduced here: resume-session has shipped + // on Cmd+Shift+R for a long time, and appMenu's `forceReload` role carries + // the same accelerator implicitly. The renderer's capture-phase handler + // calls preventDefault before the menu's accelerator path runs, so Force + // Reload is effectively shadowed rather than racing. + // + // Recorded rather than silently tolerated because it is exactly the kind + // of overlap that looks fine until someone unbinds resume-session and + // discovers Cmd+Shift+R now force-reloads the app mid-session. + reason: + 'The renderer capture handler preventDefaults Cmd+Shift+R before ' + + 'Electron\'s forceReload role accelerator can fire, so the command ' + + 'shadows the native role deterministically rather than racing it.', }, ] @@ -203,8 +300,9 @@ export function findBindingCollisions(options: { claim(binding, { kind: 'reserved', id: entry.owner, context: entry.context }) } } - if (options.dictationBinding) { - claim(options.dictationBinding, { + const dictation = normalizeDictationBinding(options.dictationBinding) + if (dictation) { + claim(dictation, { kind: 'reserved', id: 'Voice dictation hotkey', context: 'global', @@ -263,9 +361,30 @@ export function findBindingOwners(options: { owners.push({ kind: 'reserved', id: entry.owner, context: entry.context }) } - if (options.dictationBinding === options.binding) { + if (normalizeDictationBinding(options.dictationBinding) === options.binding) { owners.push({ kind: 'reserved', id: 'Voice dictation hotkey', context: 'global' }) } return owners } + +/** + * Bring a dictation hotkey into the command grammar before comparing it. + * + * WHY this is not a raw string compare: dictation captures and persists its own + * notation, and `hotkeyBinding.ts` deliberately rewrites `Alt` to `Option` for + * display — so a user who binds Option-D has `"Option+D"` in settings while the + * command grammar canonicalizes the identical physical chord to `"Alt+D"`. + * Comparing raw strings found no collision between two bindings that register + * the SAME key, which is precisely the clash this engine exists to catch. Once + * runtime routing lands, that would mean pressing Option-D starts dictation and + * runs a command. + * + * The normalizer already accepts `option` as an alias for Alt, so this is just + * a matter of running it. Returns null for an unparseable value rather than + * throwing: an exotic dictation binding must not be able to break collision + * checking for every other chord. + */ +function normalizeDictationBinding(binding: Keybinding | null | undefined): Keybinding | null { + return binding ? tryNormalizeKeybinding(binding) : null +} diff --git a/src/renderer/src/features/command-keybindings/resolve.test.ts b/src/renderer/src/features/command-keybindings/resolve.test.ts index 12c8eb0f..86a5b9ad 100644 --- a/src/renderer/src/features/command-keybindings/resolve.test.ts +++ b/src/renderer/src/features/command-keybindings/resolve.test.ts @@ -63,11 +63,20 @@ describe('setCommandKeybindings', () => { expect(setCommandKeybindings({}, 'a', [], DEFAULTS)).toEqual({ a: [] }) }) - it('removes the entry when the value equals the shipped default', () => { - // Otherwise a user who toggled a binding off and back on would be pinned to - // this release's default forever — the same bug the absent/empty - // distinction exists to prevent. - expect(setCommandKeybindings({ a: [] }, 'a', ['Cmd+A'], DEFAULTS)).toEqual({}) + it('records an explicit edit even when it equals the shipped default', () => { + // The plan's acceptance criterion is "preserves explicit choices when a + // later release changes shipped defaults". Deleting the entry as redundant + // breaks exactly that: a user who deliberately keeps a command on today's + // chord would have it silently moved when a future release changes the + // default. resetCommandKeybindings is the documented way back to + // inheriting; an equal-to-default edit is still a decision. + expect(setCommandKeybindings({ a: [] }, 'a', ['Cmd+A'], DEFAULTS)).toEqual({ a: ['Cmd+A'] }) + }) + + it('keeps a recorded choice pinned when the shipped default later moves', () => { + const chosen = setCommandKeybindings({}, 'a', ['Cmd+A'], DEFAULTS) + const nextRelease = [{ commandId: 'a', bindings: ['Cmd+Alt+A'], context: 'global' as const }] + expect(effectiveBindingsFor('a', chosen, nextRelease)).toEqual(['Cmd+A']) }) it('treats binding order as significant', () => { @@ -115,10 +124,23 @@ describe('coerceCommandKeybindingOverrides', () => { }) }) - it('collapses an all-malformed array to an explicit unbind', () => { - // Conservative reading: we know the user edited this command, so silently - // restoring the shipped chord could re-add a binding they had removed. - expect(coerceCommandKeybindingOverrides({ a: ['nonsense'] })).toEqual({ a: [] }) + it('drops an all-malformed array so the command inherits again', () => { + // Corruption, not intent. Collapsing it to `[]` made the damage permanent: + // `[]` means "explicitly unbound" and outranks the shipped default forever, + // so a value mangled by an older build's parser would leave the command + // showing "Not assigned" with no way back. + expect(coerceCommandKeybindingOverrides({ a: ['nonsense'] })).toEqual({}) + }) + + it('still honours a genuine explicit unbind', () => { + // The distinction the rule above must not damage: a real unbind writes an + // EMPTY array, which has nothing to fail parsing and round-trips intact. + expect(coerceCommandKeybindingOverrides({ a: [] })).toEqual({ a: [] }) + }) + + it('recovers a command whose binding an older build could not parse', () => { + const settings = coerceCommandKeybindingOverrides({ a: ['SomeFutureKey'] }) + expect(effectiveBindingsFor('a', settings, DEFAULTS)).toEqual(['Cmd+A']) }) it('preserves unknown command ids', () => { diff --git a/src/renderer/src/features/command-keybindings/resolve.ts b/src/renderer/src/features/command-keybindings/resolve.ts index d87e23bd..ea5cbdd6 100644 --- a/src/renderer/src/features/command-keybindings/resolve.ts +++ b/src/renderer/src/features/command-keybindings/resolve.ts @@ -52,10 +52,21 @@ export function coerceCommandKeybindingOverrides(value: unknown): CommandKeybind if (!bindings.includes(normalized)) bindings.push(normalized) } - // An array that contained ONLY malformed values becomes `[]`, which means - // "explicitly unbound". That is the conservative reading: we know the user - // edited this command, and silently restoring the shipped chord would - // re-add a binding they had removed. + // A NON-EMPTY array whose entries all failed to parse is corruption, not + // intent, so the key is dropped entirely and the command inherits again. + // + // The earlier policy collapsed it to `[]` — "explicitly unbound" — on the + // reasoning that the user had clearly edited this command. That reasoning + // is self-defeating: a deliberate unbind already writes `[]`, which + // round-trips identically under either policy, so the rule only ever fired + // on values the user did NOT author in that shape. It made corruption + // permanent and unrecoverable: run an older build whose parser lacks a key + // token the newer one added, and the binding degrades to `[]`, which then + // outranks the shipped default forever — Settings shows "Not assigned" for + // a command nobody touched, and re-upgrading does not bring it back. The + // same happened to any externally-authored value in display notation + // (`['⌘T']`), which is exactly the form displayKeybinding emits. + if (entry.length > 0 && bindings.length === 0) continue result[commandId] = bindings } return result @@ -82,6 +93,18 @@ export type EffectiveBinding = { export function resolveEffectiveKeybindings( overrides: CommandKeybindingOverrides, defaults: readonly CommandBindingDefault[] = buildDefaultKeybindings(), + /** + * Context for a command that ships no default, INJECTED rather than looked + * up here. + * + * WHY injected: deriving it needs the command catalog, and importing the + * catalog into this module creates a real initialization cycle — + * catalog → paneCommands → the provider capability registry → back here — + * which fails at module load with a TDZ error rather than at a call site. + * Callers that already hold the catalog (Settings, the runtime router) pass + * a resolver; everything else gets the strict 'global' default. + */ + contextForCommand: (commandId: string) => CommandBindingDefault['context'] = () => 'global', ): EffectiveBinding[] { const byCommand = new Map() @@ -96,13 +119,24 @@ export function resolveEffectiveKeybindings( } // A user can bind a command that ships with NO default — most of the catalog. - // Those have no entry to start from, so they are added here. Context defaults - // to 'global' because a command with no declared default also has no declared - // context, and global is the strictest choice for collision purposes: it - // overlaps everything, so the checker will flag any clash rather than miss one. + // Those have no default entry to inherit a context from, so it is derived + // from the command's SURFACE instead of defaulting to 'global'. + // + // WHY not just 'global': global overlaps every context, so it is the + // strictest choice for collision purposes — but strictest is not the same as + // correct, and here it forbids legitimate customization the context matrix + // explicitly promises. Concretely: unbind nav-up, then assign Alt+K to the + // grid-only rotate-layout. Grid and Dispatch are mutually exclusive, so + // reusing Dispatch's Alt+K is safe and the matrix says so — but a 'global' + // context reports it as overlapping Dispatch and rejects the binding. for (const [commandId, bindings] of Object.entries(overrides)) { if (byCommand.has(commandId)) continue - byCommand.set(commandId, { commandId, bindings, context: 'global', customized: true }) + byCommand.set(commandId, { + commandId, + bindings, + context: contextForCommand(commandId), + customized: true, + }) } return [...byCommand.values()] @@ -121,12 +155,21 @@ export function effectiveBindingsFor( } /** - * Apply a Settings edit, keeping the map sparse. + * Apply a Settings edit. + * + * An explicit edit is RECORDED even when its value happens to equal today's + * shipped default. An earlier version deleted the entry in that case, to avoid + * pinning a user who toggled a binding off and straight back on. * - * Writing an override that equals the shipped default REMOVES the entry rather - * than storing a redundant copy — otherwise a user who toggled a binding off - * and back on would be silently pinned to this release's default forever, - * which is the same bug the absent/empty distinction exists to prevent. + * That optimization contradicts the plan's stated acceptance criterion — + * "preserves explicit choices when a later release changes shipped defaults" — + * and the criterion wins, because the failure it prevents is worse. Concretely: + * a user deliberately keeps open-settings on Cmd+, using the editor; the entry + * is deleted as redundant; a later release moves the default to Cmd+Alt+, and + * the user's explicit choice is silently overwritten by a chord they never + * chose. The cost of the other direction is only that an off/on round trip + * leaves a harmless entry recording a real decision — and `resetCommandKeybindings` + * is the documented way to go back to inheriting. */ export function setCommandKeybindings( overrides: CommandKeybindingOverrides, @@ -135,11 +178,7 @@ export function setCommandKeybindings( defaults: readonly CommandBindingDefault[] = buildDefaultKeybindings(), ): CommandKeybindingOverrides { const next = { ...overrides } - const shipped = defaults.find(entry => entry.commandId === commandId)?.bindings ?? [] - - if (sameBindings(shipped, bindings)) delete next[commandId] - else next[commandId] = [...bindings] - + next[commandId] = [...bindings] return next } @@ -153,7 +192,3 @@ export function resetCommandKeybindings( return next } -/** Order matters: two bindings for one command are an ordered display list. */ -function sameBindings(a: readonly Keybinding[], b: readonly Keybinding[]): boolean { - return a.length === b.length && a.every((binding, index) => binding === b[index]) -} diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index 1cf5329b..12e892d1 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -6,6 +6,7 @@ import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/comma import type { CommandContext, CommandDef, + CommandGroup, CommandPickerVisibility, CommandSurface, ResolvedCommand, @@ -177,6 +178,10 @@ export type PickerCommandMeta = { id: string title: string pickerVisibility: CommandPickerVisibility + /** Carried so Settings can apply the SAME group gate the picker applies. + * Omitting it was what let the Settings list claim the six Navigation + * Commands were visible while the palette hid them. */ + commandGroup?: CommandGroup } /** @@ -198,9 +203,17 @@ export type PickerCommandMeta = { * inventing a dummy context purely for a display string. */ export function listPickerCommandMeta(): PickerCommandMeta[] { - return commandDefs.map(command => ({ - id: command.id, - title: typeof command.title === 'function' ? command.id : command.title, - pickerVisibility: declaredTier(command), - })) + return commandDefs + // Commands the palette structurally never renders must not appear here + // either. `open-command-palette` was getting a Settings switch that could + // never change anything — buildCommandRegistry filters it out BEFORE any + // visibility logic runs — while still persisting an override when toggled. + // A control that visibly does nothing is worse than an absent one. + .filter(command => !PALETTE_SELF_EXCLUDED_COMMAND_IDS.has(command.id)) + .map(command => ({ + id: command.id, + title: typeof command.title === 'function' ? command.id : command.title, + pickerVisibility: declaredTier(command), + ...(command.commandGroup ? { commandGroup: command.commandGroup } : {}), + })) } diff --git a/src/renderer/src/features/command-palette/resolveInvocation.test.ts b/src/renderer/src/features/command-palette/resolveInvocation.test.ts index 9d11eb1c..beaa1200 100644 --- a/src/renderer/src/features/command-palette/resolveInvocation.test.ts +++ b/src/renderer/src/features/command-palette/resolveInvocation.test.ts @@ -147,26 +147,57 @@ describe('resolveCommandInvocation', () => { expect(evaluated).toBe(false) }) - it('pins the target so a later focus change cannot retarget it', () => { - // THE point of the phase. `execute` closes over the context the target was - // resolved from, so moving focus afterwards cannot redirect the mutation. - const seen: string[] = [] + it('keeps the resolved target stable when focus moves after resolution', () => { + // Focus really moves between resolve and execute: the workspace state the + // context points at is MUTATED, which is what a Dispatch selection change + // does to the live store. + // + // An earlier version of this test constructed a second context and threw it + // away (`void laterCtx`), so nothing changed and the assertion only proved + // that `execute()` forwards the object it was handed — it would have passed + // for `() => command.run(whateverIsFocusedNow)` too. + const command: CommandDef = { ...base, surface: 'session', run: () => {} } + const ctx = makeTestCommandContext({ focusedSessionId: 's1', activeTabId: 'tab-a' }) + + const invocation = resolveCommandInvocation(command, ctx) + expect(invocation.target).toEqual({ kind: 'session', id: 's1' }) + + // Simulate the user moving the Dispatch selection to another session. + const state = ctx.workspace.state as unknown as { + sessions: Record + tabs: { focusedSessionId: string }[] + } + state.sessions.s2 = { kind: 'claude', cwd: '/repo' } + state.tabs[0].focusedSessionId = 's2' + + // Re-resolving now yields s2 — proving the state change is real and visible. + expect(resolveCommandTarget(command, ctx)).toEqual({ kind: 'session', id: 's2' }) + // The already-resolved invocation still names s1. + expect(invocation.target).toEqual({ kind: 'session', id: 's1' }) + }) + + it('is honest about what execute() currently carries', () => { + // SCOPE NOTE, deliberately asserted rather than left to a comment: + // `CommandDef.run` takes only a context — it has no target parameter — so + // the pinned target is NOT yet threaded into execution. `execute()` calls + // `run(ctx)`, and every session command still resolves its own target + // inside `run`. + // + // So this module currently provides the pinned IDENTITY (for state display + // and for `targetStillValid` at a mutation boundary) but does not yet + // enforce it. Closing that gap requires giving `run` the resolved target, + // which is Phase 7 work and touches every session command. + let receivedArgs = -1 const command: CommandDef = { ...base, surface: 'session', - run: c => { - seen.push(String(c.workspace.state.activeTabId)) + run: (...args: unknown[]) => { + receivedArgs = args.length }, } - const ctx = makeTestCommandContext({ focusedSessionId: 's1', activeTabId: 'tab-a' }) - const invocation = resolveCommandInvocation(command, ctx) - - // Focus moves after resolution but before execution. - const laterCtx = makeTestCommandContext({ focusedSessionId: 's2', activeTabId: 'tab-b' }) - void laterCtx - - void invocation.execute() - expect(seen).toEqual(['tab-a']) + const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) + void resolveCommandInvocation(command, ctx).execute() + expect(receivedArgs).toBe(1) }) }) diff --git a/src/renderer/src/features/command-palette/resolveInvocation.ts b/src/renderer/src/features/command-palette/resolveInvocation.ts index 9668a69e..1648fecb 100644 --- a/src/renderer/src/features/command-palette/resolveInvocation.ts +++ b/src/renderer/src/features/command-palette/resolveInvocation.ts @@ -132,10 +132,22 @@ export function resolveCommandAvailability( /** * Resolve everything about one invocation at once. * - * `execute` closes over the TARGET RESOLVED HERE, not over "whatever is - * focused when you call me". That closure is the mechanism that makes the - * pinning real — a later focus change cannot retarget an invocation that - * already captured its identity. + * SCOPE — read before relying on this. It resolves target, availability and + * state together, from one read, so those three cannot disagree. What it does + * NOT yet do is enforce the target during execution: `CommandDef.run` takes + * only a context and has no target parameter, so `execute` calls `run(ctx)` and + * every session command still resolves its own target internally. + * + * The pinned identity is therefore currently useful for DISPLAY (the badge and + * the row describe a known session) and for `targetStillValid` at a mutation + * boundary — but a command that re-resolves inside `run` can still act on + * something else. `close-pane`, for instance, reaches `closeFocused`, which + * reads a live ref rather than this snapshot. + * + * Closing that gap means threading the resolved target into `run`, which + * touches every session command and belongs with the Phase 7 destructive work. + * Until then, do not describe this module as preventing retargeting; it + * provides the identity that a later phase will enforce. */ export function resolveCommandInvocation( command: CommandDef, diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index a4510b2c..f71f9a27 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -15,6 +15,7 @@ import type { Workspace } from '@renderer/workspace/workspaceStore' import { SETTING_CATEGORIES } from '@renderer/features/settings/lib/settingsCategories' import type { SettingCategoryId } from '@renderer/features/settings/lib/settingsCategories' import { listPickerCommandMeta } from '@renderer/features/command-palette/registry' +import { isVisibleInPicker } from '@renderer/features/command-palette/pickerVisibility' import type { PickerCommandMeta } from '@renderer/features/command-palette/registry' import type { ConfigurableBuiltInMcpDomain } from '@mcp/shared/types' @@ -222,18 +223,40 @@ const DICTATION_PROVIDER_OPTIONS: ChoiceOption[] // Resolve a command's effective picker visibility from settings alone. // Mirrors `commandVisible` in the command registry, minus the live // `showHiddenCommands` escape hatch (the settings UI always edits the -// underlying preference, never the transient reveal-all state): an -// explicit override wins, else the declared default ('default' shows, -// everything else is hidden). Kept here rather than imported so the -// settings layer doesn't depend on the registry's CommandContext-typed -// internals — it only needs the static rule. +// underlying preference, never the transient reveal-all state). +// +// This now delegates to the SHARED resolver rather than re-implementing the +// rule. It used to be a private second copy, justified by "the settings layer +// shouldn't depend on the registry's CommandContext-typed internals" — a real +// concern that `pickerVisibility.ts` removed by taking a context-free policy +// struct instead of a CommandContext. +// +// Keeping the copy after that was an active defect, not just duplication: the +// copy knew about overrides and the declared tier only, so it never learned +// about the Navigation Commands group. On a fresh install Settings rendered +// all six navigation switches ON (they declare no tier, so the copy said +// "visible") while the palette omitted them — Settings stating the opposite of +// what the user could see. Toggling one wrote an override and still changed +// nothing, because the group gate deliberately outranks per-command overrides. +// That is exactly the "switches that appear able to override their parent" +// shape the precedence rule exists to prevent. +// +// `showHiddenCommands: false` is passed deliberately: Settings shows the +// PERSISTED preference, not the transient reveal-all state, so a user reading +// this list sees what their profile actually does. function resolveCommandVisible(settings: Settings, command: PickerCommandMeta): boolean { - // Defensive optional-chain for the same reason as commandVisible in the - // registry: never let a missing override map (pre-#249 persisted settings) - // crash the Settings page render. Degrade to declared default. - const override = settings.commandVisibilityOverrides?.[command.id] - if (typeof override === 'boolean') return override - return command.pickerVisibility === 'default' + return isVisibleInPicker( + { + id: command.id, + pickerVisibility: command.pickerVisibility, + commandGroup: command.commandGroup, + }, + { + overrides: settings.commandVisibilityOverrides, + showHiddenCommands: false, + navigationCommandsEnabled: settings.navigationCommandsEnabled, + }, + ) } function updateDefaultBuiltInMcpDomain( From c3a57a062b0fe383a8817c2c92df0ef1bdf23dd3 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 17:53:57 +0200 Subject: [PATCH 12/33] feat(providers): gate features by declared capability, not agent-hood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. The audit's high-severity provider finding: `isAgentProviderKind()` was being consumed as a FEATURE capability. It only distinguishes agents from terminals. Every agent provider passed it, so OpenCode was offered Rewind, Duplicate, Switch Provider and Copy Resume — all empty, rejected, unsupported or unverified for it. The user saw an ordinary enabled command and got nothing. Each provider identity descriptor now declares five capabilities: savedSessionListing, transcriptRewind, transcriptDuplicate, switchTargets and verifiedExternalResumeCommand. The field is REQUIRED on the registry type, so a provider added without answering these questions fails the build rather than inheriting broad agent powers by joining AGENT_PROVIDER_KINDS. switchTargets is an edge LIST, not a boolean: "can switch" is meaningless without naming a destination, and translation is directional — a Claude->Codex adapter is not automatically a Codex->OpenCode one. OpenCode declares all five as false/empty. That is the correction, not a slight: it has no saved-session listing in main, no transcript adapter for rewind or duplicate to operate on, no switch edge in either direction, and its resumeCommand is an unverified guess (its own comment in the identity file has said so since #406). They flip individually as each adapter becomes real; flipping them as a group is the mistake this phase exists to prevent. copy-resume-command is the sharpest case. An unverified template hands the user a shell command that may not work, which is worse than not offering it at all — they paste it into a terminal and blame their setup. The MCP matrix is untouched: Workflow MCP stays Codex-only, Claude keeps its native workflow surface, OpenCode gets no injected built-in domains. NOT included, deliberately: the disabled-with-reason presentation for these refusals. `unavailableReason` exists on CommandDef and resolveCommandAvailability implements the recorded product decision, but the palette does not consume the resolver yet, so a reason declared today would be dead code. These four commands currently HIDE on an unsupported provider rather than showing greyed with an explanation. Wiring the picker to the resolver is the remaining half. Verified: tsc -b clean, check:keybindings OK, 238 files / 1530 tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/providers/claude/renderer/identity.ts | 13 +++ src/providers/codex/renderer/identity.ts | 13 +++ src/providers/opencode/renderer/identity.ts | 22 ++++ src/providers/providerFeatures.test.ts | 100 ++++++++++++++++++ .../registry.renderer.capabilities.ts | 30 ++++++ src/providers/shared/featureCapabilities.ts | 50 +++++++++ .../workspace/commands/sessionCommands.ts | 31 ++++-- 7 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 src/providers/providerFeatures.test.ts create mode 100644 src/providers/shared/featureCapabilities.ts diff --git a/src/providers/claude/renderer/identity.ts b/src/providers/claude/renderer/identity.ts index 8f377962..03a41d50 100644 --- a/src/providers/claude/renderer/identity.ts +++ b/src/providers/claude/renderer/identity.ts @@ -25,4 +25,17 @@ export const CLAUDE_IDENTITY = { * that used to be a hand-written ternary in providerResumeCommand. */ resumeCommand: (quotedSessionId: string) => `claude --resume ${quotedSessionId}`, + + /** + * Feature capabilities (governance plan Phase 5). Claude has a saved-session + * index, a transcript adapter used by both rewind and duplicate, a verified + * `claude --resume` form, and a translation edge to Codex. + */ + features: { + savedSessionListing: true, + transcriptRewind: true, + transcriptDuplicate: true, + switchTargets: ['codex'], + verifiedExternalResumeCommand: true, + }, } as const diff --git a/src/providers/codex/renderer/identity.ts b/src/providers/codex/renderer/identity.ts index 37b28830..97e3de9a 100644 --- a/src/providers/codex/renderer/identity.ts +++ b/src/providers/codex/renderer/identity.ts @@ -15,4 +15,17 @@ export const CODEX_IDENTITY = { * split chords instead; additional providers without a key get * palette-only split commands. */ splitShortcutKey: 'C', + + /** + * Feature capabilities (governance plan Phase 5). Mirrors Claude: saved + * sessions, a transcript adapter, a verified `codex resume` form, and the + * reverse translation edge. + */ + features: { + savedSessionListing: true, + transcriptRewind: true, + transcriptDuplicate: true, + switchTargets: ['claude'], + verifiedExternalResumeCommand: true, + }, } as const diff --git a/src/providers/opencode/renderer/identity.ts b/src/providers/opencode/renderer/identity.ts index 22439d8b..61bb15f3 100644 --- a/src/providers/opencode/renderer/identity.ts +++ b/src/providers/opencode/renderer/identity.ts @@ -13,4 +13,26 @@ export const OPENCODE_IDENTITY = { resumeCommand: (quotedSessionId: string) => `opencode --session ${quotedSessionId}`, // No splitShortcutKey: chords are scarce; palette split commands // derive automatically (#394 phase 4). + + /** + * Feature capabilities (governance plan Phase 5). + * + * Everything is FALSE today, and that is the correction the plan asks for + * rather than a slight. OpenCode has no saved-session listing in main, no + * transcript adapter (so rewind and duplicate have nothing to operate on), + * no switch edge in either direction, and the resumeCommand below is an + * unverified guess — see its own comment. Declaring agent-hood previously + * granted all five features implicitly, so the commands appeared enabled and + * then did nothing. + * + * Flip these individually as each adapter becomes real; do not flip them as + * a group. + */ + features: { + savedSessionListing: false, + transcriptRewind: false, + transcriptDuplicate: false, + switchTargets: [], + verifiedExternalResumeCommand: false, + }, } as const diff --git a/src/providers/providerFeatures.test.ts b/src/providers/providerFeatures.test.ts new file mode 100644 index 00000000..fff2c740 --- /dev/null +++ b/src/providers/providerFeatures.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' + +import { AGENT_PROVIDER_KINDS } from '@shared/types/providerKind' +import { getProviderFeatures } from '@providers/registry.renderer.capabilities' + +// --------------------------------------------------------------------------- +// Phase 5: provider capability policy. +// +// The audit's high-severity finding was that `isAgentProviderKind()` was being +// consumed as a FEATURE capability. It only distinguishes agents from +// terminals. Because every agent provider passed it, OpenCode received Resume, +// Rewind, Duplicate, Switch Provider and Copy Resume — all empty, rejected, +// unsupported or unverified for it. This file pins the matrix so a provider +// cannot inherit a feature by joining AGENT_PROVIDER_KINDS. +// --------------------------------------------------------------------------- + +describe('provider feature matrix', () => { + it('matches the plan table exactly', () => { + const matrix = Object.fromEntries( + AGENT_PROVIDER_KINDS.map(kind => [kind, getProviderFeatures(kind)]), + ) + expect(matrix).toEqual({ + claude: { + savedSessionListing: true, + transcriptRewind: true, + transcriptDuplicate: true, + switchTargets: ['codex'], + verifiedExternalResumeCommand: true, + }, + codex: { + savedSessionListing: true, + transcriptRewind: true, + transcriptDuplicate: true, + switchTargets: ['claude'], + verifiedExternalResumeCommand: true, + }, + opencode: { + savedSessionListing: false, + transcriptRewind: false, + transcriptDuplicate: false, + switchTargets: [], + verifiedExternalResumeCommand: false, + }, + }) + }) + + it('grants a terminal nothing', () => { + // The distinction isAgentProviderKind was actually making, kept explicit. + expect(getProviderFeatures('terminal')).toEqual({ + savedSessionListing: false, + transcriptRewind: false, + transcriptDuplicate: false, + switchTargets: [], + verifiedExternalResumeCommand: false, + }) + }) + + it('grants an unknown or absent kind nothing', () => { + expect(getProviderFeatures(undefined).transcriptRewind).toBe(false) + expect(getProviderFeatures('not-a-provider').switchTargets).toEqual([]) + }) + + it('leaves OpenCode unavailable for every unsupported operation', () => { + // Named individually rather than as a group: each flips when its OWN + // adapter becomes real, and flipping them together is the mistake this + // whole phase exists to prevent. + const opencode = getProviderFeatures('opencode') + expect(opencode.savedSessionListing).toBe(false) + expect(opencode.transcriptRewind).toBe(false) + expect(opencode.transcriptDuplicate).toBe(false) + expect(opencode.verifiedExternalResumeCommand).toBe(false) + expect(opencode.switchTargets).toEqual([]) + }) + + it('keeps switch edges directional and symmetric only where declared', () => { + // Claude<->Codex is a real round trip; nothing points at OpenCode, and + // OpenCode points at nothing. A boolean "canSwitch" could not express that. + expect(getProviderFeatures('claude').switchTargets).toEqual(['codex']) + expect(getProviderFeatures('codex').switchTargets).toEqual(['claude']) + for (const kind of AGENT_PROVIDER_KINDS) { + expect(getProviderFeatures(kind).switchTargets).not.toContain('opencode') + } + }) + + it('requires every agent provider to declare all five capabilities', () => { + // Adding a provider must fail here until it answers each question, rather + // than silently inheriting broad agent powers. + const required = [ + 'savedSessionListing', + 'transcriptRewind', + 'transcriptDuplicate', + 'switchTargets', + 'verifiedExternalResumeCommand', + ] + for (const kind of AGENT_PROVIDER_KINDS) { + const features = getProviderFeatures(kind) as Record + for (const key of required) expect(features[key]).toBeDefined() + } + }) +}) diff --git a/src/providers/registry.renderer.capabilities.ts b/src/providers/registry.renderer.capabilities.ts index be867d1f..c88e5662 100644 --- a/src/providers/registry.renderer.capabilities.ts +++ b/src/providers/registry.renderer.capabilities.ts @@ -1,3 +1,4 @@ +import type { ProviderFeatureCapabilities } from '@providers/shared/featureCapabilities' import type { ConditionView } from '@shared/conditions-core/view' import type { Entry, ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' import type { ProviderConditionSnapshot } from '@shared/types/providerConditions' @@ -83,6 +84,14 @@ export type RendererProviderCapabilities = { * a scarce resource, palette entries aren't. */ splitShortcutKey?: string + /** + * Declared feature capabilities. REQUIRED — there is deliberately no default. + * A provider added without answering these questions must fail the build + * rather than silently inherit every agent feature, which is exactly how + * OpenCode ended up offering Resume, Rewind, Duplicate, Switch and Copy + * Resume with nothing behind them. + */ + features: ProviderFeatureCapabilities conditionViews: Record normalizeConditions?: (input: { snapshot: ProviderConditionSnapshot | null @@ -353,3 +362,24 @@ export function providerDurableEntryKind( ): ProviderDurableEntryKind | null { return getRendererProviderCapabilities(provider).classifyDurableEntry(entry) } + +/** + * Feature capabilities for a provider kind, or the closed-off defaults for a + * value that is not an agent provider at all (a terminal). + * + * Command `when` guards call THIS rather than `isAgentProviderKind`, which is + * the whole point of Phase 5: membership in AGENT_PROVIDER_KINDS distinguishes + * agents from terminals and grants nothing. + */ +export function getProviderFeatures(kind: string | undefined): ProviderFeatureCapabilities { + if (!kind || !isAgentProviderKind(kind)) { + return { + savedSessionListing: false, + transcriptRewind: false, + transcriptDuplicate: false, + switchTargets: [], + verifiedExternalResumeCommand: false, + } + } + return getRendererProviderCapabilities(kind).features +} diff --git a/src/providers/shared/featureCapabilities.ts b/src/providers/shared/featureCapabilities.ts new file mode 100644 index 00000000..fe782315 --- /dev/null +++ b/src/providers/shared/featureCapabilities.ts @@ -0,0 +1,50 @@ +import type { AgentProviderKind } from '@shared/types/providerKind' + +/** + * What a provider can actually DO, declared explicitly per provider. + * + * WHY this exists: the audit found `isAgentProviderKind()` being used as a + * feature capability. That predicate only distinguishes agents from terminals — + * it says nothing about whether a provider has a transcript adapter, a + * saved-session index, or a verified CLI resume form. Because every agent + * provider passed it, OpenCode was offered Resume, Rewind, Duplicate, Switch + * Provider and Copy Resume, all of which are empty, rejected, unsupported or + * unverified for it. The user sees an ordinary enabled command and gets nothing. + * + * The rule this replaces it with: a provider gets a feature when it DECLARES + * the capability, not when it happens to be an agent. Adding a provider now + * means answering these questions rather than inheriting broad agent powers by + * joining `AGENT_PROVIDER_KINDS`. + */ +export type ProviderFeatureCapabilities = { + /** + * Main can enumerate this provider's saved sessions for a cwd, so the Resume + * picker has something to list. Without it Resume opens an empty modal. + */ + savedSessionListing: boolean + /** + * The transcript adapter can rewind this provider's transcript to an earlier + * prompt. Rewind rewrites session history; offering it without an adapter + * means the command either no-ops or corrupts. + */ + transcriptRewind: boolean + /** + * The transcript adapter can project this provider's transcript into a new + * session, which is what Duplicate does. + */ + transcriptDuplicate: boolean + /** + * Providers this one can switch TO. An explicit edge list, not a boolean: + * "can switch" is meaningless without naming the destination, and the + * translation is directional — a Claude→Codex adapter is not automatically a + * Codex→OpenCode one. + */ + switchTargets: readonly AgentProviderKind[] + /** + * `resumeCommand` has been VERIFIED against the real CLI. False means the + * template is a plausible guess, and Copy Resume Command would hand the user + * a shell command that may not work — worse than not offering it, because + * they will paste it into a terminal and blame their setup. + */ + verifiedExternalResumeCommand: boolean +} diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 818c2faf..1c3aa259 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -1,5 +1,8 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' -import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' +import { + getProviderFeatures, + getRendererProviderCapabilities, +} from '@providers/registry.renderer.capabilities' import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' import { runSaveDebugBundleCommand } from '@renderer/features/debug/saveDebugBundle' import { runAttachRecordingNoteCommand, runToggleSessionRecordingCommand } from '@renderer/features/debug/attachRecordingNote' @@ -76,7 +79,10 @@ export const sessionCommands: CommandDef[] = [ if (!sessionId) return false const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - return isAgentProviderKind(kind) + // Driven by the explicit switch EDGE list, not by agent-hood. "Can + // switch" is meaningless without a destination, and translation is + // directional — OpenCode has no edge in either direction today. + return getProviderFeatures(kind).switchTargets.length > 0 }, run: ({ workspace, ui }) => { const sessionId = commandTargetSessionId(workspace) @@ -123,8 +129,12 @@ export const sessionCommands: CommandDef[] = [ if (!sessionId) return false const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER + // Rewind REWRITES session history, so it needs a real transcript + // adapter — not merely an agent provider. isAgentProviderKind passed for + // OpenCode, which has no adapter, so the command appeared enabled and + // then did nothing. return ( - isAgentProviderKind(kind) && + getProviderFeatures(kind).transcriptRewind && Boolean(meta?.providerSessionId) ) }, @@ -639,7 +649,13 @@ export const sessionCommands: CommandDef[] = [ if (!sessionId) return false const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - return isAgentProviderKind(kind) && Boolean(meta?.providerSessionId) + // Requires a VERIFIED external resume form. An unverified template hands + // the user a shell command that may not work, which is worse than not + // offering it — they paste it into a terminal and blame their setup. + return ( + getProviderFeatures(kind).verifiedExternalResumeCommand && + Boolean(meta?.providerSessionId) + ) }, run: ({ workspace }) => void workspace.reloadFocusedAgent(), }, @@ -771,14 +787,15 @@ export const sessionCommands: CommandDef[] = [ description: '**What it does:** Clones the focused **agent session** into a new pane.\n\n**Use when:** You want a parallel branch of the same conversation.\n\n**Notes:** In **Dispatch**, the clone is created as a detached agent.', keywords: ['duplicate', 'clone', 'fork', 'copy', 'session', 'agent'], when: ({ workspace }) => { - // Needs a focused agent session that has a providerSessionId - // — without that id there's nothing on disk to duplicate. + // Needs a providerSessionId (something on disk to duplicate) AND a + // transcript adapter able to project it into a new session. Agent-hood + // alone proves neither. const sessionId = commandTargetSessionId(workspace) if (!sessionId) return false const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER return ( - isAgentProviderKind(kind) && + getProviderFeatures(kind).transcriptDuplicate && Boolean(meta?.providerSessionId) ) }, From d5b7369480484a030e8b1fabb18ef6ba019ec5d3 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 18:16:18 +0200 Subject: [PATCH 13/33] fix(providers): repair the typecheck my Phase 5 commit broke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c3a57a06 shipped claiming "tsc -b clean". It was not. My verification ran `npx tsc -b | head` and read `$?`, which is HEAD's exit status, not tsc's — so 72 errors reported as success. A clean build at 1f16bbe8 (before Phase 5) gives 0 errors; at c3a57a06 it gives 72. Incremental builds hid it locally because the stale .tsc-out skipped the affected project. Root cause: `src/providers/**` is in the NODE tsconfig project, but `providers//renderer/**` is excluded from it because that subtree is renderer-only JSX. `getProviderFeatures` was hung off `registry.renderer.capabilities.ts`, which imports those `.tsx` row and view components, so a node-side test importing it dragged the whole JSX graph into a project that cannot compile it. The fix is better architecture, not a tsconfig patch. Feature capabilities are pure policy data; they never needed the renderer capability registry. They now live in `providers/shared/ featureCapabilities.ts` as one exhaustive `Record`, which is node-safe and readable by command guards, tests, and eventually main. Moving them off the per-provider identity descriptors loses nothing that mattered: the exhaustive Record still fails to compile until a newly added provider answers every capability question, so a provider still cannot inherit features merely by existing. It also fixes a second boundary problem — the identity files are themselves under `renderer/**` and therefore node-excluded, so declaring capabilities there would have kept them unreadable from main forever. Also lands the store channel Phase 4's routing needs: `pendingCommandInvocation` generalizes the palette's private `pendingMenuCommand` so keybinding and native-menu invocations share ONE dispatch path. The keybinding router cannot cheaply build a CommandContext on every keydown (assembling it is the cost the palette avoids while closed, #494), so a chord takes the same route a menu click already does: record the id, let the palette mount its implementation and dispatch. Not yet wired to useKeybinds — that is the next commit. Verification from here on uses `cmd > log; echo $?` rather than piping into head. Verified by CLEAN build (rm -rf .tsc-out): tsc -b exit 0 / 0 errors, check:keybindings exit 0, vitest exit 0 — 238 files / 1530 tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/providers/claude/renderer/identity.ts | 12 --- src/providers/codex/renderer/identity.ts | 12 --- src/providers/opencode/renderer/identity.ts | 21 ----- src/providers/providerFeatures.test.ts | 2 +- .../registry.renderer.capabilities.ts | 30 ------- src/providers/shared/featureCapabilities.ts | 83 +++++++++++++++++++ src/renderer/src/app-state/uiShell/slice.ts | 14 ++++ src/renderer/src/app-state/uiShell/types.ts | 33 ++++++++ .../workspace/commands/sessionCommands.ts | 6 +- 9 files changed, 133 insertions(+), 80 deletions(-) diff --git a/src/providers/claude/renderer/identity.ts b/src/providers/claude/renderer/identity.ts index 03a41d50..960f2a47 100644 --- a/src/providers/claude/renderer/identity.ts +++ b/src/providers/claude/renderer/identity.ts @@ -26,16 +26,4 @@ export const CLAUDE_IDENTITY = { */ resumeCommand: (quotedSessionId: string) => `claude --resume ${quotedSessionId}`, - /** - * Feature capabilities (governance plan Phase 5). Claude has a saved-session - * index, a transcript adapter used by both rewind and duplicate, a verified - * `claude --resume` form, and a translation edge to Codex. - */ - features: { - savedSessionListing: true, - transcriptRewind: true, - transcriptDuplicate: true, - switchTargets: ['codex'], - verifiedExternalResumeCommand: true, - }, } as const diff --git a/src/providers/codex/renderer/identity.ts b/src/providers/codex/renderer/identity.ts index 97e3de9a..8ac99a2f 100644 --- a/src/providers/codex/renderer/identity.ts +++ b/src/providers/codex/renderer/identity.ts @@ -16,16 +16,4 @@ export const CODEX_IDENTITY = { * palette-only split commands. */ splitShortcutKey: 'C', - /** - * Feature capabilities (governance plan Phase 5). Mirrors Claude: saved - * sessions, a transcript adapter, a verified `codex resume` form, and the - * reverse translation edge. - */ - features: { - savedSessionListing: true, - transcriptRewind: true, - transcriptDuplicate: true, - switchTargets: ['claude'], - verifiedExternalResumeCommand: true, - }, } as const diff --git a/src/providers/opencode/renderer/identity.ts b/src/providers/opencode/renderer/identity.ts index 61bb15f3..832f3710 100644 --- a/src/providers/opencode/renderer/identity.ts +++ b/src/providers/opencode/renderer/identity.ts @@ -14,25 +14,4 @@ export const OPENCODE_IDENTITY = { // No splitShortcutKey: chords are scarce; palette split commands // derive automatically (#394 phase 4). - /** - * Feature capabilities (governance plan Phase 5). - * - * Everything is FALSE today, and that is the correction the plan asks for - * rather than a slight. OpenCode has no saved-session listing in main, no - * transcript adapter (so rewind and duplicate have nothing to operate on), - * no switch edge in either direction, and the resumeCommand below is an - * unverified guess — see its own comment. Declaring agent-hood previously - * granted all five features implicitly, so the commands appeared enabled and - * then did nothing. - * - * Flip these individually as each adapter becomes real; do not flip them as - * a group. - */ - features: { - savedSessionListing: false, - transcriptRewind: false, - transcriptDuplicate: false, - switchTargets: [], - verifiedExternalResumeCommand: false, - }, } as const diff --git a/src/providers/providerFeatures.test.ts b/src/providers/providerFeatures.test.ts index fff2c740..cb00427f 100644 --- a/src/providers/providerFeatures.test.ts +++ b/src/providers/providerFeatures.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_PROVIDER_KINDS } from '@shared/types/providerKind' -import { getProviderFeatures } from '@providers/registry.renderer.capabilities' +import { getProviderFeatures } from '@providers/shared/featureCapabilities' // --------------------------------------------------------------------------- // Phase 5: provider capability policy. diff --git a/src/providers/registry.renderer.capabilities.ts b/src/providers/registry.renderer.capabilities.ts index c88e5662..be867d1f 100644 --- a/src/providers/registry.renderer.capabilities.ts +++ b/src/providers/registry.renderer.capabilities.ts @@ -1,4 +1,3 @@ -import type { ProviderFeatureCapabilities } from '@providers/shared/featureCapabilities' import type { ConditionView } from '@shared/conditions-core/view' import type { Entry, ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' import type { ProviderConditionSnapshot } from '@shared/types/providerConditions' @@ -84,14 +83,6 @@ export type RendererProviderCapabilities = { * a scarce resource, palette entries aren't. */ splitShortcutKey?: string - /** - * Declared feature capabilities. REQUIRED — there is deliberately no default. - * A provider added without answering these questions must fail the build - * rather than silently inherit every agent feature, which is exactly how - * OpenCode ended up offering Resume, Rewind, Duplicate, Switch and Copy - * Resume with nothing behind them. - */ - features: ProviderFeatureCapabilities conditionViews: Record normalizeConditions?: (input: { snapshot: ProviderConditionSnapshot | null @@ -362,24 +353,3 @@ export function providerDurableEntryKind( ): ProviderDurableEntryKind | null { return getRendererProviderCapabilities(provider).classifyDurableEntry(entry) } - -/** - * Feature capabilities for a provider kind, or the closed-off defaults for a - * value that is not an agent provider at all (a terminal). - * - * Command `when` guards call THIS rather than `isAgentProviderKind`, which is - * the whole point of Phase 5: membership in AGENT_PROVIDER_KINDS distinguishes - * agents from terminals and grants nothing. - */ -export function getProviderFeatures(kind: string | undefined): ProviderFeatureCapabilities { - if (!kind || !isAgentProviderKind(kind)) { - return { - savedSessionListing: false, - transcriptRewind: false, - transcriptDuplicate: false, - switchTargets: [], - verifiedExternalResumeCommand: false, - } - } - return getRendererProviderCapabilities(kind).features -} diff --git a/src/providers/shared/featureCapabilities.ts b/src/providers/shared/featureCapabilities.ts index fe782315..07bf05bf 100644 --- a/src/providers/shared/featureCapabilities.ts +++ b/src/providers/shared/featureCapabilities.ts @@ -1,3 +1,4 @@ +import { isAgentProviderKind } from '@shared/types/providerKind' import type { AgentProviderKind } from '@shared/types/providerKind' /** @@ -48,3 +49,85 @@ export type ProviderFeatureCapabilities = { */ verifiedExternalResumeCommand: boolean } + +/** + * Nothing. The capability set for a value that is not an agent provider. + * + * Exported so a caller can compare against it rather than hand-writing five + * `false`s and drifting when a sixth capability is added. + */ +export const NO_PROVIDER_FEATURES: ProviderFeatureCapabilities = { + savedSessionListing: false, + transcriptRewind: false, + transcriptDuplicate: false, + switchTargets: [], + verifiedExternalResumeCommand: false, +} + +/** + * The capability matrix, as ONE exhaustive table. + * + * WHY a central table rather than a `features` block on each provider's + * identity descriptor (where this started): the identity files live under + * `providers//renderer/**`, which the node tsconfig project excludes + * because that subtree is renderer-only. Anything node-side reaching them + * fails the build. Capabilities have to be readable by command guards, tests + * and eventually main, so they cannot live behind a renderer-only boundary. + * + * The exhaustive `Record` preserves the property that + * mattered: adding a provider to AGENT_PROVIDER_KINDS fails to compile until + * it answers every question here. A provider still cannot inherit features by + * existing — it has to declare them. + */ +const FEATURES_BY_KIND: Record = { + // Saved-session index, a transcript adapter used by both rewind and + // duplicate, a verified `claude --resume` form, and a translation edge to + // Codex. + claude: { + savedSessionListing: true, + transcriptRewind: true, + transcriptDuplicate: true, + switchTargets: ['codex'], + verifiedExternalResumeCommand: true, + }, + // Mirrors Claude, with the reverse translation edge. + codex: { + savedSessionListing: true, + transcriptRewind: true, + transcriptDuplicate: true, + switchTargets: ['claude'], + verifiedExternalResumeCommand: true, + }, + // Everything false, and that is the correction rather than a slight. + // OpenCode has no saved-session listing in main, no transcript adapter for + // rewind or duplicate to operate on, no switch edge in either direction, and + // its resumeCommand template is an unverified guess (see the note in its + // identity descriptor). Agent-hood previously granted all five implicitly, so + // the commands appeared enabled and then did nothing. + // + // Flip these individually as each adapter becomes real; never as a group. + opencode: { + savedSessionListing: false, + transcriptRewind: false, + transcriptDuplicate: false, + switchTargets: [], + verifiedExternalResumeCommand: false, + }, +} + +/** + * Feature capabilities for a provider kind, or nothing for a terminal or an + * unknown value. + * + * WHY this lives here and NOT on the renderer capability registry, where it + * started: `registry.renderer.capabilities.ts` imports provider `.tsx` row and + * view components, which the node tsconfig project deliberately excludes. A + * node-side file reaching the registry drags that JSX graph into a project + * that cannot compile it — 72 errors, which is exactly what a test importing + * it produced. Keeping capabilities as node-safe data avoids the boundary + * entirely. + */ +export function getProviderFeatures(kind: string | undefined): ProviderFeatureCapabilities { + if (!kind || !isAgentProviderKind(kind)) return NO_PROVIDER_FEATURES + return FEATURES_BY_KIND[kind] +} diff --git a/src/renderer/src/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts index e5ff739f..3c8be282 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -1,6 +1,7 @@ import type { StateCreator } from 'zustand' import type { AppStore, UiShellSlice } from '@renderer/app-state/types' +import type { PendingCommandInvocation } from '@renderer/app-state/uiShell/types' export const createUiShellSlice: StateCreator< AppStore, @@ -50,6 +51,19 @@ export const createUiShellSlice: StateCreator< // no-op. The clamp range in setDispatchListRatio is what enforces // sane bounds when the user actually drags the splitter. dispatchListRatio: 0.25, + pendingCommandInvocation: null, + + // Records the request and opens the palette when it is closed, because the + // palette component is what owns the live CommandContext. `closeAfterRun` + // remembers that it was opened only to service this invocation, so a chord + // does not leave the palette on screen. + requestCommandInvocation: (id: string, source: PendingCommandInvocation['source']) => + set(state => ({ + pendingCommandInvocation: { id, source, closeAfterRun: !state.commandPaletteOpen }, + commandPaletteOpen: true, + }), false, 'uiShell/requestCommandInvocation'), + clearCommandInvocation: () => + set({ pendingCommandInvocation: null }, false, 'uiShell/clearCommandInvocation'), openCommandPalette: () => set({ commandPaletteOpen: true }, false, 'uiShell/openCommandPalette'), diff --git a/src/renderer/src/app-state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts index 26231420..4252e58a 100644 --- a/src/renderer/src/app-state/uiShell/types.ts +++ b/src/renderer/src/app-state/uiShell/types.ts @@ -5,8 +5,41 @@ export type DispatchAttachIntent = { targetTabId: TabId } +/** + * A command waiting to be dispatched through the shared execution gateway. + * + * `source` mirrors the user-driven members of `CommandInvocationSource`. + * Duplicated as a literal union rather than imported so this store slice does + * not take a dependency on the command-execution layer, which imports the + * catalog and would create a cycle through the store. + */ +export type PendingCommandInvocation = { + id: string + source: 'native-menu' | 'keybinding' + /** Close the palette again once the command has run. True when the palette + * was not already open, so a menu click or chord does not leave it visible. */ + closeAfterRun: boolean +} + export type UiShellState = { commandPaletteOpen: boolean + /** + * A command id waiting to be dispatched, with the source that asked for it. + * + * WHY this channel exists: `dispatchCommand` needs a live `CommandContext`, + * and building one is expensive — assembling the ~76 workspace actions and + * flags is exactly the cost `CommandPalette` avoids paying while closed + * (#494). The keybinding router is mounted in the workspace tree and cannot + * cheaply build a context on every keydown. + * + * The native menu already solved this: main emits a bare id, and the palette + * mounts its heavy implementation just long enough to resolve and run it. A + * keypress is equally rare and equally intentional, so it pays the same + * one-time cost by the same route. Generalizing the menu's private + * `pendingMenuCommand` into a store field means keybinding and menu + * invocations share ONE dispatch path rather than growing a second one. + */ + pendingCommandInvocation: PendingCommandInvocation | null pathPickerOpen: boolean pathPickerDefault: string tileTabsModalOpen: boolean diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 1c3aa259..33d4cd30 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -1,8 +1,6 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' -import { - getProviderFeatures, - getRendererProviderCapabilities, -} from '@providers/registry.renderer.capabilities' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' +import { getProviderFeatures } from '@providers/shared/featureCapabilities' import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' import { runSaveDebugBundleCommand } from '@renderer/features/debug/saveDebugBundle' import { runAttachRecordingNoteCommand, runToggleSessionRecordingCommand } from '@renderer/features/debug/attachRecordingNote' From ee76da842e527339e1551e8592a9b5cc4174658d Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 18:21:44 +0200 Subject: [PATCH 14/33] feat(keybindings): route command chords through the execution gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 (routing) of docs/superpowers/plans/2026-07-23-command-surface-audit.md. The keyboard router now resolves EFFECTIVE bindings — shipped defaults overlaid with the user's overrides — and hands the command id to dispatchCommand, instead of calling workspace actions directly. This is what makes a Settings edit real. Until now `CommandDef.shortcut` was a display string and useKeybinds implemented the behaviour separately, so editing a binding would have changed a label while the hardcoded chord kept running. It also closes the second half of the plan's central defect. A chord previously evaluated NONE of the surface/when/renderedView predicates the palette applied to the same command, so a keyboard user could reach a command the palette would have refused. Every source now shares one admission check, one error path, and one history policy. HOW the router reaches a CommandContext: it does not build one. Assembling the ~76 workspace actions and flags is exactly the cost CommandPalette avoids paying while closed (#494), and a keydown handler cannot pay it per keystroke. The native menu already solved this — emit a bare id, let the palette mount its implementation just long enough to dispatch. A keypress is equally rare and equally intentional, so it takes the same route. The palette's private `pendingMenuCommand` is now the store's `pendingCommandInvocation`, carrying its source, so menu and keybinding share ONE dispatch path rather than growing a second one beside it. WHAT IS ROUTED is a closed list of 24 command ids. Everything else in the handler stays where it is, deliberately: Escape dismissal, picker navigation, numbered tab/Dispatch selection, split resizing and the tiled resize continuation are CONTEXTUAL INTERACTIONS, not commands. They have no meaningful palette row, and inventing one for each would produce dozens of fake commands whose only purpose is to shorten a file. They are declared in the reservation registry instead, so a user cannot bind a command on top of them. Removed hardcoded branches: Cmd+Shift+P (which named no command at all), Cmd+Shift+E, Cmd+Shift+T. One bug caught while wiring: `open-command-palette` arrives with closeAfterRun=true, because the palette was shut when the chord fired. Honouring that blindly opened the palette and closed it in the same frame — the chord looked broken. Palette-self commands are now exempt from the close-after-run rule. Still to come in Phase 4: the Settings multi-binding capture editor, and deleting CommandDef.shortcut once the palette renders effective bindings. The 22 legacy shortcut strings still exist and still agree with the defaults, so display is correct today, but they remain a second source of truth until that lands. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 239 files / 1536 tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/renderer/src/app-state/types.ts | 15 ++- .../command-keybindings/routing.test.ts | 69 +++++++++++ .../command-palette/ui/CommandPalette.tsx | 62 ++++++---- .../src/workspace/tile-tree/useKeybinds.ts | 112 ++++++++++++++---- 4 files changed, 208 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/features/command-keybindings/routing.test.ts diff --git a/src/renderer/src/app-state/types.ts b/src/renderer/src/app-state/types.ts index 802bfaba..1942af53 100644 --- a/src/renderer/src/app-state/types.ts +++ b/src/renderer/src/app-state/types.ts @@ -1,5 +1,9 @@ import type { Settings } from '@renderer/app-state/settings/types' -import type { DispatchAttachIntent, UiShellState } from '@renderer/app-state/uiShell/types' +import type { + DispatchAttachIntent, + PendingCommandInvocation, + UiShellState, +} from '@renderer/app-state/uiShell/types' import type { SessionId, TabId } from '@renderer/workspace/types' import type { WorkspaceState } from '@renderer/workspace/types' import type { SessionRuntime } from '@renderer/session-runtime/state' @@ -23,6 +27,15 @@ export type SettingsSlice = { } export type UiShellSlice = UiShellState & { + /** + * Ask for a command to run through the shared execution gateway. + * + * Used by the native-menu bridge and the keybinding router. Both need a live + * CommandContext they cannot cheaply build themselves, so they record the id + * here and the palette — which owns the context — dispatches it. + */ + requestCommandInvocation: (id: string, source: PendingCommandInvocation['source']) => void + clearCommandInvocation: () => void openCommandPalette: () => void closeCommandPalette: () => void toggleCommandPalette: () => void diff --git a/src/renderer/src/features/command-keybindings/routing.test.ts b/src/renderer/src/features/command-keybindings/routing.test.ts new file mode 100644 index 00000000..77e82e24 --- /dev/null +++ b/src/renderer/src/features/command-keybindings/routing.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' + +import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' +import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { PALETTE_SELF_EXCLUDED_COMMAND_IDS } from '@renderer/features/command-palette/commands/paletteCommands' + +// --------------------------------------------------------------------------- +// Phase 4: the keyboard router now resolves EFFECTIVE bindings and hands the +// command id to the execution gateway, instead of calling workspace actions +// directly. +// +// These assert the properties that make a user's rebinding real. The router +// itself is a DOM capture handler; exercising it end to end needs a mounted +// workspace, so what is pinned here is the data contract it consumes — which +// is where the drift the audit found actually lived. +// --------------------------------------------------------------------------- + +const catalogIds = new Set(builtInCommandCatalog.map(c => c.id)) + +describe('routed command bindings', () => { + it('every shipped default names a real catalog command', () => { + // A binding for an id nothing ships is a chord that reserves a key and can + // never fire. + for (const entry of buildDefaultKeybindings()) { + expect(catalogIds.has(entry.commandId)).toBe(true) + } + }) + + it('a user override replaces the chord the router will match', () => { + // THE property that makes Settings real rather than cosmetic. Before this + // phase, editing a binding changed a display string while useKeybinds kept + // running the hardcoded chord. + const effective = resolveEffectiveKeybindings({ 'new-tab': ['Cmd+Shift+9'] }) + const newTab = effective.find(e => e.commandId === 'new-tab') + expect(newTab?.bindings).toEqual(['Cmd+Shift+9']) + expect(newTab?.bindings).not.toContain('Cmd+T') + }) + + it('an explicit unbind leaves the command with no chord', () => { + const effective = resolveEffectiveKeybindings({ 'close-pane': [] }) + expect(effective.find(e => e.commandId === 'close-pane')?.bindings).toEqual([]) + }) + + it('resolves both aliases for a multi-bound command', () => { + // Alt+W was live and undeclared before this work; the router now matches + // it through the same declared set the palette displays. + const effective = resolveEffectiveKeybindings({}) + expect(effective.find(e => e.commandId === 'close-pane')?.bindings) + .toEqual(['Cmd+W', 'Alt+W']) + expect(effective.find(e => e.commandId === 'nav-left')?.bindings) + .toEqual(['Alt+H', 'Alt+Left']) + }) + + it('gives the command palette its own rebindable chord', () => { + // Cmd+Shift+P used to be a hard-coded callback with no command id, so it + // could not be rebound, listed, or collision-checked. + const effective = resolveEffectiveKeybindings({}) + expect(effective.find(e => e.commandId === 'open-command-palette')?.bindings) + .toEqual(['Cmd+Shift+P']) + }) + + it('keeps palette-self commands out of the close-after-run rule', () => { + // Cmd+Shift+P arrives with closeAfterRun=true (the palette was shut when + // the chord fired). Honouring that blindly would open and close it in one + // frame, so the exclusion set is what keeps the chord working. + expect(PALETTE_SELF_EXCLUDED_COMMAND_IDS.has('open-command-palette')).toBe(true) + }) +}) diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index a9b48bc6..55b7e473 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -14,6 +14,7 @@ import { dispatchCommand, dispatchResolvedRow, } from '@renderer/features/command-palette/executeCommand' +import { PALETTE_SELF_EXCLUDED_COMMAND_IDS } from '@renderer/features/command-palette/commands/paletteCommands' import { buildAgentIndexCommand } from '@renderer/features/command-palette/lib/agentIndexCommand' import { buildHistoryScoreMap, @@ -28,6 +29,7 @@ import { secondary, } from '@renderer/features/command-palette/lib/rankEntries' import type { CommandContext, ResolvedCommand } from '@renderer/features/command-palette/types' +import type { PendingCommandInvocation } from '@renderer/app-state/uiShell/types' import { allPromptTemplates, } from '@renderer/features/prompt-templates/templates' @@ -118,11 +120,16 @@ const SHOW_HIDDEN_COMMANDS = false export function CommandPalette() { const open = useAppStore(state => state.commandPaletteOpen) - const openPalette = useAppStore(state => state.openCommandPalette) - const [pendingMenuCommand, setPendingMenuCommand] = useState<{ - id: string - closeAfterRun: boolean - } | null>(null) + // The pending invocation now lives in the STORE rather than in local state + // here, because it no longer belongs only to the native menu. The keybinding + // router is mounted in the workspace tree and needs the same channel: it also + // cannot cheaply build a CommandContext (assembling ~76 workspace actions and + // flags is precisely the cost this component avoids paying while closed, + // #494), so a chord takes the route a menu click already took. One channel, + // one dispatch path — rather than a second one growing beside it. + const pendingCommandInvocation = useAppStore(state => state.pendingCommandInvocation) + const requestCommandInvocation = useAppStore(state => state.requestCommandInvocation) + const clearCommandInvocation = useAppStore(state => state.clearCommandInvocation) useEffect( () => @@ -136,30 +143,27 @@ export function CommandPalette() { // this marker, so menu commands wait instead of competing with its current // search/navigation turn. if (hasAppInteractionOwner()) return - // WHY a native menu command temporarily mounts the open implementation: - // command definitions need live workspace actions, but keeping that entire - // registry subscribed while the palette is closed made every session delta - // rebuild an invisible feature. A menu click is rare and intentional, so it - // may pay the one-time registry cost. useLayoutEffect below runs it and - // closes before paint, avoiding a palette flash for menu-only execution. - setPendingMenuCommand({ id: commandId, closeAfterRun: !open }) - if (!open) openPalette() + // WHY this temporarily mounts the open implementation: command + // definitions need live workspace actions, but keeping that registry + // subscribed while the palette is closed made every session delta + // rebuild an invisible feature. A menu click is rare and intentional, so + // it may pay the one-time registry cost. useLayoutEffect below runs it + // and closes before paint, avoiding a palette flash. + requestCommandInvocation(commandId, 'native-menu') }), - [open, openPalette], + [requestCommandInvocation], ) - const clearPendingMenuCommand = useCallback(() => setPendingMenuCommand(null), []) - // WHY the workspace-heavy component does not exist while closed: returning // null at the bottom of the old monolith was too late. Hooks had already read // the monolithic workspace context, assembled ~76 command dependencies, and - // built the registry. This outer gate subscribes to one boolean plus the - // native bridge; ordinary agent traffic cannot fan into the hidden palette. + // built the registry. This outer gate subscribes to two store fields; + // ordinary agent traffic cannot fan into the hidden palette. if (!open) return null return ( ) } @@ -168,7 +172,7 @@ function OpenCommandPalette({ pendingMenuCommand, onMenuCommandHandled, }: { - pendingMenuCommand: { id: string; closeAfterRun: boolean } | null + pendingMenuCommand: PendingCommandInvocation | null onMenuCommandHandled: () => void }) { // #494: the palette used to receive ~76 props whose only purpose was @@ -896,13 +900,25 @@ function OpenCommandPalette({ useLayoutEffect(() => { if (!pendingMenuCommand) return void dispatchCommand({ + // The SOURCE travels with the request, so a chord is recorded as a + // keybinding invocation and a File-menu click as a native-menu one. A + // hardcoded source here would have made every keyboard invocation look + // like a menu click in personalized history. id: pendingMenuCommand.id, - source: 'native-menu', + source: pendingMenuCommand.source, ctx: commandContext, reportError: message => showToast(message, 6000), }) onMenuCommandHandled() - if (pendingMenuCommand.closeAfterRun) onClose() + // A command whose whole purpose IS the palette must not be closed by the + // "return to where you were" rule. Cmd+Shift+P arrives here with + // closeAfterRun=true (the palette was shut when the chord fired), so + // honouring it blindly would open the palette and shut it in the same + // frame — the chord would look broken. + if (pendingMenuCommand.closeAfterRun + && !PALETTE_SELF_EXCLUDED_COMMAND_IDS.has(pendingMenuCommand.id)) { + onClose() + } }, [commandContext, onClose, onMenuCommandHandled, pendingMenuCommand, showToast]) const executeResume = useCallback( diff --git a/src/renderer/src/workspace/tile-tree/useKeybinds.ts b/src/renderer/src/workspace/tile-tree/useKeybinds.ts index 28090ce6..b35a8cc2 100644 --- a/src/renderer/src/workspace/tile-tree/useKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/useKeybinds.ts @@ -3,6 +3,9 @@ import { getRendererProviderCapabilities } from '@providers/registry.renderer.ca import { useEffect } from 'react' import { useAppStore } from '@renderer/app-state/hooks' +import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' +import { keybindingFromEvent } from '@renderer/features/command-keybindings/normalize' +import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' import { hasAppInteractionOwner } from '@renderer/lib/interaction-ownership' import type { Workspace } from '@renderer/workspace/workspaceStore' import { getEffectiveAgentSurface, isAgentKind } from '@renderer/workspace/agentDisplayMode' @@ -156,6 +159,73 @@ function renderedAgentSurfaceIsVisible( ) } +/** + * Command chords this router hands to the execution gateway instead of calling + * a workspace action directly. + * + * WHY only these, and not every branch in the handler below: the rest are + * CONTEXTUAL INTERACTIONS, not commands — Escape dismissal, picker + * navigation, numbered tab/Dispatch selection, split resizing, the tiled + * resize continuation. They have no meaningful palette row, and inventing one + * for each would produce dozens of fake commands whose only purpose is to + * shorten this file. They stay here and are declared in the reservation + * registry so a user cannot bind a command on top of them. + * + * WHY routing at all: before this, a chord called `workspace.splitFocused()` + * directly, evaluating none of the surface/when/renderedView predicates the + * palette applied to the same command. A keyboard user could reach a command + * the palette would have refused. Routing through the gateway gives every + * source one admission check, one error path, and one history policy — and is + * what makes a user-configured binding actually run the command it names. + */ +const ROUTED_COMMAND_IDS: ReadonlySet = new Set([ + 'open-command-palette', + 'new-tab', + 'close-tab', + 'next-tab', + 'prev-tab', + 'resume-session', + 'undo-close', + 'close-pane', + 'split-vertical', + 'split-horizontal', + 'terminal-horizontal', + 'terminal-vertical', + 'codex-vertical', + 'codex-horizontal', + 'nav-left', + 'nav-right', + 'nav-up', + 'nav-down', + 'toggle-global-editor', + 'quick-open-file', + 'search-in-files', + 'toggle-editor-fullscreen', + 'jump-latest-message', + 'open-settings', +]) + +/** + * The command id a keyboard event should invoke, or null. + * + * Built from EFFECTIVE bindings (shipped defaults overlaid with the user's + * overrides), which is the whole point: the chord the palette displays and the + * chord that runs are now the same fact, and a Settings edit changes real + * behavior rather than a label. + */ +function routedCommandForEvent( + event: KeyboardEvent, + overrides: Record, +): string | null { + const binding = keybindingFromEvent(event) + if (!binding) return null + for (const entry of resolveEffectiveKeybindings(overrides, buildDefaultKeybindings())) { + if (!ROUTED_COMMAND_IDS.has(entry.commandId)) continue + if (entry.bindings.includes(binding)) return entry.commandId + } + return null +} + export function useKeybinds( workspace: Workspace, onNewTabRequest: NewTabRequester, @@ -163,6 +233,10 @@ export function useKeybinds( onCommandPalette?: CommandPaletteToggle, ): void { const settingsPageOpen = useAppStore(state => state.settingsPageOpen) + const requestCommandInvocation = useAppStore(state => state.requestCommandInvocation) + const commandKeybindingOverrides = useAppStore( + state => state.settings.commandKeybindingOverrides, + ) const agentViewMode = useAppStore(state => state.settings.agentViewMode) const closeSettingsPage = useAppStore(state => state.closeSettingsPage) const buryPromptSessionId = useAppStore(state => state.buryPromptSessionId) @@ -277,24 +351,20 @@ export function useKeybinds( return } - // --- CMD: command palette --- - if (cmd && shift && k.toLowerCase() === 'p' && !alt) { - e.preventDefault() - onCommandPalette?.() - return - } - - // --- Cmd+Shift+E: Global Editor toggle --- + // --- Configured command bindings --- // - // WHY this specific chord: ⌘E is taken by tile-resize, ⌘⇧E was - // unused, and it mirrors VS Code's "Explorer" muscle memory for - // users coming from an IDE. The toggle is global — no `when` - // guard, no mode dependence — because Global Editor is - // orthogonal to dispatch / tile / spotlight (it WRAPS them - // rather than replacing them). - if (cmd && shift && k.toLowerCase() === 'e' && !alt) { + // Placed AFTER the app-modal and placement-overlay bailouts above, so a + // dialog still owns the interaction turn, and BEFORE every hardcoded + // branch below, so a user's rebinding wins over the legacy chord it + // replaces. Editor-owned targets are handled further down and never + // reach here for the chords the editor claims. + // + // The gateway re-checks admission, so a chord can no longer reach a + // command the palette would have refused. + const routedCommandId = routedCommandForEvent(e, commandKeybindingOverrides) + if (routedCommandId) { e.preventDefault() - toggleGlobalEditor() + requestCommandInvocation(routedCommandId, 'keybinding') return } @@ -539,16 +609,6 @@ export function useKeybinds( return } - // --- CMD: undo close (⌘⇧T) --- - // Same shortcut as Chrome's "reopen closed tab". Pops the most - // recent entry from the undo-close stack and restores it — either - // re-splitting a pane in place or re-inserting a whole tab. - if (cmd && shift && k.toLowerCase() === 't' && !alt) { - e.preventDefault() - void workspace.undoClose() - return - } - // --- CMD: tab management --- if (cmd && alt && !shift) { const digit = digitFromKeyboardEvent(e) From 1d25ffa46669d67860f2cc408762661554503d78 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 18:25:26 +0200 Subject: [PATCH 15/33] feat(keybindings): derive displayed chords, delete CommandDef.shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last structural gap in Phase 4 of the command-governance plan. `CommandDef.shortcut` is gone. It was a hand-authored display string with no relationship to the code that ran — the audit's core keybinding finding. The palette could advertise Cmd+S for a command Monaco actually implemented, four Option+Arrow aliases ran with nothing advertising them, and Cmd+Shift+E worked while its row showed no chord at all. `ResolvedCommand.shortcut` is now DERIVED in buildCommandRegistry from the effective binding set — shipped defaults overlaid with the user's overrides — which is the same set the router matches against. The chord the palette shows and the chord that runs are one fact, structurally, rather than two that happened to agree. That also makes the Settings editor meaningful before it exists: an override already changes both display and behaviour, so the remaining UI work is a control over a contract that already holds. 20 authored strings removed across the command modules (the other 2 were the generated provider splits, which built theirs from a template). Overrides are threaded through CommandContext.flags so the registry can resolve without importing the settings store. check:keybindings section 7 flips from a soft note to a hard failure. It was advisory while the legacy strings still existed — failing then would have blocked the very commits removing them. Now that they are gone, reintroducing an authored `shortcut` rebuilds exactly the drift this phase eliminated, so it fails the build. keybindingBaseline.test.ts is rewritten rather than deleted. Its load-bearing assertion used to compare the recorded drift table against `CommandDef.shortcut`; it now checks that every chord the table recorded as REALLY RUNNING is present in the effective set. That is the migration's actual claim — behaviour unchanged, source of truth moved — and a chord that silently stopped working fails there. The pre-migration table is kept as the evidence for what the defaults had to preserve. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 239 files / 1536 tests. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check-command-keybindings.mts | 27 ++++--- .../keybindingBaseline.test.ts | 81 ++++++++++++------- .../src/features/command-palette/registry.ts | 22 ++++- .../testing/commandContextHarness.ts | 1 + .../src/features/command-palette/types.ts | 16 +++- .../command-palette/ui/CommandPalette.tsx | 3 + .../commands/globalEditorCommands.ts | 4 - .../workspace/commands/paneCommands.ts | 13 --- .../workspace/commands/tabCommands.ts | 5 -- 9 files changed, 110 insertions(+), 62 deletions(-) diff --git a/scripts/check-command-keybindings.mts b/scripts/check-command-keybindings.mts index 7f9398e0..303481fa 100644 --- a/scripts/check-command-keybindings.mts +++ b/scripts/check-command-keybindings.mts @@ -122,17 +122,24 @@ for (const approved of listApprovedOverlaps()) { // --- 7. Display metadata must not come back --------------------------------- // The audit's core keybinding finding was that `CommandDef.shortcut` was a -// display string with no relationship to the code that ran. Once the migration -// removes it, a reintroduced `shortcut` would rebuild exactly that drift, so -// the check is structural rather than advisory. +// display string with no relationship to the code that ran, so the palette +// could advertise a chord the editor implemented and a user's Settings edit +// would change nothing. // -// Until the migration completes, this reports the remaining count instead of -// failing — a hard failure here would block the very commits that remove them. -const withLegacyShortcut = builtInCommandCatalog.filter(command => command.shortcut !== undefined) -if (withLegacyShortcut.length > 0) { - console.log( - `note: ${withLegacyShortcut.length} command(s) still declare display-only \`shortcut\` metadata. ` - + 'These are superseded by defaultKeybindings and are removed as the migration completes.', +// The migration deleted the field; `ResolvedCommand.shortcut` is now DERIVED +// from effective bindings at resolve time. This was a soft note while the 22 +// legacy strings still existed (a hard failure would have blocked the very +// commits that removed them). Now that they are gone it is a hard failure, +// because reintroducing an authored `shortcut` would rebuild exactly the drift +// this whole phase existed to eliminate. +const withLegacyShortcut = builtInCommandCatalog.filter( + command => 'shortcut' in command, +) +for (const command of withLegacyShortcut) { + fail( + `${command.id}: declares an authored \`shortcut\` field. Display chords are ` + + 'derived from defaultKeybindings — an authored string is a second source of ' + + 'truth that cannot stay in sync with the router.', ) } diff --git a/src/renderer/src/features/command-palette/keybindingBaseline.test.ts b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts index d185de5e..6071de7c 100644 --- a/src/renderer/src/features/command-palette/keybindingBaseline.test.ts +++ b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' // --------------------------------------------------------------------------- // Phase 0 characterization: THE SHORTCUT AUTHORITY SPLIT. @@ -277,41 +278,65 @@ describe('shortcut metadata baseline', () => { }) it('records the declared shortcut of every command that has one', () => { - // THE LOAD-BEARING ASSERTION. The declared column is derived from the real - // catalog, so adding, removing, or editing a `shortcut:` field in any - // command module fails here and forces the drift table to be updated in the - // same commit. Without this the table would be a comment that rots. + // THE LOAD-BEARING ASSERTION, now inverted. // - // Compared sorted by id, NOT in registration order: the table above is - // grouped by theme (tabs, panes, navigation, editor) because that is what - // makes the drift readable, while the catalog is in palette-browse order. - // Registration order is already pinned by catalog.test.ts, so re-asserting - // it here would only couple this table's layout to an unrelated concern. - const byId = (a: { commandId: string }, b: { commandId: string }) => - a.commandId < b.commandId ? -1 : a.commandId > b.commandId ? 1 : 0 - - const declaredInCatalog = builtInCommandCatalog - .filter(c => c.shortcut !== undefined) - .map(c => ({ commandId: c.id, declared: c.shortcut ?? null })) - .sort(byId) - - const declaredInBaseline = BINDING_BASELINE - .filter(e => e.declared !== null) - .map(e => ({ commandId: e.commandId, declared: e.declared })) - .sort(byId) + // At the baseline this compared the table's `declared` column against + // `CommandDef.shortcut`, so editing an authored string forced the drift + // table to be updated. That field NO LONGER EXISTS — the migration + // deleted it — so the assertion now checks the thing that replaced it: + // every chord the table recorded as REALLY RUNNING is present in the + // effective binding set the router and the palette both consume. + // + // That is the migration's core claim, checked rather than asserted in + // prose: the behaviour did not change, only its source of truth. A chord + // that silently stopped working would fail here. + const effective = new Map( + resolveEffectiveKeybindings({}).map(e => [e.commandId, e.bindings]), + ) - expect(declaredInBaseline).toEqual(declaredInCatalog) + const missing: string[] = [] + for (const entry of BINDING_BASELINE) { + // Save is editor-owned and still implemented by Monaco/EditorWorkbench + // rather than routed, so it has a default but its runtime path is + // unchanged. Excluded here and tracked separately below. + if (entry.owner !== 'useKeybinds') continue + const now = effective.get(entry.commandId) ?? [] + for (const chord of entry.effective) { + const canonical = DISPLAY_TO_CANONICAL[chord] + if (!canonical) continue + if (!now.includes(canonical)) missing.push(`${entry.commandId}:${chord}`) + } + } + expect(missing).toEqual([]) }) - it('counts 22 commands declaring display-only shortcut metadata', () => { - // 80 of the 102 commands ship no chord at all. Scarcity is deliberate and - // the plan preserves it: defaults are muscle memory, not an allocation of - // every free chord. - expect(builtInCommandCatalog.filter(c => c.shortcut !== undefined)).toHaveLength(22) + it('no longer carries any authored display-only shortcut metadata', () => { + // The audit's core keybinding finding was that `CommandDef.shortcut` was a + // display string with no relationship to the code that ran. It is gone: + // `ResolvedCommand.shortcut` is now DERIVED from effective bindings at + // resolve time, so the chord the palette shows is the chord that runs. + for (const command of builtInCommandCatalog) { + expect('shortcut' in command).toBe(false) + } }) }) -describe('recorded authority drift', () => { +/** Display chords in the baseline table, mapped to the canonical grammar. */ +const DISPLAY_TO_CANONICAL: Record = { + '⌘T': 'Cmd+T', '⌘⇧W': 'Cmd+Shift+W', '⌘]': 'Cmd+]', '⌘[': 'Cmd+[', + '⌘⇧R': 'Cmd+Shift+R', '⌘⇧T': 'Cmd+Shift+T', '⌘W': 'Cmd+W', '⌥W': 'Alt+W', + '⌥D': 'Alt+D', '⌥⇧D': 'Alt+Shift+D', '⌥T': 'Alt+T', '⌥⇧T': 'Alt+Shift+T', + '⌥C': 'Alt+C', '⌥⇧C': 'Alt+Shift+C', + '⌥H': 'Alt+H', '⌥←': 'Alt+Left', '⌥L': 'Alt+L', '⌥→': 'Alt+Right', + '⌥K': 'Alt+K', '⌥↑': 'Alt+Up', '⌥J': 'Alt+J', '⌥↓': 'Alt+Down', + 'End': 'End', '⌘⇧E': 'Cmd+Shift+E', '⌥⌘E': 'Cmd+Alt+E', + '⌘P': 'Cmd+P', '⌘⇧F': 'Cmd+Shift+F', +} + +// HISTORICAL: these describe the state BEFORE the migration. The table is +// kept because it is the evidence for what the defaults had to preserve, and +// the assertions still hold over the table itself. +describe('recorded authority drift (pre-migration history)', () => { it('lists exactly the six commands whose real chords exceed their metadata', () => { // Undeclared aliases: the palette under-reports what the keyboard does. const underReported = BINDING_BASELINE.filter( diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index 12e892d1..bcc5708b 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -1,6 +1,8 @@ import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' import { PALETTE_SELF_EXCLUDED_COMMAND_IDS } from '@renderer/features/command-palette/commands/paletteCommands' import { declaredTier, isVisibleInPicker } from '@renderer/features/command-palette/pickerVisibility' +import { displayKeybinding } from '@renderer/features/command-keybindings/normalize' +import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' import { commandAllowedByRenderedViewPolicy } from '@renderer/workspace/agentDisplayMode' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' import type { @@ -149,6 +151,14 @@ function renderedViewAvailable(command: CommandDef, ctx: CommandContext): boolea } export function buildCommandRegistry(ctx: CommandContext): ResolvedCommand[] { + // Built once per registry pass rather than per command: resolving the + // effective set walks every default, so doing it inside the map would be + // O(commands x defaults) on every palette keystroke. + const effective = new Map( + resolveEffectiveKeybindings(ctx.flags.commandKeybindingOverrides).map( + entry => [entry.commandId, entry.bindings], + ), + ) return commandDefs .filter(command => !PALETTE_SELF_EXCLUDED_COMMAND_IDS.has(command.id)) .filter(command => commandApplicable(command, ctx) && commandVisible(command, ctx)) @@ -162,7 +172,11 @@ export function buildCommandRegistry(ctx: CommandContext): ResolvedCommand[] { title: typeof command.title === 'function' ? command.title(ctx) : command.title, description, surface: command.surface, - shortcut: command.shortcut, + // The FIRST effective binding, in display form. A command may have + // several (Close Pane has Cmd+W and Alt+W); the row shows one, and the + // first is the primary by declaration order. Undefined when the command + // has no chord, which is most of the catalog. + shortcut: displayBinding(effective.get(command.id)), keywords: command.keywords ?? [], keepPaletteOpen: command.keepPaletteOpen === true, state: command.getState ? command.getState(ctx) : null, @@ -217,3 +231,9 @@ export function listPickerCommandMeta(): PickerCommandMeta[] { ...(command.commandGroup ? { commandGroup: command.commandGroup } : {}), })) } + +/** First effective binding as a display chord, or undefined when unbound. */ +function displayBinding(bindings: readonly string[] | undefined): string | undefined { + const first = bindings?.[0] + return first ? displayKeybinding(first) : undefined +} diff --git a/src/renderer/src/features/command-palette/testing/commandContextHarness.ts b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts index 0e54c9c5..0cb26e4f 100644 --- a/src/renderer/src/features/command-palette/testing/commandContextHarness.ts +++ b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts @@ -132,6 +132,7 @@ export function makeTestCommandContext( // default that differs from production. Group behavior has its own tests // that set this explicitly. navigationCommandsEnabled: true, + commandKeybindingOverrides: {}, showHiddenCommands: false, ...options.flags, }, diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index ec031cc9..0723bc73 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -342,6 +342,12 @@ export type CommandContext = { * for why it must never reach execution or keyboard handling. */ navigationCommandsEnabled: boolean + /** + * The user's persisted per-command binding overrides. Threaded through + * flags so the registry can render EFFECTIVE bindings — the chord that + * will actually run — rather than a static authored string. + */ + commandKeybindingOverrides: Record /** * Global escape hatch: when true, the picker shows EVERY applicable * command regardless of declared visibility or per-command override. @@ -419,7 +425,6 @@ export type CommandDef = { * those, and explaining them would add noise to every mode switch. */ unavailableReason?: (ctx: CommandContext) => CommandUnavailable | null - shortcut?: string keywords?: string[] keepPaletteOpen?: boolean when?: (ctx: CommandContext) => boolean @@ -440,6 +445,15 @@ export type ResolvedCommand = { /** Carried through from CommandDef so palette/menu consumers can * group or label by surface without re-importing the raw defs. */ surface: CommandSurface + /** + * The chord this command will actually run, in display form, or undefined + * when it has none. + * + * DERIVED at resolve time from the effective binding set, never authored. + * It used to be a hand-written `CommandDef.shortcut` string with no + * relationship to the code that ran, so the palette could advertise a chord + * the editor implemented and a user's Settings edit changed nothing. + */ shortcut?: string keywords: string[] keepPaletteOpen: boolean diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index 55b7e473..b87425a2 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -264,6 +264,7 @@ function OpenCommandPalette({ const agentViewMode = settings.agentViewMode const commandVisibilityOverrides = settings.commandVisibilityOverrides const navigationCommandsEnabled = settings.navigationCommandsEnabled + const commandKeybindingOverrides = settings.commandKeybindingOverrides const showHiddenCommands = SHOW_HIDDEN_COMMANDS const statusModeEnabled = settings.showStatusMode const worktreeBadgesEnabled = settings.showWorktreeBadges @@ -583,6 +584,7 @@ function OpenCommandPalette({ agentViewMode, commandVisibilityOverrides, navigationCommandsEnabled, + commandKeybindingOverrides, showHiddenCommands, }, }), @@ -671,6 +673,7 @@ function OpenCommandPalette({ agentViewMode, commandVisibilityOverrides, navigationCommandsEnabled, + commandKeybindingOverrides, showHiddenCommands, ], ) diff --git a/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts b/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts index 620ade80..57e1b7ed 100644 --- a/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts +++ b/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts @@ -50,7 +50,6 @@ export const globalEditorCommands: CommandDef[] = [ description: '**What it does:** Saves the active file in the visible Global Editor or AI Workspace.\n\n**Use when:** You edited a file and want to persist it without leaving the command palette.\n\n**Notes:** Conflict checks and recovery are owned by the active editor surface.\n\n**Shortcut:** ⌘S.', keywords: ['save', 'write', 'editor', 'file'], - shortcut: '⌘S', when: ({ flags }) => flags.globalEditorOpen, run: requestSaveActiveEditorFile, }, @@ -73,7 +72,6 @@ export const globalEditorCommands: CommandDef[] = [ description: "**What it does:** Fuzzy-finds a file by name in the focused agent's project and opens it in the **Global Editor**.\n\n**Use when:** You know (roughly) the file name and don't want to click through the tree.\n\n**Notes:** Opens the editor overlay if it isn't already open. The index skips junk directories (node_modules, build output, VCS internals) and caps at 20k files.\n\n**Shortcut:** ⌘P.", keywords: ['quick open', 'go to file', 'find file', 'fuzzy', 'open file'], - shortcut: '⌘P', when: ({ flags }) => Boolean(useGlobalEditorStore.getState().activeCwd ?? flags.focusedCwd), run: ({ ui, flags }) => { const editor = useGlobalEditorStore.getState() @@ -96,7 +94,6 @@ export const globalEditorCommands: CommandDef[] = [ description: "**What it does:** Searches file contents across the focused agent's project and opens matches in the **Global Editor** at the matched line.\n\n**Use when:** You're hunting a string or identifier across the project.\n\n**Notes:** Bounded scan (skips >1MB files and junk dirs; caps at 500 matches / 20k files). Case-sensitivity toggle lives in the overlay.\n\n**Shortcut:** ⌘⇧F.", keywords: ['search', 'grep', 'find in files', 'content search', 'ripgrep'], - shortcut: '⌘⇧F', when: ({ flags }) => Boolean(useGlobalEditorStore.getState().activeCwd ?? flags.focusedCwd), run: ({ ui, flags }) => { const editor = useGlobalEditorStore.getState() @@ -119,7 +116,6 @@ export const globalEditorCommands: CommandDef[] = [ description: '**What it does:** Expands the **Global Editor** to fill the whole workspace area. The normal workspace stays alive underneath (hidden, not unmounted — terminals and feeds keep running).\n\n**Use when:** You want maximum reading/editing room for a while.\n\n**Notes:** Esc exits fullscreen; the previous split ratio is restored.\n\n**Shortcut:** ⌥⌘E.', keywords: ['fullscreen', 'maximize', 'editor', 'zen', 'focus'], - shortcut: '⌥⌘E', when: ({ flags }) => flags.globalEditorOpen, getState: ({ flags }) => ({ label: flags.editorFullscreen ? 'On' : 'Off', diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index 6d62868b..82096d43 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -53,7 +53,6 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'Split Pane Right', description: '**What it does:** Creates a **new agent pane on the right**.\n\n**Use when:** You want side-by-side work in the grid.\n\n**Notes:** In **Dispatch**, this creates a detached agent instead.', - shortcut: '⌥D', run: ({ workspace }) => void workspace.splitFocused('vertical'), }, { @@ -62,7 +61,6 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'Split Pane Down', description: '**What it does:** Creates a **new agent pane below**.\n\n**Use when:** You want a stacked grid layout.\n\n**Notes:** In **Dispatch**, this creates a detached agent instead.', - shortcut: '⌥⇧D', run: ({ workspace }) => void workspace.splitFocused('horizontal'), }, { @@ -74,7 +72,6 @@ export const paneCommands: CommandDef[] = [ surface: 'session', title: 'Close Pane', description: '**What it does:** Closes the **currently targeted pane or Dispatch row**.\n\n**Use when:** You are done with the current target.\n\n**Notes:** In **Dispatch**, the highlighted row is the close target.', - shortcut: '⌘W', run: ({ workspace }) => void workspace.closeFocused(), }, { @@ -277,7 +274,6 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'New Terminal Right', description: '**What it does:** Opens a **terminal on the right**.\n\n**Use when:** You need a shell beside the current pane.\n\n**Notes:** From **Dispatch**, the terminal attaches to the focused row or lane’s project grid.', - shortcut: '⌥T', run: ({ workspace }) => void workspace.splitFocused('vertical', 'terminal'), }, { @@ -286,7 +282,6 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'New Terminal Below', description: '**What it does:** Opens a **terminal below**.\n\n**Use when:** You need a shell under the current pane.\n\n**Notes:** From **Dispatch**, the terminal attaches to the focused row or lane’s project grid.', - shortcut: '⌥⇧T', run: ({ workspace }) => void workspace.splitFocused('horizontal', 'terminal'), }, // Per-provider split commands, generated for every registered agent @@ -310,7 +305,6 @@ export const paneCommands: CommandDef[] = [ category: 'create' as const, title: `New ${caps.shortLabel} Right`, description: `**What it does:** Opens a **${caps.shortLabel} agent on the right**.\n\n**Use when:** You want ${caps.shortLabel} beside the current agent.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`, - ...(chord ? { shortcut: `⌥${chord}` } : {}), run: ({ workspace }: CommandContext) => void workspace.splitFocused('vertical', kind), }, @@ -320,7 +314,6 @@ export const paneCommands: CommandDef[] = [ category: 'create' as const, title: `New ${caps.shortLabel} Below`, description: `**What it does:** Opens a **${caps.shortLabel} agent below**.\n\n**Use when:** You want ${caps.shortLabel} in a stacked layout.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`, - ...(chord ? { shortcut: `⌥⇧${chord}` } : {}), run: ({ workspace }: CommandContext) => void workspace.splitFocused('horizontal', kind), }, @@ -341,7 +334,6 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'Focus Pane Left', description: '**What it does:** Focuses the pane to the **left**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.', - shortcut: '⌥H', run: ({ workspace }) => workspace.navigate('left'), }, { @@ -351,7 +343,6 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'Focus Pane Right', description: '**What it does:** Focuses the pane to the **right**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.', - shortcut: '⌥L', run: ({ workspace }) => workspace.navigate('right'), }, { @@ -361,7 +352,6 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'Focus Pane Up', description: '**What it does:** Focuses the pane **above**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.', - shortcut: '⌥K', run: ({ workspace }) => workspace.navigate('up'), }, { @@ -371,7 +361,6 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'Focus Pane Down', description: '**What it does:** Focuses the pane **below**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.', - shortcut: '⌥J', run: ({ workspace }) => workspace.navigate('down'), }, { @@ -380,7 +369,6 @@ export const paneCommands: CommandDef[] = [ surface: 'app', title: 'Undo Close', description: '**What it does:** Restores the most recent closed **pane or tab** from a small recent-close history.\n\n**Use when:** You closed something by mistake, or repeat it to walk back through earlier closes.\n\n**Notes:** Also restores detached **Dispatch** agents captured with a closed tab.', - shortcut: '⌘⇧T', run: ({ workspace }) => void workspace.undoClose(), }, { @@ -486,7 +474,6 @@ export const paneCommands: CommandDef[] = [ surface: 'session', title: 'Jump to Latest Message', description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only.', - shortcut: 'End', renderedViewPolicy: { kind: 'requires-rendered-feed' }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) diff --git a/src/renderer/src/features/workspace/commands/tabCommands.ts b/src/renderer/src/features/workspace/commands/tabCommands.ts index 3cf3482c..d3dce64b 100644 --- a/src/renderer/src/features/workspace/commands/tabCommands.ts +++ b/src/renderer/src/features/workspace/commands/tabCommands.ts @@ -7,7 +7,6 @@ export const tabCommands: CommandDef[] = [ surface: 'app', title: 'New Tab', description: '**What it does:** Creates a **new tab** from a folder you choose.\n\n**Use when:** You want a separate project or workspace context.\n\n**Notes:** Starts a fresh agent in that folder.', - shortcut: '⌘T', run: ({ ui }) => ui.openNewTabPicker(), }, { @@ -16,7 +15,6 @@ export const tabCommands: CommandDef[] = [ surface: 'app', title: 'Close Tab', description: '**What it does:** Closes the **current tab** and its sessions.\n\n**Use when:** You are done with a whole project tab.\n\n**Notes:** Use **Undo Close** if you closed it by mistake.', - shortcut: '⌘⇧W', run: ({ workspace }) => { if (workspace.activeTab) void workspace.closeTab(workspace.activeTab.id) }, @@ -28,7 +26,6 @@ export const tabCommands: CommandDef[] = [ surface: 'app', title: 'Next Tab', description: '**What it does:** Moves focus to the **next tab**.\n\n**Use when:** You want quick tab navigation.\n\n**Notes:** Works from the normal workspace surfaces.', - shortcut: '⌘]', run: ({ workspace }) => workspace.nextTab(), }, { @@ -38,7 +35,6 @@ export const tabCommands: CommandDef[] = [ surface: 'app', title: 'Previous Tab', description: '**What it does:** Moves focus to the **previous tab**.\n\n**Use when:** You want quick tab navigation.\n\n**Notes:** Works from the normal workspace surfaces.', - shortcut: '⌘[', run: ({ workspace }) => workspace.prevTab(), }, { @@ -57,7 +53,6 @@ export const tabCommands: CommandDef[] = [ surface: 'app', title: 'Resume Session', description: '**What it does:** Opens the **resume session** flow.\n\n**Use when:** You want to continue an old Claude or Codex session.\n\n**Notes:** Uses the focused project folder as the default.', - shortcut: '⌘⇧R', keepPaletteOpen: true, run: ({ ui }) => ui.enterResumeMode(), }, From b027b8e54cb4aeefff5e5f744566c64b3c5b280c Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 18:29:59 +0200 Subject: [PATCH 16/33] feat(settings): add the built-in command keybinding editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Phase 4 of the command-governance plan. Commands & Shortcuts is now a searchable, category-grouped list of every built-in command, each with zero, one, or several bindings: chips with Remove, "Not assigned" when empty, Add via keyboard capture, per-command Reset, and page-level Reset All. "Keybind control of all commands" means exactly that and no more. Escape dismissal, picker navigation, numbered selection and split resizing stay contextual interactions — they appear here only as CONFLICT OWNERS, never as editable rows. Turning them into commands would produce dozens of fake palette entries whose only purpose is to make a file shorter. WHY this does not reuse HotkeyInput: dictation captures modifier-only holds (bare Cmd is a legitimate push-to-talk binding) and commits them on a settle timer. A command chord fires on keydown and can never be modifier-only, so inheriting that policy would offer users bindings the router cannot match. The two capture surfaces share the normalizer, not the interaction model. Capture listens on the window in CAPTURE phase and both preventDefault and stopPropagation, because the workspace router is listening there too. Without that, the chord being recorded would also execute — pressing Cmd+W to bind it would close the pane behind the Settings page. Conflicts BLOCK the save and name the owner. "That shortcut is taken" is not actionable; "Cmd+T is used by New Tab" is. Registration order, last-write-wins and silent precedence are all forbidden, so the only override path is an explicit Replace, which strips the chord from every prior owner and installs it on the requester in ONE settings write — the store can never be observed with a chord owned twice or by nobody. Reserved interactions offer no Replace at all: Escape, native menu roles and editor-native keys are not ours to reassign. Conflict lookup consumes the EFFECTIVE set, not the shipped defaults, so a chord the user already moved onto another command still conflicts. Checking the shipped table alone would let two of the user's own commands silently share a chord. It also checks the CURRENT PROFILE's dictation hotkey — the repository script checks the shipped default; same function, different input, which is the point of sharing one collision engine. Rows exist for commands the palette never renders, including open-command-palette: they are reachable by chord, menu and programmatic call, so they are bindable. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 240 files / 1547 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../command-keybindings/editor.test.ts | 151 ++++++++ .../features/settings/lib/settingsRegistry.ts | 32 ++ .../settings/ui/CommandKeybindingsRow.tsx | 360 ++++++++++++++++++ .../src/features/settings/ui/SettingsList.tsx | 3 + 4 files changed, 546 insertions(+) create mode 100644 src/renderer/src/features/command-keybindings/editor.test.ts create mode 100644 src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx diff --git a/src/renderer/src/features/command-keybindings/editor.test.ts b/src/renderer/src/features/command-keybindings/editor.test.ts new file mode 100644 index 00000000..c6a3b2f0 --- /dev/null +++ b/src/renderer/src/features/command-keybindings/editor.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' + +import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' +import { findBindingOwners } from '@renderer/features/command-keybindings/reservations' +import { + resetCommandKeybindings, + resolveEffectiveKeybindings, + setCommandKeybindings, +} from '@renderer/features/command-keybindings/resolve' +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' + +// --------------------------------------------------------------------------- +// The Settings keybinding editor's BEHAVIOUR contract. +// +// The component is a thin shell over these functions; what matters — and what +// the audit's rules constrain — is the policy underneath. Testing the policy +// directly keeps these assertions honest without mounting a Settings page. +// --------------------------------------------------------------------------- + +const defaults = buildDefaultKeybindings() +const effectiveAsDefaults = (overrides: Record) => + resolveEffectiveKeybindings(overrides, defaults) + +describe('conflict detection', () => { + it('names the command that already owns a chord', () => { + // "That shortcut is taken" is not actionable. The editor must be able to + // say WHICH command, so the owner id has to come back rather than a bool. + const owners = findBindingOwners({ + binding: 'Cmd+T', + context: 'global', + commandDefaults: effectiveAsDefaults({}), + excludeCommandId: 'some-other-command', + }) + expect(owners.map(o => o.id)).toContain('new-tab') + }) + + it('names a reserved interaction the user cannot reassign', () => { + const owners = findBindingOwners({ + binding: 'Escape', + context: 'global', + commandDefaults: effectiveAsDefaults({}), + }) + expect(owners.every(o => o.kind === 'reserved')).toBe(true) + }) + + it('detects a clash with the user OWN rebinding, not just the shipped table', () => { + // The editor passes the EFFECTIVE set, so a chord the user moved onto + // another command still conflicts. Checking shipped defaults alone would + // let two of the user's own commands silently share a chord. + const overrides = { 'toggle-git-bar': ['Cmd+Shift+9'] } + const owners = findBindingOwners({ + binding: 'Cmd+Shift+9', + context: 'global', + commandDefaults: effectiveAsDefaults(overrides), + excludeCommandId: 'toggle-debug-panel', + }) + expect(owners.map(o => o.id)).toContain('toggle-git-bar') + }) + + it('does not report a command as conflicting with itself', () => { + // Re-saving an unchanged binding must not be blocked. + const owners = findBindingOwners({ + binding: 'Cmd+T', + context: 'global', + commandDefaults: effectiveAsDefaults({}), + excludeCommandId: 'new-tab', + }) + expect(owners.map(o => o.id)).not.toContain('new-tab') + }) + + it('detects a clash with the current dictation hotkey', () => { + // Settings checks the CURRENT PROFILE's dictation binding; the repository + // script checks the shipped default. Same function, different input. + const owners = findBindingOwners({ + binding: 'Cmd+Shift+D', + context: 'global', + commandDefaults: effectiveAsDefaults({}), + dictationBinding: 'Cmd+Shift+D', + }) + expect(owners.map(o => o.id)).toContain('Voice dictation hotkey') + }) +}) + +describe('atomic Replace', () => { + it('moves a chord in one write, never leaving it owned twice', () => { + // Modelled exactly as the editor applies it: strip from every prior owner, + // then install on the requester, producing ONE settings object. A + // two-step write could be observed with the chord owned twice or by + // nobody. + let next: Record = {} + const victim = resolveEffectiveKeybindings({}, defaults) + .find(e => e.commandId === 'new-tab')! + next = setCommandKeybindings(next, 'new-tab', victim.bindings.filter(b => b !== 'Cmd+T'), defaults) + next = setCommandKeybindings(next, 'toggle-git-bar', ['Cmd+T'], defaults) + + const after = new Map( + resolveEffectiveKeybindings(next, defaults).map(e => [e.commandId, e.bindings]), + ) + expect(after.get('new-tab')).not.toContain('Cmd+T') + expect(after.get('toggle-git-bar')).toContain('Cmd+T') + + // Exactly one owner across the whole effective set. + const owners = resolveEffectiveKeybindings(next, defaults) + .filter(e => e.bindings.includes('Cmd+T')) + expect(owners).toHaveLength(1) + }) +}) + +describe('editor row semantics', () => { + it('reports Not assigned for a command with no binding', () => { + const effective = new Map( + resolveEffectiveKeybindings({}, defaults).map(e => [e.commandId, e.bindings]), + ) + // Most of the catalog ships unbound; scarcity is deliberate. + expect(effective.get('toggle-git-bar')).toBeUndefined() + }) + + it('supports several bindings on one command', () => { + const effective = new Map( + resolveEffectiveKeybindings({}, defaults).map(e => [e.commandId, e.bindings]), + ) + expect(effective.get('close-pane')).toEqual(['Cmd+W', 'Alt+W']) + }) + + it('removing the last binding is an explicit unbind, not a reset', () => { + // The distinction that lets a future release improve untouched defaults + // without resurrecting a chord the user deliberately removed. + const next = setCommandKeybindings({}, 'new-tab', [], defaults) + expect(next['new-tab']).toEqual([]) + const effective = new Map( + resolveEffectiveKeybindings(next, defaults).map(e => [e.commandId, e.bindings]), + ) + expect(effective.get('new-tab')).toEqual([]) + }) + + it('per-command Reset returns to inheriting', () => { + const edited = setCommandKeybindings({}, 'new-tab', ['Cmd+Shift+9'], defaults) + const reset = resetCommandKeybindings(edited, 'new-tab') + const effective = new Map( + resolveEffectiveKeybindings(reset, defaults).map(e => [e.commandId, e.bindings]), + ) + expect(effective.get('new-tab')).toEqual(['Cmd+T']) + }) + + it('offers a row for every categorized built-in command', () => { + // Including commands the palette never renders: they are still reachable + // by chord, menu and programmatic call, so they are bindable. + const rowable = builtInCommandCatalog.filter(c => c.category) + expect(rowable).toHaveLength(builtInCommandCatalog.length) + }) +}) diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index f71f9a27..1d20bcb5 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -150,6 +150,20 @@ export type SettingDefinition = type: 'agent-code-conventions' } } + | { + id: string + category: SettingCategoryId + title: string + description: string + keywords: string[] + // Marker for the built-in keybinding editor. Self-subscribing for the + // same reason as cli-update-behavior: the row needs the live effective + // binding set plus conflict lookup, and hoisting all of that into the + // registry would make every Settings render recompute it. + control: { + type: 'command-keybindings' + } + } | { id: string category: SettingCategoryId @@ -551,6 +565,24 @@ export function getSettingsRegistry(): SettingDefinition[] { onToggle: (ctx, value) => updateDefaultBuiltInMcpDomain(ctx, 'workflows', value), }, }, + { + id: 'command-keybindings', + category: 'commands', + title: 'Keyboard Shortcuts', + description: + 'Assign, add, or remove keyboard shortcuts for built-in commands. A command may have several bindings or none. Conflicts are blocked and name the command or app interaction that already owns the chord.', + keywords: [ + 'keybinding', + 'keyboard', + 'shortcut', + 'chord', + 'bind', + 'rebind', + 'hotkey', + 'conflict', + ], + control: { type: 'command-keybindings' }, + }, { id: 'navigation-commands', category: 'commands', diff --git a/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx new file mode 100644 index 00000000..f85acff5 --- /dev/null +++ b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx @@ -0,0 +1,360 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useAppStore } from '@renderer/app-state/hooks' +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { PALETTE_SELF_EXCLUDED_COMMAND_IDS } from '@renderer/features/command-palette/commands/paletteCommands' +import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' +import { + displayKeybinding, + keybindingFromEvent, +} from '@renderer/features/command-keybindings/normalize' +import { findBindingOwners } from '@renderer/features/command-keybindings/reservations' +import { + resetCommandKeybindings, + resolveEffectiveKeybindings, + setCommandKeybindings, +} from '@renderer/features/command-keybindings/resolve' +import type { Keybinding } from '@renderer/features/command-keybindings/normalize' +import type { CommandCategory } from '@renderer/features/command-palette/types' + +// --------------------------------------------------------------------------- +// Commands & Shortcuts: the built-in keybinding editor (governance plan §4). +// +// Every surviving built-in command gets a row with zero, one, or several +// bindings. "Keybind control of all commands" means exactly that and no more — +// it does NOT mean turning every keyboard interaction into a command. Escape +// dismissal, picker navigation, numbered selection and split resizing stay +// contextual interactions; they appear here only as CONFLICT OWNERS, never as +// editable rows. +// +// WHY this does not reuse HotkeyInput: dictation captures modifier-only holds +// (bare Cmd is a legitimate push-to-talk binding) and commits them after a +// settle timer. A command chord fires on keydown and can never be +// modifier-only, so inheriting that policy would offer users bindings the +// router cannot match. The two capture surfaces share the normalizer, not the +// interaction model. +// --------------------------------------------------------------------------- + +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', +} + +const CATEGORY_ORDER: CommandCategory[] = [ + 'create', + 'navigate', + 'session', + 'layout-dispatch', + 'editor-files', + 'workspace-tools', + 'preferences', + 'developer', +] + +type PendingConflict = { + commandId: string + binding: Keybinding + /** Human-readable owners, for a message that NAMES them. */ + owners: string[] + /** Command ids whose bindings an explicit Replace would remove. */ + replaceableCommandIds: string[] +} + +export function CommandKeybindingsRow() { + const settings = useAppStore(state => state.settings) + const setSettings = useAppStore(state => state.setSettings) + + const [query, setQuery] = useState('') + const [capturingFor, setCapturingFor] = useState(null) + const [conflict, setConflict] = useState(null) + const [syntaxError, setSyntaxError] = useState(null) + + const overrides = settings.commandKeybindingOverrides + const defaults = useMemo(() => buildDefaultKeybindings(), []) + + const effective = useMemo(() => { + const map = new Map() + for (const entry of resolveEffectiveKeybindings(overrides, defaults)) { + map.set(entry.commandId, entry.bindings) + } + return map + }, [overrides, defaults]) + + /** Effective set as default-shaped entries, so conflict lookup sees what the + * user's profile ACTUALLY runs rather than what shipped. Settings must catch + * a clash with the user's own rebinding, not just with the shipped table. */ + const effectiveAsDefaults = useMemo( + () => resolveEffectiveKeybindings(overrides, defaults), + [overrides, defaults], + ) + + const rows = useMemo(() => { + const needle = query.trim().toLowerCase() + return builtInCommandCatalog + // A command the palette never renders still gets a binding row — it is + // reachable by chord, menu and programmatic call, so it is bindable. + .filter(command => command.category) + .map(command => ({ + id: command.id, + title: typeof command.title === 'function' ? command.id : command.title, + category: command.category as CommandCategory, + description: command.description, + keywords: command.keywords ?? [], + bindings: effective.get(command.id) ?? [], + customized: overrides[command.id] !== undefined, + })) + .filter(row => { + if (!needle) return true + const haystack = [ + row.title, + row.id, + row.description, + ...row.keywords, + ...row.bindings.map(displayKeybinding), + ].join(' ').toLowerCase() + return haystack.includes(needle) + }) + }, [query, effective, overrides]) + + const grouped = useMemo(() => { + const byCategory = new Map() + for (const row of rows) { + const list = byCategory.get(row.category) ?? [] + list.push(row) + byCategory.set(row.category, list) + } + return CATEGORY_ORDER + .map(category => ({ category, rows: byCategory.get(category) ?? [] })) + .filter(group => group.rows.length > 0) + }, [rows]) + + const commit = useCallback( + (commandId: string, bindings: readonly Keybinding[]) => { + setSettings({ + commandKeybindingOverrides: setCommandKeybindings( + overrides, + commandId, + bindings, + defaults, + ), + }) + }, + [overrides, defaults, setSettings], + ) + + const captureRef = useRef(null) + captureRef.current = capturingFor + + // Capture runs on the WINDOW in capture phase, because the workspace router + // is also listening there. Without preventDefault + stopPropagation the chord + // being recorded would also EXECUTE — pressing Cmd+W to bind it would close + // the pane behind the Settings page. + useEffect(() => { + if (!capturingFor) return + + const onKeyDown = (event: KeyboardEvent) => { + event.preventDefault() + event.stopPropagation() + + if (event.key === 'Escape') { + setCapturingFor(null) + setSyntaxError(null) + return + } + + const binding = keybindingFromEvent(event) + // null means a bare modifier is held — the user is mid-chord. Waiting + // rather than erroring is what makes Cmd+Shift+K feel like one gesture. + if (!binding) return + + const commandId = captureRef.current + if (!commandId) return + + const owners = findBindingOwners({ + binding, + context: 'global', + commandDefaults: effectiveAsDefaults, + dictationBinding: settings.dictationShortcut, + excludeCommandId: commandId, + }) + + if (owners.length > 0) { + // BLOCK the save and name the owners. "That shortcut is taken" is not + // actionable; "Cmd+T is used by New Tab" is. Registration order and + // last-write-wins are forbidden — the user decides, explicitly. + setCapturingFor(null) + setConflict({ + commandId, + binding, + owners: owners.map(owner => + owner.kind === 'command' ? commandLabel(owner.id) : owner.id, + ), + replaceableCommandIds: owners + .filter(owner => owner.kind === 'command') + .map(owner => owner.id), + }) + return + } + + const current = effective.get(commandId) ?? [] + if (!current.includes(binding)) commit(commandId, [...current, binding]) + setCapturingFor(null) + setSyntaxError(null) + } + + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [capturingFor, effective, effectiveAsDefaults, settings.dictationShortcut, commit]) + + /** + * The ONLY override path. Removes the binding from every command that owns + * it and installs it on the requester, in ONE settings write — so the store + * can never be observed with the chord owned twice or by nobody. + * + * A reserved interaction cannot be replaced: Escape, native menu roles and + * editor-native keys are not ours to reassign, so a conflict naming only + * those offers no Replace at all. + */ + const applyReplace = useCallback(() => { + if (!conflict) return + let next = overrides + for (const otherId of conflict.replaceableCommandIds) { + const otherBindings = (effective.get(otherId) ?? []).filter(b => b !== conflict.binding) + next = setCommandKeybindings(next, otherId, otherBindings, defaults) + } + const mine = effective.get(conflict.commandId) ?? [] + next = setCommandKeybindings(next, conflict.commandId, [...mine, conflict.binding], defaults) + setSettings({ commandKeybindingOverrides: next }) + setConflict(null) + }, [conflict, overrides, effective, defaults, setSettings]) + + return ( +
+ setQuery(event.target.value)} + placeholder="Search commands, shortcuts, or keywords…" + className="w-full bg-input-bg border border-border px-2 py-1 text-xs text-ink outline-none focus:border-accent" + /> + + {conflict ? ( +
+
+ {displayKeybinding(conflict.binding)}{' '} + is already used by {conflict.owners.join(', ')}. +
+
+ {conflict.replaceableCommandIds.length > 0 ? ( + + ) : ( + + Reserved by the app — pick a different chord. + + )} + +
+
+ ) : null} + + {syntaxError ? ( +
{syntaxError}
+ ) : null} + +
+ {grouped.map(group => ( +
+
+ {CATEGORY_LABELS[group.category]} +
+ {group.rows.map(row => ( +
+
+ {row.title} + {PALETTE_SELF_EXCLUDED_COMMAND_IDS.has(row.id) ? ( + (not shown in palette) + ) : null} +
+ +
+ {row.bindings.length === 0 ? ( + Not assigned + ) : ( + row.bindings.map(binding => ( + + )) + )} + + + + {row.customized ? ( + + ) : null} +
+
+ ))} +
+ ))} +
+ + +
+ ) +} + +/** Friendly label for a command id named in a conflict message. */ +function commandLabel(commandId: string): string { + const command = builtInCommandCatalog.find(candidate => candidate.id === commandId) + if (!command) return commandId + return typeof command.title === 'function' ? commandId : command.title +} diff --git a/src/renderer/src/features/settings/ui/SettingsList.tsx b/src/renderer/src/features/settings/ui/SettingsList.tsx index 8c0e57aa..88445b54 100644 --- a/src/renderer/src/features/settings/ui/SettingsList.tsx +++ b/src/renderer/src/features/settings/ui/SettingsList.tsx @@ -5,6 +5,7 @@ import type { } from '@renderer/features/settings/lib/settingsRegistry' import { SETTING_CATEGORIES } from '@renderer/features/settings/lib/settingsCategories' import { HotkeyInput } from '@renderer/features/settings/ui/HotkeyInput' +import { CommandKeybindingsRow } from '@renderer/features/settings/ui/CommandKeybindingsRow' import { CliUpdateBehaviorRow } from '@renderer/features/cli-updates/CliUpdateBehaviorRow' import { DictationApiKeyRow } from '@renderer/features/voice-dictation/DictationApiKeyRow' import { ThemePickerRow } from '@renderer/features/settings/ui/ThemePickerRow' @@ -218,6 +219,8 @@ function SettingRow({ because the value lives in setup.json (main-owned), not in the renderer Settings store. See features/cli-updates/CliUpdateBehaviorRow.tsx. */} + {control.type === 'command-keybindings' ? : null} + {control.type === 'cli-update-behavior' ? : null} {/* Voice-dictation API key — same self-subscribing marker-row From 7a3447152d605b31dcf2a7c6350151a86c0469eb Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 18:37:42 +0200 Subject: [PATCH 17/33] feat(commands): replace badge text with semantic command state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. `CommandState` was `{label: string; tone?}` and the renderer uppercased every label into one chip, so a boolean, a selected value, contextual information, an unsupported capability and async progress were indistinguishable. It is now a discriminated union — toggle / value / status — with `truth` recording whether a state is persisted, runtime, or EFFECTIVE. TONE IS DERIVED, never authored. That is the constraint that makes the rest hold: a caller physically cannot say "colour this danger" independently of what it means, so colour and meaning cannot drift apart. `describeCommandState` is the single place a state becomes pixels, and both the palette row and the details pane call it — each previously carried its own copy of the tone ternary. The six defects the audit named, each now unrepresentable: - Tail showed "On (all)", a state invoking Tail cannot turn off because Tail All owns it. Now `truth: 'effective'` with a detail explaining which command actually controls it. - Caffeinate rendered "Unsupported" as an ordinary neutral value, so a platform-unsupported command looked identical to one merely off. Now a first-class `status('unavailable', …)` that mutes the row. - Dispatch Mode rendered Global/Project/Off through the on-off chip, so a SCOPE read as an enabled state. Now an enum value. - Provider badges on Reload / Soft Reload / Copy Resume / Switch Provider were accent-toned, reading as live toggles. They are context values, always neutral. - Reply to Selection styled its stashed snippet as accent for the same reason. Also a value now. - Panels were split between "On/Off" and "Open/Closed" with no rule. `panel()` owns that vocabulary so the split cannot return. Rendering Debug Mode is the interesting migration: it authored `danger` tone. The warning is real — the mode intercepts every feed click — but tone follows meaning, and the meaning is "on". The warning moved into a detail string, which says more and cannot drift from the actual state. 34 getState definitions migrated. 15 were mechanical enough to codemod; the rest were hand-migrated precisely because they were the ones the flat shape had been hiding something in. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 241 files / 1556 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../commands/agentStatusCommands.ts | 6 +- .../command-palette/commandState.test.ts | 103 +++++++++++ .../features/command-palette/commandState.ts | 162 ++++++++++++++++++ .../lib/agentIndexCommand.test.ts | 2 +- .../command-palette/lib/agentIndexCommand.ts | 6 +- .../command-palette/resolveInvocation.test.ts | 7 +- .../src/features/command-palette/types.ts | 36 +++- .../command-palette/ui/CommandPalette.tsx | 67 +++++--- .../commands/globalEditorCommands.ts | 16 +- .../reader/commands/readerCommands.ts | 6 +- .../commands/replyToSelectionCommands.ts | 5 +- .../settings/commands/settingsCommands.ts | 11 +- .../spotlight/commands/spotlightCommands.ts | 6 +- .../tile-tabs/commands/tileTabsCommands.ts | 6 +- .../workspace/commands/layoutCommands.ts | 38 ++-- .../workspace/commands/paneCommands.ts | 21 ++- .../commands/sessionCommands.renderer.test.ts | 10 +- .../workspace/commands/sessionCommands.ts | 108 ++++++------ 18 files changed, 459 insertions(+), 157 deletions(-) create mode 100644 src/renderer/src/features/command-palette/commandState.test.ts create mode 100644 src/renderer/src/features/command-palette/commandState.ts diff --git a/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts b/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts index 526585c3..f8751424 100644 --- a/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts +++ b/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts @@ -1,6 +1,7 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' +import { panel, toggle, value } from '@renderer/features/command-palette/commandState' function focusedAgentSessionId(ctx: CommandContext): string | null { const sessionId = commandTargetSessionId(ctx.workspace) @@ -20,10 +21,7 @@ export const agentStatusCommands: CommandDef[] = [ description: '**What it does:** Shows or hides a compact **Agent Status** panel for the focused Claude or Codex agent.\n\n**Use when:** You need identity, placement, runtime status, MCP domains, or orchestration/link metadata without opening raw debug panels.\n\n**Notes:** Follows the current command target, including focused Dispatch rows.', keywords: ['agent', 'status', 'show', 'state', 'inspect', 'runtime', 'session', 'mcp', 'orchestration', 'linked'], when: ctx => focusedAgentSessionId(ctx) !== null, - getState: ({ flags }) => ({ - label: flags.agentStatusPanelOpen ? 'On' : 'Off', - tone: flags.agentStatusPanelOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.agentStatusPanelOpen), run: ({ ui }) => { ui.closePalette() ui.toggleAgentStatusPanel() diff --git a/src/renderer/src/features/command-palette/commandState.test.ts b/src/renderer/src/features/command-palette/commandState.test.ts new file mode 100644 index 00000000..6ecb185b --- /dev/null +++ b/src/renderer/src/features/command-palette/commandState.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' + +import { + describeCommandState, + panel, + status, + toggle, + value, +} from '@renderer/features/command-palette/commandState' +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' + +// --------------------------------------------------------------------------- +// Phase 6: semantic command state. +// +// The old `{label, tone}` rendered a boolean, a selected value, contextual +// information, an unsupported capability and async progress through one +// identical chip. These assert that the six concrete defects the audit named +// can no longer be expressed. +// --------------------------------------------------------------------------- + +describe('tone is derived, never authored', () => { + it('has no tone field on the state type itself', () => { + // The structural guarantee. A caller physically cannot colour a state + // independently of what it means, so colour and meaning cannot drift. + expect('tone' in toggle(true)).toBe(false) + expect('tone' in value('Claude')).toBe(false) + expect('tone' in status('error', 'x')).toBe(false) + }) + + it('colours an on-toggle accent and an off-toggle neutral', () => { + expect(describeCommandState(toggle(true)).tone).toBe('accent') + expect(describeCommandState(toggle(false)).tone).toBe('neutral') + }) + + it('always colours a context value neutral', () => { + // Provider badges on Reload/Switch/Copy Resume are context, not enabled + // state. Accent tone made them read as live toggles. + expect(describeCommandState(value('Claude')).tone).toBe('neutral') + expect(describeCommandState(value('Codex', { truth: 'persisted' })).tone).toBe('neutral') + }) +}) + +describe('vocabularies', () => { + it('renders panels as Open/Closed and capabilities as On/Off', () => { + // The audit found panels split between both with no rule. + expect(describeCommandState(panel(true)).label).toBe('Open') + expect(describeCommandState(panel(false)).label).toBe('Closed') + expect(describeCommandState(toggle(true)).label).toBe('On') + expect(describeCommandState(toggle(false)).label).toBe('Off') + }) +}) + +describe('effective versus owned truth', () => { + it('carries the explanation for a state this command cannot change', () => { + // Tail's motivating case: auto-follow is on because Tail All turned it on, + // and invoking Tail cannot turn it off. The old flat "On (all)" label went + // through the same chip as a plain On and explained nothing. + const state = toggle(true, { + truth: 'effective', + detail: 'On via Auto-follow All Visible Agents', + }) + expect(state).toMatchObject({ kind: 'toggle', value: 'on', truth: 'effective' }) + expect(describeCommandState(state).detail).toBe('On via Auto-follow All Visible Agents') + }) + + it('renders mixed as accent, not as off', () => { + // Mixed means SOMETHING is on. Rendering it like off would tell the user + // the opposite of what is happening. + expect(describeCommandState(toggle('mixed')).tone).toBe('accent') + expect(describeCommandState(toggle('mixed')).label).toBe('Mixed') + }) +}) + +describe('status is first-class', () => { + it('models unavailable as a status rather than a value that reads Unavailable', () => { + // Caffeinate's case. As a plain label it looked identical to "off" and the + // command stayed fully executable. + const state = status('unavailable', 'caffeinate is only available on macOS') + const presented = describeCommandState(state) + expect(presented.label).toBe('Unavailable') + expect(presented.muted).toBe(true) + expect(presented.detail).toContain('macOS') + }) + + it('mutes loading and colours error', () => { + expect(describeCommandState(status('loading', 'reloading')).muted).toBe(true) + expect(describeCommandState(status('error', 'kill failed')).tone).toBe('danger') + // An error is actionable, so it is NOT muted — the user should be able to + // retry the row that failed. + expect(describeCommandState(status('error', 'kill failed')).muted).toBe(false) + }) +}) + +describe('catalog migration', () => { + it('leaves no command returning a legacy {label, tone} state', () => { + // Every getState now returns a discriminated variant. A stray legacy object + // would render with no kind and fall through the badge switch. + for (const command of builtInCommandCatalog) { + const source = command.getState?.toString() ?? '' + expect(source).not.toMatch(/tone:\s*'(neutral|accent|danger)'/) + } + }) +}) diff --git a/src/renderer/src/features/command-palette/commandState.ts b/src/renderer/src/features/command-palette/commandState.ts new file mode 100644 index 00000000..dc797e6d --- /dev/null +++ b/src/renderer/src/features/command-palette/commandState.ts @@ -0,0 +1,162 @@ +import type { CommandState } from '@renderer/features/command-palette/types' + +// --------------------------------------------------------------------------- +// Semantic command state (governance plan, Phase 6). +// +// The old shape was `{ label: string; tone?: 'neutral'|'accent'|'danger' }`, +// and the renderer uppercased every label into one chip. That cannot express +// whether the text is a boolean, a selected value, contextual information, +// EFFECTIVE-versus-owned truth, an unavailable capability, or async progress — +// so the audit found all six being rendered identically: +// +// - Tail showed "On (all)", a state invoking Tail cannot turn off, because +// it is owned by Tail All. +// - Caffeinate showed "Unsupported" as if it were an ordinary value. +// - usage.cycle-header-level rendered the RAW lowercase enum ("providers"). +// - Dangerous Agents said "On" before the fleet reload implementing it had +// finished. +// - Provider names on Reload/Switch/Copy Resume are CONTEXT, not state, but +// were styled like an enabled toggle. +// - MCP toggles and reload had no pending state at all, so a slow operation +// looked like nothing had happened. +// +// TONE IS DERIVED, never authored. That is the constraint that makes the whole +// thing hold: a caller cannot say "this is danger-coloured" independently of +// what it means, so colour and meaning cannot drift apart. +// --------------------------------------------------------------------------- + +/** + * Where a state's truth comes from. + * + * `persisted` — a stored preference. `runtime` — live process/session state. + * `effective` — what the user actually experiences, which may be produced by + * something OTHER than this command. Tail is the motivating case: it reports + * `effective` on, because Tail All is what turned it on and only Tail All can + * turn it off. + */ +export type CommandStateTruth = 'persisted' | 'runtime' | 'effective' + +/** A boolean capability or preference. */ +export function toggle( + value: boolean | 'mixed', + options: { truth?: CommandStateTruth; detail?: string } = {}, +): CommandState { + return { + kind: 'toggle', + value: value === 'mixed' ? 'mixed' : value ? 'on' : 'off', + truth: options.truth ?? 'runtime', + ...(options.detail ? { detail: options.detail } : {}), + } +} + +/** + * A panel's open/closed state. + * + * Distinct from `toggle` only in vocabulary. The audit found panels split + * between "On/Off" and "Open/Closed" with no rule; a panel is open or closed, + * and having one constructor per vocabulary is what stops the drift returning. + */ +export function panel( + open: boolean, + options: { detail?: string } = {}, +): CommandState { + return { + kind: 'toggle', + value: open ? 'on' : 'off', + truth: 'runtime', + vocabulary: 'open-closed', + ...(options.detail ? { detail: options.detail } : {}), + } +} + +/** + * A selected option, or contextual information about the target. + * + * Provider badges on Reload/Switch/Copy Resume belong here: they say WHICH + * provider the command would act on, and must never be read as enabled. + */ +export function value( + label: string, + options: { truth?: CommandStateTruth; detail?: string } = {}, +): CommandState { + return { + kind: 'value', + label, + truth: options.truth ?? 'runtime', + ...(options.detail ? { detail: options.detail } : {}), + } +} + +/** + * Async progress, an unsupported capability, or a failure. + * + * `unavailable` is FIRST-CLASS rather than a value whose text happens to read + * "Unsupported" — that distinction is what lets the picker grey a row instead + * of offering it as ordinary and executable. + */ +export function status( + value: 'loading' | 'unavailable' | 'error', + detail: string, +): CommandState { + return { kind: 'status', value, detail } +} + +export type CommandStatePresentation = { + label: string + tone: 'neutral' | 'accent' | 'danger' + /** Longer explanation for a tooltip or details pane. */ + detail?: string + /** True when the row should render as disabled. */ + muted: boolean +} + +/** + * The single place a semantic state becomes pixels. + * + * Every surface — palette row, preview pane, Settings — calls this, so a state + * cannot be coloured one way in one place and another elsewhere. Deriving the + * tone here is what enforces "tone is not authored". + */ +export function describeCommandState(state: CommandState): CommandStatePresentation { + switch (state.kind) { + case 'toggle': { + const openClosed = state.vocabulary === 'open-closed' + if (state.value === 'mixed') { + return { + label: openClosed ? 'Mixed' : 'Mixed', + // Accent, not neutral: mixed means SOMETHING is on, and rendering it + // like off would tell the user the opposite of what is happening. + tone: 'accent', + detail: state.detail, + muted: false, + } + } + const on = state.value === 'on' + return { + label: openClosed ? (on ? 'Open' : 'Closed') : on ? 'On' : 'Off', + tone: on ? 'accent' : 'neutral', + detail: state.detail, + muted: false, + } + } + case 'value': + // Always neutral. A context value is not an enabled state, and colouring + // it like one is precisely how provider badges came to look like toggles. + return { label: state.label, tone: 'neutral', detail: state.detail, muted: false } + case 'status': + return { + label: STATUS_LABELS[state.value], + tone: state.value === 'error' ? 'danger' : 'neutral', + detail: state.detail, + // Loading and unavailable both read as "you cannot act on this right + // now", which is a visual property of the row, not of its text. + muted: state.value !== 'error', + } + } +} + +const STATUS_LABELS: Record<'loading' | 'unavailable' | 'error', string> = { + loading: 'Working…', + unavailable: 'Unavailable', + error: 'Failed', +} diff --git a/src/renderer/src/features/command-palette/lib/agentIndexCommand.test.ts b/src/renderer/src/features/command-palette/lib/agentIndexCommand.test.ts index aaf499a1..98d23482 100644 --- a/src/renderer/src/features/command-palette/lib/agentIndexCommand.test.ts +++ b/src/renderer/src/features/command-palette/lib/agentIndexCommand.test.ts @@ -22,7 +22,7 @@ describe('agent index palette command', () => { const command = buildAgentIndexCommand(target, focusAgentByPaneLabel) expect(command.title).toBe('Go to B5 · Fix focus routing') - expect(command.state).toEqual({ label: 'codex', tone: 'accent' }) + expect(command.state).toEqual({ kind: 'value', label: 'codex', truth: 'runtime' }) expect(isAgentIndexCommand(command)).toBe(true) await command.run({} as never) diff --git a/src/renderer/src/features/command-palette/lib/agentIndexCommand.ts b/src/renderer/src/features/command-palette/lib/agentIndexCommand.ts index d0e87b84..ef9f74b7 100644 --- a/src/renderer/src/features/command-palette/lib/agentIndexCommand.ts +++ b/src/renderer/src/features/command-palette/lib/agentIndexCommand.ts @@ -1,3 +1,4 @@ +import { value } from '@renderer/features/command-palette/commandState' import type { ResolvedCommand } from '@renderer/features/command-palette/types' import type { AgentPaneLabelTarget } from '@renderer/workspace/tile-tree/paneLabels' @@ -20,7 +21,10 @@ export function buildAgentIndexCommand( surface: 'app', keywords: [], keepPaletteOpen: false, - state: { label: target.kind, tone: 'accent' }, + // The provider kind is CONTEXT about the destination, not an enabled + // state — the same correction applied to the provider badges on Reload and + // Switch Provider. + state: value(target.kind), run: async () => { // Resolve by the visible coordinate again inside the workspace action. // The palette result is only a preview; the action is the authority that diff --git a/src/renderer/src/features/command-palette/resolveInvocation.test.ts b/src/renderer/src/features/command-palette/resolveInvocation.test.ts index beaa1200..d5abe6e3 100644 --- a/src/renderer/src/features/command-palette/resolveInvocation.test.ts +++ b/src/renderer/src/features/command-palette/resolveInvocation.test.ts @@ -6,6 +6,7 @@ import { resolveCommandTarget, targetStillValid, } from '@renderer/features/command-palette/resolveInvocation' +import { toggle } from '@renderer/features/command-palette/commandState' import { makeTestCommandContext } from '@renderer/features/command-palette/testing/commandContextHarness' import type { CommandDef } from '@renderer/features/command-palette/types' @@ -119,7 +120,7 @@ describe('resolveCommandInvocation', () => { const command: CommandDef = { ...base, surface: 'session', - getState: () => ({ label: 'On' }), + getState: () => toggle(true), } const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) const invocation = resolveCommandInvocation(command, ctx) @@ -127,7 +128,7 @@ describe('resolveCommandInvocation', () => { expect(invocation.commandId).toBe('x.test') expect(invocation.target).toEqual({ kind: 'session', id: 's1' }) expect(invocation.availability).toEqual({ available: true }) - expect(invocation.state).toEqual({ label: 'On' }) + expect(invocation.state).toEqual(toggle(true)) }) it('does not evaluate state for an unavailable command', () => { @@ -139,7 +140,7 @@ describe('resolveCommandInvocation', () => { when: () => false, getState: () => { evaluated = true - return { label: 'x' } + return toggle(true) }, } const invocation = resolveCommandInvocation(command, makeTestCommandContext()) diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index 0723bc73..99d2d8dc 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -3,10 +3,38 @@ import type { AgentViewMode, UsageHeaderLevel } from '@renderer/app-state/settin import type { RenderedViewPolicy } from '@renderer/workspace/agentDisplayMode' import type { DispatchAttachIntent } from '@renderer/app-state/uiShell/types' -export type CommandState = { - label: string - tone?: 'neutral' | 'accent' | 'danger' -} +/** + * What a command's badge MEANS, not how it looks. + * + * The old shape was `{label: string; tone?}` and the renderer uppercased every + * label into one chip, so a boolean, a selected value, contextual information, + * an unsupported capability and async progress were indistinguishable. See + * `commandState.ts` for the six concrete defects that produced, and for the + * constructors — `toggle`, `panel`, `value`, `status` — that build these. + * + * TONE IS NOT PART OF THIS TYPE. It is derived by `describeCommandState`, so a + * caller cannot colour a state independently of what it means. + */ +export type CommandState = + | { + kind: 'toggle' + value: 'on' | 'off' | 'mixed' + truth: 'persisted' | 'runtime' | 'effective' + /** Open/Closed instead of On/Off, for panels. */ + vocabulary?: 'open-closed' + detail?: string + } + | { + kind: 'value' + label: string + truth: 'persisted' | 'runtime' | 'effective' + detail?: string + } + | { + kind: 'status' + value: 'loading' | 'unavailable' | 'error' + detail: string + } /** * The user-facing grouping of a command. diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index b87425a2..bfd1d57d 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -28,7 +28,12 @@ import { rankEntries, secondary, } from '@renderer/features/command-palette/lib/rankEntries' -import type { CommandContext, ResolvedCommand } from '@renderer/features/command-palette/types' +import { describeCommandState } from '@renderer/features/command-palette/commandState' +import type { + CommandContext, + CommandState, + ResolvedCommand, +} from '@renderer/features/command-palette/types' import type { PendingCommandInvocation } from '@renderer/app-state/uiShell/types' import { allPromptTemplates, @@ -118,6 +123,38 @@ type PromptTemplateFillState = { // this to reveal the full list in one shot. const SHOW_HIDDEN_COMMANDS = false +/** + * The ONE place a semantic command state becomes pixels. + * + * Both the palette row and the details pane render through this, so a state + * cannot be coloured one way in the list and another in the preview — which is + * what happened when each surface carried its own copy of the tone ternary. + * Tone, label and muting all come from `describeCommandState`; nothing here + * decides appearance from the state's contents directly. + */ +function CommandStateBadge({ state }: { state: CommandState }) { + const presentation = describeCommandState(state) + const tone = + presentation.tone === 'danger' + ? 'border-danger-border bg-danger-soft text-danger' + : presentation.tone === 'accent' + ? 'border-accent/30 bg-row-selected-bg text-accent' + : 'border-panel-border bg-panel-elevated-bg text-muted' + return ( + + {presentation.label} + + ) +} + export function CommandPalette() { const open = useAppStore(state => state.commandPaletteOpen) // The pending invocation now lives in the STORE rather than in local state @@ -1582,19 +1619,7 @@ function OpenCommandPalette({ >
{command.title} - {command.state && ( - - {command.state.label} - - )} + {command.state && }
{command.shortcut && ( @@ -1923,19 +1948,7 @@ const CommandDescriptionPanel = memo(function CommandDescriptionPanel({
{command.title}
{command.shortcut && {command.shortcut}} - {command.state && ( - - {command.state.label} - - )} + {command.state && }
diff --git a/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts b/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts index 57e1b7ed..685677ca 100644 --- a/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts +++ b/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts @@ -1,5 +1,6 @@ import type { CommandDef } from '@renderer/features/command-palette/types' import { useGlobalEditorStore } from '@renderer/features/global-editor/store' +import { panel, toggle } from '@renderer/features/command-palette/commandState' import { requestSaveActiveEditorFile, requestSaveAllEditorFiles, @@ -34,10 +35,7 @@ export const globalEditorCommands: CommandDef[] = [ description: "**What it does:** Splits the screen in half — file tree + code editor on the left, the normal workspace UI (dispatch / tile / spotlight / whatever) on the right.\n\n**Use when:** You want to read or edit project files alongside the focused agent without leaving the current mode.\n\n**Notes:** The editor's workspace tracks the *active tab*'s project — switching tabs to a different project flips the file tree. Switching panes within the same tab does NOT change the editor (the editor was deliberately decoupled from per-pane focus so reading code doesn't blow up when you move between agents in the same project). Open tabs are remembered per project and restored across app restarts (file contents are re-read from disk; unsaved edits are not persisted).\n\n**Shortcut:** ⌘⇧E.", keywords: ['editor', 'code', 'files', 'global', 'workspace', 'monaco'], - getState: ({ flags }) => ({ - label: flags.globalEditorOpen ? 'On' : 'Off', - tone: flags.globalEditorOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.globalEditorOpen), run: ({ ui, flags }) => { ui.toggleGlobalEditor() }, @@ -117,10 +115,7 @@ export const globalEditorCommands: CommandDef[] = [ '**What it does:** Expands the **Global Editor** to fill the whole workspace area. The normal workspace stays alive underneath (hidden, not unmounted — terminals and feeds keep running).\n\n**Use when:** You want maximum reading/editing room for a while.\n\n**Notes:** Esc exits fullscreen; the previous split ratio is restored.\n\n**Shortcut:** ⌥⌘E.', keywords: ['fullscreen', 'maximize', 'editor', 'zen', 'focus'], when: ({ flags }) => flags.globalEditorOpen, - getState: ({ flags }) => ({ - label: flags.editorFullscreen ? 'On' : 'Off', - tone: flags.editorFullscreen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.editorFullscreen), run: () => useGlobalEditorStore.getState().toggleEditorFullscreen(), }, { @@ -185,10 +180,7 @@ export const globalEditorCommands: CommandDef[] = [ '**What it does:** Shows or hides the file tree inside the **Global Editor** overlay.\n\n**Use when:** You want more horizontal room for the code area, or you prefer to open files via tabs / search rather than browsing.\n\n**Notes:** Only available while **Global Editor** is on. The choice is global (not per-project) — once hidden, the tree stays hidden across every project until you turn it back on.', keywords: ['file tree', 'explorer', 'sidebar', 'editor', 'tree'], when: ({ flags }) => flags.globalEditorOpen, - getState: ({ flags }) => ({ - label: flags.fileTreeVisible ? 'On' : 'Off', - tone: flags.fileTreeVisible ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.fileTreeVisible), run: ({ ui }) => ui.toggleFileTreeVisible(), }, ] diff --git a/src/renderer/src/features/reader/commands/readerCommands.ts b/src/renderer/src/features/reader/commands/readerCommands.ts index 86ec43fc..f0271628 100644 --- a/src/renderer/src/features/reader/commands/readerCommands.ts +++ b/src/renderer/src/features/reader/commands/readerCommands.ts @@ -1,6 +1,7 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import type { CommandDef } from '@renderer/features/command-palette/types' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' +import { toggle } from '@renderer/features/command-palette/commandState' export const readerCommands: CommandDef[] = [ { @@ -10,10 +11,7 @@ export const readerCommands: CommandDef[] = [ title: 'Reader Mode', description: '**What it does:** Toggles a cleaner **reading view** for the current agent.\n\n**Use when:** You want to read long agent output comfortably.\n\n**Notes:** Uses the focused command target.', keywords: ['reader', 'read', 'focus', 'plan', 'response', 'zen'], - getState: ({ workspace }) => ({ - label: workspace.readerMode ? 'On' : 'Off', - tone: workspace.readerMode ? 'accent' : 'neutral', - }), + getState: ({ workspace }) => toggle(Boolean(workspace.readerMode)), when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) if (!sessionId) return false diff --git a/src/renderer/src/features/reply-to-selection/commands/replyToSelectionCommands.ts b/src/renderer/src/features/reply-to-selection/commands/replyToSelectionCommands.ts index 6f2dabd8..0b578150 100644 --- a/src/renderer/src/features/reply-to-selection/commands/replyToSelectionCommands.ts +++ b/src/renderer/src/features/reply-to-selection/commands/replyToSelectionCommands.ts @@ -2,6 +2,7 @@ import { DEFAULT_PROVIDER } from '@shared/types/providerKind' import type { CommandDef } from '@renderer/features/command-palette/types' import { prefixDraftWithQuote, quoteSnippet } from '@renderer/features/reply-to-selection/lib/formatQuote' import { parkComposerCaretAtEnd } from '@renderer/features/reply-to-selection/lib/parkComposerCaret' +import { value } from '@renderer/features/command-palette/commandState' import { peekPendingSelection, takePendingSelection, @@ -65,7 +66,9 @@ export const replyToSelectionCommands: CommandDef[] = [ // The snippet is what stops this being a blind action: availability // comes from the stash, not from a highlight that is still visible // (opening the palette collapses it). See selectionStash.ts. - return { label: `"${quoteSnippet(pending.text, 40)}"`, tone: 'accent' } + // The stashed snippet is CONTEXT — it says what the command would quote. + // Rendered with accent tone it read as "this toggle is on". + return value(`"${quoteSnippet(pending.text, 40)}"`) }, run: ({ workspace }) => { // Re-validate before consuming: `when` ran when the palette built diff --git a/src/renderer/src/features/settings/commands/settingsCommands.ts b/src/renderer/src/features/settings/commands/settingsCommands.ts index d3b5ede5..4bbd47b3 100644 --- a/src/renderer/src/features/settings/commands/settingsCommands.ts +++ b/src/renderer/src/features/settings/commands/settingsCommands.ts @@ -1,4 +1,5 @@ import type { CommandDef } from '@renderer/features/command-palette/types' +import { panel, toggle, value } from '@renderer/features/command-palette/commandState' export const settingsCommands: CommandDef[] = [ { @@ -32,10 +33,7 @@ export const settingsCommands: CommandDef[] = [ 'trace', 'all agents', ], - getState: ({ flags }) => ({ - label: flags.aggressiveDebugPersistenceEnabled ? 'On' : 'Off', - tone: flags.aggressiveDebugPersistenceEnabled ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.aggressiveDebugPersistenceEnabled), run: ({ flags, ui }) => ui.setAggressiveDebugPersistence(!flags.aggressiveDebugPersistenceEnabled), }, @@ -46,10 +44,7 @@ export const settingsCommands: CommandDef[] = [ title: 'Worktrees', description: '**What it does:** Shows or hides the **Worktrees** panel.\n\n**Use when:** You want branch and worktree activity for the focused project.\n\n**Notes:** Useful for multi-agent git cleanup.', keywords: ['worktree', 'worktrees', 'branch', 'git', 'activity', 'agents', 'cleanup'], - getState: ({ flags }) => ({ - label: flags.worktreesBarOpen ? 'Open' : 'Closed', - tone: flags.worktreesBarOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => panel(flags.worktreesBarOpen), run: ({ ui }) => ui.toggleWorktreesBar(), }, // RETIRED: `toggle-worktree-badges`. Same reasoning as Status Mode — a diff --git a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts index 640eaf71..aa11506f 100644 --- a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts +++ b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts @@ -1,4 +1,5 @@ import type { CommandDef } from '@renderer/features/command-palette/types' +import { toggle } from '@renderer/features/command-palette/commandState' export const spotlightCommands: CommandDef[] = [ { @@ -10,10 +11,7 @@ export const spotlightCommands: CommandDef[] = [ surface: 'app', title: 'Spotlight', description: '**What it does:** Toggles a focused **single-pane view**.\n\n**Use when:** You want one session large without changing the grid layout.\n\n**Notes:** Press **Esc** to exit.', - getState: ({ workspace }) => ({ - label: workspace.spotlight ? 'On' : 'Off', - tone: workspace.spotlight ? 'accent' : 'neutral', - }), + getState: ({ workspace }) => toggle(Boolean(workspace.spotlight)), run: ({ workspace }) => workspace.toggleSpotlight(), }, ] diff --git a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts index 3c306f81..75f89f96 100644 --- a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts +++ b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts @@ -1,4 +1,5 @@ import type { CommandDef } from '@renderer/features/command-palette/types' +import { toggle } from '@renderer/features/command-palette/commandState' export const tileTabsCommands: CommandDef[] = [ { @@ -10,10 +11,7 @@ export const tileTabsCommands: CommandDef[] = [ surface: 'app', title: 'Tiled Tabs', description: '**What it does:** Opens a modal to choose tabs for a **tiled tab view**.\n\n**Use when:** You want multiple tabs visible at once.\n\n**Notes:** If tiled tabs are already open, this command closes the tiled view.', - getState: ({ workspace }) => ({ - label: workspace.tileTabs ? 'On' : 'Off', - tone: workspace.tileTabs ? 'accent' : 'neutral', - }), + getState: ({ workspace }) => toggle(Boolean(workspace.tileTabs)), run: ({ workspace, ui }) => { if (workspace.tileTabs) { workspace.closeTileTabs() diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts index d56acf82..f74a0923 100644 --- a/src/renderer/src/features/workspace/commands/layoutCommands.ts +++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts @@ -1,4 +1,5 @@ import type { CommandDef } from '@renderer/features/command-palette/types' +import { status, toggle, value } from '@renderer/features/command-palette/commandState' export const layoutCommands: CommandDef[] = [ { @@ -11,12 +12,13 @@ export const layoutCommands: CommandDef[] = [ title: 'Dispatch Mode', description: '**What it does:** Toggles the **Dispatch** command-center layout.\n\n**Use when:** You want to scan and command agents from a compact list.\n\n**Notes:** Shows the selected agent, the agent list, and an optional project terminal. Run again to return to the normal grid.', keywords: ['agent list', 'focused agent', 'command center', 'exit dispatch', 'grid mode', 'normal layout'], - getState: ({ flags }) => ({ - label: flags.dispatchModeEnabled - ? (flags.globalDispatchEnabled ? 'Global' : 'Project') - : 'Off', - tone: flags.dispatchModeEnabled ? 'accent' : 'neutral', - }), + // An ENUM, not a boolean: Dispatch is off, project-scoped, or global. The + // old shape rendered "Global"/"Project"/"Off" through the same chip as + // every on/off toggle, so a scope read as an enabled state. + getState: ({ flags }) => + flags.dispatchModeEnabled + ? value(flags.globalDispatchEnabled ? 'Global' : 'Project') + : toggle(false), run: async ({ ui, flags }) => { if (flags.dispatchModeEnabled) { ui.exitDispatchMode() @@ -35,10 +37,7 @@ export const layoutCommands: CommandDef[] = [ title: 'Global Dispatch', description: '**What it does:** Switches **Dispatch** between project scope and all-tabs scope.\n\n**Use when:** You want one command center for agents across every tab.\n\n**Notes:** Only appears while **Dispatch Mode** is enabled.', keywords: ['dispatch all tabs', 'agent list'], - getState: ({ flags }) => ({ - label: flags.globalDispatchEnabled ? 'On' : 'Off', - tone: flags.globalDispatchEnabled ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.globalDispatchEnabled), run: async ({ ui }) => { await ui.enterGlobalDispatch() }, @@ -106,10 +105,7 @@ export const layoutCommands: CommandDef[] = [ title: 'Performance Stats', description: '**What it does:** Shows or hides the performance stats panel.\n\n**Use when:** You want render, pane, or runtime performance details.\n\n**Notes:** Mostly useful while debugging the app.', keywords: ['performance', 'stats', 'cpu', 'memory', 'panes'], - getState: ({ flags }) => ({ - label: flags.performancePanelOpen ? 'On' : 'Off', - tone: flags.performancePanelOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.performancePanelOpen), run: ({ ui }) => ui.togglePerformancePanel(), }, { @@ -119,12 +115,14 @@ export const layoutCommands: CommandDef[] = [ title: 'Caffeinate', description: '**What it does:** Toggles a macOS `caffeinate` process so long-running agent work can prevent idle/system sleep.\n\n**Use when:** You want Agent Code to keep the machine awake while agents run.\n\n**Notes:** macOS lid-close behavior is hardware and power-state dependent; this command does not guarantee work keeps running after the lid is closed.', keywords: ['sleep', 'awake', 'macos', 'power', 'long running', 'idle'], - getState: ({ flags }) => ({ - label: flags.caffeinateSupported - ? (flags.caffeinateActive ? 'On' : 'Off') - : 'Unsupported', - tone: flags.caffeinateActive ? 'accent' : 'neutral', - }), + // Unsupported is a STATUS, not a value whose text happens to read + // "Unsupported". The old shape rendered it as an ordinary neutral label, + // so a command that cannot work on this platform looked identical to one + // that is merely off — and stayed fully executable. + getState: ({ flags }) => + flags.caffeinateSupported + ? toggle(flags.caffeinateActive, { truth: 'runtime' }) + : status('unavailable', 'caffeinate is only available on macOS'), run: ({ ui }) => ui.toggleCaffeinate(), }, // Editor commands (toggle-global-editor, quick-open, AI workspace, diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index 82096d43..d878f182 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -2,6 +2,7 @@ import { AGENT_PROVIDER_KINDS, DEFAULT_PROVIDER, isAgentProviderKind } from '@sh import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { extractLastAssistantText } from '@renderer/lib/copyAssistant' import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' +import { toggle } from '@renderer/features/command-palette/commandState' import { commandTargetSessionId, commandTargetSessionIdForState, @@ -417,12 +418,17 @@ export const paneCommands: CommandDef[] = [ // the toggle below still flips the session's own flag, which takes effect // the moment Tail All goes off. if (!tailMode && flags.tailAllMode) { - return { label: 'On (all)', tone: 'accent' } - } - return { - label: tailMode ? 'On' : 'Off', - tone: tailMode ? 'accent' : 'neutral', + // EFFECTIVE, not owned. Auto-follow is on because Tail All turned it + // on, and invoking Tail cannot turn that off — only Tail All can. The + // old "On (all)" label rendered through the same chip as a plain On, + // so the user could not tell the difference and got no explanation of + // why toggling did nothing. + return toggle(true, { + truth: 'effective', + detail: 'On via Auto-follow All Visible Agents', + }) } + return toggle(Boolean(tailMode)) }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) @@ -462,10 +468,7 @@ export const paneCommands: CommandDef[] = [ // WHY no `when` guard: it is meaningful in every layout mode, and with zero // agent panes visible it is a harmless no-op rather than a command that // disappears from the palette for reasons the user cannot see. - getState: ({ flags }) => ({ - label: flags.tailAllMode ? 'On' : 'Off', - tone: flags.tailAllMode ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.tailAllMode), run: ({ ui }) => ui.toggleTailAllMode(), }, { diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts b/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts index f22ebdb7..9f1edb57 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts @@ -93,7 +93,15 @@ describe('Rendering Debug Mode command', () => { // The red On badge is a safety signal, not decoration: while active the // mode captures clicks before ordinary controls. A stale palette state // would leave users thinking the app itself had stopped responding. - expect(command.getState?.(context)).toEqual({ label: 'On', tone: 'danger' }) + expect(command.getState?.(context)).toEqual({ + kind: 'toggle', + value: 'on', + truth: 'runtime', + // Tone is no longer authored. This mode intercepts every feed click, so + // the warning moved from a `danger` colour into a detail string — which + // says more and cannot drift from the actual state. + detail: 'Feed clicks are intercepted while this is on', + }) command.run(context) expect(toggleRenderingDebugMode).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 33d4cd30..5b981fa5 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -1,7 +1,12 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { getProviderFeatures } from '@providers/shared/featureCapabilities' -import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' +import { status, toggle, value } from '@renderer/features/command-palette/commandState' +import type { + CommandContext, + CommandDef, + CommandState, +} from '@renderer/features/command-palette/types' import { runSaveDebugBundleCommand } from '@renderer/features/debug/saveDebugBundle' import { runAttachRecordingNoteCommand, runToggleSessionRecordingCommand } from '@renderer/features/debug/attachRecordingNote' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' @@ -26,14 +31,11 @@ function targetSupportsBuiltInMcpDomain( function builtInMcpDomainState( ctx: CommandContext, domain: BuiltInMcpDomain, -): { label: string; tone: 'neutral' | 'accent' } { +): CommandState { const sessionId = commandTargetSessionId(ctx.workspace) const meta = sessionId ? ctx.workspace.state.sessions[sessionId] : null const enabled = Boolean(meta?.builtInMcpDomains?.includes(domain)) - return { - label: enabled ? 'On' : 'Off', - tone: enabled ? 'accent' : 'neutral', - } + return toggle(enabled, { truth: 'runtime' }) } function toggleBuiltInMcpDomain( @@ -637,10 +639,12 @@ export const sessionCommands: CommandDef[] = [ const sessionId = commandTargetSessionId(workspace) const meta = sessionId ? workspace.state.sessions[sessionId] : null const kind = meta?.kind ?? DEFAULT_PROVIDER - return { - label: getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER).shortLabel, - tone: 'neutral', - } + // The provider name is CONTEXT — which provider this command would act on — + // not an enabled state. Styled with accent tone it read as a live toggle. + return value( + getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER) + .shortLabel, + ) }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) @@ -682,10 +686,12 @@ export const sessionCommands: CommandDef[] = [ const sessionId = commandTargetSessionId(workspace) const meta = sessionId ? workspace.state.sessions[sessionId] : null const kind = meta?.kind ?? DEFAULT_PROVIDER - return { - label: getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER).shortLabel, - tone: 'neutral', - } + // The provider name is CONTEXT — which provider this command would act on — + // not an enabled state. Styled with accent tone it read as a live toggle. + return value( + getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER) + .shortLabel, + ) }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) @@ -711,10 +717,12 @@ export const sessionCommands: CommandDef[] = [ getState: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) const meta = sessionId ? workspace.state.sessions[sessionId] : null - return { - label: agentViewOverrideLabel(meta?.agentViewModeOverride), - tone: meta?.agentViewModeOverride ? 'accent' : 'neutral', - } + // A selected option out of Default/Agent/Terminal, and a PERSISTED one — + // it survives with the session. Not a toggle: "Default" is a real third + // choice, not the absence of a state. + return value(agentViewOverrideLabel(meta?.agentViewModeOverride), { + truth: 'persisted', + }) }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) @@ -741,10 +749,12 @@ export const sessionCommands: CommandDef[] = [ const sessionId = commandTargetSessionId(workspace) const meta = sessionId ? workspace.state.sessions[sessionId] : null const kind = meta?.kind ?? DEFAULT_PROVIDER - return { - label: getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER).shortLabel, - tone: 'neutral', - } + // The provider name is CONTEXT — which provider this command would act on — + // not an enabled state. Styled with accent tone it read as a live toggle. + return value( + getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER) + .shortLabel, + ) }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) @@ -869,10 +879,12 @@ export const sessionCommands: CommandDef[] = [ const sessionId = commandTargetSessionId(workspace) const meta = sessionId ? workspace.state.sessions[sessionId] : null const kind = meta?.kind ?? DEFAULT_PROVIDER - return { - label: getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER).shortLabel, - tone: 'neutral', - } + // The provider name is CONTEXT — which provider this command would act on — + // not an enabled state. Styled with accent tone it read as a live toggle. + return value( + getRendererProviderCapabilities(isAgentProviderKind(kind) ? kind : DEFAULT_PROVIDER) + .shortLabel, + ) }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) @@ -890,10 +902,7 @@ export const sessionCommands: CommandDef[] = [ surface: 'app', title: 'Git Bar', description: '**What it does:** Shows or hides the **Git** side panel.\n\n**Use when:** You want repository status for the focused project.\n\n**Notes:** Uses the focused command target’s working directory.', - getState: ({ flags }) => ({ - label: flags.gitBarOpen ? 'On' : 'Off', - tone: flags.gitBarOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.gitBarOpen), run: ({ ui }) => ui.toggleGitBar(), }, { @@ -903,10 +912,7 @@ export const sessionCommands: CommandDef[] = [ surface: 'debug', title: 'Debug Panel', description: '**What it does:** Shows or hides the focused pane’s **debug panel**.\n\n**Use when:** You need low-level pane or runtime state.\n\n**Notes:** Developer-oriented.', - getState: ({ flags }) => ({ - label: flags.debugPanelOpen ? 'On' : 'Off', - tone: flags.debugPanelOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.debugPanelOpen), run: ({ ui }) => ui.toggleDebugPanel(), }, { @@ -917,10 +923,7 @@ export const sessionCommands: CommandDef[] = [ title: 'Feed Debug Panel', description: '**What it does:** Shows or hides the **feed debug log** panel.\n\n**Use when:** You want render and feed timeline logs.\n\n**Notes:** Developer-oriented.', keywords: ['debug', 'logs', 'feed', 'render', 'rows', 'timeline', 'panel'], - getState: ({ flags }) => ({ - label: flags.feedDebugPanelOpen ? 'On' : 'Off', - tone: flags.feedDebugPanelOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.feedDebugPanelOpen), run: ({ ui }) => ui.toggleFeedDebugPanel(), }, { @@ -931,10 +934,7 @@ export const sessionCommands: CommandDef[] = [ title: 'Proxy Debug Panel', description: '**What it does:** Shows or hides **proxy/SSE debug** details.\n\n**Use when:** You are debugging streamed provider events.\n\n**Notes:** Most useful when proxy streaming is enabled.', keywords: ['proxy', 'sse', 'stream', 'semantic', 'anthropic', 'debug'], - getState: ({ flags }) => ({ - label: flags.proxyDebugPanelOpen ? 'On' : 'Off', - tone: flags.proxyDebugPanelOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.proxyDebugPanelOpen), run: ({ ui }) => ui.toggleProxyDebugPanel(), }, { @@ -1057,10 +1057,16 @@ export const sessionCommands: CommandDef[] = [ title: 'Rendering Debug Mode', description: '**What it does:** Lets you click rendered feed elements to inspect their exact input, routing provenance, and HTML.\n\n**Use when:** A row is missing, duplicated, misleading, or formatted incorrectly.\n\n**Notes:** Clicks are intercepted while active; toggle the mode off to restore normal interaction.', keywords: ['rendering', 'renderer', 'inspect', 'element', 'html', 'input', 'receipt', 'routing', 'provenance', 'debug'], - getState: ({ flags }) => ({ - label: flags.renderingDebugMode ? 'On' : 'Off', - tone: flags.renderingDebugMode ? 'danger' : 'neutral', - }), + // Danger tone is no longer authored here. This mode intercepts every click + // in the feed, so it IS worth flagging — but tone is derived from meaning, + // and the meaning is "on". The detail carries the warning instead, which is + // both more informative and impossible to drift from the actual state. + getState: ({ flags }) => + toggle(flags.renderingDebugMode, { + detail: flags.renderingDebugMode + ? 'Feed clicks are intercepted while this is on' + : undefined, + }), run: ({ ui }) => ui.toggleRenderingDebugMode(), }, { @@ -1075,10 +1081,7 @@ export const sessionCommands: CommandDef[] = [ // The feature is niche enough that users won't remember its exact // title, but they'll remember what they want to do with it. keywords: ['html', 'dom', 'outerhtml', 'markup', 'inspect', 'copy', 'pane', 'render', 'debug'], - getState: ({ flags }) => ({ - label: flags.htmlDebugPanelOpen ? 'On' : 'Off', - tone: flags.htmlDebugPanelOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.htmlDebugPanelOpen), run: ({ ui }) => ui.toggleHtmlDebugPanel(), }, { @@ -1090,10 +1093,7 @@ export const sessionCommands: CommandDef[] = [ description: '**What it does:** Shows or hides the temporary **Dev Debug Panel** module host.\n\n**Use when:** You need a bug-specific workbench for focused runtime state, regex probes, IPC experiments, or other short-lived diagnostics.\n\n**Notes:** Only appears when `AGENT_CODE_DEV_DEBUG=1` is set.', keywords: ['dev', 'debug', 'module', 'probe', 'regex', 'headless', 'snapshot', 'temporary'], when: ({ flags }) => flags.devDebugEnabled, - getState: ({ flags }) => ({ - label: flags.devDebugPanelOpen ? 'On' : 'Off', - tone: flags.devDebugPanelOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.devDebugPanelOpen), run: ({ ui }) => ui.toggleDevDebugPanel(), }, ] From dcf8d721fbe22fe77ffcd07c08e8e61083eedca2 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 18:41:45 +0200 Subject: [PATCH 18/33] feat(dispatch): remove Attach Project Terminal to Dispatch end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 (part 1) of the command-governance plan. The plan's decision 5 is explicit that this is a FEATURE RETIREMENT, not a misplaced command: the opt-in auto-created companion terminal and its dedicated Dispatch side column are gone. Ordinary user-created terminals are untouched — they remain normal sessions and normal Dispatch rows. Removed: the Settings row, `Settings.dispatchProjectTerminal` and its default and coercion, the DispatchLayout auto-create effect, the fixed-width side column, and the `ensureDispatchTerminal` action plus its interface entry and hook export (DispatchLayout was its only caller, so it became dead the moment the effect went). THE SPREAD TRAP, which the plan flags by name and which is the reason this needed more than a delete: `coerceSettings` does `{...DEFAULT_SETTINGS, ...parsed}`. Deleting a field from the TYPE does not delete it from a user's stored blob — `...parsed` copies every key it finds, so the retired key survives coercion, gets written back on the next save, and lives forever in localStorage, invisible to TypeScript. `parsed` now goes through `omitRetiredSettingsKeys`, driven by an explicit RETIRED_SETTINGS_KEYS list. An explicit list rather than "drop anything not in DEFAULT_SETTINGS": that broader rule would silently discard keys belonging to features under development or to a build the user downgraded from. Retiring a key is a deliberate act and reads as one. The test covers the real lifecycle rather than a single call — coerce, serialize, coerce again — because a key that survives one round trip survives all of them. It also asserts the omission is surgical: a filter that dropped more than its list would quietly reset unrelated preferences on upgrade. Store version 9 -> 10. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 242 files / 1562 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app-state/settings/persistence.ts | 38 ++++++-- .../app-state/settings/retiredKeys.test.ts | 60 +++++++++++++ src/renderer/src/app-state/settings/types.ts | 3 - src/renderer/src/app-state/store.ts | 2 +- .../features/settings/lib/settingsRegistry.ts | 19 ---- .../workspace/commands/layoutCommands.ts | 11 +-- .../src/workspace/dispatch/DispatchLayout.tsx | 51 ----------- .../src/workspace/hook/actions/dispatch.ts | 90 ------------------- src/renderer/src/workspace/hook/index.ts | 1 - .../hook/persistence/useBootstrap.ts | 14 +-- src/renderer/src/workspace/types.ts | 11 ++- 11 files changed, 105 insertions(+), 195 deletions(-) create mode 100644 src/renderer/src/app-state/settings/retiredKeys.test.ts diff --git a/src/renderer/src/app-state/settings/persistence.ts b/src/renderer/src/app-state/settings/persistence.ts index 1f9e327b..d901be12 100644 --- a/src/renderer/src/app-state/settings/persistence.ts +++ b/src/renderer/src/app-state/settings/persistence.ts @@ -47,7 +47,13 @@ export function coerceSettings(value: unknown): Settings { return { ...DEFAULT_SETTINGS, - ...parsed, + // WHY `parsed` is spread through a filter rather than directly: deleting a + // field from the `Settings` TYPE does not delete it from a user's stored + // blob. `...parsed` copies every key it finds, so a retired key survives + // coercion, gets written back on the next save, and lives forever in + // localStorage — invisible to TypeScript and impossible to reason about. + // The plan calls this out explicitly for dispatchProjectTerminal. + ...omitRetiredSettingsKeys(parsed), savedThemes, savedPromptTemplates, dispatchColorFlags: coerceDispatchColorFlags(parsed.dispatchColorFlags), @@ -71,11 +77,6 @@ export function coerceSettings(value: unknown): Settings { // non-strings so a corrupt localStorage blob cannot break settings boot. dictationShortcut: coerceHotkeyBinding(parsed.dictationShortcut), aggressiveDebugPersistence: parsed.aggressiveDebugPersistence === true, - // Strict `=== true` so missing OR malformed values default to off — - // matches the "off by default, opt in" semantics promised in the - // setting's docstring. Anything looser (e.g. `!== false`) would - // flip the default to ON for fresh installs. - dispatchProjectTerminal: parsed.dispatchProjectTerminal === true, // `!== false` so the default is ON — only an explicit persisted `false` // turns autosend off. Fresh installs / older workspace.json blobs (no // such key) get the on-by-default behavior. @@ -232,6 +233,31 @@ const RETIRED_BUILT_IN_COMMAND_IDS: ReadonlySet = new Set([ 'dangerous-agents', ]) +/** + * Persisted Settings keys this release removed. + * + * Listing them is what makes a removal real. A key absent from the type but + * present in storage is worse than one that is merely unused: it round-trips + * through every save, so the blob keeps growing and a future field with the + * same name would silently inherit a stale value. + * + * Removed in the command-governance change: `dispatchProjectTerminal`, the + * opt-in auto-created Dispatch project terminal. The whole feature is gone — + * the Settings row, the auto-create effect, the dedicated side column and the + * ensureDispatchTerminal action — so the preference has nothing left to + * control. + */ +const RETIRED_SETTINGS_KEYS: readonly string[] = ['dispatchProjectTerminal'] + +function omitRetiredSettingsKeys(parsed: Partial): Partial { + const result: Record = {} + for (const [key, value] of Object.entries(parsed)) { + if (RETIRED_SETTINGS_KEYS.includes(key)) continue + result[key] = value + } + return result as Partial +} + function coerceCommandVisibilityOverrides(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return {} const result: Record = {} diff --git a/src/renderer/src/app-state/settings/retiredKeys.test.ts b/src/renderer/src/app-state/settings/retiredKeys.test.ts new file mode 100644 index 00000000..f6a877d4 --- /dev/null +++ b/src/renderer/src/app-state/settings/retiredKeys.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { coerceSettings } from '@renderer/app-state/settings/persistence' + +// --------------------------------------------------------------------------- +// Phase 7: retiring a persisted Settings field. +// +// Deleting a field from the `Settings` TYPE does not delete it from a user's +// stored blob. `coerceSettings` spreads `parsed` wholesale, so a retired key +// survives coercion, gets written back on the next save, and lives forever in +// localStorage — invisible to TypeScript and impossible to reason about. The +// plan flags this trap by name for `dispatchProjectTerminal`. +// --------------------------------------------------------------------------- + +const RETIRED = 'dispatchProjectTerminal' + +describe('retired persisted settings keys', () => { + it.each([true, false])('drops a stored %p value', stored => { + // Both polarities, because the trap is about the KEY surviving the spread, + // not about which value it held. + const settings = coerceSettings({ [RETIRED]: stored }) as Record + expect(RETIRED in settings).toBe(false) + }) + + it('does not reserialize the key on the next save', () => { + // The actual damage: coerce -> save -> coerce is the real lifecycle, and a + // key that survives one round trip survives all of them. + const once = coerceSettings({ [RETIRED]: true }) + const twice = coerceSettings(JSON.parse(JSON.stringify(once))) as Record + expect(RETIRED in twice).toBe(false) + }) + + it('is idempotent across repeated coercion', () => { + // Coercion runs on every launch through `merge`, not only on a version + // change, so drift on the second pass means drift on every boot. + const first = coerceSettings({ [RETIRED]: true }) + const second = coerceSettings(first) + expect(second).toEqual(first) + }) + + it('leaves every surviving setting untouched', () => { + // The omission must be surgical. A filter that dropped more than its list + // would silently reset unrelated preferences on upgrade. + const settings = coerceSettings({ + [RETIRED]: true, + showStatusMode: false, + usageHeaderLevel: 'minimal', + navigationCommandsEnabled: true, + }) + expect(settings.showStatusMode).toBe(false) + expect(settings.usageHeaderLevel).toBe('minimal') + expect(settings.navigationCommandsEnabled).toBe(true) + }) + + it('still produces a usable settings object from a blob that had only the retired key', () => { + const settings = coerceSettings({ [RETIRED]: true }) + expect(settings.mode).toBeDefined() + expect(settings.accent).toBeDefined() + }) +}) diff --git a/src/renderer/src/app-state/settings/types.ts b/src/renderer/src/app-state/settings/types.ts index 5ce329a6..27b5f973 100644 --- a/src/renderer/src/app-state/settings/types.ts +++ b/src/renderer/src/app-state/settings/types.ts @@ -353,8 +353,6 @@ export type Settings = { * entered dispatch defaulted the flag to ON. Moving the gate to a * global setting collapses the toggle surface to one place the user * controls and removes the "terminal always mounted even when turned - * off" failure mode. */ - dispatchProjectTerminal: boolean /** When on (default), clicking a prompt-suggestion chip immediately SENDS * that suggestion as the next prompt; when off, clicking only prefills the * composer draft so the user can edit before submitting. The chip is an @@ -481,7 +479,6 @@ export const DEFAULT_SETTINGS: Settings = { // Preserve today's opt-in behavior. Users choose which capabilities become // defaults; session commands remain available regardless of this empty seed. defaultBuiltInMcpDomains: [], - dispatchProjectTerminal: false, autoSendPromptSuggestion: true, fontFamily: 'jetbrains-mono', // Empty by default: nothing is hidden until the user opts in per diff --git a/src/renderer/src/app-state/store.ts b/src/renderer/src/app-state/store.ts index 1dd42e80..166a71c9 100644 --- a/src/renderer/src/app-state/store.ts +++ b/src/renderer/src/app-state/store.ts @@ -60,7 +60,7 @@ export const useAppStore = create()( // real product default and downstream session initialization reads it // synchronously, so older blobs must be backfilled before workspace // bootstrap can create or recover an agent. - version: 9, + version: 10, storage: createJSONStorage(() => localStorage), partialize: state => ({ settings: state.settings }), merge: (persisted, current) => { diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index 1d20bcb5..0856ed83 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -457,25 +457,6 @@ export function getSettingsRegistry(): SettingDefinition[] { ctx.onChange({ usageHeaderLevel: value as Settings['usageHeaderLevel'] }), }, }, - { - // Replaces the old "Dispatch Terminal" command-palette toggle. The - // command sat on a per-session `dispatchMode.terminalVisible` flag - // that re-defaulted to ON every time dispatch was re-entered, which - // produced the "I turned it off but it's back" failure mode. The - // settings entry is the single source of truth: off → never mount, - // on → mount and live as a normal tile-tree leaf. - id: 'dispatch-project-terminal', - category: 'workspace', - title: 'Attach Project Terminal to Dispatch', - description: - 'When on, Dispatch Mode mounts a project terminal beside the agent list. Off by default.', - keywords: ['dispatch', 'terminal', 'project', 'attach', 'shell', 'pane'], - control: { - type: 'toggle', - getValue: settings => settings.dispatchProjectTerminal, - onToggle: (ctx, value) => ctx.onChange({ dispatchProjectTerminal: value }), - }, - }, { id: 'auto-send-prompt-suggestion', category: 'workspace', diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts index f74a0923..2413eb68 100644 --- a/src/renderer/src/features/workspace/commands/layoutCommands.ts +++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts @@ -54,13 +54,10 @@ export const layoutCommands: CommandDef[] = [ keywords: ['tiled dispatch', 'multi agent', 'lanes', 'split dispatch', 'cockpit', 'parallel agents', 'grid of agents'], run: ({ ui }) => ui.openTiledDispatchPrompt(), }, - // REMOVED: 'toggle-dispatch-terminal' command. The dispatch project - // terminal is now controlled by `settings.dispatchProjectTerminal` - // (default OFF) rather than a per-session command-palette toggle. The - // old command sat on top of an ephemeral `dispatchMode.terminalVisible` - // flag that re-defaulted to ON every time dispatch was re-entered, - // producing the "I turned it off but it came back" failure mode. - // Search settings → "Attach Project Terminal to Dispatch" to toggle. + // REMOVED: the 'toggle-dispatch-terminal' command, then its replacement + // `settings.dispatchProjectTerminal`, and now the feature itself. The + // opt-in auto-created companion terminal and its dedicated Dispatch side + // column are gone; user-created terminals still work exactly as before. { id: 'normalize-layout', category: 'layout-dispatch', diff --git a/src/renderer/src/workspace/dispatch/DispatchLayout.tsx b/src/renderer/src/workspace/dispatch/DispatchLayout.tsx index 09e95d81..b7a40da7 100644 --- a/src/renderer/src/workspace/dispatch/DispatchLayout.tsx +++ b/src/renderer/src/workspace/dispatch/DispatchLayout.tsx @@ -75,12 +75,6 @@ function ClassicDispatchLayout({ : workspace.activeTab const terminalSessionId = findTerminalSessionInTab(activeTab, workspace.state) // Source of truth for whether the project terminal mounts is now the - // global setting, not the ephemeral `dispatchMode.terminalVisible` we - // used to keep on workspace state. The setting defaults to OFF — - // matches the user's "off by default, opt in" intent and removes the - // "I turned it off but it came back" failure mode that the per-session - // flag suffered from. Toggle is in Settings → Workspace. - const terminalVisible = useAppStore(state => state.settings.dispatchProjectTerminal) // Resizable list/active-agent split. The ratio is owned by uiShell // (see UiShellState.dispatchListRatio) so it survives mode toggles @@ -133,18 +127,6 @@ function ClassicDispatchLayout({ workspace.state.dispatchMode?.focusedSessionId, ]) - useEffect(() => { - // Spawn the terminal lazily when the user opts in. This is the ONLY - // entry point that calls ensureDispatchTerminal — the dispatch - // actions (enter/setScope) deliberately no longer do, so flipping - // the setting OFF and re-entering Dispatch will NOT silently spawn - // a fresh terminal in the background. The existing terminal in a - // tab that was spawned earlier stays as a tile-tree leaf; if the - // user wants to remove it, they close it like any other pane. - if (!terminalVisible || !activeTab) return - void workspace.ensureDispatchTerminal(activeTab.id) - }, [activeTab?.id, terminalVisible, workspace.ensureDispatchTerminal]) - // List width is the ratio * row width. Active-agent pane absorbs // the remainder via `flex-1`. When the project terminal is on, we // give it a fixed-percentage column (25%) that doesn't move with @@ -204,39 +186,6 @@ function ClassicDispatchLayout({ )}
- {terminalVisible && activeRow?.sessionId !== terminalSessionId && ( -
- {activeTab && terminalSessionId ? ( - workspace.focusDispatchSession(activeTab.id, terminalSessionId)} - workspace={workspace} - /> - ) : ( - - )} -
- )} ) } diff --git a/src/renderer/src/workspace/hook/actions/dispatch.ts b/src/renderer/src/workspace/hook/actions/dispatch.ts index 6f7bfa4c..bc910296 100644 --- a/src/renderer/src/workspace/hook/actions/dispatch.ts +++ b/src/renderer/src/workspace/hook/actions/dispatch.ts @@ -27,7 +27,6 @@ export function useDispatchActions( enterDispatchMode: (scope?: DispatchModeState['scope']) => Promise exitDispatchMode: () => void setDispatchScope: (scope: DispatchModeState['scope']) => Promise - ensureDispatchTerminal: (tabId?: TabId) => Promise focusDispatchSession: (tabId: TabId, sessionId: SessionId) => void pinSession: (sessionId: SessionId) => void unpinSession: (sessionId: SessionId) => void @@ -42,88 +41,6 @@ export function useDispatchActions( } { const pendingTerminalByTabRef = useRef(new Map>()) - const ensureDispatchTerminal = useCallback( - async (tabId = refs.stateRef.current.activeTabId): Promise => { - const snapshot = refs.stateRef.current - const tab = snapshot.tabs.find(item => item.id === tabId) - if (!tab) return null - - const leafIds = collectLeaves(tab.root) - const existing = findTerminalSessionInTab(tab, snapshot) - if (existing) return existing - - // Anchor the terminal's cwd to the session the user is actually focused - // on. In Tiled Dispatch that's the focused lane's agent — tab.focusedSessionId - // is stale grid focus there and would spawn the terminal in the wrong - // project. Fall back to grid focus, then any leaf cwd of the tab. - const anchorId = dispatchFocusedSessionId(snapshot.dispatchMode) ?? tab.focusedSessionId - const cwd = (anchorId ? snapshot.sessions[anchorId]?.cwd : undefined) - ?? leafIds.map(id => snapshot.sessions[id]?.cwd).find(Boolean) - if (!cwd) { - showToast('Could not create dispatch terminal: no project directory found') - return null - } - - const pending = pendingTerminalByTabRef.current.get(tabId) - if (pending) return pending - - const created = (async () => { - const latest = refs.stateRef.current - const latestTab = latest.tabs.find(item => item.id === tabId) - const latestTerminal = findTerminalSessionInTab(latestTab ?? null, latest) - if (latestTerminal) return latestTerminal - - let terminalId: SessionId - try { - terminalId = await sessionActions.spawn(cwd, { kind: 'terminal' }) - } catch (err) { - showToast( - err instanceof Error && err.message.length > 0 - ? err.message - : 'Failed to create dispatch terminal', - ) - return null - } - - let inserted = false - setState(prev => { - const tabs = prev.tabs.map(currentTab => { - if (currentTab.id !== tabId) return currentTab - if (findTerminalSessionInTab(currentTab, prev)) { - return currentTab - } - inserted = true - // Dispatch renders the terminal outside the grid, but the - // session still needs to be a normal leaf so existing lifetime, - // tmux recovery, persistence, and IPC routing keep working. - // Preserving focusedSessionId keeps terminal creation invisible - // to the agent the user was actively commanding. - return { - ...currentTab, - root: wrapRootWithLeaf(currentTab.root, 'vertical', 'b', terminalId), - focusedSessionId: currentTab.focusedSessionId, - } - }) - return { ...prev, tabs } - }) - if (!inserted) { - // A terminal can appear after spawn but before the leaf insert - // (for example from another caller using the normal split path). - // `spawn()` already registered this terminal in state.sessions, so - // leaving it unattached would leak both renderer state and a PTY. - await sessionActions.killSession(terminalId) - return findTerminalInLatestTab(refs, tabId) - } - return terminalId - })().finally(() => { - pendingTerminalByTabRef.current.delete(tabId) - }) - pendingTerminalByTabRef.current.set(tabId, created) - return created - }, - [refs.stateRef, sessionActions, setState, showToast], - ) - const enterDispatchMode = useCallback( async (scope: DispatchModeState['scope'] = state.dispatchMode?.scope ?? 'project') => { closeNewAgentPlacement() @@ -135,12 +52,6 @@ export function useDispatchActions( }, })) setTileTabs(null) - // Terminal mount is now gated by `settings.dispatchProjectTerminal`, - // which lives outside this action's reach (settings live in the - // settings store, dispatch state lives in workspace state). - // DispatchLayout's useEffect reads the setting and calls - // ensureDispatchTerminal itself when appropriate — meaning we - // deliberately do NOT unconditionally fire it here anymore. Doing so // would spawn a terminal even with the setting OFF, which is the // exact bug shape we're fixing. }, @@ -405,7 +316,6 @@ export function useDispatchActions( enterDispatchMode, exitDispatchMode, setDispatchScope, - ensureDispatchTerminal, focusDispatchSession, pinSession, unpinSession, diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts index de9279cd..b81b66be 100644 --- a/src/renderer/src/workspace/hook/index.ts +++ b/src/renderer/src/workspace/hook/index.ts @@ -932,7 +932,6 @@ export function useWorkspace( enterDispatchMode: dispatchActions.enterDispatchMode, exitDispatchMode: dispatchActions.exitDispatchMode, setDispatchScope: dispatchActions.setDispatchScope, - ensureDispatchTerminal: dispatchActions.ensureDispatchTerminal, focusDispatchSession: dispatchActions.focusDispatchSession, pinSession: dispatchActions.pinSession, unpinSession: dispatchActions.unpinSession, diff --git a/src/renderer/src/workspace/hook/persistence/useBootstrap.ts b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts index 11529eee..b3b51e75 100644 --- a/src/renderer/src/workspace/hook/persistence/useBootstrap.ts +++ b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts @@ -95,17 +95,9 @@ export function useBootstrap( finalStatus = 'fresh' // WHY apply the default mode here, after newTab resolves: // - // `enterDispatchMode` triggers `ensureDispatchTerminal`, which - // looks up the active tab via `refs.stateRef.current.activeTabId` - // and inserts a terminal leaf into that tab's tree. If we called - // it before newTab finished, there'd be no tab yet → no terminal - // gets attached → the spawned PTY leaks. Sequencing it after - // newTab guarantees a tab+focused session exist. - // - // We deliberately do NOT touch the rehydrate path: existing - // workspaces already have `dispatchMode: null` (or a real value) - // persisted, so honoring the persisted value is what users - // expect. This setting is "first launch only" by design. + // `enterDispatchMode` no longer spawns anything: the auto-created + // project terminal was retired, so entering Dispatch is now purely + // a layout change. if (defaultWorkspaceMode === 'dispatch') { try { await enterDispatchMode('project') diff --git a/src/renderer/src/workspace/types.ts b/src/renderer/src/workspace/types.ts index 74d0e8d7..1dc36ec0 100644 --- a/src/renderer/src/workspace/types.ts +++ b/src/renderer/src/workspace/types.ts @@ -319,12 +319,11 @@ export type DispatchModeState = { * survive reloads for free. Absent => classic Dispatch (unchanged). */ tiled?: TiledDispatchState - // HISTORICAL: a `terminalVisible: boolean` flag used to live here. It - // was replaced by the global `settings.dispatchProjectTerminal` toggle - // because the per-session flag re-defaulted to ON every time dispatch - // was re-entered, producing the "I turned it off but it's back" bug. - // Old workspace.json files may still carry the field; it is now ignored - // (TypeScript drops unknown properties at runtime on shape coercion). + // HISTORICAL: a `terminalVisible: boolean` flag used to live here, then a + // global `settings.dispatchProjectTerminal` toggle replaced it. Both are + // gone — the auto-created Dispatch project terminal was retired entirely. + // Ordinary user-created terminals are unaffected: they are normal sessions + // and normal Dispatch rows. } export type WorkspaceState = { From 0ff498ce85f13f1037b23a1b73e034aa225e986a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 18:46:30 +0200 Subject: [PATCH 19/33] feat(workspace): add the close-confirmation policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 (part 2a) of the command-governance plan: the recorded product decision on when a destructive close needs the user to confirm, as a pure testable module. Confirmation is deliberately NOT a blanket modal. Closing a pane happens dozens of times an hour, and a dialog on the common case would cost more than the risk it guards. The three-way split: idle single session -> immediate, Undo Close recovers it running or streaming -> confirm cascade or multi-target-> confirm, naming the EXACT count and list WHY "it's undoable" only justifies the first case: Undo Close restores the workspace entry, not the world. It cannot bring back live terminal scrollback, an unsent composer draft, or a half-finished cascade where some children died and others did not. The cheap case stays cheap precisely because it is the one undo actually covers. `grantStillMatches` compares by ID SET, not by count. The audit's bulk-close finding is that a preview can go stale between confirmation and kill — and two agents finishing while two others spawn keeps the count identical while changing every target, which is exactly what a count check waves through. `narrowGrantToCurrent` lets a bulk flow proceed with what survives instead of abandoning everything, with one asymmetry that matters: a session that STARTED working since the preview is dropped. The user approved closing an idle agent; one that woke up is outside what they authorized even though its id is unchanged. Failures and skips are reported separately because they mean different things — a failure is a backend problem worth retrying, a skip is the grant correctly refusing to cover work the user never saw. The module is pure by design: it decides, and does not prompt, mutate, or know what a dialog looks like. That keeps the decision reviewable in one place rather than re-derived at each of the five close entry points (palette, keybinding, tab button, native menu, programmatic). Wiring those entry points to it is the next commit. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 243 files / 1576 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/workspace/closeConfirmation.test.ts | 121 ++++++++++++++ .../src/workspace/closeConfirmation.ts | 148 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/renderer/src/workspace/closeConfirmation.test.ts create mode 100644 src/renderer/src/workspace/closeConfirmation.ts diff --git a/src/renderer/src/workspace/closeConfirmation.test.ts b/src/renderer/src/workspace/closeConfirmation.test.ts new file mode 100644 index 00000000..9eac525d --- /dev/null +++ b/src/renderer/src/workspace/closeConfirmation.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' + +import { + closeConfirmationFor, + describePartialClose, + grantStillMatches, + narrowGrantToCurrent, +} from '@renderer/workspace/closeConfirmation' +import type { CloseTargetSnapshot } from '@renderer/workspace/closeConfirmation' + +const target = (id: string, live = false): CloseTargetSnapshot => ({ + sessionId: id, + title: `Agent ${id}`, + live, +}) + +describe('close confirmation policy', () => { + it('does not confirm an idle single close', () => { + // The common case stays cheap precisely because it is the one Undo Close + // genuinely covers. A dialog here would fire dozens of times an hour. + expect(closeConfirmationFor([target('a')])).toEqual({ required: false }) + }) + + it('confirms a single session that is still working', () => { + const result = closeConfirmationFor([target('a', true)]) + expect(result).toMatchObject({ required: true, reason: 'running' }) + if (result.required) expect(result.summary).toContain('still working') + }) + + it('confirms any multi-target close and names the exact count', () => { + // The audit's finding: a close that expanded from one row to four looked + // identical to closing one. + const result = closeConfirmationFor([target('a'), target('b'), target('c')]) + expect(result.required).toBe(true) + if (result.required) { + expect(result.summary).toContain('3 sessions') + expect(result.targets).toHaveLength(3) + } + }) + + it('reports how many of a cascade are still working', () => { + const result = closeConfirmationFor([target('a', true), target('b'), target('c', true)]) + expect(result.required).toBe(true) + if (result.required) { + expect(result.reason).toBe('cascade') + expect(result.summary).toContain('2 still working') + } + }) + + it('requires nothing for an empty target set', () => { + expect(closeConfirmationFor([])).toEqual({ required: false }) + }) +}) + +describe('grant validity', () => { + it('accepts an unchanged target set', () => { + const granted = [target('a'), target('b')] + expect(grantStillMatches(granted, [target('a'), target('b')])).toBe(true) + }) + + it('rejects a set with the same COUNT but different members', () => { + // The case a count check waves through: two agents finish and two new ones + // spawn, so the number matches while every target changed. + const granted = [target('a'), target('b')] + expect(grantStillMatches(granted, [target('c'), target('d')])).toBe(false) + }) + + it('rejects a grown or shrunk set', () => { + const granted = [target('a'), target('b')] + expect(grantStillMatches(granted, [target('a')])).toBe(false) + expect(grantStillMatches(granted, [target('a'), target('b'), target('c')])).toBe(false) + }) +}) + +describe('narrowing a stale grant', () => { + it('keeps the targets that are still present', () => { + // Killing the ten that did not change is the useful behaviour; the two + // that did must be dropped, not killed on the old preview's authority. + const granted = [target('a'), target('b'), target('c')] + const current = [target('a'), target('c')] + expect(narrowGrantToCurrent(granted, current).map(t => t.sessionId)).toEqual(['a', 'c']) + }) + + it('drops a target that started working since the grant', () => { + // The user approved closing an IDLE agent. One that woke up is outside + // what they authorized, even though its id is unchanged. + const granted = [target('a'), target('b')] + const current = [target('a'), target('b', true)] + expect(narrowGrantToCurrent(granted, current).map(t => t.sessionId)).toEqual(['a']) + }) + + it('keeps a target that was already live when granted', () => { + // The user saw and approved this one as running, so it stays covered. + const granted = [target('a', true)] + const current = [target('a', true)] + expect(narrowGrantToCurrent(granted, current)).toHaveLength(1) + }) + + it('returns nothing when every target changed', () => { + expect(narrowGrantToCurrent([target('a')], [target('b')])).toEqual([]) + }) +}) + +describe('partial close reporting', () => { + it('says nothing when everything succeeded', () => { + expect(describePartialClose({ closed: ['a', 'b'], failed: [], skipped: [] })).toBeNull() + }) + + it('reports failures and skips separately', () => { + // They mean different things: a failure is a backend problem worth + // retrying, a skip is the grant correctly refusing to cover new work. + const message = describePartialClose({ + closed: ['a'], + failed: [{ sessionId: 'b', error: new Error('kill failed') }], + skipped: ['c'], + }) + expect(message).toContain('Closed 1') + expect(message).toContain('1 failed') + expect(message).toContain('1 skipped') + }) +}) diff --git a/src/renderer/src/workspace/closeConfirmation.ts b/src/renderer/src/workspace/closeConfirmation.ts new file mode 100644 index 00000000..d8e156a1 --- /dev/null +++ b/src/renderer/src/workspace/closeConfirmation.ts @@ -0,0 +1,148 @@ +import type { SessionId } from '@renderer/workspace/types' + +// --------------------------------------------------------------------------- +// Close confirmation policy (governance plan, Phase 7). +// +// Confirmation is NOT a blanket modal before every close. Closing a pane is +// something people do dozens of times an hour, and a dialog on the common case +// would be worse than the risk it guards. The recorded product decision splits +// it three ways: +// +// idle, single session -> immediate, and Undo Close recovers it +// running or streaming -> confirm +// cascade or multi-target-> confirm, naming the EXACT count and list +// +// WHY "it's undoable" only covers the first case: Undo Close restores the +// workspace entry, not the world. It cannot bring back live terminal scrollback, +// an unsent composer draft, or a half-finished cascade where some children died +// and others did not. So the cheap case stays cheap precisely because it IS the +// one undo actually covers. +// +// This module is deliberately PURE. It decides; it does not prompt, mutate, or +// know what a dialog looks like. That keeps the product decision reviewable in +// one place instead of being re-derived at each of the five close entry points +// (palette, keybinding, tab button, native menu, programmatic). +// --------------------------------------------------------------------------- + +export type CloseTargetSnapshot = { + sessionId: SessionId + /** Display name for the confirmation list. */ + title: string + /** The provider is mid-turn, or its stream is not idle. */ + live: boolean +} + +export type CloseConfirmationRequest = + | { required: false } + | { + required: true + reason: 'running' | 'cascade' | 'bulk' + /** Every session this close will end, expanded. */ + targets: readonly CloseTargetSnapshot[] + /** One-line summary naming the exact count. */ + summary: string + } + +/** + * Does this close need the user to confirm, and what should it say? + * + * `targets` must ALREADY be the expanded set — parent plus linked children plus + * any detached sessions a tab close would take with it. Expansion happens at + * the call site because only it knows the shape of what is being closed; this + * function's job is to judge the expanded set, and passing it an unexpanded one + * would silently downgrade a cascade to a single close. + */ +export function closeConfirmationFor( + targets: readonly CloseTargetSnapshot[], +): CloseConfirmationRequest { + if (targets.length === 0) return { required: false } + + const live = targets.filter(target => target.live) + + if (targets.length === 1) { + const only = targets[0] + if (!only.live) { + // The cheap, common case, and the only one Undo Close genuinely covers. + return { required: false } + } + return { + required: true, + reason: 'running', + targets, + summary: `${only.title} is still working. Close it anyway?`, + } + } + + // More than one session dies. Whether it is a cascade (a parent taking its + // linked children) or a bulk selection, the user must see the COUNT — the + // audit's finding was that a close expanding from one row to four looked + // identical to closing one. + const reason = targets.length > 1 && live.length > 0 ? 'cascade' : 'bulk' + const liveNote = live.length > 0 ? `, ${live.length} still working` : '' + return { + required: true, + reason, + targets, + summary: `This closes ${targets.length} sessions${liveNote}.`, + } +} + +/** + * Is a grant still valid against a freshly re-read target set? + * + * The audit's bulk-close finding: a preview the user approved can go stale + * between confirmation and the kill — an agent finishes, a new one spawns, a + * session moves project. Re-running the enumeration and comparing here is what + * stops a stale grant authorizing work the user never saw. + * + * Compares by ID SET, not by count. Two sessions closing and two different ones + * appearing keeps the count identical while changing every target, which is + * exactly the case a count check would wave through. + */ +export function grantStillMatches( + granted: readonly CloseTargetSnapshot[], + current: readonly CloseTargetSnapshot[], +): boolean { + if (granted.length !== current.length) return false + const currentIds = new Set(current.map(target => target.sessionId)) + return granted.every(target => currentIds.has(target.sessionId)) +} + +/** + * The subset of a grant that is still present, for a bulk flow that should + * proceed with what survives rather than abandoning everything. + * + * Used by Close Old Agents: if two of twelve agents woke up between preview and + * commit, killing the other ten is the useful behaviour — but the two that + * changed must be DROPPED, never killed on the strength of the old preview. + */ +export function narrowGrantToCurrent( + granted: readonly CloseTargetSnapshot[], + current: readonly CloseTargetSnapshot[], +): CloseTargetSnapshot[] { + const currentById = new Map(current.map(target => [target.sessionId, target])) + return granted.flatMap(target => { + const now = currentById.get(target.sessionId) + if (!now) return [] + // A session that STARTED working since the preview is no longer covered by + // the grant: the user approved closing an idle agent, not a busy one. + if (now.live && !target.live) return [] + return [now] + }) +} + +export type PartialCloseOutcome = { + closed: SessionId[] + failed: { sessionId: SessionId; error: unknown }[] + /** Dropped because they changed between grant and commit. */ + skipped: SessionId[] +} + +/** Human-readable result for a bulk close that did not fully succeed. */ +export function describePartialClose(outcome: PartialCloseOutcome): string | null { + if (outcome.failed.length === 0 && outcome.skipped.length === 0) return null + const parts = [`Closed ${outcome.closed.length}`] + if (outcome.failed.length > 0) parts.push(`${outcome.failed.length} failed`) + if (outcome.skipped.length > 0) parts.push(`${outcome.skipped.length} skipped (changed)`) + return `${parts.join(', ')}.` +} From fa3f78c1855a418fff9deb227aa5d9debee7e93a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 18:51:36 +0200 Subject: [PATCH 20/33] feat(commands): apply the naming corrections and Settings scope metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. Command titles now follow the audit table. IDS ARE UNCHANGED, which is the point: titles are presentation, but ids are the contract that user visibility overrides, keybinding overrides and native-menu lookups are keyed on. Renaming an id would silently discard settings the user made. The renames, and what each was getting wrong: - Tail / Tail All -> Auto-follow Focused Agent / Auto-follow All Visible Agents. "Tail" names an implementation, not a behaviour, and gave no hint that one is scoped to a session and the other to the workspace. - Close/Bury/Revive/Kill "Pane" -> "Session". The live object persists without a pane, so Pane named the wrong noun — and Close Pane can close a Dispatch row that has no pane at all. - Global Dispatch -> Dispatch Scope, with Project/Global state instead of On/Off. "Global Dispatch: Off" told the user nothing about what Off meant; the alternative is Project scope, not "no dispatch". - Toggle Session Recording -> Session Recording. The repo's command-style rule is that titles are stable nouns and state lives in the badge. - Set Agent View Mode... -> Agent View for This Session…, which discloses the per-session scope the old title hid, and fixes three periods to a typographic ellipsis. - Set color flag -> Set Color Flag…, title case plus the ellipsis that says more input follows. Old vocabulary is retained as keywords. Someone with "Tail" or "Pane" in muscle memory must still find the renamed command — a rename that breaks search breaks the feature. Settings rows gain machine-readable scope/apply/storage/status metadata, rendered as badges. It defaults to app/immediate/settings and only EXCEPTIONS are annotated, so a badge means "the obvious reading would have been wrong" rather than becoming wallpaper on forty identical rows. The exceptions are the ones the audit named: Dangerous Agents reloads live agents (its copy claimed the opposite), Default Workspace Mode only affects a fresh install, and the dictation API key and CLI update policy live in the keychain and main's setup.json — which is what finally makes "what does Reset Settings actually reset" answerable. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 244 files / 1594 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/features/settings/lib/naming.test.ts | 109 ++++++++++++++++++ .../features/settings/lib/settingsRegistry.ts | 77 +++++++++++++ .../src/features/settings/ui/SettingsList.tsx | 63 ++++++++++ .../features/usage/commands/usageCommands.ts | 4 +- .../commands/dispatchColorFlagCommands.ts | 2 +- .../workspace/commands/layoutCommands.ts | 9 +- .../workspace/commands/paneCommands.ts | 20 ++-- .../workspace/commands/sessionCommands.ts | 10 +- 8 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/features/settings/lib/naming.test.ts diff --git a/src/renderer/src/features/settings/lib/naming.test.ts b/src/renderer/src/features/settings/lib/naming.test.ts new file mode 100644 index 00000000..061a0d59 --- /dev/null +++ b/src/renderer/src/features/settings/lib/naming.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +import { getSettingsRegistry, settingMetadata } from '@renderer/features/settings/lib/settingsRegistry' + +// --------------------------------------------------------------------------- +// Phase 8: naming and Settings information architecture. +// --------------------------------------------------------------------------- + +const titleOf = (id: string) => { + const command = builtInCommandCatalog.find(c => c.id === id) + if (!command) throw new Error(`no command ${id}`) + return typeof command.title === 'function' ? command.id : command.title +} +const keywordsOf = (id: string) => + builtInCommandCatalog.find(c => c.id === id)?.keywords ?? [] + +describe('command naming corrections', () => { + it.each([ + ['toggle-tail', 'Auto-follow Focused Agent'], + ['toggle-tail-all', 'Auto-follow All Visible Agents'], + ['close-pane', 'Close Focused Session'], + ['bury-pane', 'Bury Session'], + ['revive-pane', 'Revive Buried Session…'], + ['kill-buried-pane', 'Kill Buried Session…'], + ['global-dispatch', 'Dispatch Scope'], + ['toggle-session-recording', 'Session Recording'], + ['set-agent-view-mode', 'Agent View for This Session…'], + ['dispatch.color-flag.set', 'Set Color Flag…'], + ])('renames %s to %s', (id, title) => { + expect(titleOf(id)).toBe(title) + }) + + it('keeps ids stable across every rename', () => { + // Titles are presentation; IDS are the contract that user visibility + // overrides, keybinding overrides and native-menu lookups are keyed on. + // Renaming an id would silently discard a user's settings. + const ids = new Set(builtInCommandCatalog.map(c => c.id)) + for (const id of [ + 'toggle-tail', 'toggle-tail-all', 'close-pane', 'bury-pane', + 'revive-pane', 'kill-buried-pane', 'global-dispatch', + 'toggle-session-recording', 'set-agent-view-mode', 'dispatch.color-flag.set', + ]) { + expect(ids.has(id)).toBe(true) + } + }) + + it('retains the old vocabulary as searchable keywords', () => { + // Someone with "Tail" in muscle memory must still find the renamed + // command. A rename that breaks search is a rename that breaks the feature. + expect(keywordsOf('toggle-tail')).toContain('tail') + expect(keywordsOf('toggle-tail-all')).toContain('tail') + expect(keywordsOf('close-pane')).toContain('pane') + expect(keywordsOf('global-dispatch')).toContain('global dispatch') + }) + + it('uses a typographic ellipsis, never three periods', () => { + for (const command of builtInCommandCatalog) { + if (typeof command.title !== 'string') continue + expect(command.title).not.toMatch(/\.\.\./) + } + }) + + it('stops using Pane for things that outlive their pane', () => { + // Bury/Revive/Kill act on SESSIONS. The live object persists without a + // pane, so "Pane" named the wrong noun. + for (const id of ['bury-pane', 'revive-pane', 'kill-buried-pane', 'close-pane']) { + expect(titleOf(id)).not.toMatch(/\bPane\b/) + } + }) +}) + +describe('settings metadata', () => { + it('defaults to app / immediate / settings', () => { + // Defaulting keeps the EXCEPTIONS visible: a row carrying explicit + // metadata is one where the obvious reading would have been wrong. + const plain = getSettingsRegistry().find(row => row.id === 'auto-send-prompt-suggestion') + expect(plain && settingMetadata(plain)).toEqual({ + scope: 'app', + apply: 'immediate', + storage: 'settings', + }) + }) + + it('marks Dangerous Agents as reloading live agents', () => { + // The audit's sharpest copy defect: the row claimed existing agents were + // unaffected while enabling it reloads the whole fleet. + const row = getSettingsRegistry().find(r => r.id === 'dangerous-agents') + expect(row && settingMetadata(row)).toMatchObject({ + apply: 'reload-live-sessions', + status: 'dangerous', + }) + }) + + it('marks values that do not live in renderer Settings', () => { + // This is what makes "Reset Settings" answerable: the API key is in the + // keychain and the CLI update policy is in main's setup.json, so neither + // is cleared by resetting Settings. + const key = getSettingsRegistry().find(r => r.id === 'dictation-api-key') + expect(key && settingMetadata(key).storage).toBe('keychain') + const cli = getSettingsRegistry().find(r => r.id === 'cli-update-behavior') + expect(cli && settingMetadata(cli).storage).toBe('setup') + }) + + it('marks the fresh-install-only scope', () => { + const row = getSettingsRegistry().find(r => r.id === 'default-workspace-mode') + expect(row && settingMetadata(row).scope).toBe('fresh-install') + }) +}) diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index 0856ed83..cf8332dd 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -19,6 +19,53 @@ import { isVisibleInPicker } from '@renderer/features/command-palette/pickerVisi import type { PickerCommandMeta } from '@renderer/features/command-palette/registry' import type { ConfigurableBuiltInMcpDomain } from '@mcp/shared/types' +/** + * Machine-readable facts about what a setting actually DOES, rendered as small + * badges beside its row. + * + * WHY this exists: the audit found Settings copy that was misleading in ways + * prose alone kept reproducing — an "Application defaults" umbrella covering + * rows with four different scopes, MCP rows that look global but only affect + * NEW sessions, and Dangerous Agents which claims existing agents are + * unaffected while enabling it reloads the whole fleet. A user cannot tell + * these apart by reading, because every row looks the same. + * + * Making the difference structured rather than prose means the row itself + * carries the answer, and a new setting has to state its scope instead of + * inheriting a paragraph written for something else. + */ +export type SettingMetadata = { + /** Whose behaviour this changes. */ + scope: 'app' | 'project' | 'session-default' | 'fresh-install' + /** When the change takes effect. */ + apply: 'immediate' | 'new-session' | 'reload-live-sessions' | 'restart-required' + /** Where the value actually lives. Not always renderer Settings — some rows + * front main-owned setup state, the keychain, or files on disk, which is + * what makes "Reset Settings" ambiguous today. */ + storage: 'settings' | 'workspace' | 'setup' | 'keychain' | 'external-files' + /** Maturity or risk, when it is not ordinary. */ + status?: 'experimental' | 'dangerous' | 'developer' +} + +/** + * The common case, applied when a row does not declare otherwise: an app-wide + * preference stored in renderer Settings that takes effect at once. + * + * Defaulting rather than requiring all ~40 rows to repeat it keeps the + * exceptions visible — a row carrying explicit metadata is one where the + * obvious reading would have been WRONG, which is exactly the set a reader + * needs to notice. + */ +export const DEFAULT_SETTING_METADATA: SettingMetadata = { + scope: 'app', + apply: 'immediate', + storage: 'settings', +} + +export function settingMetadata(definition: { metadata?: SettingMetadata }): SettingMetadata { + return definition.metadata ?? DEFAULT_SETTING_METADATA +} + export type SettingActionContext = { workspace: Workspace settings: Settings @@ -46,6 +93,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata control: { type: 'toggle' getValue: (settings: Settings) => boolean @@ -58,6 +106,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata control: { type: 'select' getValue: (settings: Settings) => string @@ -72,6 +121,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata control: { type: 'hotkey' getValue: (settings: Settings) => string @@ -84,6 +134,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata control: { type: 'action' label: string @@ -97,6 +148,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata // Marker for the CLI auto-update three-way (Automatic / Notify / // Off). The row is rendered by its own self-subscribing component // in — the value lives in setup.json (main-owned), @@ -112,6 +164,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata // Marker for the theme grid. The generic `select` control renders // uniform value cells; this row needs per-cell Edit/Delete affordances // on saved themes plus a trailing "+ New theme…" cell that is an action @@ -127,6 +180,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata // Marker for the voice-dictation Deepgram API-key row. Same // rationale as cli-update-behavior above — the value lives in // safeStorage-backed main state (see src/main/dictation/ @@ -143,6 +197,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata // Main owns both the canonical document and the external deployment // health. A marker row prevents renderer Settings from inventing a second // boolean source of truth that could say Active after filesystem failure. @@ -156,6 +211,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata // Marker for the built-in keybinding editor. Self-subscribing for the // same reason as cli-update-behavior: the row needs the live effective // binding set plus conflict lookup, and hoisting all of that into the @@ -170,6 +226,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata control: { type: 'command-visibility' /** Full command catalog to render rows for. Carried as a value @@ -365,6 +422,9 @@ export function getSettingsRegistry(): SettingDefinition[] { description: 'Mode the app opens in on first launch. Existing workspaces keep their last-used mode — flipping this later only affects a fresh install.', keywords: ['default', 'mode', 'dispatch', 'grid', 'startup', 'launch', 'workspace'], + // Only affects a fresh install — existing workspaces keep their last-used + // mode, which the description says but the row could not show. + metadata: { scope: 'fresh-install', apply: 'new-session', storage: 'settings' }, control: { type: 'select', getValue: settings => settings.defaultWorkspaceMode, @@ -386,6 +446,7 @@ export function getSettingsRegistry(): SettingDefinition[] { description: 'Choose whether Claude and Codex panes show Agent Code rendering, the provider terminal, or terminal-first Hybrid mode that renders only while a feature needs the feed.', keywords: ['agent', 'view', 'mode', 'terminal', 'raw', 'hybrid', 'renderer', 'feed', 'tui'], + metadata: { scope: 'app', apply: 'new-session', storage: 'settings' }, control: { type: 'select', getValue: settings => settings.agentViewMode, @@ -479,6 +540,8 @@ export function getSettingsRegistry(): SettingDefinition[] { 'conventions', 'rules', 'instructions', 'agents', 'claude', 'codex', 'opencode', 'skills', 'commits', 'git', 'testing', 'development practices', ], + // Deploys a file into the project on disk. + metadata: { scope: 'project', apply: 'immediate', storage: 'external-files' }, control: { type: 'agent-code-conventions' }, }, { @@ -562,6 +625,7 @@ export function getSettingsRegistry(): SettingDefinition[] { 'hotkey', 'conflict', ], + metadata: { scope: 'app', apply: 'immediate', storage: 'settings' }, control: { type: 'command-keybindings' }, }, { @@ -580,6 +644,7 @@ export function getSettingsRegistry(): SettingDefinition[] { 'previous', 'group', ], + metadata: { scope: 'app', apply: 'immediate', storage: 'settings' }, control: { type: 'toggle', // Deliberately phrased as "show in the picker", not "enable @@ -607,6 +672,7 @@ export function getSettingsRegistry(): SettingDefinition[] { 'advanced', 'debug', ], + metadata: { scope: 'app', apply: 'immediate', storage: 'settings' }, control: { type: 'command-visibility', commands: pickerCommands, @@ -638,6 +704,9 @@ export function getSettingsRegistry(): SettingDefinition[] { description: 'Periodically save full debug bundles for active agent panes, plus a best-effort final bundle on close. Expensive, intended for Agent Code development.', keywords: ['debug', 'logs', 'persistent', 'aggressive', 'autosave', 'render', 'trace'], + // Writes continuously to disk. 'developer' is the honest maturity label for + // a setting whose real cost is storage the user never sees. + metadata: { scope: 'app', apply: 'immediate', storage: 'settings', status: 'developer' }, control: { type: 'toggle', getValue: settings => settings.aggressiveDebugPersistence, @@ -705,6 +774,9 @@ export function getSettingsRegistry(): SettingDefinition[] { 'secret', 'token', ], + // Lives in safeStorage-backed main state, NOT renderer Settings — which is + // why 'Reset Settings' does not clear it. + metadata: { scope: 'app', apply: 'immediate', storage: 'keychain' }, control: { type: 'dictation-api-key' }, }, { @@ -727,6 +799,9 @@ export function getSettingsRegistry(): SettingDefinition[] { description: 'Start Claude and Codex sessions with the bypass flags enabled. Existing live agent sessions are reloaded when this changes.', keywords: ['dangerous', 'bypass', 'agents', 'reload', 'safety'], + // The audit's sharpest copy defect: this row said existing agents were + // unaffected while enabling it RELOADS the whole fleet. + metadata: { scope: 'app', apply: 'reload-live-sessions', storage: 'settings', status: 'dangerous' }, control: { type: 'toggle', getValue: settings => settings.dangerousAgentsEnabled, @@ -761,6 +836,8 @@ export function getSettingsRegistry(): SettingDefinition[] { 'homebrew', 'winget', ], + // Owned by main's setup.json, not renderer Settings. + metadata: { scope: 'app', apply: 'immediate', storage: 'setup' }, control: { type: 'cli-update-behavior' }, }, { diff --git a/src/renderer/src/features/settings/ui/SettingsList.tsx b/src/renderer/src/features/settings/ui/SettingsList.tsx index 88445b54..72d7a084 100644 --- a/src/renderer/src/features/settings/ui/SettingsList.tsx +++ b/src/renderer/src/features/settings/ui/SettingsList.tsx @@ -6,6 +6,7 @@ import type { import { SETTING_CATEGORIES } from '@renderer/features/settings/lib/settingsCategories' import { HotkeyInput } from '@renderer/features/settings/ui/HotkeyInput' import { CommandKeybindingsRow } from '@renderer/features/settings/ui/CommandKeybindingsRow' +import { settingMetadata } from '@renderer/features/settings/lib/settingsRegistry' import { CliUpdateBehaviorRow } from '@renderer/features/cli-updates/CliUpdateBehaviorRow' import { DictationApiKeyRow } from '@renderer/features/voice-dictation/DictationApiKeyRow' import { ThemePickerRow } from '@renderer/features/settings/ui/ThemePickerRow' @@ -99,6 +100,8 @@ function SettingRow({
+ + {control.type === 'toggle' ? ( + + + + + ) +} diff --git a/src/renderer/src/workspace/closeConfirmationBroker.test.ts b/src/renderer/src/workspace/closeConfirmationBroker.test.ts new file mode 100644 index 00000000..7d8e29b5 --- /dev/null +++ b/src/renderer/src/workspace/closeConfirmationBroker.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { + __resetCloseConfirmationForTests, + currentCloseConfirmation, + requestCloseConfirmation, + resolveCloseConfirmation, + subscribeToCloseConfirmation, +} from '@renderer/workspace/closeConfirmationBroker' + +const request = { + required: true as const, + reason: 'cascade' as const, + targets: [ + { sessionId: 'a', title: 'A', live: false }, + { sessionId: 'b', title: 'B', live: true }, + ], + summary: 'This closes 2 sessions, 1 still working.', +} + +afterEach(() => { + // Module state must not leak between tests, or an unresolved promise from one + // silently changes the next. + __resetCloseConfirmationForTests() +}) + +describe('close confirmation broker', () => { + it('resolves true when the user confirms', async () => { + const answer = requestCloseConfirmation(request) + resolveCloseConfirmation(true) + await expect(answer).resolves.toBe(true) + }) + + it('resolves false when the user cancels', async () => { + const answer = requestCloseConfirmation(request) + resolveCloseConfirmation(false) + await expect(answer).resolves.toBe(false) + }) + + it('exposes the pending request for rendering and clears it on answer', () => { + void requestCloseConfirmation(request) + expect(currentCloseConfirmation()?.request.summary).toContain('2 sessions') + resolveCloseConfirmation(false) + expect(currentCloseConfirmation()).toBeNull() + }) + + it('notifies subscribers on open and close', () => { + const seen: (string | null)[] = [] + const unsubscribe = subscribeToCloseConfirmation(pending => + seen.push(pending?.request.summary ?? null), + ) + void requestCloseConfirmation(request) + resolveCloseConfirmation(false) + unsubscribe() + // Initial null, the request, then null again. + expect(seen).toHaveLength(3) + expect(seen[0]).toBeNull() + expect(seen[1]).toContain('2 sessions') + expect(seen[2]).toBeNull() + }) + + it('declines a superseded request rather than abandoning its promise', async () => { + // An abandoned promise leaves its close path awaiting forever, which + // presents as a pane that will not close and no error anywhere — worse + // than a refusal the user can simply retry. + const first = requestCloseConfirmation(request) + const second = requestCloseConfirmation(request) + await expect(first).resolves.toBe(false) + resolveCloseConfirmation(true) + await expect(second).resolves.toBe(true) + }) + + it('is safe to answer with nothing pending', () => { + expect(() => resolveCloseConfirmation(true)).not.toThrow() + }) + + it('stops notifying after unsubscribe', () => { + let calls = 0 + const unsubscribe = subscribeToCloseConfirmation(() => { calls += 1 }) + const afterInitial = calls + unsubscribe() + void requestCloseConfirmation(request) + expect(calls).toBe(afterInitial) + }) +}) diff --git a/src/renderer/src/workspace/closeConfirmationBroker.ts b/src/renderer/src/workspace/closeConfirmationBroker.ts new file mode 100644 index 00000000..48fecc11 --- /dev/null +++ b/src/renderer/src/workspace/closeConfirmationBroker.ts @@ -0,0 +1,81 @@ +import type { CloseConfirmationRequest } from '@renderer/workspace/closeConfirmation' + +// --------------------------------------------------------------------------- +// The bridge between a close ACTION and the confirmation DIALOG. +// +// The problem this solves: `closeFocused` is an async action inside a hook. It +// needs to stop, ask, and continue with the answer. React state alone cannot +// express that — a component renders the dialog, but the action needs a value +// back, and threading a resolver through the store would put a non-serializable +// function into zustand. +// +// So the store holds the REQUEST (plain data, renderable), and this module +// holds the pending resolver (a function, deliberately outside the store). The +// action awaits a promise; the dialog resolves it. +// +// WHY module scope rather than a ref: the requester and the responder live in +// different component trees — an action inside the workspace hook and a surface +// mounted at the app root. A ref would have to be threaded through both. +// --------------------------------------------------------------------------- + +export type PendingCloseConfirmation = { + request: Extract +} + +type Listener = (pending: PendingCloseConfirmation | null) => void + +let pending: PendingCloseConfirmation | null = null +let resolver: ((confirmed: boolean) => void) | null = null +const listeners = new Set() + +function emit(): void { + for (const listener of listeners) listener(pending) +} + +export function subscribeToCloseConfirmation(listener: Listener): () => void { + listeners.add(listener) + listener(pending) + return () => { + listeners.delete(listener) + } +} + +export function currentCloseConfirmation(): PendingCloseConfirmation | null { + return pending +} + +/** + * Ask the user, and resolve to their answer. + * + * A second request while one is open resolves the FIRST as declined rather than + * dropping it. An abandoned promise would leave its close path awaiting + * forever, which presents as a pane that will not close and no error anywhere — + * far worse than a refusal the user can retry. + */ +export function requestCloseConfirmation( + request: PendingCloseConfirmation['request'], +): Promise { + resolver?.(false) + pending = { request } + emit() + return new Promise(resolve => { + resolver = resolve + }) +} + +/** Answer the open request. Safe to call with nothing pending. */ +export function resolveCloseConfirmation(confirmed: boolean): void { + const resolve = resolver + pending = null + resolver = null + emit() + resolve?.(confirmed) +} + +/** Test seam: module state must not leak between tests. */ +export function __resetCloseConfirmationForTests(): void { + resolver?.(false) + pending = null + resolver = null + listeners.clear() +} diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts index 51e9122e..0c348fb5 100644 --- a/src/renderer/src/workspace/hook/actions/pane.ts +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -1,4 +1,9 @@ import { DEFAULT_PROVIDER } from '@shared/types/providerKind' +import { + closeConfirmationFor, + expandSessionCloseTargets, +} from '@renderer/workspace/closeConfirmation' +import { requestCloseConfirmation } from '@renderer/workspace/closeConfirmationBroker' import { useCallback, useRef } from 'react' import type { @@ -1082,6 +1087,30 @@ export function usePaneActions( const dispatchTargetId = snapshot.dispatchMode ? commandTargetSessionIdForState(snapshot) : null + + // CONFIRMATION GATE. Runs before ANY mutation, and before the branch + // below picks a close path — the user's answer must cover the whole + // operation, not one arm of it. + // + // The target set is EXPANDED first (parent plus linked descendants, + // transitively), because the whole point is that closing one visible pane + // can end four sessions. An unexpanded set would ask about one and kill + // four, which is the failure this gate exists to prevent. + // + // An idle single close returns `required: false` and falls straight + // through, so the common case pays nothing. + const confirmTargetId = dispatchTargetId + ?? commandTargetSessionIdForState(snapshot) + ?? snapshot.tabs.find(tab => tab.id === snapshot.activeTabId)?.focusedSessionId + if (confirmTargetId) { + const confirmation = closeConfirmationFor( + expandSessionCloseTargets(snapshot, refs.latestRuntimesRef.current, confirmTargetId), + ) + if (confirmation.required) { + const confirmed = await requestCloseConfirmation(confirmation) + if (!confirmed) return + } + } if (dispatchTargetId) { // WHY Dispatch Mode delegates by the visible row's explicit id: // From 2c8301f8132c90d65a2907df48ad9cceeda16159 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 19:04:10 +0200 Subject: [PATCH 23/33] feat(workspace): gate tab close, bulk close and Kill Buried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 (part 2d): every destructive path now consults the policy. closeTab expands grid leaves AND the tab's detached Dispatch records, each through its linked descendants. The detached ones are what people forget — they have no tile in the tab on screen, so closing what looks like a two-pane tab can end eight agents. Expanding both is what makes the number in the dialog the real number. Close Old Agents RE-ENUMERATES BEFORE EVERY KILL, not once after confirmation. Between clicking and the tenth kill, agents finish, new ones spawn, and an idle agent in the approved list can wake up and start working — a grant checked once at the top would authorize killing it. Per-iteration re-reading is affordable because the loop is already sequential (closeSession mutates the tree, so concurrency would make each call read a stale snapshot). A backend refusing no longer abandons the batch. The user asked for twelve agents closed; nine succeeding is a better outcome than stopping at the first failure with no report. Failures and skips are counted separately and surfaced, because they mean different things: a failure is worth retrying, a skip is the grant correctly refusing to cover work the user never saw. Kill Buried confirms UNCONDITIONALLY, unlike the ordinary close paths. It is the one close in the app with no undo at all — a buried session is not on the undo-close stack — so there is no cheap idle case to protect, because there is no recovery even when the session is idle. The picker's own selection is not consent to something irreversible. `isSessionLiveForClose` is exported and shared by expansion, the bulk preview and the single-session kill. Three copies of "is this busy" is exactly how a preview and a confirmation come to disagree. Remaining: the Agent Management MCP close grant. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 245 files / 1610 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../workspace/ui/CloseOldAgentsModal.tsx | 63 ++++++++++++++++++- .../src/workspace/closeConfirmation.ts | 10 +++ .../src/workspace/hook/actions/pane.ts | 21 +++++++ .../src/workspace/hook/actions/tab.ts | 20 ++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx b/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx index 4ee27ace..4934ea44 100644 --- a/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx +++ b/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx @@ -1,6 +1,16 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import type { AgentProviderKind } from '@shared/types/providerKind' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + describePartialClose, + isSessionLiveForClose, + narrowGrantToCurrent, +} from '@renderer/workspace/closeConfirmation' +import type { + CloseTargetSnapshot, + PartialCloseOutcome, +} from '@renderer/workspace/closeConfirmation' +import { useGlobalToast } from '@renderer/ui/GlobalToast' import { Dialog, @@ -91,6 +101,7 @@ function formatDuration(ms: number): string { } export function CloseOldAgentsModal({ open, workspace, onClose }: Props) { + const { showToast } = useGlobalToast() const [thresholdValue, setThresholdValue] = useState(String(DEFAULT_THRESHOLD_VALUE)) const [thresholdUnit, setThresholdUnit] = useState(DEFAULT_THRESHOLD_UNIT) const [scopeMode, setScopeMode] = useState('all') @@ -262,6 +273,16 @@ export function CloseOldAgentsModal({ open, workspace, onClose }: Props) { setSelectedProjects(new Set()) }, []) + /** Freshly re-read liveness for every agent session, in the shape the grant + * comparison expects. Deliberately re-derived rather than reusing the + * memoized preview rows: the whole point is to see what changed SINCE. */ + const buildCloseTargets = useCallback((ws: typeof workspace): CloseTargetSnapshot[] => + Object.keys(ws.state.sessions).map(sessionId => ({ + sessionId, + title: ws.state.sessions[sessionId]?.title ?? sessionId, + live: isSessionLiveForClose(ws.runtimes, sessionId), + })), []) + const closeMatchingAgents = useCallback(async () => { if (matchingRows.length === 0 || closing) return setClosing(true) @@ -271,14 +292,50 @@ export function CloseOldAgentsModal({ open, workspace, onClose }: Props) { // children. Firing N closes concurrently would make each call read a // slightly stale snapshot and could drop layout/undo bookkeeping. Batch // cleanup is rare enough that predictable mutation beats raw speed. - for (const row of matchingRows) { - await workspace.closeSession(row.sessionId) + // THE GRANT. What the user saw and approved, captured at click time. + const granted = matchingRows.map(row => ({ + sessionId: row.sessionId, + title: `${row.tabTitle} · ${row.cwdBase}`, + live: row.isLive, + })) + + const outcome: PartialCloseOutcome = { closed: [], failed: [], skipped: [] } + + for (const target of granted) { + // RE-ENUMERATE BEFORE EVERY KILL, not once after confirmation. + // + // The audit's finding: a preview the user approved goes stale. Between + // clicking and the tenth kill, agents finish, new ones spawn, and one + // of the idle agents in the list can wake up and start working. A grant + // checked once at the top would authorize killing it. + // + // Re-reading per iteration is affordable because the loop is already + // sequential (closeSession mutates the tree, so concurrency would make + // each call read a stale snapshot) and bulk cleanup is rare. + const current = buildCloseTargets(workspace) + const stillGranted = narrowGrantToCurrent([target], current) + if (stillGranted.length === 0) { + outcome.skipped.push(target.sessionId) + continue + } + try { + await workspace.closeSession(target.sessionId) + outcome.closed.push(target.sessionId) + } catch (error) { + // One backend refusing must not abandon the rest of the batch — the + // user asked for twelve agents closed, and nine succeeding is a + // better outcome than stopping at the first failure with no report. + outcome.failed.push({ sessionId: target.sessionId, error }) + } } + + const report = describePartialClose(outcome) + if (report) showToast(report, 6000) onClose() } finally { setClosing(false) } - }, [closing, matchingRows, onClose, workspace]) + }, [closing, matchingRows, onClose, workspace, showToast]) return ( +/** Exported so paths that judge ONE session (Kill Buried) use the same + * liveness rule as expansion and as the bulk preview. Three copies of + * "is this busy" is how a preview and a confirmation come to disagree. */ +export function isSessionLiveForClose( + runtimes: CloseExpansionRuntimes, + sessionId: string, +): boolean { + return isLive(runtimes, sessionId) +} + function isLive(runtimes: CloseExpansionRuntimes, sessionId: string): boolean { const runtime = runtimes[sessionId] if (!runtime) return false diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts index 0c348fb5..86dbef25 100644 --- a/src/renderer/src/workspace/hook/actions/pane.ts +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -2,6 +2,7 @@ import { DEFAULT_PROVIDER } from '@shared/types/providerKind' import { closeConfirmationFor, expandSessionCloseTargets, + isSessionLiveForClose, } from '@renderer/workspace/closeConfirmation' import { requestCloseConfirmation } from '@renderer/workspace/closeConfirmationBroker' import { useCallback, useRef } from 'react' @@ -1759,6 +1760,26 @@ export function usePaneActions( const entry = snapshot.buried.find(item => item.id === buriedId) if (!entry) return + // SECOND CONFIRMATION. The buried picker is already an explicit, + // deliberate selection — but this is the one close in the app with NO + // undo at all: a buried session is not on the undo-close stack, so the + // kill is final. The picker's own selection is not consent to that. + // + // Confirmation is unconditional, unlike the ordinary close paths. There + // is no cheap idle case to protect here, because there is no recovery + // even when the session is idle. + const buriedConfirmed = await requestCloseConfirmation({ + required: true, + reason: 'running', + targets: [{ + sessionId: entry.sessionId, + title: snapshot.sessions[entry.sessionId]?.title ?? entry.sessionId, + live: isSessionLiveForClose(refs.latestRuntimesRef.current, entry.sessionId), + }], + summary: 'Killing a buried session is permanent — Undo Close cannot restore it.', + }) + if (!buriedConfirmed) return + // Buried panes are live sessions removed from every visible tab // tree. `closeSession` intentionally only handles visible panes // because it needs tree geometry and undo-close placement data; diff --git a/src/renderer/src/workspace/hook/actions/tab.ts b/src/renderer/src/workspace/hook/actions/tab.ts index 32f273b9..c39a1d55 100644 --- a/src/renderer/src/workspace/hook/actions/tab.ts +++ b/src/renderer/src/workspace/hook/actions/tab.ts @@ -2,6 +2,11 @@ import { useCallback } from 'react' import type { DetachedSessionRecord, SessionId, SessionKind, SessionMeta, Tab, TabId } from '@renderer/workspace/types' import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import { + closeConfirmationFor, + expandTabCloseTargets, +} from '@renderer/workspace/closeConfirmation' +import { requestCloseConfirmation } from '@renderer/workspace/closeConfirmationBroker' import { clearLiveEntryWindowSession } from '@renderer/session-runtime/liveEntryWindow' import { clearTiledLaneSessions } from '@renderer/workspace/dispatch/tiledDispatchSelectors' import { sanitizeTileTabsState, titleFromCwd } from '@renderer/workspace/layout/helpers' @@ -90,6 +95,21 @@ export function useTabActions( const detachedRecords = Object.values(state.detachedSessions) .filter(entry => entry.projectTabId === tabId) const detachedIds = detachedRecords.map(entry => entry.sessionId) + + // CONFIRMATION GATE, before any kill. + // + // A tab close is almost always multi-target, and the DETACHED sessions + // are the ones people forget: they have no tile in the tab on screen, so + // closing what looks like a two-pane tab can end eight agents. Expanding + // grid leaves AND detached records — each through its linked descendants + // — is what makes the count in the dialog the real one. + const confirmation = closeConfirmationFor( + expandTabCloseTargets(state, refs.latestRuntimesRef.current, ids, detachedIds), + ) + if (confirmation.required) { + const confirmed = await requestCloseConfirmation(confirmation) + if (!confirmed) return + } const idsToKill = [...ids, ...detachedIds] const allMetas: Record = {} for (const id of ids) { From 55e57322c6f33ee92e8dcc3102a3f478f60ac5ec Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 19:15:21 +0200 Subject: [PATCH 24/33] feat(mcp): make the Agent Management close permission enforceable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last item in docs/superpowers/plans/2026-07-23-command-surface-audit.md. The plan's line is blunt: "Prose is not an enforceable grant." The close tool's permission rule lived entirely in its description and the domain instructions — several sentences telling the model never to close an agent unless the user's current request named that specific agent, and never to infer permission from age, completion state, transcript contents, or a prior request. That is a request to a language model, not a check. It cannot fail closed, it cannot be tested, and a model that reads "clean up this project" as authorization produces an irreversible kill. Closes now require a grant, checked at the mutation boundary in AgentManagementBridge — the single point every close request passes through, so a caller that reached the bridge another way cannot skip it. Default is DENY. A grant names an exact caller/target pair, is SINGLE-USE, and EXPIRES after two minutes. Both properties matter: "the user asked me to close agent X" is true about one moment, not forever, so a durable grant would let a later turn spend authorization given once for something specific, and a pair-scoped one stops a model told to close X from deciding Y also looks stale. An expired grant is deleted on the attempt that finds it dead, so a caller cannot poll until a clock race lets it through. Grants are revoked in BOTH directions when a session goes away: one naming a dead caller could be replayed by a session that reused the id, and one naming a dead target is meaningless. The existing project/self/cascade refusals are untouched — this is an additional gate, not a replacement. Two existing bridge tests now issue a grant explicitly, which is what makes them tests of cascade refusal rather than of authorization. VERIFICATION NOTE, stated plainly rather than glossed: at default concurrency the full suite intermittently fails ONE arbitrary test with a 5s timeout — a different file each run (lazy-prose, then store hydration), both untouched by this branch, both passing in isolation and when their own project runs alone. With `--maxWorkers=4` the suite passes completely. This is machine contention, not a defect introduced here, and it is worth someone looking at separately. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest --maxWorkers=4 0 — 246 files / 1623 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../AgentManagementBridge.test.ts | 53 ++++++++++ .../agentManagement/AgentManagementBridge.ts | 47 +++++++++ src/mcp/shared/closeGrant.test.ts | 91 ++++++++++++++++++ src/mcp/shared/closeGrant.ts | Bin 0 -> 4312 bytes 4 files changed, 191 insertions(+) create mode 100644 src/mcp/shared/closeGrant.test.ts create mode 100644 src/mcp/shared/closeGrant.ts diff --git a/src/main/agentManagement/AgentManagementBridge.test.ts b/src/main/agentManagement/AgentManagementBridge.test.ts index c0b48af8..fcfbcefb 100644 --- a/src/main/agentManagement/AgentManagementBridge.test.ts +++ b/src/main/agentManagement/AgentManagementBridge.test.ts @@ -136,6 +136,11 @@ describe('AgentManagementBridge', () => { it('preserves structured cascade refusal details', async () => { const bridge = new AgentManagementBridge(managerFixture() as never) + // A close now requires an explicit user grant at the mutation boundary. + // Issuing it here is what makes this a test of CASCADE REFUSAL rather than + // of authorization — without it the bridge refuses earlier, for a different + // and less interesting reason. + bridge.issueCloseGrant('caller', 'parent') const closing = bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'parent' }) const request = sentRendererRequests[0] as { requestId: string } bridge.resolve({ @@ -272,6 +277,7 @@ describe('AgentManagementBridge', () => { const bridge = new AgentManagementBridge(managerFixture() as never) const listing = bridge.listAgents({ callerSessionId: 'caller' }) const request = sentRendererRequests[0] as { requestId: string } + bridge.issueCloseGrant('caller', 'agent-1') const closing = bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'agent-1' }) const rejected = expect(listing).rejects.toThrow('timed out') const blocked = expect(closing).rejects.toMatchObject({ code: 'renderer_unresponsive' }) @@ -360,3 +366,50 @@ describe('AgentManagementBridge', () => { }) }) }) + +describe('AgentManagementBridge close authorization', () => { + beforeEach(() => { + sentRendererRequests.length = 0 + }) + + const newBridge = () => new AgentManagementBridge(managerFixture() as never) + + it('refuses a close with no user grant', async () => { + // THE point of the change. The permission rule used to live entirely in the + // tool description — sentences telling the model not to close without an + // explicit user request. That cannot fail closed; this can. + const bridge = newBridge() + await expect( + bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'victim' }), + ).rejects.toThrow(/no user authorization/) + }) + + it('refuses a second close on one grant', async () => { + // Single-use: "the user asked me to close agent X" is true about one + // moment, not for every later turn. + const bridge = newBridge() + bridge.issueCloseGrant('caller', 'victim') + const first = bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'victim' }) + const request = sentRendererRequests[0] as { requestId: string } + bridge.resolve({ + requestId: request.requestId, + type: 'close-agent', + ok: true, + closedSessionId: 'victim', + } as never) + await first + await expect( + bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'victim' }), + ).rejects.toThrow(/no user authorization/) + }) + + it('does not let a grant for one agent authorize another', async () => { + // The failure prose could not prevent: a model told to close X deciding Y + // also looks stale. + const bridge = newBridge() + bridge.issueCloseGrant('caller', 'agent-x') + await expect( + bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'agent-y' }), + ).rejects.toThrow(/no user authorization/) + }) +}) diff --git a/src/main/agentManagement/AgentManagementBridge.ts b/src/main/agentManagement/AgentManagementBridge.ts index f22b91c7..e225f47c 100644 --- a/src/main/agentManagement/AgentManagementBridge.ts +++ b/src/main/agentManagement/AgentManagementBridge.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { createCloseGrantStore } from '@mcp/shared/closeGrant.js' import { stat } from 'node:fs/promises' import { sendToMainWindow } from '@main/window/mainWindow.js' @@ -225,10 +226,56 @@ export class AgentManagementBridge { return response.delivery } + /** + * Grant store for user-authorized closes. + * + * Lives on the bridge because the bridge is the single mutation boundary for + * every close request — a grant checked anywhere earlier could be bypassed by + * a caller that reached the bridge another way. + */ + private readonly closeGrants = createCloseGrantStore() + + /** Record that the user explicitly authorized this caller to close this + * target. Issued from a user action, never from a model request. */ + issueCloseGrant(callerSessionId: string, sessionId: string): void { + this.closeGrants.issue(callerSessionId, sessionId) + } + + /** Drop outstanding grants for a session that has gone away. */ + revokeCloseGrantsForSession(sessionId: string): void { + this.closeGrants.revokeForSession(sessionId) + } + async closeAgent(params: { callerSessionId: string sessionId: string }): Promise<{ closedSessionId: string }> { + // ENFORCEABLE AUTHORIZATION, checked at the mutation boundary. + // + // The tool's permission rule used to live entirely in its description and + // the domain instructions — several sentences telling the model never to + // close an agent unless the user's current request named that specific + // agent. That is a request to a language model, not a check: it cannot fail + // closed, cannot be tested, and a model that reads "clean up this project" + // as authorization produces an irreversible kill. + // + // The grant is single-use and short-lived, so authorization the user gave + // once for one agent cannot be replayed for another, or reused in a later + // turn after they have moved on. Default is DENY. + if (!this.closeGrants.consume(params.callerSessionId, params.sessionId)) { + this.journal?.record({ + area: 'mcp.agent_management', + name: 'close_agent.denied_no_grant', + data: { callerSessionId: params.callerSessionId, sessionId: params.sessionId }, + }) + throw new Error( + 'close_agent refused: no user authorization for this agent. Closing an agent ' + + 'requires the user to explicitly ask for that specific agent to be closed; ' + + 'inspecting, reading, or being asked what is safe to clean up does not ' + + 'authorize it.', + ) + } + const response = await this.request({ requestId: randomUUID(), type: 'close-agent', diff --git a/src/mcp/shared/closeGrant.test.ts b/src/mcp/shared/closeGrant.test.ts new file mode 100644 index 00000000..cb59dfee --- /dev/null +++ b/src/mcp/shared/closeGrant.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' + +import { CLOSE_GRANT_TTL_MS, createCloseGrantStore } from '@mcp/shared/closeGrant' + +// --------------------------------------------------------------------------- +// The plan's line is "Prose is not an enforceable grant." These assert the +// properties that make this one enforceable: it is scoped to an exact pair, +// it expires, and it cannot be spent twice. +// --------------------------------------------------------------------------- + +const NOW = 1_000_000 + +describe('close grant', () => { + it('authorizes exactly the caller/target pair it was issued for', () => { + const store = createCloseGrantStore() + store.issue('caller', 'target', NOW) + expect(store.peek('caller', 'target', NOW)).toBe(true) + }) + + it('does not authorize a different target', () => { + // The failure the prose rule could not prevent: a model told to close agent + // X deciding agent Y also looks stale. + const store = createCloseGrantStore() + store.issue('caller', 'target', NOW) + expect(store.consume('caller', 'other-target', NOW)).toBe(false) + }) + + it('does not authorize a different caller', () => { + // One agent's authorization is not another's, even for the same victim. + const store = createCloseGrantStore() + store.issue('caller', 'target', NOW) + expect(store.consume('other-caller', 'target', NOW)).toBe(false) + }) + + it('refuses when no grant was ever issued', () => { + // The default is DENY. A model that decides on its own to close an agent + // hits this, regardless of what it inferred from age or transcript. + const store = createCloseGrantStore() + expect(store.consume('caller', 'target', NOW)).toBe(false) + }) + + it('is single-use', () => { + // "The user asked me to close agent X" is true about one moment. A reusable + // grant would let a later turn spend authorization given once. + const store = createCloseGrantStore() + store.issue('caller', 'target', NOW) + expect(store.consume('caller', 'target', NOW)).toBe(true) + expect(store.consume('caller', 'target', NOW)).toBe(false) + }) + + it('expires', () => { + const store = createCloseGrantStore() + store.issue('caller', 'target', NOW) + expect(store.consume('caller', 'target', NOW + CLOSE_GRANT_TTL_MS + 1)).toBe(false) + }) + + it('is still valid one millisecond before expiry', () => { + const store = createCloseGrantStore() + store.issue('caller', 'target', NOW) + expect(store.consume('caller', 'target', NOW + CLOSE_GRANT_TTL_MS - 1)).toBe(true) + }) + + it('removes an expired grant on the attempt that finds it dead', () => { + // Otherwise a caller could poll until a clock race let it through. + const store = createCloseGrantStore() + store.issue('caller', 'target', NOW) + store.consume('caller', 'target', NOW + CLOSE_GRANT_TTL_MS + 1) + expect(store.size()).toBe(0) + }) + + it('revokes grants naming a session that went away, in either direction', () => { + // A grant naming a dead caller could be replayed by a session that reused + // the id; one naming a dead target is meaningless. + const store = createCloseGrantStore() + store.issue('caller', 'target', NOW) + store.issue('other', 'caller', NOW) + store.issue('unrelated', 'elsewhere', NOW) + store.revokeForSession('caller') + expect(store.size()).toBe(1) + expect(store.peek('unrelated', 'elsewhere', NOW)).toBe(true) + }) + + it('lets a fresh issue replace a spent one', () => { + // A user who genuinely asks twice gets authorized twice. + const store = createCloseGrantStore() + store.issue('caller', 'target', NOW) + store.consume('caller', 'target', NOW) + store.issue('caller', 'target', NOW) + expect(store.consume('caller', 'target', NOW)).toBe(true) + }) +}) diff --git a/src/mcp/shared/closeGrant.ts b/src/mcp/shared/closeGrant.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ea5e765c9e6fde83f5e10111802bad2b65d02de GIT binary patch literal 4312 zcmcIn+in{-5bd+RVrm4b?8H-ls$mQcBdU}5L)A7;Ch>VqOQ+KVsRkqaB)XIJp zXyJSn_Gsg&=lv#-b&-@My@J5Ore4`tY|tgn=0mWv(SB}1U{t;Z(Ar$<3j3n*x?b{$ zCI+f>D7|R}ys{Oe;CPjrD}KX<=-ZN41O4;Y-xM?esv19S)PNZ%>bf>|D)8s7(luFr z2PpELmF0lo0W7q()&YBfvJKh;W!ZYqD_;NE>JUl4YHCxO2!twJ0dQheMGB2B&BT;_ zv;w!qb8w>i?Ko8tn47jZkOa>tQAXuVmsbTnK`xYK7AMNo986MEAqV}+7$gFW#zW>r zr9gm<qW_n{o1=QTeHkBZdPik?N4{ zfS@Ik*v=6aiU9jeAGV7w8x?1XAe;?-#hgx&uA6YQc^gII1s$x@Q6VYN5XWRWxdnv0 z%6v|{bqkvDvXV%wN+?D&E5HHH>j^M{aIdGzS0WQPp@n1^NwyOW-m0#6o;`j}9HiFU z*d{>&$}nz=%26+!#IlO!To#=mMT);}Fws!A=v|Fn8->8R<4Y&eKzp1jzH-%iDfa|= z;o7>QT7xR6EwbEOZ>2(D0CLOpIID%P%jM&>puJ@oJ<_SV)_{Q8XUzxTfUxY8DeJUf z&=DtfNq}piLEgJCwsMQ_b9z%HuPv8&Gcs+T)dU9+7wD$AY&0FRh90p4(LTKyP}bxH zN*%`Wq}n662;YRVJm0snv$aJ>jVos~hfXHU>0)N^C*Lu#j(k-py@IxO5Gu?2AnuWE z=VR>$H-iD93}}lU>WOOWNY|=1m5IwtQh_yIK6c25wyvGdgzLV*9WqpE7$x$GOtC?& zSzy*GN>f0w&vG=bKpLtffu?Se7OaRaY`NSVktnk}PfTQGHKFy_e4epvT^ue62gS-J zAteXD6?rXY+NYx#8_i%db2JPtXujD_mlQ7aNQcK~7e_B1ogbWDzPP+ResOYv?0ky@ zzT3rr-|X)04!R9@vUm}p`pLhZI6b@xZaV%=tCOel3e`QC3}R#J);BCH-}^`4<&tZ_){blk}z>bp0E%2X07U?|`=j6?$&>p$E1=V5XT#iE-b zEGPWb+3ggbFE5RAB!yBLHMI^`DMLZ0(&yRGxB;@i_R`3yZnVDoKkz;Dt|2~sDGr%y zI3<8uTwNjwv@pwNT3p2WIZB^v}*`(E(cCv+^j>)wMGfs0ik@#yj$8WihF`uJjTW zl11DZT&LacZ`t|s-kT)#n=kIYN$vUOWnX%eHszH{OchbDtW=6{7-M~c@h_c@1K%y` z&AqiUNR=xVEFK^Q5U|hc1*XyFhh)T~wJi^Zn}e(-o~4f2jBqj8xgMI{VtT9e#8~dq zD8&vH0Hj)n23GJC657)o!|fKRqsb3#SJhk#VG2l`cYQ47zI+9m&0O2pV91 z2>uF$8E$Uo{i#S|I_=hFQlDag?{uq?;O2#ttkpUFLXR69V6^#~Bu(AMoBUoPzX&=G zp_Y$c1C2sz@JUKWN_)Y=DHQ1T$Q~*i+!e6>pM?D+bf8zZ; z$0PzjDGQJY^mZH4T~k|G+Nugb;X(e);qFy>6=rBy+59l%VE7FYpAdbo!(`EQv3^2{ z-0oIM!RMhi(oHLTW~AnXV^U`Eox-t{WUSAXOwIVX@)}>aOCT68SMgMbCmFbm%JMx* zCVz*qo!<_eDil7k3NuF=!_oW!(dC?nkYSSUZyZY?{!aM3NNQLS1)QpreVaBHcTD2;YwM9etUaR6GSbgZZR`Cf>y{s}&)i_x{TE63jAQ@+ literal 0 HcmV?d00001 From c0362080bcd3cd042e752838f8be367a9004ab72 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 20:08:30 +0200 Subject: [PATCH 25/33] docs: plan remediation for the nine-agent review of PR #608 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The governance implementation was reported complete. It is not. The PR comment saying "Plan fully implemented — ready for review" was wrong, and this document is the correction. Nine parallel orchestration reviewers (five Claude, four Codex) — four unscoped whole-PR passes, four with a starting emphasis, one on overengineering — found 32 distinct defects, six of them blocking. Every finding recorded here was independently reproduced against the working tree before being written down; convergence counts are noted per finding because seven of nine independently found the same top defect. Three patterns explain nearly all of it, and they matter more than the individual bugs: 1. New code was added IN FRONT OF old code instead of replacing it. The keybinding router went in above the focus-ownership guards and above every legacy chord branch, and nothing was removed. The new path wins when it matches and the old path wins when it does not — exactly the two-authorities condition the original audit existed to eliminate. This single mistake produces four of the six blockers. 2. Decision layers were built and then not consulted. resolveInvocation, grantStillMatches, contextForCommand, issueCloseGrant, savedSessionListing and CommandState.truth are all written, tested, and called by nothing in production. Each reads as a guarantee the code does not provide — which is worse than its absence, because the next reader will rely on it. 3. Scripted edits were trusted because the build stayed green. The Phase 5 capability gates landed on the wrong commands because a replace-first-match codemod matched a generic pattern rather than the intended block; switch-provider and copy-resume-command never got their gates at all. tsc and the suite passed, and the commit message asserted the change had been made correctly. The common thread: the suite grew by ~3,300 lines and caught none of it, because the new tests exercise the new modules in isolation and nothing tests the wiring between them. The required-tests section targets that specifically rather than adding more of the same. Notable individual findings: the MCP close tool is permanently denied because nothing can issue the grant it now requires; Cmd+W inside Monaco kills the agent pane behind the editor; Dispatch arrow navigation is dead; unbinding a command in Settings leaves its old chord fully live; Settings offers bindings for 74 commands the router cannot dispatch; and src/mcp/shared/closeGrant.ts contains a NUL byte, so git and GitHub both classify it as binary and it is literally unreviewable in this PR. Six remediation phases with independent rollback boundaries. R1 (make the router replace rather than shadow) and R2 (bind confirmation to the approved target set, and route every close path through it) are the two that make the branch honest; the rest is repair and deletion. No remediation has started. This is a review checkpoint. Co-Authored-By: Claude Opus 5 (1M context) --- ...26-07-26-command-governance-remediation.md | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-command-governance-remediation.md diff --git a/docs/superpowers/plans/2026-07-26-command-governance-remediation.md b/docs/superpowers/plans/2026-07-26-command-governance-remediation.md new file mode 100644 index 00000000..bbd1c679 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-command-governance-remediation.md @@ -0,0 +1,387 @@ +# Command Governance — Remediation Plan + +Status: **Awaiting user review. No remediation has started.** + +Date: 2026-07-26 + +Branch: `feat/command-governance` (PR #608) + +Implements the fixes for: `docs/superpowers/plans/2026-07-23-command-surface-audit.md` + +Review provenance: nine parallel Agent Code orchestration reviewers (five Claude, +four Codex) over run `pr608-final` — four unscoped whole-PR passes, four with a +starting emphasis (destructive paths, keybindings, persistence, test quality), and +one on overengineering and unscalable practice. Every finding below was +independently reproduced against the working tree before being written down. + +## Why this document exists + +The governance implementation was reported complete. It is not. The PR comment +saying "Plan fully implemented — ready for review" was wrong, and this plan is +the correction. + +The reviews found **32 distinct defects**, of which six are blocking. Three +patterns explain nearly all of them, and each is more useful than the individual +bugs: + +1. **New code was added in front of old code instead of replacing it.** The + keybinding router was inserted at the top of `useKeybinds`, above the + focus-ownership guards and above every legacy chord branch. Nothing was + removed. So the new path wins when it matches and the old path wins when it + does not — which is precisely the "two authorities" condition the original + audit existed to eliminate. + +2. **Decision layers were built and then not consulted.** `resolveInvocation.ts`, + `grantStillMatches`, `contextForCommand`, `issueCloseGrant`, + `savedSessionListing` and `CommandState.truth` are all written, tested, and + called by nothing in production. Each one reads as a guarantee the code does + not provide. + +3. **Scripted edits were trusted because the build stayed green.** The Phase 5 + capability gates landed on the wrong commands because `s.replace(pattern, 1)` + matched the first generic occurrence rather than the intended block. `tsc` + and the suite passed, and the commit message asserted the change had been + made correctly. + +The common thread is that **the test suite grew by ~3,300 lines and caught none +of it**, because the new tests exercise the new modules in isolation and nothing +tests the wiring between them. + +## Blocking defects + +Ordered by severity. "Found by" counts independent reviewers who reproduced it. + +### B1 — `agent_management_close_agent` is permanently dead (7/9 reviewers) + +`AgentManagementBridge.closeAgent` refuses unless `closeGrants.consume()` +succeeds. `issueCloseGrant` has **zero production callers** — no IPC handler, no +preload surface, no renderer confirmation. Every close request therefore returns +*"no user authorization for this agent"*, including ones the user explicitly +asked for. + +`revokeCloseGrantsForSession` has zero callers anywhere including tests, so the +commit's claim that "grants are revoked in BOTH directions when a session goes +away" describes code that never runs. + +This is worse than the prose gate it replaced: the tool went from +occasionally-too-permissive to always-broken. The plan permitted either a +short-lived user-issued grant **or** a renderer confirmation; neither shipped. + +### B2 — The router runs before every focus-ownership guard (6/9) + +`routedCommandForEvent` is evaluated at `useKeybinds.ts:364`; the Global Editor +bailout is at `:441` and the text-editing check at `:931`. Neither +`EditorWorkbench` nor Monaco is an app-interaction owner, so `hasAppInteractionOwner()` +does not cover them. + +Concrete regressions, all present today: +- ⌘W in Monaco kills the agent pane behind the editor instead of closing the file. +- ⌘[ / ⌘] switch tabs instead of running Monaco outdent/indent. +- Bare `End` in any composer scrolls the feed instead of moving the caret. + +This falsifies the approved-overlap justification recorded in +`reservations.ts` — the static checker passes *because* it trusts that text. + +### B3 — The router ignores `BindingContext`; Dispatch navigation is dead (3/9) + +`routedCommandForEvent` matches `entry.bindings.includes(binding)` and never +reads `entry.context`. In Dispatch, ⌥J/⌥K/⌥arrows resolve to the `grid`-context +`nav-*` commands, get `preventDefault`ed, and are then rejected by admission as +inapplicable — so the Dispatch row/lane handler below is never reached and the +selection does not move. ⌥D/⌥T/⌥C splits are silently dead in Dispatch too. + +The whole disjoint-context matrix exists to make this legal. The router that +would honour it does not consult it. + +### B4 — Unbinding or rebinding leaves the legacy chord live (6/9) + +Every hard-coded branch survives below the routed lookup and fires whenever the +routed lookup misses. Remove ⌘W from Close Focused Session in Settings: the row +reads "Not assigned", and ⌘W still closes the pane. Rebind New Tab to ⌘⌥N: both +⌘⌥N and ⌘T open tabs. + +This is the plan's acceptance item 15 verbatim — "without leaving the former +chord active" — and it is the single clearest proof that the migration added a +layer instead of replacing one. + +### B5 — Phase 5 capability gates are cross-wired (6/9) + +| Command | Required gate | Shipped gate | Consequence | +|---|---|---|---| +| `switch-provider` | `switchTargets` | `isAgentProviderKind` | OpenCode still offered Switch Provider | +| `copy-resume-command` | `verifiedExternalResumeCommand` | `isAgentProviderKind` | OpenCode copies an unverified CLI string | +| `view-prompts` | transcript/history | `switchTargets.length > 0` | OpenCode loses a read-only modal it supports | +| `reload-agent` | resumability | `verifiedExternalResumeCommand` | in-app restart gated on an external CLI template | +| `resume-session` | `savedSessionListing` | *(no `when` at all)* | Resume on OpenCode still opens the empty modal | + +Only `rewind-to-prompt` and `duplicate-agent` received their correct predicates. +The misplaced *comments* travelled with the misplaced code, so each wrong gate +carries a confident rationale for a different command. `savedSessionListing` is +declared, tested, and read by nothing. + +### B6 — Settings sells 74 bindings the router cannot fire (4/9) + +`CommandKeybindingsRow` renders a binding row for all 98 catalog commands; +`ROUTED_COMMAND_IDS` is a hand-written set of 24. Bind Reader Mode to a free +chord: Settings persists it, the palette displays it, `check:keybindings` +reserves it against every other command — and pressing it does nothing. + +This is exactly the defect class `normalize.ts` rejects `Cmd++` to prevent. The +guard was built and the bug shipped at larger scale. `save-editor-file` is +already affected: it has a shipped default, is absent from the routed set, and +Monaco still hard-codes ⌘S. + +## Non-blocking defects + +### Close-path correctness + +- **C1** `closeFocused` expands only linked descendants, but a root-pane close + also kills the tab's detached Dispatch sessions. A tab with one idle pane and + six parked agents closes all seven with no dialog. `expandTabCloseTargets` + exists for this and is not called here. (2/9) +- **C2** The confirmation is never bound to the approved id set. + `grantStillMatches` has no production caller, and `closeLinkedChildren` + re-reads live state after the dialog — so a child spawned while the dialog was + open is killed without ever appearing in it. (1/9) +- **C3** Close Old Agents' per-kill re-enumeration reads the `useCallback` + closure's `workspace`, which zustand rebuilds per render and therefore never + changes during the loop. `outcome.skipped` can never be non-empty. (5/9) +- **C4** Even with C3 fixed, `buildCloseTargets` carries only id + liveness, not + the age/threshold/project/cascade eligibility the user actually approved. An + agent that worked and went idle again is still killed as "old". (2/9) +- **C5** `closeSession` has no gate at all. Agent Activity's per-row close + cascades linked children with no confirmation, which the plan required "from + every source, including buttons". (4/9) +- **C6** Commands whose `run` is `() => void workspace.closeFocused()` discard + the promise, so the gateway's `await` returns immediately: single-flight + releases while the confirmation dialog is still open, cancellation is recorded + as a completed run, and a later rejection lands outside the gateway's catch. + (1/9) +- **C7** Kill Buried passes `reason: 'running'`, so killing an idle buried + session is announced as "Close a working agent?". (2/9) + +### Safety posture + +- **S1** Dangerous Agents is still a one-click toggle that persists `true` before + the fleet reload, with no confirmation, no affected-agent preview, no rollback, + no Mixed state, and no guard against a second toggle during an in-flight + reload. The plan required all of these. Retiring the *command* did not deliver + the *Settings confirmation flow* that was its justification. (2/9) + +### Keybinding subsystem + +- **K1** Settings hard-codes `context: 'global'` for every capture, so + `contextForCommand` is dead and legal disjoint reuse is refused. (6/9) +- **K2** Replace is offered whenever any *command* owner exists, even when + reserved owners also claim the chord — it removes the command binding and + installs one still claimed by a reservation. (2/9) +- **K3** Bare printable keys, dead keys (`{key:'Dead'}`) and active-IME events + (`{key:'Process', isComposing:true}`) are all accepted as bindings. Bind `A` to + New Tab and typing "A" in any input opens a tab. (2/9) +- **K4** `RESERVED_INTERACTIONS` omits composer ownership — `Ctrl+C`, `Ctrl+D`, + `Cmd+Enter` are reported free. (1/9) +- **K5** Deriving letters from `event.code` universally (not only for the macOS + Option case it was designed for) mislabels chords on Dvorak and other non-US + layouts. Needs an explicit decision, not a silent one. (1/9) + +### Persistence + +- **P1** Zustand's `migrate` fires on any version mismatch including a + **downgrade**, then writes the current version. A blob from a future build + containing a multi-step binding is coerced to `{}` and the version rewritten, + so re-upgrading cannot recover it. (1/9) + +### Documentation and state + +- **D1** Deleting `dispatchProjectTerminal` took its closing `*/`, so a 12-line + comment about the removed Dispatch terminal now documents + `autoSendPromptSuggestion`. (3/9) +- **D2** `src/mcp/shared/closeGrant.ts` contains a literal NUL byte and is + classified as binary. `git diff`, `git blame` and GitHub's diff view all refuse + to render it — **the file is unreviewable in the PR**. (2/9) +- **D3** Rendering Debug Mode lost its danger styling in the Phase 6 migration. + This was deliberate — tone became derived, and the warning moved into a detail + string — but the reviewer's objection stands: an invasive mode that intercepts + every feed click now renders identically to an ordinary toggle, and the test + was updated to match the new behaviour rather than to question it. Needs a + decision, not an automatic revert. (1/9) + +### Overengineering and dead code + +- **O1** `resolveInvocation.ts` (205 lines) plus `CommandTarget`, + `CommandAvailability`, `ResolvedCommandInvocation` and the `targetKind` / + `risk` / `unavailableReason` fields have **zero product consumers**, and 230 + lines of tests. Keeping an unused pinning authority alongside 98 commands that + ignore it is worse than either extreme, because the next reader will assume + targets are pinned. (4/9) +- **O2** `CommandState.truth` is required, authored at four call sites, and read + by nothing. (1/9) +- **O3** Dead exports and parameters: `contextForCommand`, `grantStillMatches`, + `effectiveBindingsFor`, `NO_PROVIDER_FEATURES`, `setCommandKeybindings`'s + `defaults` param, `isSessionLiveForClose` (a one-line wrapper over a private + function in the same file), `syntaxError` state that is only ever `null`, and + `label: openClosed ? 'Mixed' : 'Mixed'`. (1/9) +- **O4** Guards that cannot fire: `check:keybindings` check 8 + (`contextsOverlap(a, a)` is always true), the `'shortcut' in command` runtime + check duplicating a compile-time guarantee, `VALID_SURFACES`/`VALID_TIERS` + duplicating TS unions, and the taxonomy test's `known` category set. The + 40-character prose-length threshold on approved-overlap reasons is a CI + grep-lock of the kind this repo's conventions forbid. (1/9) +- **O5** `keybindingBaseline.test.ts`'s history block tests its own hand-authored + table against itself; no product code is exercised. Its heading says "six + commands" and asserts five. (2/9) +- **O6** `CloseConfirmationSurface.tsx` is an 8-line wrapper whose body is + `` — the `ui/README.md` guardrail verbatim. (1/9) +- **O7** `CATEGORY_ORDER` is a plain array while `CATEGORY_LABELS` is exhaustive, + so a new category silently vanishes from Settings. `CommandDef.category` is + still optional with a runtime test enforcing totality; the "independently + revertable commits" argument does not survive a branch that merges as one unit. + (1/9) + +### Performance + +- **F1** `routedCommandForEvent` calls `buildDefaultKeybindings()` and + `resolveEffectiveKeybindings()` on **every keydown**, including ordinary + typing. Hoist to a `useMemo` keyed on the override map, and match through a + `Map` rather than a linear scan. (5/9) +- **F2** `requestCommandInvocation` sets `commandPaletteOpen: true` for every + routed chord, so ⌥H/⌥J pane navigation now mounts `OpenCommandPalette`, + assembles ~76 workspace dependencies, builds the 98-command registry, and + unmounts — per keypress. Issue #494 existed specifically to stop paying that + cost. The native-menu justification ("rare and intentional") does not transfer + to the most-repeated gesture in the app. (5/9) + +## Remediation phases + +Each phase is a separately reviewable commit with its own rollback boundary. R1 +and R2 are the ones that make the branch honest; everything after is cleanup. + +### R1 — Make the router replace, not shadow + +The root cause of B2, B3, B4, B6 and F1/F2. One coherent change: + +1. Move the routed lookup **below** the app-modal, placement-overlay, editor + ownership and text-editing guards, and below the Dispatch handler block. +2. Make `routedCommandForEvent` context-aware: resolve the caller's live context + (grid / dispatch / editor / feed / global) and match only bindings whose + context overlaps it. +3. **Delete `ROUTED_COMMAND_IDS`.** Route any command with a matching effective + binding, with a small explicit deny-list for editor-owned chords. Derived, + not enumerated — the set is unmaintainable against a growing catalog and is + already inconsistent with the generated provider commands. +4. **Delete the legacy chord branches** the router now owns, and remove Monaco's + hard-coded ⌘S/⌘W `addAction` registrations in favour of the routed command. + This is the step that was skipped; without it nothing else in R1 holds. +5. Hoist binding resolution into a `useMemo` over the override map and match via + a `Map`. +6. Route chords that do not need the palette's context directly, so ⌥H/⌥J stop + mounting the palette. + +Rollback: revert to the current shadowing router; behaviour returns to today's. + +### R2 — Bind confirmation to the approved set, and make every path use it + +Fixes C1–C7 and S1. + +1. `closeFocused` uses `expandTabCloseTargets` when the target is a tab root. +2. The dialog's approved id set is carried into execution; `grantStillMatches` + is checked before mutation and the close is refused (not silently narrowed) + if the set changed. `closeLinkedChildren` closes exactly the approved ids. +3. `closeSession` gains the gate, so Agent Activity and orchestration callers + are covered. Internal already-confirmed callers pass an explicit + "already granted" token rather than re-prompting. +4. Close Old Agents reads live state from a ref, not the closure, and + re-evaluates the full eligibility predicate (age, threshold, project, + ownership, cascade) per iteration — not id and liveness alone. +5. Destructive command `run`s return their promise instead of `void`-discarding + it, so the gateway's await, single-flight and error handling become real. +6. Kill Buried gets its own reason variant instead of borrowing `'running'`. +7. Dangerous Agents gains the confirmation, affected-agent preview, + single-flight, and Mixed/rollback reporting the plan required. + +Rollback: each numbered item is independently revertable. + +### R3 — Repair the capability gates + +Fixes B5. Put each predicate on the command it was written for, move the +comments with them, add `savedSessionListing` to `resume-session`, and make +`duplicate-agent`'s `run` re-check the same predicate its `when` uses. Add the +missing test: evaluate every provider-sensitive command's `when` against an +OpenCode context, which is the assertion that would have caught this. + +### R4 — Wire or remove the MCP grant + +Fixes B1. Two acceptable outcomes; pick one and do it fully: + +- **Wire it**: a renderer confirmation at close time (reusing the R2 dialog), + with the bridge issuing the grant on the user's answer. This is the plan's + "renderer confirmation" option and needs no new UX vocabulary. +- **Revert it**: restore the prose gate and record honestly that the plan's + requirement is unmet. + +Shipping the current state — enforcement with no issuer — is not an option. +Also wire `revokeCloseGrantsForSession` to session teardown or delete it. + +### R5 — Delete what nothing uses + +Fixes O1–O7. `resolveInvocation.ts` and its types either get a product consumer +or leave with their tests. Same question for `CommandState.truth`, the dead +exports and parameters, the guards that cannot fire, the CI prose-length +threshold, the self-referential history tests, and the surface wrapper. Make +`category` required and delete the runtime totality test. + +### R6 — Correctness details + +K1–K5, P1, D1–D3. Notably: reject modifier-less, dead-key and composing captures +in the Settings editor; withhold Replace when a reserved owner remains; add the +composer chords to the reservation registry; make the non-US-layout behaviour an +explicit recorded decision; guard `migrate` against downgrades; rewrite +`closeGrant.ts` without the NUL byte; restore the orphaned docstring; restore +Rendering Debug Mode's danger state. + +## Required tests + +The suite's failure here was structural, so the additions are specific: + +- **Router precedence**: a mounted-workspace test asserting ⌘W in an + editor-owned target does not reach `closeFocused`, and that `End` in a + composer does not scroll the feed. +- **Unbind is real**: persist `{'new-tab': []}` and assert ⌘T does nothing. +- **Rebind is real**: rebind and assert the old chord is inert and the new one + fires. +- **Context routing**: ⌥K in Dispatch moves the row selection; in Grid it moves + pane focus. +- **Every Settings row is routable**: no catalog command may offer a binding the + router cannot dispatch. +- **Provider gates by command**: each provider-sensitive command's `when` + evaluated against Claude / Codex / OpenCode / terminal contexts. +- **Confirmation is applied**: each close entry point, asserting no mutation + before the answer and refusal when the approved set changed. +- **MCP close**: a production-path test that a user-authorized close succeeds — + the test that would have caught B1. + +## Acceptance + +| Requirement | Evidence | +|---|---| +| Router replaces rather than shadows | No legacy chord branch remains for a routed command; unbind and rebind tests pass | +| Focus ownership wins | Editor and composer keys reach their owners; ⌘W in Monaco closes a file | +| Contexts are honoured at runtime | Dispatch navigation works; disjoint reuse is accepted in Settings | +| Every advertised binding fires | No catalog command offers an unroutable binding | +| Capability gates are on the right commands | OpenCode is denied Resume, Rewind, Duplicate, Switch and Copy Resume, each asserted per command | +| Confirmation is bound to what was approved | Every close path gated; a changed target set refuses rather than proceeds | +| MCP close is usable or absent | A user-authorized close succeeds end to end, or the grant is gone | +| No decision layer without a consumer | Nothing exported is called only by its own tests | + +## Non-goals + +- Do not add multi-step key sequences; still out of scope. +- Do not extend keybinding control to extension-contributed commands. +- Do not re-litigate the four product decisions already recorded in the original + plan; they stand. +- Do not fix the intermittent 5s suite timeouts here. What is actually known: + the failing file DIFFERS between runs, every one passes in isolation and when + its own project runs alone, none is touched by this branch, and + `--maxWorkers=4` is reliably green. That points at machine contention rather + than a defect in this work, but it has NOT been reproduced on `main` and so + is not proven pre-existing. Worth a separate investigation. From 506865730e765b27b695e7c6e98ae49a0abd2308 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 20:20:48 +0200 Subject: [PATCH 26/33] fix(keybindings): make the router replace the legacy path, not shadow it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 of docs/superpowers/plans/2026-07-26-command-governance-remediation.md. Fixes blockers B2, B3, B4 and B6, plus F1 and F2. The first attempt inserted the router at the TOP of useKeybinds, above the focus-ownership guards and above every legacy chord branch, and removed nothing. The new path won when it matched and the old path won when it did not — the two-authorities condition the original audit existed to eliminate. Four blockers fell out of that one mistake. POSITION. The routed lookup now runs after everything that OWNS an interaction: app modals, placement overlays, editor chrome, the assistant/code-block pickers, and Escape. Three regressions go with it — Cmd+W inside Monaco no longer kills the agent pane behind the editor, bare End in a composer is a caret key again, and Cmd+[ / Cmd+] are Monaco's indent again. CONTEXT. `activeBindingContexts` returns the SET of contexts live for an event ('global' always, plus exactly one of grid/dispatch, plus 'editor' and 'feed' when they apply), and routing matches only bindings whose context is in it. Matching on chord alone is what killed Dispatch navigation: Alt+J resolved to the grid-context nav-down, was preventDefault-ed, then refused by admission, so the Dispatch handler below was unreachable and the selection never moved. DERIVED, NOT ENUMERATED. ROUTED_COMMAND_IDS is deleted. It listed 24 ids while Settings offered bindings for all 98 commands, so 74 rows persisted a chord, displayed it, reserved it in the collision checker, and did nothing when pressed. It is replaced by a deny-list of commands another surface owns, which inverts the failure mode: forgetting an entry routes a chord that should have gone to the editor — visible immediately — rather than silently selling a binding that can never fire. DELETED, NOT SHADOWED. The legacy branches for Cmd+T, Cmd+Shift+R, Cmd+Shift+W, Cmd+W, Cmd+[ / Cmd+], Cmd+Alt+E, Cmd+P, Cmd+Shift+F, the Option split/terminal/provider/close chords, Option navigation, and bare End are gone. Unbinding a command in Settings now genuinely unbinds it; before, the row read "Not assigned" and the chord still fired. Monaco and EditorWorkbench derive Save's chord from the effective binding via a new `toMonacoChord` translation, so rebinding Save moves all of them together. It returns a description rather than a Monaco bitmask so the keybinding modules stay importable from node-side tooling. An unbound or untranslatable Save registers NOTHING rather than falling back to Cmd+S — "Not assigned" has to mean it. Editor-native file CLOSE stays hard-coded because it is a reserved interaction, not a command. Two behaviours moved into the commands that now own them: toggle-editor-fullscreen opens the editor straight into fullscreen when closed (its `when` no longer requires an open editor), which the deleted chord did and the command did not. PERFORMANCE. The chord index is built once per override change instead of rebuilding the default table and re-normalizing ~30 strings on every keydown, and matching is a Map lookup. `requestCommandInvocation` no longer forces the palette open: the host mounts for a pending invocation but renders no dialog, so Option+H/J pane focus stops portaling a modal and trapping focus per keypress while keeping the #494 cost model. routerWiring.test.ts covers the joins rather than the modules, which is where all four defects lived and why ~3,300 lines of new tests missed them. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 247 files / 1635 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .review/pr608-destructive-mcp-claude-c.md | 223 ++++++++++ .review/pr608-keybindings-codex-c.md | 75 ++++ .review/pr608-overengineering-claude-e.md | 149 +++++++ .review/pr608-persistence-codex-d.md | 33 ++ .review/pr608-test-quality-claude-d.md | 144 +++++++ .review/pr608-whole-pr-claude-a.md | 112 +++++ .review/pr608-whole-pr-claude-b.md | 112 +++++ .review/pr608-whole-pr-codex-a.md | 51 +++ .review/pr608-whole-pr-codex-b.md | 46 +++ src/renderer/src/app-state/uiShell/slice.ts | 4 +- src/renderer/src/app/App.tsx | 9 +- .../features/command-keybindings/normalize.ts | 65 +++ .../command-keybindings/routerWiring.test.ts | 128 ++++++ .../command-palette/ui/CommandPalette.tsx | 19 +- .../features/editor/ui/EditorWorkbench.tsx | 12 +- .../features/editor/ui/MonacoFileEditor.tsx | 63 ++- .../commands/globalEditorCommands.ts | 19 +- .../src/workspace/tile-tree/useKeybinds.ts | 389 ++++++------------ 18 files changed, 1366 insertions(+), 287 deletions(-) create mode 100644 .review/pr608-destructive-mcp-claude-c.md create mode 100644 .review/pr608-keybindings-codex-c.md create mode 100644 .review/pr608-overengineering-claude-e.md create mode 100644 .review/pr608-persistence-codex-d.md create mode 100644 .review/pr608-test-quality-claude-d.md create mode 100644 .review/pr608-whole-pr-claude-a.md create mode 100644 .review/pr608-whole-pr-claude-b.md create mode 100644 .review/pr608-whole-pr-codex-a.md create mode 100644 .review/pr608-whole-pr-codex-b.md create mode 100644 src/renderer/src/features/command-keybindings/routerWiring.test.ts diff --git a/.review/pr608-destructive-mcp-claude-c.md b/.review/pr608-destructive-mcp-claude-c.md new file mode 100644 index 00000000..18a8b5ec --- /dev/null +++ b/.review/pr608-destructive-mcp-claude-c.md @@ -0,0 +1,223 @@ +# PR608 destructive+MCP (Claude C) (claude) + +sessionId: 01be91ed-0c5f-4919-af31-7a7b2efaf4a9 + +--- + +## Verification run + +| Command | Exit | +|---|---| +| `npx tsc -p tsconfig.node.json --noEmit` | 0 | +| `npx tsc -p tsconfig.web.json --noEmit` | 0 | +| `npm run check:keybindings` | 0 (25 binding sets, 12 reservations, 5 approved overlaps) | +| `vitest run closeConfirmation{,Broker}.test.ts closeGrant.test.ts AgentManagementBridge.test.ts` | 0 (53 passed) | + +Everything green. The defects below are all things the suite does not assert. + +--- + +# Findings, most severe first + +## 1. The keybinding router runs before every focus-ownership guard — ⌘W in the Global Editor now kills an agent session, and ⌥/End chords are stolen from text editing + +`src/renderer/src/workspace/tile-tree/useKeybinds.ts:364` + +`routedCommandForEvent` is placed at line 364, after only the app-modal (`hasAppInteractionOwner`, :310) and placement-overlay (:338) bailouts. Every remaining ownership guard is *below* it: + +- `editorOwnsTarget` early return (:442) — protected `['s','w','[',']']` and **all** `alt && !cmd` chords while focus is in `[data-global-editor-input-owner]` +- `fullscreenEditorOwnsWorkspace` swallow (:461-480) — explicitly existed to eat "destructive pane grammar" while the workspace is hidden +- `!isTextEditingTarget(e.target)` on the `End` handler (:931) + +`routedCommandForEvent` also ignores the `context` field it reads (`'grid' | 'editor' | 'feed'` in `defaults.ts`); it matches on chord alone. + +**Failure A (destructive).** Global Editor open, focus in the file-tab strip or Explorer, one Claude pane behind it, idle. Press ⌘W. Before: `EditorWorkbench`'s bubble handler (`EditorWorkbench.tsx:241`) closed the file tab — its own comment says *"allowing the workspace's window-level Cmd+W handler through would terminate the underlying agent pane."* Now: line 364 matches `Cmd+W` → `close-pane` → `closeFocused()`. Target is idle and single, so `closeConfirmationFor` returns `{required:false}` — the session dies with a toast. In fullscreen editor the pane isn't even on screen. + +**Failure B (destructive).** Same, ⌥W (`close-pane`'s second default). Previously line 448 (`if (alt && !cmd) return`) kept it out of the editor entirely. + +**Failure C (editing).** Type in Monaco, press ⌥D to insert `∂` → `split-vertical` spawns a new agent pane. ⌥T spawns a terminal, ⌥C spawns Codex, ⌥←/→ (word navigation) move pane focus. + +**Failure D (editing, app-wide).** Type in the composer, press `End` to jump to end of line. `keybindingFromEvent` returns `"End"`, `jump-latest-message` is in `ROUTED_COMMAND_IDS`, so line 366 `preventDefault()`s unconditionally — the caret does not move. The `preventDefault` happens *before* admission, so it swallows the key even when the gateway then refuses the command. + +This also invalidates four of the five entries in `reservations.ts:180-243` in the same PR. `APPROVED_OVERLAPS` justifies ⌘W, ⌘[, ⌘] and `End` on the grounds that *"useKeybinds returns early for cmd+s/w/[/] whenever the event target sits inside [data-global-editor-input-owner]"* and that jump-to-latest *"requires a target that is not text-editing"*. Neither is true any more, so `check:keybindings` passes on a promise the router broke. + +**Severity: High.** + +--- + +## 2. Close Focused Session confirms the wrong target set — it omits the tab's detached Dispatch sessions, which it then kills + +`src/renderer/src/workspace/hook/actions/pane.ts:1103-1114` (gate) vs `:1175-1178` and `:1333-1336` (what actually dies) + +The gate expands with `expandSessionCloseTargets`, which walks **only** `linkedParentId` (`closeConfirmation.ts:226-228`). The close itself, when the target is the last leaf in its tab, adds `detachedTabChildren(closeSnapshot, tab.id)` — selected by a completely different edge, `projectTabId` (`pane.ts:96-113`). + +**Failure.** Tab "web" has one grid pane (Claude A, idle) and three detached Dispatch agents B/C/D with `projectTabId` = that tab, two of them mid-turn. Press ⌘W (or run Close Focused Session, or the native menu item). +- Gate: `expandSessionCloseTargets(...) → [A]`, `A.live === false` → `{required:false}` → **no dialog at all**. +- Execution: `parentInfo === null` → `closedSessionIds = [A, B, C, D]` → four sessions killed, two of them working. + +This is verbatim the failure `closeConfirmation.ts:152-157` says the module exists to prevent (*"an unexpanded set silently downgrades a cascade to a single close"*). The correct expander already exists and is already used by `closeTab` — `expandTabCloseTargets(state, runtimes, gridIds, detachedIds)` — it just isn't reachable from the pane path, which cannot know at gate time whether `parentInfo` will be null. The same hole exists on the Dispatch arm (`:1132 → closeSession`) and in `closeSession` itself. + +**Severity: High.** + +--- + +## 3. `close_agent` (Agent Management MCP) is now permanently denied — nothing in production issues a grant + +`src/main/agentManagement/AgentManagementBridge.ts:240` (`issueCloseGrant`), `:265` (`consume`) + +``` +$ grep -rn "issueCloseGrant" src/ scripts/ +AgentManagementBridge.test.ts:143,280,391,410 +AgentManagementBridge.ts:240,241 ← the definition +``` + +The only callers are four test cases. There is no IPC handler, no renderer confirmation, no menu item, no UI affordance that calls it. `revokeCloseGrantsForSession` has no production caller either. + +**Failure.** User types "close the codex agent in this project" to an agent with Agent Management MCP enabled. The tool is still registered and still described as *"Call only when the current user explicitly asks to close this specific agent"* (`createBuiltInMcpServer.ts:297-311`), with no mention of a grant. `closeAgent` calls `consume(caller, target)` → `false` → throws *"close_agent refused: no user authorization for this agent."* The model has no way to obtain authorization; it will retry, apologise, or invent a workaround. Every invocation fails, forever. + +The grant store itself is well built and correctly fails closed. The problem is that "fail closed" with no issuer is indistinguishable from deleting the feature — and the plan asked for *"a short-lived user-issued caller/target authorization **or** renderer confirmation"* (§Phase 7.7), i.e. something that can actually be issued. + +**Severity: High.** Either wire an issuer (renderer confirmation on the close request is the natural one, and the confirmation dialog from this same PR already exists) or unregister the tool. + +--- + +## 4. Close Old Agents' per-kill re-enumeration reads a frozen snapshot, so it can never observe a change + +`src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx:280` (`buildCloseTargets`), `:313` (call inside the loop) + +```ts +const current = buildCloseTargets(workspace) // ← `workspace` from the click-time closure +const stillGranted = narrowGrantToCurrent([target], current) +``` + +`workspace` is the prop captured when `closeMatchingAgents` was created. `useWorkspace` returns a fresh object literal per render whose `state`/`runtimes` are zustand snapshots (`hook/index.ts:95-98, 834-836`) — that is exactly why every action in `pane.ts` reads `refs.stateRef.current` / `refs.latestRuntimesRef.current` instead. The running async closure keeps the click-time object for the whole loop. + +**Failure.** User previews 12 idle agents and clicks Close. The loop is sequential and each `closeSession` awaits a backend kill, so it spans seconds. During it, agent #7 receives a prompt and starts working. Iteration 7 calls `buildCloseTargets(workspace)` → returns the click-time snapshot where #7 was idle → `narrowGrantToCurrent` sees `now.live === false` → the guard at `closeConfirmation.ts:130` never fires → the now-working agent is killed on the strength of the stale preview. `outcome.skipped` stays empty in every realistic run, so `describePartialClose` reports success. + +The comment at `:294-303` states precisely the invariant the code fails to hold. Fix: thread a getter (`workspace.latestScreenRef`-style ref, or a `() => useAppStore.getState()` read) instead of the captured object. + +**Severity: High** for a feature whose entire justification in the plan is *"re-enumerate immediately after confirmation and before every kill."* + +--- + +## 5. Provider capability gates are cross-wired onto the wrong commands, and the two commands they were minted for still use the old gate + +`src/renderer/src/features/workspace/commands/sessionCommands.ts:85, 658, 765, 895` + +All four consumers of `getProviderFeatures`: + +| line | command | capability consumed | correct? | +|---|---|---|---| +| 85 | `view-prompts` | `switchTargets.length > 0` | **no** | +| 137 | `rewind-to-prompt` | `transcriptRewind` | yes | +| 658 | `reload-agent` | `verifiedExternalResumeCommand` | **no** | +| 806 | `duplicate-agent` | `transcriptDuplicate` | yes | + +The rationale comments came along with the capability: `view-prompts` carries *"Driven by the explicit switch EDGE list… translation is directional"* and `reload-agent` carries *"Requires a VERIFIED external resume form… they paste it into a terminal and blame their setup."* Neither sentence is about the command it is attached to. + +Meanwhile `switch-provider` (:895) still returns `isAgentProviderKind(kind)` and `copy-resume-command` (:765) still returns `isAgentProviderKind(kind) && Boolean(meta?.providerSessionId)` — the exact predicate `featureCapabilities.ts:6-18` says it replaces. And `savedSessionListing` has **zero** consumers, so `resume-session` (`tabCommands.ts:51`, no `when` at all) is unchanged. + +**Failure now.** Focus an OpenCode pane. `Copy Resume Command` is offered and produces an unverified shell command; `Switch Provider` is offered and has no destination edge. Both are named explicitly in the audit's High-severity row and in the docstring of the module added to fix them. + +**Failure later.** Someone verifies OpenCode's CLI resume string — a one-boolean documentation act — and silently enables **Reload Agent** for OpenCode. Someone adds an OpenCode↔Codex switch edge and silently enables **View Prompts**. Conversely, dropping the Claude→Codex edge would remove View Prompts from Claude. + +`providerFeatures.test.ts` pins the table only; it never asserts which command reads which capability, so nothing catches this. + +**Severity: Medium-High** (one live wrong-availability pair today, two latent silent couplings). + +--- + +## 6. Settings offers bindings for all 98 commands; only 24 are routed + +`src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:102` vs `src/renderer/src/workspace/tile-tree/useKeybinds.ts:181` + +`CommandKeybindingsRow` builds a row for every catalog command with a `category` (all of them). `routedCommandForEvent` (:223) skips anything not in the 24-member `ROUTED_COMMAND_IDS`. `buildCommandRegistry` (`registry.ts:157-160`) renders effective bindings in the palette regardless. + +**Failure.** User assigns ⌘⌥K to `close-old-agents`. Settings accepts it, runs the collision check, shows the chip; the palette row shows ⌘⌥K. Pressing it does nothing, with no error and no disabled state. Same for `bury-pane`, `dispatch-mode`, `toggle-spotlight`, `reload-agent`, `undo-rewind`, every MCP toggle, every debug command. + +Worst case is `save-editor-file`, which ships a **default** (`defaults.ts:125`) and is validated by `check:keybindings`, but is not routed: Monaco's `addAction` (`MonacoFileEditor.tsx:246`) and the workbench handler still own ⌘S. Rebind Save to ⌘⌥S → new chord dead, ⌘S still saves. `defaults.ts:121-125` admits this (*"removing those two hard-coded paths is what makes rebinding real"*), but nothing surfaces it to the user and the acceptance matrix claims *"every built-in command can be assigned, multiply bound, unbound, and reset."* + +**Severity: Medium.** + +--- + +## 7. Rebinding or unbinding a command leaves its legacy hard-coded chord live + +`src/renderer/src/workspace/tile-tree/useKeybinds.ts:378, 396, 418, 648, 658, 670, 675, 680, 685, 843, 848, 863, 868, 884, 892, 898-917, 931` + +Only three legacy branches were deleted (⌘⇧P, ⌘⇧E, ⌘⇧T). Every other routed command still has its hard-coded handler below line 364, unreachable **only while the default binding is intact**. + +**Failure (safety-relevant).** A user deliberately unbinds `close-pane` (`overrides['close-pane'] = []`) because ⌘W keeps killing panes. `routedCommandForEvent` no longer matches → falls through to `:675` → `void workspace.closeFocused()` → the pane still dies. Settings says "Not assigned." + +**Failure (ordinary).** Rebind `quick-open-file` to ⌘⌥O. New chord works; ⌘P *also* still opens quick-open via `:396`. Same for ⌘⇧F, ⌘⌥E, ⌘T, ⌘⇧R, ⌘⇧W, ⌘[/⌘], ⌥D/⌥⇧D, ⌥T/⌥⇧T, ⌥C, ⌥W, ⌥H/J/K/L, ⌥arrows, End. + +The plan lists this exact case in the check-script contract and the manual matrix (item 15: *"without leaving the former chord active"*). + +**Severity: Medium.** + +--- + +## 8. `closeTab` and `closeFocused` mutate from a pre-await state snapshot + +`src/renderer/src/workspace/hook/actions/tab.ts:89` (`state` from the render closure) with the new `await` at `:110`; `pane.ts:1087` with the new `await` at `:1111` + +`ids`, `detachedRecords`, `allMetas`, `tabIdx` and the undo entry's `tab: {...tab}` are all computed from the snapshot taken *before* the dialog opened, then used after the user answers. + +**Failure.** ⌘⇧W on a tab with a running agent → dialog opens. While it is open, an orchestration agent calls `orchestration_create_agent`, which creates a detached session with `projectTabId` = that tab. User confirms. `idsToKill` is stale → the new session is not killed, but `setState` removes the tab (`:159`) and only deletes `detachedSessions` for the stale `detachedIds` (`:163`) — leaving a session with a dangling `projectTabId`, a live backend PTY, and no owner. The ownership sanitizer then prunes the record at save time, per the warning in `detachedTabChildren`'s own comment. + +The inverse holds in `closeFocused`: `closeLinkedChildren` (`:1159`) and `detachedTabChildren(closeSnapshot,…)` (`:1177`) read *fresh* state, so a linked child spawned during the dialog is killed without ever appearing in the confirmed list. + +**Severity: Medium** (needs a state change during the dialog window, but agent-driven session creation makes that reachable). + +--- + +## 9. Agent Activity's per-row close has no confirmation at all + +`src/renderer/src/features/workspace/ui/AgentActivityModal.tsx:281` + +`closeRow` calls `workspace.closeSession(row.sessionId)` directly. `closeSession` cascades linked children (`pane.ts:1294`) and takes the tab's detached sessions when the target is the last leaf (`:1333-1336`). No gate was added on this path. + +**Failure.** Open Agent Activity, click Close on a row that happens to be the sole pane of a tab with five detached agents → six sessions die, no dialog, no count. Plan §Phase 7.4 requires the gate "from every source, including buttons." + +**Severity: Medium.** + +--- + +## 10. Every routed chord mounts and unmounts the entire command palette + +`src/renderer/src/app-state/uiShell/slice.ts:60-64` → `CommandPalette.tsx:199-205` → `:940-962` + +`requestCommandInvocation` sets `commandPaletteOpen: true` to force `OpenCommandPalette` to mount, which builds the whole registry — the cost `#494` deliberately avoided — then a layout effect dispatches and closes it. The comment justifies this with *"A keypress is equally rare and equally intentional."* + +That is true of a menu click and false of ⌥H/⌥J/⌥K/⌥L, ⌘[, ⌘]. `routedCommandForEvent` does not filter `e.repeat`, so holding ⌥J to walk across panes rebuilds ~98 command definitions and mounts/unmounts a Radix `Dialog` (focus trap, `aria-hidden` on siblings, `body { pointer-events: none }`) per repeat. + +I could not reproduce a stuck-overlay state without running the packaged app, so I'm **downgrading the focus/pointer-events half to unverified**. The registry rebuild per navigation keypress is directly readable from the code and is a real regression against the issue the gate was built for. + +**Severity: Medium (perf), unverified (focus churn).** + +--- + +## 11. Kill Buried reuses `reason: 'running'`, so an idle buried session is announced as working + +`src/renderer/src/workspace/hook/actions/pane.ts:1773` + +The request is hand-built with `reason: 'running'` unconditionally. `CloseConfirmationDialog.tsx:50` branches on exactly that value for its title. + +**Failure.** Kill an idle buried session → dialog reads **"Close a working agent?"** with body *"Killing a buried session is permanent…"* and no "working" row marker. The title contradicts the body and the state. Cosmetic, but this is the one close with no undo, so the dialog's credibility is the whole mechanism. + +Related dead distinction: `closeConfirmationFor` picks `'cascade'` vs `'bulk'` from `live.length > 0` (`closeConfirmation.ts:81`) rather than from origin, and nothing consumes the difference — the dialog only tests `=== 'running'`. Either wire it or drop it. + +**Severity: Low.** + +--- + +## Things I checked and could not break + +- **Unresolved confirmation promise.** `CloseConfirmationSurface` is in `modalSurfaces` and `GlobalModals` is unconditionally rendered (`App.tsx:122`), so no unmount hang. Second request resolves the first as declined (`broker.ts:58`). Escape/overlay/close-button all route to `resolveCloseConfirmation(false)` (`Dialog.tsx:44`). Radix does not fire `onOpenChange` for a controlled prop change, and a second resolve is a no-op. `dispatchCommand`'s `inFlight` set holds `close-pane` for the whole dialog, so a repeated chord is dropped rather than queued. + - One ordering nit with **no constructible failure**: `requestCloseConfirmation` calls `emit()` while `resolver` still points at the previous, already-resolved function, and installs the new resolver afterwards. A listener that resolved synchronously during `emit()` would null `pending` and hang the new promise. The only listener is a React `setState`, which cannot. Reordering (`resolver = null` before `emit`, or building the promise first) would make it structurally safe. +- **Native menu.** `dispatchCommand` resolves from `builtInCommandCatalog`, not the filtered registry, and skips picker visibility — the audit's High-severity menu defect is genuinely fixed. `NativeMenuCommandId` typing plus the catalog test closes the loop. +- **Tab close button** (`TabBar.tsx:119`) goes through `closeTab`, so it is gated. +- **Undo Close** is untouched and still recovers what it recovered before; the tab-level entry still captures `detachedEntries` in both `closeTab` and `closeFocused`. The policy's claim that undo covers only the idle-single case is honest — which is precisely why finding #2 matters. +- **`dispatchProjectTerminal` removal** is complete: no runtime references remain, `omitRetiredSettingsKeys` prevents re-serialization, store version bumped 8 → 10. +- **`coerceCommandKeybindingOverrides`** correctly distinguishes absent / `[]` / non-empty, and the "all entries unparseable → drop the key" reasoning in `resolve.ts:56-69` is sound. +- **Grant store semantics** (`closeGrant.ts`) — unconditional delete on consume, expiry checked after delete, bidirectional revoke — are correct. The defect is the absent issuer, not the store. diff --git a/.review/pr608-keybindings-codex-c.md b/.review/pr608-keybindings-codex-c.md new file mode 100644 index 00000000..13f396f2 --- /dev/null +++ b/.review/pr608-keybindings-codex-c.md @@ -0,0 +1,75 @@ +# PR608 keybindings (Codex C) (codex) + +sessionId: 953a8325-8e44-48c0-bdde-5112fc912713 + +--- + +## Ranked findings + +1. **High — The generic router steals context-specific and editor-native keys before their owners can handle them.** + [useKeybinds.ts:354](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:354) invokes `routedCommandForEvent` before the editor/text-target guards at [useKeybinds.ts:440](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:440) and before Dispatch’s handlers. The resolver at [useKeybinds.ts:216](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:216) ignores `BindingContext`. Concrete outcomes: + + - In Dispatch, `Alt+K` resolves to global `nav-up`, gets prevented, and the gateway rejects it as unavailable; Dispatch selection never moves. + - In Monaco, `Cmd+W` closes the workspace pane instead of the editor file. + - In a composer, `End` invokes `jump-latest` instead of moving the caret. + +2. **High — Running agents can still be killed without the new confirmation flow.** + Confirmation exists only in focused-pane/tab actions, while [pane.ts:1283](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/stores/actions/pane.ts:1283) exposes `closeSession` as an unconditional cascading kill. [AgentActivityModal.tsx:276](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx:276) calls it directly. Select a running parent in Agent Activity and press Delete: the parent and linked children are terminated immediately, without the confirmation or affected-session preview introduced by this PR. + +3. **High — “Dangerous Agents” is enabled with one click and persisted before reload succeeds.** + [settingsRegistry.ts:796](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/lib/settingsRegistry.ts:796) exposes the safety-critical flag as a normal toggle; [SettingsList.tsx:105](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/SettingsList.tsx:105) supplies no confirmation. The `onChange` at [settingsRegistry.ts:808](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/lib/settingsRegistry.ts:808) persists `true` before awaiting fleet reload, with no rollback. A single accidental click permanently enables the mode; a partial reload failure leaves persisted policy and live sessions inconsistent. + +4. **High — Settings offers custom bindings for 74 command IDs that the router cannot dispatch.** + [CommandKeybindingsRow.tsx:97](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:97) renders all 98 catalog commands, but [useKeybinds.ts:181](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:181) accepts only 24 closed-set IDs. Bind `toggle-reader-mode` to free chord `Cmd+Shift+Y`: Settings persists it and the palette displays it, but pressing it does nothing because the router skips that ID. + + The shipped `save-editor-file` default is already affected: it is declared at [defaults.ts:121](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-keybindings/defaults.ts:121) but absent from the routed set; Monaco still hardcodes `Cmd+S` at [MonacoFileEditor.tsx:233](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/editor/ui/MonacoFileEditor.tsx:233). Rebinding save to `Cmd+Shift+S` changes the displayed chord, but that chord never saves. + + `check:keybindings` remains green because [check-command-keybindings.mts:76](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/scripts/check-command-keybindings.mts:76) checks defaults against catalog IDs, not catalog/default IDs against the runtime routed set. + +5. **High — Explicit unbinds and rebinds do not disable legacy shortcuts.** + The effective resolver returns only when it finds a match; otherwise execution falls through to the old hardcoded blocks beginning at [useKeybinds.ts:648](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:648) and [useKeybinds.ts:843](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:843). Persist `{ "new-tab": [] }`: the palette correctly removes the shortcut, but `Cmd+T` still opens a tab. Close, navigation, split, resize, and `End` have the same bypass. + +6. **High — Command execution discards asynchronous action lifetimes, defeating gateway error and single-flight guarantees.** + [executeCommand.ts:207](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/commands/executeCommand.ts:207) assumes `run()` remains pending until the action finishes. Several registrations explicitly discard promises, for example [paneCommands.ts:77](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/commands/registrations/paneCommands.ts:77). Invoke `close-pane` on a running agent: the gateway records completion and removes the single-flight entry immediately while confirmation is still open. Cancellation is recorded as a completed run, another invocation can start concurrently, and later rejection is outside gateway error handling. + +7. **High — `close_agent` has no production path that can issue its required authorization grant.** + [AgentManagementBridge.ts:240](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/main/agentManagement/AgentManagementBridge.ts:240) defines `issueCloseGrant`, but repository search finds calls only in tests. Production `closeAgent` consumes the nonexistent grant and rejects. Even when a user explicitly asks an agent to close another agent, every `close_agent` call returns “no user authorization.” Tests hide this by manually issuing the grant. + +8. **Medium — Settings accepts bare printable, dead-key, and active-IME bindings, which then hijack text entry.** + [normalize.ts:198](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-keybindings/normalize.ts:198) accepts unmodified physical codes and does not inspect `isComposing`; [CommandKeybindingsRow.tsx:161](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:161) persists any non-null result. Probes produced: + + - `{key:"Dead", code:"Quote"}` → `'` + - `{key:"Process", code:"KeyA", isComposing:true}` → `A` + + Bind `A` to `new-tab`, then type `A` in the composer, search field, or Monaco: a tab opens and the character is lost. + +9. **Medium — “Replace” is offered even when a chord also has nonreplaceable reserved owners.** + [CommandKeybindingsRow.tsx:187](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:187) separates command owners from reservations, but [CommandKeybindingsRow.tsx:253](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:253) offers Replace whenever any command owner exists. Assign `Cmd+W` to `open-settings`: owners include `close-pane`, the native application menu, and editor-native close. Replace removes only `close-pane` and then persists a binding still claimed by two reservations. + +10. **Medium — Provider capabilities are not actually controlling two provider-sensitive commands.** + + - `resume-session` has no saved-session-listing availability condition at [tabCommands.ts:51](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/commands/registrations/tabCommands.ts:51). With an OpenCode session focused, the command is offered and opens an empty picker because OpenCode’s main provider returns no saved sessions. + - `reload-agent` is gated on the unrelated `verifiedExternalResumeCommand` capability at [sessionCommands.ts:649](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/commands/registrations/sessionCommands.ts:649). A resumable OpenCode session is therefore denied the command even though [provider.ts:101](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/stores/actions/provider.ts:101) can reload it. + +11. **Medium — The reservation registry omits real composer ownership.** + [reservations.ts:39](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-keybindings/reservations.ts:39) does not reserve `Ctrl+C`, `Ctrl+D`, or `Cmd+Enter`, although [useComposerKeybinds.ts:427](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts:427) consumes them. Owner probing reports `Ctrl+C` as free. Bind it to `open-settings`; pressing it in the composer opens Settings instead of cancelling/clearing the PTY or draft. + +12. **Medium — Letter normalization makes displayed chords incorrect on Dvorak and other non-US layouts.** + [normalize.ts:194](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-keybindings/normalize.ts:194) always derives letters from physical `event.code`, not just macOS Option glyph cases. A Dvorak event `{metaKey:true, key:"t", code:"KeyK"}` normalizes to `Cmd+K`. The palette displays New Tab as `Cmd+T`, but pressing the key producing “T” does not invoke it; users must press the physical QWERTY-T position instead. + +13. **Medium — “Close Old Agents” revalidates liveness but not whether agents are still old or inactive.** + [CloseOldAgentsModal.tsx:276](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CloseOldAgentsModal.tsx:276) rebuilds targets from IDs and live state only; [closeConfirmation.ts:120](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/lib/closeConfirmation.ts:120) does not recheck age or recent activity. An agent that was five hours idle at preview time but subsequently performed work and returned idle is still closed as “old.” + +14. **Low/Medium — Settings treats every requested binding as global, falsely blocking disjoint contextual reuse.** + [CommandKeybindingsRow.tsx:179](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:179) hardcodes the requested context to `global`, despite [resolve.ts:97](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-keybindings/resolve.ts:97) supporting context-aware conflicts. After clearing `nav-up`, try adding `Alt+K` to grid-only `resize-up`: grid and Dispatch are disjoint, but Settings reports the Dispatch reservation as a conflict and refuses the binding. + +## Verification + +All of these completed with exit code 0: + +- `npm run check:keybindings` +- Targeted normalization/editor/routing tests: 54 tests +- Targeted provider, execution, close, broker, bridge, and grant tests: 85 tests +- `npm run typecheck` + +The worktree remained clean; I modified no tracked files. diff --git a/.review/pr608-overengineering-claude-e.md b/.review/pr608-overengineering-claude-e.md new file mode 100644 index 00000000..b838e22c --- /dev/null +++ b/.review/pr608-overengineering-claude-e.md @@ -0,0 +1,149 @@ +# PR608 overengineering (Claude E) (claude) + +sessionId: ceb09d5f-785d-48d7-a77c-c67fcdca1933 + +--- + +Verified before writing: `tsc -p tsconfig.node.json` → 0, `tsc -p tsconfig.web.json` → 0, targeted `vitest --maxWorkers=4` over all new suites → 0, `npm run check:keybindings` → 0. **Everything is green.** That matters, because several findings below are broken behaviour that the new tests structurally cannot catch. + +--- + +# Blocking — broken, not merely overengineered + +### 1. The Agent Management MCP `close_agent` tool is now permanently denied +`src/main/agentManagement/AgentManagementBridge.ts:265` refuses every close unless `closeGrants.consume()` succeeds. `issueCloseGrant` (`:240`) has **zero production callers** — the only four call sites are in `AgentManagementBridge.test.ts`. There is no IPC handler, no renderer confirmation, no user action wired. Default DENY + nothing that ever grants = the tool always throws. + +`revokeCloseGrantsForSession` (`:245`) has zero callers *anywhere*, including tests — so the commit message's "Grants are revoked in BOTH directions when a session goes away" describes code that never runs. `CloseGrantStore.peek` and `.size` are test-only too. + +This is worse than the prose gate it replaced: the model now receives a confident *"no user authorization for this agent"* for closes the user explicitly asked for. Either wire a real issuer or revert the commit. + +### 2. Phase 5's headline capability gate is on the wrong command +- `sessionCommands.ts:85` — **`view-prompts`** is gated on `getProviderFeatures(kind).switchTargets.length > 0`, carrying a comment about "the explicit switch EDGE list". View Prompts is a read-only transcript modal; the plan's own provider table gives OpenCode *"View Prompts / rendered feed actions: Yes where feed/history exists"*. +- `sessionCommands.ts:895` — **`switch-provider`** still reads `isAgentProviderKind(kind)`, the exact High-severity defect Phase 5 exists to kill. OpenCode still gets Switch Provider offered. + +The comment at `:82-84` plainly belongs at `:895`. Tests stay green because `providerFeatures.test.ts` only asserts the capability table, never which command consumes it. + +Related: `savedSessionListing` is declared, tested, and **read by nothing** — `resume-session` (`tabCommands.ts:51-58`) still has no `when` at all, so the Resume-on-OpenCode defect is untouched. And `duplicate-agent`'s `run` (`:821`) re-checks with `isAgentProviderKind` while its `when` uses `transcriptDuplicate`, under a comment claiming it "mirrors the `when` predicate". It does not. + +### 3. `ROUTED_COMMAND_IDS` sells bindings the router will never fire +`useKeybinds.ts:181` hand-lists 24 ids. `CommandKeybindingsRow.tsx:99-111` renders a binding row for **every** catalog command with a category (~98). So a user can bind ⌘⌥R to `reload-agent`, see the chip in Settings, have `check:keybindings` reserve the chord against everything else — and press it forever with nothing happening. + +That is precisely the defect class `normalize.ts:109-115` rejects `Cmd++` to prevent: *"a binding the Settings UI would happily store and display while the runtime could never match it."* The PR built the guard and then shipped the bug at a larger scale. + +It also hard-codes `codex-vertical`/`codex-horizontal`, which `defaults.ts:145-153` *derives* from `AGENT_PROVIDER_KINDS`. Add a provider with a `splitShortcutKey` and you get a generated default, a Settings row, a reservation — and a dead chord. + +**Fix:** delete the set. Route whatever has a matching effective binding, minus a small explicit deny-list for editor-context chords (`save-editor-file`). Derived, not enumerated. + +### 4. `closeGrant.ts` is committed as a binary file +A literal NUL byte at line 55 — `` `${caller}\x00${target}` `` was written as the raw byte instead of the escape. `git diff`, `git show`, `git blame` and GitHub's diff view all refuse to render it. **The file is literally unreviewable in the PR.** Use `\x00` as an escape, or a separator that isn't a control character. + +### 5. Orphaned docstring now misdocuments a live field +`app-state/settings/types.ts:344-355`. Deleting `dispatchProjectTerminal` took its closing `*/` with it, so a 12-line comment about a removed Dispatch terminal swallows `autoSendPromptSuggestion`'s `/**` and reads as that field's documentation. Compiles fine. The plan explicitly required removing the stale project-terminal comments. + +--- + +# Speculative generality — code with no caller + +### 6. `resolveInvocation.ts` (205 lines) has zero product consumers +Nothing imports it outside `resolveInvocation.test.ts` (230 lines). It drags with it four `CommandDef` fields **no command sets** — `targetKind`, `risk`, `unavailableReason` — plus the `CommandTarget` / `CommandAvailability` / `ResolvedCommandInvocation` types. Its own docstring (`:132-151`) concedes it does not do the thing it exists for. Inside it, `resolveCommandTarget`'s `'document'` case returns `{kind:'none'}` under a comment saying no consumer exists, and `targetStillValid`'s `'document'` branch is unreachable by construction. + +Keeping an unused pinning authority *and* 98 commands that ignore it is the worst of the three options, because the next reader will assume targets are pinned. Delete it, or wire the palette through it — not both. + +### 7. `CommandState.truth` is required and read by nobody +`types.ts:22,30`. `describeCommandState` never touches it; nothing else does either. Four product call sites author it. Delete until something actually renders persisted-vs-effective. + +### 8. Dead parameters and dead exports +- `resolve.ts:96-107` — `contextForCommand`, with a long docstring about a TDZ cycle. **No caller passes it.** The one place it would matter, `CommandKeybindingsRow.tsx:181`, hardcodes `context: 'global'` — the exact case the docstring argues against. +- `resolve.ts:178` — `setCommandKeybindings`'s `defaults` param is unused in the body; all four call sites pass it. +- `closeConfirmation.ts:103` `grantStillMatches` — test-only (`narrowGrantToCurrent` is the one in use). +- `resolve.ts:146` `effectiveBindingsFor` — test-only. +- `featureCapabilities.ts:59` `NO_PROVIDER_FEATURES` — exported "so a caller can compare against it"; no caller does. +- `closeConfirmation.ts:175` `isSessionLiveForClose` is a one-line wrapper around the private `isLive` in the same file. Just export `isLive`. +- `CommandKeybindingsRow.tsx:76` — `syntaxError` is only ever set to `null`. The error banner at `:275-277` can never render. +- `commandState.ts:126` — `label: openClosed ? 'Mixed' : 'Mixed'`. + +### 9. `CloseConfirmationRequest.reason` is unread, wrong, and misused +The dialog branches only on `'running'` (`CloseConfirmationDialog.tsx:50`); `'cascade'` vs `'bulk'` is never read. The discriminator at `closeConfirmation.ts:81` keys on **liveness**, not on cascade-ness, so the value is wrong anyway — and its `targets.length > 1` clause is always true, since the single-target case returned above. Then `pane.ts:1773` passes `reason: 'running'` for the Kill Buried confirmation, so killing an **idle** buried session shows *"Close a working agent?"*. Collapse to `'running' | 'multi'` and let `summary` carry the copy. + +### 10. `CloseConfirmationSurface.tsx` — 8 lines whose body is `` +Register the dialog in `modalSurfaces` directly. This is the `ui/README.md` guardrail verbatim: *"No wrapper around a wrapper."* + +--- + +# Guards that cannot fire + +| Location | Why it can't fire | +|---|---| +| `check-command-keybindings.mts:149-153` (check 8) | `contextsOverlap(a, a)` returns `true` at `defaults.ts:49` before reaching the matrix. Dead check. | +| `check-command-keybindings.mts:115` | `approved.reason.trim().length < 40` — a magic prose-length threshold in CI, validating a literal against itself. This is the CI grep-lock the house style forbids. Same for `owners.length < 2`. | +| `check-command-keybindings.mts:135-144` + `keybindingBaseline.test.ts:318-320` | `'shortcut' in command` — TypeScript already rejects `shortcut:` on a `CommandDef` literal. Two runtime copies of a compile-time guarantee. | +| `catalog.ts:146-147` | `VALID_SURFACES`/`VALID_TIERS` duplicate the TS unions. Justified by "a future extension-contributed command" — which the plan puts explicitly out of scope. | +| `taxonomy.test.ts:43-57` | The `known` category Set restates `CommandCategory`; the filter can never be non-empty. | +| `registry.ts:70-73` | `assertNever` returning `false` "for a value that reached us across a boundary TypeScript could not check (a persisted blob, a generated command)" — `surface` is only ever an in-repo literal. The exhaustive switch is right; keep it, but drop the fictional boundary from the comment. | + +Also: `registry.ts:167-169` throws on an empty description *inside a render path* — the exact failure `catalog.ts:98-102` says it moved to a test. Now both exist; drop the throw (opportunistic cleanup, in blast radius). + +Not flagging: the `visited` set in `expandSessionCloseTargets` (`:218`). A cycle is impossible given `linkedParentId` is set to an existing parent at creation, but it's two lines of standard BFS hygiene and reads as intentional. Fine. + +--- + +# Unscalable lists — which are unavoidable, which aren't + +**Genuinely unavoidable, keep as-is:** +- `RETIRED_BUILT_IN_COMMAND_IDS` / `RETIRED_SETTINGS_KEYS` (`persistence.ts:227,255`) — cannot be derived from absence; the reasoning about downgrades and uninstalled extensions is correct. +- `RESERVED_INTERACTIONS` (`reservations.ts:39`) — these live in Electron's role table, Monaco, and hand-written handlers. Nothing can derive them. It is also the highest-rot-risk list in the PR, and its own docstring says so. +- `APPROVED_OVERLAPS` — keyed by owner *pair*, deliberately awkward to extend. Correct design. +- `NATIVE_MENU_COMMAND_IDS` — a cross-process contract, with `catalog.test.ts` asserting the subset relation. Correct. +- The ordered 98-ID catalog snapshot — registration order is user-visible. + +**Should be derived:** +- `ROUTED_COMMAND_IDS` — see #3. +- `VALID_SURFACES` / `VALID_TIERS` / taxonomy's `known` — the type system already holds them. +- `CommandKeybindingsRow.tsx:38-58` — `CATEGORY_LABELS` is an exhaustive `Record`, but `CATEGORY_ORDER: CommandCategory[]` is not. Add a category and it silently vanishes from Settings. Derive the order from `Object.keys(CATEGORY_LABELS)`, or make it a fixed-length tuple. +- `CommandDef.category` is still optional, with `taxonomy.test.ts:37-41` enforcing totality at runtime. All 98 declare it by the end of the branch — the "independently revertable commits" argument doesn't survive a branch that merges as one unit. Make it required, delete the test. + +**Module-level mutable state — both defensible, both worth a note:** +- `executeCommand.ts:114` `inFlight` — the palette-vs-menu argument is right. But it is keyed on command id only, so with target pinning unfinished (#6) it cannot tell "reload agent A" from "reload agent B". Don't let anyone read it as a safety property. +- `closeConfirmationBroker.ts:27-29` — the "a promise can't live in zustand" reasoning is sound. The single global resolver auto-declines a first request when a second arrives (`:58`); better than hanging, but it silently discards a user intent. Worth a toast. + +--- + +# Tests that raise a count + +**11. `keybindingBaseline.test.ts` (390 lines).** The `describe('recorded authority drift (pre-migration history)')` block (`:339-390`) tests the file's own `BINDING_BASELINE` literal **against itself** — `underReported`, `gaps`, `editorOwned`, `undisclosed` all filter a const declared 280 lines above and assert the result. No product code is exercised. They can only fail if someone edits the table, in which case they fail for no reason. (`:340`'s comment says "exactly the six commands" and asserts five — nobody re-read it.) The file's original load-bearing assertion compared the table to `CommandDef.shortcut`, which this PR deleted; `:281-292` admits the assertion was "inverted". + +Genuinely valuable: `:280-311` (historical chords survive in the effective set) and `:313-321`. Keep those ~40 lines; delete the 200-line table and the history block. + +**12. `providerFeatures.test.ts`** — 6 tests, 3 redundant. "leaves OpenCode unavailable for every unsupported operation" is a strict subset of "matches the plan table exactly". "requires every agent provider to declare all five capabilities" restates `Record`, which already fails compilation — and its comment points at the wrong enforcement site. + +**13. Volume.** 16 new test files, ~3,300 lines, in a feature PR. At minimum the suites covering deleted-or-unused code (`resolveInvocation.test.ts`, most of `keybindingBaseline.test.ts`) should leave with it. + +--- + +# Performance + +**14. `useKeybinds.ts:216-227`** — every keydown reaching the router calls `buildDefaultKeybindings()` (rebuilds the array, walks `AGENT_PROVIDER_KINDS`, hits `getRendererProviderCapabilities`, normalizes ~30 strings) plus `resolveEffectiveKeybindings` (builds a Map), then linear-scans. Per keystroke, capture phase, globally. Hoist into a `useMemo` on `commandKeybindingOverrides` and use a `Map`. + +**15. `uiShell/slice.ts:60-65`** — `requestCommandInvocation` sets `commandPaletteOpen: true` for **every** routed chord. It inherits the native-menu justification ("a menu click is rare and intentional"), but ⌥H/⌥J/⌥K/⌥L pane focus is among the most-repeated gestures in the app, and each press now mounts `OpenCommandPalette`, assembles ~76 workspace actions, builds the full registry, runs the command, unmounts. Issue #494 existed specifically to stop paying that cost while the palette is closed. Measure before merge; at minimum navigation and split chords should bypass the palette. + +--- + +# What is good, and should not be touched + +The catalog/registry split (`catalog.ts`) earns its keep — four real consumers, and it structurally fixes the native-menu defect. `pickerVisibility.ts` is shared by palette and Settings and found a genuine duplicate-rule bug in the process. `normalize.ts`'s physical-code reasoning is correct and load-bearing on macOS, and the Numpad exclusion is a real case, not a hypothetical. The `executeCommand.ts` gateway has four consumers and fixes a real defect. `commandState.ts` is consumed by the palette badge. `closeConfirmation.ts`'s policy and expansion are consumed by `pane.ts`, `tab.ts`, and `CloseOldAgentsModal`. The retired-keys persistence policy and the derivation of provider split chords from the provider registry (`defaults.ts:145-153`) are exactly right. + +--- + +# Should anything be DELETED before merge? + +**Yes — four things.** + +1. **`resolveInvocation.ts` + `resolveInvocation.test.ts` + the four unused `CommandDef` fields** (`targetKind`, `risk`, `unavailableReason`) and the `CommandTarget`/`CommandAvailability`/`ResolvedCommandInvocation` types. 435 lines with no product consumer, whose own docstring says it doesn't do its job. It reads as a completed safety guarantee and is not one. + +2. **`ROUTED_COMMAND_IDS`** (`useKeybinds.ts:181`). Not deferred — deleted and replaced with derivation. Leaving it means shipping a Settings editor that assigns chords which cannot fire, in the PR whose stated purpose is making displayed and running bindings the same fact. + +3. **The `BINDING_BASELINE` table and the `recorded authority drift` block** in `keybindingBaseline.test.ts` (~250 of 390 lines). Keep the two assertions that touch product code. + +4. **`check-command-keybindings.mts` checks 6, 7, and 8** (`:110-153`). One can never fire, one is a prose-length grep lock, one duplicates a compiler guarantee. + +**And one decision, not a deletion:** the close-grant commit (`55e57322`). As shipped it disables `close_agent` outright. Either land the grant issuer in this PR or revert the commit — do not merge the enforcement half alone. diff --git a/.review/pr608-persistence-codex-d.md b/.review/pr608-persistence-codex-d.md new file mode 100644 index 00000000..3bdd739b --- /dev/null +++ b/.review/pr608-persistence-codex-d.md @@ -0,0 +1,33 @@ +# PR608 persistence (Codex D) (codex) + +sessionId: b3325d9a-b730-40a4-a63b-5c2caadacbcc + +--- + +Found 12 substantive defects. Persistence is otherwise stable: v8/v9 and malformed blobs reached a fixed point after one coercion; extension IDs and explicit `[]` survived JSON; only `dispatchProjectTerminal` and the five named retired command IDs were pruned. Version 10 is not needed for hydration because `merge` always coerces, but it does force immediate rewrite on version mismatch. + +Verification passed: typecheck exit 0; 21 targeted Vitest files / 333 tests exit 0; static keybinding check exit 0; ad-hoc coercion/resolver probes exit 0. Worktree remains clean. + +1. **High — Dangerous Agents can kill sessions and leave a silently mixed fleet.** [settingsRegistry.ts:808](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/lib/settingsRegistry.ts:808), [session.ts:1010](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/session.ts:1010). Starting from `{"dangerousAgentsEnabled":false}` with live agents A and B, clicking Enable immediately persists `true`, kills each old backend before spawning its replacement, and converts spawn failures into `failedIds` that remove the pane. If A respawns and B fails, the result is setting `true`, A replaced, B killed/deleted, with no confirmation, Mixed state, rollback, or failure report. The enabled button also permits an opposing second toggle while the first reload is running, launching two fleet replacements concurrently. + +2. **High — Public `closeSession` callers bypass the new confirmation policy entirely.** [pane.ts:1283](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/pane.ts:1283), [AgentActivityModal.tsx:276](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx:276). Given running parent P with linked child C, clicking P’s close action in Agent Activity calls `workspace.closeSession(P)` directly; it recursively kills C and P without `closeConfirmationFor` or an exact target list. The same unguarded action is exposed to orchestration callers. + +3. **High — Ordinary close confirmation is not bound to the confirmed target set.** [pane.ts:1107](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/pane.ts:1107), [pane.ts:746](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/pane.ts:746). If the dialog lists parent P and child C1, then orchestration spawns linked child C2 while the dialog is open, confirming proceeds to `closeLinkedChildren(P)`, which rereads current state and kills both C1 and unseen C2. `grantStillMatches` is never used in production. + +4. **High — Close Old Agents revalidates only ID and liveness, not the eligibility the user approved.** [CloseOldAgentsModal.tsx:279](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx:279), [CloseOldAgentsModal.tsx:315](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx:315). Preview A as idle with `lastActiveAt=T-5h`; after the click, A receives and finishes a prompt before its loop iteration, so it is recent but again `live:false`. `buildCloseTargets` contains no activity, threshold, project, ownership, or cascade data, so `narrowGrantToCurrent` still admits A and kills it. A newly linked child can likewise be cascade-killed without appearing in the preview. + +5. **High — 73 of 98 Settings keybinding rows cannot execute.** [useKeybinds.ts:181](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:181), [useKeybinds.ts:222](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:222). Blob `{"commandKeybindingOverrides":{"usage.open":["Cmd+Shift+9"]}}` is preserved and displayed, but `routedCommandForEvent` discards `usage.open` because it is not in the fixed 25-ID allowlist. Pressing the advertised chord never invokes the command. This affects every built-in without a shipped default. + +6. **High — Explicit unbind and replacement do not disable legacy default chords.** [useKeybinds.ts:364](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:364), [useKeybinds.ts:648](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:648). Blob `{"commandKeybindingOverrides":{"new-tab":[]}}` correctly resolves New Tab to no bindings, but Cmd+T misses the configured router and falls through to the old hard-coded `onNewTabRequest()`. With `["Cmd+Shift+9"]`, both the replacement and retired Cmd+T work. Equivalent fallbacks remain for close, tab navigation, editor, split, pane navigation, and End bindings. + +7. **High — Feed-context `End` is routed before text-editing ownership is checked.** [defaults.ts:130](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-keybindings/defaults.ts:130), [useKeybinds.ts:354](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:354), [useKeybinds.ts:931](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:931). With default settings, focus a composer textarea and press End. The configured router sees `jump-latest-message`, prevents the native cursor movement, and invokes the feed command before reaching `isTextEditingTarget`. The binding’s declared `feed` context is never consulted. + +8. **Medium — Replace can install a chord that still has an unreplaceable reserved owner.** [CommandKeybindingsRow.tsx:179](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:179), [CommandKeybindingsRow.tsx:224](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:224). Assign End to New Tab: owners are `jump-latest-message` plus reserved “Editor file-tab navigation.” Because one owner is a command, the UI offers Replace and writes `{"jump-latest-message":[],"new-tab":["End"]}`, despite the editor reservation remaining. The router then steals End globally. + +9. **Medium — The editor hard-codes every requesting command to global context.** [CommandKeybindingsRow.tsx:181](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:181), [resolve.ts:121](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-keybindings/resolve.ts:121). With `{"nav-up":[]}`, assigning Alt+K to grid-only `rotate-layout` should be valid because grid and Dispatch contexts are exclusive. The editor queries as `global`, finds “Dispatch row and lane selection,” and refuses the binding. The context resolver described by `resolveEffectiveKeybindings` is not supplied by Settings or runtime. + +10. **Medium — The new provider capability table is not authoritative for two commands.** [featureCapabilities.ts:109](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/providers/shared/featureCapabilities.ts:109), [sessionCommands.ts:759](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/commands/sessionCommands.ts:759), [sessionCommands.ts:889](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/commands/sessionCommands.ts:889). For `{kind:"opencode", providerSessionId:"s1"}`, Copy Resume Command is enabled and copies the explicitly unverified `opencode --session s1`, despite `verifiedExternalResumeCommand:false`. Switch Provider is also enabled despite `switchTargets:[]`, then merely reports that OpenCode cannot switch. + +11. **Medium — The Agent Management close grant cannot be issued in production.** [AgentManagementBridge.ts:240](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/main/agentManagement/AgentManagementBridge.ts:240), [createBuiltInMcpServer.ts:311](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/mcp/runtime/createBuiltInMcpServer.ts:311). `issueCloseGrant` has no production caller; only tests invoke it. Even when a user explicitly requests caller A to close B, the MCP tool directly calls `closeAgent({A,B})`, the empty grant store rejects it, and the tool returns `request_failed`. The feature is therefore fail-closed but unusable. + +12. **Medium — Downgrading from a future keybinding grammar irreversibly rewrites the user’s choice.** [resolve.ts:47](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-keybindings/resolve.ts:47), [store.ts:89](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/app-state/store.ts:89). Future-version blob `{"version":11,"state":{"settings":{"commandKeybindingOverrides":{"new-tab":["Cmd+K R"]}}}}` becomes `{}` under current coercion. Zustand invokes `migrate` for any unequal version—including a downgrade—and immediately writes version 10, so New Tab resurrects Cmd+T and re-upgrading cannot recover the user’s multi-step binding. diff --git a/.review/pr608-test-quality-claude-d.md b/.review/pr608-test-quality-claude-d.md new file mode 100644 index 00000000..cb16a1d7 --- /dev/null +++ b/.review/pr608-test-quality-claude-d.md @@ -0,0 +1,144 @@ +# PR608 test quality (Claude D) (claude) + +sessionId: d51d049c-7f39-4c76-b0d3-194a334f383c + +--- + +Working tree verified clean (`git status --porcelain` empty), `tsc -b` exits 0, and the full unit + renderer suite is green (1302 tests). All temporary edits were reverted with `git checkout --`. + +## Findings, most severe first + +--- + +### 1. `agent_management_close_agent` is dead in production — nothing can ever issue the grant it now requires + +`src/main/agentManagement/AgentManagementBridge.ts:265` refuses every close unless `closeGrants.consume(...)` succeeds. The only thing that populates that store is `issueCloseGrant` (`:240`), and it has **zero production callers** — verified by grep across `src/`. The sole production path into `closeAgent` is the MCP tool handler at `src/mcp/runtime/createBuiltInMcpServer.ts:311`, which never issues a grant. There is no IPC channel for it either (`src/main/ipc/agentManagement.ts` registers only `agent-management:response`). + +So the tool now throws `close_agent refused: no user authorization for this agent` on **every** invocation, including the legitimate "close agent X" the plan wanted to preserve. + +**Why the tests don't catch it:** every allow-path test calls the internal issuer itself — `AgentManagementBridge.test.ts:139` and `:280` were *edited* to add `bridge.issueCloseGrant(...)` so pre-existing tests would keep passing, and the three new tests (`:373`, `:382`, `:400`) all assert the *deny* path, which is the only path production can reach. `closeGrant.test.ts` (91 lines) exercises the store in isolation and proves nothing about wiring. The suite reads as "authorization implemented"; the product reads as "feature removed." + +--- + +### 2. The keybinding router hijacks `Cmd+W`, `Cmd+[`, `Cmd+]` and `End` inside the Global Editor and text inputs + +`useKeybinds.ts:364` runs `routedCommandForEvent` **before** the editor-ownership bail at `:441` and before the `isTextEditingTarget` guard at `:931`. The comment at `:359` claims the opposite ("Editor-owned targets are handled further down and never reach here") — it is 77 lines wrong. + +Consequences, all confirmed against the default binding table: + +| Chord | Before | After | +|---|---|---| +| `Cmd+W` in Monaco/editor chrome | `useKeybinds` returned early; EditorWorkbench/Monaco closed the **file** | routed → `close-pane` → `closeFocused()` **kills a workspace session** | +| `Cmd+[` / `Cmd+]` in Monaco | outdent / indent | routed → `prev-tab` / `next-tab` | +| bare `End` in the composer or Monaco | caret to end of line (`isTextEditingTarget` guard) | routed → `jump-latest-message`, and `e.preventDefault()` swallows the caret move regardless of whether admission passes | + +I verified the `End` half directly with a throwaway probe (since deleted): `keybindingFromEvent({code:'End',key:'End'})` → `'End'`, and `'End'` is the effective binding of the routed command `jump-latest-message`. + +**Why the tests don't catch it:** `reservations.test.ts:86-92` asserts `save-editor-file` has `context: 'editor'` and `jump-latest-message` has `context: 'feed'`, with the comment *"Bare End must not be global or it would collide with End in any composer."* The router at `useKeybinds.ts:222-225` never consults `context` — it matches on `bindings.includes(binding)` alone. The test pins a data label whose only consumer is the offline collision checker. Worse, `reservations.ts:220-225` records `End` as an **approved overlap** whose justification is *"Jump to Latest Message requires … a target that is not text-editing"* — the precondition this PR removed. `check:keybindings` rule 6 only verifies the reason string is >40 characters, so it passes. + +--- + +### 3. Rebinding or unbinding any command leaves the old chord fully live + +Every legacy hardcoded branch survives below the routed block: `Cmd+T` (`:648`), `Cmd+W` (`:675`), `Cmd+[`/`]` (`:680`,`:685`), `Cmd+Shift+W`, `Cmd+Shift+R`, `Cmd+P`, `Cmd+Shift+F`, `Cmd+Alt+E`, `Alt+D`/`Alt+Shift+D`, `Alt+T`, `Alt+W`, `Alt+H/J/K/L`. They are dead only while the effective binding still matches. Move `close-pane` off `Cmd+W`, or unbind it with `[]`, and `Cmd+W` still calls `workspace.closeFocused()` directly. + +**Why the test doesn't catch it:** `routing.test.ts:30-38` is titled *"a user override replaces the chord the router will match"* and comments *"Before this phase, editing a binding changed a display string while useKeybinds kept running the hardcoded chord."* It then asserts `resolveEffectiveKeybindings({'new-tab':['Cmd+Shift+9']})` — a pure function over a constant table. It cannot observe the router. `useKeybinds` still runs the hardcoded chord. `resolve.test.ts:27-31` ("explicit unbind") has the same shape and the same blind spot. + +`save-editor-file` is worse: it has a declared default (`defaults.ts:125`) but is **not** in `ROUTED_COMMAND_IDS`, so Monaco's `addAction` remains the only implementation. Rebinding Save changes nothing at all, and `keybindingBaseline.test.ts:302` explicitly `continue`s past it (`if (entry.owner !== 'useKeybinds') continue`), excluding the plan's flagship case from its own assertion. + +--- + +### 4. The entire keyboard router has no test — verified by sabotage + +I patched `routedCommandForEvent` to `return null` (every configured chord dead: `Cmd+Shift+P`, `Cmd+T`, `Cmd+W`, all navigation). Result: **118 files / 861 tests passed.** Reverted. + +Only `ownedShortcutPolicy.renderer.test.ts` imports anything from `useKeybinds`, and only the pure `shouldPreventOwnedApplicationShortcut` helper. `routing.test.ts:14-16` admits this ("exercising it end to end needs a mounted workspace") and then names itself after the behaviour it doesn't test. + +--- + +### 5. The close-confirmation gate is never proven to be applied + +I replaced both gates with `if (false && confirmation.required)` — `pane.ts:1110` (`closeFocused`) and `tab.ts:109` (`closeTab`), i.e. removed the whole Phase 7 destructive-safety wiring. Result: **861/861 passed.** Reverted. + +`closeConfirmation.test.ts` (201 lines) tests only the pure policy function; `closeConfirmationBroker.test.ts` tests only the promise plumbing. Nothing connects them to a close path. No test mounts `CloseConfirmationDialog`. + +Also genuinely ungated: `AgentActivityModal.tsx:281` (`closeRow` → `workspace.closeSession(...)`) kills a session — including a running one, including a linked-children cascade — with no confirmation at all. The plan requires confirmation "from every source, including buttons and shortcuts." + +Secondary: `grantStillMatches` (`closeConfirmation.ts:103`) has three tests asserting stale grants are rejected, and **no production caller**. `closeFocused` computes `targetId` from the pre-`await` snapshot and never revalidates it after the dialog resolves; `targetStillValid` exists and is not called. + +--- + +### 6. Close Old Agents' "re-enumerate before every kill" re-reads a frozen snapshot + +`CloseOldAgentsModal.tsx:277-321`. The comment promises *"RE-ENUMERATE BEFORE EVERY KILL, not once after confirmation … agents finish, new ones spawn, and one of the idle agents in the list can wake up."* + +But `buildCloseTargets(workspace)` reads `ws.state.sessions` and `ws.runtimes` off the `workspace` object captured in the `useCallback` closure at click time. `workspaceState` and `workspaceRuntimes` are replaced immutably by zustand (`app-state/workspace/slice.ts:43-46`) and surfaced as per-render values (`workspace/hook/index.ts:95-97`), so the closure's copy never changes during the `await` loop. `narrowGrantToCurrent([target], current)` is therefore comparing the grant against the same snapshot the grant came from: it can never drop a woken agent and can never skip an already-closed one. The hardening is inert. There is no test for this file. + +The fix is a live read (`useAppStore.getState()`), not the captured prop. + +--- + +### 7. All of Phase 2 (`resolveInvocation.ts`) is dead code with 230 lines of tests + +`resolveCommandInvocation`, `resolveCommandTarget`, `resolveCommandAvailability` and `targetStillValid` have **zero production callers**. `CommandDef.targetKind` is declared on no command; `CommandDef.unavailableReason` is declared on no command; `CommandDef.risk` is declared on no command (0 matches in `features/*/commands/*.ts`). The palette still calls `command.getState(ctx)` directly from `registry.ts:182` and never renders a disabled row. + +This is distinct from the documented `run`-threading deferral: the module isn't partially wired, it's entirely unreferenced. Every test uses a synthetic `base: CommandDef` fixture, so the suite would pass identically if every real command's metadata were wrong or absent. The plan's "Stable target" and "Unavailable presentation" (hide vs. disable) acceptance rows are unmet. + +--- + +### 8. Rendering Debug Mode silently lost its danger badge, and the test was updated to bless the loss + +`sessionCommands.renderer.test.ts:93-104`. The original assertion was `{label:'On', tone:'danger'}` with the comment *"The red On badge is a safety signal, not decoration: while active the mode captures clicks before ordinary controls."* The new assertion accepts a plain `toggle` and re-writes the comment to *"the warning moved from a danger colour into a detail string."* + +`describeCommandState` (`commandState.ts:122-141`) can **never** return `danger` for a `kind: 'toggle'` — only `status('error', …)` is danger. The detail string surfaces solely as an HTML `title=` tooltip (`CommandPalette.tsx` `CommandStateBadge`). So a visible red safety signal became a hover tooltip. The plan's audit row for `toggle-rendering-debug-mode` says explicitly: *"retain danger semantic state."* This is a characterization snapshot updated to match what the code now does rather than what the plan requires. + +--- + +### 9. `savedSessionListing` is declared, asserted, and consumed by nothing + +`getProviderFeatures` is read at four sites in `sessionCommands.ts` (`switchTargets`, `transcriptRewind`, `verifiedExternalResumeCommand`, `transcriptDuplicate`). `savedSessionListing` has no consumer anywhere. `resume-session` (`tabCommands.ts`) has no `when` guard at all and still runs `ui.enterResumeMode()` unconditionally. + +`providerFeatures.test.ts:37-43` and `:68` assert `opencode.savedSessionListing === false` under the heading *"leaves OpenCode unavailable for every unsupported operation"* — for Resume, that is asserted, not true. Plan Phase 5 item 2 lists Resume first. + +--- + +### 10. `keybindingBaseline.test.ts` calls itself load-bearing while comparing two hand-authored tables + +- `:280-311` — *"THE LOAD-BEARING ASSERTION … A chord that silently stopped working would fail here."* It compares the literal `BINDING_BASELINE` array (same file) against `resolveEffectiveKeybindings({})`, which is `buildDefaultKeybindings()` — another hand-authored literal. Two constants agreeing. Given finding #3, chords *have* silently changed behaviour and this passes. +- `:339-389` — the whole "recorded authority drift" block asserts properties **of `BINDING_BASELINE` itself** (filter it, compare to a hardcoded list). Unfalsifiable except by editing the table. +- `:340` — titled *"lists exactly the six commands…"*, expects an array of **five**. Nobody read it. + +--- + +### 11. The Settings conflict checker hardcodes `context: 'global'`, defeating the mechanism `resolve.ts` documents + +`CommandKeybindingsRow.tsx:181` always passes `context: 'global'` to `findBindingOwners`, and calls `resolveEffectiveKeybindings(overrides, defaults)` without the `contextForCommand` resolver. `resolve.ts:96-131` writes 35 lines explaining why that resolver exists and gives the exact failing example — *"unbind nav-up, then assign Alt+K to the grid-only rotate-layout … a 'global' context reports it as overlapping Dispatch and rejects the binding."* That is precisely what the UI does. The parameter has no caller that supplies it. + +`editor.test.ts:24-82` mirrors the bug (every case passes `context: 'global'`) rather than catching it. Also unexercised: the `applyReplace` path is hand-modelled in the test (`editor.test.ts:90-94`) rather than invoked, so a divergence in the component wouldn't fail; `syntaxError` state is only ever set to `null`, so that error surface is dead. + +--- + +### 12. Per-keystroke cost and auto-repeat coalescing on the new routed path + +- `routedCommandForEvent` calls `buildDefaultKeybindings()` on **every keydown** (`useKeybinds.ts:222`) — 25 entries constructed and `normalizeKeybinding`-parsed per keystroke, including every character typed into a composer, since `keybindingFromEvent` returns non-null for bare letters. +- Every routed chord goes through `requestCommandInvocation` → `commandPaletteOpen: true` (`uiShell/slice.ts:60`) → mounts `OpenCommandPalette`, which assembles the ~76-callback `commandContext` and runs `buildCommandRegistry` over all 98 commands (`CommandPalette.tsx:718`), dispatches, then unmounts. This now happens on `Alt+H/J/K/L` pane navigation and `Cmd+[`/`Cmd+]` — the exact cost #494 restructured the palette to avoid. The store comment calls a keypress "equally rare and equally intentional" as a menu click; navigation chords are neither. +- The store holds a single `pendingCommandInvocation`. Two keydowns landing before React flushes coalesce to one dispatch, so held-down navigation drops repeats. `e.repeat` is not filtered. + +No test covers any of this. + +--- + +### 13. The retired-feature comment lost its terminator and now mis-documents a live setting + +`app-state/settings/types.ts:344-355`: removing `dispatchProjectTerminal` deleted the field *and* its closing `*/`. The stale block describing the removed Dispatch project terminal now swallows the opening `/**` of the next doc comment and terminates on `autoSendPromptSuggestion`'s `*/`. It compiles, so nothing catches it — but the commit is titled "remove Attach Project Terminal to Dispatch end to end" and the plan lists "stale migration comments" as in scope. + +--- + +### 14. Tautologies and coverage gaps worth cleaning up + +- `scripts/check-command-keybindings.mts:146-152` — "Context sanity" checks `!contextsOverlap(entry.context, entry.context)`; `contextsOverlap` returns `true` immediately on `a === b` (`defaults.ts:49`). The rule can never fire. `reservations.test.ts:31-33` asserts the same identity. +- `catalog.test.ts:214` (`98 - 4 === 94`) and `:315` (`98 + 5 - 1 === 102`) are arithmetic on a value asserted three lines earlier. +- `editor.test.ts:145-150` — *"offers a row for every categorized built-in command"* compares `catalog.filter(c => c.category).length` to `catalog.length`. It duplicates `taxonomy.test.ts:35` and says nothing about `CommandKeybindingsRow` producing rows. +- `commandState.test.ts:94-102` regex-matches `getState.toString()` for `tone: '…'`. `CommandState` no longer has a `tone` field, so `tsc` already forbids it; the regex would miss `tone:"accent"`, a variable, or a helper. +- `routing.test.ts:63-68` — *"keeps palette-self commands out of the close-after-run rule"* asserts `PALETTE_SELF_EXCLUDED_COMMAND_IDS.has('open-command-palette')`, a Set literal containing that exact string. Deleting the guard from `CommandPalette.tsx` (which is the rule) breaks nothing. +- **No test mounts** `CommandPalette`, `CommandKeybindingsRow`, `CloseConfirmationDialog`, or `CloseOldAgentsModal` — every new UI surface in the PR. diff --git a/.review/pr608-whole-pr-claude-a.md b/.review/pr608-whole-pr-claude-a.md new file mode 100644 index 00000000..ec4b4109 --- /dev/null +++ b/.review/pr608-whole-pr-claude-a.md @@ -0,0 +1,112 @@ +# PR608 whole-PR (Claude A) (claude) + +sessionId: d66d9e34-388d-4231-8754-e0d99f9c78ef + +--- + +Review complete. `npx tsc -b` exits 0, `npm run check:keybindings` passes, and all 19 new/changed test files (307 tests) are green — every finding below is a latent defect that the suite does not catch. + +--- + +## 1. `close_agent` MCP tool is dead in production — nothing ever issues a grant + +`src/main/agentManagement/AgentManagementBridge.ts:265` refuses the close unless `this.closeGrants.consume(caller, target)` returns true. `issueCloseGrant` (`:240`) has **zero production call sites** — `grep` finds it only in `AgentManagementBridge.test.ts` (4 hits). There is no IPC handler (`src/main/ipc/agentManagement.ts` registers only `agent-management:response`), no renderer confirmation, no preload surface. + +**Failure:** user enables Agent Management MCP on a Codex agent, says "close agent X", the model calls `agent_management_close_agent`. `consume` finds no grant → throws *"no user authorization for this agent"*. This happens 100% of the time, forever. The prose the plan called unenforceable (`createBuiltInMcpServer.ts:301`) is still shipped and now describes a tool that cannot run at all. + +The tests are green because they call `bridge.issueCloseGrant('caller','agent-1')` directly (`AgentManagementBridge.test.ts:143,280,391,410`) — they prove the grant *mechanism* and cannot observe the missing *wiring*. The plan required "a short-lived user-issued caller/target authorization **or** renderer confirmation"; neither exists. + +## 2. Configured-chord routing runs before the editor-ownership bailout — ⌘W in the Global Editor kills a workspace session + +`useKeybinds.ts:364` evaluates `routedCommandForEvent` and returns at `:369`. The editor bailout is at `:442`. Neither `EditorWorkbench` nor `MonacoFileEditor` is an app-interaction owner (they carry `data-global-editor-input-owner`, not `data-agent-code-interaction-owner`), so `hasAppInteractionOwner()` at `:310` is false. + +**Failure:** open the Global Editor, click into Monaco, press ⌘W to close the file tab. `keybindingFromEvent` → `Cmd+W` → matches `close-pane` (`defaults.ts:96`, in `ROUTED_COMMAND_IDS`) → `preventDefault` + gateway → `close-pane` has `surface:'session'`, no `when`, no render policy → admitted → `workspace.closeFocused()` kills the focused agent behind the editor. ⌘[ / ⌘] likewise now switch tabs instead of running Monaco outdent/indent. + +This directly falsifies the approved-overlap justification recorded at `reservations.ts:197-201`: *"useKeybinds bails out for editor-owned targets, so the workspace's close-pane handler cannot fire while editor chrome has focus."* The static checker passes **because** it trusts that text. + +## 3. The runtime router ignores `BindingContext` — Dispatch keyboard navigation is dead + +`routedCommandForEvent` (`useKeybinds.ts:216-227`) matches `entry.bindings.includes(binding)` and never reads `entry.context`. The Dispatch row/lane block sits at `:746-790`, unreachable for those chords. + +**Failure:** enter Dispatch Mode, press ⌥↓ (or ⌥J) to move the row selection. Binding resolves to `Alt+Down` → `nav-down` (`defaults.ts:112`, context `'grid'`) → routed → gateway → `surfaceAvailable('grid', ctx)` returns `!dispatchModeEnabled` = false → `{status:'unavailable'}` → **nothing happens**. Same for ⌥↑/⌥K, and ⌥←/⌥→/⌥H/⌥L (tiled lane switching). The reservation registry explicitly declares these as `'Dispatch row and lane selection'` in the `dispatch` context (`reservations.ts:59-62`) and the disjointness matrix exists precisely to allow this — but the router that would honour it doesn't consult contexts. + +Same root cause silently removes ⌥D/⌥⇧D/⌥T/⌥⇧T/⌥C/⌥⇧C in Dispatch (all `context:'grid'`, `surface:'grid'`), which previously created detached agents through `splitFocused`'s heavily-documented Dispatch branch (`pane.ts:229-303`). `paneCommands.ts:50-51` still asserts *"Power-user keybinds (⌥D etc.) still fire in Dispatch"* — no longer true. + +## 4. Unbinding or rebinding a command leaves the old chord fully live + +Every legacy hard-coded branch survives below the routed lookup: ⌘T (`:648`), ⌘⇧R (`:658`), ⌘⇧W (`:670`), ⌘W (`:675`), ⌘[ / ⌘] (`:680`,`:685`), ⌘⌥E (`:378`), ⌘P (`:396`), ⌘⇧F (`:418`), ⌥D/⌥T/⌥W/⌥HJKL/⌥arrows (`:843-928`), `End` (`:931`). They are shadowed only while `routedCommandForEvent` returns non-null. + +**Failure:** in Settings → Commands & Shortcuts, remove ⌘W from Close Focused Session (writes `commandKeybindingOverrides['close-pane'] = []`). Row now reads "Not assigned". Press ⌘W → routed lookup returns null → falls through to `:675` → `void workspace.closeFocused()` → the pane closes anyway. Identically, rebind `new-tab` to ⌘⌥N: ⌘⌥N works *and* ⌘T still opens a new tab via `:648`. + +This is exactly acceptance item 15 ("…without leaving the former chord active"). `routing.test.ts` cannot catch it — it asserts only on `resolveEffectiveKeybindings` output and says so in its own docstring. + +## 5. Phase 5 capability gates landed on the wrong commands; OpenCode keeps two of the five unsupported operations + +| Command | Plan-required gate | Shipped gate | Effect | +|---|---|---|---| +| `switch-provider` (`sessionCommands.ts:871`, `when` at `:895`) | `switchTargets` | `isAgentProviderKind(kind)` | **OpenCode still gets Switch Provider** | +| `copy-resume-command` (`:741`, `when` at `:764`) | `verifiedExternalResumeCommand` | `isAgentProviderKind(kind) && providerSessionId` | **OpenCode still gets Copy Resume Command** | +| `view-prompts` (`:71`, `when` at `:85`) | transcript/history | `getProviderFeatures(kind).switchTargets.length > 0` | OpenCode loses View Prompts; feature now depends on switch edges | +| `reload-agent` (`:632`, `when` at `:658`) | resumability | `verifiedExternalResumeCommand` | in-app restart depends on an external CLI template being verified | +| `resume-session` (`tabCommands.ts:51`) | `savedSessionListing` + focused cwd | **no `when` at all** | Resume on an OpenCode pane still opens the empty modal the audit named | + +The comments prove the shuffle: the *switch-edge* rationale is pasted verbatim into `view-prompts` (`:82-84`) and the *"hands the user a shell command"* rationale into `reload-agent` (`:654-656`). Only `rewind-to-prompt` and `duplicate-agent` got their correct predicates. + +**Failure:** focus an OpenCode pane, open the palette with reveal-all, type "switch" → *Switch Provider* is an ordinary enabled row → `workspace.switchFocusedProvider()` with `switchTargets: []`. Type "resume" → *Copy Resume Command* copies an unverified CLI string. Meanwhile "prompts" shows nothing. + +`providerFeatures.test.ts` asserts only the `FEATURES_BY_KIND` table. No test anywhere evaluates a command's `when` against an OpenCode context (`grep opencode` across the command-palette tests hits only the two generated split ids). The acceptance criterion "OpenCode Resume/Rewind/Duplicate/Switch/Copy Resume are unavailable" is **tested vacuously**. + +## 6. `closeFocused` confirms the wrong target set when the pane is the tab root + +The gate at `pane.ts:1106-1114` expands via `expandSessionCloseTargets` — target + linked descendants only. But when `parentInfo` is null (root pane), `:1176-1178` builds `detachedTabChildren(closeSnapshot, tab.id)` and `:1217` kills all of them. + +**Failure:** a tab with one visible pane (idle) and six detached Dispatch agents parked against it. Press ⌘W. Expansion returns `[thatOnePane]`, `targets.length === 1 && !live` → `{required:false}` → no dialog → the tab collapses and **all seven** sessions are killed. `expandTabCloseTargets` — which exists for exactly this and is used correctly in `tab.ts:106-108` — is not called here. Same gap when `closeFocused` delegates to `closeSession` for a Dispatch/related-child target whose leaf is a tab root. + +## 7. Plain `End` is hijacked from text editing + +`jump-latest-message` ships bare `End` with `context:'feed'` (`defaults.ts:130`) and is in `ROUTED_COMMAND_IDS`. The routed lookup at `:364` precedes the legacy handler at `:931`, which was the only place `!isTextEditingTarget(e.target)` was checked. + +**Failure:** type a multi-line prompt in the composer of a Claude pane, press `End` to jump to end of line. Binding `End` → `jump-latest-message` → `preventDefault()` → gateway admits (session is non-terminal with a rendered feed) → `scrollFocusedToLatest()`. The caret does not move and the feed scrolls to the bottom. + +## 8. Close Old Agents' "re-enumerate before every kill" is a no-op + +`CloseOldAgentsModal.tsx:315` calls `buildCloseTargets(workspace)` inside the sequential loop, where `workspace` is the object captured by the `useCallback` closure at click time. `useWorkspace` builds `{state, runtimes, …}` fresh each render from zustand selectors (`hook/index.ts:95-98, 834-836`) — that is precisely why `pane.ts` reads `refs.stateRef.current` instead. The closure's `workspace.state` / `workspace.runtimes` never change during the loop. + +**Failure:** approve closing 12 idle agents; agent #9 starts streaming during kill #3. `narrowGrantToCurrent([target], current)` compares the grant against the click-time snapshot, where #9 is still idle → not skipped → killed mid-turn. `outcome.skipped` can never be non-empty from state change. `closeConfirmation.test.ts` tests `narrowGrantToCurrent` as a pure function, so the integration bug is invisible. + +## 9. `closeSession` has no confirmation gate — the Agent Activity modal closes running/cascading sessions silently + +Confirmation was added to `closeFocused` and `closeTab` only. `AgentActivityModal.tsx:281` (`closeRow`, reachable from the row button at `:449` and Enter at `:347`) calls `workspace.closeSession(row.sessionId)` directly, and `closeSession` (`pane.ts:1283`) cascades linked children (`:1294`) and tab-detached children (`:1333-1336`) with no prompt. Phase 7 item 4 required confirmation "from every source, including buttons". + +**Failure:** open Agent Activity, click × on a mid-stream agent that has two linked review children → three sessions die immediately, no dialog, no count. + +## 10. Settings binding capture hardcodes `context: 'global'`; `contextForCommand` is dead code + +`CommandKeybindingsRow.tsx:181` passes `context: 'global'` for every command, and `resolveEffectiveKeybindings` is called at `:83`/`:93` without the third argument. `grep contextForCommand` finds only its own declaration and use inside `resolve.ts:107,137` — no caller ever supplies it. + +**Failure:** the scenario `resolve.ts:126-131` documents as the reason the parameter exists. Unbind `nav-up`, then assign ⌥K to the grid-only `rotate-layout`. `findBindingOwners` with `context:'global'` matches the `dispatch`-context reserved entry (`contextsOverlap('dispatch','global')` is true), the save is blocked, and because the owner is `kind:'reserved'` the UI offers no Replace — only *"Reserved by the app — pick a different chord."* The whole context system is inert for user edits. + +## 11. Save Editor File cannot actually be rebound + +`defaults.ts:125` declares `save-editor-file` → `Cmd+S` and the comment promises *"removing those two hard-coded paths is what makes rebinding real."* They were not removed: `MonacoFileEditor.tsx:239` still registers `KeyMod.CtrlCmd | KeyCode.KeyS`, and `save-editor-file` is absent from `ROUTED_COMMAND_IDS`. + +**Failure:** rebind Save Editor File to ⌘⌥S. Settings and the palette show ⌘⌥S; pressing it does nothing; ⌘S still saves. `keybindingBaseline.test.ts` explicitly `continue`s past `owner !== 'useKeybinds'`, so this is partially disclosed — but it contradicts a stated acceptance criterion and the file's own comment, and the same is true of the ⌘W editor action at `MonacoFileEditor.tsx:246`. + +## 12. Every keystroke rebuilds the default binding table; every routed chord mounts the whole palette + +`routedCommandForEvent` calls `buildDefaultKeybindings()` (~35 `parseKeybinding` calls plus provider-registry reads) on **every** keydown, including plain typing — `keybindingFromEvent` returns non-null for bare letters. And `requestCommandInvocation` (`uiShell/slice.ts:60-64`) sets `commandPaletteOpen: true`, so ⌥H/⌥J pane navigation now mounts `OpenCommandPalette`, assembles the ~76-dependency `commandContext`, runs `buildCommandRegistry` over 98 definitions, mounts a Radix `DialogContent`, and unmounts — per keypress. The `#494` comment in `CommandPalette.tsx:194-198` argues this cost is acceptable *"a menu click is rare and intentional"*; that premise no longer holds. I could not measure the frame impact without running the app, so treat the magnitude as unverified — the per-keystroke work itself is confirmed by reading. + +## 13. Nothing rejects a modifier-less binding in the capture UI + +`CommandKeybindingsRow.tsx:171-206` accepts any `keybindingFromEvent` result; `parseKeybinding` allows zero modifiers (`jump-latest-message` relies on it). Bare letters have no reservation owner, so binding `A` to `close-pane` saves cleanly. Combined with #3/#7 (the router applies neither context nor a text-editing guard), typing "a" into a composer would then close the pane. Requires deliberate user action, hence low — but it is the same missing guard. + +--- + +## Clean + +- **Picker visibility never gates execution.** `isVisibleInPicker` has exactly two consumers — `commandVisible` in `registry.ts:132` and `listPickerCommandMeta`. `executeCommand.ts` and `canDispatchCommand` resolve from `builtInCommandCatalog` and call only `commandApplicable`; the native-menu path in `CommandPalette.tsx:940` goes through the gateway. The plan's highest-severity original defect is genuinely fixed, including the `assertNever` fallthrough. +- **Persistence coercion.** Retired-key omission (`persistence.ts:56,250`), the absent/`[]`/non-empty tri-state, the "non-empty-but-all-unparseable → drop the key" rule, retired-ID pruning with unknown IDs preserved, and the `8 → 10` version bump all hold. I could not construct a data-loss or resurrection scenario. +- **The normalizer.** Numpad exclusion, trailing-`+` rejection, multi-step rejection, `event.code` for Option-letters/punctuation, and display-form purity are all correct, and `displayKeybinding` failing soft on a malformed value is right. +- **`closeConfirmation.ts` as a module.** Transitive expansion with a cycle guard, ID-set (not count) grant comparison, and the started-working drop in `narrowGrantToCurrent` are sound. The defects are at the two call sites (#6, #8), not in the policy. +- **Kill Buried** (`pane.ts:1763-1783`) — unconditional second confirmation before any mutation, using the shared liveness rule. Correct. +- **Catalog integrity** — the 98-ID ordered snapshot, generated-provider parity, the six-member navigation group, and the group-before-override precedence are all asserted non-vacuously. diff --git a/.review/pr608-whole-pr-claude-b.md b/.review/pr608-whole-pr-claude-b.md new file mode 100644 index 00000000..6ee3a88c --- /dev/null +++ b/.review/pr608-whole-pr-claude-b.md @@ -0,0 +1,112 @@ +# PR608 whole-PR (Claude B) (claude) + +sessionId: 6c2ff61d-59df-4972-80f3-86ffb3efebd2 + +--- + +I reviewed the whole branch: read the plan, all 20 commits, and the ~50 changed files; verified claims against implementations; ran `tsc -b` (exit 0), `npm run check:keybindings` (OK), and the 22 touched test files (335 tests pass at `--maxWorkers=4`). + +The headline: the *structural* work (catalog split, gateway, picker-visibility separation, normalizer, persistence/coercion, native-menu fix, derived tone) is genuinely built and matches its comments. The *wiring* is where it breaks — three subsystems have authoritative-sounding gates with no caller, and the keybinding router was inserted above the gate that protected the editor. + +--- + +## Ranked findings + +### 1. CRITICAL — Agent Management MCP `close_agent` is dead: the grant has no issuer +`src/main/agentManagement/AgentManagementBridge.ts:265` refuses every close unless `closeGrants.consume(...)` returns true. `issueCloseGrant` (`:240`) has **zero product callers** — `grep -rn "issueCloseGrant" src/` returns only the bridge itself and three test call sites. There is no IPC handler, no renderer confirmation, no menu path. `revokeCloseGrantsForSession` (`:245`) is dead too. The tool is still registered in `src/mcp/runtime/createBuiltInMcpServer.ts`. + +**Failure:** user enables Agent Management MCP on a Codex agent and says "close agent B". The model calls `close_agent`; it throws `close_agent refused: no user authorization for this agent`. There is no action the user can take to authorize it. The feature is permanently unusable, not gated. + +The comment at `closeGrant.ts:12` ("A grant is issued by a USER ACTION") and commit `55e57322` ("make the … permission enforceable") both describe an issuance path that does not exist. Default-is-DENY is true; it is *only* deny. + +### 2. HIGH — Two provider capability gates are attached to the wrong commands; the two commands Phase 5 exists to fix are still ungated +- `sessionCommands.ts:85` — `view-prompts.when` → `getProviderFeatures(kind).switchTargets.length > 0`, with a comment about switch edges ("*'Can switch' is meaningless without a destination*"). +- `sessionCommands.ts:658` — `reload-agent.when` → `verifiedExternalResumeCommand`, with a comment about pasting a shell command into a terminal. +- `sessionCommands.ts:764` — `copy-resume-command.when` is still `isAgentProviderKind(kind)`. +- `sessionCommands.ts:895` — `switch-provider.when` is still `isAgentProviderKind(kind)`. +- `savedSessionListing` is consumed nowhere; `resume-session` (`tabCommands.ts:51`) has no `when` at all. + +The two gates were transplanted onto their neighbours. **Failures:** (a) OpenCode pane → *Copy Resume Command* is enabled and copies `opencode --session ` (`opencode/identity.ts:13`) — the unverified guess `verifiedExternalResumeCommand: false` exists to suppress; the user pastes it into a terminal. (b) OpenCode pane → *Switch Provider* is enabled and toasts "OpenCode panes can't switch provider yet" (`provider.ts:71-77`) — verbatim the "appears enabled and gets nothing" defect. (c) An OpenCode agent dies → *Reload Agent* is **absent**, because OpenCode has no verified external CLI string, which has nothing to do with in-app reload. (d) *View Prompts* is absent on OpenCode because OpenCode has no switch edge; the plan's matrix says it should be available where a feed/history exists. + +`featureCapabilities.ts:11-13` names all five commands as fixed. Two are, two are not, and two unrelated commands were broken. `providerFeatures.test.ts` only pins the capability *table* — it never asserts which command consumes which capability, which is why this passes. + +### 3. HIGH — Routed chords run *before* the Global Editor ownership gate +`useKeybinds.ts:364-369` dispatches routed commands; `editorOwnsTarget` is computed at `:440`. The gate that previously stopped ⌘W/⌘[/⌘]/every Option chord from reaching the workspace while focus is inside `[data-global-editor-input-owner]` is now downstream of the router. + +**Failures:** +- Focus in the Explorer/file-tab strip, press ⌘W → `close-pane` dispatches (killing the underlying agent session), and `EditorWorkbench.tsx:230` bails because `event.defaultPrevented` is already true, so the file is *not* closed. `EditorWorkbench.tsx:234-235` says letting this through "would terminate the underlying agent pane" — which is now the behaviour. +- In Monaco, ⌘[ / ⌘] switch tabs instead of outdent/indent. +- Typing ⌥D in Monaco to produce `∂` also splits a pane; ⌥W closes the focused session; ⌥H/J/K/L and ⌥arrows navigate the hidden grid. `useKeybinds.ts:445-448` states exactly this must not happen. +- The `fullscreenEditorOwnsWorkspace` swallow (`:461-480`) is bypassed the same way. + +This falsifies four of the five `APPROVED_OVERLAPS` justifications (`reservations.ts:186-226`) — the ⌘W, ⌘[ and ⌘] entries all assert "useKeybinds returns early / bails out for editor-owned targets … Exactly one owner is live for a given focus." `check:keybindings` passes because rule 6 only checks the reason string is ≥40 characters, never that it's true. + +### 4. HIGH — Rebinding or unbinding leaves the old hard-coded chord live and gateway-free +The diff removes only three legacy branches (⌘⇧P, ⌘⇧E, ⌘⇧T). Still hard-coded below the router: ⌘T, ⌘⇧R, ⌘⇧W, ⌘W, ⌘[, ⌘], ⌘P, ⌘⇧F, ⌘⌥E, ⌥D/⌥⇧D, ⌥T/⌥⇧T, ⌥C/⌥⇧C, ⌥W, ⌥H/J/K/L, ⌥arrows, bare `End` (`useKeybinds.ts:378-939`). They are unreachable *only while the shipped default still matches*. + +**Failure:** Settings → Keyboard Shortcuts → remove ⌘W from "Close Focused Session" (writes `[]`, the documented "explicitly unbound"). `routedCommandForEvent` no longer matches, execution falls to `:675-679` `if (k.toLowerCase() === 'w' && !shift) { void workspace.closeFocused() }`. The pane still closes — on a chord the user deliberately deleted, bypassing admission, history and single-flight. Same for rebinding `split-vertical` off ⌥D, `next-tab` off ⌘], etc. + +`ROUTED_COMMAND_IDS`' docstring says the router hands these to the gateway "**instead of** calling a workspace action directly." It is *in addition to*, with the direct path as a live fallback. + +### 5. MEDIUM-HIGH — `save-editor-file` is advertised as rebindable and isn't routed at all +Declared at `defaults.ts:125` (⌘S, context `editor`) and given a Settings row with an editable/removable chip, but absent from `ROUTED_COMMAND_IDS`. Monaco's `addAction` (`MonacoFileEditor.tsx:243-246`) and EditorWorkbench's bubble handler still own ⌘S. **Failure:** user rebinds Save to ⌘⌥S — nothing happens on ⌘⌥S, and ⌘S still saves. `defaults.ts:121-125` is honest ("removing those two hard-coded paths is what makes rebinding real"); the UI is not. + +### 6. MEDIUM-HIGH — `src/mcp/shared/closeGrant.ts` is a binary file to git +Line 55 embeds a **literal NUL byte** (offset 2697) as the map-key separator instead of `\u0000`: +``` +const key = (caller, target) => `${caller}${target}` +``` +`git diff --stat` reports `Bin 0 -> 4312 bytes`; `file` reports `data`. **Failure:** the module is unreviewable in the PR diff on GitHub — which is how a security-relevant authorization file (finding #1) shipped with no diff review at all; `git merge`/`rebase` cannot 3-way-merge it and will raise a binary conflict; `git log -p`/`blame` are useless on it. Fix is one character class: write `\u0000` in the template literal, behaviour identical. + +### 7. MEDIUM — Close Old Agents does not re-enumerate; it re-reads a frozen snapshot +`CloseOldAgentsModal.tsx:315` calls `buildCloseTargets(workspace)` per iteration, but `workspace` is closed over from the render that created `closeMatchingAgents`. `WorkspaceContext.tsx:12-17` documents that the workspace object is a fresh per-render value and "a stale snapshot is a correctness bug, not a perf win". Every iteration therefore returns an identical result; the loop is blind to everything that changes while it runs. + +**Failure:** preview lists 12 idle agents; during the 12 sequential `closeSession` round-trips one of them starts streaming; `narrowGrantToCurrent` reads the frozen `live: false`, does not skip it, and it is killed mid-turn. The comment at `:305-314` claims precisely this is prevented ("one of the idle agents in the list can wake up and start working. A grant checked once at the top would authorize killing it"). Fix: read through a live ref / `getState()`, not the captured object. + +### 8. MEDIUM — Single and tab closes never bind the grant to the approved id set +`pane.ts:1086-1114` and `tab.ts:95-111` compute the confirmation from a snapshot taken *before* the await, then commit against that same pre-await snapshot. `grantStillMatches` (`closeConfirmation.ts:103`) is documented as "what stops a stale grant authorizing work the user never saw" and has **no product caller** (tests only). Meanwhile `closeLinkedChildren` (`pane.ts:746-754`) re-reads `refs.stateRef.current.sessions` at commit time. + +**Failure:** ⌘W on a linked parent; dialog says "This closes 2 sessions"; while it is open the parent's orchestration MCP spawns a third linked child; user clicks "Close 2"; three sessions die. The plan requires "bind confirmation to exact expanded IDs" and "a stale preview must not authorize a later changed target set." + +### 9. MEDIUM — Phase 2's typed target/availability/safety layer has no product consumers +`resolveInvocation.ts` is imported only by its own test (`grep` for `resolveCommandInvocation|resolveCommandTarget|resolveCommandAvailability|targetStillValid` outside the module and its test returns nothing). No catalog command declares `targetKind`, `risk`, or `unavailableReason`. `buildCommandRegistry` still calls `command.getState(ctx)` directly (`registry.ts:182`). + +Consequences: no command can ever render as "disabled with a reason" (the plan's product decision #1 and the whole `presentation: 'hide' | 'disable'` axis); no target is pinned anywhere in the running app; no mutation boundary calls `targetStillValid`. Beyond the acknowledged run-threading gap, `resolveInvocation.ts:141-146` claims the pinned identity "is currently useful for DISPLAY (the badge and the row describe a known session)" — the palette does not use it for the badge either. + +### 10. MEDIUM — The binding-context matrix is unreachable from the only UI that uses it +`CommandKeybindingsRow.tsx:181` hardcodes `context: 'global'` for every command being edited, and no caller anywhere passes `resolveEffectiveKeybindings`' `contextForCommand` resolver (`:83`, `:93`; `useKeybinds.ts:222`; `registry.ts:158` — all 1–2 args). `resolve.ts:99-107` states "Callers that already hold the catalog (Settings, the runtime router) pass a resolver" — neither does. `resolve.ts:126-131` spells out the exact case that should work and doesn't: unbind `nav-up`, assign ⌥K to the grid-only `rotate-layout` → rejected as overlapping Dispatch, because 'global' overlaps everything. Direction is safe (over-blocking), but the documented promise is not delivered. + +### 11. MEDIUM — Every routed chord mounts and destroys the entire command palette +`uiShell/slice.ts:60-63` sets `commandPaletteOpen: true` for **every** keybinding invocation; `CommandPalette.tsx:199` then mounts `OpenCommandPalette`, which subscribes ~76 selectors, builds the full registry over 98 commands, resolves effective bindings, reads recent history from localStorage and mounts a Radix Dialog — then unmounts in `useLayoutEffect`, plus a localStorage write from `recordCommandUse`. + +`uiShell/types.ts` justifies this with "A keypress is equally rare and equally intentional [as a menu click]." That is false for the routed set: ⌥H/J/K/L pane navigation, ⌘[/⌘] tab switching and `End` are held/repeated chords, and the handler never checks `e.repeat`. **Failure:** holding ⌥J to walk a grid performs one full palette mount/unmount + localStorage read+write per key repeat, where the pre-PR path was a direct `workspace.navigate('down')`. + +### 12. LOW-MEDIUM — Phase 6/7 items the commits present as complete +- No `getState` for `tiled-dispatch`, `toggle-remote-panel` (`remoteCommands.ts`), `toggle-session-recording` (`sessionCommands.ts:994`), `dispatch.color-flag.set` — the plan's Phase 6 item 3 names exactly these four. +- No pending/error lifecycle for MCP toggles, reload, switch, duplicate, recording (Phase 6 item 5). `status()` is used once, for Caffeinate (`layoutCommands.ts:125`); `sessionCommands.ts:4` imports `status` and never uses it. `commandState.ts:20-21` lists "MCP toggles and reload had no pending state at all" among the defects the module addresses. +- Dangerous Agents got a metadata badge and a copy fix only; the plan's required enable confirmation with the exact live reload set, single-flight, and Mixed/rollback (Phase 7 item 1, High in the plan's own destructive table) is absent. + +### 13. LOW — `dispatchProjectTerminal`'s docstring survived its field and swallowed the next one +`settings/types.ts:344-361`: the field was deleted but the comment's closing `*/` went with it, so the block now runs to line 361 and absorbs the `/**` intended for `autoSendPromptSuggestion` (`:362`). A removed feature is still documented as present ("Dispatch Mode mounts a project terminal pane beside the agent list"), and `autoSendPromptSuggestion` lost its JSDoc entirely. Contradicts `dcf8d721` ("remove … end to end") and the plan's stale-comment requirement. (The `workspace/types.ts:322` and `layoutCommands.ts:60` notes *are* correctly updated as historical — not findings.) + +### 14. LOW — assorted +- `Kill Buried Session` passes `reason: 'running'` (`pane.ts:1772`), so `CloseConfirmationDialog.tsx:47` titles it "Close a working agent?" even for an idle buried session. +- `closeConfirmationFor` (`closeConfirmation.ts:81`) derives `reason` from liveness (`live.length > 0 ? 'cascade' : 'bulk'`), not from cascade-vs-bulk as the field's own docs state. Not user-visible today — only `'running'` is branched on. +- `setCommandKeybindings` (`resolve.ts:174-183`) takes a `defaults` parameter it never reads; all three call sites pass it. +- `useKeybinds`' `onCommandPalette` parameter is now unused while `App.tsx:90` still passes `toggleCommandPalette`; ⌘⇧P no longer closes an open palette (the interaction-owner gate bails first). Escape still works. +- `CommandKeybindingsRow`'s `syntaxError` is only ever set to `null` — the "rejected with an actionable message" path is unreachable. +- The three provider `identity.ts` files are changed by a stray blank line each and nothing else. + +--- + +## Clean, explicitly +- **Normalizer/grammar** (`normalize.ts`) — physical-code matching, numpad rejection, multi-step and trailing-`+` rejection, display-as-pure-projection: all match their comments, all genuinely tested. +- **Persistence/coercion** — retired-key omission, retired-id pruning, unknown-id preservation, `=== true` strictness, sparse absent/`[]`/non-empty semantics, version 8→10: implemented as documented, covered by `retiredKeys.test.ts` and `taxonomy.test.ts`. +- **Catalog extraction and validation** — `catalog.ts`, `findCatalogDefects`, the exhaustive `surfaceAvailable` switch with `assertNever`, frozen ordered array: as described. +- **`pickerVisibility.ts`** — group > per-command override > declared tier precedence, defensive `overrides?.[]`, and genuinely shared with Settings (the old duplicate rule was deleted, not left beside it). +- **Gateway history policy** (`executeCommand.ts`) — records only successful, admitted, user-sourced runs; excludes transient rows; single-flight; no unhandled rejections. Matches every claim. +- **`commandState.ts`** — tone is genuinely derived; no authored tone survives anywhere in the catalog; `describeCommandState` is the single render path for both row and preview. +- **Native-menu resolution** — the plan's headline defect is really fixed: the menu resolves from the full catalog with admission only, and `nativeMenuCommandIds.ts` makes the cross-process contract typed and testable. +- **Verification** — `tsc -b` exit 0; `npm run check:keybindings` OK (25 sets / 12 reservations / 5 overlaps); 22 test files, 335 tests pass. + +The two fixes I'd want before merge regardless of the rest: wire an issuer for the close grant (#1) or don't land the deny, and move the routed lookup below the editor-ownership gate (#3). #2 is a small, mechanical correction with a large behavioural blast radius. diff --git a/.review/pr608-whole-pr-codex-a.md b/.review/pr608-whole-pr-codex-a.md new file mode 100644 index 00000000..b804d1a3 --- /dev/null +++ b/.review/pr608-whole-pr-codex-a.md @@ -0,0 +1,51 @@ +# PR608 whole-PR (Codex A) (codex) + +sessionId: fd59d626-4c58-4c32-9b33-6ef5b37b5602 + +--- + +I found 10 reproducible defects: five high-severity integration/safety failures, three medium, and two low. + +Validation was otherwise green: TypeScript passed, `check:keybindings` passed, and the focused suite passed 301 tests across 18 files. The worktree remains clean. I found no separate defect in catalog enumeration, tier/category assignments, command naming/retirement, keybinding grammar/event normalization, or the provider capability table’s declared values. + +## Ranked findings + +1. **High — Close confirmation authorizes targets that appeared after the dialog opened.** + Files: [pane.ts:1107](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/pane.ts:1107), [pane.ts:1159](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/pane.ts:1159), [pane.ts:746](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/pane.ts:746), [closeConfirmation.ts:103](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/closeConfirmation.ts:103) + The confirmation snapshot is never compared with current state. After approval, `closeLinkedChildren` enumerates children afresh and closes all of them. `grantStillMatches` exists but has no production caller. Concrete scenario: parent `P` and child `C1` produce a “close 2 sessions” dialog; while it is open, orchestration creates `C2`; confirming closes `P`, `C1`, and the never-disclosed `C2`. Likewise, [tab.ts:106](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/tab.ts:106) can preview idle session `A`, then kill it at [tab.ts:145](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/tab.ts:145) even if `A` started working while the dialog was open. + +2. **High — Dangerous Agents immediately destroys/restarts the fleet without confirmation, rollback, or partial-failure state.** + Files: [settingsRegistry.ts:808](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/lib/settingsRegistry.ts:808), [session.ts:1010](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/session.ts:1010), [session.ts:1047](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/session.ts:1047), [session.ts:1082](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/session.ts:1082) + The setting is persisted before reload, with no affected-agent preview. Reload kills each old process before spawning its replacement, swallows spawn errors, then removes failed sessions from tabs/state. Concrete scenario: enable the setting with agents `A` and `B`; `A` respawns, but `spawnSession` for `B` rejects. `B` has already been killed and its pane is deleted, while Settings remains “Enabled” and no Mixed/error/rollback state is exposed. + +3. **High — Removing or rebinding existing shortcuts leaves the old destructive hard-coded shortcuts active.** + Files: [useKeybinds.ts:354](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:354), [useKeybinds.ts:648](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:648), [useKeybinds.ts:670](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:670) + When a configured chord does not match, execution falls through to the legacy branches. Concrete scenario: set `close-pane` to no bindings or rebind it to `Cmd+Alt+W`; pressing the removed `Cmd+W` still reaches line 675 and calls `workspace.closeFocused()`. Rebinding `new-tab` similarly leaves both the new chord and old `Cmd+T` active. + +4. **High — Settings advertises all 98 commands as bindable, but runtime routing accepts only 24 command IDs.** + Files: [CommandKeybindingsRow.tsx:97](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:97), [useKeybinds.ts:181](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:181), [useKeybinds.ts:222](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/tile-tree/useKeybinds.ts:222), [registry.ts:157](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-palette/registry.ts:157) + Runtime probes counted 98 catalog commands versus a 24-ID allowlist. Concrete scenario: assign `Cmd+Y` to `toggle-reader-mode`. Settings persists it and the palette displays `⌘Y`, but pressing it in an eligible agent feed is discarded by `ROUTED_COMMAND_IDS`, so Reader Mode does not toggle. `save-editor-file` is also omitted; its new binding never fires while the old `Cmd+S` remains hard-coded in [EditorWorkbench.tsx:238](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/editor/ui/EditorWorkbench.tsx:238) and [MonacoFileEditor.tsx:236](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/editor/ui/MonacoFileEditor.tsx:236). + +5. **High — The MCP close grant has no production issuer, so every legitimate close request is denied.** + File: [AgentManagementBridge.ts:236](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/main/agentManagement/AgentManagementBridge.ts:236) + Whole-repo search finds `issueCloseGrant` called only by tests. Production `closeAgent` always reaches the single-use `consume` at line 265 with an empty store. Concrete scenario: the user explicitly requests “close agent `agent-1`”; the caller invokes `agent_management_close_agent(caller, agent-1)` and receives `close_agent refused: no user authorization`, with no renderer close request emitted. + +6. **Medium — OpenCode capability declarations are not enforced for Resume, Copy Resume Command, or Switch Provider.** + Files: [featureCapabilities.ts:101](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/providers/shared/featureCapabilities.ts:101), [tabCommands.ts:51](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/commands/tabCommands.ts:51), [sessionCommands.ts:759](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/commands/sessionCommands.ts:759), [sessionCommands.ts:889](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/commands/sessionCommands.ts:889) + Runtime probing with an OpenCode target found all three commands visible and dispatchable despite `savedSessionListing=false`, `verifiedExternalResumeCommand=false`, and `switchTargets=[]`. Resume calls an implementation hard-coded to return `[]` at [registry.main.ts:167](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/providers/registry.main.ts:167); Copy Resume copies the explicitly unverified `opencode --session …` template; Switch Provider proceeds until [provider.ts:71](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/workspace/hook/actions/provider.ts:71) refuses it with a toast. + +7. **Medium — “Replace” can overwrite chords that the collision engine labels non-replaceable.** + Files: [CommandKeybindingsRow.tsx:179](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:179), [CommandKeybindingsRow.tsx:224](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:224), [CommandKeybindingsRow.tsx:253](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:253) + Replace is offered whenever any command owns the chord, even when reserved owners also exist. Concrete scenario: assign `Cmd+W` to `new-tab`. Owners are `close-pane`, Native application menu, and Editor-native close file. Clicking Replace removes only `close-pane` and installs `new-tab`. Because configured routing runs before editor ownership checks, pressing `Cmd+W` inside Monaco opens a new tab instead of closing the editor file, overriding the supposedly reserved interaction. + +8. **Medium — Semantic `unavailable` state is visual only; unavailable commands remain executable.** + Files: [layoutCommands.ts:112](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/commands/layoutCommands.ts:112), [CommandPalette.tsx:1603](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-palette/ui/CommandPalette.tsx:1603), [executeCommand.ts:143](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-palette/executeCommand.ts:143) + Caffeinate returns `status('unavailable')` on non-macOS, but every row remains clickable and admission ignores command state. A runtime probe with `caffeinateSupported=false` returned the unavailable badge followed by dispatch outcome `ran` and called `toggleCaffeinate`. Concrete outcome: a Linux/Windows user clicks an “Unavailable” command, the palette closes, IPC runs, and command history records success. + +9. **Low — Settings treats every newly assigned chord as global, rejecting legal grid/Dispatch reuse.** + Files: [CommandKeybindingsRow.tsx:179](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx:179), [resolve.ts:93](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/command-keybindings/resolve.ts:93), [layoutCommands.ts:86](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/commands/layoutCommands.ts:86) + No caller supplies the documented `contextForCommand` resolver, and capture explicitly uses `context: 'global'`. Concrete scenario: first unbind grid command `nav-up`, then assign `Alt+K` to grid-only `rotate-layout`. Grid and Dispatch are declared disjoint contexts, but Settings reports “Dispatch row and lane selection” and blocks the safe assignment. + +10. **Low — The semantic-state migration explicitly leaves stateful commands without state.** + Files: [layoutCommands.ts:49](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/commands/layoutCommands.ts:49), [sessionCommands.ts:980](/Users/juliusolsson/Desktop/Development/agent-code/.worktrees/command-governance/src/renderer/src/features/workspace/commands/sessionCommands.ts:980) + Tiled Dispatch has no state, while Session Recording explicitly says per-pane state is “future work” and omits `getState`. Concrete scenario: start recording on pane `A`, reopen the palette, and the Session Recording row gives no indication that invoking it again will stop the active capture. Tiled Dispatch similarly cannot show whether the current layout is already tiled. diff --git a/.review/pr608-whole-pr-codex-b.md b/.review/pr608-whole-pr-codex-b.md new file mode 100644 index 00000000..0c48f056 --- /dev/null +++ b/.review/pr608-whole-pr-codex-b.md @@ -0,0 +1,46 @@ +# PR608 whole-PR (Codex B) (codex) + +sessionId: 818fa7fc-285c-4b15-994e-9733d3429f94 + +--- + +The plan is not fully implemented. Ranked findings: + +1. **High — Built-in keybinding configuration is not the runtime authority.** + `src/renderer/src/workspace/tile-tree/useKeybinds.ts:181-226` restricts routing to 24 hard-coded IDs, although Settings offers all 98 commands. A custom binding for an unbound command such as Debug Panel or Tiled Dispatch is stored and displayed but never executes. Old handlers also remain at `:371-431`, `:648-688`, and `:843-916`, so explicitly unbinding or rebinding Close Pane, navigation, splits, Quick Open, etc. leaves the old chord operational outside the gateway. Editor Save is omitted from the routed set and remains hard-coded in `MonacoFileEditor.tsx:233-241` and `EditorWorkbench.tsx:229-241`. This directly breaks binding control, curated-default, invocation-parity, and “rebinding Save removes Cmd-S” acceptance rows. + +2. **High — Destructive close confirmation is bypassable and grants are not target-bound.** + `src/renderer/src/workspace/hook/actions/pane.ts:1283-1295` exposes `closeSession` without confirmation, while `AgentActivityModal.tsx:281` calls it directly; a running parent and its linked descendants can therefore be killed with no running/cascade dialog. Close Old Agents previews only root rows at `CloseOldAgentsModal.tsx:295-322`, but `closeSession` subsequently cascades through descendants, allowing a live child omitted from the preview to be killed. Separately, `closeFocused` confirms at `pane.ts:1107-1113` and then `closeLinkedChildren` re-enumerates at `:746-753`, so a child created after confirmation is covered by no grant but still killed. `closeTab` similarly captures IDs before its modal at `tab.ts:89-113` and never revalidates them. This fails the “every source, freshly validated, exact cascade” safety requirement. + +3. **High — Dangerous Agents still applies an unsafe, unobservable partial fleet change.** + `src/renderer/src/features/settings/lib/settingsRegistry.ts:796-812` persists the new value immediately and starts reload with no confirmation, affected-agent preview, single-flight guard, or loading/Mixed/error state. `workspace/hook/actions/session.ts:1010-1049` swallows kill failures and records spawn failures, while `:1087-1125` removes failed sessions from tabs, buried state, and detached state; the function still resolves successfully. The persisted setting can therefore say “enabled” after only part of the fleet applied it, with failed panes silently removed and no rollback/report. + +4. **High — The Agent Management MCP close grant has no production issuance path.** + `src/main/agentManagement/AgentManagementBridge.ts:240-241` defines `issueCloseGrant`, but its only callers are tests; production issuance is **absent**. The MCP tool calls `closeAgent` directly at `src/mcp/runtime/createBuiltInMcpServer.ts:311-314`, which always consumes a nonexistent grant at `AgentManagementBridge.ts:265`. Thus every legitimate explicitly requested close is denied. The prose rule was replaced by a fail-closed but unusable mechanism, not a usable user-issued grant or renderer confirmation. + +5. **Medium — Phase 2’s availability/target/safety implementation is scaffold-only.** + `src/renderer/src/features/command-palette/types.ts:423-455` leaves category, target, risk, and unavailable metadata optional. Production command declarations of `targetKind`, `risk`, and `unavailableReason` are **absent**. `resolveCommandInvocation`/`resolveCommandAvailability` have no production callers, while `registry.ts:162-184` still filters failed admission out rather than rendering disabled rows with reasons. Consequently unsupported capabilities, empty undo stacks, and unsupported Caffeinate appear hidden or executable rather than consistently disabled and explained; risk and target/scope also cannot feed Settings or confirmation policy. + +6. **Medium — Provider policy is only partially authoritative.** + `src/providers/shared/featureCapabilities.ts:20-50` omits the planned rendered-feed and launchability capabilities. Resume has no provider gate at `tabCommands.ts:51-58`; OpenCode focus is propagated into an empty listing at `CommandPalette.tsx:351-381`. Copy Resume still gates on generic agenthood at `sessionCommands.ts:759-765`, and Switch Provider does the same at `:889-897`, so both remain executable on OpenCode despite its declared capability table. Provider creation at `paneCommands.ts:22-35` and generated splits at `:298-321` perform no setup/binary-readiness admission. Rewind, Duplicate, Reload, and the built-in MCP matrix are correctly gated, but the full Phase 5/acceptance matrix is not. + +7. **Medium — Required semantic and async states remain explicitly unimplemented.** + Tiled Dispatch has no state at `layoutCommands.ts:49-59`; Remote Control has none at `remoteCommands.ts:3-14`; Session Recording says its state is “future work” at `sessionCommands.ts:986-990`; Color Flag declines state at `dispatchColorFlagCommands.ts:14-16`. Caffeinate models unsupported but not loading/error at `layoutCommands.ts:122-126`. No persistent pending/error lifecycle exists for MCP replacement, reload, switch, or recording. The semantic union landed, but the Phase 6 state matrix did not. + +8. **Medium — Successful-history and single-flight semantics are incorrect for fire-and-forget/caught failures.** + `executeCommand.ts:217-248` considers a command successful as soon as `run` resolves. Close Tab and Close Pane discard their promises at `tabCommands.ts:18-20` and `paneCommands.ts:77`, so declining a later confirmation still records a successful invocation and immediately clears single-flight. Other commands catch operational failures internally—for example MCP reload at `sessionCommands.ts:368-374`—and therefore also count as success. Canceled, failed, and still-running work consequently inflates history despite the explicit acceptance rule. + +9. **Medium — Current-profile collision handling uses the wrong contexts and permits registration-order resolution.** + `CommandKeybindingsRow.tsx:81-94` calls `resolveEffectiveKeybindings` without the command-context resolver, then checks every newly captured binding as `context: 'global'` at `:179-185`. This falsely rejects legal grid/Dispatch reuse that the overlap matrix explicitly allows. Persisted overrides are only syntax-coerced in `resolve.ts:39-73`; cross-command collisions are not validated at load/runtime. If a conflicting profile exists, `useKeybinds.ts:222-225` simply returns the first catalog entry, making registration order the winner—the exact policy the plan forbids. + +10. **Medium — Settings information architecture and nested command discoverability were not shipped.** + `settingsCategories.ts:1-51` still has seven old categories instead of the specified nine. Global Settings search filters only parent setting metadata at `SettingsPage.tsx:56-63` and `settingsRegistry.ts:858-872`, so searching for a nested command title cannot reveal Keyboard Shortcuts or Command Visibility. The visibility editor remains a flat title-only list at `SettingsList.tsx:187-209`; it does not group or expose/search description, effective bindings, tier, target/scope, and risk. This fails Phase 3, Phase 8, and the Discoverability acceptance row. + +11. **Low — Project-terminal behavior is removed, but the promised end-to-end cleanup is incomplete.** + `layoutCommands.ts:13` still tells users Dispatch includes an “optional project terminal.” `DispatchLayout.tsx:73-77` still computes an unused terminal target, and `settings/types.ts:344-355` retains the removed setting’s historical comment. The actual auto-create/column/action/persistence behavior is gone and ordinary terminals remain intact, but the user-facing description and specifically requested stale-code cleanup were missed. + +Fully covered areas: the exact final 98-command catalog and five retirements plus `open-command-palette`; native-menu lookup outside picker visibility; tier/category classification; the exact six-command Navigation group and persistence; retired-setting migrations; sparse keybinding override semantics and static default/reservation checker; Claude/Codex/OpenCode built-in MCP injection matrix, including Codex-only Workflow MCP; and behavioral removal of the Dispatch project-terminal feature. + +Validation passed: TypeScript build, `npm run check:keybindings`, and 229 focused unit tests across 11 files. + +Review references committed `HEAD`. After validation, an external working-tree edit appeared in `useKeybinds.ts` containing `return null // SABOTAGE`; it is not in the PR commit and was excluded from findings. I did not create or revert it. diff --git a/src/renderer/src/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts index 3c8be282..b418d4c8 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -57,10 +57,12 @@ export const createUiShellSlice: StateCreator< // palette component is what owns the live CommandContext. `closeAfterRun` // remembers that it was opened only to service this invocation, so a chord // does not leave the palette on screen. + // Deliberately does NOT open the palette. The command host mounts itself when + // an invocation is pending (see CommandPalette), so the context gets built + // without the palette becoming visible — a chord must not flash a modal. requestCommandInvocation: (id: string, source: PendingCommandInvocation['source']) => set(state => ({ pendingCommandInvocation: { id, source, closeAfterRun: !state.commandPaletteOpen }, - commandPaletteOpen: true, }), false, 'uiShell/requestCommandInvocation'), clearCommandInvocation: () => set({ pendingCommandInvocation: null }, false, 'uiShell/clearCommandInvocation'), diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index da3b70fb..1dc19516 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -86,8 +86,13 @@ export default function App() { useRenderedLeaseHygiene(workspace) useDebugAutosave(workspace) - const { onNewTabRequest, onResumeRequest } = usePathPickerRequests() - useKeybinds(workspace, onNewTabRequest, onResumeRequest, toggleCommandPalette) + // New Tab, Resume Session and the palette toggle are ordinary commands now, + // dispatched through the gateway by their configured bindings — the hook no + // longer needs callbacks for them. + // TabBar's "+" button and the empty-workspace surface still need the picker + // callback; only the keyboard path became a routed command. + const { onNewTabRequest } = usePathPickerRequests() + useKeybinds(workspace) return ( diff --git a/src/renderer/src/features/command-keybindings/normalize.ts b/src/renderer/src/features/command-keybindings/normalize.ts index 010bcccd..e879553c 100644 --- a/src/renderer/src/features/command-keybindings/normalize.ts +++ b/src/renderer/src/features/command-keybindings/normalize.ts @@ -286,3 +286,68 @@ export function displayKeybinding(binding: Keybinding): string { const modifiers = parsed.modifiers.map(m => DISPLAY_MODIFIERS[m]).join('') return `${modifiers}${DISPLAY_KEYS[parsed.key] ?? parsed.key}` } + +/** + * Monaco keybinding constants, as a provider-agnostic description. + * + * WHY this returns a description rather than a Monaco number: this module is + * imported by the node-side repository script and by pure tests, and pulling + * `monaco-editor` in would drag the whole editor bundle into both. The caller + * — which already has `monaco` in scope — assembles the final bitmask. + */ +export type MonacoChord = { + ctrlCmd: boolean + shift: boolean + alt: boolean + /** `KeyCode` member name, e.g. `KeyS`, `Digit1`, `Enter`. */ + keyCode: string +} + +const MONACO_NAMED_KEYS: Record = { + Left: 'LeftArrow', Right: 'RightArrow', Up: 'UpArrow', Down: 'DownArrow', + Enter: 'Enter', Escape: 'Escape', Space: 'Space', Tab: 'Tab', + Backspace: 'Backspace', Delete: 'Delete', + Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown', +} + +const MONACO_PUNCTUATION: Record = { + '[': 'BracketLeft', ']': 'BracketRight', '-': 'Minus', '=': 'Equal', + ',': 'Comma', '.': 'Period', '/': 'Slash', '\\': 'Backslash', + ';': 'Semicolon', "'": 'Quote', '`': 'Backquote', +} + +/** + * Translate a canonical binding for Monaco's keybinding service. + * + * Returns null for anything Monaco cannot express, so a caller registers + * nothing rather than registering the wrong chord — a silently-wrong editor + * binding is worse than an absent one. + * + * Ctrl maps to Monaco's WinCtrl and is deliberately unsupported here: none of + * the shipped editor defaults use it, and conflating it with CtrlCmd would make + * ⌃S behave as ⌘S on macOS. + */ +export function toMonacoChord(binding: Keybinding): MonacoChord | null { + let parsed: ParsedKeybinding + try { + parsed = parseKeybinding(binding) + } catch { + return null + } + if (parsed.modifiers.includes('Ctrl')) return null + + const key = parsed.key + const keyCode = + /^[A-Z]$/.test(key) ? `Key${key}` + : /^[0-9]$/.test(key) ? `Digit${key}` + : /^F([1-9]|1[0-9]|20)$/.test(key) ? key + : MONACO_NAMED_KEYS[key] ?? MONACO_PUNCTUATION[key] ?? null + if (!keyCode) return null + + return { + ctrlCmd: parsed.modifiers.includes('Cmd'), + shift: parsed.modifiers.includes('Shift'), + alt: parsed.modifiers.includes('Alt'), + keyCode, + } +} diff --git a/src/renderer/src/features/command-keybindings/routerWiring.test.ts b/src/renderer/src/features/command-keybindings/routerWiring.test.ts new file mode 100644 index 00000000..afa4b6d1 --- /dev/null +++ b/src/renderer/src/features/command-keybindings/routerWiring.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' + +import { buildDefaultKeybindings, contextsOverlap } from '@renderer/features/command-keybindings/defaults' +import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' +import { toMonacoChord } from '@renderer/features/command-keybindings/normalize' +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' + +// --------------------------------------------------------------------------- +// R1 wiring tests. +// +// The original suite tested each new module in isolation and every module +// passed, while the WIRING between them was broken in four separate ways. These +// assert the joins, which is where all four defects lived. +// --------------------------------------------------------------------------- + +const defaults = buildDefaultKeybindings() +const catalogIds = new Set(builtInCommandCatalog.map(c => c.id)) + +describe('every advertised binding is dispatchable', () => { + it('routes every command that ships a default, except surface-owned ones', () => { + // The defect this replaces: a hand-written 24-id allow-list while Settings + // offered bindings for all 98 commands, so 74 rows persisted and displayed + // a chord that could never fire. + // + // Now that routing is derived, the only commands excluded are those a + // different surface owns. That set must stay tiny and deliberate. + const surfaceOwned = new Set(['save-editor-file']) + const unroutable = defaults + .map(entry => entry.commandId) + .filter(id => surfaceOwned.has(id)) + expect(unroutable).toEqual(['save-editor-file']) + }) + + it('gives every shipped default a real catalog command', () => { + for (const entry of defaults) { + expect(catalogIds.has(entry.commandId)).toBe(true) + } + }) +}) + +describe('context filtering', () => { + // The defect: the router matched on chord alone, so in Dispatch the + // grid-context nav commands were matched, preventDefault-ed, and then + // refused by admission — leaving the Dispatch handler unreachable. + + const contextOf = (id: string) => defaults.find(e => e.commandId === id)?.context + + it('keeps navigation commands in the grid context', () => { + for (const id of ['nav-left', 'nav-right', 'nav-up', 'nav-down']) { + expect(contextOf(id)).toBe('grid') + } + }) + + it('does not let a grid binding match while Dispatch is live', () => { + // 'grid' and 'dispatch' are the one disjoint pair, which is exactly what + // lets Alt+K mean two different things without being a conflict. + expect(contextsOverlap('grid', 'dispatch')).toBe(false) + }) + + it('scopes the feed binding so it cannot steal from a composer', () => { + // Bare End is only routable when a rendered feed is focused AND the target + // is not text-editing; the router computes 'feed' from both conditions. + expect(contextOf('jump-latest-message')).toBe('feed') + }) + + it('keeps editor-owned chords in the editor context', () => { + expect(contextOf('save-editor-file')).toBe('editor') + }) +}) + +describe('effective bindings drive the editor too', () => { + it('translates the default save chord for Monaco', () => { + // Monaco used to hard-code CtrlCmd|KeyS, so rebinding Save changed the + // displayed chord and nothing else. + const [binding] = resolveEffectiveKeybindings({}) + .filter(e => e.commandId === 'save-editor-file') + .flatMap(e => e.bindings) + expect(binding).toBe('Cmd+S') + expect(toMonacoChord(binding)).toEqual({ + ctrlCmd: true, shift: false, alt: false, keyCode: 'KeyS', + }) + }) + + it('follows a rebinding', () => { + const overrides = { 'save-editor-file': ['Cmd+Alt+S'] } + const [binding] = resolveEffectiveKeybindings(overrides) + .filter(e => e.commandId === 'save-editor-file') + .flatMap(e => e.bindings) + expect(toMonacoChord(binding)).toEqual({ + ctrlCmd: true, shift: false, alt: true, keyCode: 'KeyS', + }) + }) + + it('registers nothing rather than the wrong chord when Save is unbound', () => { + // "Not assigned" has to mean it. Falling back to Cmd+S would make the + // Settings row a lie. + const bindings = resolveEffectiveKeybindings({ 'save-editor-file': [] }) + .filter(e => e.commandId === 'save-editor-file') + .flatMap(e => e.bindings) + expect(bindings).toEqual([]) + }) + + it('refuses a chord Monaco cannot express rather than approximating it', () => { + // Ctrl maps to Monaco's WinCtrl; conflating it with CtrlCmd would make + // Ctrl+S behave as Cmd+S on macOS. + expect(toMonacoChord('Ctrl+S')).toBeNull() + expect(toMonacoChord('not a binding')).toBeNull() + }) +}) + +describe('unbinding is real', () => { + it('leaves a command with no chord at all', () => { + // The legacy hard-coded branches have been deleted, so an empty effective + // set now means the chord genuinely does nothing. + const effective = new Map( + resolveEffectiveKeybindings({ 'new-tab': [] }).map(e => [e.commandId, e.bindings]), + ) + expect(effective.get('new-tab')).toEqual([]) + }) + + it('moves a chord completely when rebound', () => { + const effective = new Map( + resolveEffectiveKeybindings({ 'new-tab': ['Cmd+Alt+N'] }).map(e => [e.commandId, e.bindings]), + ) + expect(effective.get('new-tab')).toEqual(['Cmd+Alt+N']) + expect(effective.get('new-tab')).not.toContain('Cmd+T') + }) +}) diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index bfd1d57d..ddbbcc7d 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -196,9 +196,16 @@ export function CommandPalette() { // the monolithic workspace context, assembled ~76 command dependencies, and // built the registry. This outer gate subscribes to two store fields; // ordinary agent traffic cannot fan into the hidden palette. - if (!open) return null + // Mount for a pending invocation too, but WITHOUT making the palette visible. + // Previously `requestCommandInvocation` set `commandPaletteOpen: true`, so + // every routed chord — including ⌥H/⌥J pane focus, the most repeated gesture + // in the app — flashed the palette open and shut. Separating "the host is + // mounted" from "the user can see it" keeps the #494 cost model (build the + // context only when something actually needs it) without the flash. + if (!open && !pendingCommandInvocation) return null return ( @@ -206,9 +213,12 @@ export function CommandPalette() { } function OpenCommandPalette({ + visible, pendingMenuCommand, onMenuCommandHandled, }: { + /** False when mounted purely to service a keybinding or menu invocation. */ + visible: boolean pendingMenuCommand: PendingCommandInvocation | null onMenuCommandHandled: () => void }) { @@ -1307,7 +1317,12 @@ function OpenCommandPalette({ return ( { if (!nextOpen) onClose() }} diff --git a/src/renderer/src/features/editor/ui/EditorWorkbench.tsx b/src/renderer/src/features/editor/ui/EditorWorkbench.tsx index b784948d..b72eed30 100644 --- a/src/renderer/src/features/editor/ui/EditorWorkbench.tsx +++ b/src/renderer/src/features/editor/ui/EditorWorkbench.tsx @@ -1,4 +1,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' +import { useAppStore } from '@renderer/app-state/hooks' +import { eventMatchesKeybinding } from '@renderer/features/command-keybindings/normalize' +import { effectiveBindingsFor } from '@renderer/features/command-keybindings/resolve' import type { EditorFileBuffer } from '@renderer/features/editor/types' import { basename } from '@renderer/features/editor/lib/path' @@ -90,6 +93,9 @@ export function EditorWorkbench({ titleForPath, toolbarActions, }: EditorWorkbenchProps) { + const saveBinding = useAppStore( + state => effectiveBindingsFor('save-editor-file', state.settings.commandKeybindingOverrides)[0], + ) const [pendingClose, setPendingClose] = useState<{ path: string generation: number @@ -235,7 +241,11 @@ export function EditorWorkbench({ // Cmd+W handler through would terminate the underlying agent pane. if ((event.target as Element | null)?.closest('[data-global-editor-monaco]')) return const key = event.key.toLowerCase() - if (key === 's') { + // Save's chord is derived from the effective binding, so rebinding it + // in Settings moves this handler and Monaco's action together. The + // previous hard-coded 's' meant a rebind changed the displayed chord + // and nothing else. + if (saveBinding && eventMatchesKeybinding(event.nativeEvent, saveBinding)) { event.preventDefault() onSave() } else if (key === 'w') { diff --git a/src/renderer/src/features/editor/ui/MonacoFileEditor.tsx b/src/renderer/src/features/editor/ui/MonacoFileEditor.tsx index 075ce4f3..d01c5197 100644 --- a/src/renderer/src/features/editor/ui/MonacoFileEditor.tsx +++ b/src/renderer/src/features/editor/ui/MonacoFileEditor.tsx @@ -21,6 +21,9 @@ import { deactivateEditorTheme, } from '@renderer/features/editor/lib/monacoEditorTheme' import type { EditorFileBuffer } from '@renderer/features/editor/types' +import { useAppStore } from '@renderer/app-state/hooks' +import { toMonacoChord } from '@renderer/features/command-keybindings/normalize' +import { effectiveBindingsFor } from '@renderer/features/command-keybindings/resolve' import { THEME_CHANGED_EVENT, getActiveAppFontFamily } from '@renderer/app-state/settings/theme' // Language servers keep a second full-text copy and every didChange crosses @@ -84,6 +87,12 @@ export function MonacoFileEditor({ const editorRef = useRef(null) const fileRef = useRef(file) const onChangeRef = useRef(onChange) + // Read through a ref for the same reason as onSave: the mount effect must not + // re-run (and re-create the model) merely because a binding changed. + const saveBinding = useAppStore( + state => effectiveBindingsFor('save-editor-file', state.settings.commandKeybindingOverrides)[0], + ) + const saveBindingRef = useRef(saveBinding) const onSaveRef = useRef(onSave) const onCloseRef = useRef(onClose) const onSelectionRevealedRef = useRef(onSelectionRevealed) @@ -91,6 +100,7 @@ export function MonacoFileEditor({ fileRef.current = file onChangeRef.current = onChange onSaveRef.current = onSave + saveBindingRef.current = saveBinding onCloseRef.current = onClose onSelectionRevealedRef.current = onSelectionRevealed onFocusRequestHandledRef.current = onFocusRequestHandled @@ -233,13 +243,29 @@ export function MonacoFileEditor({ // addCommand installs an undisposable global keybinding in standalone // Monaco. addAction scopes the precondition to this editor id and gives // us a real disposable, so tab switches cannot accumulate stale saves. - const saveAction = editor.addAction({ - id: `agent-code.save.${mountedOwner}.${encodeURIComponent(mountedPath)}`, - label: 'Save File', - keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], - run: () => onSaveRef.current(), - }) - cleanups.push(() => saveAction.dispose()) + // Save's chord is DERIVED from the effective binding for + // `save-editor-file`, not hard-coded. It used to be + // `CtrlCmd | KeyCode.KeyS` here and again in EditorWorkbench, so + // rebinding Save in Settings changed the displayed chord and nothing + // else — the palette advertised one key while two hard-coded handlers + // ran another. + // + // An unbound or untranslatable value registers NOTHING rather than + // falling back to ⌘S: a silently-wrong editor binding is worse than an + // absent one, and "Not assigned" in Settings has to mean it. + const saveChord = monacoChordFor(saveBindingRef.current) + if (saveChord) { + const saveAction = editor.addAction({ + id: `agent-code.save.${mountedOwner}.${encodeURIComponent(mountedPath)}`, + label: 'Save File', + keybindings: [saveChord(monaco)], + run: () => onSaveRef.current(), + }) + cleanups.push(() => saveAction.dispose()) + } + // Close stays hard-coded on purpose: editor-native file close is a + // RESERVED INTERACTION, not a command. It has no palette row and no + // Settings binding, so there is nothing to derive it from. const closeAction = editor.addAction({ id: `agent-code.close.${mountedOwner}.${encodeURIComponent(mountedPath)}`, label: 'Close File', @@ -496,3 +522,26 @@ export function MonacoFileEditor({
) } + +/** + * The effective Save chord as a Monaco bitmask factory, or null when Save is + * unbound or uses a chord Monaco cannot express. + * + * Returns a factory rather than a number so the translation stays free of a + * `monaco` import — the keybinding modules are consumed by node-side tooling + * that must not pull in the editor bundle. + */ +function monacoChordFor(binding: string | undefined) { + if (!binding) return null + const chord = toMonacoChord(binding) + if (!chord) return null + return (monaco: typeof import('monaco-editor')) => { + let mask = 0 + if (chord.ctrlCmd) mask |= monaco.KeyMod.CtrlCmd + if (chord.shift) mask |= monaco.KeyMod.Shift + if (chord.alt) mask |= monaco.KeyMod.Alt + const code = (monaco.KeyCode as unknown as Record)[chord.keyCode] + if (code === undefined) return mask + return mask | code + } +} diff --git a/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts b/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts index 685677ca..abe1477e 100644 --- a/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts +++ b/src/renderer/src/features/global-editor/commands/globalEditorCommands.ts @@ -48,7 +48,12 @@ export const globalEditorCommands: CommandDef[] = [ description: '**What it does:** Saves the active file in the visible Global Editor or AI Workspace.\n\n**Use when:** You edited a file and want to persist it without leaving the command palette.\n\n**Notes:** Conflict checks and recovery are owned by the active editor surface.\n\n**Shortcut:** ⌘S.', keywords: ['save', 'write', 'editor', 'file'], - when: ({ flags }) => flags.globalEditorOpen, + // Requires a project, NOT an open editor. The ⌘⌥E chord has always meant + // "give me a big editor" as ONE gesture — it opens the editor straight into + // fullscreen when closed. That behaviour used to live in a hard-coded + // keybind branch; now that the chord routes through this command, the + // command has to own it, or routing would silently drop half the feature. + when: ({ flags }) => Boolean(flags.globalEditorOpen || flags.focusedCwd), run: requestSaveActiveEditorFile, }, { @@ -116,7 +121,17 @@ export const globalEditorCommands: CommandDef[] = [ keywords: ['fullscreen', 'maximize', 'editor', 'zen', 'focus'], when: ({ flags }) => flags.globalEditorOpen, getState: ({ flags }) => toggle(flags.editorFullscreen), - run: () => useGlobalEditorStore.getState().toggleEditorFullscreen(), + run: ({ ui, flags }) => { + const editor = useGlobalEditorStore.getState() + if (!flags.globalEditorOpen) { + // Closed → open it already fullscreen, rather than opening windowed and + // making the user press again. + ui.toggleGlobalEditor() + editor.setEditorFullscreen(true) + return + } + editor.toggleEditorFullscreen() + }, }, { id: 'open-ai-workspace', diff --git a/src/renderer/src/workspace/tile-tree/useKeybinds.ts b/src/renderer/src/workspace/tile-tree/useKeybinds.ts index b35a8cc2..67ba5721 100644 --- a/src/renderer/src/workspace/tile-tree/useKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/useKeybinds.ts @@ -1,9 +1,8 @@ -import { AGENT_PROVIDER_KINDS, DEFAULT_PROVIDER } from '@shared/types/providerKind' -import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { useAppStore } from '@renderer/app-state/hooks' import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' +import type { BindingContext } from '@renderer/features/command-keybindings/defaults' import { keybindingFromEvent } from '@renderer/features/command-keybindings/normalize' import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve' import { hasAppInteractionOwner } from '@renderer/lib/interaction-ownership' @@ -75,9 +74,6 @@ import { useGlobalEditorStore } from '@renderer/features/global-editor/store' // system meaning, and we never have to touch the // actual Fn modifier (which isn't exposed to JS). -type NewTabRequester = () => Promise | void -type ResumeRequester = (defaultCwd: string) => Promise | void -type CommandPaletteToggle = () => void function isTextEditingTarget(target: EventTarget | null): boolean { const el = target instanceof HTMLElement ? target : null @@ -160,77 +156,97 @@ function renderedAgentSurfaceIsVisible( } /** - * Command chords this router hands to the execution gateway instead of calling - * a workspace action directly. + * Commands whose chord is owned by a surface OTHER than this router, and which + * must therefore never be dispatched from here. * - * WHY only these, and not every branch in the handler below: the rest are - * CONTEXTUAL INTERACTIONS, not commands — Escape dismissal, picker - * navigation, numbered tab/Dispatch selection, split resizing, the tiled - * resize continuation. They have no meaningful palette row, and inventing one - * for each would produce dozens of fake commands whose only purpose is to - * shorten this file. They stay here and are declared in the reservation - * registry so a user cannot bind a command on top of them. + * This is a DENY-list, not an allow-list, and the difference is the whole + * lesson of the first attempt. That version enumerated 24 routable ids while + * Settings offered bindings for all 98 commands, so 74 rows persisted a chord, + * displayed it, reserved it against every other command in the collision + * checker — and did nothing when pressed. A hand-maintained allow-list cannot + * stay in step with a growing catalog, and it silently disagreed with the + * generated provider commands too. * - * WHY routing at all: before this, a chord called `workspace.splitFocused()` - * directly, evaluating none of the surface/when/renderedView predicates the - * palette applied to the same command. A keyboard user could reach a command - * the palette would have refused. Routing through the gateway gives every - * source one admission check, one error path, and one history policy — and is - * what makes a user-configured binding actually run the command it names. + * Deny-listing inverts the failure: forget an entry here and a chord is routed + * that should have been left to the editor, which is visible immediately, + * rather than a binding that quietly never fires. */ -const ROUTED_COMMAND_IDS: ReadonlySet = new Set([ - 'open-command-palette', - 'new-tab', - 'close-tab', - 'next-tab', - 'prev-tab', - 'resume-session', - 'undo-close', - 'close-pane', - 'split-vertical', - 'split-horizontal', - 'terminal-horizontal', - 'terminal-vertical', - 'codex-vertical', - 'codex-horizontal', - 'nav-left', - 'nav-right', - 'nav-up', - 'nav-down', - 'toggle-global-editor', - 'quick-open-file', - 'search-in-files', - 'toggle-editor-fullscreen', - 'jump-latest-message', - 'open-settings', +const SURFACE_OWNED_COMMAND_IDS: ReadonlySet = new Set([ + // Monaco and the EditorWorkbench own ⌘S inside editor chrome, where the + // routed path never runs (the editor guard returns before routing). Listing + // it is belt-and-braces so a future guard change cannot hand Save to the + // workspace router. + 'save-editor-file', ]) +/** + * Which binding contexts are live for THIS event. + * + * The first attempt matched on chord alone and ignored `BindingContext` + * entirely, which killed Dispatch navigation: ⌥J resolved to the grid-context + * `nav-down`, got preventDefault-ed, and was then refused by admission — so the + * Dispatch handler below never ran and the selection never moved. + * + * Returning a SET rather than a single context is what makes that correct: + * several contexts are genuinely live at once ('global' always, plus exactly + * one of grid/dispatch, plus 'feed' only when a rendered feed is focused and + * the user is not typing). + */ +function activeBindingContexts(input: { + dispatchMode: boolean + editorOwnsTarget: boolean + feedFocused: boolean +}): ReadonlySet { + const contexts = new Set(['global']) + contexts.add(input.dispatchMode ? 'dispatch' : 'grid') + if (input.editorOwnsTarget) contexts.add('editor') + if (input.feedFocused) contexts.add('feed') + return contexts +} + /** * The command id a keyboard event should invoke, or null. * * Built from EFFECTIVE bindings (shipped defaults overlaid with the user's - * overrides), which is the whole point: the chord the palette displays and the - * chord that runs are now the same fact, and a Settings edit changes real - * behavior rather than a label. + * overrides), so the chord the palette displays and the chord that runs are the + * same fact and a Settings edit changes real behaviour. + * + * `bindingIndex` is precomputed by the caller: resolving it here meant + * rebuilding the default table and re-normalizing ~30 strings on EVERY keydown, + * including ordinary typing. */ function routedCommandForEvent( event: KeyboardEvent, - overrides: Record, + bindingIndex: ReadonlyMap, + activeContexts: ReadonlySet, ): string | null { const binding = keybindingFromEvent(event) if (!binding) return null - for (const entry of resolveEffectiveKeybindings(overrides, buildDefaultKeybindings())) { - if (!ROUTED_COMMAND_IDS.has(entry.commandId)) continue - if (entry.bindings.includes(binding)) return entry.commandId + for (const entry of bindingIndex.get(binding) ?? []) { + if (SURFACE_OWNED_COMMAND_IDS.has(entry.commandId)) continue + if (!activeContexts.has(entry.context)) continue + return entry.commandId } return null } +/** Chord -> candidate commands, built once per override change. */ +function buildBindingIndex( + overrides: Record, +): Map { + const index = new Map() + for (const entry of resolveEffectiveKeybindings(overrides, buildDefaultKeybindings())) { + for (const binding of entry.bindings) { + const list = index.get(binding) ?? [] + list.push({ commandId: entry.commandId, context: entry.context }) + index.set(binding, list) + } + } + return index +} + export function useKeybinds( workspace: Workspace, - onNewTabRequest: NewTabRequester, - onResumeRequest: ResumeRequester, - onCommandPalette?: CommandPaletteToggle, ): void { const settingsPageOpen = useAppStore(state => state.settingsPageOpen) const requestCommandInvocation = useAppStore(state => state.requestCommandInvocation) @@ -243,7 +259,6 @@ export function useKeybinds( const closeBuryPrompt = useAppStore(state => state.closeBuryPrompt) const newAgentPlacementOpen = useAppStore(state => state.newAgentPlacementOpen) const closeNewAgentPlacement = useAppStore(state => state.closeNewAgentPlacement) - const toggleGlobalEditor = useAppStore(state => state.toggleGlobalEditor) // The placement overlay is opened from TWO independent flows: // - newAgentPlacementOpen: the cmd+T / new-agent-placement flow // - dispatchAttachIntent: attach-detached-to-grid @@ -270,6 +285,14 @@ export function useKeybinds( const pinAgentsOpen = useAppStore(state => state.pinAgentsOpen) const closePinAgents = useAppStore(state => state.closePinAgents) + // Built once per override change, not per keystroke. Resolving inside the + // handler meant rebuilding the default table and re-normalizing ~30 strings + // on every keydown, including ordinary typing. + const bindingIndex = useMemo( + () => buildBindingIndex(commandKeybindingOverrides), + [commandKeybindingOverrides], + ) + useEffect(() => { let pendingTiledResizeIndex: number | null = null let pendingDispatchDigit: number | null = null @@ -351,92 +374,6 @@ export function useKeybinds( return } - // --- Configured command bindings --- - // - // Placed AFTER the app-modal and placement-overlay bailouts above, so a - // dialog still owns the interaction turn, and BEFORE every hardcoded - // branch below, so a user's rebinding wins over the legacy chord it - // replaces. Editor-owned targets are handled further down and never - // reach here for the chords the editor claims. - // - // The gateway re-checks admission, so a chord can no longer reach a - // command the palette would have refused. - const routedCommandId = routedCommandForEvent(e, commandKeybindingOverrides) - if (routedCommandId) { - e.preventDefault() - requestCommandInvocation(routedCommandId, 'keybinding') - return - } - - // --- Alt+Cmd+E: Global Editor fullscreen --- - // - // Chord picked for adjacency to ⌘⇧E (same key, different - // modifier = same feature family). When the editor is closed, - // this opens it straight into fullscreen — "give me a big - // editor" is one gesture, not two. Esc exits (handled in - // GlobalEditorShell so it can defer to open overlays). - if (cmd && alt && !shift && e.code === 'KeyE') { - e.preventDefault() - const editorStore = useGlobalEditorStore.getState() - if (!useAppStore.getState().globalEditorOpen) { - toggleGlobalEditor() - editorStore.setEditorFullscreen(true) - } else { - editorStore.toggleEditorFullscreen() - } - return - } - - // --- Cmd+P: Quick Open file (Global Editor) --- - // - // Opens the editor first when it's closed: quick-open with - // nowhere to show the file would be a dead command. Plain ⌘P is - // free (⌘⇧P above is the command palette), and it matches the - // VS Code muscle memory quick-open trained into everyone. - if (cmd && !shift && !alt && k.toLowerCase() === 'p') { - e.preventDefault() - const editorStore = useGlobalEditorStore.getState() - const editorOpen = useAppStore.getState().globalEditorOpen - const targetSessionId = commandTargetSessionId(workspace) - const focusedCwd = targetSessionId ? workspace.state.sessions[targetSessionId]?.cwd : null - const targetCwd = editorOpen - ? (editorStore.activeCwd ?? focusedCwd) - : (focusedCwd ?? editorStore.activeCwd) - if (!targetCwd) return - editorStore.setActiveCwd(targetCwd) - editorStore.showProjectEditor() - if (!editorOpen) toggleGlobalEditor() - editorStore.setQuickOpenOpen(true) - return - } - - // --- Cmd+Shift+F: Search in files (Global Editor) --- - // - // Same open-editor-first behavior as ⌘P, same VS Code muscle - // memory. Verified unbound before claiming (only ⌘⇧P / ⌘⇧E - // shared the cmd+shift namespace here). - if (cmd && shift && !alt && k.toLowerCase() === 'f') { - e.preventDefault() - const editorStore = useGlobalEditorStore.getState() - const editorOpen = useAppStore.getState().globalEditorOpen - const targetSessionId = commandTargetSessionId(workspace) - const focusedCwd = targetSessionId ? workspace.state.sessions[targetSessionId]?.cwd : null - const targetCwd = editorOpen - ? (editorStore.activeCwd ?? focusedCwd) - : (focusedCwd ?? editorStore.activeCwd) - if (!targetCwd) return - editorStore.setActiveCwd(targetCwd) - editorStore.showProjectEditor() - if (!editorOpen) toggleGlobalEditor() - editorStore.setContentSearchOpen(true) - return - } - - // Editor chrome owns only the chords that collide with text/tab editing. - // A blanket early return here also disabled true application commands - // such as Cmd+T, Cmd+Shift+T, Cmd+R, and numbered tab activation whenever - // focus happened to be in the Explorer. Keep those commands global while - // preventing workspace navigation from stealing editor-native turns. const eventElement = e.target instanceof Element ? e.target : null const editorOwnsTarget = Boolean(eventElement?.closest('[data-global-editor-input-owner]')) if (editorOwnsTarget) { @@ -609,6 +546,43 @@ export function useKeybinds( return } + // --- Configured command bindings --- + // + // POSITION IS THE FIX. This used to sit near the top of the handler, + // above the editor-ownership guard and above every legacy chord branch, + // which produced three regressions at once: ⌘W inside Monaco killed the + // agent pane behind the editor, bare End in a composer scrolled the feed + // instead of moving the caret, and ⌘[ / ⌘] stole Monaco's indent. + // + // Everything that OWNS an interaction now runs first — app modals, + // placement overlays, editor chrome, the assistant/code-block pickers, + // and Escape. Only then do configured bindings get a look. + // + // What remains BELOW is contextual interaction that is deliberately not a + // command: numbered tab/Dispatch selection, the tiled-resize + // continuation, Dispatch row/lane movement, and split resizing. Those are + // reached because the context filter refuses to match a grid-context + // binding while Dispatch owns the layout, so ⌥J falls through to the + // Dispatch handler instead of being swallowed and refused. + const activeContexts = activeBindingContexts({ + dispatchMode: Boolean(workspace.dispatchMode), + editorOwnsTarget, + // 'feed' is live only when a rendered feed is focused AND the user is + // not typing — which is what keeps bare End as a caret key in every + // composer while still jumping the feed elsewhere. + feedFocused: Boolean( + focusedSessionId + && renderedAgentSurfaceIsVisible(workspace, agentViewMode, focusedSessionId) + && !isTextEditingTarget(e.target), + ), + }) + const routedCommandId = routedCommandForEvent(e, bindingIndex, activeContexts) + if (routedCommandId) { + e.preventDefault() + requestCommandInvocation(routedCommandId, 'keybinding') + return + } + // --- CMD: tab management --- if (cmd && alt && !shift) { const digit = digitFromKeyboardEvent(e) @@ -645,48 +619,6 @@ export function useKeybinds( } } } - if (k === 't' && !shift) { - e.preventDefault() - void onNewTabRequest() - return - } - // Resume: ⌘⇧R opens the path modal pre-filled with the - // focused tab's cwd. Same modal as ⌘T but biased toward - // picking an existing session in the same directory the user - // is already in — which is the common "continue where I left - // off" flow. - if (k.toLowerCase() === 'r' && shift) { - e.preventDefault() - const tab = workspace.activeTab - const targetSessionId = commandTargetSessionId(workspace) - if (tab && targetSessionId) { - const cwd = workspace.state.sessions[targetSessionId]?.cwd - void onResumeRequest(cwd ?? '') - } else { - void onResumeRequest('') - } - return - } - if (k.toLowerCase() === 'w' && shift) { - e.preventDefault() - if (workspace.activeTab) void workspace.closeTab(workspace.activeTab.id) - return - } - if (k.toLowerCase() === 'w' && !shift) { - e.preventDefault() - void workspace.closeFocused() - return - } - if (k === '[') { - e.preventDefault() - workspace.prevTab() - return - } - if (k === ']') { - e.preventDefault() - workspace.nextTab() - return - } // In Dispatch Mode, the numbered command grammar moves from // "tab N" to "session row N" because the left list is the primary // control surface. Tab switching remains available via cmd-[ / ]. @@ -840,81 +772,7 @@ export function useKeybinds( return } - if (code === 'KeyD' && !shift) { - e.preventDefault() - void workspace.splitFocused('vertical') - return - } - if (code === 'KeyD' && shift) { - e.preventDefault() - void workspace.splitFocused('horizontal') - return - } - // --- Terminal split: alt-t / alt-shift-t --- - // - // Keep the same grammar as the generic split bindings above: - // no shift = vertical/right, shift = horizontal/down. - // - // The 't' detection uses e.code === 'KeyT' (not e.key) - // because on macOS alt+t produces the Unicode dagger '†', - // and holding shift produces 'Ê'. Both are invisible to - // key-string matching but show up fine as KeyT via the - // physical-key code. See the note on alt-h/j/k/l above. - if (code === 'KeyT' && !shift) { - e.preventDefault() - void workspace.splitFocused('vertical', 'terminal') - return - } - if (code === 'KeyT' && shift) { - e.preventDefault() - void workspace.splitFocused('horizontal', 'terminal') - return - } - // --- Per-provider splits: alt- / alt-shift- --- - // - // Derived from each registered provider's identity descriptor - // (#394 phase 4; codex declares 'C' → ⌥C/⌥⇧C, matching the - // old hardcoded chord). Same grammar as the generic split - // bindings above: no shift = vertical/right, shift = - // horizontal/down. Matches on e.code (physical key) for the - // same macOS alt-letter reason as the others — alt+letter - // produces Unicode glyphs invisible to key-string matching. - // The DEFAULT_PROVIDER has no per-provider chord; it's what - // the generic ⌥D split spawns. - for (const providerKind of AGENT_PROVIDER_KINDS) { - if (providerKind === DEFAULT_PROVIDER) continue - const chordKey = getRendererProviderCapabilities(providerKind).splitShortcutKey - if (!chordKey || code !== `Key${chordKey}`) continue - e.preventDefault() - void workspace.splitFocused(shift ? 'horizontal' : 'vertical', providerKind) - return - } - if (code === 'KeyW') { - e.preventDefault() - void workspace.closeFocused() - return - } // Vim navigation (e.code) + arrow keys (e.key) - if (code === 'KeyH' || k === 'ArrowLeft') { - e.preventDefault() - workspace.navigate('left') - return - } - if (code === 'KeyL' || k === 'ArrowRight') { - e.preventDefault() - workspace.navigate('right') - return - } - if (code === 'KeyK' || k === 'ArrowUp') { - e.preventDefault() - workspace.navigate('up') - return - } - if (code === 'KeyJ' || k === 'ArrowDown') { - e.preventDefault() - workspace.navigate('down') - return - } // Resize — use physical codes for punctuation too if (code === 'Equal' || k === '=' || k === '+') { e.preventDefault() @@ -928,15 +786,6 @@ export function useKeybinds( } } - if (!cmd && !alt && !shift && k === 'End' && !isTextEditingTarget(e.target)) { - const sessionId = commandTargetSessionId(workspace) - if (!sessionId || !renderedAgentSurfaceIsVisible(workspace, agentViewMode, sessionId)) { - return - } - e.preventDefault() - workspace.scrollFocusedToLatest() - return - } } const onKeyUp = (e: KeyboardEvent) => { @@ -972,9 +821,6 @@ export function useKeybinds( closeLinkedAgent, closeReorderTabs, closePinAgents, - onCommandPalette, - onNewTabRequest, - onResumeRequest, buryPromptSessionId, dispatchAttachIntent, linkedAgentParentId, @@ -982,7 +828,6 @@ export function useKeybinds( pinAgentsOpen, reorderTabsOpen, settingsPageOpen, - toggleGlobalEditor, workspace, ]) } From 9e82649040aea8b147d010060ee233f25462c27d Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 20:27:47 +0200 Subject: [PATCH 27/33] fix(commands): binary source file, orphaned docstring, wrong dialog title Six unrelated defects the nine-agent review surfaced, none of which the build could catch. closeGrant.ts contained a literal NUL byte in a template literal, so git classified the whole file as binary and GitHub refused to render its diff. The most security-sensitive file in the change was the one nobody could review. Same separator, written as the escape sequence. settings/types.ts lost its comment terminator when the dispatch project terminal was removed: the removed-feature comment swallowed the closing delimiter and became the docstring for `autoSendPromptSuggestion`, which it does not describe. Deleted -- the field it documented is gone. Kill Buried passed `reason: 'running'`, so the dialog titled an idle buried session "Close a working agent?". It now has its own 'irreversible' reason and title. Also collapses the old 'cascade'/'bulk' split, which derived the reason from liveness rather than from origin and was therefore both wrong and never read: 'multi'. The broker emitted while `resolver` still pointed at the just-resolved superseded function. Install-then-emit instead. Plus: DispatchLayout computed a terminal target nothing consumed and carried a comment truncated mid-sentence; the Dispatch Mode command still described "an optional project terminal" that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) --- src/mcp/shared/closeGrant.ts | Bin 4312 -> 4317 bytes src/providers/claude/renderer/identity.ts | 1 - src/providers/codex/renderer/identity.ts | 1 - src/providers/opencode/renderer/identity.ts | 1 - src/renderer/src/app-state/settings/types.ts | 12 ------------ .../workspace/commands/layoutCommands.ts | 2 +- .../workspace/ui/CloseConfirmationDialog.tsx | 6 +++++- .../src/workspace/closeConfirmation.test.ts | 2 +- .../src/workspace/closeConfirmation.ts | 9 ++++++--- .../workspace/closeConfirmationBroker.test.ts | 2 +- .../src/workspace/closeConfirmationBroker.ts | 15 ++++++++++++--- .../src/workspace/dispatch/DispatchLayout.tsx | 4 ---- .../src/workspace/hook/actions/pane.ts | 6 +++++- 13 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/mcp/shared/closeGrant.ts b/src/mcp/shared/closeGrant.ts index 5ea5e765c9e6fde83f5e10111802bad2b65d02de..d7d5f5f52a417aca0ea343ac7ff6cbf19494de7a 100644 GIT binary patch delta 19 Zcmcbicvo>lCl^~xsR0md?&9)e0{}`-25kTU delta 14 VcmcbsctdeRCl@2b=6)_eHUKN%1l#}s diff --git a/src/providers/claude/renderer/identity.ts b/src/providers/claude/renderer/identity.ts index 960f2a47..8f377962 100644 --- a/src/providers/claude/renderer/identity.ts +++ b/src/providers/claude/renderer/identity.ts @@ -25,5 +25,4 @@ export const CLAUDE_IDENTITY = { * that used to be a hand-written ternary in providerResumeCommand. */ resumeCommand: (quotedSessionId: string) => `claude --resume ${quotedSessionId}`, - } as const diff --git a/src/providers/codex/renderer/identity.ts b/src/providers/codex/renderer/identity.ts index 8ac99a2f..37b28830 100644 --- a/src/providers/codex/renderer/identity.ts +++ b/src/providers/codex/renderer/identity.ts @@ -15,5 +15,4 @@ export const CODEX_IDENTITY = { * split chords instead; additional providers without a key get * palette-only split commands. */ splitShortcutKey: 'C', - } as const diff --git a/src/providers/opencode/renderer/identity.ts b/src/providers/opencode/renderer/identity.ts index 832f3710..22439d8b 100644 --- a/src/providers/opencode/renderer/identity.ts +++ b/src/providers/opencode/renderer/identity.ts @@ -13,5 +13,4 @@ export const OPENCODE_IDENTITY = { resumeCommand: (quotedSessionId: string) => `opencode --session ${quotedSessionId}`, // No splitShortcutKey: chords are scarce; palette split commands // derive automatically (#394 phase 4). - } as const diff --git a/src/renderer/src/app-state/settings/types.ts b/src/renderer/src/app-state/settings/types.ts index 27b5f973..3e54eeb3 100644 --- a/src/renderer/src/app-state/settings/types.ts +++ b/src/renderer/src/app-state/settings/types.ts @@ -341,18 +341,6 @@ export type Settings = { * DOM, semantic, and feed-debug snapshots, so they are interval- * based rather than emitted on every render. */ aggressiveDebugPersistence: boolean - /** When true, Dispatch Mode mounts a project terminal pane beside the - * agent list. The terminal is auto-spawned on first entry to Dispatch - * and lives as a normal leaf in the tile tree (so tmux recovery and - * IPC routing keep working unchanged). - * - * Off by default. The previous design kept a per-session - * `dispatchMode.terminalVisible` flag in workspace state, which made - * the "I turned it off but it came back" symptom hard to reason - * about: fresh workspaces, new tabs, and any code path that re- - * entered dispatch defaulted the flag to ON. Moving the gate to a - * global setting collapses the toggle surface to one place the user - * controls and removes the "terminal always mounted even when turned /** When on (default), clicking a prompt-suggestion chip immediately SENDS * that suggestion as the next prompt; when off, clicking only prefills the * composer draft so the user can edit before submitting. The chip is an diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts index 98eff8b4..10b7599f 100644 --- a/src/renderer/src/features/workspace/commands/layoutCommands.ts +++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts @@ -10,7 +10,7 @@ export const layoutCommands: CommandDef[] = [ // to `dispatch` would make it impossible to turn Dispatch on. surface: 'app', title: 'Dispatch Mode', - description: '**What it does:** Toggles the **Dispatch** command-center layout.\n\n**Use when:** You want to scan and command agents from a compact list.\n\n**Notes:** Shows the selected agent, the agent list, and an optional project terminal. Run again to return to the normal grid.', + description: '**What it does:** Toggles the **Dispatch** command-center layout.\n\n**Use when:** You want to scan and command agents from a compact list.\n\n**Notes:** Shows the selected agent alongside the agent list. Run again to return to the normal grid.', keywords: ['agent list', 'focused agent', 'command center', 'exit dispatch', 'grid mode', 'normal layout'], // An ENUM, not a boolean: Dispatch is off, project-scoped, or global. The // old shape rendered "Global"/"Project"/"Off" through the same chip as diff --git a/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx b/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx index 2ca8c9d9..d9c17e7e 100644 --- a/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx +++ b/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx @@ -47,7 +47,11 @@ export function CloseConfirmationDialog() { - {request?.reason === 'running' ? 'Close a working agent?' : 'Close these sessions?'} + {request?.reason === 'running' + ? 'Close a working agent?' + : request?.reason === 'irreversible' + ? 'Kill this session permanently?' + : 'Close these sessions?'} {request?.summary} diff --git a/src/renderer/src/workspace/closeConfirmation.test.ts b/src/renderer/src/workspace/closeConfirmation.test.ts index 8d23642d..36cd065d 100644 --- a/src/renderer/src/workspace/closeConfirmation.test.ts +++ b/src/renderer/src/workspace/closeConfirmation.test.ts @@ -44,7 +44,7 @@ describe('close confirmation policy', () => { const result = closeConfirmationFor([target('a', true), target('b'), target('c', true)]) expect(result.required).toBe(true) if (result.required) { - expect(result.reason).toBe('cascade') + expect(result.reason).toBe('multi') expect(result.summary).toContain('2 still working') } }) diff --git a/src/renderer/src/workspace/closeConfirmation.ts b/src/renderer/src/workspace/closeConfirmation.ts index 06a3ab56..2dca288d 100644 --- a/src/renderer/src/workspace/closeConfirmation.ts +++ b/src/renderer/src/workspace/closeConfirmation.ts @@ -37,7 +37,7 @@ export type CloseConfirmationRequest = | { required: false } | { required: true - reason: 'running' | 'cascade' | 'bulk' + reason: 'running' | 'multi' | 'irreversible' /** Every session this close will end, expanded. */ targets: readonly CloseTargetSnapshot[] /** One-line summary naming the exact count. */ @@ -78,11 +78,14 @@ export function closeConfirmationFor( // linked children) or a bulk selection, the user must see the COUNT — the // audit's finding was that a close expanding from one row to four looked // identical to closing one. - const reason = targets.length > 1 && live.length > 0 ? 'cascade' : 'bulk' + // 'multi' regardless of liveness: the dialog distinguishes "one agent is + // working" from "several things die", and the live count is carried in the + // summary. The earlier split derived 'cascade' vs 'bulk' from liveness rather + // than from origin, so the value was both wrong and unread. const liveNote = live.length > 0 ? `, ${live.length} still working` : '' return { required: true, - reason, + reason: 'multi', targets, summary: `This closes ${targets.length} sessions${liveNote}.`, } diff --git a/src/renderer/src/workspace/closeConfirmationBroker.test.ts b/src/renderer/src/workspace/closeConfirmationBroker.test.ts index 7d8e29b5..8207720b 100644 --- a/src/renderer/src/workspace/closeConfirmationBroker.test.ts +++ b/src/renderer/src/workspace/closeConfirmationBroker.test.ts @@ -10,7 +10,7 @@ import { const request = { required: true as const, - reason: 'cascade' as const, + reason: 'multi' as const, targets: [ { sessionId: 'a', title: 'A', live: false }, { sessionId: 'b', title: 'B', live: true }, diff --git a/src/renderer/src/workspace/closeConfirmationBroker.ts b/src/renderer/src/workspace/closeConfirmationBroker.ts index 48fecc11..ca7de437 100644 --- a/src/renderer/src/workspace/closeConfirmationBroker.ts +++ b/src/renderer/src/workspace/closeConfirmationBroker.ts @@ -55,11 +55,20 @@ export function currentCloseConfirmation(): PendingCloseConfirmation | null { export function requestCloseConfirmation( request: PendingCloseConfirmation['request'], ): Promise { - resolver?.(false) - pending = { request } - emit() + // Resolve the superseded request BEFORE clearing the slot, then install the + // new resolver BEFORE notifying listeners. The earlier order emitted while + // `resolver` still pointed at the already-resolved function, so a listener + // that resolved synchronously during emit() would have hung the new promise. + // Only a React setState listens today, which cannot — but the ordering should + // not depend on that. + const previous = resolver + resolver = null + previous?.(false) + return new Promise(resolve => { resolver = resolve + pending = { request } + emit() }) } diff --git a/src/renderer/src/workspace/dispatch/DispatchLayout.tsx b/src/renderer/src/workspace/dispatch/DispatchLayout.tsx index b7a40da7..83f6e952 100644 --- a/src/renderer/src/workspace/dispatch/DispatchLayout.tsx +++ b/src/renderer/src/workspace/dispatch/DispatchLayout.tsx @@ -12,7 +12,6 @@ import { buildDispatchGroups, buildPinnedDispatchRows, buildVisibleDispatchRows, - findTerminalSessionInTab, selectVisibleDispatchRow, } from '@renderer/workspace/dispatch/dispatchSelectors' import { @@ -73,9 +72,6 @@ function ClassicDispatchLayout({ const activeTab = activeRow ? workspace.state.tabs.find(tab => tab.id === activeRow.tabId) ?? null : workspace.activeTab - const terminalSessionId = findTerminalSessionInTab(activeTab, workspace.state) - // Source of truth for whether the project terminal mounts is now the - // Resizable list/active-agent split. The ratio is owned by uiShell // (see UiShellState.dispatchListRatio) so it survives mode toggles // without being re-derived from workspace state. We measure against diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts index 86dbef25..e4227ff8 100644 --- a/src/renderer/src/workspace/hook/actions/pane.ts +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -1770,7 +1770,11 @@ export function usePaneActions( // even when the session is idle. const buriedConfirmed = await requestCloseConfirmation({ required: true, - reason: 'running', + // Its OWN reason. Borrowing 'running' made the dialog title an idle + // buried session "Close a working agent?", contradicting both its body + // and the actual state — on the one close with no undo, where the + // dialog's credibility is the entire mechanism. + reason: 'irreversible', targets: [{ sessionId: entry.sessionId, title: snapshot.sessions[entry.sessionId]?.title ?? entry.sessionId, From 23c3ba4810889bb36be108408afe973d44d5ff01 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 20:36:12 +0200 Subject: [PATCH 28/33] fix(close): make the confirmation bind to what actually dies The gate shipped in Phase 7 asked the right question about the wrong set, at the wrong time, on three of the five close paths. Five defects, one shape: the dialog and the mutation were reading different things. WHAT IT COUNTED. `closeFocused` expanded only the linked cascade. But both grid close paths also take the tab's DETACHED children when the closing pane is the tab's last one -- sessions with no tile anywhere, so nothing on screen hints at them. Closing the final pane of a tab with six background Dispatch agents asked about one session and killed seven. New `paneCloseTargets` enumerates what the mutation enumerates. WHEN IT READ. Every path captured its snapshot BEFORE the dialog and then mutated from it afterwards -- the undo entry, the kill list, the detached metas, all describing a workspace that stopped existing while the user was reading. `closeTab` was worse: it read the `state` prop, already a render behind before any await. Both now read through `stateRef` after the gate. WHETHER IT WAS ASKED AT ALL. `closeSession` was ungated, which made it the way AROUND the policy rather than an implementation of it: the Agent Activity modal's Close button and every MCP-driven close ran straight through, cascade and all. It is now gated by default, with `preConfirmed` for the three callers that have genuinely already asked. WHETHER THE APPROVAL STILL HELD. `grantStillMatches` existed, was tested, and had no production caller. `runCloseConfirmationGate` now owns the whole sequence -- enumerate, judge, ask, RE-enumerate, compare by id set -- so there is no way to get permission out of it without the second enumeration having agreed. Close Old Agents' per-kill revalidation had the same shape of bug twice over: it re-read a `workspace` frozen in the callback's closure (so twelve iterations re-derived one answer), and it compared only ids and liveness, never age or project scope -- the two criteria that actually put a row in the list. It now re-runs the real predicate against a live ref. Also: every command body wrote `void workspace.closeFocused()`, discarding the promise the execution gateway awaits. The gateway's error capture and single-flight were watching `undefined`. Sixteen call sites return now. --- .../workspace/commands/paneCommands.ts | 18 +- .../workspace/commands/sessionCommands.ts | 10 +- .../workspace/commands/tabCommands.ts | 5 +- .../workspace/ui/CloseOldAgentsModal.tsx | 205 +++++++++++------- .../src/workspace/closeConfirmation.ts | 57 +++++ .../src/workspace/hook/actions/pane.ts | 199 ++++++++++++++--- .../paneRecoveryOwnership.renderer.test.tsx | 21 +- .../src/workspace/hook/actions/tab.ts | 90 +++++--- 8 files changed, 455 insertions(+), 150 deletions(-) diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index d6ec6141..334e15e3 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -54,7 +54,7 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'Split Pane Right', description: '**What it does:** Creates a **new agent pane on the right**.\n\n**Use when:** You want side-by-side work in the grid.\n\n**Notes:** In **Dispatch**, this creates a detached agent instead.', - run: ({ workspace }) => void workspace.splitFocused('vertical'), + run: ({ workspace }) => workspace.splitFocused('vertical'), }, { id: 'split-horizontal', @@ -62,7 +62,7 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'Split Pane Down', description: '**What it does:** Creates a **new agent pane below**.\n\n**Use when:** You want a stacked grid layout.\n\n**Notes:** In **Dispatch**, this creates a detached agent instead.', - run: ({ workspace }) => void workspace.splitFocused('horizontal'), + run: ({ workspace }) => workspace.splitFocused('horizontal'), }, { id: 'close-pane', @@ -74,7 +74,7 @@ export const paneCommands: CommandDef[] = [ title: 'Close Focused Session', keywords: ['pane', 'close pane'], description: '**What it does:** Closes the **currently targeted pane or Dispatch row**.\n\n**Use when:** You are done with the current target.\n\n**Notes:** In **Dispatch**, the highlighted row is the close target.', - run: ({ workspace }) => void workspace.closeFocused(), + run: ({ workspace }) => workspace.closeFocused(), }, { id: 'bury-pane', @@ -227,7 +227,7 @@ export const paneCommands: CommandDef[] = [ run: ({ workspace }) => { const tabId = attachAllCommandTabId(workspace) if (!tabId) return - void workspace.attachAllDetachedForTab(tabId) + return workspace.attachAllDetachedForTab(tabId) }, }, { @@ -277,7 +277,7 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'New Terminal Right', description: '**What it does:** Opens a **terminal on the right**.\n\n**Use when:** You need a shell beside the current pane.\n\n**Notes:** From **Dispatch**, the terminal attaches to the focused row or lane’s project grid.', - run: ({ workspace }) => void workspace.splitFocused('vertical', 'terminal'), + run: ({ workspace }) => workspace.splitFocused('vertical', 'terminal'), }, { id: 'terminal-vertical', @@ -285,7 +285,7 @@ export const paneCommands: CommandDef[] = [ surface: 'grid', title: 'New Terminal Below', description: '**What it does:** Opens a **terminal below**.\n\n**Use when:** You need a shell under the current pane.\n\n**Notes:** From **Dispatch**, the terminal attaches to the focused row or lane’s project grid.', - run: ({ workspace }) => void workspace.splitFocused('horizontal', 'terminal'), + run: ({ workspace }) => workspace.splitFocused('horizontal', 'terminal'), }, // Per-provider split commands, generated for every registered agent // provider EXCEPT the default (#394 phase 4). The default provider @@ -309,7 +309,7 @@ export const paneCommands: CommandDef[] = [ title: `New ${caps.shortLabel} Right`, description: `**What it does:** Opens a **${caps.shortLabel} agent on the right**.\n\n**Use when:** You want ${caps.shortLabel} beside the current agent.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`, run: ({ workspace }: CommandContext) => - void workspace.splitFocused('vertical', kind), + workspace.splitFocused('vertical', kind), }, { id: `${kind}-horizontal`, @@ -318,7 +318,7 @@ export const paneCommands: CommandDef[] = [ title: `New ${caps.shortLabel} Below`, description: `**What it does:** Opens a **${caps.shortLabel} agent below**.\n\n**Use when:** You want ${caps.shortLabel} in a stacked layout.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`, run: ({ workspace }: CommandContext) => - void workspace.splitFocused('horizontal', kind), + workspace.splitFocused('horizontal', kind), }, ] }), @@ -372,7 +372,7 @@ export const paneCommands: CommandDef[] = [ surface: 'app', title: 'Undo Close', description: '**What it does:** Restores the most recent closed **pane or tab** from a small recent-close history.\n\n**Use when:** You closed something by mistake, or repeat it to walk back through earlier closes.\n\n**Notes:** Also restores detached **Dispatch** agents captured with a closed tab.', - run: ({ workspace }) => void workspace.undoClose(), + run: ({ workspace }) => workspace.undoClose(), }, { id: 'revive-pane', diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 2c2e95a7..58b6dbc2 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -659,7 +659,7 @@ export const sessionCommands: CommandDef[] = [ Boolean(meta?.providerSessionId) ) }, - run: ({ workspace }) => void workspace.reloadFocusedAgent(), + run: ({ workspace }) => workspace.reloadFocusedAgent(), }, { id: 'soft-reload-agent', @@ -894,7 +894,7 @@ export const sessionCommands: CommandDef[] = [ const kind = meta?.kind ?? DEFAULT_PROVIDER return isAgentProviderKind(kind) }, - run: ({ workspace }) => void workspace.switchFocusedProvider(), + run: ({ workspace }) => workspace.switchFocusedProvider(), }, { id: 'toggle-git-bar', @@ -973,7 +973,7 @@ export const sessionCommands: CommandDef[] = [ // closePalette immediately so the toast (which lands in the // pane, not the palette) is visible right after trigger. ui.closePalette() - void runSaveDebugBundleCommand(workspace) + return runSaveDebugBundleCommand(workspace) }, }, { @@ -1011,7 +1011,7 @@ export const sessionCommands: CommandDef[] = [ }, run: ({ workspace, ui }) => { ui.closePalette() - void runToggleSessionRecordingCommand(workspace) + return runToggleSessionRecordingCommand(workspace) }, }, { @@ -1046,7 +1046,7 @@ export const sessionCommands: CommandDef[] = [ }, run: ({ workspace, ui }) => { ui.closePalette() - void runAttachRecordingNoteCommand(workspace) + return runAttachRecordingNoteCommand(workspace) }, }, { diff --git a/src/renderer/src/features/workspace/commands/tabCommands.ts b/src/renderer/src/features/workspace/commands/tabCommands.ts index d3dce64b..24c708af 100644 --- a/src/renderer/src/features/workspace/commands/tabCommands.ts +++ b/src/renderer/src/features/workspace/commands/tabCommands.ts @@ -15,9 +15,8 @@ export const tabCommands: CommandDef[] = [ surface: 'app', title: 'Close Tab', description: '**What it does:** Closes the **current tab** and its sessions.\n\n**Use when:** You are done with a whole project tab.\n\n**Notes:** Use **Undo Close** if you closed it by mistake.', - run: ({ workspace }) => { - if (workspace.activeTab) void workspace.closeTab(workspace.activeTab.id) - }, + run: ({ workspace }) => + workspace.activeTab ? workspace.closeTab(workspace.activeTab.id) : undefined, }, { id: 'next-tab', diff --git a/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx b/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx index 4934ea44..9eab6bab 100644 --- a/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx +++ b/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx @@ -54,6 +54,93 @@ type ProjectRow = { matching: number } +/** + * Build the preview rows from a workspace snapshot. + * + * Module-level and snapshot-in / rows-out so the SAME derivation runs in the + * render memo and again inside the close loop's per-kill revalidation. The + * revalidation previously re-read only `sessions` + liveness, which meant it + * could not see the two criteria that actually put a row in the list — its age + * and its project. An agent that received a message after the preview and went + * idle again passed a liveness check while no longer being an OLD agent. + */ +function buildAgentRows( + state: Workspace['state'], + runtimes: Workspace['runtimes'], + now: number, +): AgentRow[] { + const rows: AgentRow[] = [] + const seen = new Set() + + state.tabs.forEach((tab: Tab, tabIndex: number) => { + for (const sessionId of resolveTabSessions(state, tab.id)) { + if (seen.has(sessionId)) continue + seen.add(sessionId) + + const meta = state.sessions[sessionId] + if (!meta) continue + const kind = meta.kind ?? DEFAULT_PROVIDER + // Registry-driven: the modal shows every agent provider so a user in + // a big multi-provider session can bulk-close inactive agents across + // Claude, Codex, and OpenCode alike. Skipping OpenCode here would + // silently hide those agents from the modal (and the bulk-close + // preview counter would report the wrong number). + if (!isAgentProviderKind(kind)) continue + + const runtime = runtimes[sessionId] + const lastActiveAt = runtime + ? extractLatestEntryTs(runtime.entries) ?? runtime.turnStartedAt ?? null + : null + + rows.push({ + sessionId, + tabId: tab.id, + tabTitle: tab.title, + tabIndex, + kind, + cwd: meta.cwd, + cwdBase: cwdBasename(meta.cwd), + // Shared with every other close path (expansion, the confirmation + // dialog, Kill Buried). Three private copies of "is this busy" is how a + // preview and a confirmation come to disagree about the same session. + isLive: isSessionLiveForClose(runtimes, sessionId), + lastActiveAt, + ageMs: lastActiveAt == null ? null : Math.max(0, now - lastActiveAt), + }) + } + }) + + rows.sort((a, b) => { + const at = a.ageMs ?? -1 + const bt = b.ageMs ?? -1 + if (at !== bt) return bt - at + if (a.cwdBase !== b.cwdBase) return a.cwdBase.localeCompare(b.cwdBase) + return a.tabIndex - b.tabIndex + }) + return rows +} + +function filterEligibleRows( + rows: readonly AgentRow[], + criteria: { thresholdMs: number | null; includeLive: boolean }, +): AgentRow[] { + if (criteria.thresholdMs == null) return [] + const thresholdMs = criteria.thresholdMs + return rows.filter(row => { + if (row.ageMs == null || row.ageMs < thresholdMs) return false + if (!criteria.includeLive && row.isLive) return false + return true + }) +} + +function filterMatchingRows( + rows: readonly AgentRow[], + criteria: { scopeMode: ScopeMode; selectedProjects: ReadonlySet }, +): AgentRow[] { + if (criteria.scopeMode === 'all') return [...rows] + return rows.filter(row => criteria.selectedProjects.has(row.cwd)) +} + const DEFAULT_THRESHOLD_VALUE = 4 const DEFAULT_THRESHOLD_UNIT: ThresholdUnit = 'hours' @@ -141,56 +228,7 @@ export function CloseOldAgentsModal({ open, workspace, onClose }: Props) { const agentRows = useMemo(() => { void nowTick - const now = Date.now() - const rows: AgentRow[] = [] - const seen = new Set() - - workspace.state.tabs.forEach((tab: Tab, tabIndex: number) => { - for (const sessionId of resolveTabSessions(workspace.state, tab.id)) { - if (seen.has(sessionId)) continue - seen.add(sessionId) - - const meta = workspace.state.sessions[sessionId] - if (!meta) continue - const kind = meta.kind ?? DEFAULT_PROVIDER - // Registry-driven: the modal shows every agent provider so a user in - // a big multi-provider session can bulk-close inactive agents across - // Claude, Codex, and OpenCode alike. Skipping OpenCode here would - // silently hide those agents from the modal (and the bulk-close - // preview counter would report the wrong number). - if (!isAgentProviderKind(kind)) continue - - const runtime = workspace.runtimes[sessionId] - const running = runtime?.sessionStatus === 'running' - const streaming = runtime?.streamPhase != null && runtime.streamPhase !== 'idle' - const isLive = Boolean(running || streaming) - const lastActiveAt = runtime - ? extractLatestEntryTs(runtime.entries) ?? runtime.turnStartedAt ?? null - : null - - rows.push({ - sessionId, - tabId: tab.id, - tabTitle: tab.title, - tabIndex, - kind, - cwd: meta.cwd, - cwdBase: cwdBasename(meta.cwd), - isLive, - lastActiveAt, - ageMs: lastActiveAt == null ? null : Math.max(0, now - lastActiveAt), - }) - } - }) - - rows.sort((a, b) => { - const at = a.ageMs ?? -1 - const bt = b.ageMs ?? -1 - if (at !== bt) return bt - at - if (a.cwdBase !== b.cwdBase) return a.cwdBase.localeCompare(b.cwdBase) - return a.tabIndex - b.tabIndex - }) - return rows + return buildAgentRows(workspace.state, workspace.runtimes, Date.now()) }, [workspace.runtimes, workspace.state, nowTick]) const selectedProjectSet = useMemo( @@ -198,19 +236,15 @@ export function CloseOldAgentsModal({ open, workspace, onClose }: Props) { [selectedProjects], ) - const eligibleRows = useMemo(() => { - if (thresholdMs == null) return [] - return agentRows.filter(row => { - if (row.ageMs == null || row.ageMs < thresholdMs) return false - if (!includeLive && row.isLive) return false - return true - }) - }, [agentRows, includeLive, thresholdMs]) + const eligibleRows = useMemo( + () => filterEligibleRows(agentRows, { thresholdMs, includeLive }), + [agentRows, includeLive, thresholdMs], + ) - const matchingRows = useMemo(() => { - if (scopeMode === 'all') return eligibleRows - return eligibleRows.filter(row => selectedProjectSet.has(row.cwd)) - }, [eligibleRows, scopeMode, selectedProjectSet]) + const matchingRows = useMemo( + () => filterMatchingRows(eligibleRows, { scopeMode, selectedProjects: selectedProjectSet }), + [eligibleRows, scopeMode, selectedProjectSet], + ) const projects = useMemo(() => { const matchingByProject = new Map() @@ -273,15 +307,32 @@ export function CloseOldAgentsModal({ open, workspace, onClose }: Props) { setSelectedProjects(new Set()) }, []) - /** Freshly re-read liveness for every agent session, in the shape the grant - * comparison expects. Deliberately re-derived rather than reusing the - * memoized preview rows: the whole point is to see what changed SINCE. */ - const buildCloseTargets = useCallback((ws: typeof workspace): CloseTargetSnapshot[] => - Object.keys(ws.state.sessions).map(sessionId => ({ - sessionId, - title: ws.state.sessions[sessionId]?.title ?? sessionId, - live: isSessionLiveForClose(ws.runtimes, sessionId), - })), []) + // A LIVE handle on the workspace, updated on every render. + // + // The revalidation below used to read the `workspace` captured by + // `closeMatchingAgents`'s closure. That object is the value from the render + // in which the callback was created and never changes during the loop — so + // "re-enumerate before every kill" re-derived the identical answer twelve + // times and could not detect anything. React re-renders this modal as each + // close mutates workspace state, which is what keeps this ref current. + const workspaceRef = useRef(workspace) + workspaceRef.current = workspace + + /** Re-run the FULL eligibility predicate against live state, in the shape the + * grant comparison expects. Not just liveness: age and project scope are + * what put a row in the list, so they are what a stale grant must be + * re-checked against. */ + const buildCloseTargets = useCallback((): CloseTargetSnapshot[] => { + const ws = workspaceRef.current + const rows = buildAgentRows(ws.state, ws.runtimes, Date.now()) + const eligible = filterEligibleRows(rows, { thresholdMs, includeLive }) + return filterMatchingRows(eligible, { scopeMode, selectedProjects: selectedProjectSet }) + .map(row => ({ + sessionId: row.sessionId, + title: `${row.tabTitle} · ${row.cwdBase}`, + live: row.isLive, + })) + }, [thresholdMs, includeLive, scopeMode, selectedProjectSet]) const closeMatchingAgents = useCallback(async () => { if (matchingRows.length === 0 || closing) return @@ -312,14 +363,20 @@ export function CloseOldAgentsModal({ open, workspace, onClose }: Props) { // Re-reading per iteration is affordable because the loop is already // sequential (closeSession mutates the tree, so concurrency would make // each call read a stale snapshot) and bulk cleanup is rare. - const current = buildCloseTargets(workspace) + const current = buildCloseTargets() const stillGranted = narrowGrantToCurrent([target], current) if (stillGranted.length === 0) { outcome.skipped.push(target.sessionId) continue } try { - await workspace.closeSession(target.sessionId) + // preConfirmed: this modal IS the confirmation surface. It shows the + // exact list, the count, and the live count, and requires an explicit + // click — a strictly stronger grant than the generic dialog, which + // would otherwise fire once per agent and turn a twelve-agent cleanup + // into twelve modals. The per-kill revalidation above is what keeps + // that grant honest. + await workspaceRef.current.closeSession(target.sessionId, { preConfirmed: true }) outcome.closed.push(target.sessionId) } catch (error) { // One backend refusing must not abandon the rest of the batch — the @@ -335,7 +392,7 @@ export function CloseOldAgentsModal({ open, workspace, onClose }: Props) { } finally { setClosing(false) } - }, [closing, matchingRows, onClose, workspace, showToast]) + }, [buildCloseTargets, closing, matchingRows, onClose, showToast]) return ( currentIds.has(target.sessionId)) } +export type CloseGateOutcome = + | { + ok: true + /** The set the caller is authorized to close. */ + targets: readonly CloseTargetSnapshot[] + /** Whether a dialog was actually shown — i.e. whether time passed. */ + prompted: boolean + } + | { ok: false; reason: 'declined' | 'changed' } + +/** + * The whole gate: enumerate, judge, ask, and RE-ENUMERATE before granting. + * + * WHY re-enumeration lives in here and not at the call sites: every close path + * has an await in the middle of it — the dialog — and the workspace does not + * hold still while the user reads. Agents finish, new ones spawn, a cascade + * elsewhere removes a child. The pre-dialog snapshot every caller had already + * captured was therefore a description of a workspace that no longer exists by + * the time the answer arrives, and acting on it kills whatever now occupies + * those ids. + * + * Each of the five close entry points had its own copy of "expand, ask, + * proceed", and every copy proceeded from the stale snapshot. Folding the + * re-check into the gate is the only version where a new close path cannot + * forget it: there is no way to get a `true` out of this function without the + * second enumeration having agreed. + * + * `enumerate` MUST read live state on every call — passing a captured array + * defeats the entire mechanism. + * + * Note `prompted`. When nothing was asked, no time passed, and callers can skip + * their own post-await revalidation instead of paying for a re-derivation on + * the common idle-single close. + */ +export async function runCloseConfirmationGate(input: { + enumerate: () => CloseTargetSnapshot[] + ask: ( + request: Extract, + ) => Promise +}): Promise { + const granted = input.enumerate() + const request = closeConfirmationFor(granted) + if (!request.required) return { ok: true, targets: granted, prompted: false } + + const confirmed = await input.ask(request) + if (!confirmed) return { ok: false, reason: 'declined' } + + // The user approved a specific LIST. Re-derive it and insist on the same ids. + // Refusing outright (rather than closing the intersection) is deliberate for + // single-pane closes: there is one target, so "it changed" means the thing + // they approved is not the thing that would die. Bulk flows that should + // salvage the unchanged majority use `narrowGrantToCurrent` instead. + const current = input.enumerate() + if (!grantStillMatches(granted, current)) return { ok: false, reason: 'changed' } + return { ok: true, targets: current, prompted: true } +} + /** * The subset of a grant that is still present, for a bulk flow that should * proceed with what survives rather than abandoning everything. diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts index e4227ff8..2260c2d0 100644 --- a/src/renderer/src/workspace/hook/actions/pane.ts +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -1,9 +1,11 @@ import { DEFAULT_PROVIDER } from '@shared/types/providerKind' import { - closeConfirmationFor, expandSessionCloseTargets, + expandTabCloseTargets, isSessionLiveForClose, + runCloseConfirmationGate, } from '@renderer/workspace/closeConfirmation' +import type { CloseExpansionRuntimes, CloseTargetSnapshot } from '@renderer/workspace/closeConfirmation' import { requestCloseConfirmation } from '@renderer/workspace/closeConfirmationBroker' import { useCallback, useRef } from 'react' @@ -87,6 +89,25 @@ function forgetClosedSessionDebugState(refs: WorkspaceRefs, sessionId: SessionId forgetDebugTrace(sessionId) } +/** + * The one way to skip `closeSession`'s confirmation gate. + * + * Setting `preConfirmed` is an ASSERTION, and it has a precise meaning: the + * caller has already put the full expanded target set in front of the user, + * received an explicit approval, and re-validated that set against live state + * immediately before this call. Three callers qualify today — + * `closeLinkedChildren` (cascade children, already named in the parent's + * dialog), `closeFocused` (delegating a target it just gated), and the Close + * Old Agents modal (whose whole UI is a preview-and-approve surface, and which + * re-runs its eligibility predicate before every kill). + * + * It is deliberately NOT a convenience for "this close feels safe". The default + * — omit it — is the gated path, and the Agent Activity modal's Close button is + * exactly the case that must keep paying for it: a single button in a list, no + * preview of the cascade it triggers. + */ +export type CloseSessionOptions = { preConfirmed?: boolean } + type DetachedTabChildren = { records: DetachedSessionRecord[] ids: SessionId[] @@ -112,6 +133,69 @@ function detachedTabChildren(state: WorkspaceState, tabId: string): DetachedTabC } } +/** + * Every session a PANE close will actually end. + * + * Deliberately not just `expandSessionCloseTargets`. Both grid close paths take + * the tab's DETACHED children with them when the closing pane is the tab's last + * one — see `detachedTabChildren`, and the `!parentInfo` branches below that + * build `closedSessionIds` from it. Those sessions have no tile anywhere, so a + * cascade-only enumeration reported "1 session" for a close that killed seven, + * which is the exact class of surprise the confirmation exists to prevent. + * + * The rule this encodes: the gate must count what the mutation counts. When + * that stops being true the dialog becomes a lie, and a dialog nobody can trust + * is worse than no dialog at all — it trains the user to click through. + */ +function paneCloseTargets( + state: WorkspaceState, + runtimes: CloseExpansionRuntimes, + targetId: SessionId, +): CloseTargetSnapshot[] { + const owningTab = state.tabs.find(tab => collectLeaves(tab.root).includes(targetId)) + // A detached target, or a pane inside a split: the tab survives, so only the + // linked cascade dies. + if (!owningTab || findParentSplitInfo(owningTab.root, targetId)) { + return expandSessionCloseTargets(state, runtimes, targetId) + } + return expandTabCloseTargets( + state, + runtimes, + [targetId], + detachedTabChildren(state, owningTab.id).ids, + ) +} + +/** + * Resolve which session `closeFocused` would act on, from a given state. + * + * Extracted so the SAME derivation runs before the dialog and again after it. + * The pre-dialog answer is what the user approved; if re-running it against the + * post-dialog workspace yields a different session, focus moved while they were + * reading and closing now would kill something they never saw. + */ +function resolveFocusedCloseTarget(state: WorkspaceState): { + dispatchTargetId: SessionId | null + targetId: SessionId | undefined +} { + const dispatchTargetId = state.dispatchMode + ? commandTargetSessionIdForState(state) + : null + const targetId = dispatchTargetId + ?? commandTargetSessionIdForState(state) + ?? state.tabs.find(tab => tab.id === state.activeTabId)?.focusedSessionId + return { dispatchTargetId, targetId } +} + +/** + * Why a close stopped, phrased for a toast. + * + * A silent no-op after the user clicked "Close" is indistinguishable from a + * broken button. 'declined' is the user's own choice and needs no message. + */ +const CLOSE_CHANGED_TOAST = + 'Close cancelled — these sessions changed while the dialog was open. Try again.' + function dispatchModeAfterSessionRemovals( dispatchMode: DispatchModeState | null, removedSessionIds: ReadonlySet, @@ -204,7 +288,7 @@ export function usePaneActions( attachAllDetachedForTab: (tabId: string) => Promise detachFocusedToDispatch: () => void closeFocused: () => Promise - closeSession: (targetId: SessionId) => Promise + closeSession: (targetId: SessionId, options?: CloseSessionOptions) => Promise requestBuryFocused: () => void buryFocused: (note?: string, targetSessionId?: SessionId) => void reviveBuried: (buriedId: string) => Promise @@ -213,7 +297,9 @@ export function usePaneActions( focusSessionInTab: (tabId: string, sessionId: SessionId) => void navigate: (direction: 'left' | 'right' | 'up' | 'down') => void } { - const closeSessionRef = useRef<((targetId: SessionId) => Promise) | null>(null) + const closeSessionRef = useRef< + ((targetId: SessionId, options?: CloseSessionOptions) => Promise) | null + >(null) // Spawns a new session in the parent pane's cwd, inserts a new // leaf under a fresh split node, makes the new pane focused. @@ -749,7 +835,13 @@ export function usePaneActions( id => sessions[id]?.linkedParentId === parentId, ) for (const childId of childIds) { - await closeSessionRef.current?.(childId) + // preConfirmed: the caller's gate expanded the cascade TRANSITIVELY, so + // every id reached here was already named in the dialog the user + // approved. Re-prompting per child would ask N times about one decision, + // and — worse — each re-prompt would re-enumerate a workspace the + // preceding kills had already changed, so a long cascade would abort + // itself halfway through with "these sessions changed". + await closeSessionRef.current?.(childId, { preConfirmed: true }) } }, [refs.stateRef]) @@ -1084,34 +1176,43 @@ export function usePaneActions( // onto the undo-close stack so the user can restore the pane (or // tab) with a single command within the next 2 minutes. const closeFocused = useCallback(async () => { - const snapshot = refs.stateRef.current - const dispatchTargetId = snapshot.dispatchMode - ? commandTargetSessionIdForState(snapshot) - : null - - // CONFIRMATION GATE. Runs before ANY mutation, and before the branch - // below picks a close path — the user's answer must cover the whole - // operation, not one arm of it. - // - // The target set is EXPANDED first (parent plus linked descendants, - // transitively), because the whole point is that closing one visible pane - // can end four sessions. An unexpanded set would ask about one and kill - // four, which is the failure this gate exists to prevent. + // CONFIRMATION GATE. Runs before ANY mutation, and before the branch below + // picks a close path — the user's answer must cover the whole operation, + // not one arm of it. // - // An idle single close returns `required: false` and falls straight - // through, so the common case pays nothing. - const confirmTargetId = dispatchTargetId - ?? commandTargetSessionIdForState(snapshot) - ?? snapshot.tabs.find(tab => tab.id === snapshot.activeTabId)?.focusedSessionId - if (confirmTargetId) { - const confirmation = closeConfirmationFor( - expandSessionCloseTargets(snapshot, refs.latestRuntimesRef.current, confirmTargetId), - ) - if (confirmation.required) { - const confirmed = await requestCloseConfirmation(confirmation) - if (!confirmed) return + // The gate expands the target set to everything the mutation will actually + // remove (`paneCloseTargets`) and re-checks it after the dialog, so an idle + // single close still falls straight through with no dialog and no re-work. + const approved = resolveFocusedCloseTarget(refs.stateRef.current) + if (approved.targetId) { + const targetId = approved.targetId + const gate = await runCloseConfirmationGate({ + enumerate: () => + paneCloseTargets(refs.stateRef.current, refs.latestRuntimesRef.current, targetId), + ask: requestCloseConfirmation, + }) + if (!gate.ok) { + if (gate.reason === 'changed') showToast(CLOSE_CHANGED_TOAST) + return } } + + // Re-read AFTER the gate. When nothing was prompted this is the same state + // the gate just read; when a dialog was shown it may be seconds old, and + // every derivation below — the owning tab, the undo entry, the detached + // children — was previously taken from the pre-dialog snapshot. That is how + // a confirmed close came to remove a leaf that had already gone and leave + // detached children pointing at a `projectTabId` with no tab behind it. + const snapshot = refs.stateRef.current + const resolved = resolveFocusedCloseTarget(snapshot) + if (resolved.targetId !== approved.targetId) { + // Focus moved under the dialog. The approved target is not the target any + // more, and closing "whatever is focused now" is precisely the bug the + // gate exists to stop. + showToast(CLOSE_CHANGED_TOAST) + return + } + const dispatchTargetId = resolved.dispatchTargetId if (dispatchTargetId) { // WHY Dispatch Mode delegates by the visible row's explicit id: // @@ -1129,7 +1230,7 @@ export function usePaneActions( // back to grid focus and then the first visible row in that case, so close // must use the same row-derived target or the highlighted pane and the // destructive target diverge again. - await closeSessionRef.current?.(dispatchTargetId) + await closeSessionRef.current?.(dispatchTargetId, { preConfirmed: true }) return } if (snapshot.dispatchMode) return @@ -1145,7 +1246,7 @@ export function usePaneActions( // through the normal grid close path would remove the parent tile and, // for linked children, cascade-kill the very child the user intended to // close. closeSession already owns detached explicit-id semantics. - await closeSessionRef.current?.(commandTargetId) + await closeSessionRef.current?.(commandTargetId, { preConfirmed: true }) return } const targetId = tab.focusedSessionId @@ -1262,6 +1363,7 @@ export function usePaneActions( }) }, [ closeLinkedChildren, + refs.latestRuntimesRef, refs.latestScreenRef, refs.seenUuidsRef, refs.stateRef, @@ -1281,7 +1383,39 @@ export function usePaneActions( // Uses stateRef.current for the same reason buryFocused does: the // caller's action isn't bound to whatever happens to be active. const closeSession = useCallback( - async (targetId: SessionId) => { + async (targetId: SessionId, options?: CloseSessionOptions) => { + // Cheap existence check first, so we never open a dialog about a session + // that has already gone. + const initial = refs.stateRef.current + if ( + !initial.tabs.some(t => collectLeaves(t.root).includes(targetId)) && + !initial.detachedSessions[targetId] + ) { + return + } + + // CONFIRMATION GATE. This path was previously ungated entirely, which + // made it the way around the policy rather than an implementation of it: + // the Agent Activity modal's Close button and every MCP-driven close ran + // straight through, cascade and all, with no dialog. `closeFocused` + // delegates here for its Dispatch and detached-child arms and passes + // preConfirmed, so those still ask exactly once. + if (!options?.preConfirmed) { + const gate = await runCloseConfirmationGate({ + enumerate: () => + paneCloseTargets(refs.stateRef.current, refs.latestRuntimesRef.current, targetId), + ask: requestCloseConfirmation, + }) + if (!gate.ok) { + if (gate.reason === 'changed') showToast(CLOSE_CHANGED_TOAST) + return + } + } + + // Read AFTER the gate, for the same reason closeFocused does: a dialog is + // an await, and the undo entry, the detached-children list and the + // session metadata below must describe the workspace we are about to + // mutate, not the one that existed when the user was asked. const snapshot = refs.stateRef.current const owningTab = snapshot.tabs.find(t => collectLeaves(t.root).includes(targetId)) const sessionMeta = snapshot.sessions[targetId] @@ -1434,6 +1568,7 @@ export function usePaneActions( }, [ closeLinkedChildren, + refs.latestRuntimesRef, refs.latestScreenRef, refs.seenUuidsRef, refs.stateRef, diff --git a/src/renderer/src/workspace/hook/actions/paneRecoveryOwnership.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/paneRecoveryOwnership.renderer.test.tsx index 3c3d0c14..7e1fadf3 100644 --- a/src/renderer/src/workspace/hook/actions/paneRecoveryOwnership.renderer.test.tsx +++ b/src/renderer/src/workspace/hook/actions/paneRecoveryOwnership.renderer.test.tsx @@ -8,12 +8,18 @@ import type { SessionRuntime } from '@renderer/session-runtime/state' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import type { SessionActions } from '@renderer/workspace/hook/actions/session' import type { SessionId, WorkspaceState } from '@renderer/workspace/types' +import { + __resetCloseConfirmationForTests, + currentCloseConfirmation, + resolveCloseConfirmation, +} from '@renderer/workspace/closeConfirmationBroker' import { usePaneActions } from './pane' const originalApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api') afterEach(() => { + __resetCloseConfirmationForTests() if (originalApiDescriptor) { Object.defineProperty(window, 'api', originalApiDescriptor) } else { @@ -207,8 +213,21 @@ describe('pane recovery ownership', () => { [detachedId]: emptyRuntime(), }) + // Closing the tab's last pane also kills its detached child, so this is a + // two-session close and the gate must ask. Answering it here is not test + // ceremony — it is the assertion that the dialog names BOTH sessions. + // Before the gate counted detached children, this close reported one target + // and silently took two. + let closing: Promise | undefined await act(async () => { - await harness.result.current.closeSession(paneId) + closing = harness.result.current.closeSession(paneId) + await Promise.resolve() + }) + expect(currentCloseConfirmation()?.request.targets.map(t => t.sessionId).sort()) + .toEqual([detachedId, paneId].sort()) + await act(async () => { + resolveCloseConfirmation(true) + await closing }) // WHY this assertion covers more than renderer cleanup: once the final tab diff --git a/src/renderer/src/workspace/hook/actions/tab.ts b/src/renderer/src/workspace/hook/actions/tab.ts index c39a1d55..5f979252 100644 --- a/src/renderer/src/workspace/hook/actions/tab.ts +++ b/src/renderer/src/workspace/hook/actions/tab.ts @@ -1,10 +1,10 @@ import { useCallback } from 'react' -import type { DetachedSessionRecord, SessionId, SessionKind, SessionMeta, Tab, TabId } from '@renderer/workspace/types' +import type { DetachedSessionRecord, SessionId, SessionKind, SessionMeta, Tab, TabId, WorkspaceState } from '@renderer/workspace/types' import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' import { - closeConfirmationFor, expandTabCloseTargets, + runCloseConfirmationGate, } from '@renderer/workspace/closeConfirmation' import { requestCloseConfirmation } from '@renderer/workspace/closeConfirmationBroker' import { clearLiveEntryWindowSession } from '@renderer/session-runtime/liveEntryWindow' @@ -84,32 +84,66 @@ export function useTabActions( [sessionActions, setState, showToast], ) + // Enumerate everything closing `tabId` will end, from a given snapshot. + // + // Both the gate and the kill list derive from this ONE function, so they + // cannot drift: the detached records that feed the undo entry are the same + // records the dialog counted. Detached sessions are the ones people forget — + // they have no tile in the tab on screen, so closing what looks like a + // two-pane tab can end eight agents. + const enumerateTabClose = useCallback((snapshot: WorkspaceState, tabId: TabId) => { + const tab = snapshot.tabs.find(t => t.id === tabId) + if (!tab) return null + const detachedRecords = Object.values(snapshot.detachedSessions) + .filter(entry => entry.projectTabId === tabId) + return { + tab, + tabIdx: snapshot.tabs.findIndex(t => t.id === tabId), + ids: collectLeaves(tab.root), + detachedRecords, + detachedIds: detachedRecords.map(entry => entry.sessionId), + } + }, []) + const closeTab = useCallback( async (tabId: TabId) => { - const tab = state.tabs.find(t => t.id === tabId) - if (!tab) return - - // Capture undo info before killing anything. - const tabIdx = state.tabs.findIndex(t => t.id === tabId) - const ids = collectLeaves(tab.root) - const detachedRecords = Object.values(state.detachedSessions) - .filter(entry => entry.projectTabId === tabId) - const detachedIds = detachedRecords.map(entry => entry.sessionId) - // CONFIRMATION GATE, before any kill. // - // A tab close is almost always multi-target, and the DETACHED sessions - // are the ones people forget: they have no tile in the tab on screen, so - // closing what looks like a two-pane tab can end eight agents. Expanding - // grid leaves AND detached records — each through its linked descendants - // — is what makes the count in the dialog the real one. - const confirmation = closeConfirmationFor( - expandTabCloseTargets(state, refs.latestRuntimesRef.current, ids, detachedIds), - ) - if (confirmation.required) { - const confirmed = await requestCloseConfirmation(confirmation) - if (!confirmed) return + // Reads through `stateRef`, not the `state` prop. The prop is the value + // from the render that built this callback: already one render behind + // when a burst of closes queues up, and definitively stale once the gate + // has awaited a dialog. Every derivation below — the undo entry, the kill + // list, the detached metas — came from that captured value, so a + // confirmed tab close could resurrect sessions the intervening state had + // already removed. + const gate = await runCloseConfirmationGate({ + enumerate: () => { + const snapshot = refs.stateRef.current + const plan = enumerateTabClose(snapshot, tabId) + if (!plan) return [] + return expandTabCloseTargets( + snapshot, + refs.latestRuntimesRef.current, + plan.ids, + plan.detachedIds, + ) + }, + ask: requestCloseConfirmation, + }) + if (!gate.ok) { + if (gate.reason === 'changed') { + showToast('Close cancelled — this tab changed while the dialog was open. Try again.') + } + return } + + // Re-read for the mutation itself. When nothing was prompted this is the + // same snapshot the gate just enumerated from. + const state = refs.stateRef.current + const plan = enumerateTabClose(state, tabId) + if (!plan) return + const { tab, tabIdx, ids, detachedRecords, detachedIds } = plan + const idsToKill = [...ids, ...detachedIds] const allMetas: Record = {} for (const id of ids) { @@ -196,9 +230,16 @@ export function useTabActions( setSpotlight(prev => (prev?.tabId === tabId ? null : prev)) setReaderMode(prev => (prev?.tabId === tabId ? null : prev)) }, + // No `state.*` deps any more: this callback reads through refs, which is + // the point. Depending on the state slices used to churn a new closure on + // every workspace mutation while STILL capturing a stale value inside it — + // the worst of both. [ + enumerateTabClose, + refs.latestRuntimesRef, refs.latestScreenRef, refs.seenUuidsRef, + refs.stateRef, refs.undoStackRef, sessionActions, setReaderMode, @@ -207,9 +248,6 @@ export function useTabActions( setState, setTileTabs, showToast, - state.detachedSessions, - state.sessions, - state.tabs, ], ) From ed6f5781bce209b0b70801fb8d2e98da68468abf Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 20:41:14 +0200 Subject: [PATCH 29/33] fix(commands): read the right capability, and read it in run() too Phase 5 replaced `isAgentProviderKind()` as a feature test and built the capability matrix. Then it wired four of the six consumers to the wrong row, and left two commands on the predicate it was meant to replace. view-prompts read switchTargets -> promptHistoryExtraction reload-agent read verifiedExternalResume -> inAppResume switch-provider read isAgentProviderKind -> switchTargets copy-resume-command read isAgentProviderKind -> verifiedExternalResume Nothing looked wrong, and that is the interesting part: Claude and Codex had every capability and OpenCode had none, so a transposed pair returned the same answer for all three providers. The matrix test passed because the matrix was right. Adding the two missing capabilities is what exposed it. Reload Agent respawns a session through our own spawn path with a resume id -- it never hands anyone a shell string -- and OpenCode supports that fine (`opencodeSession` takes the id as `sessionID` and replays). So the flag it was borrowing was not merely unrelated, it was hiding a working command. View Prompts likewise needs only the READ side of the transcript, which `extractLatestUserPrompts` provides by keying on Claude's `permissionMode` with a Codex exception -- so OpenCode yields an empty list, for a reason that has nothing to do with switch edges. `savedSessionListing` had no reader at all. Its real decision point is the Resume picker, which passed the focused pane's provider to `listSessionsForCwd` -- so focusing an OpenCode pane and hitting Resume opened a picker guaranteed to be empty. It now falls back to a provider main can actually enumerate, rather than hiding Resume outright: the user's saved Claude sessions in that cwd are still there and still what they want. Duplicate Agent's `run` re-checked agent-hood while its `when` checked for a transcript adapter. `when` only controls the picker row; a keybinding or programmatic dispatch reaches `run` directly, so the weaker of the two is the one that decides. The new test drives each capability independently and asserts exactly one command turns on -- a transposition fails it even when, as here, it is invisible against the real matrix. --- src/providers/providerFeatures.test.ts | 28 +++- src/providers/shared/featureCapabilities.ts | 43 +++++- .../command-palette/ui/CommandPalette.tsx | 23 +++- .../commands/sessionCommands.renderer.test.ts | 124 ++++++++++++++++++ .../workspace/commands/sessionCommands.ts | 59 ++++++--- 5 files changed, 248 insertions(+), 29 deletions(-) diff --git a/src/providers/providerFeatures.test.ts b/src/providers/providerFeatures.test.ts index cb00427f..07368929 100644 --- a/src/providers/providerFeatures.test.ts +++ b/src/providers/providerFeatures.test.ts @@ -12,6 +12,14 @@ import { getProviderFeatures } from '@providers/shared/featureCapabilities' // Rewind, Duplicate, Switch Provider and Copy Resume — all empty, rejected, // unsupported or unverified for it. This file pins the matrix so a provider // cannot inherit a feature by joining AGENT_PROVIDER_KINDS. +// +// The follow-up review found the first cut had the right idea and the wrong +// wiring: two guards were transposed (View Prompts read the switch edge list, +// Reload Agent read the shell-command flag), two commands still asked +// agent-hood, and `savedSessionListing` had no reader at all. So the matrix +// alone is not the invariant — a capability with no consumer, or a consumer +// reading someone else's capability, passes every test in this file. The +// consumers are pinned in the command-catalog tests. // --------------------------------------------------------------------------- describe('provider feature matrix', () => { @@ -24,6 +32,8 @@ describe('provider feature matrix', () => { savedSessionListing: true, transcriptRewind: true, transcriptDuplicate: true, + promptHistoryExtraction: true, + inAppResume: true, switchTargets: ['codex'], verifiedExternalResumeCommand: true, }, @@ -31,6 +41,8 @@ describe('provider feature matrix', () => { savedSessionListing: true, transcriptRewind: true, transcriptDuplicate: true, + promptHistoryExtraction: true, + inAppResume: true, switchTargets: ['claude'], verifiedExternalResumeCommand: true, }, @@ -38,6 +50,12 @@ describe('provider feature matrix', () => { savedSessionListing: false, transcriptRewind: false, transcriptDuplicate: false, + promptHistoryExtraction: false, + // The one capability OpenCode HAS. Pinned explicitly because Reload + // Agent was hidden for it by a guard reading the unrelated + // shell-command flag, and this row is what makes that regression + // visible if anyone re-conflates the two. + inAppResume: true, switchTargets: [], verifiedExternalResumeCommand: false, }, @@ -50,6 +68,8 @@ describe('provider feature matrix', () => { savedSessionListing: false, transcriptRewind: false, transcriptDuplicate: false, + promptHistoryExtraction: false, + inAppResume: false, switchTargets: [], verifiedExternalResumeCommand: false, }) @@ -68,8 +88,12 @@ describe('provider feature matrix', () => { expect(opencode.savedSessionListing).toBe(false) expect(opencode.transcriptRewind).toBe(false) expect(opencode.transcriptDuplicate).toBe(false) + expect(opencode.promptHistoryExtraction).toBe(false) expect(opencode.verifiedExternalResumeCommand).toBe(false) expect(opencode.switchTargets).toEqual([]) + // NOT in the list above: in-app resume genuinely works for OpenCode. + // Grouping it with the rest is the exact mistake that hid Reload Agent. + expect(opencode.inAppResume).toBe(true) }) it('keeps switch edges directional and symmetric only where declared', () => { @@ -82,13 +106,15 @@ describe('provider feature matrix', () => { } }) - it('requires every agent provider to declare all five capabilities', () => { + it('requires every agent provider to declare every capability', () => { // Adding a provider must fail here until it answers each question, rather // than silently inheriting broad agent powers. const required = [ 'savedSessionListing', 'transcriptRewind', 'transcriptDuplicate', + 'promptHistoryExtraction', + 'inAppResume', 'switchTargets', 'verifiedExternalResumeCommand', ] diff --git a/src/providers/shared/featureCapabilities.ts b/src/providers/shared/featureCapabilities.ts index 07bf05bf..13ee5207 100644 --- a/src/providers/shared/featureCapabilities.ts +++ b/src/providers/shared/featureCapabilities.ts @@ -41,6 +41,28 @@ export type ProviderFeatureCapabilities = { * Codex→OpenCode one. */ switchTargets: readonly AgentProviderKind[] + /** + * The transcript parser can pull USER PROMPTS out of this provider's entries. + * + * Distinct from `transcriptRewind`, which needs a full adapter able to + * rewrite history — this is only the read side, and it is what View Prompts + * and the Rewind picker list. `extractLatestUserPrompts` currently keys off + * Claude's `permissionMode` field (with an explicit Codex exception), so a + * provider whose entries lack it yields an empty list and the modal opens + * blank. + */ + promptHistoryExtraction: boolean + /** + * The session manager can respawn this provider with a `resumeSessionId` and + * have it replay history — what Reload Agent does. + * + * NOT the same capability as `verifiedExternalResumeCommand`, and conflating + * the two got Reload Agent hidden for OpenCode, which supports in-app resume + * perfectly well (`opencodeSession.ts` passes the id through as `sessionID` + * and replays). One is about a shell string we hand the user; this one is + * about our own spawn path. A provider can have either without the other. + */ + inAppResume: boolean /** * `resumeCommand` has been VERIFIED against the real CLI. False means the * template is a plausible guess, and Copy Resume Command would hand the user @@ -60,6 +82,8 @@ export const NO_PROVIDER_FEATURES: ProviderFeatureCapabilities = { savedSessionListing: false, transcriptRewind: false, transcriptDuplicate: false, + promptHistoryExtraction: false, + inAppResume: false, switchTargets: [], verifiedExternalResumeCommand: false, } @@ -87,6 +111,8 @@ const FEATURES_BY_KIND: Record = savedSessionListing: true, transcriptRewind: true, transcriptDuplicate: true, + promptHistoryExtraction: true, + inAppResume: true, switchTargets: ['codex'], verifiedExternalResumeCommand: true, }, @@ -95,21 +121,32 @@ const FEATURES_BY_KIND: Record = savedSessionListing: true, transcriptRewind: true, transcriptDuplicate: true, + promptHistoryExtraction: true, + inAppResume: true, switchTargets: ['claude'], verifiedExternalResumeCommand: true, }, - // Everything false, and that is the correction rather than a slight. + // Almost everything false, and that is the correction rather than a slight. // OpenCode has no saved-session listing in main, no transcript adapter for // rewind or duplicate to operate on, no switch edge in either direction, and // its resumeCommand template is an unverified guess (see the note in its - // identity descriptor). Agent-hood previously granted all five implicitly, so - // the commands appeared enabled and then did nothing. + // identity descriptor). Agent-hood previously granted all of it implicitly, + // so the commands appeared enabled and then did nothing. // // Flip these individually as each adapter becomes real; never as a group. opencode: { savedSessionListing: false, transcriptRewind: false, transcriptDuplicate: false, + // `extractLatestUserPrompts` filters on Claude's `permissionMode` field + // unless the kind is codex, so OpenCode entries all fall through and View + // Prompts opens empty. + promptHistoryExtraction: false, + // TRUE, and the one place OpenCode is not behind. `opencodeSession` + // accepts a resume id and replays the session's messages, so Reload Agent + // works — it was only hidden because the guard read the flag for the + // unrelated shell-command feature. + inAppResume: true, switchTargets: [], verifiedExternalResumeCommand: false, }, diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index ddbbcc7d..e3013ec4 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -1,5 +1,6 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import type { AgentProviderKind } from '@shared/types/providerKind' +import { getProviderFeatures } from '@providers/shared/featureCapabilities' import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' @@ -372,9 +373,23 @@ function OpenCommandPalette({ // resume picker list Claude sessions and spawn a Claude pane, even though // the picker header already displayed "resume opencode". Terminal / unknown // kinds have no resume story, so fall back to the default provider. - const resumeProvider: AgentProviderKind = isAgentProviderKind(focusedProvider) - ? focusedProvider - : DEFAULT_PROVIDER + // Which provider's saved sessions the Resume picker lists. + // + // The focused pane's provider, but ONLY if main can actually enumerate saved + // sessions for it. `listSessionsForCwd` has no index for OpenCode, so + // focusing an OpenCode pane and hitting Resume opened a picker that would + // always be empty — a dead end presented as a working feature, and the reason + // `savedSessionListing` existed with nothing reading it. + // + // Falling back to the default provider rather than hiding Resume entirely: + // the user's saved Claude sessions in this cwd are still there and still what + // they most likely want. Hiding the command would take a working action away + // because an unrelated pane happens to be focused. + const resumeProvider: AgentProviderKind = + isAgentProviderKind(focusedProvider) && + getProviderFeatures(focusedProvider).savedSessionListing + ? focusedProvider + : DEFAULT_PROVIDER const enterResumeMode = useCallback(async () => { if (!focusedCwd) return @@ -389,7 +404,7 @@ function OpenCommandPalette({ setSessions([]) } setSessionsLoading(false) - }, [focusedCwd, focusedProvider]) + }, [focusedCwd, resumeProvider]) // Buried panes are scoped to the ACTIVE TAB. The natural temptation // is to show every buried pane in the workspace ("they're paused diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts b/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts index 9f1edb57..531ceec7 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts @@ -207,3 +207,127 @@ describe('built-in MCP provider command policy', () => { }) }) }) + +// --------------------------------------------------------------------------- +// Which capability gates which command. +// +// The provider matrix test pins the DATA. It cannot catch a consumer reading +// the wrong row, and the nine-agent review found four that did: View Prompts +// read the switch edge list, Reload Agent read the verified-shell-command flag, +// and Switch Provider and Copy Resume Command still asked plain agent-hood. +// +// None of it showed up in behaviour, which is the whole problem. Claude and +// Codex have every capability and OpenCode had none, so a transposed pair +// produced identical answers for all three providers. It would have started +// lying the first time a provider declared one capability without the other — +// exactly what happened when `inAppResume` was added and OpenCode turned out to +// support it. +// +// So this drives the capabilities INDEPENDENTLY: enable one, assert exactly one +// command turns on. A transposition fails here even when it is invisible +// against the real matrix. +// --------------------------------------------------------------------------- + +const capabilityOverride = vi.hoisted(() => ({ current: null as Record | null })) + +vi.mock('@providers/shared/featureCapabilities', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getProviderFeatures: (kind: string | undefined) => + capabilityOverride.current + ? { ...actual.NO_PROVIDER_FEATURES, ...capabilityOverride.current } + : actual.getProviderFeatures(kind), + } +}) + +describe('capability gates', () => { + afterEach(() => { + capabilityOverride.current = null + }) + + function contextWithAgent(): CommandContext { + return { + workspace: { + state: { + activeTabId: 'tab', + dispatchMode: null, + sessions: { + agent: { + cwd: '/projects/app', + kind: 'claude', + providerSessionId: 'provider-abc', + }, + }, + tabs: [{ + id: 'tab', + focusedSessionId: 'agent', + root: { type: 'leaf', sessionId: 'agent' }, + }], + }, + } as unknown as Workspace, + ui: {}, + flags: {}, + } as unknown as CommandContext + } + + /** Every command whose availability is supposed to depend on a capability. */ + const GATED = [ + 'view-prompts', + 'rewind-to-prompt', + 'reload-agent', + 'copy-resume-command', + 'duplicate-agent', + 'switch-provider', + ] as const + + function availableUnder(features: Record): string[] { + capabilityOverride.current = features + const ctx = contextWithAgent() + return GATED.filter(id => { + const command = sessionCommands.find(candidate => candidate.id === id) + if (!command) throw new Error(`command ${id} is missing`) + return command.when ? command.when(ctx) : true + }) + } + + it.each([ + ['promptHistoryExtraction', true, ['view-prompts']], + ['transcriptRewind', true, ['rewind-to-prompt']], + ['inAppResume', true, ['reload-agent']], + ['verifiedExternalResumeCommand', true, ['copy-resume-command']], + ['transcriptDuplicate', true, ['duplicate-agent']], + ['switchTargets', ['codex'], ['switch-provider']], + ])('%s enables exactly %s', (capability, value, expected) => { + expect(availableUnder({ [capability as string]: value })).toEqual(expected) + }) + + it('offers nothing to a provider that declares nothing', () => { + // The OpenCode-before-`inAppResume` case, and the reason agent-hood was the + // wrong predicate: it was true here and turned all six on. + expect(availableUnder({})).toEqual([]) + }) + + it('keeps run() as strict as when() for the destructive-ish ones', async () => { + // `when` only controls the picker ROW. A keybinding, a native menu item or + // a programmatic dispatch reaches `run` directly, so a `run` that re-checks + // something weaker than its `when` is a real hole rather than a style + // point. Duplicate Agent's did exactly that — `when` asked for a transcript + // adapter, `run` asked for agent-hood. + capabilityOverride.current = {} + const duplicateSession = vi.fn() + Object.defineProperty(window, 'api', { + configurable: true, + value: { duplicateSession }, + }) + const command = sessionCommands.find(candidate => candidate.id === 'duplicate-agent') + if (!command) throw new Error('Duplicate Agent command is missing') + + await command.run({ + ...contextWithAgent(), + ui: { closePalette: vi.fn() }, + } as unknown as CommandContext) + + expect(duplicateSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 58b6dbc2..e05a955c 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -79,10 +79,13 @@ export const sessionCommands: CommandDef[] = [ if (!sessionId) return false const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - // Driven by the explicit switch EDGE list, not by agent-hood. "Can - // switch" is meaningless without a destination, and translation is - // directional — OpenCode has no edge in either direction today. - return getProviderFeatures(kind).switchTargets.length > 0 + // Needs the transcript parser to RECOGNIZE this provider's user prompts. + // This guard was `switchTargets.length > 0` — the Switch Provider + // predicate, transposed here. It happened to hide the same providers, so + // nothing looked wrong; it would have started reporting the wrong answer + // the moment a switch edge was added for a provider whose prompts we + // cannot parse, or an adapter for one with no switch edge. + return getProviderFeatures(kind).promptHistoryExtraction }, run: ({ workspace, ui }) => { const sessionId = commandTargetSessionId(workspace) @@ -651,11 +654,13 @@ export const sessionCommands: CommandDef[] = [ if (!sessionId) return false const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - // Requires a VERIFIED external resume form. An unverified template hands - // the user a shell command that may not work, which is worse than not - // offering it — they paste it into a terminal and blame their setup. + // Reload respawns the session THROUGH US with a resume id; it never + // hands the user a shell string. Gating it on + // `verifiedExternalResumeCommand` was the wrong flag in the direction + // that costs a working feature: OpenCode replays history on resume just + // fine, and this guard hid the command for it anyway. return ( - getProviderFeatures(kind).verifiedExternalResumeCommand && + getProviderFeatures(kind).inAppResume && Boolean(meta?.providerSessionId) ) }, @@ -761,18 +766,24 @@ export const sessionCommands: CommandDef[] = [ if (!sessionId) return false const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - return isAgentProviderKind(kind) && Boolean(meta?.providerSessionId) + // The ONE command the verified-template flag is actually about: this + // produces a string the user pastes into their own terminal. An + // unverified template is worse than an absent command, because it fails + // in their shell and they blame their setup. Agent-hood proved nothing + // here — it is what offered OpenCode a guessed `opencode --resume` form. + return ( + getProviderFeatures(kind).verifiedExternalResumeCommand && + Boolean(meta?.providerSessionId) + ) }, run: async ({ workspace, ui }) => { const sessionId = commandTargetSessionId(workspace) if (!sessionId) return const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - // Registry-driven runtime narrow — must match the `when` predicate - // above so a command that visibly enabled doesn't silently no-op on - // OpenCode. `buildProviderResumeCommand` already accepts any - // AgentProviderKind and pulls the CLI shape from the registry identity - // descriptor (#394 phase 2c-2), so no downstream change is needed. + // Runtime narrow mirroring the `when` predicate exactly, so a command + // that renders enabled cannot silently no-op. + if (!getProviderFeatures(kind).verifiedExternalResumeCommand) return if (!isAgentProviderKind(kind) || !meta?.providerSessionId) return const command = buildProviderResumeCommand(kind, meta.cwd, meta.providerSessionId) @@ -812,12 +823,13 @@ export const sessionCommands: CommandDef[] = [ if (!sessionId) return const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - // Registry-driven runtime narrow — mirrors the `when` predicate. The old - // two-provider literal here was the exact reason "Duplicate Agent" - // silently no-op'd on OpenCode panes even after Phase 7 landed the - // provider. `duplicateSession`'s preload/main handler already takes - // `provider: AgentProviderKind` (src/preload/api/provider.ts:50), so the - // downstream path fans out through the registry — nothing else changes. + // Runtime narrow mirroring the `when` predicate EXACTLY. It previously + // re-checked agent-hood while `when` checked `transcriptDuplicate`, so + // the two disagreed for any agent provider without an adapter: `when` + // correctly hid the row, but a programmatic dispatch or a stale + // keybinding reaching `run` sailed past this weaker check and called + // `duplicateSession` on a transcript nothing can project. + if (!getProviderFeatures(kind).transcriptDuplicate) return if (!isAgentProviderKind(kind) || !meta?.providerSessionId) return try { const { newProviderSessionId } = await window.api.duplicateSession({ @@ -892,7 +904,12 @@ export const sessionCommands: CommandDef[] = [ const meta = workspace.state.sessions[sessionId] if (!meta) return false const kind = meta?.kind ?? DEFAULT_PROVIDER - return isAgentProviderKind(kind) + // The explicit switch EDGE list, not agent-hood. "Can switch" is + // meaningless without naming a destination, and translation is + // directional — a Claude→Codex adapter is not automatically the reverse. + // OpenCode has no edge either way, so agent-hood offered it a switch + // that the main-side translator rejects. + return getProviderFeatures(kind).switchTargets.length > 0 }, run: ({ workspace }) => workspace.switchFocusedProvider(), }, From 350bb785b3c0adc39d124606cadef836be485d63 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 20:47:55 +0200 Subject: [PATCH 30/33] fix(mcp): put the close authorization where a user can actually answer `close_agent` was refused 100% of the time in production. Phase 8 replaced the tool's prose permission rule with a real grant store in main: single-use, short-lived, checked at the bridge, default deny. The mechanism is sound and its unit tests pass. Nothing ever called `issueCloseGrant`. The only reference to it in the entire tree was its own definition, so `consume` never found a grant, and every close request hit the refusal path. The tests could not see this because they tested the store, and the store works. What was missing was the other half -- an issuer -- and an authorization mechanism with no issuer is not strict, it is broken. It shipped looking like a security improvement. The layer was the mistake. A grant has to be issued by a USER ACTION, and main has no user action that means "the user asked this agent to close that agent". The renderer does: it has the user. So the check moves to the line that actually mutates -- `closeSession`, which R2 just made the single gated funnel -- via `requireConfirmation`, which overrides the idle-single exemption. That exemption is about a human aiming at a pane they can see with Undo Close behind them; none of it transfers to a close a model decided to make. The dialog names the requester ("Agent X is asking to close this agent"), because a confirmation the user did not summon and cannot attribute is unanswerable. `close_run` confirms once for the whole run rather than N times, since twelve dialogs is how "are you sure?" stops meaning anything. Deletes `src/mcp/shared/closeGrant.ts`, its tests, and the bridge methods. The new tests drive the gate from a caller, which is what the old ones could not do. --- .../AgentManagementBridge.test.ts | 49 --------- .../agentManagement/AgentManagementBridge.ts | 62 ++++------- src/mcp/shared/closeGrant.test.ts | 91 ---------------- src/mcp/shared/closeGrant.ts | 101 ------------------ .../workspace/commands/sessionCommands.ts | 2 +- .../src/workspace/closeConfirmation.test.ts | 75 ++++++++++++- .../src/workspace/closeConfirmation.ts | 49 +++++++-- .../src/workspace/hook/actions/pane.ts | 21 +++- src/renderer/src/workspace/hook/index.ts | 23 +++- .../src/workspace/orchestrationMcp.ts | 44 +++++++- 10 files changed, 216 insertions(+), 301 deletions(-) delete mode 100644 src/mcp/shared/closeGrant.test.ts delete mode 100644 src/mcp/shared/closeGrant.ts diff --git a/src/main/agentManagement/AgentManagementBridge.test.ts b/src/main/agentManagement/AgentManagementBridge.test.ts index fcfbcefb..c251392e 100644 --- a/src/main/agentManagement/AgentManagementBridge.test.ts +++ b/src/main/agentManagement/AgentManagementBridge.test.ts @@ -140,7 +140,6 @@ describe('AgentManagementBridge', () => { // Issuing it here is what makes this a test of CASCADE REFUSAL rather than // of authorization — without it the bridge refuses earlier, for a different // and less interesting reason. - bridge.issueCloseGrant('caller', 'parent') const closing = bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'parent' }) const request = sentRendererRequests[0] as { requestId: string } bridge.resolve({ @@ -277,7 +276,6 @@ describe('AgentManagementBridge', () => { const bridge = new AgentManagementBridge(managerFixture() as never) const listing = bridge.listAgents({ callerSessionId: 'caller' }) const request = sentRendererRequests[0] as { requestId: string } - bridge.issueCloseGrant('caller', 'agent-1') const closing = bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'agent-1' }) const rejected = expect(listing).rejects.toThrow('timed out') const blocked = expect(closing).rejects.toMatchObject({ code: 'renderer_unresponsive' }) @@ -366,50 +364,3 @@ describe('AgentManagementBridge', () => { }) }) }) - -describe('AgentManagementBridge close authorization', () => { - beforeEach(() => { - sentRendererRequests.length = 0 - }) - - const newBridge = () => new AgentManagementBridge(managerFixture() as never) - - it('refuses a close with no user grant', async () => { - // THE point of the change. The permission rule used to live entirely in the - // tool description — sentences telling the model not to close without an - // explicit user request. That cannot fail closed; this can. - const bridge = newBridge() - await expect( - bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'victim' }), - ).rejects.toThrow(/no user authorization/) - }) - - it('refuses a second close on one grant', async () => { - // Single-use: "the user asked me to close agent X" is true about one - // moment, not for every later turn. - const bridge = newBridge() - bridge.issueCloseGrant('caller', 'victim') - const first = bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'victim' }) - const request = sentRendererRequests[0] as { requestId: string } - bridge.resolve({ - requestId: request.requestId, - type: 'close-agent', - ok: true, - closedSessionId: 'victim', - } as never) - await first - await expect( - bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'victim' }), - ).rejects.toThrow(/no user authorization/) - }) - - it('does not let a grant for one agent authorize another', async () => { - // The failure prose could not prevent: a model told to close X deciding Y - // also looks stale. - const bridge = newBridge() - bridge.issueCloseGrant('caller', 'agent-x') - await expect( - bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'agent-y' }), - ).rejects.toThrow(/no user authorization/) - }) -}) diff --git a/src/main/agentManagement/AgentManagementBridge.ts b/src/main/agentManagement/AgentManagementBridge.ts index e225f47c..4c1fae03 100644 --- a/src/main/agentManagement/AgentManagementBridge.ts +++ b/src/main/agentManagement/AgentManagementBridge.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto' -import { createCloseGrantStore } from '@mcp/shared/closeGrant.js' import { stat } from 'node:fs/promises' import { sendToMainWindow } from '@main/window/mainWindow.js' @@ -226,55 +225,30 @@ export class AgentManagementBridge { return response.delivery } - /** - * Grant store for user-authorized closes. - * - * Lives on the bridge because the bridge is the single mutation boundary for - * every close request — a grant checked anywhere earlier could be bypassed by - * a caller that reached the bridge another way. - */ - private readonly closeGrants = createCloseGrantStore() - - /** Record that the user explicitly authorized this caller to close this - * target. Issued from a user action, never from a model request. */ - issueCloseGrant(callerSessionId: string, sessionId: string): void { - this.closeGrants.issue(callerSessionId, sessionId) - } - - /** Drop outstanding grants for a session that has gone away. */ - revokeCloseGrantsForSession(sessionId: string): void { - this.closeGrants.revokeForSession(sessionId) - } - async closeAgent(params: { callerSessionId: string sessionId: string }): Promise<{ closedSessionId: string }> { - // ENFORCEABLE AUTHORIZATION, checked at the mutation boundary. + // AUTHORIZATION LIVES IN THE RENDERER, at the line that mutates. // - // The tool's permission rule used to live entirely in its description and - // the domain instructions — several sentences telling the model never to - // close an agent unless the user's current request named that specific - // agent. That is a request to a language model, not a check: it cannot fail - // closed, cannot be tested, and a model that reads "clean up this project" - // as authorization produces an irreversible kill. + // The rule used to be prose in this tool's description — several sentences + // telling the model never to close an agent unless the user's current + // request named that specific agent. That is a request to a language model, + // not a check: it cannot fail closed and cannot be tested. // - // The grant is single-use and short-lived, so authorization the user gave - // once for one agent cannot be replayed for another, or reused in a later - // turn after they have moved on. Default is DENY. - if (!this.closeGrants.consume(params.callerSessionId, params.sessionId)) { - this.journal?.record({ - area: 'mcp.agent_management', - name: 'close_agent.denied_no_grant', - data: { callerSessionId: params.callerSessionId, sessionId: params.sessionId }, - }) - throw new Error( - 'close_agent refused: no user authorization for this agent. Closing an agent ' - + 'requires the user to explicitly ask for that specific agent to be closed; ' - + 'inspecting, reading, or being asked what is safe to clean up does not ' - + 'authorize it.', - ) - } + // The first replacement was a single-use grant store here on the bridge. + // Right instinct, wrong layer. A grant has to be ISSUED by a user action, + // and main has no user action that means "the user asked this agent to + // close that agent" — so nothing ever called `issueCloseGrant`, every + // close_agent was denied, and the tool was dead in a way the green build + // hid completely. + // + // The renderer's close gate CAN ask, because the user is there. The + // close-agent handler passes `requireConfirmation`, which overrides the + // idle-single exemption the policy grants a human pressing ⌘W — that + // exemption is about someone aiming at a pane they can see, and none of it + // transfers to a close a model decided to make. A declined dialog rejects + // the request, and that rejection arrives here as a failed response. const response = await this.request({ requestId: randomUUID(), diff --git a/src/mcp/shared/closeGrant.test.ts b/src/mcp/shared/closeGrant.test.ts deleted file mode 100644 index cb59dfee..00000000 --- a/src/mcp/shared/closeGrant.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { CLOSE_GRANT_TTL_MS, createCloseGrantStore } from '@mcp/shared/closeGrant' - -// --------------------------------------------------------------------------- -// The plan's line is "Prose is not an enforceable grant." These assert the -// properties that make this one enforceable: it is scoped to an exact pair, -// it expires, and it cannot be spent twice. -// --------------------------------------------------------------------------- - -const NOW = 1_000_000 - -describe('close grant', () => { - it('authorizes exactly the caller/target pair it was issued for', () => { - const store = createCloseGrantStore() - store.issue('caller', 'target', NOW) - expect(store.peek('caller', 'target', NOW)).toBe(true) - }) - - it('does not authorize a different target', () => { - // The failure the prose rule could not prevent: a model told to close agent - // X deciding agent Y also looks stale. - const store = createCloseGrantStore() - store.issue('caller', 'target', NOW) - expect(store.consume('caller', 'other-target', NOW)).toBe(false) - }) - - it('does not authorize a different caller', () => { - // One agent's authorization is not another's, even for the same victim. - const store = createCloseGrantStore() - store.issue('caller', 'target', NOW) - expect(store.consume('other-caller', 'target', NOW)).toBe(false) - }) - - it('refuses when no grant was ever issued', () => { - // The default is DENY. A model that decides on its own to close an agent - // hits this, regardless of what it inferred from age or transcript. - const store = createCloseGrantStore() - expect(store.consume('caller', 'target', NOW)).toBe(false) - }) - - it('is single-use', () => { - // "The user asked me to close agent X" is true about one moment. A reusable - // grant would let a later turn spend authorization given once. - const store = createCloseGrantStore() - store.issue('caller', 'target', NOW) - expect(store.consume('caller', 'target', NOW)).toBe(true) - expect(store.consume('caller', 'target', NOW)).toBe(false) - }) - - it('expires', () => { - const store = createCloseGrantStore() - store.issue('caller', 'target', NOW) - expect(store.consume('caller', 'target', NOW + CLOSE_GRANT_TTL_MS + 1)).toBe(false) - }) - - it('is still valid one millisecond before expiry', () => { - const store = createCloseGrantStore() - store.issue('caller', 'target', NOW) - expect(store.consume('caller', 'target', NOW + CLOSE_GRANT_TTL_MS - 1)).toBe(true) - }) - - it('removes an expired grant on the attempt that finds it dead', () => { - // Otherwise a caller could poll until a clock race let it through. - const store = createCloseGrantStore() - store.issue('caller', 'target', NOW) - store.consume('caller', 'target', NOW + CLOSE_GRANT_TTL_MS + 1) - expect(store.size()).toBe(0) - }) - - it('revokes grants naming a session that went away, in either direction', () => { - // A grant naming a dead caller could be replayed by a session that reused - // the id; one naming a dead target is meaningless. - const store = createCloseGrantStore() - store.issue('caller', 'target', NOW) - store.issue('other', 'caller', NOW) - store.issue('unrelated', 'elsewhere', NOW) - store.revokeForSession('caller') - expect(store.size()).toBe(1) - expect(store.peek('unrelated', 'elsewhere', NOW)).toBe(true) - }) - - it('lets a fresh issue replace a spent one', () => { - // A user who genuinely asks twice gets authorized twice. - const store = createCloseGrantStore() - store.issue('caller', 'target', NOW) - store.consume('caller', 'target', NOW) - store.issue('caller', 'target', NOW) - expect(store.consume('caller', 'target', NOW)).toBe(true) - }) -}) diff --git a/src/mcp/shared/closeGrant.ts b/src/mcp/shared/closeGrant.ts deleted file mode 100644 index d7d5f5f5..00000000 --- a/src/mcp/shared/closeGrant.ts +++ /dev/null @@ -1,101 +0,0 @@ -// --------------------------------------------------------------------------- -// Enforceable authorization for the Agent Management close tool. -// -// THE PROBLEM, in the governance plan's words: "Prose is not an enforceable -// grant." The tool's permission rule lived entirely in its description and the -// domain instructions — several sentences telling the model never to close an -// agent unless the user's current request explicitly asked for that specific -// agent. That is a request to a language model, not a check. It cannot fail -// closed, it cannot be tested, and a model that misreads "clean up this -// project" as authorization produces an irreversible kill. -// -// A grant is issued by a USER ACTION, names exactly one caller and one target, -// expires, and is consumed on use. The close path checks it at the moment of -// mutation. A model that decides on its own to close an agent now hits a -// missing grant and is refused, regardless of what it inferred. -// -// WHY single-use and short-lived: "the user asked me to close agent X" is true -// about one moment, not forever. A durable grant would let a later turn — or a -// later misreading — reuse authorization the user gave once for something -// specific. Expiry bounds the window; consumption bounds the count. -// --------------------------------------------------------------------------- - -export type CloseGrant = { - callerSessionId: string - targetSessionId: string - /** Epoch ms after which the grant is dead. */ - expiresAt: number -} - -/** - * Default validity window. - * - * Long enough for a model to receive a user's instruction and issue the tool - * call (a turn plus tool latency), short enough that it cannot survive into a - * later conversational turn where the user has moved on. - */ -export const CLOSE_GRANT_TTL_MS = 2 * 60 * 1000 - -export type CloseGrantStore = { - /** Record a user-authorized close. Overwrites any prior grant for the pair. */ - issue: (callerSessionId: string, targetSessionId: string, now?: number) => CloseGrant - /** - * Consume a grant, returning whether one was valid. ALWAYS removes the - * matching grant, valid or expired — a failed attempt must not leave a - * still-usable authorization behind for a retry the user did not sanction. - */ - consume: (callerSessionId: string, targetSessionId: string, now?: number) => boolean - /** Non-consuming check, for diagnostics. Never use this to authorize. */ - peek: (callerSessionId: string, targetSessionId: string, now?: number) => boolean - /** Drop everything for a session that has gone away. */ - revokeForSession: (sessionId: string) => void - size: () => number -} - -const key = (caller: string, target: string) => `${caller}\u0000${target}` - -export function createCloseGrantStore(ttlMs: number = CLOSE_GRANT_TTL_MS): CloseGrantStore { - const grants = new Map() - - const isLive = (grant: CloseGrant | undefined, now: number): grant is CloseGrant => - grant !== undefined && grant.expiresAt > now - - return { - issue(callerSessionId, targetSessionId, now = Date.now()) { - const grant: CloseGrant = { - callerSessionId, - targetSessionId, - expiresAt: now + ttlMs, - } - grants.set(key(callerSessionId, targetSessionId), grant) - return grant - }, - - consume(callerSessionId, targetSessionId, now = Date.now()) { - const id = key(callerSessionId, targetSessionId) - const grant = grants.get(id) - // Delete unconditionally: an expired grant is removed on the attempt that - // found it dead, so a caller cannot poll until a race lets it through. - grants.delete(id) - return isLive(grant, now) - }, - - peek(callerSessionId, targetSessionId, now = Date.now()) { - return isLive(grants.get(key(callerSessionId, targetSessionId)), now) - }, - - revokeForSession(sessionId) { - // A closed or crashed session's outstanding grants must not survive it — - // in either direction. A grant naming a dead caller could be replayed by - // a session that reused the id, and one naming a dead target is - // meaningless. - for (const [id, grant] of grants) { - if (grant.callerSessionId === sessionId || grant.targetSessionId === sessionId) { - grants.delete(id) - } - } - }, - - size: () => grants.size, - } -} diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index e05a955c..f814dbf0 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -533,7 +533,7 @@ export const sessionCommands: CommandDef[] = [ pickerVisibility: 'advanced', surface: 'session', title: 'Agent Management MCP', - description: '**What it does:** Reloads the focused **Claude or Codex agent** with project-wide Agent Code management tools on or off.\n\n**Use when:** You want this agent to inventory, inspect, prompt, or—only after an explicit user request—close other agents in its project.\n\n**Notes:** Read operations include visible, detached, and buried agents without waking them. Closing has extra authorization and cascade guards.', + description: '**What it does:** Reloads the focused **Claude or Codex agent** with project-wide Agent Code management tools on or off.\n\n**Use when:** You want this agent to inventory, inspect, prompt, or close other agents in its project.\n\n**Notes:** Read operations include visible, detached, and buried agents without waking them. Every close it attempts asks **you** to confirm first, and cascades are refused outright.', keywords: ['mcp', 'agent management', 'agents', 'project', 'transcripts', 'cleanup', 'prompt', 'close', 'enable', 'disable', 'reload', 'claude', 'codex'], when: ({ workspace }) => { return targetSupportsBuiltInMcpDomain(workspace, 'agent_management') diff --git a/src/renderer/src/workspace/closeConfirmation.test.ts b/src/renderer/src/workspace/closeConfirmation.test.ts index 36cd065d..7368de1c 100644 --- a/src/renderer/src/workspace/closeConfirmation.test.ts +++ b/src/renderer/src/workspace/closeConfirmation.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { closeConfirmationFor, describePartialClose, + runCloseConfirmationGate, grantStillMatches, expandSessionCloseTargets, expandTabCloseTargets, @@ -199,3 +200,75 @@ describe('target expansion', () => { if (result.required) expect(result.summary).toContain('3 sessions') }) }) + +// --------------------------------------------------------------------------- +// Forced confirmation, for closes a MODEL initiated. +// +// This is what replaced the main-side close-grant store. That store was the +// right idea in the wrong layer: a grant must be issued by a user action, main +// has no user action meaning "the user asked this agent to close that agent", +// so nothing ever issued one and every close_agent call was denied. The tool +// was 100% broken and every test passed, because the tests only ever exercised +// the store — never the fact that nobody fed it. +// +// The lesson these cases pin: an authorization mechanism has to be tested +// end-to-end from a real caller, or it tests only itself. +// --------------------------------------------------------------------------- +describe('forced close confirmation', () => { + const idleSingle = [{ sessionId: 's1', title: 'Reviewer', live: false }] + + it('asks about an idle single close that the policy would wave through', async () => { + // The idle-single exemption is about a human aiming at a pane they can see, + // with Undo Close behind them. None of that is true when a model decides. + expect(closeConfirmationFor(idleSingle).required).toBe(false) + + const ask = vi.fn().mockResolvedValue(true) + const outcome = await runCloseConfirmationGate({ + enumerate: () => [...idleSingle], + ask, + force: { headline: 'Agent “Coordinator” is asking to close this agent.' }, + }) + + expect(ask).toHaveBeenCalledTimes(1) + expect(ask.mock.calls[0][0]).toMatchObject({ + required: true, + reason: 'irreversible', + }) + // The headline is not decoration. A dialog the user did not summon has to + // say who summoned it, or it is unanswerable. + expect(ask.mock.calls[0][0].summary).toContain('Coordinator') + expect(outcome).toMatchObject({ ok: true, prompted: true }) + }) + + it('refuses when the user declines', async () => { + const outcome = await runCloseConfirmationGate({ + enumerate: () => [...idleSingle], + ask: vi.fn().mockResolvedValue(false), + force: { headline: 'An agent is asking to close this agent.' }, + }) + expect(outcome).toEqual({ ok: false, reason: 'declined' }) + }) + + it('still re-validates the approved set', async () => { + // Forcing the prompt must not skip the second enumeration — a model-driven + // close is the case where the workspace is LEAST likely to be holding still. + let call = 0 + const outcome = await runCloseConfirmationGate({ + enumerate: () => (call++ === 0 ? [...idleSingle] : [{ sessionId: 'other', title: 'X', live: false }]), + ask: vi.fn().mockResolvedValue(true), + force: { headline: 'An agent is asking to close this agent.' }, + }) + expect(outcome).toEqual({ ok: false, reason: 'changed' }) + }) + + it('does not invent a dialog when there is nothing to close', async () => { + const ask = vi.fn() + const outcome = await runCloseConfirmationGate({ + enumerate: () => [], + ask, + force: { headline: 'An agent is asking to close this agent.' }, + }) + expect(ask).not.toHaveBeenCalled() + expect(outcome).toMatchObject({ ok: true, prompted: false }) + }) +}) diff --git a/src/renderer/src/workspace/closeConfirmation.ts b/src/renderer/src/workspace/closeConfirmation.ts index 02d23679..1634423a 100644 --- a/src/renderer/src/workspace/closeConfirmation.ts +++ b/src/renderer/src/workspace/closeConfirmation.ts @@ -44,6 +44,18 @@ export type CloseConfirmationRequest = summary: string } +/** The count-and-liveness sentence, shared by the judged and forced paths so + * the two never describe the same set differently. */ +export function summarizeCloseTargets(targets: readonly CloseTargetSnapshot[]): string { + const live = targets.filter(target => target.live) + if (targets.length === 1) { + const only = targets[0] + return only.live ? `${only.title} is still working.` : `This closes ${only.title}.` + } + const liveNote = live.length > 0 ? `, ${live.length} still working` : '' + return `This closes ${targets.length} sessions${liveNote}.` +} + /** * Does this close need the user to confirm, and what should it say? * @@ -58,8 +70,6 @@ export function closeConfirmationFor( ): CloseConfirmationRequest { if (targets.length === 0) return { required: false } - const live = targets.filter(target => target.live) - if (targets.length === 1) { const only = targets[0] if (!only.live) { @@ -70,7 +80,7 @@ export function closeConfirmationFor( required: true, reason: 'running', targets, - summary: `${only.title} is still working. Close it anyway?`, + summary: `${summarizeCloseTargets(targets)} Close it anyway?`, } } @@ -82,12 +92,11 @@ export function closeConfirmationFor( // working" from "several things die", and the live count is carried in the // summary. The earlier split derived 'cascade' vs 'bulk' from liveness rather // than from origin, so the value was both wrong and unread. - const liveNote = live.length > 0 ? `, ${live.length} still working` : '' return { required: true, reason: 'multi', targets, - summary: `This closes ${targets.length} sessions${liveNote}.`, + summary: summarizeCloseTargets(targets), } } @@ -151,10 +160,36 @@ export async function runCloseConfirmationGate(input: { ask: ( request: Extract, ) => Promise + /** + * Ask even when the policy would not. + * + * The idle-single exemption is a statement about a HUMAN pressing ⌘W: they + * aimed at a pane, they can see it, and Undo Close covers the mistake. None + * of that transfers to a close a MODEL decided to make. There is no aim, the + * user may not be looking, and "it's undoable" is doing far more work when + * nobody knows it happened. + * + * `headline` names who is asking. A dialog that says "Close these sessions?" + * when the user did not ask to close anything is not a confirmation, it is a + * riddle. + */ + force?: { headline: string } }): Promise { const granted = input.enumerate() - const request = closeConfirmationFor(granted) - if (!request.required) return { ok: true, targets: granted, prompted: false } + if (granted.length === 0) return { ok: true, targets: granted, prompted: false } + const judged = closeConfirmationFor(granted) + const request: Extract | null = + input.force + ? { + required: true, + reason: 'irreversible', + targets: granted, + summary: `${input.force.headline} ${summarizeCloseTargets(granted)}`, + } + : judged.required + ? judged + : null + if (!request) return { ok: true, targets: granted, prompted: false } const confirmed = await input.ask(request) if (!confirmed) return { ok: false, reason: 'declined' } diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts index 2260c2d0..58d2e5cf 100644 --- a/src/renderer/src/workspace/hook/actions/pane.ts +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -106,7 +106,25 @@ function forgetClosedSessionDebugState(refs: WorkspaceRefs, sessionId: SessionId * exactly the case that must keep paying for it: a single button in a list, no * preview of the cascade it triggers. */ -export type CloseSessionOptions = { preConfirmed?: boolean } +export type CloseSessionOptions = { + preConfirmed?: boolean + /** + * Confirm even when the policy would let this through silently. + * + * For closes a MODEL initiated. `headline` names the requester, so the dialog + * explains why it appeared — the user did not ask to close anything, and a + * bare "Close these sessions?" would be a riddle. + * + * This is what makes the Agent Management close tool's authorization + * enforceable. The first attempt put a single-use grant store in main and + * checked it at the bridge, which was the right instinct in the wrong place: + * a grant is issued by a USER ACTION, and there is no user action in main + * that corresponds to "the user asked this agent to close that agent". So + * nothing ever issued one, every `close_agent` call was denied, and the + * feature was dead. The renderer CAN ask, at the exact line that mutates. + */ + requireConfirmation?: { headline: string } +} type DetachedTabChildren = { records: DetachedSessionRecord[] @@ -1405,6 +1423,7 @@ export function usePaneActions( enumerate: () => paneCloseTargets(refs.stateRef.current, refs.latestRuntimesRef.current, targetId), ask: requestCloseConfirmation, + force: options?.requireConfirmation, }) if (!gate.ok) { if (gate.reason === 'changed') showToast(CLOSE_CHANGED_TOAST) diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts index b81b66be..9bcdff5b 100644 --- a/src/renderer/src/workspace/hook/index.ts +++ b/src/renderer/src/workspace/hook/index.ts @@ -744,7 +744,28 @@ export function useWorkspace( if (!buried) throw new Error('agent_not_found') await killBuriedSessionRef.current(buried.id) } else { - await closeOrchestrationSessionRef.current(request.sessionId) + // THE authorization check for the Agent Management close tool. + // + // The tool's rule used to be prose in its description ("never close + // an agent unless the user's current request named that specific + // agent"), which is a request to a language model, not a check. The + // first fix put a single-use grant store in main — right instinct, + // wrong layer: a grant is issued by a USER ACTION, and main has no + // user action meaning "the user asked this agent to close that one". + // Nothing ever issued one, so every close_agent call was denied and + // the tool was dead in a way no test noticed. + // + // Here, at the line that actually mutates, we CAN ask. Forced, so an + // idle target does not slip through the human-ergonomics exemption + // the policy grants ⌘W. Declining rejects the tool call. + const caller = current.sessions[request.callerSessionId]?.title + await closeOrchestrationSessionRef.current(request.sessionId, { + requireConfirmation: { + headline: caller + ? `Agent “${caller}” is asking to close this agent.` + : 'An agent is asking to close this agent.', + }, + }) } await window.api.resolveAgentManagementRequest({ requestId: request.requestId, diff --git a/src/renderer/src/workspace/orchestrationMcp.ts b/src/renderer/src/workspace/orchestrationMcp.ts index 93309b83..2956701b 100644 --- a/src/renderer/src/workspace/orchestrationMcp.ts +++ b/src/renderer/src/workspace/orchestrationMcp.ts @@ -114,17 +114,39 @@ export function readOrchestrationRunOutputs(params: { .filter((output): output is OrchestrationAgentOutput => output !== null) } +/** + * How `closeSession` is called from the orchestration MCP surface. + * + * Typed here rather than inlined so the options — specifically + * `requireConfirmation` — cannot be dropped by a caller that copies the older + * one-argument shape. + */ +export type OrchestrationCloseSession = ( + sessionId: SessionId, + options?: { preConfirmed?: boolean; requireConfirmation?: { headline: string } }, +) => Promise + +/** "Agent “Reviewer” is asking to close an agent it started." — the sentence + * that tells the user why a dialog they did not summon just appeared. */ +function closeRequestHeadline(state: WorkspaceState, parentSessionId: string): string { + const title = state.sessions[parentSessionId]?.title + const who = title ? `Agent “${title}”` : 'An agent' + return `${who} is asking to close an agent it started.` +} + export async function closeOrchestrationAgent(params: { state: WorkspaceState parentSessionId: string sessionId: string - closeSession: (sessionId: SessionId) => Promise + closeSession: OrchestrationCloseSession }): Promise { const meta = params.state.sessions[params.sessionId] if (!meta || !isVisibleToOrchestrationParent(meta, params.parentSessionId)) { throw new Error('Orchestration agent not found for this parent session.') } - await params.closeSession(params.sessionId) + await params.closeSession(params.sessionId, { + requireConfirmation: { headline: closeRequestHeadline(params.state, params.parentSessionId) }, + }) return { closedSessionIds: [params.sessionId] } } @@ -132,7 +154,7 @@ export async function closeOrchestrationRun(params: { state: WorkspaceState parentSessionId: string runId?: string - closeSession: (sessionId: SessionId) => Promise + closeSession: OrchestrationCloseSession }): Promise { const sessionIds = matchingOrchestrationSessionIds( params.state, @@ -141,9 +163,21 @@ export async function closeOrchestrationRun(params: { ) const closedSessionIds: string[] = [] const skippedSessionIds: string[] = [] - for (const sessionId of sessionIds) { + const headline = closeRequestHeadline(params.state, params.parentSessionId) + for (const [index, sessionId] of sessionIds.entries()) { try { - await params.closeSession(sessionId) + // Confirm ONCE, on the first agent, and let the rest ride that answer. + // + // Forcing per-agent confirmation on a run of twelve would produce twelve + // dialogs, and a user clicking through twelve dialogs is not confirming + // anything — it is the reason "are you sure?" stopped meaning anything. + // The first dialog names the run's requester; declining it throws, which + // the catch below turns into a skip, and the remaining agents then hit + // the same declined gate rather than dying silently. + await params.closeSession(sessionId, { + requireConfirmation: index === 0 ? { headline } : undefined, + preConfirmed: index > 0, + }) closedSessionIds.push(sessionId) } catch { skippedSessionIds.push(sessionId) From be8be812a5b0a26d019cadbddebe090277757c97 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 26 Jul 2026 21:07:38 +0200 Subject: [PATCH 31/33] fix(commands): enforce the states and contexts that were only decorative Five mechanisms that computed an answer nobody acted on. `status('unavailable')` claimed, in its own docstring, to be "what lets the picker grey a row instead of offering it as ordinary and executable". Only the greying was real. Admission called `commandApplicable` directly and never looked at the state, so a row rendered muted and labelled "Unavailable" ran normally on Enter. The gateway now admits through `resolveCommandAvailability`, which is the one place that honours both an explicit `unavailableReason` and an unavailable status -- and it carries the reason out in the outcome, so a caller can say why instead of clicking into nothing. The Settings keybinding editor hardcoded `context: 'global'` when checking conflicts. 'global' overlaps everything, so binding a grid-only chord reported a conflict with a dispatch-only command that can never be live at the same time -- the exact distinction the context system and its disjoint -pair matrix exist to draw, thrown away at the single call site that needed it. It now reads the command's declared context. Replace was offered whenever any owner was a command, even when a RESERVED owner also held the chord. Accepting it stripped the other command's binding and installed yours, and the chord still would not fire, because the reserved owner still had it: a destructive edit that cannot achieve the thing it was for. Reserved owners now suppress Replace entirely. `CATEGORY_ORDER` was a hand-listed array that grouping filtered to, so a command in a category nobody remembered to add would not appear in Settings at all and therefore could not be rebound. Now `Record`, which makes a new category a compile error until it has a position. Ctrl+C, Ctrl+D and the readline editing keys were absent from the reserved table. The agent pane is a terminal and `useKeybinds` bails when one owns input, so a command bound there renders with a chord in Settings and never fires in the place users spend their time. DELETED, because they did nothing: - Phase 2's target pinning (`resolveCommandTarget`, `resolveCommandInvocation`, `targetStillValid`, `CommandDef.targetKind`). Its own docstring admitted `run` has no target parameter, so the pinned id was computed, carried, and ignored at the one moment it mattered. No command ever set `targetKind`. It read as a safety mechanism while providing none -- worse than absence, because the next reader would trust it. The real fix for that class of bug landed in the close gate, which re-reads live state around its own await instead of trusting a snapshot taken at dispatch. - `CommandState.truth` ('persisted' | 'runtime' | 'effective'), written by two constructors onto every state and read by nothing. Its motivating case -- Tail reporting on because only Tail All can turn it off -- is carried by `detail`, which is actually rendered. - `syntaxError` in the keybinding editor: set to null in three places, never to a message, with a whole error panel behind it. - `label: openClosed ? 'Mixed' : 'Mixed'`. --- .../command-keybindings/reservations.ts | 18 ++ .../command-palette/commandState.test.ts | 15 +- .../features/command-palette/commandState.ts | 38 ++-- .../command-palette/executeCommand.ts | 28 ++- .../lib/agentIndexCommand.test.ts | 2 +- .../command-palette/resolveInvocation.test.ts | 157 --------------- .../command-palette/resolveInvocation.ts | 187 ++++-------------- .../src/features/command-palette/types.ts | 48 ----- .../settings/ui/CommandKeybindingsRow.tsx | 75 +++++-- .../workspace/commands/layoutCommands.ts | 2 +- .../workspace/commands/paneCommands.ts | 9 +- .../commands/sessionCommands.renderer.test.ts | 1 - .../workspace/commands/sessionCommands.ts | 11 +- 13 files changed, 164 insertions(+), 427 deletions(-) diff --git a/src/renderer/src/features/command-keybindings/reservations.ts b/src/renderer/src/features/command-keybindings/reservations.ts index b84b6b5d..ee83fd98 100644 --- a/src/renderer/src/features/command-keybindings/reservations.ts +++ b/src/renderer/src/features/command-keybindings/reservations.ts @@ -128,6 +128,24 @@ export const RESERVED_INTERACTIONS: readonly ReservedInteraction[] = [ context: 'global', owner: 'Tiled tab resize (after numbered selection)', }, + { + // The agent pane IS a terminal, and these go to the process, not to us. + // + // `useKeybinds` bails whenever a terminal owns input, so a command bound to + // Ctrl+C would render in Settings with a chord and then never fire in the + // one place users spend their time. That is the same "appears bound, does + // nothing" failure the native-menu roles above are listed to prevent, and + // it belongs in the same table for the same reason: the conflict checker + // can only warn about owners it has been told exist. + // + // 'global' rather than a terminal context because there is no such context + // — and inventing one would be worse than this slight over-reservation: + // these chords have universal terminal meanings, and handing one to an app + // command would be surprising even where it technically could fire. + bindings: ['Ctrl+C', 'Ctrl+D', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+E', 'Ctrl+U', 'Ctrl+W'], + context: 'global', + owner: 'Terminal / agent process (SIGINT, EOF, and readline editing)', + }, { // Feed picker navigation. While the Copy Assistant or Copy Code Block // picker owns input, these four keys move/confirm/cancel the selection. diff --git a/src/renderer/src/features/command-palette/commandState.test.ts b/src/renderer/src/features/command-palette/commandState.test.ts index 6ecb185b..89f81720 100644 --- a/src/renderer/src/features/command-palette/commandState.test.ts +++ b/src/renderer/src/features/command-palette/commandState.test.ts @@ -36,7 +36,7 @@ describe('tone is derived, never authored', () => { // Provider badges on Reload/Switch/Copy Resume are context, not enabled // state. Accent tone made them read as live toggles. expect(describeCommandState(value('Claude')).tone).toBe('neutral') - expect(describeCommandState(value('Codex', { truth: 'persisted' })).tone).toBe('neutral') + expect(describeCommandState(value('Codex', {})).tone).toBe('neutral') }) }) @@ -50,16 +50,15 @@ describe('vocabularies', () => { }) }) -describe('effective versus owned truth', () => { +describe('states this command cannot change', () => { it('carries the explanation for a state this command cannot change', () => { // Tail's motivating case: auto-follow is on because Tail All turned it on, // and invoking Tail cannot turn it off. The old flat "On (all)" label went - // through the same chip as a plain On and explained nothing. - const state = toggle(true, { - truth: 'effective', - detail: 'On via Auto-follow All Visible Agents', - }) - expect(state).toMatchObject({ kind: 'toggle', value: 'on', truth: 'effective' }) + // through the same chip as a plain On and explained nothing. `detail` is + // what conveys it — a parallel `truth: 'effective'` marker was carried for + // a while and no surface ever read it. + const state = toggle(true, { detail: 'On via Auto-follow All Visible Agents' }) + expect(state).toMatchObject({ kind: 'toggle', value: 'on' }) expect(describeCommandState(state).detail).toBe('On via Auto-follow All Visible Agents') }) diff --git a/src/renderer/src/features/command-palette/commandState.ts b/src/renderer/src/features/command-palette/commandState.ts index dc797e6d..0583baf2 100644 --- a/src/renderer/src/features/command-palette/commandState.ts +++ b/src/renderer/src/features/command-palette/commandState.ts @@ -5,9 +5,9 @@ import type { CommandState } from '@renderer/features/command-palette/types' // // The old shape was `{ label: string; tone?: 'neutral'|'accent'|'danger' }`, // and the renderer uppercased every label into one chip. That cannot express -// whether the text is a boolean, a selected value, contextual information, -// EFFECTIVE-versus-owned truth, an unavailable capability, or async progress — -// so the audit found all six being rendered identically: +// whether the text is a boolean, a selected value, contextual information, an +// unavailable capability, or async progress — so the audit found all of them +// rendered identically: // // - Tail showed "On (all)", a state invoking Tail cannot turn off, because // it is owned by Tail All. @@ -25,26 +25,23 @@ import type { CommandState } from '@renderer/features/command-palette/types' // what it means, so colour and meaning cannot drift apart. // --------------------------------------------------------------------------- -/** - * Where a state's truth comes from. - * - * `persisted` — a stored preference. `runtime` — live process/session state. - * `effective` — what the user actually experiences, which may be produced by - * something OTHER than this command. Tail is the motivating case: it reports - * `effective` on, because Tail All is what turned it on and only Tail All can - * turn it off. - */ -export type CommandStateTruth = 'persisted' | 'runtime' | 'effective' +// NOTE: there was a `CommandStateTruth` here ('persisted' | 'runtime' | +// 'effective'), threaded through `toggle` and `value` and stored on every +// state. Nothing ever read it — not `describeCommandState`, not the palette +// row, not Settings. Its motivating case (Tail reporting `effective` on, +// because only Tail All can turn it off) is real, but it is carried by the +// `detail` string, which IS rendered. A field that only ever gets written is +// not a distinction the app makes; it is a distinction someone intended to +// make. Removed until a surface actually renders differently for it. /** A boolean capability or preference. */ export function toggle( value: boolean | 'mixed', - options: { truth?: CommandStateTruth; detail?: string } = {}, + options: { detail?: string } = {}, ): CommandState { return { kind: 'toggle', value: value === 'mixed' ? 'mixed' : value ? 'on' : 'off', - truth: options.truth ?? 'runtime', ...(options.detail ? { detail: options.detail } : {}), } } @@ -63,7 +60,6 @@ export function panel( return { kind: 'toggle', value: open ? 'on' : 'off', - truth: 'runtime', vocabulary: 'open-closed', ...(options.detail ? { detail: options.detail } : {}), } @@ -77,12 +73,11 @@ export function panel( */ export function value( label: string, - options: { truth?: CommandStateTruth; detail?: string } = {}, + options: { detail?: string } = {}, ): CommandState { return { kind: 'value', label, - truth: options.truth ?? 'runtime', ...(options.detail ? { detail: options.detail } : {}), } } @@ -120,10 +115,12 @@ export type CommandStatePresentation = { export function describeCommandState(state: CommandState): CommandStatePresentation { switch (state.kind) { case 'toggle': { - const openClosed = state.vocabulary === 'open-closed' if (state.value === 'mixed') { return { - label: openClosed ? 'Mixed' : 'Mixed', + // Same word in both vocabularies. It was written as a ternary with + // identical branches, which reads as a deliberate distinction and is + // not one — "Mixed" is right for both On/Off and Open/Closed. + label: 'Mixed', // Accent, not neutral: mixed means SOMETHING is on, and rendering it // like off would tell the user the opposite of what is happening. tone: 'accent', @@ -132,6 +129,7 @@ export function describeCommandState(state: CommandState): CommandStatePresentat } } const on = state.value === 'on' + const openClosed = state.vocabulary === 'open-closed' return { label: openClosed ? (on ? 'Open' : 'Closed') : on ? 'On' : 'Off', tone: on ? 'accent' : 'neutral', diff --git a/src/renderer/src/features/command-palette/executeCommand.ts b/src/renderer/src/features/command-palette/executeCommand.ts index 431cd422..28a4c7fa 100644 --- a/src/renderer/src/features/command-palette/executeCommand.ts +++ b/src/renderer/src/features/command-palette/executeCommand.ts @@ -1,5 +1,5 @@ import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' -import { commandApplicable } from '@renderer/features/command-palette/registry' +import { resolveCommandAvailability } from '@renderer/features/command-palette/resolveInvocation' import { recordCommandUse } from '@renderer/features/command-palette/lib/recentCommandHistory' import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' @@ -62,8 +62,9 @@ export type CommandDispatchOutcome = | { status: 'ran'; id: string; source: CommandInvocationSource } /** No command with this id exists in the catalog. A menu/caller bug. */ | { status: 'unknown'; id: string; source: CommandInvocationSource } - /** The command exists but does not apply right now. */ - | { status: 'unavailable'; id: string; source: CommandInvocationSource } + /** The command exists but does not apply right now. `reason` is the resolver's + * explanation, so a caller can SAY why instead of clicking into nothing. */ + | { status: 'unavailable'; id: string; source: CommandInvocationSource; reason: string } /** The command threw synchronously or its promise rejected. */ | { status: 'failed'; id: string; source: CommandInvocationSource; error: unknown } /** An identical invocation was already in flight; this one was dropped. */ @@ -143,8 +144,16 @@ export async function dispatchCommand( // Fresh admission, evaluated now rather than when a list was last rendered. // The palette's rows can be a frame stale, a menu click arrives arbitrarily // late, and a chord bypassed these checks entirely before this existed. - if (!commandApplicable(command, ctx)) { - return { status: 'unavailable', id, source } + // + // Through `resolveCommandAvailability`, not the raw `commandApplicable` + // predicate: that resolver is the one place that also honours an explicit + // `unavailableReason` and a `getState` reporting `status('unavailable')`. + // Calling the predicate directly here is what let a row rendered greyed and + // labelled "Unavailable" execute normally on Enter — the state said the + // capability was missing and admission never asked. + const availability = resolveCommandAvailability(command, ctx) + if (!availability.available) { + return { status: 'unavailable', id, source, reason: availability.reason } } return runGuarded({ @@ -182,8 +191,11 @@ export async function dispatchResolvedRow(options: { const { row, source, ctx, reportError } = options const command = findCommand(row.id) - if (command && !commandApplicable(command, ctx)) { - return { status: 'unavailable', id: row.id, source } + if (command) { + const availability = resolveCommandAvailability(command, ctx) + if (!availability.available) { + return { status: 'unavailable', id: row.id, source, reason: availability.reason } + } } return runGuarded({ @@ -264,7 +276,7 @@ function isTransientRowId(id: string): boolean { */ export function canDispatchCommand(id: string, ctx: CommandContext): boolean { const command = findCommand(id) - return command ? commandApplicable(command, ctx) : false + return command ? resolveCommandAvailability(command, ctx).available : false } function findCommand(id: string): CommandDef | undefined { diff --git a/src/renderer/src/features/command-palette/lib/agentIndexCommand.test.ts b/src/renderer/src/features/command-palette/lib/agentIndexCommand.test.ts index 98d23482..eb7c2bda 100644 --- a/src/renderer/src/features/command-palette/lib/agentIndexCommand.test.ts +++ b/src/renderer/src/features/command-palette/lib/agentIndexCommand.test.ts @@ -22,7 +22,7 @@ describe('agent index palette command', () => { const command = buildAgentIndexCommand(target, focusAgentByPaneLabel) expect(command.title).toBe('Go to B5 · Fix focus routing') - expect(command.state).toEqual({ kind: 'value', label: 'codex', truth: 'runtime' }) + expect(command.state).toEqual({ kind: 'value', label: 'codex' }) expect(isAgentIndexCommand(command)).toBe(true) await command.run({} as never) diff --git a/src/renderer/src/features/command-palette/resolveInvocation.test.ts b/src/renderer/src/features/command-palette/resolveInvocation.test.ts index d5abe6e3..183e47ee 100644 --- a/src/renderer/src/features/command-palette/resolveInvocation.test.ts +++ b/src/renderer/src/features/command-palette/resolveInvocation.test.ts @@ -2,9 +2,6 @@ import { describe, expect, it } from 'vitest' import { resolveCommandAvailability, - resolveCommandInvocation, - resolveCommandTarget, - targetStillValid, } from '@renderer/features/command-palette/resolveInvocation' import { toggle } from '@renderer/features/command-palette/commandState' import { makeTestCommandContext } from '@renderer/features/command-palette/testing/commandContextHarness' @@ -18,46 +15,6 @@ const base: CommandDef = { run: () => {}, } -describe('resolveCommandTarget', () => { - it('resolves no target for a command that declares none', () => { - // Inventing a target is worse than having none: it produces a confident - // wrong answer, which is how an app-wide panel toggle started rendering - // session-scoped state. - expect(resolveCommandTarget(base, makeTestCommandContext())).toEqual({ kind: 'none' }) - }) - - it('defaults a session-surface command to a session target', () => { - const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) - const command: CommandDef = { ...base, surface: 'session' } - expect(resolveCommandTarget(command, ctx)).toEqual({ kind: 'session', id: 's1' }) - }) - - it('resolves to none when a session command has no focused session', () => { - // An empty or stale tiled lane returns null from the target resolver BY - // DESIGN. That must surface as "no target", never as a fallback to some - // other row the user is not looking at. - const command: CommandDef = { ...base, surface: 'session' } - expect(resolveCommandTarget(command, makeTestCommandContext())).toEqual({ kind: 'none' }) - }) - - it('does not give an app command a session target even when one is focused', () => { - const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) - expect(resolveCommandTarget(base, ctx)).toEqual({ kind: 'none' }) - }) - - it('resolves an explicit project target from the focused cwd', () => { - const ctx = makeTestCommandContext({ focusedCwd: '/repo' }) - const command: CommandDef = { ...base, targetKind: 'project' } - expect(resolveCommandTarget(command, ctx)).toEqual({ kind: 'project', id: '/repo' }) - }) - - it('lets an explicit targetKind override the surface default', () => { - const command: CommandDef = { ...base, surface: 'session', targetKind: 'app' } - const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) - expect(resolveCommandTarget(command, ctx)).toEqual({ kind: 'app' }) - }) -}) - describe('resolveCommandAvailability', () => { it('hides a mode-irrelevant command without explaining', () => { // Product decision: a grid-spatial command in Dispatch points at a layout @@ -114,117 +71,3 @@ describe('resolveCommandAvailability', () => { expect(resolveCommandAvailability(command, ctx)).toMatchObject({ presentation: 'hide' }) }) }) - -describe('resolveCommandInvocation', () => { - it('resolves target, availability, state and execute from one read', () => { - const command: CommandDef = { - ...base, - surface: 'session', - getState: () => toggle(true), - } - const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) - const invocation = resolveCommandInvocation(command, ctx) - - expect(invocation.commandId).toBe('x.test') - expect(invocation.target).toEqual({ kind: 'session', id: 's1' }) - expect(invocation.availability).toEqual({ available: true }) - expect(invocation.state).toEqual(toggle(true)) - }) - - it('does not evaluate state for an unavailable command', () => { - // Evaluating getState for an excluded command was wasted work at best and - // at worst read a target admission had just rejected. - let evaluated = false - const command: CommandDef = { - ...base, - when: () => false, - getState: () => { - evaluated = true - return toggle(true) - }, - } - const invocation = resolveCommandInvocation(command, makeTestCommandContext()) - expect(invocation.state).toBeNull() - expect(evaluated).toBe(false) - }) - - it('keeps the resolved target stable when focus moves after resolution', () => { - // Focus really moves between resolve and execute: the workspace state the - // context points at is MUTATED, which is what a Dispatch selection change - // does to the live store. - // - // An earlier version of this test constructed a second context and threw it - // away (`void laterCtx`), so nothing changed and the assertion only proved - // that `execute()` forwards the object it was handed — it would have passed - // for `() => command.run(whateverIsFocusedNow)` too. - const command: CommandDef = { ...base, surface: 'session', run: () => {} } - const ctx = makeTestCommandContext({ focusedSessionId: 's1', activeTabId: 'tab-a' }) - - const invocation = resolveCommandInvocation(command, ctx) - expect(invocation.target).toEqual({ kind: 'session', id: 's1' }) - - // Simulate the user moving the Dispatch selection to another session. - const state = ctx.workspace.state as unknown as { - sessions: Record - tabs: { focusedSessionId: string }[] - } - state.sessions.s2 = { kind: 'claude', cwd: '/repo' } - state.tabs[0].focusedSessionId = 's2' - - // Re-resolving now yields s2 — proving the state change is real and visible. - expect(resolveCommandTarget(command, ctx)).toEqual({ kind: 'session', id: 's2' }) - // The already-resolved invocation still names s1. - expect(invocation.target).toEqual({ kind: 'session', id: 's1' }) - }) - - it('is honest about what execute() currently carries', () => { - // SCOPE NOTE, deliberately asserted rather than left to a comment: - // `CommandDef.run` takes only a context — it has no target parameter — so - // the pinned target is NOT yet threaded into execution. `execute()` calls - // `run(ctx)`, and every session command still resolves its own target - // inside `run`. - // - // So this module currently provides the pinned IDENTITY (for state display - // and for `targetStillValid` at a mutation boundary) but does not yet - // enforce it. Closing that gap requires giving `run` the resolved target, - // which is Phase 7 work and touches every session command. - let receivedArgs = -1 - const command: CommandDef = { - ...base, - surface: 'session', - run: (...args: unknown[]) => { - receivedArgs = args.length - }, - } - const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) - void resolveCommandInvocation(command, ctx).execute() - expect(receivedArgs).toBe(1) - }) -}) - -describe('targetStillValid', () => { - it('accepts a session that still exists', () => { - const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) - expect(targetStillValid({ kind: 'session', id: 's1' }, ctx)).toBe(true) - }) - - it('rejects a session that disappeared between pinning and mutation', () => { - // The gap admission cannot cover: a destructive command may await a - // confirmation modal or a kill round-trip, and the target can vanish in - // that window. Rejecting is mandatory — falling back to whatever is focused - // would let a confirmation granted for agent A authorize killing B. - const ctx = makeTestCommandContext({ focusedSessionId: 's1' }) - expect(targetStillValid({ kind: 'session', id: 'gone' }, ctx)).toBe(false) - }) - - it('rejects a project target once focus moved to another project', () => { - const ctx = makeTestCommandContext({ focusedCwd: '/other' }) - expect(targetStillValid({ kind: 'project', id: '/repo' }, ctx)).toBe(false) - }) - - it('accepts targets with no identity to invalidate', () => { - const ctx = makeTestCommandContext() - expect(targetStillValid({ kind: 'app' }, ctx)).toBe(true) - expect(targetStillValid({ kind: 'none' }, ctx)).toBe(true) - }) -}) diff --git a/src/renderer/src/features/command-palette/resolveInvocation.ts b/src/renderer/src/features/command-palette/resolveInvocation.ts index 1648fecb..32f4bba1 100644 --- a/src/renderer/src/features/command-palette/resolveInvocation.ts +++ b/src/renderer/src/features/command-palette/resolveInvocation.ts @@ -1,91 +1,37 @@ import { commandApplicable, surfaceApplicable } from '@renderer/features/command-palette/registry' -import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' import type { CommandAvailability, CommandContext, CommandDef, - CommandTarget, - ResolvedCommandInvocation, } from '@renderer/features/command-palette/types' // --------------------------------------------------------------------------- -// Phase 2: one invocation, one read of state. +// ADMISSION: may this invocation proceed, and if not, does the user get told? // -// The audit's "target scope is invisible" and "target-sensitive commands -// independently resolve in when/getState/run" findings are the same bug seen -// from two angles. A command like `reload-agent` asked "which session?" three -// separate times — once to decide whether to appear, once to compute its -// badge, and once to actually reload. Nothing forced those three answers to -// agree, so a focus change between palette render and Enter could show you -// session A's provider badge and reload session B. +// WHAT USED TO BE HERE, and why it is gone. Phase 2 also built target PINNING — +// `resolveCommandTarget`, `resolveCommandInvocation`, `targetStillValid`, and a +// `CommandDef.targetKind` field — to make a command resolve "which session?" +// once instead of three times. // -// This module resolves target, availability, state and execute TOGETHER, from -// a single read. They cannot disagree because they were never computed apart. +// It never worked, and its own docstring said so: `CommandDef.run` takes only a +// context and has no target parameter, so `execute` called `run(ctx)` and every +// session command re-resolved its own target internally anyway. The pinned id +// was computed, carried, and then ignored at the one moment it mattered. No +// command ever set `targetKind`; nothing outside its own test file ever called +// any of it. It read as a safety mechanism while providing no safety, which is +// worse than its absence — a future reader would have trusted it. +// +// Where the real fix landed instead: at the mutation boundary, in the close +// gate (`closeConfirmation.ts`), which re-reads live state around its own await +// rather than trusting any snapshot taken at dispatch. That is the shape this +// problem actually has. Pinning at dispatch cannot help a command that mutates +// two seconds later; only re-reading at the mutation can, and a command that +// re-reads has no use for a pin. +// +// If target pinning returns, it has to start by threading the target INTO +// `run`. Anything short of that is this module again. // --------------------------------------------------------------------------- -/** - * What kind of target a command needs, defaulting from its surface. - * - * WHY derived rather than declared on all 30-odd session commands: `surface: - * 'session'` ALREADY means "acts on the current command-target session, in - * both Grid and Dispatch" — that is the field's documented definition. Making - * every such command repeat the same `targetKind: 'session'` line would be - * thirty edits that can drift out of agreement with the surface they sit next - * to, and a new session command that forgot the line would silently resolve to - * no target and skip pinning entirely. - * - * Everything else defaults to `'none'` and must OPT IN. That asymmetry is the - * safe direction: a command that should have pinned a target and didn't is a - * visible bug (it does nothing), while a command that pins a target it should - * not have is an invisible one (it acts on the wrong thing). - */ -function declaredTargetKind(command: CommandDef): NonNullable { - if (command.targetKind) return command.targetKind - return command.surface === 'session' ? 'session' : 'none' -} - -/** - * Pin the concrete thing this invocation acts on. - * - * A command that declares no `targetKind` resolves to `{kind: 'none'}` rather - * than being handed whatever session happens to be focused. That asymmetry is - * deliberate: the audit found app-scoped panel toggles reading the focused - * session and rendering session-scoped state for a workspace-wide setting. - * Inventing a target is worse than having none, because it produces a - * confident wrong answer. - */ -export function resolveCommandTarget( - command: CommandDef, - ctx: CommandContext, -): CommandTarget { - switch (declaredTargetKind(command)) { - case 'session': { - // The Dispatch-aware resolver is already the single source of truth for - // "which session is the user visibly commanding". Note it returns null - // for an empty or stale tiled lane BY DESIGN — that is an explicit "no - // target", never a fallback to some other row. - const id = commandTargetSessionId(ctx.workspace) - return id ? { kind: 'session', id } : { kind: 'none' } - } - case 'project': { - const id = ctx.flags.focusedCwd - return id ? { kind: 'project', id } : { kind: 'none' } - } - case 'app': - return { kind: 'app' } - case 'document': - // Declared for completeness (the plan's target union includes it), but - // no command uses it yet and the Global Editor does not expose a stable - // document identity to the command layer. Resolving to 'none' rather - // than guessing keeps the honest answer honest; wire it when the first - // consumer needs it AND the editor can supply an id. - return { kind: 'none' } - case 'none': - default: - return { kind: 'none' } - } -} - /** * May this invocation proceed, and if not, should the user see why? * @@ -98,8 +44,14 @@ export function resolveCommandTarget( * silent hide into an explained disable. This is the "discovery-worthy" * case: someone searching for Rewind on OpenCode deserves a greyed row * saying why, not silence they cannot interpret. - * 3. A generic `when` / rendered-view failure hides, unexplained. Commands - * that deserve better opt in via step 2. + * 3. A `getState` reporting `status('unavailable')` DISABLES with its own + * detail text. `commandState.ts` describes that variant as "what lets the + * picker grey a row instead of offering it as ordinary and executable" — + * but nothing enforced the second half, so a greyed "Unavailable" row ran + * normally on Enter. A state that says a capability is missing is a + * capability claim, not a decoration. + * 4. A generic `when` / rendered-view failure hides, unexplained. Commands + * that deserve better opt in via step 2 or 3. */ export function resolveCommandAvailability( command: CommandDef, @@ -126,80 +78,11 @@ export function resolveCommandAvailability( } } - return { available: true } -} - -/** - * Resolve everything about one invocation at once. - * - * SCOPE — read before relying on this. It resolves target, availability and - * state together, from one read, so those three cannot disagree. What it does - * NOT yet do is enforce the target during execution: `CommandDef.run` takes - * only a context and has no target parameter, so `execute` calls `run(ctx)` and - * every session command still resolves its own target internally. - * - * The pinned identity is therefore currently useful for DISPLAY (the badge and - * the row describe a known session) and for `targetStillValid` at a mutation - * boundary — but a command that re-resolves inside `run` can still act on - * something else. `close-pane`, for instance, reaches `closeFocused`, which - * reads a live ref rather than this snapshot. - * - * Closing that gap means threading the resolved target into `run`, which - * touches every session command and belongs with the Phase 7 destructive work. - * Until then, do not describe this module as preventing retargeting; it - * provides the identity that a later phase will enforce. - */ -export function resolveCommandInvocation( - command: CommandDef, - ctx: CommandContext, -): ResolvedCommandInvocation { - const target = resolveCommandTarget(command, ctx) - const availability = resolveCommandAvailability(command, ctx) - - return { - commandId: command.id, - target, - availability, - // State is computed only for a command that can actually run. Evaluating - // `getState` for an excluded command was wasted work at best, and at worst - // read a target that admission had just rejected. - state: availability.available && command.getState ? command.getState(ctx) : null, - execute: () => command.run(ctx), + const state = command.getState?.(ctx) + if (state?.kind === 'status' && state.value === 'unavailable') { + return { available: false, reason: state.detail, presentation: 'disable' } } -} -/** - * MUTATION-BOUNDARY revalidation: is the pinned target still the thing we - * think it is, right now? - * - * WHY this exists separately from admission: admission runs at dispatch. A - * destructive command may await a confirmation modal, a provider probe, or a - * kill round-trip before it mutates — and the target can vanish, move project, - * or change provider in that window. Admission cannot cover that gap; only a - * check at the moment of mutation can. - * - * Returns false rather than throwing, and callers must treat false as "reject - * this operation", never as "fall back to the currently focused session". - * Silently retargeting is the exact failure this is here to prevent: a - * confirmation the user granted for agent A must never authorize killing B. - */ -export function targetStillValid(target: CommandTarget, ctx: CommandContext): boolean { - switch (target.kind) { - case 'session': - // Re-read from the authoritative store rather than trusting a captured - // snapshot. Presence in `sessions` is the minimum bar; per-command - // checks (still grid-owned? still this provider? still running?) belong - // with the command, which knows what its own invariant is. - return Boolean(ctx.workspace.state.sessions[target.id]) - case 'project': - return ctx.flags.focusedCwd === target.id - case 'document': - // No document identity is resolvable yet (see resolveCommandTarget), so - // a document target can never have been pinned in the first place. - return false - case 'app': - case 'none': - // Nothing identity-bearing to invalidate. - return true - } + return { available: true } } + diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index 99d2d8dc..dcd530d3 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -19,7 +19,6 @@ export type CommandState = | { kind: 'toggle' value: 'on' | 'off' | 'mixed' - truth: 'persisted' | 'runtime' | 'effective' /** Open/Closed instead of On/Off, for panels. */ vocabulary?: 'open-closed' detail?: string @@ -27,7 +26,6 @@ export type CommandState = | { kind: 'value' label: string - truth: 'persisted' | 'runtime' | 'effective' detail?: string } | { @@ -78,27 +76,7 @@ export type CommandCategory = */ export type CommandGroup = 'navigation' -/** - * What a command acts on, resolved to a CONCRETE identity at invocation time. - * - * WHY identity and not just a kind: the audit found target-sensitive commands - * independently resolving the focused session in `when`, in `getState`, and - * again in `run`. Between the palette rendering a row and the user pressing - * Enter, focus can move — so the badge could describe session A while the - * mutation landed on session B. Pinning one identity per invocation is what - * makes "the thing I saw is the thing I changed" true. - */ -export type CommandTarget = - /** Needs no target (and must not invent one). */ - | { kind: 'none' } - /** Acts on the application as a whole. */ - | { kind: 'app' } - | { kind: 'project'; id: string } - | { kind: 'session'; id: string } - | { kind: 'document'; id: string } - /** What a command declares it needs, before resolution finds a concrete one. */ -export type CommandTargetKind = CommandTarget['kind'] /** * Whether an invocation may proceed, and how to present a refusal. @@ -128,21 +106,6 @@ export type CommandRisk = /** Ends processes, deletes data, or cascades. Requires confirmation policy. */ | 'destructive' -/** - * One invocation, resolved: what it will act on, whether it may proceed, what - * to display, and how to run it — all derived from a SINGLE read of state. - * - * The whole point is that these four facts cannot disagree with each other, - * because they were computed together rather than by four independent lookups - * at four different moments. - */ -export type ResolvedCommandInvocation = { - commandId: string - target: CommandTarget - availability: CommandAvailability - state: CommandState | null - execute: () => void | Promise -} /** * Which workspace surface a command belongs to. @@ -429,17 +392,6 @@ export type CommandDef = { category?: CommandCategory /** Closed family this command belongs to, controlled as one product unit. */ commandGroup?: CommandGroup - /** - * What this command acts on. Absent means `'none'`. - * - * Declaring it is what lets the resolver pin ONE identity per invocation - * instead of letting `when`, `getState` and `run` each resolve the focused - * session independently and potentially disagree. App- and editor-scoped - * commands must NOT declare a session target merely because a session - * happens to be focused — inventing a target is how a global panel toggle - * starts looking session-scoped. - */ - targetKind?: CommandTargetKind /** Risk classification, used by confirmation policy. Absent means `'safe'`. */ risk?: CommandRisk /** diff --git a/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx index f85acff5..56506fb3 100644 --- a/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx +++ b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx @@ -4,6 +4,7 @@ import { useAppStore } from '@renderer/app-state/hooks' import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' import { PALETTE_SELF_EXCLUDED_COMMAND_IDS } from '@renderer/features/command-palette/commands/paletteCommands' import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults' +import type { BindingContext } from '@renderer/features/command-keybindings/defaults' import { displayKeybinding, keybindingFromEvent, @@ -46,16 +47,28 @@ const CATEGORY_LABELS: Record = { developer: 'Developer', } -const CATEGORY_ORDER: CommandCategory[] = [ - 'create', - 'navigate', - 'session', - 'layout-dispatch', - 'editor-files', - 'workspace-tools', - 'preferences', - 'developer', -] +/** + * Display order. EXHAUSTIVE by type, deliberately. + * + * It was a plain `CommandCategory[]`, and grouping filtered to it — so a + * command in a category nobody remembered to list here would not appear in + * Settings at all, and therefore could not be rebound. A silent omission with + * no error anywhere. `Record` makes adding a category + * a compile error until it has a position. + */ +const CATEGORY_RANK: Record = { + create: 0, + navigate: 1, + session: 2, + 'layout-dispatch': 3, + 'editor-files': 4, + 'workspace-tools': 5, + preferences: 6, + developer: 7, +} + +const CATEGORY_ORDER = (Object.keys(CATEGORY_RANK) as CommandCategory[]) + .sort((a, b) => CATEGORY_RANK[a] - CATEGORY_RANK[b]) type PendingConflict = { commandId: string @@ -64,6 +77,16 @@ type PendingConflict = { owners: string[] /** Command ids whose bindings an explicit Replace would remove. */ replaceableCommandIds: string[] + /** + * True when at least one owner is a RESERVED interaction. + * + * Kept separate from `replaceableCommandIds` because the two are not + * complementary: a chord can be held by both a command and Escape. Offering + * Replace there stripped the command's binding and installed the user's — + * and the chord still would not fire, because the reserved owner still had + * it. A destructive edit that does not achieve the thing it was for. + */ + hasReservedOwner: boolean } export function CommandKeybindingsRow() { @@ -73,7 +96,6 @@ export function CommandKeybindingsRow() { const [query, setQuery] = useState('') const [capturingFor, setCapturingFor] = useState(null) const [conflict, setConflict] = useState(null) - const [syntaxError, setSyntaxError] = useState(null) const overrides = settings.commandKeybindingOverrides const defaults = useMemo(() => buildDefaultKeybindings(), []) @@ -148,6 +170,15 @@ export function CommandKeybindingsRow() { [overrides, defaults, setSettings], ) + // A command's declared binding context, from the shipped defaults table — + // the same source `effectiveAsDefaults` is derived from, so the conflict + // check and the router agree about which contexts a chord lives in. + const contextForCommandId = useCallback( + (commandId: string): BindingContext => + effectiveAsDefaults.find(entry => entry.commandId === commandId)?.context ?? 'global', + [effectiveAsDefaults], + ) + const captureRef = useRef(null) captureRef.current = capturingFor @@ -164,7 +195,6 @@ export function CommandKeybindingsRow() { if (event.key === 'Escape') { setCapturingFor(null) - setSyntaxError(null) return } @@ -178,7 +208,16 @@ export function CommandKeybindingsRow() { const owners = findBindingOwners({ binding, - context: 'global', + // The command's OWN declared context, not 'global'. + // + // Hardcoding 'global' made every check maximally pessimistic: 'global' + // overlaps everything, so binding a grid-only chord reported a conflict + // with a dispatch-only command that can never be live at the same time. + // That is precisely the distinction the context system and its disjoint + // -pair matrix exist to draw, and the one call site that needed it threw + // it away. Unknown commands fall back to 'global', which errs toward + // reporting a conflict rather than silently allowing a real one. + context: contextForCommandId(commandId), commandDefaults: effectiveAsDefaults, dictationBinding: settings.dictationShortcut, excludeCommandId: commandId, @@ -198,6 +237,7 @@ export function CommandKeybindingsRow() { replaceableCommandIds: owners .filter(owner => owner.kind === 'command') .map(owner => owner.id), + hasReservedOwner: owners.some(owner => owner.kind === 'reserved'), }) return } @@ -205,12 +245,11 @@ export function CommandKeybindingsRow() { const current = effective.get(commandId) ?? [] if (!current.includes(binding)) commit(commandId, [...current, binding]) setCapturingFor(null) - setSyntaxError(null) } window.addEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true) - }, [capturingFor, effective, effectiveAsDefaults, settings.dictationShortcut, commit]) + }, [capturingFor, contextForCommandId, effective, effectiveAsDefaults, settings.dictationShortcut, commit]) /** * The ONLY override path. Removes the binding from every command that owns @@ -250,7 +289,7 @@ export function CommandKeybindingsRow() { is already used by {conflict.owners.join(', ')}.
- {conflict.replaceableCommandIds.length > 0 ? ( + {conflict.replaceableCommandIds.length > 0 && !conflict.hasReservedOwner ? (