diff --git a/docs/superpowers/plans/2026-05-28-universal-command-palette-action-system.md b/docs/superpowers/plans/2026-05-28-universal-command-palette-action-system.md new file mode 100644 index 00000000..8ef0620c --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-universal-command-palette-action-system.md @@ -0,0 +1,1368 @@ +# Universal Command Palette Action System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the TUI command palette into a universal, searchable, context-sensitive action system backed by a single unified action model. + +**Architecture:** Introduce one `Action` shape (`{id,label,detail,group,keywords?,available?,enabled?,run}`) evaluated against a rich internal `ActionContext`. Built-in actions are produced by builders from live state; plugin `TuiActionRegistration`s are wrapped by an adapter that narrows the rich context to the existing `TuiPluginContext`. A shared `computeVisibleActions` helper produces the flat, ordered list used both for rendering (grouped, or flat when querying) and for keyboard selection indexing. + +**Tech Stack:** TypeScript, React + OpenTUI, `bun test`. + +**Spec:** `docs/superpowers/specs/2026-05-28-universal-command-palette-action-system-design.md` + +**Test commands:** run a single file with `bun test `; full suite with `bun test`. Typecheck with `bun run typecheck` (or `bunx tsc --noEmit` if no script). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `src/tui/actions/types.ts` (new) | `ActionGroup`, `Action`, `ActionContext`, `GROUP_ORDER` | +| `src/tui/actions/visibility.ts` (new) | `computeVisibleActions` + keyword-aware fuzzy ranking (`VisibleAction`) | +| `src/tui/actions/plugin-adapter.ts` (new) | `buildPluginActions(entries, mkPluginCtx)` → `Action[]` (group `Plugins`) | +| `src/tui/actions/builtin-actions.ts` (new) | `buildBuiltInActions(ctx)` → `Action[]` for Navigation/Agents/Workflow/Contributions | +| `src/tui/components/command-palette.tsx` (modify) | Render `Action[]` grouped (no query) / flat ranked (query); drop `PaletteItem` + old builders; keep `fuzzyMatch`/`renderHighlighted` | +| `src/tui/app.tsx` (modify) | Build `ActionContext`, assemble action list, collapse `onPaletteSelect`, add `pendingQuestionCount` fetcher | +| tests alongside each new file + `command-palette.test.tsx` migration | + +--- + +## Task 1: Action model types + +**Files:** +- Create: `src/tui/actions/types.ts` +- Test: `src/tui/actions/types.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/tui/actions/types.test.ts +import { describe, expect, test } from "bun:test"; +import { GROUP_ORDER } from "./types.js"; + +describe("action types", () => { + test("GROUP_ORDER lists the five groups in display order", () => { + expect(GROUP_ORDER).toEqual([ + "Navigation", + "Agents", + "Workflow", + "Contributions", + "Plugins", + ]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/tui/actions/types.test.ts` +Expected: FAIL — cannot find module `./types.js`. + +- [ ] **Step 3: Write the types** + +```ts +// src/tui/actions/types.ts +import type { Claim } from "../../core/models.js"; +import type { AgentTopology } from "../../core/topology.js"; +import type { Panel } from "../hooks/use-panel-focus.js"; + +/** Top-level grouping for palette actions. */ +export type ActionGroup = + | "Navigation" + | "Agents" + | "Workflow" + | "Contributions" + | "Plugins"; + +/** Fixed display order for groups when no query is active. */ +export const GROUP_ORDER: readonly ActionGroup[] = [ + "Navigation", + "Agents", + "Workflow", + "Contributions", + "Plugins", +]; + +/** An agent profile loaded from .grove/agents.json. */ +export interface LoadedProfile { + readonly name: string; + readonly role: string; + readonly platform: string; + readonly command?: string | undefined; +} + +/** A gossip peer with free agent capacity. */ +export interface GossipPeerSlot { + readonly peerId: string; + readonly address: string; + readonly freeSlots: number; +} + +/** + * Rich, internal context handed to built-in actions. Holds read-only state for + * enumeration/gating plus imperative capabilities closed over app machinery. + * Plugins never see this — see `plugin-adapter.ts`. + */ +export interface ActionContext { + // --- read state --- + readonly topology?: AgentTopology | undefined; + readonly sessions: readonly string[]; + readonly profiles: readonly LoadedProfile[]; + readonly gossipPeers: readonly GossipPeerSlot[]; + /** Active claims, or null when scoped session can't see them. */ + readonly claims: readonly Claim[] | null; + readonly selectedSession?: string | undefined; + readonly selectedCid?: string | undefined; + readonly parentAgentId?: string | undefined; + readonly pendingQuestionCount: number; + readonly hasGoals: boolean; + readonly canSpawn: boolean; + readonly canDelegate: boolean; + readonly isPanelVisible: (panel: Panel) => boolean; + + // --- capabilities --- + readonly focusPanel: (panel: Panel) => void; + readonly togglePanel: (panel: Panel) => void; + readonly openContribution: (cid: string) => void; + readonly jumpToSession: (session: string) => void; + readonly enterGoalMode: () => void; + readonly enterCompareMode: () => void; + readonly addToCompare: (cid: string) => void; + readonly adoptContribution: (cid: string, summary: string) => void; + readonly answerPendingQuestion: (verdict: "approve" | "deny") => void; + readonly registerAgentProfile: () => void; + readonly spawn: (roleId: string, command: string, parentAgentId?: string) => void; + readonly kill: (session: string) => void; + readonly delegate: (peerAddress: string) => void; + readonly showMessage: (message: string) => void; +} + +/** A single unified palette action. */ +export interface Action { + /** Stable, unique id, e.g. "nav.panel.terminal", "agent.spawn.reviewer". */ + readonly id: string; + readonly label: string; + readonly detail: string; + readonly group: ActionGroup; + /** Extra fuzzy-match terms beyond the label. */ + readonly keywords?: readonly string[] | undefined; + /** Relevance gate. False → item is HIDDEN entirely. Default: visible. */ + readonly available?: ((ctx: ActionContext) => boolean) | undefined; + /** Capability gate. False → item shown but GREYED and not executable. */ + readonly enabled?: ((ctx: ActionContext) => boolean) | undefined; + readonly run: (ctx: ActionContext) => void | Promise; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/tui/actions/types.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/actions/types.ts src/tui/actions/types.test.ts +git commit -m "feat(tui): add unified Action model + ActionContext types (#194)" +``` + +--- + +## Task 2: Visibility + keyword-aware ranking helper + +**Files:** +- Create: `src/tui/actions/visibility.ts` +- Test: `src/tui/actions/visibility.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/tui/actions/visibility.test.ts +import { describe, expect, test } from "bun:test"; +import type { Action, ActionContext } from "./types.js"; +import { computeVisibleActions } from "./visibility.js"; + +// Minimal ctx — only fields read by these actions matter. +function ctx(overrides: Partial = {}): ActionContext { + return { + sessions: [], + profiles: [], + gossipPeers: [], + claims: [], + pendingQuestionCount: 0, + hasGoals: false, + canSpawn: false, + canDelegate: false, + isPanelVisible: () => false, + focusPanel: () => undefined, + togglePanel: () => undefined, + openContribution: () => undefined, + jumpToSession: () => undefined, + enterGoalMode: () => undefined, + enterCompareMode: () => undefined, + addToCompare: () => undefined, + adoptContribution: () => undefined, + answerPendingQuestion: () => undefined, + registerAgentProfile: () => undefined, + spawn: () => undefined, + kill: () => undefined, + delegate: () => undefined, + showMessage: () => undefined, + ...overrides, + }; +} + +function act(o: Partial & Pick): Action { + return { label: o.id, detail: "", run: () => undefined, ...o }; +} + +describe("computeVisibleActions", () => { + test("no query: hides unavailable, orders by group", () => { + const actions: Action[] = [ + act({ id: "p1", group: "Plugins", label: "plugin one" }), + act({ id: "n1", group: "Navigation", label: "nav one" }), + act({ id: "hidden", group: "Agents", label: "hidden", available: () => false }), + ]; + const visible = computeVisibleActions(actions, ctx(), ""); + expect(visible.map((v) => v.action.id)).toEqual(["n1", "p1"]); + expect(visible[0]?.matchedIndices).toEqual([]); + }); + + test("query: flat ranked, matches label or keywords", () => { + const actions: Action[] = [ + act({ id: "terminal", group: "Navigation", label: "Focus Terminal" }), + act({ id: "vfs", group: "Navigation", label: "Focus VFS", keywords: ["files"] }), + ]; + const visible = computeVisibleActions(actions, ctx(), "files"); + expect(visible.map((v) => v.action.id)).toEqual(["vfs"]); + }); + + test("query: still respects available()", () => { + const actions: Action[] = [ + act({ id: "x", group: "Workflow", label: "answer question", available: () => false }), + ]; + expect(computeVisibleActions(actions, ctx(), "answer")).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/tui/actions/visibility.test.ts` +Expected: FAIL — cannot find module `./visibility.js`. + +- [ ] **Step 3: Write the helper** + +```ts +// src/tui/actions/visibility.ts +import { fuzzyMatch } from "../components/command-palette.js"; +import type { Action, ActionContext } from "./types.js"; +import { GROUP_ORDER } from "./types.js"; + +/** An available action with label-match metadata for highlighting. */ +export interface VisibleAction { + readonly action: Action; + readonly matchedIndices: readonly number[]; +} + +function isAvailable(action: Action, ctx: ActionContext): boolean { + return action.available?.(ctx) ?? true; +} + +/** + * Produce the ordered, flat list of actions the palette displays. + * + * - No query: available actions sorted by GROUP_ORDER (stable within a group). + * - Query: available actions whose label OR any keyword fuzzy-matches, ranked + * by best score (desc). `matchedIndices` reflects the label match only + * (empty when only a keyword matched). + * + * This single list is the source of truth for BOTH grouped rendering and the + * keyboard selection index — keeping them in sync. + */ +export function computeVisibleActions( + actions: readonly Action[], + ctx: ActionContext, + query: string, +): readonly VisibleAction[] { + const available = actions.filter((a) => isAvailable(a, ctx)); + const q = query.trim(); + + if (!q) { + const ordered = [...available].sort( + (a, b) => GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group), + ); + return ordered.map((action) => ({ action, matchedIndices: [] })); + } + + const ranked: Array = []; + for (const action of available) { + const labelResult = fuzzyMatch(q, action.label); + let best = labelResult.match ? labelResult.score : -1; + let matchedIndices: readonly number[] = labelResult.match ? labelResult.matchedIndices : []; + for (const kw of action.keywords ?? []) { + const r = fuzzyMatch(q, kw); + if (r.match && r.score > best) { + best = r.score; + // Keep label highlight only; a keyword-only match yields no label indices. + if (!labelResult.match) matchedIndices = []; + } + } + if (best >= 0) ranked.push({ action, matchedIndices, score: best }); + } + ranked.sort((a, b) => b.score - a.score); + return ranked.map(({ action, matchedIndices }) => ({ action, matchedIndices })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/tui/actions/visibility.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/actions/visibility.ts src/tui/actions/visibility.test.ts +git commit -m "feat(tui): add computeVisibleActions ordering + keyword ranking (#194)" +``` + +--- + +## Task 3: Plugin adapter + +**Files:** +- Create: `src/tui/actions/plugin-adapter.ts` +- Test: `src/tui/actions/plugin-adapter.test.ts` + +`mkPluginCtx` converts the rich `ActionContext` into the narrow `TuiPluginContext`. The app supplies it (it owns `provider`/`density`); the adapter never reaches app internals itself. + +- [ ] **Step 1: Write the failing test** + +```ts +// src/tui/actions/plugin-adapter.test.ts +import { describe, expect, mock, test } from "bun:test"; +import { mergeTuiActionRegistrations } from "../plugins/registry.js"; +import type { TuiActionRegistration, TuiPluginContext } from "../plugins/types.js"; +import { buildPluginActions } from "./plugin-adapter.js"; + +const pluginCtx = { density: "compact", showMessage: () => undefined } as unknown as TuiPluginContext; + +function reg(o: Partial = {}): TuiActionRegistration { + return { id: "audit-refresh", label: "Refresh audit", detail: "audit", run: () => undefined, ...o }; +} + +describe("buildPluginActions", () => { + test("wraps plugin registrations as Plugins-group actions", () => { + const merged = mergeTuiActionRegistrations({ builtIns: [], plugins: [reg()] }); + const actions = buildPluginActions(merged.entries, () => pluginCtx); + expect(actions).toHaveLength(1); + expect(actions[0]?.id).toBe("audit-refresh"); + expect(actions[0]?.group).toBe("Plugins"); + expect(actions[0]?.label).toBe("Refresh audit"); + }); + + test("run delegates to the registration with the narrow plugin context", async () => { + const run = mock(() => undefined); + const merged = mergeTuiActionRegistrations({ builtIns: [], plugins: [reg({ run })] }); + const actions = buildPluginActions(merged.entries, () => pluginCtx); + // ActionContext arg is ignored by the adapter; pass an empty stub. + await actions[0]?.run({} as never); + expect(run).toHaveBeenCalledTimes(1); + expect(run.mock.calls[0]?.[0]).toBe(pluginCtx); + }); + + test("enabled delegates to the registration predicate via plugin context", () => { + const enabled = mock((c: TuiPluginContext) => c.density === "compact"); + const merged = mergeTuiActionRegistrations({ builtIns: [], plugins: [reg({ enabled })] }); + const actions = buildPluginActions(merged.entries, () => pluginCtx); + expect(actions[0]?.enabled?.({} as never)).toBe(true); + expect(enabled).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/tui/actions/plugin-adapter.test.ts` +Expected: FAIL — cannot find module `./plugin-adapter.js`. + +- [ ] **Step 3: Write the adapter** + +```ts +// src/tui/actions/plugin-adapter.ts +import type { TuiActionRegistryEntry } from "../plugins/registry.js"; +import type { TuiPluginContext } from "../plugins/types.js"; +import type { Action, ActionContext } from "./types.js"; + +/** + * Wrap plugin action registry entries as unified `Action`s in the Plugins group. + * + * `mkPluginCtx` builds the narrow plugin context from the rich one so plugins + * never receive app internals (panel focus, spawn/kill, dispatch). + */ +export function buildPluginActions( + entries: readonly TuiActionRegistryEntry[], + mkPluginCtx: (ctx: ActionContext) => TuiPluginContext, +): readonly Action[] { + const actions: Action[] = []; + for (const entry of entries) { + if (entry.source !== "plugin" || entry.registration === undefined) continue; + const reg = entry.registration; + actions.push({ + id: entry.id, + label: entry.label, + detail: entry.detail, + group: "Plugins", + enabled: reg.enabled ? (ctx) => reg.enabled?.(mkPluginCtx(ctx)) ?? true : undefined, + run: (ctx) => reg.run(mkPluginCtx(ctx)), + }); + } + return Object.freeze(actions); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/tui/actions/plugin-adapter.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/actions/plugin-adapter.ts src/tui/actions/plugin-adapter.test.ts +git commit -m "feat(tui): add plugin-action adapter to unified model (#194)" +``` + +--- + +## Task 4: Built-in action builders + +**Files:** +- Create: `src/tui/actions/builtin-actions.ts` +- Test: `src/tui/actions/builtin-actions.test.ts` + +Builders enumerate per-entity actions from `ctx` and gate them via `available`/`enabled`. Uses `checkSpawn` for capacity, `OPERATOR_PANELS`/`PANEL_LABELS` for navigation, `agentIdFromSession` is NOT needed (jump-to-session uses raw session names). + +- [ ] **Step 1: Write the failing test** + +```ts +// src/tui/actions/builtin-actions.test.ts +import { describe, expect, test } from "bun:test"; +import type { ActionContext } from "./types.js"; +import { buildBuiltInActions } from "./builtin-actions.js"; + +function ctx(overrides: Partial = {}): ActionContext { + return { + sessions: [], + profiles: [], + gossipPeers: [], + claims: [], + pendingQuestionCount: 0, + hasGoals: false, + canSpawn: false, + canDelegate: false, + isPanelVisible: () => false, + focusPanel: () => undefined, + togglePanel: () => undefined, + openContribution: () => undefined, + jumpToSession: () => undefined, + enterGoalMode: () => undefined, + enterCompareMode: () => undefined, + addToCompare: () => undefined, + adoptContribution: () => undefined, + answerPendingQuestion: () => undefined, + registerAgentProfile: () => undefined, + spawn: () => undefined, + kill: () => undefined, + delegate: () => undefined, + showMessage: () => undefined, + ...overrides, + }; +} + +function ids(c: ActionContext): string[] { + return buildBuiltInActions(c) + .filter((a) => a.available?.(c) ?? true) + .map((a) => a.id); +} + +describe("buildBuiltInActions", () => { + test("navigation: one open/focus action per operator panel + always offers register/compare", () => { + const present = ids(ctx()); + expect(present).toContain("nav.panel.terminal"); + expect(present).toContain("workflow.compare"); + expect(present).toContain("workflow.register-agent"); + }); + + test("set goal only available when provider has goals", () => { + expect(ids(ctx())).not.toContain("workflow.set-goal"); + expect(ids(ctx({ hasGoals: true }))).toContain("workflow.set-goal"); + }); + + test("answer-question actions only available when a question is pending", () => { + expect(ids(ctx())).not.toContain("workflow.approve-question"); + const pending = ids(ctx({ pendingQuestionCount: 1 })); + expect(pending).toContain("workflow.approve-question"); + expect(pending).toContain("workflow.deny-question"); + }); + + test("contribution actions only available when a contribution is selected", () => { + expect(ids(ctx())).not.toContain("contrib.open"); + const sel = ids(ctx({ selectedCid: "bafy123" })); + expect(sel).toContain("contrib.open"); + expect(sel).toContain("contrib.compare-add"); + expect(sel).toContain("contrib.adopt"); + }); + + test("kill action per live session; jump-to-session per session", () => { + const present = ids(ctx({ sessions: ["grove-reviewer-1"] })); + expect(present).toContain("agent.kill.grove-reviewer-1"); + expect(present).toContain("nav.session.grove-reviewer-1"); + }); + + test("spawn from profile is present but disabled at capacity", () => { + const c = ctx({ + canSpawn: true, + profiles: [{ name: "@rev", role: "reviewer", platform: "claude-code" }], + // No topology → checkSpawn allowed:true path; force disable via claims is + // covered by spawn-validator tests, here we assert presence + enabled default. + }); + const spawn = buildBuiltInActions(c).find((a) => a.id === "agent.spawn.reviewer"); + expect(spawn).toBeDefined(); + expect(spawn?.enabled?.(c) ?? true).toBe(true); + }); + + test("delegate only available when canDelegate and peer has free slots", () => { + const peers = [{ peerId: "p1", address: "http://p1", freeSlots: 2 }]; + expect(ids(ctx({ canDelegate: false, gossipPeers: peers }))).not.toContain( + "agent.delegate.http://p1", + ); + expect(ids(ctx({ canDelegate: true, gossipPeers: peers }))).toContain( + "agent.delegate.http://p1", + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/tui/actions/builtin-actions.test.ts` +Expected: FAIL — cannot find module `./builtin-actions.js`. + +- [ ] **Step 3: Write the builders** + +```ts +// src/tui/actions/builtin-actions.ts +import { checkSpawn } from "../agents/spawn-validator.js"; +import { OPERATOR_PANELS, PANEL_LABELS, type Panel } from "../hooks/use-panel-focus.js"; +import type { Action, ActionContext } from "./types.js"; + +/** Build the full set of built-in actions from the current context. */ +export function buildBuiltInActions(ctx: ActionContext): readonly Action[] { + return Object.freeze([ + ...navigationActions(ctx), + ...agentActions(ctx), + ...workflowActions(ctx), + ...contributionActions(), + ]); +} + +function navigationActions(ctx: ActionContext): readonly Action[] { + const actions: Action[] = []; + for (const panel of OPERATOR_PANELS) { + const label = PANEL_LABELS[panel]; + const key = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + actions.push({ + id: `nav.panel.${key}`, + label: `Go to ${label} panel`, + detail: "panel", + group: "Navigation", + keywords: ["open", "focus", "panel", label], + run: (c) => (c.isPanelVisible(panel as Panel) ? c.focusPanel(panel as Panel) : c.togglePanel(panel as Panel)), + }); + } + for (const session of ctx.sessions) { + actions.push({ + id: `nav.session.${session}`, + label: `Jump to session ${session}`, + detail: "session", + group: "Navigation", + keywords: ["session", "agent", "jump"], + run: (c) => c.jumpToSession(session), + }); + } + return actions; +} + +function agentActions(ctx: ActionContext): readonly Action[] { + const actions: Action[] = []; + const claims = ctx.claims ?? []; + + // Spawn from profiles first, then topology roles not covered by a profile. + const profileRoles = new Set(); + if (ctx.canSpawn) { + for (const profile of ctx.profiles) { + profileRoles.add(profile.role); + const role = profile.role; + actions.push({ + id: `agent.spawn.${role}`, + label: `Spawn ${profile.name} [${profile.platform}]`, + detail: "spawn", + group: "Agents", + keywords: ["spawn", "agent", role], + enabled: (c) => spawnAllowed(c, role), + run: (c) => { + const command = profile.command ?? topologyCommand(c, role) ?? process.env.SHELL ?? "bash"; + c.spawn(role, command, c.parentAgentId); + }, + }); + } + for (const role of ctx.topology?.roles ?? []) { + if (profileRoles.has(role.name)) continue; + const name = role.name; + actions.push({ + id: `agent.spawn.${name}`, + label: `Spawn ${name}`, + detail: "spawn", + group: "Agents", + keywords: ["spawn", "agent", name], + enabled: (c) => spawnAllowed(c, name), + run: (c) => c.spawn(name, role.command ?? process.env.SHELL ?? "bash", c.parentAgentId), + }); + } + } + + for (const session of ctx.sessions) { + actions.push({ + id: `agent.kill.${session}`, + label: `Kill ${session}`, + detail: "running", + group: "Agents", + keywords: ["kill", "stop", "agent"], + run: (c) => c.kill(session), + }); + } + + for (const peer of ctx.gossipPeers) { + if (peer.freeSlots <= 0) continue; + actions.push({ + id: `agent.delegate.${peer.address}`, + label: `Delegate to ${peer.peerId} (${peer.freeSlots} free)`, + detail: "delegate", + group: "Agents", + keywords: ["delegate", "peer"], + available: (c) => c.canDelegate, + run: (c) => c.delegate(peer.address), + }); + } + + void claims; // capacity is recomputed inside spawnAllowed from live ctx + return actions; +} + +function workflowActions(ctx: ActionContext): readonly Action[] { + void ctx; + return [ + { + id: "workflow.set-goal", + label: "Set goal", + detail: "Set or update the session goal for all agents", + group: "Workflow", + keywords: ["goal", "objective"], + available: (c) => c.hasGoals, + run: (c) => c.enterGoalMode(), + }, + { + id: "workflow.approve-question", + label: "Approve pending question", + detail: "approvals", + group: "Workflow", + keywords: ["answer", "approve", "question", "ask"], + available: (c) => c.pendingQuestionCount > 0, + run: (c) => c.answerPendingQuestion("approve"), + }, + { + id: "workflow.deny-question", + label: "Deny pending question", + detail: "approvals", + group: "Workflow", + keywords: ["answer", "deny", "question", "ask"], + available: (c) => c.pendingQuestionCount > 0, + run: (c) => c.answerPendingQuestion("deny"), + }, + { + id: "workflow.compare", + label: "Compare contributions", + detail: "compare", + group: "Workflow", + keywords: ["compare", "diff"], + run: (c) => c.enterCompareMode(), + }, + { + id: "workflow.register-agent", + label: "Register new agent profile", + detail: "agents.json", + group: "Workflow", + keywords: ["register", "profile"], + run: (c) => c.registerAgentProfile(), + }, + ]; +} + +function contributionActions(): readonly Action[] { + const hasSelection = (c: ActionContext) => c.selectedCid !== undefined; + return [ + { + id: "contrib.open", + label: "Open selected contribution", + detail: "inspect", + group: "Contributions", + keywords: ["open", "detail", "contribution"], + available: hasSelection, + run: (c) => { + if (c.selectedCid) c.openContribution(c.selectedCid); + }, + }, + { + id: "contrib.compare-add", + label: "Add selected contribution to compare", + detail: "compare", + group: "Contributions", + keywords: ["compare", "add"], + available: hasSelection, + run: (c) => { + if (c.selectedCid) c.addToCompare(c.selectedCid); + }, + }, + { + id: "contrib.adopt", + label: "Adopt selected contribution", + detail: "adopt", + group: "Contributions", + keywords: ["adopt", "build on"], + available: hasSelection, + run: (c) => { + if (c.selectedCid) c.adoptContribution(c.selectedCid, ""); + }, + }, + ]; +} + +function spawnAllowed(ctx: ActionContext, role: string): boolean { + if (!ctx.topology) return true; // no topology constraints to enforce + if (ctx.claims === null) return false; // scoped session: conservative + return checkSpawn(ctx.topology, role, ctx.claims, ctx.parentAgentId).allowed; +} + +function topologyCommand(ctx: ActionContext, role: string): string | undefined { + return ctx.topology?.roles.find((r) => r.name === role)?.command; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/tui/actions/builtin-actions.test.ts` +Expected: PASS. If `checkSpawn`'s signature differs, open `src/tui/agents/spawn-validator.ts` and match its parameter order (it is `(topology, role, claims, parentAgentId?, activeSpawnCounts?)`). + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/actions/builtin-actions.ts src/tui/actions/builtin-actions.test.ts +git commit -m "feat(tui): add built-in action builders for nav/agents/workflow/contrib (#194)" +``` + +--- + +## Task 5: Render the palette from the unified model + +**Files:** +- Modify: `src/tui/components/command-palette.tsx` +- Modify: `src/tui/components/command-palette.test.tsx` + +Keep `fuzzyMatch` and `renderHighlighted` exported (still imported by `visibility.ts` and `app.tsx`). Replace the `PaletteItem` rendering with `Action`-based grouped/flat rendering driven by `computeVisibleActions`. Remove `PaletteItem`, `buildPaletteItems`, `buildPluginPaletteItems`, `LoadedProfile`, and `getBuiltInPaletteActionRegistryEntries` (superseded). The component receives the unified `actions`, the `ctx`, the `query`, and `selectedIndex`. + +- [ ] **Step 1: Write the failing test (replace the file body)** + +```tsx +// src/tui/components/command-palette.test.tsx +import { describe, expect, test } from "bun:test"; +import type { Action, ActionContext } from "../actions/types.js"; +import { computeVisibleActions } from "../actions/visibility.js"; +import { fuzzyMatch } from "./command-palette.js"; + +function ctx(overrides: Partial = {}): ActionContext { + return { + sessions: [], profiles: [], gossipPeers: [], claims: [], + pendingQuestionCount: 0, hasGoals: false, canSpawn: false, canDelegate: false, + isPanelVisible: () => false, focusPanel: () => undefined, togglePanel: () => undefined, + openContribution: () => undefined, jumpToSession: () => undefined, + enterGoalMode: () => undefined, enterCompareMode: () => undefined, + addToCompare: () => undefined, adoptContribution: () => undefined, + answerPendingQuestion: () => undefined, registerAgentProfile: () => undefined, + spawn: () => undefined, kill: () => undefined, delegate: () => undefined, + showMessage: () => undefined, ...overrides, + }; +} +function act(o: Partial & Pick): Action { + return { label: o.id, detail: "", run: () => undefined, ...o }; +} + +describe("command palette model", () => { + test("fuzzyMatch still scores word-boundary bonuses", () => { + expect(fuzzyMatch("ft", "Focus Terminal").match).toBe(true); + expect(fuzzyMatch("zzz", "Focus Terminal").match).toBe(false); + }); + + test("visible list is the flat selection index space", () => { + const actions = [ + act({ id: "n1", group: "Navigation", label: "nav" }), + act({ id: "a1", group: "Agents", label: "agent" }), + ]; + const visible = computeVisibleActions(actions, ctx(), ""); + expect(visible.map((v) => v.action.id)).toEqual(["n1", "a1"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/tui/components/command-palette.test.tsx` +Expected: FAIL — old exports (`buildPluginPaletteItems`, `getBuiltInPaletteActionRegistryEntries`) referenced elsewhere still compile, but this test imports `../actions/types.js`/`../actions/visibility.js` and expects the new flat-list behavior; it fails to compile only if those modules are missing (they exist from Tasks 1–2), so the failure is the removed-export mismatch once Step 3 lands. Run it; if it passes immediately that's fine (it only asserts new behavior). + +- [ ] **Step 3: Rewrite the component** + +Replace the entire contents of `src/tui/components/command-palette.tsx` with: + +```tsx +/** + * Command palette overlay for the TUI. + * + * Renders the unified Action model. With no query, actions are shown grouped by + * section (Navigation, Agents, Workflow, Contributions, Plugins). With a query, + * group headers are hidden and a single fuzzy-ranked list is shown. The parent + * drives selection via `selectedIndex` over the flat `computeVisibleActions` + * list; Enter executes the selected action. + */ + +import React, { useMemo } from "react"; +import type { Action, ActionContext, ActionGroup } from "../actions/types.js"; +import { GROUP_ORDER } from "../actions/types.js"; +import { computeVisibleActions } from "../actions/visibility.js"; +import { theme } from "../theme.js"; + +interface FuzzyResult { + readonly match: boolean; + readonly score: number; + readonly matchedIndices: readonly number[]; +} + +/** Fuzzy-match `pattern` against `text`. (+2 at word boundary, +1 otherwise.) */ +export function fuzzyMatch(pattern: string, text: string): FuzzyResult { + if (!pattern) return { match: true, score: 0, matchedIndices: [] }; + const lower = text.toLowerCase(); + const pat = pattern.toLowerCase(); + let pi = 0; + let score = 0; + const matchedIndices: number[] = []; + for (let i = 0; i < lower.length && pi < pat.length; i++) { + if (lower[i] === pat[pi]) { + const bonus = i === 0 || lower[i - 1] === " " || lower[i - 1] === "/" ? 2 : 1; + score += bonus; + matchedIndices.push(i); + pi++; + } + } + return { match: pi === pat.length, score, matchedIndices }; +} + +function renderHighlighted( + label: string, + matchedIndices: readonly number[], + baseColor: string, +): React.ReactNode { + if (matchedIndices.length === 0) return {label}; + const indexSet = new Set(matchedIndices); + const segments: React.ReactNode[] = []; + let run = ""; + let runHighlighted = false; + const flush = (highlighted: boolean, key: string) => { + if (!run) return; + segments.push( + highlighted ? ( + + {run} + + ) : ( + + {run} + + ), + ); + run = ""; + }; + for (let i = 0; i < label.length; i++) { + const h = indexSet.has(i); + if (h !== runHighlighted) { + flush(runHighlighted, `s${i}`); + runHighlighted = h; + } + run += label[i]; + } + flush(runHighlighted, "end"); + return {segments}; +} + +export interface CommandPaletteProps { + readonly visible: boolean; + readonly actions: readonly Action[]; + readonly ctx: ActionContext; + readonly query?: string | undefined; + readonly selectedIndex?: number | undefined; + readonly adoptContext?: { readonly targetCid: string; readonly summary: string } | undefined; +} + +export const CommandPalette: React.NamedExoticComponent = React.memo( + function CommandPalette({ + visible, + actions, + ctx, + query, + selectedIndex, + adoptContext, + }: CommandPaletteProps): React.ReactNode { + const q = (query ?? "").trim(); + const visibleActions = useMemo( + () => computeVisibleActions(actions, ctx, q), + [actions, ctx, q], + ); + + if (!visible) return null; + const idx = selectedIndex ?? 0; + + // When no query, compute the group header to print before each item. + const headerBefore: (ActionGroup | undefined)[] = []; + if (!q) { + let lastGroup: ActionGroup | undefined; + for (const { action } of visibleActions) { + headerBefore.push(action.group !== lastGroup ? action.group : undefined); + lastGroup = action.group; + } + } + + return ( + + + Command Palette + {adoptContext ? ( + {` Adopt: ${adoptContext.targetCid.slice(0, 12)}…`} + ) : null} + {q ? — filter: : (Esc to close)} + {q ? {q} : null} + + + {visibleActions.length === 0 && ( + + {q ? `No matches for "${q}"` : "No actions available"} + + )} + + + {visibleActions.map(({ action, matchedIndices }, i) => { + const isSelected = i === idx; + const dimmed = !(action.enabled?.(ctx) ?? true); + const labelColor = isSelected ? theme.focus : dimmed ? theme.disabled : theme.text; + const detailColor = isSelected ? theme.focus : dimmed ? theme.inactive : theme.secondary; + const cursor = isSelected ? "> " : " "; + const group = !q ? headerBefore[i] : undefined; + return ( + + {group ? ( + + {group} + + ) : null} + + {cursor} + {q && matchedIndices.length > 0 + ? renderHighlighted(action.label, matchedIndices, labelColor) + : {action.label}} + {action.detail ? [{action.detail}] : null} + + + ); + })} + + + + [j/k] navigate [Enter] execute [Esc] close + + + ); + }, +); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/tui/components/command-palette.test.tsx` +Expected: PASS. (Compile errors in `app.tsx` are expected until Task 6 — that file still imports the removed exports.) + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/components/command-palette.tsx src/tui/components/command-palette.test.tsx +git commit -m "feat(tui): render command palette from unified Action model (#194)" +``` + +--- + +## Task 6: Wire the ActionContext + selection into app.tsx + +**Files:** +- Modify: `src/tui/app.tsx` + +This task rewires App to build the action list and `ActionContext`, collapse `onPaletteSelect`, add the `pendingQuestionCount` fetcher, and update the `` props. It produces no new unit test (covered by Tasks 1–5 + the existing app reducer test); verification is typecheck + full suite. + +- [ ] **Step 1: Add the pending-questions fetcher** + +After the `gossipPeers` fetcher block (around `app.tsx:457`), add: + +```tsx + // Poll pending questions for the answer-question palette actions. + const pendingQuestionsFetcher = useCallback(async (): Promise => { + const askProvider = provider as unknown as { + getPendingQuestions?: () => Promise; + }; + if (!askProvider.getPendingQuestions) return 0; + try { + return (await askProvider.getPendingQuestions()).length; + } catch { + return 0; + } + }, [provider]); + const { data: pendingQuestionCount, refresh: refreshPendingQuestions } = + useEventDrivenData(pendingQuestionsFetcher, undefined, undefined, paletteVisible); +``` + +Add `refreshPendingQuestions()` to the `refreshAll` callback body and dependency array (alongside `refreshGossip()`). + +- [ ] **Step 2: Add an `answerPendingQuestion` helper** + +Near `handleApproveQuestion`/`handleDenyQuestion` (≈`app.tsx:670`), add a helper that answers the FIRST pending question (palette actions are not cursor-bound): + +```tsx + const answerPendingQuestion = useCallback( + async (verdict: "approve" | "deny") => { + const askProvider = provider as unknown as { + answerQuestion?: (cid: string, answer: string) => Promise; + getPendingQuestions?: () => Promise; + }; + if (!askProvider.answerQuestion || !askProvider.getPendingQuestions) return; + try { + const questions = await askProvider.getPendingQuestions(); + const selected = questions[0]; + if (!selected) return; + const answer = verdict === "approve" ? selected.options?.[0] ?? "Approved" : "Denied"; + await askProvider.answerQuestion(selected.cid, answer); + showError(`Answered: ${answer}`); + } catch (err) { + showError(err instanceof Error ? err.message : "Failed to answer"); + } + }, + [provider, showError], + ); +``` + +- [ ] **Step 3: Add a `registerAgentProfile` helper** + +Extract the existing `register` branch body from `onPaletteSelect` into a reusable callback (≈ near `handleKill`): + +```tsx + const registerAgentProfile = useCallback(() => { + void (async () => { + try { + const { existsSync, writeFileSync, mkdirSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const dir = resolve(process.cwd(), ".grove"); + const path = resolve(dir, "agents.json"); + if (!existsSync(path)) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const template = JSON.stringify( + { + profiles: [ + { + name: "@agent-1", + role: topology?.roles[0]?.name ?? "worker", + platform: "claude-code", + command: "claude --dangerously-skip-permissions", + }, + ], + }, + null, + 2, + ); + writeFileSync(path, template); + showError(`Created ${path} — edit to add agent profiles`); + } else { + showError(`Profiles loaded from ${path} (${String(agentProfiles?.length ?? 0)} profiles)`); + } + } catch (err) { + showError(err instanceof Error ? err.message : "Registration failed"); + } + })(); + }, [topology, agentProfiles, showError]); +``` + +- [ ] **Step 4: Build the `ActionContext` and action list** + +Replace the `corePaletteItems` / `pluginPaletteItems` / `paletteItems` / `filteredPaletteItems` block (≈`app.tsx:589-637`) with: + +```tsx + const hasGoals = isGoalProvider(provider); + + const mkPluginCtx = useCallback( + (_c: import("./actions/types.js").ActionContext): TuiPluginContext => pluginContext, + [pluginContext], + ); + + const actionContext = useMemo( + () => ({ + topology, + sessions: paletteSessions ?? [], + profiles: agentProfiles ?? [], + gossipPeers: canDelegate ? (gossipPeers ?? []) : [], + claims: activeClaims, + selectedSession, + selectedCid: nav.detailCid, + parentAgentId: paletteParentId, + pendingQuestionCount: pendingQuestionCount ?? 0, + hasGoals, + canSpawn, + canDelegate, + isPanelVisible: (panel) => panels.isVisible(panel), + focusPanel: (panel) => panels.focus(panel), + togglePanel: (panel) => panels.toggle(panel), + openContribution: (cid) => nav.pushDetail(cid), + jumpToSession: (session) => { + setSelectedSession(session); + panels.toggle(Panel.Terminal); + }, + enterGoalMode: () => { + panels.setMode(InputMode.GoalInput); + dispatch({ type: "GOAL_INPUT_MODE" }); + }, + enterCompareMode: () => dispatch({ type: "COMPARE_TOGGLE" }), + addToCompare: (cid) => dispatch({ type: "COMPARE_SELECT", cid }), + adoptContribution: (cid, summary) => { + dispatch({ type: "ADOPT_SET", targetCid: cid, summary }); + panels.setMode(InputMode.CommandPalette); + }, + answerPendingQuestion: (verdict) => void answerPendingQuestion(verdict), + registerAgentProfile, + spawn: (roleId, command, parentAgentId) => handleSpawn(roleId, command, "HEAD", parentAgentId), + kill: (session) => handleKill(session), + delegate: (peerAddress) => void handleDelegate(peerAddress), + showMessage: showError, + }), + [ + topology, paletteSessions, agentProfiles, gossipPeers, canDelegate, activeClaims, + selectedSession, nav, paletteParentId, pendingQuestionCount, hasGoals, canSpawn, + panels, answerPendingQuestion, registerAgentProfile, handleSpawn, handleKill, + handleDelegate, showError, + ], + ); + + const paletteActions = useMemo( + () => [ + ...buildBuiltInActions(actionContext), + ...buildPluginActions(mergedActionRegistry.entries, mkPluginCtx), + ], + [actionContext, mergedActionRegistry.entries, mkPluginCtx], + ); + + const visiblePaletteActions = useMemo( + () => computeVisibleActions(paletteActions, actionContext, ks.paletteQuery), + [paletteActions, actionContext, ks.paletteQuery], + ); +``` + +Add these imports at the top of `app.tsx`: + +```tsx +import { buildBuiltInActions } from "./actions/builtin-actions.js"; +import { buildPluginActions } from "./actions/plugin-adapter.js"; +import { computeVisibleActions } from "./actions/visibility.js"; +import { Panel } from "./hooks/use-panel-focus.js"; +``` + +Replace the `command-palette.js` import to drop removed names — keep only what remains (none from that module are now needed except via the component import); update: + +```tsx +import { CommandPalette } from "./components/command-palette.js"; +``` + +Remove the now-unused imports: `buildPaletteItems`, `buildPluginPaletteItems`, `fuzzyMatch`, `getBuiltInPaletteActionRegistryEntries` (note: `getBuiltInPaletteActionRegistryEntries` is still used by `mergeTuiActionRegistrations` for built-in dedup — see Step 7). + +- [ ] **Step 5: Collapse `onPaletteSelect`** + +Replace the entire `onPaletteSelect` callback (≈`app.tsx:1003-1061`) with: + +```tsx + onPaletteSelect: () => { + const entry = visiblePaletteActions[ks.paletteIndex]; + if (!entry) return; + const action = entry.action; + if (!(action.enabled?.(actionContext) ?? true)) return; + // Close the palette FIRST. Mode-switching actions (goal, compare, adopt) + // re-set their target mode inside run, landing after this Normal set. + panels.setMode(InputMode.Normal); + dispatch({ type: "PALETTE_RESET" }); + void Promise.resolve(action.run(actionContext)).catch((err: unknown) => { + showError(err instanceof Error ? err.message : "Action failed"); + }); + }, +``` + +Update `paletteItemCount` in the `keyboardActions` object to use the new list: + +```tsx + paletteItemCount: visiblePaletteActions.length, +``` + +Update the `keyboardActions` `useMemo` dependency array: remove `filteredPaletteItems`, `agentProfiles`, `paletteParentId`, `pluginContext` if now only referenced via `actionContext`; ADD `visiblePaletteActions` and `actionContext`. (Keep `ks.paletteIndex`, `showError`, `panels`, `nav`.) + +- [ ] **Step 6: Update the `` JSX** + +Replace the `` element (≈`app.tsx:1173-1187`) with: + +```tsx + +``` + +- [ ] **Step 7: Keep built-in registry dedup intact** + +`mergeTuiActionRegistrations` still needs built-in IDs for duplicate protection. `getBuiltInPaletteActionRegistryEntries` was removed from `command-palette.tsx` in Task 5 — move it to a tiny module so the registry keeps reserving `set-goal`/`register-agent` IDs (preventing a plugin from shadowing them). Create `src/tui/actions/reserved-ids.ts`: + +```ts +import type { TuiActionRegistryEntry } from "../plugins/registry.js"; + +/** Built-in action IDs reserved so plugins can't shadow them. */ +export function getReservedActionRegistryEntries(): readonly TuiActionRegistryEntry[] { + return Object.freeze([ + Object.freeze({ id: "set-goal", label: "Set goal", detail: "", order: 0, source: "builtin" as const }), + Object.freeze({ id: "register-agent", label: "Register agent", detail: "", order: 10, source: "builtin" as const }), + ]); +} +``` + +In `app.tsx`, change the `mergedActionRegistry` builtIns source from `getBuiltInPaletteActionRegistryEntries()` to `getReservedActionRegistryEntries()` and import it from `./actions/reserved-ids.js`. + +- [ ] **Step 8: Typecheck** + +Run: `bun run typecheck` (or `bunx tsc --noEmit`) +Expected: no errors. Fix any leftover references to removed symbols. + +- [ ] **Step 9: Commit** + +```bash +git add src/tui/app.tsx src/tui/actions/reserved-ids.ts +git commit -m "feat(tui): wire ActionContext + unified palette selection in app (#194)" +``` + +--- + +## Task 7: Migrate old tests + reserved-id test, full verification + +**Files:** +- Modify: `src/tui/plugins/registry.test.ts` (only if it imports removed symbols) +- Create: `src/tui/actions/reserved-ids.test.ts` +- Verify: whole suite + +- [ ] **Step 1: Add the reserved-ids test** + +```ts +// src/tui/actions/reserved-ids.test.ts +import { describe, expect, test } from "bun:test"; +import { getReservedActionRegistryEntries } from "./reserved-ids.js"; + +describe("reserved action ids", () => { + test("reserves set-goal and register-agent", () => { + expect(getReservedActionRegistryEntries().map((e) => e.id)).toEqual(["set-goal", "register-agent"]); + }); +}); +``` + +Run: `bun test src/tui/actions/reserved-ids.test.ts` +Expected: PASS. + +- [ ] **Step 2: Grep for removed symbols** + +Run: +```bash +rg -n "buildPaletteItems|buildPluginPaletteItems|getBuiltInPaletteActionRegistryEntries|\bPaletteItem\b" src +``` +Expected: no matches (or only inside the deleted spec's history). Fix every hit by migrating to the new model or deleting the dead assertion. + +- [ ] **Step 3: Run the full suite** + +Run: `bun test` +Expected: PASS. Investigate and fix any failure before continuing — do not weaken assertions to make them pass. + +- [ ] **Step 4: Typecheck again** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "test(tui): migrate palette tests to unified action model (#194)" +``` + +--- + +## Task 8: Manual smoke verification + +**Files:** none (verification only) + +- [ ] **Step 1: Launch the TUI** per the project's run path (e.g. `grove up` with a preset that mounts the palette). Use the `run` skill if available. + +- [ ] **Step 2: Verify acceptance criteria** + - Open palette (Ctrl+P). Confirm grouped sections render: Navigation, Agents, Workflow, Contributions (when a contribution is selected), Plugins (if any). + - Type a query → headers disappear, results fuzzy-rank, keyword matches (e.g. "files" → VFS) appear. + - With no contribution selected, `Open/Adopt/Add to compare` are ABSENT. Select one (focus Detail/Frontier) → they APPEAR. + - With no pending question, approve/deny are ABSENT. + - A spawn at capacity is shown GREYED and Enter is a no-op. + - Execute: Go to Terminal panel → focuses/opens Terminal. Set goal → enters goal input. Compare → enters compare mode. Spawn/kill → behaves as before. + +- [ ] **Step 3: Record results** in the PR description (what was exercised, what passed). + +--- + +## Self-Review + +**Spec coverage:** +- Common action model → Task 1 (`Action`/`ActionContext`). ✓ +- Navigation actions (open/focus panel, jump session, open contribution) → Task 4 `navigationActions` + `contributionActions`. ✓ +- Workflow actions (set goal, answer question, compare, delegate, spawn, kill, register) → Task 4 `workflowActions`/`agentActions`. ✓ +- Context-sensitive via `available` (hide) vs `enabled` (grey) → Tasks 1, 4, 5. ✓ +- Grouped sections, flat when querying → Tasks 2, 5. ✓ +- Two-tier context (plugin narrow) → Tasks 1, 3, 6 (`mkPluginCtx`). ✓ +- Flat selection index unchanged reducer → Tasks 2, 6 (`visiblePaletteActions` drives `paletteItemCount`). ✓ +- Plugin dedup reserved IDs preserved → Task 6 Step 7 / Task 7. ✓ + +**Placeholder scan:** every code step contains full code; no TBD/TODO. ✓ + +**Type consistency:** `Action`, `ActionContext`, `VisibleAction`, `computeVisibleActions`, `buildBuiltInActions`, `buildPluginActions`, `getReservedActionRegistryEntries` names match across tasks; `CommandPaletteProps` updated to `{actions, ctx, query, selectedIndex, adoptContext}`. ✓ + +**Note for implementer:** confirm `checkSpawn`'s exact signature in `src/tui/agents/spawn-validator.ts` and `panels.isVisible`/`panels.toggle`/`panels.focus` names in `use-panel-focus.ts` before Task 4/6 (referenced as-is from current code). diff --git a/docs/superpowers/specs/2026-05-28-universal-command-palette-action-system-design.md b/docs/superpowers/specs/2026-05-28-universal-command-palette-action-system-design.md new file mode 100644 index 00000000..b2d55695 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-universal-command-palette-action-system-design.md @@ -0,0 +1,225 @@ +# Universal Command Palette Action System (#194) + +**Status:** Approved design — ready for implementation plan +**Issue:** https://github.com/windoliver/grove/issues/194 +**Date:** 2026-05-28 + +## Problem + +The TUI command palette is narrower than the actual capability surface. Many +actions still require memorizing keys or navigating to specific panels. Two +incompatible action shapes coexist: + +- **Built-in** `PaletteItem` — a discriminated union (`kind`: + `spawn | kill | register | delegate | goal | plugin-action`) handled by a + large `switch` inside `onPaletteSelect` in `app.tsx`. +- **Plugin** `TuiActionRegistration` — `{ id, label, detail, enabled?, run(ctx) }` + executed via `runTuiActionRegistration`. + +The issue asks for ONE common model and a universal, searchable, context-sensitive +action surface that is the primary discoverability tool for new operators. + +## Goals (acceptance criteria) + +- Every major TUI action is reachable from the palette. +- Palette items are searchable (already true via `fuzzyMatch`) — preserved. +- Context-sensitive actions appear only when relevant. +- The palette is the primary discoverability surface. + +## Decisions (locked during brainstorming) + +1. **Delivery:** design + full implementation on a branch with tests. +2. **Action model:** unify everything on `run(ctx)`. Built-in spawn/kill/goal/etc. + become `run`-based actions built from live state. The `kind` switch is removed. +3. **Context:** two-tier. A rich internal `ActionContext` for built-ins; the + existing narrow `TuiPluginContext` stays as a documented subset for plugins. +4. **Relevance:** actions declare `available(ctx)` and are **hidden** when it + returns false. This is distinct from `enabled(ctx)`, which keeps the item + visible but **greyed** (e.g. spawn at capacity). +5. **Grouping:** grouped sections (Navigation, Agents, Workflow, Contributions, + Plugins) when no query; collapse to a flat fuzzy-ranked list when a query is + active. + +## Out of scope (YAGNI) + +- A separate jump-to-agent registry (agent IDs derive from session names, so + jump-to-agent folds into jump-to-session). +- Action history / recents. +- Per-action user-customizable keybindings. + +--- + +## Architecture + +### 1. Common action model — `src/tui/actions/types.ts` (new) + +```ts +export type ActionGroup = + | "Navigation" + | "Agents" + | "Workflow" + | "Contributions" + | "Plugins"; + +export interface Action { + /** Stable, unique id, e.g. "nav.panel.terminal", "agent.spawn.reviewer". */ + readonly id: string; + readonly label: string; + readonly detail: string; + readonly group: ActionGroup; + /** Extra fuzzy-match terms beyond the label, e.g. "open" for a focus action. */ + readonly keywords?: readonly string[] | undefined; + /** Relevance gate. False → item is HIDDEN entirely. Default: visible. */ + readonly available?: ((ctx: ActionContext) => boolean) | undefined; + /** Capability gate. False → item is shown but GREYED and not executable. */ + readonly enabled?: ((ctx: ActionContext) => boolean) | undefined; + readonly run: (ctx: ActionContext) => void | Promise; +} +``` + +`available` and `enabled` are independent: `available` controls presence, +`enabled` controls executability of a present item. + +### 2. Two-tier context + +**`ActionContext`** (internal, rich) — defined in `src/tui/actions/types.ts`. +Holds closures over app machinery plus read-only state. Capabilities: + +- Navigation: `focusPanel(panel)`, `togglePanel(panel)`, `openContribution(cid)`, + `jumpToSession(session)`. +- Workflow: `enterGoalMode()`, `enterCompareMode()`, `answerPendingQuestion(verdict: "approve" | "deny")`, `registerAgentProfile()`. +- Agents: `spawn(roleId, command, parentAgentId)`, `kill(session)`, `delegate(peerAddress)`. +- Read state: `topology`, `selectedSession`, `selectedCid`, `sessions`, + `claims`, `profiles`, `gossipPeers`, `pendingQuestionCount`, `hasGoals`, + `canSpawn`, `canDelegate`, `parentAgentId`. +- `showMessage(msg)`. + +**`TuiPluginContext`** — unchanged (`provider`, `topology`, `selectedSession`, +`selectedCid`, `density`, `showMessage`). Plugin blast radius is not widened. + +**`pluginAdapter(reg, mkPluginCtx)`** — wraps a `TuiActionRegistration` into an +`Action` with `group: "Plugins"`. Its `available`/`enabled`/`run` build the +narrow `TuiPluginContext` from the rich `ActionContext` before delegating to the +registration's `enabled?`/`run`. + +### 3. Built-in action builders — `src/tui/actions/builtin-actions.ts` (new) + +A `buildBuiltInActions(ctx-snapshot): readonly Action[]` family (or per-group +builder functions composed into one list). Replaces `buildPaletteItems`. + +- **Navigation** + - For each operator panel that can be shown: a `focus/open panel` action. + `available` ≈ always; `run` → `togglePanel` (open if hidden) or `focusPanel` + (focus if already visible). `keywords: ["open", "focus", "go to"]`. + - `jump to session ` per live grove session; `run` → `jumpToSession`. + - `open contribution` — `available` only when `selectedCid` set; `run` → + `openContribution(selectedCid)`. +- **Agents** + - `spawn:` per profile, then topology roles not covered by a profile. + `enabled` ← capacity check (`checkSpawn`); `run` → `spawn`. + - `kill:` per live session; `run` → `kill`. + - `delegate:` per gossip peer with free slots; `available` ← + `canDelegate`; `run` → `delegate`. +- **Workflow** + - `set goal` — `available` ← `hasGoals`; `run` → `enterGoalMode`. + - `answer pending question` — `available` ← `pendingQuestionCount > 0`; `run` + → `answerPendingQuestion`. + - `compare contributions` — `run` → `enterCompareMode`. + - `register agent profile` — `run` → `registerAgentProfile`. +- **Contributions** (context-sensitive; `available` only when `selectedCid` set) + - `open`, `adopt`, `add to compare`. + +### 4. Rendering — `src/tui/components/command-palette.tsx` + +- Input: a unified `readonly Action[]` (built by the parent, single source of + truth) plus the rich `ActionContext` for evaluating `available`/`enabled`. +- **No query:** filter to `available` items, render a section header per + non-empty `group` (fixed group order), items beneath. `enabled === false` → + greyed. +- **Query active:** hide group headers; fuzzy-rank a flat list, matching against + `label` + `keywords`; render highlighted matches (reuse `renderHighlighted`). +- **Selection index stays flat** across the displayed (available) items in + group order (no-query) or rank order (query). `paletteIndex`, `paletteItemCount`, + and the `PALETTE_*` reducer actions are unchanged. Pressing Enter on a + `enabled === false` item is a no-op (current behaviour preserved). +- `fuzzyMatch` and `renderHighlighted` are retained as-is. + +### 5. Wiring — `src/tui/app.tsx` + +- Build `actionContext` from existing memoized closures: `handleSpawn`, + `handleKill`, `handleDelegate`, `panels.focus`/`panels.toggle`, + `nav.pushDetail`, `setSelectedSession`, goal/compare `dispatch`, + `handleApproveQuestion`/`handleDenyQuestion`, `showError`. New: a + `getPendingQuestions` fetcher polled while the palette is visible to populate + `pendingQuestionCount`. +- Build the action list: `[...buildBuiltInActions(...), ...pluginActions]` where + `pluginActions` come from `mergedActionRegistry.entries` via `pluginAdapter`. +- `filteredActions` mirrors the palette's displayed order (same fuzzy logic the + component uses) so Enter executes the visually selected item. +- `onPaletteSelect` collapses to: + ```ts + const a = filteredActions[ks.paletteIndex]; + if (!a || !(a.enabled?.(actionContext) ?? true)) return; + // Close the palette FIRST, then run. Mode-switching actions (set goal, + // compare) re-set their target input mode from inside run via the context, + // so their setMode lands after this Normal transition in the same tick. + panels.setMode(InputMode.Normal); + dispatch({ type: "PALETTE_RESET" }); + void Promise.resolve(a.run(actionContext)) + .catch((e) => showError(e instanceof Error ? e.message : "Action failed")); + ``` + Ordering rationale: `enterGoalMode()` dispatches `GOAL_INPUT_MODE` and + `panels.setMode(InputMode.GoalInput)`; because `run` is invoked after the + `setMode(Normal)` above, the committed mode is `GoalInput` — matching today's + goal-action behaviour, where the goal case `return`ed before the generic + `setMode(Normal)`. Plain actions leave the mode at `Normal`. + +### 6. Migration & data flow + +- Remove `buildPaletteItems`, `buildPluginPaletteItems`, `PaletteItem`, + `getBuiltInPaletteActionRegistryEntries`'s `builtInAction` coupling. The + built-in registry entries (`set-goal`, `register-agent`) are superseded by the + built-in action builders. +- The existing fetchers (`activeClaims`, `paletteSessions`, `gossipPeers`, + `agentProfiles`, `hasGoals`, `canSpawn`, `canDelegate`, `paletteParentId`) + feed the builder snapshot — no new polling except `pendingQuestionCount`. + +## Error handling + +- `run` is awaited via `Promise.resolve(...).catch(showError)`; failures surface + in the status bar (5 s auto-clear), same channel as today. +- Disabled items never execute. Hidden (unavailable) items are absent from the + index space, so selection cannot land on them. +- Scoped-session spawn guard (`activeClaims === null`) is preserved in + `handleSpawn`; the `spawn` action inherits it. + +## Testing + +- **Builder catalog** (`builtin-actions.test.ts`): given state snapshots, assert + which actions are present (`available`) and which are greyed (`enabled`): + spawn at capacity → present+disabled; `answer pending question` absent when + `pendingQuestionCount===0`; contribution actions absent when no `selectedCid`; + delegate absent when `canDelegate===false`. +- **Plugin adapter** (`plugin-adapter.test.ts`): registration → `Action` with + `group:"Plugins"`; `run` receives a narrow `TuiPluginContext` (no app + internals leak). +- **Rendering** (`command-palette.test.tsx`): grouped sections with headers when + no query; flat ranked list with headers hidden when query set; greyed disabled + items; highlighted matches. +- **Flat-index selection** : displayed-order flattening maps `paletteIndex` to + the correct `Action` in both grouped and query modes. +- **Reducer** (`app-reducer.test.ts`): `PALETTE_*` transitions unchanged. +- Update existing palette tests that reference `PaletteItem`/`buildPaletteItems` + to the new model. + +## File summary + +| File | Change | +|---|---| +| `src/tui/actions/types.ts` | new — `Action`, `ActionGroup`, `ActionContext` | +| `src/tui/actions/builtin-actions.ts` | new — built-in `Action[]` builders | +| `src/tui/actions/plugin-adapter.ts` | new — `TuiActionRegistration` → `Action` | +| `src/tui/components/command-palette.tsx` | grouped/flat render on `Action[]`; drop `PaletteItem`/builders; keep fuzzy + highlight | +| `src/tui/app.tsx` | build `actionContext` + action list; collapse `onPaletteSelect` switch; add `pendingQuestionCount` fetcher | +| `src/tui/plugins/types.ts` | `TuiPluginContext` unchanged (documented subset) | +| tests | new builder/adapter/render/index tests; migrate old palette tests | diff --git a/src/tui/actions/answer-guard.test.ts b/src/tui/actions/answer-guard.test.ts new file mode 100644 index 00000000..96d32db5 --- /dev/null +++ b/src/tui/actions/answer-guard.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { resolveAnswerableQuestion } from "./answer-guard.js"; + +describe("resolveAnswerableQuestion", () => { + test("exactly one pending, no expected cid → that question", () => { + const q = { cid: "bafyQ1", options: ["Yes"] }; + expect(resolveAnswerableQuestion([q], undefined)).toBe(q); + }); + + test("exactly one pending matching expected cid → that question", () => { + const q = { cid: "bafyQ1" }; + expect(resolveAnswerableQuestion([q], "bafyQ1")).toBe(q); + }); + + test("zero pending → undefined", () => { + expect(resolveAnswerableQuestion([], "bafyQ1")).toBeUndefined(); + }); + + test("multiple pending → undefined (ambiguous, do not blind-answer)", () => { + expect( + resolveAnswerableQuestion([{ cid: "bafyQ1" }, { cid: "bafyQ2" }], "bafyQ1"), + ).toBeUndefined(); + }); + + test("single pending but identity changed → undefined", () => { + // The action was shown for bafyQ1, but the only remaining question is now a + // different one (the original was answered/removed and a new one arrived). + expect(resolveAnswerableQuestion([{ cid: "bafyQ2" }], "bafyQ1")).toBeUndefined(); + }); +}); diff --git a/src/tui/actions/answer-guard.ts b/src/tui/actions/answer-guard.ts new file mode 100644 index 00000000..8cff461c --- /dev/null +++ b/src/tui/actions/answer-guard.ts @@ -0,0 +1,29 @@ +/** + * Pure guard for the palette's approve/deny actions. Re-validates, at execution + * time, that the pending-question set is still safe to answer blindly: + * exactly one question must remain AND (when an expected cid is supplied) it + * must be the same question that made the action available. Anything else + * (a question added/removed/replaced between palette render and Enter) returns + * `undefined` so the caller aborts instead of answering the wrong prompt. + * + * Kept pure/separate from app.tsx so the TOCTOU contract is unit-testable. + */ + +export interface PendingQuestion { + readonly cid: string; + readonly options?: readonly string[] | undefined; +} + +export function resolveAnswerableQuestion( + questions: readonly PendingQuestion[], + expectedCid: string | undefined, +): PendingQuestion | undefined { + // Ambiguous: not exactly one pending question anymore. + if (questions.length !== 1) return undefined; + const q = questions[0]; + if (q === undefined) return undefined; + // Identity changed: the single remaining question is not the one the operator + // saw when they invoked the action. + if (expectedCid !== undefined && q.cid !== expectedCid) return undefined; + return q; +} diff --git a/src/tui/actions/builtin-actions.test.ts b/src/tui/actions/builtin-actions.test.ts new file mode 100644 index 00000000..32ce564c --- /dev/null +++ b/src/tui/actions/builtin-actions.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, test } from "bun:test"; +import { Panel } from "../hooks/use-panel-focus.js"; +import { buildBuiltInActions } from "./builtin-actions.js"; +import type { ActionContext } from "./types.js"; + +function ctx(overrides: Partial = {}): ActionContext { + return { + sessions: [], + profiles: [], + gossipPeers: [], + claims: [], + pendingQuestionCount: 0, + hasGoals: false, + canSpawn: false, + canDelegate: false, + isPanelVisible: () => false, + focusPanel: () => undefined, + togglePanel: () => undefined, + cyclePanelNext: () => undefined, + cyclePanelPrev: () => undefined, + openContribution: () => undefined, + jumpToSession: () => undefined, + enterGoalMode: () => undefined, + enterCompareMode: () => undefined, + addToCompare: () => undefined, + adoptContribution: () => undefined, + answerPendingQuestion: () => undefined, + registerAgentProfile: () => undefined, + spawn: () => undefined, + kill: () => undefined, + delegate: () => undefined, + focusedPanel: 1, + frontierSliceCount: 1, + broadcastMessage: () => undefined, + directMessage: () => undefined, + refresh: () => undefined, + enterSearch: () => undefined, + cycleZoom: () => undefined, + resetZoom: () => undefined, + toggleLayout: () => undefined, + cycleViewMode: () => undefined, + showHelp: () => undefined, + quit: () => undefined, + nextFrontierSlice: () => undefined, + prevFrontierSlice: () => undefined, + scrollTerminalToBottom: () => undefined, + showMessage: () => undefined, + ...overrides, + }; +} + +function ids(c: ActionContext): string[] { + return buildBuiltInActions(c) + .filter((a) => a.available?.(c) ?? true) + .map((a) => a.id); +} + +describe("buildBuiltInActions", () => { + test("navigation: open/focus action per core AND operator panel + register/compare", () => { + const present = ids(ctx()); + // Operator panel + expect(present).toContain("nav.panel.terminal"); + // Core panels are reachable too (always-visible → focus) + expect(present).toContain("nav.panel.dag"); + expect(present).toContain("nav.panel.frontier"); + expect(present).toContain("workflow.compare"); + expect(present).toContain("workflow.register-agent"); + }); + + test("set goal only available when provider has goals", () => { + expect(ids(ctx())).not.toContain("workflow.set-goal"); + expect(ids(ctx({ hasGoals: true }))).toContain("workflow.set-goal"); + }); + + test("approve/deny only with exactly one pending; multiple routes to Decisions review", () => { + // None pending → no question actions at all. + const none = ids(ctx()); + expect(none).not.toContain("workflow.approve-question"); + expect(none).not.toContain("workflow.review-questions"); + // Exactly one → unambiguous approve/deny, no review-routing. + const one = ids(ctx({ pendingQuestionCount: 1 })); + expect(one).toContain("workflow.approve-question"); + expect(one).toContain("workflow.deny-question"); + expect(one).not.toContain("workflow.review-questions"); + // Multiple → no blind approve/deny; route to the Decisions panel instead. + const many = ids(ctx({ pendingQuestionCount: 3 })); + expect(many).not.toContain("workflow.approve-question"); + expect(many).not.toContain("workflow.deny-question"); + expect(many).toContain("workflow.review-questions"); + }); + + test("contribution actions only available when a contribution is selected", () => { + expect(ids(ctx())).not.toContain("contrib.open"); + const sel = ids(ctx({ selectedCid: "bafy123" })); + expect(sel).toContain("contrib.open"); + expect(sel).toContain("contrib.compare-add"); + expect(sel).toContain("contrib.adopt"); + }); + + test("contrib.adopt runs adoptContribution with the selected cid (summary resolved by app)", () => { + const calls: string[] = []; + const c = ctx({ selectedCid: "bafySEL", adoptContribution: (cid) => calls.push(cid) }); + const adopt = buildBuiltInActions(c).find((a) => a.id === "contrib.adopt"); + adopt?.run(c); + expect(calls).toEqual(["bafySEL"]); + }); + + test("contrib.open is disabled when the highlighted cid is already the open detail", () => { + const open = buildBuiltInActions(ctx()).find((a) => a.id === "contrib.open"); + // Highlighted row differs from open detail (or no detail) → enabled. + expect(open?.enabled?.(ctx({ selectedCid: "bafyAAA", detailCid: undefined }))).toBe(true); + expect(open?.enabled?.(ctx({ selectedCid: "bafyAAA", detailCid: "bafyBBB" }))).toBe(true); + // Highlighted row IS the open detail → disabled (re-open would duplicate). + expect(open?.enabled?.(ctx({ selectedCid: "bafyAAA", detailCid: "bafyAAA" }))).toBe(false); + }); + + test("kill action per live session; jump-to-session per session", () => { + const present = ids(ctx({ sessions: ["grove-reviewer-1"] })); + expect(present).toContain("agent.kill.grove-reviewer-1"); + expect(present).toContain("nav.session.grove-reviewer-1"); + }); + + test("spawn from profile is present but disabled at capacity", () => { + const c = ctx({ + canSpawn: true, + profiles: [{ name: "@rev", role: "reviewer", platform: "claude-code" }], + }); + const spawn = buildBuiltInActions(c).find((a) => a.id === "agent.spawn.reviewer"); + expect(spawn).toBeDefined(); + expect(spawn?.enabled?.(c) ?? true).toBe(true); + }); + + test("spawn detail shows capacity and edges from topology", () => { + const topology = { + roles: [ + { name: "planner", maxInstances: 3, edges: [{ target: "reviewer" }] }, + { name: "reviewer", maxInstances: 1 }, + ], + } as unknown as ActionContext["topology"]; + const c = ctx({ canSpawn: true, topology, claims: [] }); + const planner = buildBuiltInActions(c).find((a) => a.id === "agent.spawn.planner"); + expect(planner?.detail).toBe("0/3 → reviewer"); + const reviewer = buildBuiltInActions(c).find((a) => a.id === "agent.spawn.reviewer"); + expect(reviewer?.detail).toBe("0/1"); + }); + + test("spawn detail falls back to 'spawn' without topology", () => { + const c = ctx({ + canSpawn: true, + profiles: [{ name: "@w", role: "worker", platform: "claude-code" }], + }); + const spawn = buildBuiltInActions(c).find((a) => a.id === "agent.spawn.worker"); + expect(spawn?.detail).toBe("spawn"); + }); + + test("delegate only available when canDelegate and peer has free slots", () => { + const peers = [{ peerId: "p1", address: "http://p1", freeSlots: 2 }]; + expect(ids(ctx({ canDelegate: false, gossipPeers: peers }))).not.toContain( + "agent.delegate.http://p1", + ); + expect(ids(ctx({ canDelegate: true, gossipPeers: peers }))).toContain( + "agent.delegate.http://p1", + ); + }); + + test("messaging actions are always offered in the Agents group", () => { + const actions = buildBuiltInActions(ctx()); + const broadcast = actions.find((a) => a.id === "agent.broadcast"); + const dm = actions.find((a) => a.id === "agent.direct-message"); + expect(broadcast?.group).toBe("Agents"); + expect(dm?.group).toBe("Agents"); + expect(ids(ctx())).toEqual(expect.arrayContaining(["agent.broadcast", "agent.direct-message"])); + }); + + test("view/system actions are always offered in the View group", () => { + const actions = buildBuiltInActions(ctx()); + for (const id of [ + "view.refresh", + "view.search", + "view.zoom", + "view.zoom-reset", + "view.layout", + "view.view-mode", + "view.help", + "view.quit", + ]) { + const action = actions.find((a) => a.id === id); + expect(action, id).toBeDefined(); + expect(action?.group).toBe("View"); + } + }); + + test("frontier-slice actions appear only when Frontier focused with >1 slice", () => { + expect(ids(ctx())).not.toContain("nav.frontier.next-slice"); + // Focused but only one slice → still hidden. + expect(ids(ctx({ focusedPanel: Panel.Frontier, frontierSliceCount: 1 }))).not.toContain( + "nav.frontier.next-slice", + ); + const focused = ids(ctx({ focusedPanel: Panel.Frontier, frontierSliceCount: 3 })); + expect(focused).toContain("nav.frontier.next-slice"); + expect(focused).toContain("nav.frontier.prev-slice"); + }); + + test("terminal scroll-to-bottom appears only when Terminal focused", () => { + expect(ids(ctx())).not.toContain("nav.terminal.scroll-bottom"); + expect(ids(ctx({ focusedPanel: Panel.Terminal }))).toContain("nav.terminal.scroll-bottom"); + }); + + test("panel-cycle and help actions are offered", () => { + const present = ids(ctx()); + expect(present).toContain("nav.panel.next"); + expect(present).toContain("nav.panel.prev"); + expect(present).toContain("view.help"); + }); + + test("two profiles sharing a role produce a single (de-duped) spawn action", () => { + const c = ctx({ + canSpawn: true, + profiles: [ + { name: "@a", role: "reviewer", platform: "claude-code" }, + { name: "@b", role: "reviewer", platform: "codex" }, + ], + }); + const spawnIds = buildBuiltInActions(c) + .map((a) => a.id) + .filter((id) => id === "agent.spawn.reviewer"); + expect(spawnIds).toHaveLength(1); + // All ids across the full catalog are unique. + const all = buildBuiltInActions(c).map((a) => a.id); + expect(new Set(all).size).toBe(all.length); + }); +}); diff --git a/src/tui/actions/builtin-actions.ts b/src/tui/actions/builtin-actions.ts new file mode 100644 index 00000000..9513a8a2 --- /dev/null +++ b/src/tui/actions/builtin-actions.ts @@ -0,0 +1,390 @@ +import { checkSpawn } from "../agents/spawn-validator.js"; +import { CORE_PANELS, OPERATOR_PANELS, PANEL_LABELS, Panel } from "../hooks/use-panel-focus.js"; +import type { Action, ActionContext } from "./types.js"; + +/** Build the full set of built-in actions from the current context. */ +export function buildBuiltInActions(ctx: ActionContext): readonly Action[] { + return Object.freeze([ + ...navigationActions(ctx), + ...focusedPanelActions(), + ...agentActions(ctx), + ...workflowActions(), + ...viewActions(), + ...contributionActions(), + ]); +} + +function navigationActions(ctx: ActionContext): readonly Action[] { + const actions: Action[] = []; + // Core panels are always visible (focus only); operator panels open-or-focus. + for (const panel of [...CORE_PANELS, ...OPERATOR_PANELS]) { + const label = PANEL_LABELS[panel]; + const key = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + actions.push({ + id: `nav.panel.${key}`, + label: `Go to ${label} panel`, + detail: "panel", + group: "Navigation", + keywords: ["open", "focus", "panel", label], + run: (c) => + c.isPanelVisible(panel as Panel) + ? c.focusPanel(panel as Panel) + : c.togglePanel(panel as Panel), + }); + } + for (const session of ctx.sessions) { + actions.push({ + id: `nav.session.${session}`, + label: `Jump to session ${session}`, + detail: "session", + group: "Navigation", + keywords: ["session", "agent", "jump"], + run: (c) => c.jumpToSession(session), + }); + } + actions.push( + { + id: "nav.panel.next", + label: "Cycle to next panel", + detail: "panel", + group: "Navigation", + keywords: ["panel", "next", "cycle", "tab"], + run: (c) => c.cyclePanelNext(), + }, + { + id: "nav.panel.prev", + label: "Cycle to previous panel", + detail: "panel", + group: "Navigation", + keywords: ["panel", "previous", "cycle"], + run: (c) => c.cyclePanelPrev(), + }, + ); + return actions; +} + +function agentActions(ctx: ActionContext): readonly Action[] { + const actions: Action[] = []; + + // Spawn from profiles first, then topology roles not covered by a profile. + const profileRoles = new Set(); + if (ctx.canSpawn) { + for (const profile of ctx.profiles) { + // De-dupe by role: two profiles sharing a role would otherwise emit a + // duplicate `agent.spawn.` id. First profile for a role wins. + if (profileRoles.has(profile.role)) continue; + profileRoles.add(profile.role); + const role = profile.role; + actions.push({ + id: `agent.spawn.${role}`, + label: `Spawn ${profile.name} [${profile.platform}]`, + detail: spawnDetail(ctx, role), + group: "Agents", + keywords: ["spawn", "agent", role], + enabled: (c) => spawnAllowed(c, role), + run: (c) => { + const command = + profile.command ?? topologyCommand(c, role) ?? process.env.SHELL ?? "bash"; + c.spawn(role, command, c.parentAgentId); + }, + }); + } + for (const role of ctx.topology?.roles ?? []) { + if (profileRoles.has(role.name)) continue; + const name = role.name; + actions.push({ + id: `agent.spawn.${name}`, + label: `Spawn ${name}`, + detail: spawnDetail(ctx, name), + group: "Agents", + keywords: ["spawn", "agent", name], + enabled: (c) => spawnAllowed(c, name), + run: (c) => c.spawn(name, role.command ?? process.env.SHELL ?? "bash", c.parentAgentId), + }); + } + } + + for (const session of ctx.sessions) { + actions.push({ + id: `agent.kill.${session}`, + label: `Kill ${session}`, + detail: "running", + group: "Agents", + keywords: ["kill", "stop", "agent"], + run: (c) => c.kill(session), + }); + } + + for (const peer of ctx.gossipPeers) { + if (peer.freeSlots <= 0) continue; + actions.push({ + id: `agent.delegate.${peer.address}`, + label: `Delegate to ${peer.peerId} (${peer.freeSlots} free)`, + detail: "delegate", + group: "Agents", + keywords: ["delegate", "peer"], + available: (c) => c.canDelegate, + run: (c) => c.delegate(peer.address), + }); + } + + actions.push( + { + id: "agent.broadcast", + label: "Broadcast message to all agents", + detail: "message", + group: "Agents", + keywords: ["message", "broadcast", "all", "tell"], + run: (c) => c.broadcastMessage(), + }, + { + id: "agent.direct-message", + label: "Direct message an agent", + detail: "message", + group: "Agents", + keywords: ["message", "direct", "dm", "tell"], + run: (c) => c.directMessage(), + }, + ); + + return actions; +} + +/** + * Actions that only make sense for the currently focused panel. They are hidden + * (via `available`) unless the relevant panel holds focus. + */ +function focusedPanelActions(): readonly Action[] { + return [ + { + id: "nav.frontier.next-slice", + label: "Next frontier slice", + detail: "frontier", + group: "Navigation", + keywords: ["frontier", "slice", "tab", "next"], + available: (c) => c.focusedPanel === Panel.Frontier && c.frontierSliceCount > 1, + run: (c) => c.nextFrontierSlice(), + }, + { + id: "nav.frontier.prev-slice", + label: "Previous frontier slice", + detail: "frontier", + group: "Navigation", + keywords: ["frontier", "slice", "tab", "previous"], + available: (c) => c.focusedPanel === Panel.Frontier && c.frontierSliceCount > 1, + run: (c) => c.prevFrontierSlice(), + }, + { + id: "nav.terminal.scroll-bottom", + label: "Scroll terminal to bottom", + detail: "terminal", + group: "Navigation", + keywords: ["terminal", "scroll", "bottom"], + available: (c) => c.focusedPanel === Panel.Terminal, + run: (c) => c.scrollTerminalToBottom(), + }, + ]; +} + +/** Global view / system actions. */ +function viewActions(): readonly Action[] { + return [ + { + id: "view.refresh", + label: "Refresh all data", + detail: "view", + group: "View", + keywords: ["refresh", "reload", "update"], + run: (c) => c.refresh(), + }, + { + id: "view.search", + label: "Search transcripts", + detail: "view", + group: "View", + keywords: ["search", "find", "filter"], + run: (c) => c.enterSearch(), + }, + { + id: "view.zoom", + label: "Cycle zoom level", + detail: "view", + group: "View", + keywords: ["zoom", "focus", "expand"], + run: (c) => c.cycleZoom(), + }, + { + id: "view.zoom-reset", + label: "Reset zoom", + detail: "view", + group: "View", + keywords: ["zoom", "reset", "normal"], + run: (c) => c.resetZoom(), + }, + { + id: "view.layout", + label: "Toggle layout (grid/tab)", + detail: "view", + group: "View", + keywords: ["layout", "grid", "tab", "toggle"], + run: (c) => c.toggleLayout(), + }, + { + id: "view.view-mode", + label: "Cycle view mode (grid/pipeline)", + detail: "view", + group: "View", + keywords: ["view", "pipeline", "grid", "mode", "cycle"], + run: (c) => c.cycleViewMode(), + }, + { + id: "view.help", + label: "Show help", + detail: "view", + group: "View", + keywords: ["help", "keys", "shortcuts", "?"], + run: (c) => c.showHelp(), + }, + { + id: "view.quit", + label: "Quit grove", + detail: "view", + group: "View", + keywords: ["quit", "exit", "close"], + run: (c) => c.quit(), + }, + ]; +} + +function workflowActions(): readonly Action[] { + return [ + { + id: "workflow.set-goal", + label: "Set goal", + detail: "Set or update the session goal for all agents", + group: "Workflow", + keywords: ["goal", "objective"], + available: (c) => c.hasGoals, + run: (c) => c.enterGoalMode(), + }, + // Approve/deny are offered ONLY when exactly one question is pending — then + // there is no ambiguity about which one is answered. With multiple pending, + // a blind global answer could unblock the wrong prompt, so we route the + // operator to the Decisions panel (cursor-scoped) instead. + { + id: "workflow.approve-question", + label: "Approve pending question", + detail: "approvals", + group: "Workflow", + keywords: ["answer", "approve", "question", "ask"], + available: (c) => c.pendingQuestionCount === 1, + run: (c) => c.answerPendingQuestion("approve", c.pendingQuestionCid), + }, + { + id: "workflow.deny-question", + label: "Deny pending question", + detail: "approvals", + group: "Workflow", + keywords: ["answer", "deny", "question", "ask"], + available: (c) => c.pendingQuestionCount === 1, + run: (c) => c.answerPendingQuestion("deny", c.pendingQuestionCid), + }, + { + id: "workflow.review-questions", + label: "Review pending questions", + detail: "approvals", + group: "Workflow", + keywords: ["answer", "question", "ask", "decisions", "review", "approvals"], + // Multiple pending → don't blind-answer; open the Decisions panel where + // the cursor selects exactly which question to approve/deny. + available: (c) => c.pendingQuestionCount > 1, + run: (c) => + c.isPanelVisible(Panel.Decisions) + ? c.focusPanel(Panel.Decisions) + : c.togglePanel(Panel.Decisions), + }, + { + id: "workflow.compare", + label: "Compare contributions", + detail: "compare", + group: "Workflow", + keywords: ["compare", "diff"], + run: (c) => c.enterCompareMode(), + }, + { + id: "workflow.register-agent", + label: "Register new agent profile", + detail: "agents.json", + group: "Workflow", + keywords: ["register", "profile"], + run: (c) => c.registerAgentProfile(), + }, + ]; +} + +function contributionActions(): readonly Action[] { + const hasSelection = (c: ActionContext) => c.selectedCid !== undefined; + return [ + { + id: "contrib.open", + label: "Open selected contribution", + detail: "inspect", + group: "Contributions", + keywords: ["open", "detail", "contribution"], + available: hasSelection, + // Greyed when the highlighted contribution is already the open detail — + // re-opening it would just push a duplicate nav entry. + enabled: (c) => c.selectedCid !== c.detailCid, + run: (c) => { + if (c.selectedCid) c.openContribution(c.selectedCid); + }, + }, + { + id: "contrib.compare-add", + label: "Add selected contribution to compare", + detail: "compare", + group: "Contributions", + keywords: ["compare", "add"], + available: hasSelection, + run: (c) => { + if (c.selectedCid) c.addToCompare(c.selectedCid); + }, + }, + { + id: "contrib.adopt", + label: "Adopt selected contribution", + detail: "adopt", + group: "Contributions", + keywords: ["adopt", "build on"], + available: hasSelection, + run: (c) => { + if (c.selectedCid) c.adoptContribution(c.selectedCid); + }, + }, + ]; +} + +function spawnAllowed(ctx: ActionContext, role: string): boolean { + if (!ctx.topology) return true; // no topology constraints to enforce + if (ctx.claims === null) return false; // scoped session: conservative + return checkSpawn(ctx.topology, role, ctx.claims, ctx.parentAgentId).allowed; +} + +/** + * Capacity/edge summary shown next to a spawn action, e.g. "1/3 → reviewer". + * Falls back to "spawn" when topology constraints can't be evaluated (no + * topology, or scoped session where claims are unavailable). + */ +function spawnDetail(ctx: ActionContext, role: string): string { + if (!ctx.topology || ctx.claims === null) return "spawn"; + const check = checkSpawn(ctx.topology, role, ctx.claims, ctx.parentAgentId); + const max = check.maxInstances !== undefined ? String(check.maxInstances) : "∞"; + const suffix = !check.allowed ? " (at capacity)" : ""; + const edges = ctx.topology.roles.find((r) => r.name === role)?.edges; + const edgeSuffix = edges && edges.length > 0 ? ` → ${edges.map((e) => e.target).join(", ")}` : ""; + return `${check.currentInstances}/${max}${suffix}${edgeSuffix}`; +} + +function topologyCommand(ctx: ActionContext, role: string): string | undefined { + return ctx.topology?.roles.find((r) => r.name === role)?.command; +} diff --git a/src/tui/actions/fuzzy.ts b/src/tui/actions/fuzzy.ts new file mode 100644 index 00000000..87a0d45c --- /dev/null +++ b/src/tui/actions/fuzzy.ts @@ -0,0 +1,37 @@ +/** + * Pure fuzzy-matching primitive shared by the palette renderer and the action + * visibility/ranking helper. Lives in its own module so neither + * `command-palette.tsx` nor `visibility.ts` has to import the other (avoids a + * circular dependency). + */ + +/** Result of a fuzzy match attempt. */ +export interface FuzzyResult { + readonly match: boolean; + readonly score: number; + /** Indices in `text` that matched pattern characters. */ + readonly matchedIndices: readonly number[]; +} + +/** + * Fuzzy-match `pattern` against `text`. + * + * Scoring: +2 for a match at position 0 or after a space / '/', +1 otherwise. + */ +export function fuzzyMatch(pattern: string, text: string): FuzzyResult { + if (!pattern) return { match: true, score: 0, matchedIndices: [] }; + const lower = text.toLowerCase(); + const pat = pattern.toLowerCase(); + let pi = 0; + let score = 0; + const matchedIndices: number[] = []; + for (let i = 0; i < lower.length && pi < pat.length; i++) { + if (lower[i] === pat[pi]) { + const bonus = i === 0 || lower[i - 1] === " " || lower[i - 1] === "/" ? 2 : 1; + score += bonus; + matchedIndices.push(i); + pi++; + } + } + return { match: pi === pat.length, score, matchedIndices }; +} diff --git a/src/tui/actions/plugin-adapter.test.ts b/src/tui/actions/plugin-adapter.test.ts new file mode 100644 index 00000000..34991bf1 --- /dev/null +++ b/src/tui/actions/plugin-adapter.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, mock, test } from "bun:test"; +import { mergeTuiActionRegistrations } from "../plugins/registry.js"; +import type { TuiActionRegistration, TuiPluginContext } from "../plugins/types.js"; +import { buildPluginActions } from "./plugin-adapter.js"; + +const pluginCtx = { + density: "compact", + showMessage: () => undefined, +} as unknown as TuiPluginContext; + +function reg(o: Partial = {}): TuiActionRegistration { + return { + id: "audit-refresh", + label: "Refresh audit", + detail: "audit", + run: () => undefined, + ...o, + }; +} + +describe("buildPluginActions", () => { + test("wraps plugin registrations as Plugins-group actions", () => { + const merged = mergeTuiActionRegistrations({ builtIns: [], plugins: [reg()] }); + const actions = buildPluginActions(merged.entries, () => pluginCtx); + expect(actions).toHaveLength(1); + expect(actions[0]?.id).toBe("audit-refresh"); + expect(actions[0]?.group).toBe("Plugins"); + expect(actions[0]?.label).toBe("Refresh audit"); + }); + + test("run delegates to the registration with the narrow plugin context", async () => { + const run = mock(() => undefined); + const merged = mergeTuiActionRegistrations({ builtIns: [], plugins: [reg({ run })] }); + const actions = buildPluginActions(merged.entries, () => pluginCtx); + // ActionContext arg is ignored by the adapter; pass an empty stub. + await actions[0]?.run({} as never); + expect(run).toHaveBeenCalledTimes(1); + expect((run.mock.calls as unknown as [TuiPluginContext[]])[0]?.[0]).toBe(pluginCtx); + }); + + test("enabled delegates to the registration predicate via plugin context", () => { + const enabled = mock((c: TuiPluginContext) => c.density === "compact"); + const merged = mergeTuiActionRegistrations({ builtIns: [], plugins: [reg({ enabled })] }); + const actions = buildPluginActions(merged.entries, () => pluginCtx); + expect(actions[0]?.enabled?.({} as never)).toBe(true); + expect(enabled).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tui/actions/plugin-adapter.ts b/src/tui/actions/plugin-adapter.ts new file mode 100644 index 00000000..8c3a61a4 --- /dev/null +++ b/src/tui/actions/plugin-adapter.ts @@ -0,0 +1,29 @@ +import type { TuiActionRegistryEntry } from "../plugins/registry.js"; +import type { TuiPluginContext } from "../plugins/types.js"; +import type { Action, ActionContext } from "./types.js"; + +/** + * Wrap plugin action registry entries as unified `Action`s in the Plugins group. + * + * `mkPluginCtx` builds the narrow plugin context from the rich one so plugins + * never receive app internals (panel focus, spawn/kill, dispatch). + */ +export function buildPluginActions( + entries: readonly TuiActionRegistryEntry[], + mkPluginCtx: (ctx: ActionContext) => TuiPluginContext, +): readonly Action[] { + const actions: Action[] = []; + for (const entry of entries) { + if (entry.source !== "plugin" || entry.registration === undefined) continue; + const reg = entry.registration; + actions.push({ + id: entry.id, + label: entry.label, + detail: entry.detail, + group: "Plugins", + enabled: reg.enabled ? (ctx) => reg.enabled?.(mkPluginCtx(ctx)) ?? true : undefined, + run: (ctx) => reg.run(mkPluginCtx(ctx)), + }); + } + return Object.freeze(actions); +} diff --git a/src/tui/actions/reserved-ids.test.ts b/src/tui/actions/reserved-ids.test.ts new file mode 100644 index 00000000..0ec0df80 --- /dev/null +++ b/src/tui/actions/reserved-ids.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test"; +import { getReservedActionRegistryEntries } from "./reserved-ids.js"; + +describe("reserved action ids", () => { + test("reserves the workflow set-goal and register-agent action ids", () => { + expect(getReservedActionRegistryEntries().map((e) => e.id)).toEqual([ + "workflow.set-goal", + "workflow.register-agent", + ]); + }); +}); diff --git a/src/tui/actions/reserved-ids.ts b/src/tui/actions/reserved-ids.ts new file mode 100644 index 00000000..8e433c4d --- /dev/null +++ b/src/tui/actions/reserved-ids.ts @@ -0,0 +1,25 @@ +import type { TuiActionRegistryEntry } from "../plugins/registry.js"; + +/** + * Built-in action IDs reserved so plugins can't shadow them. These mirror the + * ids produced by `buildBuiltInActions` (see `builtin-actions.ts`) for the + * workflow actions a plugin is most likely to collide with. + */ +export function getReservedActionRegistryEntries(): readonly TuiActionRegistryEntry[] { + return Object.freeze([ + Object.freeze({ + id: "workflow.set-goal", + label: "Set goal", + detail: "", + order: 0, + source: "builtin" as const, + }), + Object.freeze({ + id: "workflow.register-agent", + label: "Register agent", + detail: "", + order: 10, + source: "builtin" as const, + }), + ]); +} diff --git a/src/tui/actions/selection.test.ts b/src/tui/actions/selection.test.ts new file mode 100644 index 00000000..d80e95ec --- /dev/null +++ b/src/tui/actions/selection.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { Panel } from "../hooks/use-panel-focus.js"; +import { resolveSelectedCid } from "./selection.js"; + +const entries = [{ cid: "bafyFRONT0" }, { cid: "bafyFRONT1" }]; + +describe("resolveSelectedCid", () => { + test("Frontier focused → highlighted Frontier row cid", () => { + expect( + resolveSelectedCid({ + focusedPanel: Panel.Frontier, + cursor: 1, + frontierEntries: entries, + detailCid: "bafyDETAIL", + }), + ).toBe("bafyFRONT1"); + }); + + test("Frontier focused with cursor miss → undefined (no detail fallback)", () => { + // Out of range + expect( + resolveSelectedCid({ + focusedPanel: Panel.Frontier, + cursor: 9, + frontierEntries: entries, + detailCid: "bafyDETAIL", + }), + ).toBeUndefined(); + // Empty/stale slice + expect( + resolveSelectedCid({ + focusedPanel: Panel.Frontier, + cursor: 0, + frontierEntries: [], + detailCid: "bafyDETAIL", + }), + ).toBeUndefined(); + }); + + test("Detail focused → the open detail cid", () => { + expect( + resolveSelectedCid({ + focusedPanel: Panel.Detail, + cursor: 0, + frontierEntries: entries, + detailCid: "bafyDETAIL", + }), + ).toBe("bafyDETAIL"); + }); + + test("non-contribution panels → undefined even when a detail is open", () => { + for (const p of [Panel.Activity, Panel.Terminal, Panel.Dag, Panel.Claims, Panel.Search]) { + expect( + resolveSelectedCid({ + focusedPanel: p, + cursor: 0, + frontierEntries: entries, + detailCid: "bafyDETAIL", + }), + `panel ${p}`, + ).toBeUndefined(); + } + }); +}); diff --git a/src/tui/actions/selection.ts b/src/tui/actions/selection.ts new file mode 100644 index 00000000..b1c32979 --- /dev/null +++ b/src/tui/actions/selection.ts @@ -0,0 +1,36 @@ +/** + * Pure resolver for "which contribution is the operator acting on" in the + * command palette. Kept separate from app.tsx so the focused-panel contract is + * unit-testable. + * + * Contract (strict — no cross-panel fallback): + * - Frontier focused → the highlighted Frontier row (its own per-slice + * cursor→cid list). A cursor miss (empty/loading/stale slice, out of range) + * yields `undefined` so contribution actions DISAPPEAR rather than acting on + * an unrelated contribution. + * - Detail focused → the open detail's cid (`detailCid`). + * - Any other panel → `undefined` (no contribution is "selected" there; + * notably Activity/Terminal/DAG do not feed a reliable cursor→cid list). + * + * The result drives both built-in contribution actions AND the cid handed to + * plugin actions, so non-contribution panels must produce `undefined`. + */ + +import { Panel } from "../hooks/use-panel-focus.js"; + +export interface SelectionInput { + readonly focusedPanel: Panel; + readonly cursor: number; + readonly frontierEntries: ReadonlyArray<{ readonly cid: string }>; + readonly detailCid: string | undefined; +} + +export function resolveSelectedCid(input: SelectionInput): string | undefined { + if (input.focusedPanel === Panel.Frontier) { + return input.frontierEntries[input.cursor]?.cid; + } + if (input.focusedPanel === Panel.Detail) { + return input.detailCid; + } + return undefined; +} diff --git a/src/tui/actions/types.test.ts b/src/tui/actions/types.test.ts new file mode 100644 index 00000000..1b7fc90c --- /dev/null +++ b/src/tui/actions/types.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { GROUP_ORDER } from "./types.js"; + +describe("action types", () => { + test("GROUP_ORDER lists the six groups in display order", () => { + expect(GROUP_ORDER).toEqual([ + "Navigation", + "Agents", + "Workflow", + "View", + "Contributions", + "Plugins", + ]); + }); +}); diff --git a/src/tui/actions/types.ts b/src/tui/actions/types.ts new file mode 100644 index 00000000..12e58069 --- /dev/null +++ b/src/tui/actions/types.ts @@ -0,0 +1,120 @@ +import type { Claim } from "../../core/models.js"; +import type { AgentTopology } from "../../core/topology.js"; +import type { Panel } from "../hooks/use-panel-focus.js"; + +/** Top-level grouping for palette actions. */ +export type ActionGroup = + | "Navigation" + | "Agents" + | "Workflow" + | "View" + | "Contributions" + | "Plugins"; + +/** Fixed display order for groups when no query is active. */ +export const GROUP_ORDER: readonly ActionGroup[] = [ + "Navigation", + "Agents", + "Workflow", + "View", + "Contributions", + "Plugins", +]; + +/** An agent profile loaded from .grove/agents.json. */ +export interface LoadedProfile { + readonly name: string; + readonly role: string; + readonly platform: string; + readonly command?: string | undefined; +} + +/** A gossip peer with free agent capacity. */ +export interface GossipPeerSlot { + readonly peerId: string; + readonly address: string; + readonly freeSlots: number; +} + +/** + * Rich, internal context handed to built-in actions. Holds read-only state for + * enumeration/gating plus imperative capabilities closed over app machinery. + * Plugins never see this — see `plugin-adapter.ts`. + */ +export interface ActionContext { + // --- read state --- + readonly topology?: AgentTopology | undefined; + readonly sessions: readonly string[]; + readonly profiles: readonly LoadedProfile[]; + readonly gossipPeers: readonly GossipPeerSlot[]; + /** Active claims, or null when scoped session can't see them. */ + readonly claims: readonly Claim[] | null; + readonly selectedSession?: string | undefined; + /** CID of the highlighted contribution (cursor row), or the open detail. */ + readonly selectedCid?: string | undefined; + /** CID of the currently-open detail view, if any (distinct from selectedCid). */ + readonly detailCid?: string | undefined; + readonly parentAgentId?: string | undefined; + readonly pendingQuestionCount: number; + /** CID of the sole pending question (only when exactly one) — pins approve/deny. */ + readonly pendingQuestionCid?: string | undefined; + readonly hasGoals: boolean; + readonly canSpawn: boolean; + readonly canDelegate: boolean; + readonly isPanelVisible: (panel: Panel) => boolean; + /** Currently focused panel — drives focused-panel-sensitive actions. */ + readonly focusedPanel: Panel; + /** Number of frontier slice tabs — frontier-slice nav needs more than one. */ + readonly frontierSliceCount: number; + + // --- capabilities --- + readonly focusPanel: (panel: Panel) => void; + readonly togglePanel: (panel: Panel) => void; + readonly cyclePanelNext: () => void; + readonly cyclePanelPrev: () => void; + readonly openContribution: (cid: string) => void; + readonly jumpToSession: (session: string) => void; + readonly enterGoalMode: () => void; + readonly enterCompareMode: () => void; + readonly addToCompare: (cid: string) => void; + /** Begin adopting a contribution; the summary is resolved from the cid. */ + readonly adoptContribution: (cid: string) => void; + readonly answerPendingQuestion: (verdict: "approve" | "deny", expectedCid?: string) => void; + readonly registerAgentProfile: () => void; + readonly spawn: (roleId: string, command: string, parentAgentId?: string) => void; + readonly kill: (session: string) => void; + readonly delegate: (peerAddress: string) => void; + // Messaging + readonly broadcastMessage: () => void; + readonly directMessage: () => void; + // View / system + readonly refresh: () => void; + readonly enterSearch: () => void; + readonly cycleZoom: () => void; + readonly resetZoom: () => void; + readonly toggleLayout: () => void; + readonly cycleViewMode: () => void; + readonly showHelp: () => void; + readonly quit: () => void; + // Focused-panel-sensitive + readonly nextFrontierSlice: () => void; + readonly prevFrontierSlice: () => void; + readonly scrollTerminalToBottom: () => void; + readonly showMessage: (message: string) => void; +} + +/** A single unified palette action. */ +export interface Action { + /** Stable, unique id, e.g. "nav.panel.terminal", "agent.spawn.reviewer". */ + readonly id: string; + readonly label: string; + readonly detail: string; + readonly group: ActionGroup; + /** Extra fuzzy-match terms beyond the label. */ + readonly keywords?: readonly string[] | undefined; + /** Relevance gate. False → item is HIDDEN entirely. Default: visible. */ + readonly available?: ((ctx: ActionContext) => boolean) | undefined; + /** Capability gate. False → item shown but GREYED and not executable. */ + readonly enabled?: ((ctx: ActionContext) => boolean) | undefined; + readonly run: (ctx: ActionContext) => void | Promise; +} diff --git a/src/tui/actions/visibility.test.ts b/src/tui/actions/visibility.test.ts new file mode 100644 index 00000000..16dba6db --- /dev/null +++ b/src/tui/actions/visibility.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import type { Action, ActionContext } from "./types.js"; +import { computeVisibleActions } from "./visibility.js"; + +// Minimal ctx — only fields read by these actions matter. +function ctx(overrides: Partial = {}): ActionContext { + return { + sessions: [], + profiles: [], + gossipPeers: [], + claims: [], + pendingQuestionCount: 0, + hasGoals: false, + canSpawn: false, + canDelegate: false, + isPanelVisible: () => false, + focusPanel: () => undefined, + togglePanel: () => undefined, + cyclePanelNext: () => undefined, + cyclePanelPrev: () => undefined, + openContribution: () => undefined, + jumpToSession: () => undefined, + enterGoalMode: () => undefined, + enterCompareMode: () => undefined, + addToCompare: () => undefined, + adoptContribution: () => undefined, + answerPendingQuestion: () => undefined, + registerAgentProfile: () => undefined, + spawn: () => undefined, + kill: () => undefined, + delegate: () => undefined, + focusedPanel: 1, + frontierSliceCount: 1, + broadcastMessage: () => undefined, + directMessage: () => undefined, + refresh: () => undefined, + enterSearch: () => undefined, + cycleZoom: () => undefined, + resetZoom: () => undefined, + toggleLayout: () => undefined, + cycleViewMode: () => undefined, + showHelp: () => undefined, + quit: () => undefined, + nextFrontierSlice: () => undefined, + prevFrontierSlice: () => undefined, + scrollTerminalToBottom: () => undefined, + showMessage: () => undefined, + ...overrides, + }; +} + +function act(o: Partial & Pick): Action { + return { label: o.id, detail: "", run: () => undefined, ...o }; +} + +describe("computeVisibleActions", () => { + test("no query: hides unavailable, orders by group", () => { + const actions: Action[] = [ + act({ id: "p1", group: "Plugins", label: "plugin one" }), + act({ id: "n1", group: "Navigation", label: "nav one" }), + act({ id: "hidden", group: "Agents", label: "hidden", available: () => false }), + ]; + const visible = computeVisibleActions(actions, ctx(), ""); + expect(visible.map((v) => v.action.id)).toEqual(["n1", "p1"]); + expect(visible[0]?.matchedIndices).toEqual([]); + }); + + test("query: flat ranked, matches label or keywords", () => { + const actions: Action[] = [ + act({ id: "terminal", group: "Navigation", label: "Focus Terminal" }), + act({ id: "vfs", group: "Navigation", label: "Focus VFS", keywords: ["files"] }), + ]; + const visible = computeVisibleActions(actions, ctx(), "files"); + expect(visible.map((v) => v.action.id)).toEqual(["vfs"]); + }); + + test("query: still respects available()", () => { + const actions: Action[] = [ + act({ id: "x", group: "Workflow", label: "answer question", available: () => false }), + ]; + expect(computeVisibleActions(actions, ctx(), "answer")).toHaveLength(0); + }); +}); diff --git a/src/tui/actions/visibility.ts b/src/tui/actions/visibility.ts new file mode 100644 index 00000000..edcb896f --- /dev/null +++ b/src/tui/actions/visibility.ts @@ -0,0 +1,58 @@ +import { fuzzyMatch } from "./fuzzy.js"; +import type { Action, ActionContext } from "./types.js"; +import { GROUP_ORDER } from "./types.js"; + +/** An available action with label-match metadata for highlighting. */ +export interface VisibleAction { + readonly action: Action; + readonly matchedIndices: readonly number[]; +} + +function isAvailable(action: Action, ctx: ActionContext): boolean { + return action.available?.(ctx) ?? true; +} + +/** + * Produce the ordered, flat list of actions the palette displays. + * + * - No query: available actions sorted by GROUP_ORDER (stable within a group). + * - Query: available actions whose label OR any keyword fuzzy-matches, ranked + * by best score (desc). `matchedIndices` reflects the label match only + * (empty when only a keyword matched). + * + * This single list is the source of truth for BOTH grouped rendering and the + * keyboard selection index — keeping them in sync. + */ +export function computeVisibleActions( + actions: readonly Action[], + ctx: ActionContext, + query: string, +): readonly VisibleAction[] { + const available = actions.filter((a) => isAvailable(a, ctx)); + const q = query.trim(); + + if (!q) { + const ordered = [...available].sort( + (a, b) => GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group), + ); + return ordered.map((action) => ({ action, matchedIndices: [] })); + } + + const ranked: Array = []; + for (const action of available) { + const labelResult = fuzzyMatch(q, action.label); + let best = labelResult.match ? labelResult.score : -1; + let matchedIndices: readonly number[] = labelResult.match ? labelResult.matchedIndices : []; + for (const kw of action.keywords ?? []) { + const r = fuzzyMatch(q, kw); + if (r.match && r.score > best) { + best = r.score; + // Keep label highlight only; a keyword-only match yields no label indices. + if (!labelResult.match) matchedIndices = []; + } + } + if (best >= 0) ranked.push({ action, matchedIndices, score: best }); + } + ranked.sort((a, b) => b.score - a.score); + return ranked.map(({ action, matchedIndices }) => ({ action, matchedIndices })); +} diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 5d576031..36fe7e03 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -15,16 +15,16 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "r import { TUI_REFRESH_ROLE } from "../core/event-bus.js"; import type { Claim, Contribution } from "../core/models.js"; import { safeCleanup } from "../shared/safe-cleanup.js"; +import { resolveAnswerableQuestion } from "./actions/answer-guard.js"; +import { buildBuiltInActions } from "./actions/builtin-actions.js"; +import { buildPluginActions } from "./actions/plugin-adapter.js"; +import { getReservedActionRegistryEntries } from "./actions/reserved-ids.js"; +import { resolveSelectedCid } from "./actions/selection.js"; +import { computeVisibleActions } from "./actions/visibility.js"; import { checkSpawn, checkSpawnDepth } from "./agents/spawn-validator.js"; import { agentIdFromSession } from "./agents/tmux-manager.js"; import { INITIAL_KEYBOARD_STATE, tuiReducer } from "./app-reducer.js"; -import { - buildPaletteItems, - buildPluginPaletteItems, - CommandPalette, - fuzzyMatch, - getBuiltInPaletteActionRegistryEntries, -} from "./components/command-palette.js"; +import { CommandPalette } from "./components/command-palette.js"; import { HelpOverlay } from "./components/help-overlay.js"; import { InputBar } from "./components/input-bar.js"; import { type ScreenContext, StatusBar } from "./components/status-bar.js"; @@ -48,13 +48,12 @@ import { import type { KeyboardActions } from "./hooks/use-keyboard-handler.js"; import { nextZoom, routeKey } from "./hooks/use-keyboard-handler.js"; import { useNavigation } from "./hooks/use-navigation.js"; -import { InputMode, usePanelFocus } from "./hooks/use-panel-focus.js"; +import { InputMode, Panel, usePanelFocus } from "./hooks/use-panel-focus.js"; import { useTuiStatePersistence } from "./hooks/use-session-persistence.js"; import { resolveKeymapWithOverrides } from "./keymap/keymap.js"; import type { ZoomLevel } from "./panels/panel-manager.js"; import { PanelManager } from "./panels/panel-manager.js"; import { getBuiltInTuiRegistryEntries } from "./panels/panel-registry.js"; -import { runTuiActionRegistration } from "./plugins/actions.js"; import { collectTuiActionRegistrations, collectTuiPanelRegistrations, @@ -249,7 +248,7 @@ export function App({ const mergedActionRegistry = useMemo( () => mergeTuiActionRegistrations({ - builtIns: getBuiltInPaletteActionRegistryEntries(), + builtIns: getReservedActionRegistryEntries(), plugins: pluginActionRegistrations, }), [pluginActionRegistrations], @@ -461,6 +460,28 @@ export function App({ paletteVisible, ); + // Poll pending questions for the answer-question palette actions. We carry + // the cids (not just the count) so the approve/deny actions can pin the exact + // question they were shown for and revalidate it at execution time. + const pendingQuestionsFetcher = useCallback(async (): Promise => { + const askProvider = provider as unknown as { + getPendingQuestions?: () => Promise; + }; + if (!askProvider.getPendingQuestions) return []; + try { + return await askProvider.getPendingQuestions(); + } catch { + return []; + } + }, [provider]); + const { data: pendingQuestions, refresh: refreshPendingQuestions } = useEventDrivenData< + readonly { cid: string }[] + >(pendingQuestionsFetcher, undefined, undefined, paletteVisible); + const pendingQuestionCount = pendingQuestions?.length ?? 0; + // The single pending question's cid (only when exactly one) — pins the + // approve/deny target so a concurrent add/remove can't redirect the answer. + const pendingQuestionCid = pendingQuestions?.length === 1 ? pendingQuestions[0]?.cid : undefined; + // Derive parentAgentId from the selected session for lineage-aware palette display const paletteParentId = selectedSession ? agentIdFromSession(selectedSession) : undefined; @@ -543,6 +564,7 @@ export function App({ refreshPR(); refreshDashboard(); refreshGossip(); + refreshPendingQuestions(); refreshProfiles(); refreshTerminalBuffers(); }, [ @@ -553,6 +575,7 @@ export function App({ refreshPR, refreshDashboard, refreshGossip, + refreshPendingQuestions, refreshProfiles, refreshTerminalBuffers, ]); @@ -587,54 +610,6 @@ export function App({ }, [eventBus, topology, provider, triggerGlobalRefresh]); const hasGoals = isGoalProvider(provider); - const corePaletteItems = useMemo( - () => - buildPaletteItems( - topology, - activeClaims ?? [], - paletteSessions ?? [], - tmux !== undefined, - canSpawn, - true, - paletteParentId, - canDelegate ? (gossipPeers ?? undefined) : undefined, - agentProfiles ?? undefined, - hasGoals, - ), - [ - topology, - activeClaims, - paletteSessions, - tmux, - canSpawn, - canDelegate, - paletteParentId, - gossipPeers, - agentProfiles, - hasGoals, - ], - ); - const pluginPaletteItems = useMemo( - () => buildPluginPaletteItems(mergedActionRegistry.entries, pluginContext), - [mergedActionRegistry.entries, pluginContext], - ); - const paletteItems = useMemo( - () => [...corePaletteItems, ...pluginPaletteItems], - [corePaletteItems, pluginPaletteItems], - ); - - // Filtered + ranked palette items — matches CommandPalette's rendering order - // so Enter always executes the visually selected item. - const filteredPaletteItems = useMemo(() => { - const q = ks.paletteQuery.trim(); - if (!q) return paletteItems; - const ranked = paletteItems - .map((item) => ({ item, score: fuzzyMatch(q, item.label) })) - .filter((r) => r.score.match) - .sort((a, b) => b.score.score - a.score.score) - .map((r) => r.item); - return ranked; - }, [paletteItems, ks.paletteQuery]); const handleContributionsLoaded = useCallback((contributions: readonly Contribution[]) => { if (!contributions) return; @@ -687,6 +662,38 @@ export function App({ } }, [provider, nav.state.cursor, showError]); + const answerPendingQuestion = useCallback( + async (verdict: "approve" | "deny", expectedCid?: string) => { + const askProvider = provider as unknown as { + answerQuestion?: (cid: string, answer: string) => Promise; + getPendingQuestions?: () => Promise< + readonly { cid: string; options?: readonly string[] }[] + >; + }; + if (!askProvider.answerQuestion || !askProvider.getPendingQuestions) return; + try { + // Re-fetch and revalidate: blind-answering is only safe when exactly one + // question remains AND it is the same one the action was shown for. A + // concurrent add/remove/replace between palette render and Enter aborts + // here and routes the operator to the Decisions panel instead. + const questions = await askProvider.getPendingQuestions(); + const selected = resolveAnswerableQuestion(questions, expectedCid); + if (!selected) { + showError("Pending questions changed — review them in the Decisions panel"); + if (panels.isVisible(Panel.Decisions)) panels.focus(Panel.Decisions); + else panels.toggle(Panel.Decisions); + return; + } + const answer = verdict === "approve" ? (selected.options?.[0] ?? "Approved") : "Denied"; + await askProvider.answerQuestion(selected.cid, answer); + showError(`Answered: ${answer}`); + } catch (err) { + showError(err instanceof Error ? err.message : "Failed to answer"); + } + }, + [provider, showError, panels], + ); + /** Send a message via the boardroom API or local provider. */ const sendTuiMessage = useCallback( async (recipients: string, body: string) => { @@ -854,16 +861,224 @@ export function App({ [showError, spawnManager], ); + const registerAgentProfile = useCallback(() => { + void (async () => { + try { + const { existsSync, writeFileSync, mkdirSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const dir = resolve(process.cwd(), ".grove"); + const path = resolve(dir, "agents.json"); + if (!existsSync(path)) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const template = JSON.stringify( + { + profiles: [ + { + name: "@agent-1", + role: topology?.roles[0]?.name ?? "worker", + platform: "claude-code", + command: "claude --dangerously-skip-permissions", + }, + ], + }, + null, + 2, + ); + writeFileSync(path, template); + showError(`Created ${path} — edit to add agent profiles`); + } else { + showError( + `Profiles loaded from ${path} (${String(agentProfiles?.length ?? 0)} profiles)`, + ); + } + } catch (err) { + showError(err instanceof Error ? err.message : "Registration failed"); + } + })(); + }, [topology, agentProfiles, showError]); + const handleCommandPaletteClose = useCallback(() => { dispatch({ type: "ADOPT_CLEAR" }); panels.setMode(InputMode.Normal); dispatch({ type: "PALETTE_RESET" }); }, [panels]); + const mkPluginCtx = useCallback( + (ctx: import("./actions/types.js").ActionContext): TuiPluginContext => ({ + // Keep the narrow plugin surface but hand plugins the panel-aware + // selection, not the detail-only cid baked into pluginContext. + ...pluginContext, + selectedCid: ctx.selectedCid, + }), + [pluginContext], + ); + + const actionContext = useMemo( + () => ({ + topology, + sessions: paletteSessions ?? [], + profiles: agentProfiles ?? [], + gossipPeers: canDelegate ? (gossipPeers ?? []) : [], + claims: activeClaims, + selectedSession, + // Strict focused-panel selection (see resolveSelectedCid): Frontier row, + // or the open Detail, else undefined. No cross-panel detail fallback — + // focusing Terminal/Activity/DAG must NOT keep contribution (or plugin) + // actions acting on a stale open detail. + selectedCid: resolveSelectedCid({ + focusedPanel: panels.state.focused, + cursor: nav.state.cursor, + frontierEntries: frontierEntriesRef.current, + detailCid: nav.detailCid, + }), + detailCid: nav.detailCid, + parentAgentId: paletteParentId, + pendingQuestionCount, + pendingQuestionCid, + hasGoals, + canSpawn, + canDelegate, + isPanelVisible: (panel) => panels.isVisible(panel), + focusedPanel: panels.state.focused, + frontierSliceCount: ks.frontierTabKeys.length, + focusPanel: (panel) => panels.focus(panel), + togglePanel: (panel) => panels.toggle(panel), + cyclePanelNext: () => panels.cycleNext(), + cyclePanelPrev: () => panels.cyclePrev(), + openContribution: (cid) => nav.pushDetail(cid), + jumpToSession: (session) => { + setSelectedSession(session); + // Reveal the Terminal panel (focus if already shown; toggle would hide). + if (panels.isVisible(Panel.Terminal)) panels.focus(Panel.Terminal); + else panels.toggle(Panel.Terminal); + }, + enterGoalMode: () => { + panels.setMode(InputMode.GoalInput); + dispatch({ type: "GOAL_INPUT_MODE" }); + }, + // Idempotent ENTRY: only toggle when compare mode is off. Wiring straight + // to COMPARE_TOGGLE would turn compare OFF if invoked while already on. + enterCompareMode: () => { + if (!ks.compareMode) dispatch({ type: "COMPARE_TOGGLE" }); + }, + addToCompare: (cid) => { + // Entering compare mode clears compareCids, so if it is currently off + // we must toggle it ON first, then select — otherwise the operator's + // later COMPARE_TOGGLE would wipe the cid added here. + if (!ks.compareMode) dispatch({ type: "COMPARE_TOGGLE" }); + dispatch({ type: "COMPARE_SELECT", cid }); + }, + adoptContribution: (cid) => { + // Resolve the row summary the same way the Frontier 'a' / compare-adopt + // paths do, so palette-adopt carries it into the spawned agent context + // (handleSpawn reads ks.adoptContext.summary). Empty string would spawn + // with a blank adoptSummary. + const summary = + frontierEntriesRef.current.find((e) => e.cid === cid)?.summary ?? + contributionList.find((c) => c.cid === cid)?.summary ?? + ""; + dispatch({ type: "ADOPT_SET", targetCid: cid, summary }); + panels.setMode(InputMode.CommandPalette); + }, + answerPendingQuestion: (verdict, expectedCid) => + void answerPendingQuestion(verdict, expectedCid), + registerAgentProfile, + spawn: (roleId, command, parentAgentId) => + handleSpawn(roleId, command, "HEAD", parentAgentId), + kill: (session) => handleKill(session), + delegate: (peerAddress) => void handleDelegate(peerAddress), + broadcastMessage: () => { + dispatch({ type: "BROADCAST_MODE" }); + panels.setMode(InputMode.MessageInput); + }, + directMessage: () => { + dispatch({ type: "DIRECT_MESSAGE_MODE" }); + panels.setMode(InputMode.MessageInput); + }, + refresh: refreshAll, + enterSearch: () => { + // Reveal + focus the Search panel before entering input mode — otherwise + // the user types into an invisible buffer (the keyboard '/' path relies + // on the panel already being visible). toggle adds-and-focuses when + // hidden; focus when already shown (toggle would hide it). + if (panels.isVisible(Panel.Search)) panels.focus(Panel.Search); + else panels.toggle(Panel.Search); + dispatch({ type: "SEARCH_START", currentQuery: ks.searchQuery }); + panels.setMode(InputMode.SearchInput); + }, + cycleZoom: () => dispatch({ type: "ZOOM_CYCLE" }), + resetZoom: () => dispatch({ type: "ZOOM_RESET" }), + toggleLayout: () => dispatch({ type: "LAYOUT_TOGGLE" }), + cycleViewMode: () => panels.cycleViewMode(), + showHelp: () => panels.setMode(InputMode.Help), + quit: handleQuit, + // Mirror the keyboard frontier-slice handlers: rotate slice, reset the + // cursor, and synchronously clear the entry/cid refs so a follow-up + // adopt/select reads the new slice (see onFrontierTabNext). + nextFrontierSlice: () => { + dispatch({ type: "FRONTIER_SLICE_NEXT" }); + nav.resetCursor(); + frontierEntriesRef.current = []; + frontierCidsRef.current = []; + }, + prevFrontierSlice: () => { + dispatch({ type: "FRONTIER_SLICE_PREV" }); + nav.resetCursor(); + frontierEntriesRef.current = []; + frontierCidsRef.current = []; + }, + scrollTerminalToBottom: () => dispatch({ type: "TERMINAL_SCROLL_BOTTOM" }), + showMessage: showError, + }), + [ + topology, + paletteSessions, + agentProfiles, + gossipPeers, + canDelegate, + activeClaims, + selectedSession, + nav, + paletteParentId, + pendingQuestionCount, + pendingQuestionCid, + hasGoals, + canSpawn, + panels, + contributionList, + nav.state.cursor, + ks.frontierTabKeys, + ks.searchQuery, + ks.compareMode, + answerPendingQuestion, + registerAgentProfile, + handleSpawn, + handleKill, + handleDelegate, + refreshAll, + handleQuit, + showError, + ], + ); + + const paletteActions = useMemo( + () => [ + ...buildBuiltInActions(actionContext), + ...buildPluginActions(mergedActionRegistry.entries, mkPluginCtx), + ], + [actionContext, mergedActionRegistry.entries, mkPluginCtx], + ); + + const visiblePaletteActions = useMemo( + () => computeVisibleActions(paletteActions, actionContext, ks.paletteQuery), + [paletteActions, actionContext, ks.paletteQuery], + ); + // --------------------------------------------------------------------------- // KeyboardActions adapter — maps routeKey callbacks to state transitions. - // Complex palette execution (spawn/kill/register/delegate) and paste safety - // remain here because they need closure access to spawnManager, etc. + // Palette execution now runs through `actionContext`/`action.run`; the paste- + // safety path and other handlers remain here for closure access to + // spawnManager, etc. // --------------------------------------------------------------------------- const keyboardActions: KeyboardActions = useMemo( @@ -1001,68 +1216,22 @@ export function App({ onPaletteChar: (char: string) => dispatch({ type: "PALETTE_CHAR", char }), onPaletteBackspace: () => dispatch({ type: "PALETTE_BACKSPACE" }), onPaletteSelect: () => { - const item = filteredPaletteItems[ks.paletteIndex]; - if (!item?.enabled) return; - if (item.kind === "spawn") { - const profileCommand = agentProfiles?.find((p) => p.role === item.id)?.command; - const roleCommand = topology?.roles.find((r) => r.name === item.id)?.command; - const shell = profileCommand ?? roleCommand ?? process.env.SHELL ?? "bash"; - handleSpawn(item.id, shell, "HEAD", paletteParentId); - } else if (item.kind === "kill") { - handleKill(item.id); - } else if (item.kind === "register") { - void (async () => { - try { - const { existsSync, writeFileSync, mkdirSync } = await import("node:fs"); - const { resolve } = await import("node:path"); - const dir = resolve(process.cwd(), ".grove"); - const path = resolve(dir, "agents.json"); - if (!existsSync(path)) { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const template = JSON.stringify( - { - profiles: [ - { - name: "@agent-1", - role: topology?.roles[0]?.name ?? "worker", - platform: "claude-code", - command: "claude --dangerously-skip-permissions", - }, - ], - }, - null, - 2, - ); - writeFileSync(path, template); - showError(`Created ${path} — edit to add agent profiles`); - } else { - showError( - `Profiles loaded from ${path} (${String(agentProfiles?.length ?? 0)} profiles)`, - ); - } - } catch (err) { - showError(err instanceof Error ? err.message : "Registration failed"); - } - })(); - } else if (item.kind === "delegate") { - void handleDelegate(item.id); - } else if (item.kind === "plugin-action" && item.pluginAction !== undefined) { - void runTuiActionRegistration(item.pluginAction, pluginContext).catch((err: unknown) => { - showError(err instanceof Error ? err.message : "Plugin action failed"); - }); - } else if (item.kind === "goal") { - panels.setMode(InputMode.GoalInput); - dispatch({ type: "GOAL_INPUT_MODE" }); - dispatch({ type: "PALETTE_RESET" }); - return; - } + const entry = visiblePaletteActions[ks.paletteIndex]; + if (!entry) return; + const action = entry.action; + if (!(action.enabled?.(actionContext) ?? true)) return; + // Close the palette FIRST. Mode-switching actions (goal, compare, adopt) + // re-set their target mode inside run, landing after this Normal set. panels.setMode(InputMode.Normal); dispatch({ type: "PALETTE_RESET" }); + void Promise.resolve(action.run(actionContext)).catch((err: unknown) => { + showError(err instanceof Error ? err.message : "Action failed"); + }); }, onSelect: handleSelect, rowCount, pageSize: PAGE_SIZE, - paletteItemCount: filteredPaletteItems.length, + paletteItemCount: visiblePaletteActions.length, compareMode: ks.compareMode, frontierCids: () => frontierCidsRef.current, selectedSession, @@ -1109,15 +1278,13 @@ export function App({ handleSelect, handleApproveQuestion, handleDenyQuestion, - handleSpawn, - handleKill, - handleDelegate, sendTuiMessage, showError, tmux, selectedSession, rowCount, - filteredPaletteItems, + visiblePaletteActions, + actionContext, ks.compareMode, ks.compareCids, ks.searchQuery, @@ -1126,10 +1293,6 @@ export function App({ ks.goalBuffer, ks.paletteIndex, contributionList, - agentProfiles, - topology, - paletteParentId, - pluginContext, resolvedKeymap, keymapPrefix, refreshAll, @@ -1172,17 +1335,10 @@ export function App({ > diff --git a/src/tui/components/command-palette.render.test.tsx b/src/tui/components/command-palette.render.test.tsx new file mode 100644 index 00000000..ec9e56a8 --- /dev/null +++ b/src/tui/components/command-palette.render.test.tsx @@ -0,0 +1,193 @@ +/** + * Render-level tests for CommandPalette — mounts the real component via + * react-test-renderer and inspects emitted nodes. Exercises the + * builders → computeVisibleActions → grouped/flat render pipeline end to end, + * covering the #194 acceptance criteria that are observable in the output: + * grouped sections, query collapsing groups, greyed disabled items, and + * context-sensitive actions appearing only when relevant. + */ + +import { describe, expect, test } from "bun:test"; +import React from "react"; +import TestRenderer, { act } from "react-test-renderer"; +import { buildBuiltInActions } from "../actions/builtin-actions.js"; +import type { ActionContext } from "../actions/types.js"; +import { theme } from "../theme.js"; +import { CommandPalette } from "./command-palette.js"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function ctx(overrides: Partial = {}): ActionContext { + return { + sessions: [], + profiles: [], + gossipPeers: [], + claims: [], + pendingQuestionCount: 0, + hasGoals: false, + canSpawn: false, + canDelegate: false, + isPanelVisible: () => false, + focusPanel: () => undefined, + togglePanel: () => undefined, + cyclePanelNext: () => undefined, + cyclePanelPrev: () => undefined, + openContribution: () => undefined, + jumpToSession: () => undefined, + enterGoalMode: () => undefined, + enterCompareMode: () => undefined, + addToCompare: () => undefined, + adoptContribution: () => undefined, + answerPendingQuestion: () => undefined, + registerAgentProfile: () => undefined, + spawn: () => undefined, + kill: () => undefined, + delegate: () => undefined, + focusedPanel: 1, + frontierSliceCount: 1, + broadcastMessage: () => undefined, + directMessage: () => undefined, + refresh: () => undefined, + enterSearch: () => undefined, + cycleZoom: () => undefined, + resetZoom: () => undefined, + toggleLayout: () => undefined, + cycleViewMode: () => undefined, + showHelp: () => undefined, + quit: () => undefined, + nextFrontierSlice: () => undefined, + prevFrontierSlice: () => undefined, + scrollTerminalToBottom: () => undefined, + showMessage: () => undefined, + ...overrides, + }; +} + +/** + * Collect every string fragment rendered inside nodes, joined with "". + * Joining with "" matters: under an active query, a matched label is split into + * highlighted/unhighlighted runs, so a space separator would break the + * contiguous label string. "" reconstructs it. + */ +function allText(node: TestRenderer.ReactTestRendererJSON | null): string { + if (node === null) return ""; + const parts: string[] = []; + const walk = (n: TestRenderer.ReactTestRendererJSON | string): void => { + if (typeof n === "string") { + parts.push(n); + return; + } + for (const child of n.children ?? []) { + walk(child as TestRenderer.ReactTestRendererJSON | string); + } + }; + walk(node); + return parts.join(""); +} + +/** Find all nodes whose direct text contains `needle`, return their color prop. */ +function colorsForLabel(node: TestRenderer.ReactTestRendererJSON, needle: string): string[] { + const colors: string[] = []; + const textOf = (n: TestRenderer.ReactTestRendererJSON): string => { + const parts: string[] = []; + for (const c of n.children ?? []) { + if (typeof c === "string") parts.push(c); + } + return parts.join(""); + }; + const walk = (n: TestRenderer.ReactTestRendererJSON | string): void => { + if (typeof n === "string") return; + if (n.type === "text" && textOf(n).includes(needle)) { + colors.push(n.props?.color as string); + } + for (const c of n.children ?? []) walk(c as TestRenderer.ReactTestRendererJSON | string); + }; + walk(node); + return colors; +} + +function render(props: { + actions: ReturnType; + ctx: ActionContext; + query?: string; + selectedIndex?: number; +}): TestRenderer.ReactTestRendererJSON { + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create( + React.createElement(CommandPalette, { + visible: true, + actions: props.actions, + ctx: props.ctx, + query: props.query, + selectedIndex: props.selectedIndex, + }), + ); + }); + const json = renderer.toJSON(); + renderer.unmount(); + if (json === null || Array.isArray(json)) throw new Error("unexpected render output"); + return json; +} + +describe("CommandPalette render (#194)", () => { + test("no query: renders group section headers", () => { + const c = ctx({ hasGoals: true }); + const text = allText(render({ actions: buildBuiltInActions(c), ctx: c })); + // Section headers (rendered as their own bold ). + expect(text).toContain("Navigation"); + expect(text).toContain("Workflow"); + // Built-in actions present. + expect(text).toContain("Set goal"); + expect(text).toContain("Go to Terminal panel"); + }); + + test("renders the View group + messaging/system actions", () => { + const c = ctx(); + const text = allText(render({ actions: buildBuiltInActions(c), ctx: c })); + expect(text).toContain("View"); + expect(text).toContain("Refresh all data"); + expect(text).toContain("Quit grove"); + expect(text).toContain("Broadcast message to all agents"); + }); + + test("query collapses groups to a flat ranked list (no headers)", () => { + const c = ctx({ hasGoals: true }); + const json = render({ actions: buildBuiltInActions(c), ctx: c, query: "goal" }); + const text = allText(json); + // The matching action is present... + expect(text).toContain("Set goal"); + // ...and the "Navigation" section header is gone under an active query. + expect(text).not.toContain("Navigation"); + }); + + test("context-sensitive: contribution actions appear only when a cid is selected", () => { + const without = allText(render({ actions: buildBuiltInActions(ctx()), ctx: ctx() })); + expect(without).not.toContain("Open selected contribution"); + + const withSel = ctx({ selectedCid: "bafy123" }); + const text = allText(render({ actions: buildBuiltInActions(withSel), ctx: withSel })); + expect(text).toContain("Open selected contribution"); + expect(text).toContain("Contributions"); + }); + + test("disabled action renders greyed (theme.disabled)", () => { + // canSpawn + a profile + a topology at capacity → spawn present but disabled. + const topology = { + roles: [{ name: "reviewer", maxInstances: 0 }], + } as unknown as ActionContext["topology"]; + const c = ctx({ + canSpawn: true, + topology, + claims: [], + profiles: [{ name: "@rev", role: "reviewer", platform: "claude-code" }], + }); + const actions = buildBuiltInActions(c); + const spawn = actions.find((a) => a.id === "agent.spawn.reviewer"); + // Sanity: this action is indeed disabled in this context. + expect(spawn?.enabled?.(c)).toBe(false); + const json = render({ actions, ctx: c, selectedIndex: -1 }); + const colors = colorsForLabel(json, "Spawn @rev"); + expect(colors).toContain(theme.disabled); + }); +}); diff --git a/src/tui/components/command-palette.test.tsx b/src/tui/components/command-palette.test.tsx index 209e71d3..616fdc8e 100644 --- a/src/tui/components/command-palette.test.tsx +++ b/src/tui/components/command-palette.test.tsx @@ -1,112 +1,70 @@ -import { describe, expect, mock, test } from "bun:test"; -import { mergeTuiActionRegistrations } from "../plugins/registry.js"; -import type { TuiActionRegistration, TuiPluginContext } from "../plugins/types.js"; -import type { TuiDataProvider } from "../provider.js"; -import { - buildPluginPaletteItems, - getBuiltInPaletteActionRegistryEntries, -} from "./command-palette.js"; +// src/tui/components/command-palette.test.tsx +import { describe, expect, test } from "bun:test"; +import type { Action, ActionContext } from "../actions/types.js"; +import { computeVisibleActions } from "../actions/visibility.js"; +import { fuzzyMatch } from "./command-palette.js"; -function providerStub(): TuiDataProvider { +function ctx(overrides: Partial = {}): ActionContext { return { - capabilities: { - outcomes: false, - artifacts: false, - vfs: false, - messaging: false, - costTracking: false, - askUser: false, - github: false, - bounties: false, - gossip: false, - goals: false, - sessions: false, - handoffs: false, - }, - getDashboard: async () => { - throw new Error("getDashboard not used"); - }, - getContributions: async () => [], - getContribution: async () => undefined, - getClaims: async () => [], - getFrontier: async () => ({ - byMetric: {}, - byAdoption: [], - byRecency: [], - byReviewScore: [], - byReproduction: [], - }), - getActivity: async () => [], - getDag: async () => ({ contributions: [] }), - getHotThreads: async () => [], - close: () => undefined, - }; -} - -function context(): TuiPluginContext { - return { - provider: providerStub(), - density: "compact", + sessions: [], + profiles: [], + gossipPeers: [], + claims: [], + pendingQuestionCount: 0, + hasGoals: false, + canSpawn: false, + canDelegate: false, + isPanelVisible: () => false, + focusPanel: () => undefined, + togglePanel: () => undefined, + cyclePanelNext: () => undefined, + cyclePanelPrev: () => undefined, + openContribution: () => undefined, + jumpToSession: () => undefined, + enterGoalMode: () => undefined, + enterCompareMode: () => undefined, + addToCompare: () => undefined, + adoptContribution: () => undefined, + answerPendingQuestion: () => undefined, + registerAgentProfile: () => undefined, + spawn: () => undefined, + kill: () => undefined, + delegate: () => undefined, + focusedPanel: 1, + frontierSliceCount: 1, + broadcastMessage: () => undefined, + directMessage: () => undefined, + refresh: () => undefined, + enterSearch: () => undefined, + cycleZoom: () => undefined, + resetZoom: () => undefined, + toggleLayout: () => undefined, + cycleViewMode: () => undefined, + showHelp: () => undefined, + quit: () => undefined, + nextFrontierSlice: () => undefined, + prevFrontierSlice: () => undefined, + scrollTerminalToBottom: () => undefined, showMessage: () => undefined, - }; -} - -function action(overrides: Partial = {}): TuiActionRegistration { - return { - id: "audit-refresh", - label: "Refresh audit panel", - detail: "audit", - run: () => undefined, ...overrides, }; } +function act(o: Partial & Pick): Action { + return { label: o.id, detail: "", run: () => undefined, ...o }; +} -describe("plugin palette items", () => { - test("includes fixed built-in action IDs for duplicate protection", () => { - expect(getBuiltInPaletteActionRegistryEntries().map((entry) => entry.id)).toEqual([ - "set-goal", - "register-agent", - ]); - }); - - test("projects enabled plugin actions into palette items", () => { - const refresh = action(); - const merged = mergeTuiActionRegistrations({ - builtIns: getBuiltInPaletteActionRegistryEntries(), - plugins: [refresh], - }); - - const items = buildPluginPaletteItems(merged.entries, context()); - - expect( - items.map((item) => [item.kind, item.id, item.label, item.detail, item.enabled]), - ).toEqual([["plugin-action", "audit-refresh", "Refresh audit panel", "audit", true]]); - expect(items[0]?.pluginAction).toBe(refresh); - }); - - test("projects disabled plugin actions as non-executable palette items", () => { - const refresh = action({ enabled: () => false }); - const merged = mergeTuiActionRegistrations({ - builtIns: getBuiltInPaletteActionRegistryEntries(), - plugins: [refresh], - }); - - const items = buildPluginPaletteItems(merged.entries, context()); - - expect(items[0]?.enabled).toBe(false); +describe("command palette model", () => { + test("fuzzyMatch still scores word-boundary bonuses", () => { + expect(fuzzyMatch("ft", "Focus Terminal").match).toBe(true); + expect(fuzzyMatch("zzz", "Focus Terminal").match).toBe(false); }); - test("evaluates enabled predicate with the plugin context", () => { - const enabled = mock((ctx: TuiPluginContext) => ctx.density === "compact"); - const refresh = action({ enabled }); - const merged = mergeTuiActionRegistrations({ - builtIns: getBuiltInPaletteActionRegistryEntries(), - plugins: [refresh], - }); - - const items = buildPluginPaletteItems(merged.entries, context()); - - expect(items[0]?.enabled).toBe(true); - expect(enabled).toHaveBeenCalledTimes(1); + test("visible list is the flat selection index space", () => { + const actions = [ + act({ id: "n1", group: "Navigation", label: "nav" }), + act({ id: "a1", group: "Agents", label: "agent" }), + ]; + const visible = computeVisibleActions(actions, ctx(), ""); + expect(visible.map((v) => v.action.id)).toEqual(["n1", "a1"]); }); }); diff --git a/src/tui/components/command-palette.tsx b/src/tui/components/command-palette.tsx index ff58b9fa..a49ce475 100644 --- a/src/tui/components/command-palette.tsx +++ b/src/tui/components/command-palette.tsx @@ -1,77 +1,33 @@ /** * Command palette overlay for the TUI. * - * Activated via Ctrl+P, displays an interactive selectable list of - * spawn (role) and kill (session) actions. The parent drives navigation - * via the `selectedIndex` prop; Enter confirms the selected action. - * - * Supports fuzzy filtering: items matching the query are ranked by score - * and matched characters are highlighted in bold. + * Renders the unified Action model. With no query, actions are shown grouped by + * section (Navigation, Agents, Workflow, Contributions, Plugins). With a query, + * group headers are hidden and a single fuzzy-ranked list is shown. The parent + * drives selection via `selectedIndex` over the flat `computeVisibleActions` + * list; Enter executes the selected action. */ import React, { useMemo } from "react"; -import type { Claim } from "../../core/models.js"; -import type { AgentTopology } from "../../core/topology.js"; -import { checkSpawn } from "../agents/spawn-validator.js"; -import type { TmuxManager } from "../agents/tmux-manager.js"; -import type { TuiActionRegistryEntry } from "../plugins/registry.js"; -import type { TuiActionRegistration, TuiPluginContext } from "../plugins/types.js"; +import type { Action, ActionContext, ActionGroup } from "../actions/types.js"; +import { computeVisibleActions } from "../actions/visibility.js"; import { theme } from "../theme.js"; -// --------------------------------------------------------------------------- -// Fuzzy match -// --------------------------------------------------------------------------- - -/** Result of a fuzzy match attempt. */ -interface FuzzyResult { - readonly match: boolean; - readonly score: number; - /** Indices in `text` that matched pattern characters. */ - readonly matchedIndices: readonly number[]; -} +// Re-exported for consumers that historically imported it from here. The +// implementation lives in actions/fuzzy.ts so visibility.ts and this component +// can both depend on it without a circular import. +export { fuzzyMatch } from "../actions/fuzzy.js"; -/** - * Fuzzy-match `pattern` against `text`. - * - * Scoring: - * +2 for a match at position 0, or after a space / '/' - * +1 for any other matching character - */ -export function fuzzyMatch(pattern: string, text: string): FuzzyResult { - if (!pattern) return { match: true, score: 0, matchedIndices: [] }; - const lower = text.toLowerCase(); - const pat = pattern.toLowerCase(); - let pi = 0; - let score = 0; - const matchedIndices: number[] = []; - for (let i = 0; i < lower.length && pi < pat.length; i++) { - if (lower[i] === pat[pi]) { - const bonus = i === 0 || lower[i - 1] === " " || lower[i - 1] === "/" ? 2 : 1; - score += bonus; - matchedIndices.push(i); - pi++; - } - } - return { match: pi === pat.length, score, matchedIndices }; -} - -/** - * Render a label string with matched character indices bolded. - * Returns an array of React text nodes. - */ function renderHighlighted( label: string, matchedIndices: readonly number[], baseColor: string, ): React.ReactNode { - if (matchedIndices.length === 0) { - return {label}; - } + if (matchedIndices.length === 0) return {label}; const indexSet = new Set(matchedIndices); const segments: React.ReactNode[] = []; let run = ""; let runHighlighted = false; - const flush = (highlighted: boolean, key: string) => { if (!run) return; segments.push( @@ -87,7 +43,6 @@ function renderHighlighted( ); run = ""; }; - for (let i = 0; i < label.length; i++) { const h = indexSet.has(i); if (h !== runHighlighted) { @@ -97,307 +52,43 @@ function renderHighlighted( run += label[i]; } flush(runHighlighted, "end"); - return {segments}; } -/** A single actionable entry in the palette. */ -export interface PaletteItem { - readonly kind: "spawn" | "kill" | "register" | "delegate" | "goal" | "plugin-action"; - /** For spawn: role name. For kill: session name. For delegate: peer address. For plugin-action: action id. */ - readonly id: string; - readonly label: string; - readonly enabled: boolean; - readonly detail: string; - readonly pluginAction?: TuiActionRegistration | undefined; -} - -/** Props for the CommandPalette component. */ export interface CommandPaletteProps { readonly visible: boolean; - readonly tmux?: TmuxManager | undefined; - readonly onClose: () => void; - readonly onSpawn?: ((agentId: string, command: string, target: string) => void) | undefined; - readonly onKill?: ((sessionName: string) => void) | undefined; - readonly topology?: AgentTopology | undefined; - readonly activeClaims?: readonly Claim[] | undefined; - /** Active spawn counts per role — for capacity checks without auto-claims. */ - readonly activeSpawnCounts?: ReadonlyMap | undefined; - /** Index of the currently selected palette item (driven by parent). */ - readonly selectedIndex?: number | undefined; - /** Live tmux sessions for kill actions. */ - readonly sessions?: readonly string[] | undefined; - /** Parent agent ID for lineage-aware capacity display. */ - readonly parentAgentId?: string | undefined; - /** Gossip peers with free agent capacity for delegation. */ - readonly gossipPeers?: - | readonly { peerId: string; address: string; freeSlots: number }[] - | undefined; - /** Pre-built palette items from parent (single source of truth). */ - readonly items?: readonly PaletteItem[] | undefined; - /** Current fuzzy filter query (controlled by parent). */ + readonly actions: readonly Action[]; + readonly ctx: ActionContext; readonly query?: string | undefined; - /** When set, palette is being opened to adopt a contribution. */ + readonly selectedIndex?: number | undefined; readonly adoptContext?: { readonly targetCid: string; readonly summary: string } | undefined; } -/** An agent profile loaded from .grove/agents.json. */ -export interface LoadedProfile { - readonly name: string; - readonly role: string; - readonly platform: string; - readonly command?: string | undefined; -} - -const BUILT_IN_PALETTE_ACTIONS: readonly TuiActionRegistryEntry[] = Object.freeze([ - Object.freeze({ - id: "set-goal", - label: "Set goal", - detail: "Set or update the session goal for all agents", - order: 0, - source: "builtin" as const, - builtInAction: "goal", - }), - Object.freeze({ - id: "register-agent", - label: "[r] Register new agent profile", - detail: "agents.json", - order: 10, - source: "builtin" as const, - builtInAction: "register", - }), -]); - -export function getBuiltInPaletteActionRegistryEntries(): readonly TuiActionRegistryEntry[] { - return BUILT_IN_PALETTE_ACTIONS; -} - -export function buildPluginPaletteItems( - entries: readonly TuiActionRegistryEntry[], - context: TuiPluginContext, -): readonly PaletteItem[] { - const items: PaletteItem[] = []; - for (const entry of entries) { - if (entry.source !== "plugin" || entry.registration === undefined) continue; - const enabled = entry.registration.enabled?.(context) ?? true; - items.push({ - kind: "plugin-action", - id: entry.id, - label: entry.label, - detail: entry.detail, - enabled, - pluginAction: entry.registration, - }); - } - return Object.freeze(items); -} - -/** Build the unified list of palette items from topology roles and tmux sessions. */ -export function buildPaletteItems( - topology: AgentTopology | undefined, - activeClaims: readonly Claim[], - sessions: readonly string[], - hasSpawnRuntime: boolean, - hasSpawn: boolean, - hasKill: boolean, - parentAgentId?: string | undefined, - gossipPeers?: readonly { peerId: string; address: string; freeSlots: number }[] | undefined, - agentProfiles?: readonly LoadedProfile[] | undefined, - hasGoals?: boolean | undefined, - activeSpawnCounts?: ReadonlyMap | undefined, -): readonly PaletteItem[] { - const items: PaletteItem[] = []; - - // Goal management — only when provider supports goals - if (hasGoals) { - items.push({ - label: "Set goal", - detail: "Set or update the session goal for all agents", - kind: "goal", - id: "set-goal", - enabled: true, - }); - } - - // Register item — always available at the top - items.push({ - kind: "register" as const, - id: "register-agent", - label: "[r] Register new agent profile", - enabled: true, - detail: "agents.json", - }); - - // Spawn items from registered profiles (take precedence over raw topology roles) - const profileRoles = new Set(); - if (agentProfiles && agentProfiles.length > 0 && hasSpawnRuntime && hasSpawn) { - for (const profile of agentProfiles) { - profileRoles.add(profile.role); - const check = topology - ? checkSpawn(topology, profile.role, activeClaims, parentAgentId, activeSpawnCounts) - : { allowed: true, currentInstances: 0 }; - const max = - "maxInstances" in check && check.maxInstances !== undefined - ? String(check.maxInstances) - : "\u221E"; - const suffix = !check.allowed ? " (at capacity)" : ""; - const roleEdges = topology?.roles.find((r) => r.name === profile.role)?.edges; - const edgeSuffix = - roleEdges && roleEdges.length > 0 ? ` → ${roleEdges.map((e) => e.target).join(", ")}` : ""; - items.push({ - kind: "spawn", - id: profile.role, - label: `spawn: ${profile.name} [${profile.platform}]`, - enabled: check.allowed, - detail: `${check.currentInstances}/${max}${suffix}${edgeSuffix}`, - }); - } - } - - // Spawn items from topology roles (only those not already covered by profiles) - if (topology && hasSpawnRuntime && hasSpawn) { - for (const role of topology.roles) { - if (profileRoles.has(role.name)) continue; - const check = checkSpawn(topology, role.name, activeClaims, parentAgentId, activeSpawnCounts); - const max = check.maxInstances !== undefined ? String(check.maxInstances) : "\u221E"; - const suffix = !check.allowed ? " (at capacity)" : ""; - const edgeSuffix = - role.edges && role.edges.length > 0 - ? ` → ${role.edges.map((e) => e.target).join(", ")}` - : ""; - items.push({ - kind: "spawn", - id: role.name, - label: `spawn: ${role.name}`, - enabled: check.allowed, - detail: `${check.currentInstances}/${max}${suffix}${edgeSuffix}`, - }); - } - } - - // Kill items from live tmux sessions - if (hasSpawnRuntime && hasKill && sessions.length > 0) { - for (const session of sessions) { - items.push({ - kind: "kill", - id: session, - label: `kill: ${session}`, - enabled: true, - detail: "running", - }); - } - } - - // Delegate items from gossip peers with free capacity - if (gossipPeers) { - for (const peer of gossipPeers) { - if (peer.freeSlots > 0) { - items.push({ - kind: "delegate" as const, - id: peer.address, - label: `[d] Delegate to ${peer.peerId} (${peer.freeSlots} free)`, - enabled: true, - detail: `${peer.freeSlots} slots`, - }); - } - } - } - - return items; -} - -/** A palette item augmented with fuzzy match metadata for rendering. */ -interface RankedItem { - readonly item: PaletteItem; - readonly matchedIndices: readonly number[]; - /** Original index in the unfiltered list (stable key). */ - readonly originalIndex: number; -} - -/** Ctrl+P command palette overlay with interactive selection and fuzzy search. */ export const CommandPalette: React.NamedExoticComponent = React.memo( function CommandPalette({ visible, - tmux, - onClose, - onSpawn, - onKill, - topology, - activeClaims, - selectedIndex, - sessions, - parentAgentId, - gossipPeers, - activeSpawnCounts: _activeSpawnCounts, - items: externalItems, + actions, + ctx, query, + selectedIndex, adoptContext, }: CommandPaletteProps): React.ReactNode { - const hasSpawnRuntime = tmux !== undefined || onSpawn !== undefined; + const q = (query ?? "").trim(); + const visibleActions = useMemo(() => computeVisibleActions(actions, ctx, q), [actions, ctx, q]); - // Suppress unused-variable lint — onClose/onSpawn/onKill are invoked by - // the parent keyboard handler, not directly by this presentational component. - void onClose; - void onSpawn; - void onKill; - - // Use parent-provided items (single source of truth) or build internally as fallback - const internalItems = useMemo( - () => - buildPaletteItems( - topology, - activeClaims ?? [], - sessions ?? [], - hasSpawnRuntime, - onSpawn !== undefined, - onKill !== undefined, - parentAgentId, - gossipPeers, - ), - [ - topology, - activeClaims, - sessions, - hasSpawnRuntime, - onSpawn, - onKill, - parentAgentId, - gossipPeers, - ], - ); - const allItems = externalItems ?? internalItems; + if (!visible) return null; + const idx = selectedIndex ?? 0; - // Apply fuzzy filtering and sort by score (highest first) when query is set - const rankedItems = useMemo((): readonly RankedItem[] => { - const q = query?.trim() ?? ""; - if (!q) { - return allItems.map((item, i) => ({ item, matchedIndices: [], originalIndex: i })); - } - const ranked: Array = []; - for (let i = 0; i < allItems.length; i++) { - const item = allItems[i]; - if (!item) continue; - const result = fuzzyMatch(q, item.label); - if (result.match) { - ranked.push({ - item, - matchedIndices: result.matchedIndices, - originalIndex: i, - score: result.score, - }); - } + // When no query, compute the group header to print before each item. + const headerBefore: (ActionGroup | undefined)[] = []; + if (!q) { + let lastGroup: ActionGroup | undefined; + for (const { action } of visibleActions) { + headerBefore.push(action.group !== lastGroup ? action.group : undefined); + lastGroup = action.group; } - ranked.sort((a, b) => b.score - a.score); - return ranked; - }, [allItems, query]); - - if (!visible) { - return null; } - const idx = selectedIndex ?? 0; - const q = query?.trim() ?? ""; - return ( @@ -413,20 +104,18 @@ export const CommandPalette: React.NamedExoticComponent = R {q ? {q} : null} - {rankedItems.length === 0 && ( + {visibleActions.length === 0 && ( - {q - ? `No matches for "${q}"` - : `No actions available${!hasSpawnRuntime ? " (no agent runtime detected)" : ""}`} + {q ? `No matches for "${q}"` : "No actions available"} )} - {rankedItems.map(({ item, matchedIndices, originalIndex }, i) => { + {visibleActions.map(({ action, matchedIndices }, i) => { const isSelected = i === idx; - const dimmed = !item.enabled; + const dimmed = !(action.enabled?.(ctx) ?? true); const labelColor = isSelected ? theme.focus : dimmed ? theme.disabled : theme.text; const detailColor = isSelected ? theme.focus @@ -434,15 +123,24 @@ export const CommandPalette: React.NamedExoticComponent = R ? theme.inactive : theme.secondary; const cursor = isSelected ? "> " : " "; + const group = !q ? headerBefore[i] : undefined; return ( - - {cursor} - {q && matchedIndices.length > 0 ? ( - renderHighlighted(item.label, matchedIndices, labelColor) - ) : ( - {item.label} - )} - [{item.detail}] + // biome-ignore lint/suspicious/noArrayIndexKey: index disambiguates the stable action.id + + {group ? ( + + {group} + + ) : null} + + {cursor} + {q && matchedIndices.length > 0 ? ( + renderHighlighted(action.label, matchedIndices, labelColor) + ) : ( + {action.label} + )} + {action.detail ? [{action.detail}] : null} + ); })}