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..6a5e52e9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-command-surface-audit.md @@ -0,0 +1,1378 @@ +# 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, 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 +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. | +| 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. | +| 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. | +| 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, 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 + +- `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. +- 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. +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. +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 + +| 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. +- `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.” + +## 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 (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. | +| `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 (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; 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. | +| `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 · 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 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. | +| `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 | 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. | +| `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 · 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 + +### 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 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: + +```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 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` | **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. | +| 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 +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. + +`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; +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: + +`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 +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, 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. +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 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. + +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 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. + +### 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, 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. + +### 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`, 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. +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/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: + +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, 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. +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 — 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: + +- `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 6 — 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 7 — Move ownership and harden destructive flows + +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. 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. +2. Keep only Aggressive Debug Persistence as a Settings-primary quick command + from this preference group and classify it as debug-hidden. +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 + 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. +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: 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 8 — 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 + +- 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 + `Navigate` command inherits group behavior by category. +- Native-menu IDs are a subset of executable catalog IDs. +- 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. + +### 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. | +| Navigation Commands group | disabled, enabled. | +| 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; +- 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; +- 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. +- 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. +- 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. +- `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. +- 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 | 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. | +| 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. | +| 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, 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 + +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:keybindings +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. the five retired commands are absent while their Settings controls still + persist and Aggressive Debug Persistence remains debug-only; +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; +11. Settings restart persistence and new-session versus current-session scope; +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, 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 + user searches for them. +2. **Close confirmation threshold.** Recommendation: confirm running/streaming + targets and any cascade/multi-target close; keep an idle single close + immediate and undoable. +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. +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. + +## 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 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. +- 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 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 + approved. 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..08a13449 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-command-governance-remediation.md @@ -0,0 +1,437 @@ +# 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. + + +--- + +## Outcome (2026-07-26) + +All six batches landed. Commits `9e826490`, `23c3ba48`, `ed6f5781`, +`350bb785`, `be8be812`, on top of R1's `50686573`. + +Verification: `tsc -b` clean on both projects (after `rm -rf .tsc-out`), +`check:keybindings` OK (25 binding sets, 13 reserved, 5 approved overlaps), +246/246 vitest files passing. + +### Findings that did NOT survive investigation + +Recorded so a later reader does not re-open them: + +- **"`migrate` on downgrade destroys keybindings."** No mechanism. `partialize` + persists only `settings`, `merge` reconstructs everything else from current + defaults regardless of version, and `coerceSettings` preserves unknown keys + through `...omitRetiredSettingsKeys(parsed)`. A downgrade re-coerces; it does + not prune. +- **"`effectiveBindingsFor` is a dead export."** It has two consumers + (`MonacoFileEditor`, `EditorWorkbench`) — wired in R1, so the report was + reading a pre-R1 tree. +- **"`VALID_SURFACES` / `VALID_TIERS` are guards that cannot fire."** True + against the current catalog and deliberate anyway: the catalog is the + boundary an extension-contributed command crosses, and those arrive as + strings TypeScript cannot check at construction. The existing comment already + says so. + +### The pattern worth remembering + +Every blocking defect in this round had the same shape: **a mechanism that +computes a correct answer, and nothing that consults it.** + +- The close gate expanded the wrong set and re-read a stale snapshot. +- Four capability guards read each other's flags; a fifth capability had no + reader at all. +- The MCP close grant had no issuer, so the tool was refused 100% of the time. +- `status('unavailable')` greyed a row that still executed. +- Target pinning pinned a target that `run` never received. +- `CommandState.truth` was written onto every state and read by nothing. + +None of it was catchable by the build, and most of it was not catchable by the +tests either — because the tests exercised the mechanism, which worked, rather +than the path from a real caller, which did not. Where a test was added this +round it drives the consumer, not the helper: the capability test flips one +flag and asserts exactly one command turns on; the close-gate test answers a +dialog from a real close. 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..303481fa --- /dev/null +++ b/scripts/check-command-keybindings.mts @@ -0,0 +1,169 @@ +/** + * 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, + findCatalogDefects, +} 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) { + 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 +// 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, so the palette +// could advertise a chord the editor implemented and a user's Settings edit +// would change nothing. +// +// 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.', + ) +} + +// --- 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/main/agentManagement/AgentManagementBridge.test.ts b/src/main/agentManagement/AgentManagementBridge.test.ts index c0b48af8..c251392e 100644 --- a/src/main/agentManagement/AgentManagementBridge.test.ts +++ b/src/main/agentManagement/AgentManagementBridge.test.ts @@ -136,6 +136,10 @@ 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. const closing = bridge.closeAgent({ callerSessionId: 'caller', sessionId: 'parent' }) const request = sentRendererRequests[0] as { requestId: string } bridge.resolve({ diff --git a/src/main/agentManagement/AgentManagementBridge.ts b/src/main/agentManagement/AgentManagementBridge.ts index f22b91c7..4c1fae03 100644 --- a/src/main/agentManagement/AgentManagementBridge.ts +++ b/src/main/agentManagement/AgentManagementBridge.ts @@ -229,6 +229,27 @@ export class AgentManagementBridge { callerSessionId: string sessionId: string }): Promise<{ closedSessionId: string }> { + // AUTHORIZATION LIVES IN THE RENDERER, at the line that mutates. + // + // 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 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(), type: 'close-agent', 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/providers/providerFeatures.test.ts b/src/providers/providerFeatures.test.ts new file mode 100644 index 00000000..07368929 --- /dev/null +++ b/src/providers/providerFeatures.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' + +import { AGENT_PROVIDER_KINDS } from '@shared/types/providerKind' +import { getProviderFeatures } from '@providers/shared/featureCapabilities' + +// --------------------------------------------------------------------------- +// 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. +// +// 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', () => { + 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, + promptHistoryExtraction: true, + inAppResume: true, + switchTargets: ['codex'], + verifiedExternalResumeCommand: true, + }, + codex: { + savedSessionListing: true, + transcriptRewind: true, + transcriptDuplicate: true, + promptHistoryExtraction: true, + inAppResume: true, + switchTargets: ['claude'], + verifiedExternalResumeCommand: true, + }, + opencode: { + 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, + }, + }) + }) + + it('grants a terminal nothing', () => { + // The distinction isAgentProviderKind was actually making, kept explicit. + expect(getProviderFeatures('terminal')).toEqual({ + savedSessionListing: false, + transcriptRewind: false, + transcriptDuplicate: false, + promptHistoryExtraction: false, + inAppResume: 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.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', () => { + // 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 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', + ] + 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/shared/featureCapabilities.ts b/src/providers/shared/featureCapabilities.ts new file mode 100644 index 00000000..13ee5207 --- /dev/null +++ b/src/providers/shared/featureCapabilities.ts @@ -0,0 +1,170 @@ +import { isAgentProviderKind } from '@shared/types/providerKind' +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[] + /** + * 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 + * 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 +} + +/** + * 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, + promptHistoryExtraction: false, + inAppResume: 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, + promptHistoryExtraction: true, + inAppResume: true, + switchTargets: ['codex'], + verifiedExternalResumeCommand: true, + }, + // Mirrors Claude, with the reverse translation edge. + codex: { + savedSessionListing: true, + transcriptRewind: true, + transcriptDuplicate: true, + promptHistoryExtraction: true, + inAppResume: true, + switchTargets: ['claude'], + verifiedExternalResumeCommand: true, + }, + // 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 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, + }, +} + +/** + * 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/settings/persistence.ts b/src/renderer/src/app-state/settings/persistence.ts index a33444af..d901be12 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' @@ -46,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), @@ -70,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. @@ -127,6 +129,16 @@ 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, + commandKeybindingOverrides: pruneRetiredKeybindingOverrides( + coerceCommandKeybindingOverrides(parsed.commandKeybindingOverrides), + ), } } @@ -194,11 +206,76 @@ 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', +]) + +/** + * 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 = {} 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/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 b1de94b0..3e54eeb3 100644 --- a/src/renderer/src/app-state/settings/types.ts +++ b/src/renderer/src/app-state/settings/types.ts @@ -341,20 +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 - * 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 @@ -392,6 +378,46 @@ 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 + /** + * 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 @@ -441,13 +467,20 @@ 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 // 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, + // 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/app-state/store.ts b/src/renderer/src/app-state/store.ts index acda968e..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: 8, + version: 10, storage: createJSONStorage(() => localStorage), partialize: state => ({ settings: state.settings }), merge: (persisted, current) => { 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/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts index e5ff739f..b418d4c8 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,21 @@ 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. + // 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 }, + }), 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/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/app/surfaces/registry.tsx b/src/renderer/src/app/surfaces/registry.tsx index 3e45f557..c744e1f1 100644 --- a/src/renderer/src/app/surfaces/registry.tsx +++ b/src/renderer/src/app/surfaces/registry.tsx @@ -16,6 +16,7 @@ import { TileTabsModalSurface } from '@renderer/features/workspace/surfaces/Tile import { ReorderTabsSurface } from '@renderer/features/workspace/surfaces/ReorderTabsSurface' import { PinAgentsSurface } from '@renderer/features/dispatch-pin/surfaces/PinAgentsSurface' import { BuryPanePromptSurface } from '@renderer/features/workspace/surfaces/BuryPanePromptSurface' +import { CloseConfirmationSurface } from '@renderer/features/workspace/surfaces/CloseConfirmationSurface' import { ViewPromptsSurface } from '@renderer/features/workspace/surfaces/ViewPromptsSurface' import { PromptSearchSurface } from '@renderer/features/workspace/surfaces/PromptSearchSurface' import { AgentActivitySurface } from '@renderer/features/workspace/surfaces/AgentActivitySurface' @@ -64,6 +65,7 @@ export const modalSurfaces: SurfaceEntry[] = [ { id: 'reorder-tabs', Component: ReorderTabsSurface }, { id: 'pin-agents', Component: PinAgentsSurface }, { id: 'bury-pane', Component: BuryPanePromptSurface }, + { id: 'close-confirmation', Component: CloseConfirmationSurface }, { id: 'debug-bundle-note', Component: DebugBundleNoteSurface }, { id: 'recording-note', Component: RecordingNoteSurface }, { id: 'view-prompts', Component: ViewPromptsSurface }, diff --git a/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts b/src/renderer/src/features/agent-status/commands/agentStatusCommands.ts index 7e33fb6b..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) @@ -14,15 +15,13 @@ 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.', 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-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/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/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..e879553c --- /dev/null +++ b/src/renderer/src/features/command-keybindings/normalize.ts @@ -0,0 +1,353 @@ +// --------------------------------------------------------------------------- +// 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.`, + ) + } + + // 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('+') + + 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 + + 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 + } + + // 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 + } + + // 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}` +} + +/** + * 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/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..ee83fd98 --- /dev/null +++ b/src/renderer/src/features/command-keybindings/reservations.ts @@ -0,0 +1,408 @@ +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' + +// --------------------------------------------------------------------------- +// 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. + // + // 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', + // role: 'editMenu' → Paste and Match Style + 'Cmd+Alt+Shift+V', + ], + context: 'global', + owner: 'Native editing commands', + }, + { + // 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)', + }, + { + // 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. + 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 file-tab navigation', + }, +] + +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 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 + // 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), 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.', + }, +] + +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 }) + } + } + const dictation = normalizeDictationBinding(options.dictationBinding) + if (dictation) { + claim(dictation, { + 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 (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 new file mode 100644 index 00000000..86a5b9ad --- /dev/null +++ b/src/renderer/src/features/command-keybindings/resolve.test.ts @@ -0,0 +1,187 @@ +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('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', () => { + // 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('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', () => { + // 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..ea5cbdd6 --- /dev/null +++ b/src/renderer/src/features/command-keybindings/resolve.ts @@ -0,0 +1,194 @@ +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) + } + + // 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 +} + +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(), + /** + * 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() + + 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 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: contextForCommand(commandId), + 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. + * + * 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. + * + * 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, + commandId: string, + bindings: readonly Keybinding[], + defaults: readonly CommandBindingDefault[] = buildDefaultKeybindings(), +): CommandKeybindingOverrides { + const next = { ...overrides } + 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 +} + 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-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/catalog.test.ts b/src/renderer/src/features/command-palette/catalog.test.ts new file mode 100644 index 00000000..fe4ef5d4 --- /dev/null +++ b/src/renderer/src/features/command-palette/catalog.test.ts @@ -0,0 +1,382 @@ +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. +// +// 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 +// 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 after governance (was 102 at 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 (8, was 9: toggle-status-mode retired) + 'dispatch-mode', + 'global-dispatch', + 'tiled-dispatch', + 'normalize-layout', + 'hard-normalize-layout', + 'rotate-layout', + '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 (3, was 5: worktree-badges + dangerous-agents retired) + 'open-settings', + 'toggle-aggressive-debug-persistence', + 'toggle-worktrees-bar', + // 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 (1, was 3: both header preferences retired) + 'usage.open', + // paletteCommands (1) — the single approved addition + 'open-command-palette', +] + +/** 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', + '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 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 98 commands', () => { + // Stated separately from the order assertion because the plan's headline + // number is the thing later phases move (102 → 98), and a bare count + // failure is a clearer signal than a 102-line array diff. + expect(builtInCommandCatalog).toHaveLength(98) + }) + + it('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', () => { + // 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', () => { + 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', () => { + 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. + // + // 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).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) + }) +}) + +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('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('no longer contains any of the five retired commands', () => { + const catalogIds = new Set(ids()) + for (const id of RETIRED_COMMAND_IDS) { + expect(catalogIds.has(id)).toBe(false) + } + }) + + 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', () => { + const catalogIds = new Set(ids()) + for (const id of NAVIGATION_COMMAND_GROUP) { + expect(catalogIds.has(id)).toBe(true) + } + }) + + 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) + }) +}) + +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..1d713065 --- /dev/null +++ b/src/renderer/src/features/command-palette/catalog.ts @@ -0,0 +1,147 @@ +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 { paletteCommands } from '@renderer/features/command-palette/commands/paletteCommands' +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, + // 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, +]) + +/** + * 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/commandState.test.ts b/src/renderer/src/features/command-palette/commandState.test.ts new file mode 100644 index 00000000..89f81720 --- /dev/null +++ b/src/renderer/src/features/command-palette/commandState.test.ts @@ -0,0 +1,102 @@ +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', {})).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('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. `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') + }) + + 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..0583baf2 --- /dev/null +++ b/src/renderer/src/features/command-palette/commandState.ts @@ -0,0 +1,160 @@ +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, 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. +// - 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. +// --------------------------------------------------------------------------- + +// 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: { detail?: string } = {}, +): CommandState { + return { + kind: 'toggle', + value: value === 'mixed' ? 'mixed' : value ? 'on' : 'off', + ...(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', + 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: { detail?: string } = {}, +): CommandState { + return { + kind: 'value', + label, + ...(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': { + if (state.value === 'mixed') { + return { + // 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', + detail: state.detail, + muted: false, + } + } + const on = state.value === 'on' + const openClosed = state.vocabulary === 'open-closed' + 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/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/executeCommand.test.ts b/src/renderer/src/features/command-palette/executeCommand.test.ts new file mode 100644 index 00000000..75b4ea25 --- /dev/null +++ b/src/renderer/src/features/command-palette/executeCommand.test.ts @@ -0,0 +1,307 @@ +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 { makeTestCommandContext } from '@renderer/features/command-palette/testing/commandContextHarness' +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[] = [] + +/** 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(() => { + 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..28a4c7fa --- /dev/null +++ b/src/renderer/src/features/command-palette/executeCommand.ts @@ -0,0 +1,290 @@ +import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog' +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' + +// --------------------------------------------------------------------------- +// 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. `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. */ + | { 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. + // + // 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({ + 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) { + const availability = resolveCommandAvailability(command, ctx) + if (!availability.available) { + return { status: 'unavailable', id: row.id, source, reason: availability.reason } + } + } + + 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 ? resolveCommandAvailability(command, ctx).available : 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/keybindingBaseline.test.ts b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts new file mode 100644 index 00000000..6071de7c --- /dev/null +++ b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts @@ -0,0 +1,390 @@ +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. +// +// `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, now inverted. + // + // 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]), + ) + + 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('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) + } + }) +}) + +/** 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( + 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 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']) + // 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', () => { + // 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/lib/agentIndexCommand.test.ts b/src/renderer/src/features/command-palette/lib/agentIndexCommand.test.ts index aaf499a1..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({ label: 'codex', tone: 'accent' }) + 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/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/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..5f24719f --- /dev/null +++ b/src/renderer/src/features/command-palette/pickerVisibility.test.ts @@ -0,0 +1,103 @@ +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 + 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', () => { + 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..1f97c0c0 --- /dev/null +++ b/src/renderer/src/features/command-palette/pickerVisibility.ts @@ -0,0 +1,84 @@ +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 + /** Mirrors `Settings.navigationCommandsEnabled`. */ + navigationCommandsEnabled: 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. 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, + 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 + // 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 ffb21561..bcc5708b 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -1,56 +1,23 @@ -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 { 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 { CommandContext, CommandDef, + CommandGroup, CommandPickerVisibility, CommandSurface, 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`. @@ -65,47 +32,109 @@ const commandDefs: CommandDef[] = [ * 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 +export function commandApplicable(command: CommandDef, ctx: CommandContext): boolean { + return ( + surfaceAvailable(command.surface, ctx) && + (command.when ? command.when(ctx) : true) && + renderedViewAvailable(command, ctx) + ) +} - // 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 +/** + * 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) +} - 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, + navigationCommandsEnabled: ctx.flags.navigationCommandsEnabled, + }) } function renderedViewAvailable(command: CommandDef, ctx: CommandContext): boolean { @@ -122,14 +151,17 @@ 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 => - surfaceAvailable(command.surface, ctx) && - (command.when ? command.when(ctx) : true) && - renderedViewAvailable(command, ctx) && - commandVisible(command, ctx), - ) + .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() if (!description) { @@ -140,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, @@ -156,6 +192,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 } /** @@ -177,9 +217,23 @@ 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: command.pickerVisibility ?? 'default', - })) + 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 } : {}), + })) +} + +/** 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/resolveInvocation.test.ts b/src/renderer/src/features/command-palette/resolveInvocation.test.ts new file mode 100644 index 00000000..183e47ee --- /dev/null +++ b/src/renderer/src/features/command-palette/resolveInvocation.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import { + resolveCommandAvailability, +} 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' + +const base: CommandDef = { + id: 'x.test', + surface: 'app', + title: 'Test', + description: 'test', + run: () => {}, +} + +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' }) + }) +}) 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..32f4bba1 --- /dev/null +++ b/src/renderer/src/features/command-palette/resolveInvocation.ts @@ -0,0 +1,88 @@ +import { commandApplicable, surfaceApplicable } from '@renderer/features/command-palette/registry' +import type { + CommandAvailability, + CommandContext, + CommandDef, +} from '@renderer/features/command-palette/types' + +// --------------------------------------------------------------------------- +// ADMISSION: may this invocation proceed, and if not, does the user get told? +// +// 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. +// +// 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. +// --------------------------------------------------------------------------- + +/** + * 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 `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, + 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', + } + } + + const state = command.getState?.(ctx) + if (state?.kind === 'status' && state.value === 'unavailable') { + return { available: false, reason: state.detail, presentation: 'disable' } + } + + return { available: true } +} + 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..a2d5835b --- /dev/null +++ b/src/renderer/src/features/command-palette/taxonomy.test.ts @@ -0,0 +1,306 @@ +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) + }) +}) + +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/testing/commandContextHarness.ts b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts new file mode 100644 index 00000000..0cb26e4f --- /dev/null +++ b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts @@ -0,0 +1,140 @@ +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: {}, + // 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, + commandKeybindingOverrides: {}, + 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..dcd530d3 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -3,10 +3,109 @@ 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' + /** Open/Closed instead of On/Off, for panels. */ + vocabulary?: 'open-closed' + detail?: string + } + | { + kind: 'value' + label: string + detail?: string + } + | { + kind: 'status' + value: 'loading' | 'unavailable' | 'error' + detail: string + } + +/** + * 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 declares it needs, before resolution finds a concrete one. */ + +/** + * 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' + /** * Which workspace surface a command belongs to. @@ -91,6 +190,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 @@ -221,6 +323,22 @@ 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 + /** + * 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. @@ -265,7 +383,28 @@ 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 - shortcut?: string + /** + * 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 + /** 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 keywords?: string[] keepPaletteOpen?: boolean when?: (ctx: CommandContext) => boolean @@ -273,6 +412,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 @@ -280,6 +425,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 c26716de..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' @@ -11,14 +12,16 @@ 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 { PALETTE_SELF_EXCLUDED_COMMAND_IDS } from '@renderer/features/command-palette/commands/paletteCommands' +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, @@ -26,7 +29,13 @@ 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, } from '@renderer/features/prompt-templates/templates' @@ -115,13 +124,50 @@ 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) - 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( () => @@ -135,39 +181,46 @@ 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. - if (!open) return null + // built the registry. This outer gate subscribes to two store fields; + // ordinary agent traffic cannot fan into the hidden palette. + // 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 ( ) } function OpenCommandPalette({ + visible, pendingMenuCommand, onMenuCommandHandled, }: { - pendingMenuCommand: { id: string; closeAfterRun: boolean } | null + /** False when mounted purely to service a keybinding or menu invocation. */ + visible: boolean + pendingMenuCommand: PendingCommandInvocation | null onMenuCommandHandled: () => void }) { // #494: the palette used to receive ~76 props whose only purpose was @@ -179,6 +232,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) @@ -192,6 +249,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) @@ -253,6 +311,8 @@ 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 @@ -313,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 @@ -330,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 @@ -486,6 +560,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, @@ -566,6 +645,8 @@ function OpenCommandPalette({ globalDispatchEnabled, agentViewMode, commandVisibilityOverrides, + navigationCommandsEnabled, + commandKeybindingOverrides, showHiddenCommands, }, }), @@ -653,6 +734,8 @@ function OpenCommandPalette({ globalDispatchEnabled, agentViewMode, commandVisibilityOverrides, + navigationCommandsEnabled, + commandKeybindingOverrides, showHiddenCommands, ], ) @@ -840,23 +923,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 +952,39 @@ 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({ + // 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: pendingMenuCommand.source, + ctx: commandContext, + reportError: message => showToast(message, 6000), + }) onMenuCommandHandled() - if (pendingMenuCommand.closeAfterRun) onClose() - }, [commandContext, commands, onClose, onMenuCommandHandled, pendingMenuCommand]) + // 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( (session: SessionInfo) => { @@ -1224,7 +1332,12 @@ function OpenCommandPalette({ return ( { if (!nextOpen) onClose() }} @@ -1536,19 +1649,7 @@ function OpenCommandPalette({ >
{command.title} - {command.state && ( - - {command.state.label} - - )} + {command.state && }
{command.shortcut && ( @@ -1877,19 +1978,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/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/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 d2a507ae..abe1477e 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, @@ -25,6 +26,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. @@ -33,27 +35,30 @@ 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() }, }, { id: 'save-editor-file', + category: 'editor-files', surface: 'editor', title: 'Save Editor File', 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, + // 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, }, { id: 'save-all-editor-files', + category: 'editor-files', surface: 'editor', title: 'Save All Editor Files', description: @@ -64,12 +69,12 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'quick-open-file', + category: 'editor-files', surface: 'editor', title: 'Quick Open File', 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() @@ -86,12 +91,12 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'search-in-files', + category: 'editor-files', surface: 'editor', title: 'Search in Files', 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() @@ -108,21 +113,30 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'toggle-editor-fullscreen', + category: 'editor-files', surface: 'editor', title: 'Editor Fullscreen', 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', - tone: flags.editorFullscreen ? 'accent' : 'neutral', - }), - run: () => useGlobalEditorStore.getState().toggleEditorFullscreen(), + getState: ({ flags }) => toggle(flags.editorFullscreen), + 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', + category: 'editor-files', + pickerVisibility: 'advanced', surface: 'editor', title: 'Open AI Workspace', description: @@ -133,6 +147,8 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'create-ai-workspace', + category: 'editor-files', + pickerVisibility: 'advanced', surface: 'editor', title: 'Create AI Workspace', description: @@ -143,6 +159,8 @@ export const globalEditorCommands: CommandDef[] = [ }, { id: 'clear-ai-workspace', + category: 'editor-files', + pickerVisibility: 'advanced', surface: 'editor', title: 'Clear AI Workspace', description: @@ -167,6 +185,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. @@ -176,10 +195,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/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..f0271628 100644 --- a/src/renderer/src/features/reader/commands/readerCommands.ts +++ b/src/renderer/src/features/reader/commands/readerCommands.ts @@ -1,18 +1,17 @@ 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[] = [ { 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.', 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/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..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, @@ -33,6 +34,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 @@ -64,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/dangerousCommands.ts b/src/renderer/src/features/settings/commands/dangerousCommands.ts deleted file mode 100644 index b1725700..00000000 --- a/src/renderer/src/features/settings/commands/dangerousCommands.ts +++ /dev/null @@ -1,33 +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', - 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 5d2db064..4bbd47b3 100644 --- a/src/renderer/src/features/settings/commands/settingsCommands.ts +++ b/src/renderer/src/features/settings/commands/settingsCommands.ts @@ -1,9 +1,10 @@ import type { CommandDef } from '@renderer/features/command-palette/types' -import { dangerousCommands } from '@renderer/features/settings/commands/dangerousCommands' +import { panel, toggle, value } from '@renderer/features/command-palette/commandState' 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.', @@ -30,36 +33,29 @@ 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), }, { 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.', 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(), }, - { - id: 'toggle-worktree-badges', - 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/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 940bb2fc..cf8332dd 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -15,9 +15,57 @@ 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' +/** + * 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 @@ -45,6 +93,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata control: { type: 'toggle' getValue: (settings: Settings) => boolean @@ -57,6 +106,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata control: { type: 'select' getValue: (settings: Settings) => string @@ -71,6 +121,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata control: { type: 'hotkey' getValue: (settings: Settings) => string @@ -83,6 +134,7 @@ export type SettingDefinition = title: string description: string keywords: string[] + metadata?: SettingMetadata control: { type: 'action' label: string @@ -96,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), @@ -111,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 @@ -126,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/ @@ -142,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. @@ -155,6 +211,22 @@ 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 + // registry would make every Settings render recompute it. + control: { + type: 'command-keybindings' + } + } + | { + id: string + category: SettingCategoryId + title: string + description: string + keywords: string[] + metadata?: SettingMetadata control: { type: 'command-visibility' /** Full command catalog to render rows for. Carried as a value @@ -222,18 +294,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( @@ -328,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, @@ -349,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, @@ -420,25 +518,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', @@ -461,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' }, }, { @@ -528,6 +609,53 @@ 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', + ], + metadata: { scope: 'app', apply: 'immediate', storage: 'settings' }, + control: { type: 'command-keybindings' }, + }, + { + 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', + ], + metadata: { scope: 'app', apply: 'immediate', storage: 'settings' }, + 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', @@ -544,6 +672,7 @@ export function getSettingsRegistry(): SettingDefinition[] { 'advanced', 'debug', ], + metadata: { scope: 'app', apply: 'immediate', storage: 'settings' }, control: { type: 'command-visibility', commands: pickerCommands, @@ -575,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, @@ -642,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' }, }, { @@ -664,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, @@ -698,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/CommandKeybindingsRow.tsx b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx new file mode 100644 index 00000000..56506fb3 --- /dev/null +++ b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx @@ -0,0 +1,395 @@ +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 type { BindingContext } 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', +} + +/** + * 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 + binding: Keybinding + /** Human-readable owners, for a message that NAMES them. */ + 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() { + 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 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], + ) + + // 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 + + // 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) + 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, + // 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, + }) + + 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), + hasReservedOwner: owners.some(owner => owner.kind === 'reserved'), + }) + return + } + + const current = effective.get(commandId) ?? [] + if (!current.includes(binding)) commit(commandId, [...current, binding]) + setCapturingFor(null) + } + + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [capturingFor, contextForCommandId, 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 && !conflict.hasReservedOwner ? ( + + ) : ( + + Reserved by the app — pick a different chord. + + )} + +
+
+ ) : 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..72d7a084 100644 --- a/src/renderer/src/features/settings/ui/SettingsList.tsx +++ b/src/renderer/src/features/settings/ui/SettingsList.tsx @@ -5,6 +5,8 @@ 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 { 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' @@ -98,6 +100,8 @@ function SettingRow({
+ + {control.type === 'toggle' ? (
) } + +/** + * Small badges stating what a setting actually changes and when. + * + * Only the NON-DEFAULT facts are rendered. Showing "app / immediate / + * settings" on the thirty rows where that is already the obvious reading + * would bury the handful where it is not — and those are the whole point: + * the row that reloads live agents, the one that only affects a fresh + * install, the one whose value lives in the keychain and survives Reset + * Settings. + */ +function SettingMetadataBadges({ definition }: { definition: SettingDefinition }) { + const meta = settingMetadata(definition) + const badges: string[] = [] + if (meta.scope !== 'app') badges.push(SCOPE_LABELS[meta.scope]) + if (meta.apply !== 'immediate') badges.push(APPLY_LABELS[meta.apply]) + if (meta.storage !== 'settings') badges.push(STORAGE_LABELS[meta.storage]) + if (meta.status) badges.push(STATUS_LABELS[meta.status]) + if (badges.length === 0) return null + return ( +
+ {badges.map(badge => ( + + {badge} + + ))} +
+ ) +} + +const SCOPE_LABELS = { + app: 'App', + project: 'Project', + 'session-default': 'New sessions', + 'fresh-install': 'Fresh install only', +} as const + +const APPLY_LABELS = { + immediate: 'Immediate', + 'new-session': 'Next session', + 'reload-live-sessions': 'Reloads live agents', + 'restart-required': 'Restart required', +} as const + +const STORAGE_LABELS = { + settings: 'Settings', + workspace: 'Workspace', + setup: 'App setup', + keychain: 'Keychain', + 'external-files': 'Files on disk', +} as const + +const STATUS_LABELS = { + experimental: 'Experimental', + dangerous: 'Dangerous', + developer: 'Developer', +} as const diff --git a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts index 80c638b9..aa11506f 100644 --- a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts +++ b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts @@ -1,18 +1,17 @@ import type { CommandDef } from '@renderer/features/command-palette/types' +import { toggle } from '@renderer/features/command-palette/commandState' 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. 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 e645e2f1..75f89f96 100644 --- a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts +++ b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts @@ -1,18 +1,17 @@ import type { CommandDef } from '@renderer/features/command-palette/types' +import { toggle } from '@renderer/features/command-palette/commandState' 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. 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/usage/commands/usageCommands.ts b/src/renderer/src/features/usage/commands/usageCommands.ts index 034a97d5..3d2aa02c 100644 --- a/src/renderer/src/features/usage/commands/usageCommands.ts +++ b/src/renderer/src/features/usage/commands/usageCommands.ts @@ -3,39 +3,28 @@ import type { CommandDef } from '@renderer/features/command-palette/types' export const usageCommands: CommandDef[] = [ { id: 'usage.open', - title: 'Usage', + category: 'workspace-tools', + title: 'Open Usage', description: 'Open provider usage for Claude and Codex.', surface: 'app', - keywords: ['quota', 'tokens', 'limits', 'claude', 'codex'], + keywords: ['quota', 'tokens', 'limits', 'claude', 'codex', 'usage'], run: ({ ui }) => { ui.openUsageModal() ui.closePalette() }, }, - { - id: 'usage.toggle-header', - 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', - 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/dispatchColorFlagCommands.ts b/src/renderer/src/features/workspace/commands/dispatchColorFlagCommands.ts index 796684a4..040b132a 100644 --- a/src/renderer/src/features/workspace/commands/dispatchColorFlagCommands.ts +++ b/src/renderer/src/features/workspace/commands/dispatchColorFlagCommands.ts @@ -32,6 +32,23 @@ import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/comma export const dispatchColorFlagCommands: CommandDef[] = [ { id: 'dispatch.color-flag.set', + // `session`, NOT `layout-dispatch`, and `default`, NOT `advanced`. + // + // Both were set on this branch while the flag was a Dispatch-list + // affordance, and #609 (session header color flags) invalidated both: the + // flag now renders as a chunk of the pane's session header, so it is + // visible — and worth changing — to a user who never opens Dispatch. + // + // Filing it under Layout & Dispatch would put it in the one category a + // grid-only user has no reason to browse, and `advanced` would keep it out + // of their palette entirely. Someone who just noticed a coloured header and + // wants to change it would find nothing. The category matches what the + // command ACTS on (a session), which is also what `surface: 'session'` + // already says. + // + // The `dispatch.` id prefix stays for the persistence reason above; it is + // not evidence about where this belongs in the picker. + category: 'session', surface: 'session', // Title case + ellipsis per docs/command-style.md rules 8 and 9: `run` // always opens the swatch picker, so the command needs more input after diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts index bbb2b6b3..48b2d2dd 100644 --- a/src/renderer/src/features/workspace/commands/layoutCommands.ts +++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts @@ -1,21 +1,24 @@ import type { CommandDef } from '@renderer/features/command-palette/types' +import { status, toggle, value } from '@renderer/features/command-palette/commandState' 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. 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'], - 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() @@ -26,23 +29,25 @@ 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. surface: 'dispatch', - title: 'Global Dispatch', + title: 'Dispatch Scope', 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', - }), + keywords: ['dispatch all tabs', 'agent list', 'global dispatch'], + // A SCOPE, not a boolean. "Global Dispatch: On" told the user nothing + // about what Off meant — the alternative is Project scope, not "no + // dispatch". Naming both ends is the whole correction. + getState: ({ flags }) => value(flags.globalDispatchEnabled ? 'Global' : 'Project'), run: async ({ ui }) => { await ui.enterGlobalDispatch() }, }, { 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. @@ -52,15 +57,14 @@ 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', + 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 +75,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,46 +84,45 @@ 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.', run: ({ workspace }) => workspace.rotateLayout(), }, - { - id: 'toggle-status-mode', - 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', + 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.', 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(), }, { 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.', 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) + : 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 c89c7dfa..32056fc5 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, @@ -19,6 +20,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,40 +50,46 @@ 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.', - shortcut: '⌥D', - run: ({ workspace }) => void workspace.splitFocused('vertical'), + run: ({ workspace }) => workspace.splitFocused('vertical'), }, { 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.', - shortcut: '⌥⇧D', - run: ({ workspace }) => void workspace.splitFocused('horizontal'), + run: ({ workspace }) => workspace.splitFocused('horizontal'), }, { 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. surface: 'session', - title: 'Close Pane', + 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.', - shortcut: '⌘W', - run: ({ workspace }) => void workspace.closeFocused(), + run: ({ workspace }) => workspace.closeFocused(), }, { id: 'bury-pane', + category: 'layout-dispatch', + pickerVisibility: 'advanced', surface: 'session', - title: 'Bury Pane', + title: 'Bury Session', + keywords: ['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.', run: ({ workspace }) => workspace.requestBuryFocused(), }, { 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 @@ -213,7 +227,7 @@ export const paneCommands: CommandDef[] = [ run: ({ workspace }) => { const tabId = attachAllCommandTabId(workspace) if (!tabId) return - void workspace.attachAllDetachedForTab(tabId) + return workspace.attachAllDetachedForTab(tabId) }, }, { @@ -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 @@ -260,16 +277,15 @@ 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'), + run: ({ workspace }) => workspace.splitFocused('vertical', 'terminal'), }, { 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.', - shortcut: '⌥⇧T', - 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 @@ -286,20 +302,23 @@ 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}` } : {}), run: ({ workspace }: CommandContext) => - void workspace.splitFocused('vertical', kind), + workspace.splitFocused('vertical', kind), }, { 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}` } : {}), run: ({ workspace }: CommandContext) => - void workspace.splitFocused('horizontal', kind), + workspace.splitFocused('horizontal', kind), }, ] }), @@ -313,51 +332,58 @@ 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.', - shortcut: '⌥H', run: ({ workspace }) => workspace.navigate('left'), }, { 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.', - shortcut: '⌥L', run: ({ workspace }) => workspace.navigate('right'), }, { 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.', - shortcut: '⌥K', run: ({ workspace }) => workspace.navigate('up'), }, { 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.', - shortcut: '⌥J', run: ({ workspace }) => workspace.navigate('down'), }, { 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.', - shortcut: '⌘⇧T', - run: ({ workspace }) => void workspace.undoClose(), + run: ({ workspace }) => workspace.undoClose(), }, { 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. surface: 'app', - title: 'Revive Buried Pane', + title: 'Revive Buried Session…', + keywords: ['pane'], description: '**What it does:** Restores a **buried live pane**.\n\n**Use when:** You parked a session and want it back.\n\n**Notes:** Opens a picker when multiple buried panes exist.', keepPaletteOpen: true, when: ({ workspace }) => workspace.state.buried.length > 0, @@ -365,18 +391,22 @@ export const paneCommands: CommandDef[] = [ }, { id: 'kill-buried-pane', + category: 'layout-dispatch', + pickerVisibility: 'advanced', surface: 'app', - title: 'Kill Buried Pane…', + title: 'Kill Buried Session…', 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.', - keywords: ['kill', 'buried', 'hidden', 'pane', 'session'], + keywords: ['kill', 'buried', 'hidden', 'pane', 'session', 'pane'], keepPaletteOpen: true, when: ({ workspace }) => workspace.state.buried.length > 0, run: ({ ui }) => ui.enterKillBuriedMode(), }, { id: 'toggle-tail', + category: 'session', surface: 'session', - title: 'Tail', + title: 'Auto-follow Focused Agent', + keywords: ['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.', renderedViewPolicy: { kind: 'requires-rendered-feed' }, getState: ({ workspace, flags }) => { @@ -392,12 +422,18 @@ 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. + // `detail` is what actually reaches the user — it renders in the row's + // explanation. A `truth: 'effective'` marker rode alongside it for a + // while and was never read by any surface, so the sentence was always + // doing the whole job. + return toggle(true, { detail: 'On via Auto-follow All Visible Agents' }) } + return toggle(Boolean(tailMode)) }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) @@ -417,15 +453,17 @@ 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 // on the focused pane. surface: 'app', - title: 'Tail All', + title: 'Auto-follow All Visible Agents', description: '**What it does:** Toggles feed **auto-follow for every visible agent** at once.\n\n**Use when:** You are watching several agents work and want them all pinned to the bottom.\n\n**Notes:** Scopes to what is on screen — in **single dispatch** that is the one agent, in **tiled** every lane, in the **grid** the current tab\'s panes only. Panes you open afterward tail too, until you toggle it off. Terminals are never affected.\n\n**Caution:** A tailing feed cannot be scrolled up — this takes scrollback away from every visible pane at once, and turning it off does not restore where you were reading.', - keywords: ['tail', 'all', 'follow', 'auto-scroll', 'bulk', 'every', 'watch'], + keywords: ['tail', 'all', 'follow', 'auto-scroll', 'bulk', 'every', 'watch', 'tail all', 'tail'], // WHY no `renderedViewPolicy` even though per-session Tail has one: that // gate resolves ONE target session and checks whether it renders a feed. // Tail All has no single target — it is a stance that applies to whatever @@ -435,18 +473,15 @@ 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(), }, { 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.', - shortcut: 'End', renderedViewPolicy: { kind: 'requires-rendered-feed' }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) @@ -460,6 +495,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.renderer.test.ts b/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts index f22ebdb7..f826ccc9 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,14 @@ 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', + // 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() }) @@ -199,3 +206,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 0271e03e..9aadb590 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -1,6 +1,12 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' -import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types' +import { getProviderFeatures } from '@providers/shared/featureCapabilities' +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' @@ -25,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) } function toggleBuiltInMcpDomain( @@ -66,6 +69,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.', @@ -75,7 +79,13 @@ export const sessionCommands: CommandDef[] = [ if (!sessionId) return false const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - return isAgentProviderKind(kind) + // 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) @@ -98,6 +108,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.', @@ -120,8 +132,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) ) }, @@ -141,6 +157,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,8 +201,9 @@ 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…', + title: 'Open 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.', keywords: [ 'agent', @@ -213,6 +232,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 +267,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 +300,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 +323,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 +379,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 +429,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 +479,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,9 +529,11 @@ 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.', + 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') @@ -545,6 +580,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 +633,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.', @@ -604,22 +642,34 @@ 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) if (!sessionId) return false const meta = workspace.state.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - return isAgentProviderKind(kind) && Boolean(meta?.providerSessionId) + // 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).inAppResume && + Boolean(meta?.providerSessionId) + ) }, - run: ({ workspace }) => void workspace.reloadFocusedAgent(), + run: ({ workspace }) => workspace.reloadFocusedAgent(), }, { 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.', @@ -641,10 +691,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) @@ -661,17 +713,18 @@ export const sessionCommands: CommandDef[] = [ }, { id: 'set-agent-view-mode', + category: 'session', + pickerVisibility: 'advanced', surface: 'session', - title: 'Set Agent View Mode...', + title: 'Agent View for This Session…', 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.', - keywords: ['agent', 'view', 'mode', 'terminal', 'rendering', 'raw', 'override', 'default'], + keywords: ['agent', 'view', 'mode', 'terminal', 'rendering', 'raw', 'override', 'default', 'set agent view mode'], 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. Not a toggle: + // "Default" is a real third choice, not the absence of a state. + return value(agentViewOverrideLabel(meta?.agentViewModeOverride)) }, when: ({ workspace }) => { const sessionId = commandTargetSessionId(workspace) @@ -688,6 +741,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.', @@ -696,28 +751,36 @@ 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) 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) @@ -733,19 +796,22 @@ 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.', 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) ) }, @@ -754,12 +820,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({ @@ -811,6 +878,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.', @@ -819,10 +888,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) @@ -830,54 +901,54 @@ 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 }) => void workspace.switchFocusedProvider(), + run: ({ workspace }) => workspace.switchFocusedProvider(), }, { 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.', - getState: ({ flags }) => ({ - label: flags.gitBarOpen ? 'On' : 'Off', - tone: flags.gitBarOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.gitBarOpen), run: ({ ui }) => ui.toggleGitBar(), }, { 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.', - getState: ({ flags }) => ({ - label: flags.debugPanelOpen ? 'On' : 'Off', - tone: flags.debugPanelOpen ? 'accent' : 'neutral', - }), + getState: ({ flags }) => toggle(flags.debugPanelOpen), run: ({ ui }) => ui.toggleDebugPanel(), }, { 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.', 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(), }, { 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.', 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(), }, { @@ -893,6 +964,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.', @@ -914,7 +987,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) }, }, { @@ -933,8 +1006,10 @@ 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', + title: '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/`.', keywords: ['recording', 'record', 'start', 'stop', 'capture', 'session', 'soak', 'fixture', 'debug'], when: ({ flags, workspace }) => { @@ -950,7 +1025,7 @@ export const sessionCommands: CommandDef[] = [ }, run: ({ workspace, ui }) => { ui.closePalette() - void runToggleSessionRecordingCommand(workspace) + return runToggleSessionRecordingCommand(workspace) }, }, { @@ -967,8 +1042,10 @@ 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', + 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.', keywords: ['recording', 'note', 'mark', 'bookmark', 'annotate', 'soak', 'session', 'record', 'tick', 'debug'], when: ({ flags, workspace }) => { @@ -983,23 +1060,33 @@ export const sessionCommands: CommandDef[] = [ }, run: ({ workspace, ui }) => { ui.closePalette() - void runAttachRecordingNoteCommand(workspace) + return runAttachRecordingNoteCommand(workspace) }, }, { 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.', 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(), }, { 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.', @@ -1008,23 +1095,19 @@ 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(), }, { 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.', 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(), }, ] diff --git a/src/renderer/src/features/workspace/commands/tabCommands.ts b/src/renderer/src/features/workspace/commands/tabCommands.ts index ca536be6..24c708af 100644 --- a/src/renderer/src/features/workspace/commands/tabCommands.ts +++ b/src/renderer/src/features/workspace/commands/tabCommands.ts @@ -3,40 +3,42 @@ 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.', - shortcut: '⌘T', run: ({ ui }) => ui.openNewTabPicker(), }, { 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.', - shortcut: '⌘⇧W', - run: ({ workspace }) => { - if (workspace.activeTab) void workspace.closeTab(workspace.activeTab.id) - }, + run: ({ workspace }) => + workspace.activeTab ? workspace.closeTab(workspace.activeTab.id) : undefined, }, { 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.', - shortcut: '⌘]', run: ({ workspace }) => workspace.nextTab(), }, { 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.', - shortcut: '⌘[', run: ({ workspace }) => workspace.prevTab(), }, { 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,10 +48,10 @@ 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.', - shortcut: '⌘⇧R', keepPaletteOpen: true, run: ({ ui }) => ui.enterResumeMode(), }, diff --git a/src/renderer/src/features/workspace/surfaces/CloseConfirmationSurface.tsx b/src/renderer/src/features/workspace/surfaces/CloseConfirmationSurface.tsx new file mode 100644 index 00000000..a9423c52 --- /dev/null +++ b/src/renderer/src/features/workspace/surfaces/CloseConfirmationSurface.tsx @@ -0,0 +1,8 @@ +import { CloseConfirmationDialog } from '@renderer/features/workspace/ui/CloseConfirmationDialog' + +/** Always mounted: a close can be initiated from the palette, a chord, the tab + * button, the native menu, or programmatically, and the dialog must be able to + * answer whichever asked. */ +export function CloseConfirmationSurface() { + return +} diff --git a/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx b/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx new file mode 100644 index 00000000..d9c17e7e --- /dev/null +++ b/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react' + +import { Button } from '@renderer/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog' +import { + currentCloseConfirmation, + resolveCloseConfirmation, + subscribeToCloseConfirmation, +} from '@renderer/workspace/closeConfirmationBroker' +import type { PendingCloseConfirmation } from '@renderer/workspace/closeConfirmationBroker' + +/** + * The confirmation the close paths await before ending anything. + * + * It LISTS the sessions rather than only counting them. The audit's finding was + * that a close expanding from one row to four looked identical to closing one; + * a bare "close 4 sessions?" fixes the count but still leaves the user unable + * to check whether the four are the four they meant. + */ +export function CloseConfirmationDialog() { + const [pending, setPending] = useState( + currentCloseConfirmation(), + ) + + useEffect(() => subscribeToCloseConfirmation(setPending), []) + + const request = pending?.request + const live = request?.targets.filter(target => target.live) ?? [] + + return ( + { + // Escape, the overlay, and the close button all mean DECLINE. Leaving + // the promise unresolved would hang the close path forever, which + // presents as a pane that will not close with no error anywhere. + if (!nextOpen) resolveCloseConfirmation(false) + }} + > + + + + {request?.reason === 'running' + ? 'Close a working agent?' + : request?.reason === 'irreversible' + ? 'Kill this session permanently?' + : 'Close these sessions?'} + + {request?.summary} + + + {request && request.targets.length > 1 ? ( +
+ {request.targets.map(target => ( +
+ {target.title} + {target.live ? ( + + working + + ) : null} +
+ ))} +
+ ) : null} + + {live.length > 0 ? ( +
+ Undo Close restores the layout, but not live terminal output or unsent drafts. +
+ ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx b/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.tsx index 4ee27ace..9eab6bab 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, @@ -44,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' @@ -91,6 +188,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') @@ -130,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( @@ -187,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() @@ -262,6 +307,33 @@ export function CloseOldAgentsModal({ open, workspace, onClose }: Props) { setSelectedProjects(new Set()) }, []) + // 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 setClosing(true) @@ -271,14 +343,56 @@ 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() + const stillGranted = narrowGrantToCurrent([target], current) + if (stillGranted.length === 0) { + outcome.skipped.push(target.sessionId) + continue + } + try { + // 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 + // 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]) + }, [buildCloseTargets, closing, matchingRows, onClose, showToast]) return ( ({ + 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('multi') + 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') + }) +}) + +describe('target expansion', () => { + const state = { + sessions: { + parent: { title: 'Parent' }, + child: { title: 'Child', linkedParentId: 'parent' }, + grandchild: { title: 'Grandchild', linkedParentId: 'child' }, + unrelated: { title: 'Unrelated' }, + detached: { title: 'Detached' }, + }, + } + + it('expands linked descendants transitively', () => { + // A linked child can itself have linked children. Stopping at one level + // would under-report the cascade in exactly the deep case where the count + // matters most. + const ids = expandSessionCloseTargets(state, {}, 'parent').map(t => t.sessionId) + expect(ids.sort()).toEqual(['child', 'grandchild', 'parent']) + }) + + it('does not sweep in unrelated sessions', () => { + const ids = expandSessionCloseTargets(state, {}, 'parent').map(t => t.sessionId) + expect(ids).not.toContain('unrelated') + }) + + it('survives a malformed parent cycle instead of hanging', () => { + // Guarding with a visited set rather than trusting the data to be a tree: + // an infinite loop here would freeze the app on a close. + const cyclic = { + sessions: { + a: { title: 'A', linkedParentId: 'b' }, + b: { title: 'B', linkedParentId: 'a' }, + }, + } + const ids = expandSessionCloseTargets(cyclic, {}, 'a').map(t => t.sessionId) + expect(ids.sort()).toEqual(['a', 'b']) + }) + + it('marks a running session live', () => { + const runtimes = { parent: { sessionStatus: 'running' } } + expect(expandSessionCloseTargets(state, runtimes, 'parent')[0].live).toBe(true) + }) + + it('marks a mid-stream session live even when not running', () => { + // Both signals matter, and they are the SAME pair CloseOldAgentsModal uses + // — so its preview and this confirmation cannot disagree about who is busy. + const runtimes = { parent: { streamPhase: 'delta' } } + expect(expandSessionCloseTargets(state, runtimes, 'parent')[0].live).toBe(true) + }) + + it('treats an idle stream phase as not live', () => { + const runtimes = { parent: { sessionStatus: 'idle', streamPhase: 'idle' } } + expect(expandSessionCloseTargets(state, runtimes, 'parent')[0].live).toBe(false) + }) + + it('includes a tab detached sessions alongside its grid leaves', () => { + // The ones people forget: a detached session has no tile in the tab the + // user is looking at, so a tab close that takes six background agents with + // it looks like closing an empty tab. + const targets = expandTabCloseTargets(state, {}, ['parent'], ['detached']) + expect(targets.map(t => t.sessionId).sort()).toEqual([ + 'child', 'detached', 'grandchild', 'parent', + ]) + }) + + it('does not double-count a session reachable two ways', () => { + const targets = expandTabCloseTargets(state, {}, ['parent', 'child'], []) + expect(targets).toHaveLength(3) + }) + + it('turns a cascade into a confirmation that names the count', () => { + // The end-to-end point of expansion: closing ONE pane confirms as three. + const targets = expandSessionCloseTargets(state, {}, 'parent') + const result = closeConfirmationFor(targets) + expect(result.required).toBe(true) + 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 new file mode 100644 index 00000000..1634423a --- /dev/null +++ b/src/renderer/src/workspace/closeConfirmation.ts @@ -0,0 +1,352 @@ +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' | 'multi' | 'irreversible' + /** Every session this close will end, expanded. */ + targets: readonly CloseTargetSnapshot[] + /** One-line summary naming the exact count. */ + 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? + * + * `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 } + + 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: `${summarizeCloseTargets(targets)} 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. + // '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. + return { + required: true, + reason: 'multi', + targets, + summary: summarizeCloseTargets(targets), + } +} + +/** + * 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)) +} + +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 + /** + * 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() + 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' } + + // 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. + * + * 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(', ')}.` +} + +// --------------------------------------------------------------------------- +// Target expansion. +// +// The policy above judges an EXPANDED set. Expansion is separate because +// getting it wrong is the actual danger: an unexpanded set silently downgrades +// a cascade to a single close, and the user confirms one thing while four die. +// --------------------------------------------------------------------------- + +/** The slice of workspace state expansion needs. Narrowed so these functions + * stay pure and testable without constructing a whole store. */ +export type CloseExpansionState = { + sessions: Record +} + +/** The slice of runtime state that decides "is this session live". Mirrors what + * CloseOldAgentsModal already uses, so preview and confirmation agree. */ +export type CloseExpansionRuntimes = Record< + string, + { sessionStatus?: string; streamPhase?: string | null } | undefined +> + +/** 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 + const running = runtime.sessionStatus === 'running' + const streaming = runtime.streamPhase != null && runtime.streamPhase !== 'idle' + return Boolean(running || streaming) +} + +function snapshot( + state: CloseExpansionState, + runtimes: CloseExpansionRuntimes, + sessionId: string, +): CloseTargetSnapshot { + return { + sessionId, + title: state.sessions[sessionId]?.title ?? sessionId, + live: isLive(runtimes, sessionId), + } +} + +/** + * Every session that closing `rootId` will end: the root plus its linked + * descendants, TRANSITIVELY. + * + * Transitive matters — a linked child can itself have linked children, and + * stopping at one level would under-report the cascade in exactly the deep case + * where the user most needs the count. The visited set guards against a + * malformed parent cycle rather than trusting the data to be a clean tree. + */ +export function expandSessionCloseTargets( + state: CloseExpansionState, + runtimes: CloseExpansionRuntimes, + rootId: string, +): CloseTargetSnapshot[] { + const out: CloseTargetSnapshot[] = [] + const visited = new Set() + const queue = [rootId] + + while (queue.length > 0) { + const id = queue.shift() as string + if (visited.has(id)) continue + visited.add(id) + if (!state.sessions[id]) continue + out.push(snapshot(state, runtimes, id)) + for (const [childId, meta] of Object.entries(state.sessions)) { + if (meta?.linkedParentId === id) queue.push(childId) + } + } + return out +} + +/** + * Every session a TAB close will end: each grid leaf expanded through its + * linked descendants, plus the tab's detached Dispatch sessions. + * + * Detached sessions are the ones people forget. They have no tile in the tab + * the user is looking at, so a tab close that silently takes six background + * agents with it looks like closing an empty tab. + */ +export function expandTabCloseTargets( + state: CloseExpansionState, + runtimes: CloseExpansionRuntimes, + gridSessionIds: readonly string[], + detachedSessionIds: readonly string[], +): CloseTargetSnapshot[] { + const seen = new Set() + const out: CloseTargetSnapshot[] = [] + for (const id of [...gridSessionIds, ...detachedSessionIds]) { + for (const target of expandSessionCloseTargets(state, runtimes, id)) { + if (seen.has(target.sessionId)) continue + seen.add(target.sessionId) + out.push(target) + } + } + return out +} diff --git a/src/renderer/src/workspace/closeConfirmationBroker.test.ts b/src/renderer/src/workspace/closeConfirmationBroker.test.ts new file mode 100644 index 00000000..8207720b --- /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: 'multi' 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..ca7de437 --- /dev/null +++ b/src/renderer/src/workspace/closeConfirmationBroker.ts @@ -0,0 +1,90 @@ +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 { + // 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() + }) +} + +/** 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/dispatch/DispatchLayout.tsx b/src/renderer/src/workspace/dispatch/DispatchLayout.tsx index 09e95d81..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,15 +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 - // 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 // without being re-derived from workspace state. We measure against @@ -133,18 +123,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 +182,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/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts index 51e9122e..58d2e5cf 100644 --- a/src/renderer/src/workspace/hook/actions/pane.ts +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -1,4 +1,12 @@ import { DEFAULT_PROVIDER } from '@shared/types/providerKind' +import { + 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' import type { @@ -81,6 +89,43 @@ 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 + /** + * 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[] ids: SessionId[] @@ -106,6 +151,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, @@ -198,7 +306,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 @@ -207,7 +315,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. @@ -743,7 +853,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]) @@ -1078,10 +1194,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 () => { + // 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 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 dispatchTargetId = snapshot.dispatchMode - ? commandTargetSessionIdForState(snapshot) - : null + 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: // @@ -1099,7 +1248,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 @@ -1115,7 +1264,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 @@ -1232,6 +1381,7 @@ export function usePaneActions( }) }, [ closeLinkedChildren, + refs.latestRuntimesRef, refs.latestScreenRef, refs.seenUuidsRef, refs.stateRef, @@ -1251,7 +1401,40 @@ 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, + force: options?.requireConfirmation, + }) + 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] @@ -1404,6 +1587,7 @@ export function usePaneActions( }, [ closeLinkedChildren, + refs.latestRuntimesRef, refs.latestScreenRef, refs.seenUuidsRef, refs.stateRef, @@ -1730,6 +1914,30 @@ 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, + // 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, + 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/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 32f273b9..5f979252 100644 --- a/src/renderer/src/workspace/hook/actions/tab.ts +++ b/src/renderer/src/workspace/hook/actions/tab.ts @@ -1,7 +1,12 @@ 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 { + expandTabCloseTargets, + runCloseConfirmationGate, +} 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' @@ -79,17 +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 + // CONFIRMATION GATE, before any kill. + // + // 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 - // 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) const idsToKill = [...ids, ...detachedIds] const allMetas: Record = {} for (const id of ids) { @@ -176,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, @@ -187,9 +248,6 @@ export function useTabActions( setState, setTileTabs, showToast, - state.detachedSessions, - state.sessions, - state.tabs, ], ) diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts index de9279cd..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, @@ -932,7 +953,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/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) diff --git a/src/renderer/src/workspace/tile-tree/useKeybinds.ts b/src/renderer/src/workspace/tile-tree/useKeybinds.ts index 28090ce6..67ba5721 100644 --- a/src/renderer/src/workspace/tile-tree/useKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/useKeybinds.ts @@ -1,8 +1,10 @@ -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' import type { Workspace } from '@renderer/workspace/workspaceStore' import { getEffectiveAgentSurface, isAgentKind } from '@renderer/workspace/agentDisplayMode' @@ -72,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 @@ -156,20 +155,110 @@ function renderedAgentSurfaceIsVisible( ) } +/** + * Commands whose chord is owned by a surface OTHER than this router, and which + * must therefore never be dispatched from here. + * + * 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. + * + * 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 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), 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, + bindingIndex: ReadonlyMap, + activeContexts: ReadonlySet, +): string | null { + const binding = keybindingFromEvent(event) + if (!binding) return null + 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) + 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) 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 @@ -196,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 @@ -277,96 +374,6 @@ export function useKeybinds( return } - // --- CMD: command palette --- - if (cmd && shift && k.toLowerCase() === 'p' && !alt) { - e.preventDefault() - onCommandPalette?.() - return - } - - // --- Cmd+Shift+E: Global Editor toggle --- - // - // 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) { - e.preventDefault() - toggleGlobalEditor() - 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) { @@ -539,13 +546,40 @@ 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) { + // --- 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() - void workspace.undoClose() + requestCommandInvocation(routedCommandId, 'keybinding') return } @@ -585,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-[ / ]. @@ -780,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() @@ -868,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) => { @@ -912,9 +821,6 @@ export function useKeybinds( closeLinkedAgent, closeReorderTabs, closePinAgents, - onCommandPalette, - onNewTabRequest, - onResumeRequest, buryPromptSessionId, dispatchAttachIntent, linkedAgentParentId, @@ -922,7 +828,6 @@ export function useKeybinds( pinAgentsOpen, reorderTabsOpen, settingsPageOpen, - toggleGlobalEditor, workspace, ]) } 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 = { 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]