From fa3d5f114269dc032dbdc1680ee43835c365cac1 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 13:20:28 -0700 Subject: [PATCH 01/22] docs(tui): #275 unified action registry design spec --- ...26-05-29-tui-275-action-registry-design.md | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-tui-275-action-registry-design.md diff --git a/docs/superpowers/specs/2026-05-29-tui-275-action-registry-design.md b/docs/superpowers/specs/2026-05-29-tui-275-action-registry-design.md new file mode 100644 index 00000000..7612807e --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-tui-275-action-registry-design.md @@ -0,0 +1,376 @@ +# Unified Action Registry (#275) + +**Status:** Approved design — ready for implementation plan +**Issue:** https://github.com/windoliver/grove/issues/275 +**Date:** 2026-05-29 + +## Problem + +Keybinds, palette entries, and slash commands are wired independently. Adding a +user-invocable action means touching multiple places, discoverability is uneven, +and there is no uniform way to say "this action is disabled in this context with +this reason." + +#194 (merged, PR #461) already unified the **command palette** behind a single +`Action` model (`src/tui/actions/types.ts`), and #186 (merged) added a leader-key +keymap with `.grove/keybindings.json` overrides (`src/tui/keymap/keymap.ts`). But: + +- The **keybinding resolver still dispatches through a parallel 50-case + `executeKeymapAction` switch** keyed on `TuiActionId` — it does not go through + `Action.run`. Remapping a key does not update palette help, and keybinds are + effectively a second action system. +- There is **no persistent registry API** (`register/list/byId/search`); actions + are rebuilt every render via `buildBuiltInActions(ctx)`, so nothing can resolve + an action by id (the keymap can't look one up). +- **Slash commands do not exist.** +- **MCP-exposed prompts and skills are not surfaced as actions** (Grove's MCP + server exposes tools only; skills have no enumeration API). + +#275 makes one registry the single input to the palette, slash surfaces, and the +leader-chord resolver, with MCP prompts and skills plugged in as additional +sources. + +## Goals (acceptance criteria) + +- A single `ActionRegistry` is the only input to: command palette, slash + surfaces, and the keyboard leader-chord resolver. +- The keybinding resolver dispatches through `Action.run` via `registry.byId`; + the `executeKeymapAction` switch is removed. +- Remapping a key in `keybindings.json` updates both dispatch and the palette + keybind column (single binding source of truth = the keymap). +- Disabled actions render greyed with a `reason` in the palette footer. +- Slash commands work via two surfaces: `/` opens the palette pre-filtered to + slash-bearing actions, and a dedicated command-line input parses `/ args`. +- A 2-second leader-chord modal window: after the leader key, focus blurs, the + follow-up key is captured, and a pending-chord overlay lists candidate + continuations. +- MCP-exposed prompts (defined by Grove's own MCP server) appear as actions. +- Skills appear as actions, scoped to the selected agent slot's role skill set. + +## Decisions (locked during brainstorming) + +1. **Delivery:** one cohesive design + one implementation plan delivered as a + single (large) PR. The plan **sequences work internally** P1 backbone → P2 + slash → P3 prompts → P4 skills so each phase is independently testable. + (Acknowledged risk: ~20 files across TUI + MCP server + core; big-bang is the + hardest to review — mitigated by the internal phase sequencing and TDD.) +2. **Registry architecture (Approach A):** a persistent registry of **static + action descriptors** plus registered **dynamic sources** (`(ctx) => Action[]` + generators) for per-entity actions. `list/byId/search` expand sources at call + time. MCP prompts and skills are additional dynamic sources. This is the only + model where palette, slash, keybind, prompts, and skills all flow through one + registry. +3. **Binding source of truth:** the keymap file (`keymap.ts` defaults + + `.grove/keybindings.json` overrides) remains authoritative for keybinds. The + registry's `keybind` field is **filled from the resolved keymap**, never + authored on the descriptor. +4. **Leader key stays `space`** (the #186 default), configurable via + `keybindings.json`. The issue's suggested `ctrl+x` is **not** adopted — it + would break shipped muscle memory. +5. **MCP prompts source:** Grove's own MCP server defines named prompts from the + repo `prompts/` directory (self-contained; no generic external MCP client). +6. **Skills source:** bundled `skills/grove/*` + topology-declared role skills, + enumerated via a new provider method. "Scoped to slot" = gated/ordered by the + selected agent slot's role skill set; invoked via existing + `grove_request_skill`. +7. **Slash surfaces:** both — `/` filtered palette (discovery / no-arg) and a + dedicated command-line input (power / args). + +## Out of scope (YAGNI) + +- Redesigning inline docks for agent-originated interactions (#193 — already + merged). We register the permission/question/todo/followup/revert actions so + docks *can* consume the registry, but do not change dock UI here. +- A generic MCP client that enumerates prompts from arbitrary external servers + (decision 5 keeps it to Grove's own server). +- Nexus skill-catalog enumeration (decision 6 keeps it to bundled + topology). +- Plugin distribution mechanism (#189). +- Redesigning the DAG view. + +--- + +## Architecture + +### 1. Action model extensions — `src/tui/actions/types.ts` + +Keep #194's existing fields (`id`, `label`, `detail`, `group`, `keywords`, +`available`, `enabled`, `run`) to avoid churn. **Add:** + +```ts +export interface Action { + // ... existing #194 fields unchanged ... + /** Slash trigger, e.g. "/cancel". Source of truth for the slash surfaces. */ + readonly slash?: string | undefined; + /** Palette shows suggested actions first when the filter is empty. */ + readonly suggested?: boolean | undefined; + /** + * Filled by the registry from the resolved keymap — NOT authored here. + * The registry annotates each Action returned from list()/byId()/search(). + */ + readonly keybind?: string | undefined; + /** + * Capability gate. Extended from #194's boolean to optionally carry a reason + * shown in the palette footer when greyed. Boolean returns remain valid. + */ + readonly enabled?: + | ((ctx: ActionContext) => boolean | { enabled: boolean; reason?: string }) + | undefined; + /** May now receive parsed slash args (Surface 2). Optional, back-compat. */ + readonly run: (ctx: ActionContext, args?: readonly string[]) => void | Promise; +} + +export type ActionGroup = + | "Navigation" | "Agents" | "Workflow" | "View" + | "Contributions" | "Prompts" | "Skills" | "Plugins"; +``` + +`GROUP_ORDER` gains `"Prompts"` and `"Skills"` (before `"Plugins"`). A +`resolveEnabled(action, ctx): { enabled: boolean; reason?: string }` helper +normalizes the boolean | object union for callers (palette + keybind dispatch). + +`available` (hidden) vs `enabled` (greyed) distinction from #194 is preserved. + +### 2. Registry — `src/tui/actions/registry.ts` (new) + +```ts +export type DynamicSource = (ctx: ActionContext) => readonly Action[]; + +export interface ActionRegistry { + register(action: Action): void; + registerDynamic(idPrefix: string, source: DynamicSource): void; + /** id → keybind string, from the resolved keymap (+ overrides). */ + setBindings(bindings: ReadonlyMap): void; + /** available-filtered; suggested-first then GROUP_ORDER; keybind/slash merged. */ + list(ctx: ActionContext): readonly Action[]; + byId(id: string, ctx: ActionContext): Action | undefined; + /** fuzzy (reuse fuzzyMatch) over list(); matches label + keywords + slash. */ + search(query: string, ctx: ActionContext): readonly Action[]; +} + +export function createActionRegistry(): ActionRegistry; +``` + +- **Pure, no React.** Constructed once at app root (a `useMemo` with empty deps, + or a module-level factory invoked once). +- **Static actions** registered via `register`. **Per-entity actions** + (`spawn:`, `kill:`, `jump:`, `delegate:`) are + registered as **dynamic sources** wrapping today's builder logic from + `builtin-actions.ts`. The registry object is therefore stable across renders; + expansion happens at `list/search/byId` time from live `ctx`. +- **`byId`:** exact match for static actions; for dynamic ids, match the + registered `idPrefix` then locate the entry within `source(ctx)`. +- **Keybind/slash merge:** `list/byId/search` annotate each returned Action with + `keybind` from the `setBindings` map (`slash` already lives on the descriptor). +- **Ordering:** when listed with no query, `suggested:true` first, then by + `GROUP_ORDER`, then declaration order; with a query, fuzzy rank (flat). + +### 3. Keybind → Action bridge — `keymap.ts`, `use-keyboard-handler.ts` + +Today a `KeyBinding` carries `action: TuiActionId` and is dispatched through the +`executeKeymapAction` switch (~50 cases). Change: + +- A binding's `action` field becomes the registry **action id** (a string). A + one-time **migration table** maps each existing `TuiActionId` → its action id + (most map 1:1 to existing built-in action ids; panel-target bindings map to + `nav.focus.` ids). +- `keymap.ts` exports `resolvedKeymapBindings(resolved): ReadonlyMap` + (id → human keybind string) used to feed `registry.setBindings`. +- Dispatch in `use-keyboard-handler.ts` collapses to: + + ```ts + const action = registry.byId(binding.action, ctx); + if (!action) return; // unknown id (e.g. stale override) — no-op + const { enabled } = resolveEnabled(action, ctx); + if (!enabled) return; + void Promise.resolve(action.run(ctx)).catch(showError); + ``` + +- **`executeKeymapAction` is deleted.** Effect parity is asserted by tests (each + old `TuiActionId` → action id runs the same observable effect). +- Because `setBindings` is fed from the resolved keymap (including overrides), + remapping a key updates both dispatch and the palette keybind column from the + one source. + +### 4. Leader-chord modal — `use-keyboard-handler.ts` + overlay + +Leader stays `space`. Today only a pending prefix (`keymapPrefix`) is tracked and +`resolveKeyBinding` returns `pending`. Add: + +- **2-second window:** a timer armed whenever the prefix is non-empty, reset on + each follow-up key, expiry clears the prefix (chord cancelled). The timer lives + in app state / a ref; clears on resolve or `Esc`. +- **Focus blur / capture:** while a chord is pending, `routeKey` intercepts *all* + keys to the resolver before any focused-panel input handler runs. +- **Pending-chord overlay** — `src/tui/components/leader-overlay.tsx` (new) or a + status-bar extension: shows the current prefix and candidate continuations, + derived from bindings whose `sequence` starts with the prefix, each labelled + from its action (`space → p: palette · t: terminal · ? : help`). Doubles as + discoverability. Reuses the resolved keymap, so it reflects overrides. + +### 5. Slash surfaces — `command-palette.tsx`, `slash-input.tsx` (new) + +`slash` on descriptors is the source of truth. A registry-derived **slash index** +(`slash string → action id`) backs resolution. + +- **Surface 1 — `/` opens palette pre-filtered:** pressing `/` in Normal mode + opens the palette in "slash mode" — only actions with a `slash` are shown, and + the query is seeded after the `/`. Reuses all palette machinery (grouping, + fuzzy, flat index, Enter-to-run). +- **Surface 2 — command-line input:** a new `InputMode.SlashCommand` renders a + bottom input line. It parses `/ `, resolves `cmd` via the slash + index, and calls `action.run(ctx, args)`. This is why `run` gained the optional + `args` param. Arg-bearing actions (e.g. `/spawn reviewer`) read `args`; others + ignore it. Unknown `/cmd` → footer error; the input stays open for correction. + +### 6. MCP-prompt backend — `src/mcp/server.ts`, `src/mcp/prompts.ts` (new), provider + +- **Grove MCP server** gains `prompts: {}` capability and registers prompts from + the repo `prompts/` directory: `src/mcp/prompts.ts` loads each file as a named + prompt (`name` = file stem, content = template), and `server.registerPrompt` + wires standard `prompts/list` + `prompts/get`. +- **Provider:** additive `TuiPromptProvider { listMcpPrompts(): Promise }` + `capabilities.prompts`. `NexusDataProvider` implements it + against the Grove MCP server; `LocalDataProvider` reads `prompts/` directly. + `PromptInfo = { name; description?; arguments?: readonly PromptArg[] }`. +- **ActionContext** gains `mcpPrompts?: readonly PromptInfo[]` (fetched while the + palette/slash surface is open, mirroring the `pendingQuestionCount` fetcher + pattern) and a capability `runPrompt(name, session, args?)`. +- **Dynamic source `prompt.*`** → group `"Prompts"`; `id` = `prompt.`; + `slash` = `/prompt:` (optional); `available` when a session is selected; + `run(ctx, args)` = render the template (with args) and deliver it to the + selected agent through `runPrompt`, which routes via the provider's existing + agent-message path (ACP / Nexus IPC) — **never** tmux send-keys. +- **Honest scope:** "invoking a prompt" = stage/send the template to the selected + agent. Parameterized prompts take their arguments via Surface 2. + +### 7. Skill backend — provider, `core/runtime-skill-acquisition.ts` + +- **Provider:** additive `TuiSkillProvider { listAvailableSkills(): Promise }` + `capabilities.skills`. Enumerates bundled `skills/grove/*` + plus skills referenced across topology roles. `SkillInfo = { name; description?; + roles?: readonly string[] }`. +- **Core:** `runtime-skill-acquisition.ts` gains a `listAvailableSkills()` that + enumerates bundled + known skills (the inverse of the existing + `requestSkill(name)` resolve path). +- **ActionContext** gains `availableSkills?: readonly SkillInfo[]`, + `selectedAgentRole?` (derived from `selectedSession` → topology role), and a + capability `requestSkill(name, session)` (calls `grove_request_skill`). +- **Dynamic source `skill.request.*`** → group `"Skills"`; `id` = + `skill.request.`; `slash` = `/skill ` (resolved via Surface 2); + **scoped to slot** = `available`/ordering gated to the selected agent slot's + role skill set; `run` = `requestSkill(name, selectedSession)`. + +### 8. Inline docks (#193) — light touch + +Register the agent-interaction actions (permission / question / todo / followup / +revert) in the registry so docks can consume the same source, but do **not** +redesign dock UI here. Deferred to a #193 follow-up. + +### 9. Wiring — `src/tui/app.tsx` + +- Build the registry once at root. Register: static actions (from refactored + `builtin-actions.ts`), dynamic per-entity sources, the plugin adapter source + (existing), and the prompt + skill sources. +- Call `registry.setBindings(resolvedKeymapBindings(resolvedKeymap))`; refresh + when overrides change (existing `useKeybindingOverrides` hook). +- Palette consumes `registry.list(ctx)` / `registry.search(query, ctx)` instead + of the `buildBuiltInActions(...)` + plugin concat (that concat logic moves into + registration). +- Keymap dispatch routes through `registry.byId` + `run` (section 3). +- Add `InputMode.SlashCommand` and the `/` handlers (section 5). + +## Data flow + +``` +keymap.ts defaults + .grove/keybindings.json + │ resolveKeymap(+overrides) + ▼ +resolvedKeymapBindings (id → keybind) ──► registry.setBindings + │ +static actions ──► register ───────────────────┤ +per-entity builders ──► registerDynamic ───────┤ +plugin adapter ──► registerDynamic ────────────┤ +prompt source (provider.listMcpPrompts) ───────┤ +skill source (provider.listAvailableSkills) ───┤ + ▼ + ActionRegistry + ┌────────────────┼─────────────────┐ + list/search byId(id,ctx) slash index + │ │ │ + Palette Keymap dispatch Slash input + (+ keybind col, (run after (/cmd args → + reason footer) enabled check) run(ctx,args)) +``` + +## Error handling + +- `run` is awaited via `Promise.resolve(...).catch(showError)` — failures surface + in the status bar (existing 5s auto-clear channel). +- Disabled actions (`enabled` → false) never execute; the `reason` shows in the + palette footer. Unavailable actions are absent from the index space, so + selection cannot land on them. +- Unknown `/cmd` (Surface 2) → footer error, input stays open. Stale keymap + override pointing at an unknown id → `byId` returns undefined → no-op. +- Leader-chord timeout → silent prefix reset. `Esc` cancels a pending chord. +- Missing provider capability (`prompts`/`skills`) → the corresponding dynamic + source yields `[]` (graceful degradation; no error). + +## Testing + +Targeted TDD runs with a temp `bunfig.toml` (`coverage = false`) per the repo +convention; full-suite/typecheck/check use the repo config. + +- **`registry.test.ts`** — register/list/byId/search; dynamic-source expansion; + `available` filtering; suggested-first + `GROUP_ORDER` sort; keybind/slash + merge from `setBindings`; dynamic `byId`-by-prefix; `resolveEnabled` union. +- **Keybind bridge** — each migrated `TuiActionId` → action id runs the same + observable effect (parity); remap via overrides reflects in `list().keybind`; + unknown id → no-op. +- **Leader modal** — pending prefix capture; 2s timer reset/expiry; candidate + continuation list derived from bindings; `Esc` cancel; focus capture (panel + input does not see keys while pending). +- **Slash** — `/` filters palette to slash-bearing actions; command-line parse + `/cmd a b` → resolve + `run(ctx,["a","b"])`; unknown cmd → footer error. +- **MCP prompts** — `src/mcp/prompts.ts` loads `prompts/` into named prompts; + server `prompts/list` returns them; provider `listMcpPrompts`; `prompt.*` + source produces actions, gated on selected session. +- **Skills** — `listAvailableSkills` enumerates bundled + topology; `skill.request.*` + source `available` gated to selected role (scoped to slot); `run` → + `requestSkill`. +- **Migration** — update existing palette/keymap tests that reference the old + concat path / `executeKeymapAction`. + +## File summary + +| File | Change | +|---|---| +| `src/tui/actions/types.ts` | extend `Action` (`slash`, `suggested`, `keybind`, `enabled` reason union, `run` args); add `Prompts`/`Skills` groups; `resolveEnabled` helper | +| `src/tui/actions/registry.ts` | **new** — `createActionRegistry` (register/registerDynamic/setBindings/list/byId/search) | +| `src/tui/actions/registry.test.ts` | **new** | +| `src/tui/actions/builtin-actions.ts` | refactor builders into static actions + dynamic sources | +| `src/tui/actions/dynamic-sources.ts` | **new** — per-entity + prompt + skill sources (+ tests) | +| `src/tui/keymap/keymap.ts` | binding `action` = registry id; export `resolvedKeymapBindings`; migration table | +| `src/tui/hooks/use-keyboard-handler.ts` | retire `executeKeymapAction`; dispatch via `registry.byId` + `run`; leader 2s modal + focus capture | +| `src/tui/components/command-palette.tsx` | keybind column; disabled `reason` footer; suggested-first; slash mode | +| `src/tui/components/slash-input.tsx` | **new** — `InputMode.SlashCommand` command-line | +| `src/tui/components/leader-overlay.tsx` | **new** — pending-chord overlay | +| `src/tui/provider.ts` | add `TuiPromptProvider`, `TuiSkillProvider`; `capabilities.prompts`/`.skills` | +| `src/tui/nexus-provider.ts` (+ local provider) | implement `listMcpPrompts` / `listAvailableSkills` | +| `src/mcp/server.ts` | `prompts: {}` capability + register prompts from `prompts/` | +| `src/mcp/prompts.ts` | **new** — prompt loader from `prompts/` dir | +| `src/core/runtime-skill-acquisition.ts` | add `listAvailableSkills()` | +| `src/tui/app.tsx` | build/wire registry; `setBindings`; slash modes | +| tests | new registry/source/slash/leader/mcp/skill tests; migrate palette/keymap tests | + +## Implementation phasing (within the single PR) + +1. **P1 — Backbone:** types extensions, `registry.ts`, refactor `builtin-actions` + into static + dynamic sources, keybind→Action bridge (delete + `executeKeymapAction`), leader 2s modal + overlay, palette keybind column + + reason footer. Fully testable without new backends. +2. **P2 — Slash:** `slash` field wiring, slash index, `/` filtered palette, + `slash-input.tsx` command-line, `run(ctx,args)`. +3. **P3 — MCP prompts:** server `prompts` capability + `mcp/prompts.ts`, provider + `listMcpPrompts`, `prompt.*` source. +4. **P4 — Skills:** provider `listAvailableSkills`, core enumeration, + `skill.request.*` source scoped to slot. From 9f46a3e2f94a6ebbf6dee9a27a629340157a5900 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 13:43:48 -0700 Subject: [PATCH 02/22] docs(tui): #275 unified action registry implementation plan --- .../2026-05-29-tui-275-action-registry.md | 2164 +++++++++++++++++ 1 file changed, 2164 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-29-tui-275-action-registry.md diff --git a/docs/superpowers/plans/2026-05-29-tui-275-action-registry.md b/docs/superpowers/plans/2026-05-29-tui-275-action-registry.md new file mode 100644 index 00000000..a05892b4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-tui-275-action-registry.md @@ -0,0 +1,2164 @@ +# Unified Action Registry (#275) 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:** Make a single persistent `ActionRegistry` the only input to the TUI command palette, slash surfaces, and the keyboard leader-chord resolver, with MCP prompts and skills plugged in as additional sources. + +**Architecture:** Approach A — a persistent registry holds static `Action` descriptors plus registered dynamic sources (`(ctx) => Action[]`). `list/byId/search` expand the sources from live `ActionContext` at call time. Keybinds are merged in from the resolved keymap (keymap file stays the binding source of truth). The keymap resolver dispatches through `registry.byId(...).run(ctx)`, retiring the `executeKeymapAction` switch. Slash and MCP-prompt/skill sources are additive layers on the same registry. + +**Tech Stack:** Bun 1.3.x, TypeScript strict mode, React 19 / OpenTUI, `bun:test`, Biome. MCP via `@modelcontextprotocol/sdk` (`McpServer`). + +**Spec:** `docs/superpowers/specs/2026-05-29-tui-275-action-registry-design.md` + +--- + +## Execution Notes + +This repo's `bunfig.toml` enables coverage thresholds globally. For the targeted +TDD commands in this plan, run the listed files with a temporary config that +disables coverage. Wherever a step says **[focused-test] ``**, run: + +```bash +tmpdir=$(mktemp -d /tmp/grove-bunfig.XXXXXX) +tmp="$tmpdir/bunfig.toml" +printf '[test]\ncoverage = false\n' > "$tmp" +PATH="$HOME/.bun/bin:$PATH" bun --config="$tmp" test +rc=$? +rm -rf "$tmpdir" +exit $rc +``` + +For `bun run typecheck`, `bun run check`, and full-suite commands, use the +repository config as written. Run `bun run check` (Biome) before each commit. + +## Phasing + +The plan is one document delivered as one PR, sequenced into 4 internally +independent phases. Each phase ends green and is independently testable: + +- **Phase 1 — Backbone** (Tasks 1–8): registry, dynamic-source refactor, + keybind→Action bridge, leader modal, palette cheatsheet + reason footer. +- **Phase 2 — Slash** (Tasks 9–11): `slash` wiring, `/` filtered palette, + command-line input. +- **Phase 3 — MCP prompts** (Tasks 12–14): Grove server prompts, provider, + `prompt.*` source. +- **Phase 4 — Skills** (Tasks 15–17): skill enumeration, provider, slot-scoped + `skill.request.*` source. + +## File Structure + +| File | Responsibility | +|---|---| +| `src/tui/actions/types.ts` | `Action`/`ActionContext`/`ActionGroup` + `resolveEnabled` | +| `src/tui/actions/registry.ts` | **new** — `createActionRegistry` | +| `src/tui/actions/dynamic-sources.ts` | **new** — per-entity + prompt + skill dynamic sources | +| `src/tui/actions/builtin-actions.ts` | static actions (per-entity logic moves out) | +| `src/tui/actions/register-builtins.ts` | **new** — populate a registry from built-ins + sources | +| `src/tui/actions/visibility.ts` | suggested-first ordering | +| `src/tui/keymap/keymap-action-map.ts` | **new** — binding→registry-id map + `resolvedKeymapBindings` | +| `src/tui/hooks/use-keyboard-handler.ts` | dispatch via registry; leader 2s modal + capture | +| `src/tui/components/command-palette.tsx` | keybind column, reason footer | +| `src/tui/components/leader-overlay.tsx` | **new** — pending-chord overlay | +| `src/tui/components/slash-input.tsx` | **new** — `/cmd args` command-line | +| `src/tui/provider.ts` | `TuiPromptProvider`, `TuiSkillProvider`, capabilities | +| `src/tui/nexus-provider.ts` (+ local) | implement prompt/skill listing | +| `src/mcp/prompts.ts` | **new** — load `prompts/` into named MCP prompts | +| `src/mcp/server.ts` | register prompts + `prompts: {}` capability | +| `src/core/runtime-skill-acquisition.ts` | `listAvailableSkills()` | +| `src/tui/app.tsx` | build/wire registry; slash modes | + +--- + +# Phase 1 — Backbone + +## Task 1: Action model extensions + +**Files:** +- Modify: `src/tui/actions/types.ts` +- Test: `src/tui/actions/types.test.ts` (exists — add cases) + +- [ ] **Step 1: Write the failing test** + +Append to `src/tui/actions/types.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { GROUP_ORDER, resolveEnabled, type Action, type ActionContext } from "./types.js"; + +describe("ActionGroup order", () => { + test("includes Prompts and Skills before Plugins", () => { + expect(GROUP_ORDER).toEqual([ + "Navigation", + "Agents", + "Workflow", + "View", + "Contributions", + "Prompts", + "Skills", + "Plugins", + ]); + }); +}); + +describe("resolveEnabled", () => { + const base = { id: "x", label: "X", detail: "", group: "View", run: () => {} } as const; + const ctx = {} as ActionContext; + + test("undefined enabled → enabled, no reason", () => { + expect(resolveEnabled(base as Action, ctx)).toEqual({ enabled: true }); + }); + test("boolean enabled is normalized", () => { + const a = { ...base, enabled: () => false } as Action; + expect(resolveEnabled(a, ctx)).toEqual({ enabled: false }); + }); + test("object enabled carries a reason", () => { + const a = { ...base, enabled: () => ({ enabled: false, reason: "at capacity" }) } as Action; + expect(resolveEnabled(a, ctx)).toEqual({ enabled: false, reason: "at capacity" }); + }); +}); +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +[focused-test] `src/tui/actions/types.test.ts` +Expected: FAIL — `resolveEnabled` not exported; `GROUP_ORDER` lacks Prompts/Skills. + +- [ ] **Step 3: Implement the extensions** + +In `src/tui/actions/types.ts`, replace the `ActionGroup` union and `GROUP_ORDER`: + +```ts +export type ActionGroup = + | "Navigation" + | "Agents" + | "Workflow" + | "View" + | "Contributions" + | "Prompts" + | "Skills" + | "Plugins"; + +export const GROUP_ORDER: readonly ActionGroup[] = [ + "Navigation", + "Agents", + "Workflow", + "View", + "Contributions", + "Prompts", + "Skills", + "Plugins", +]; +``` + +Replace the `Action` interface's `enabled` and `run` members and add the three +new optional fields (keep all other members unchanged): + +```ts +export interface Action { + readonly id: string; + readonly label: string; + readonly detail: string; + readonly group: ActionGroup; + readonly keywords?: readonly string[] | undefined; + /** Slash trigger, e.g. "/cancel". Source of truth for the slash surfaces. */ + readonly slash?: string | undefined; + /** Palette shows suggested actions first when the filter is empty. */ + readonly suggested?: boolean | undefined; + /** Filled by the registry from the resolved keymap — never authored here. */ + readonly keybind?: string | undefined; + readonly available?: ((ctx: ActionContext) => boolean) | undefined; + /** Capability gate. boolean OR { enabled, reason } for a greyed footer note. */ + readonly enabled?: + | ((ctx: ActionContext) => boolean | { enabled: boolean; reason?: string }) + | undefined; + readonly run: (ctx: ActionContext, args?: readonly string[]) => void | Promise; +} + +/** Normalize the boolean | object `enabled` union to a single shape. */ +export function resolveEnabled( + action: Action, + ctx: ActionContext, +): { enabled: boolean; reason?: string } { + const result = action.enabled?.(ctx); + if (result === undefined) return { enabled: true }; + if (typeof result === "boolean") return { enabled: result }; + return result; +} +``` + +- [ ] **Step 4: Run the focused test to verify it passes** + +[focused-test] `src/tui/actions/types.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `bun run typecheck` +Expected: PASS. (Existing `enabled?.(ctx) ?? true` call sites still compile — +a boolean-returning predicate is still assignable; object returns are new.) + +- [ ] **Step 6: Commit** + +```bash +git add src/tui/actions/types.ts src/tui/actions/types.test.ts +git commit -m "feat(tui): #275 extend Action model (slash, suggested, keybind, enabled reason)" +``` + +--- + +## Task 2: Registry core + +**Files:** +- Create: `src/tui/actions/registry.ts` +- Test: `src/tui/actions/registry.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/tui/actions/registry.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { createActionRegistry } from "./registry.js"; +import type { Action, ActionContext } from "./types.js"; + +const ctx = {} as ActionContext; +const action = (over: Partial & Pick): Action => ({ + label: over.id, + detail: "", + group: "View", + run: () => {}, + ...over, +}); + +describe("ActionRegistry", () => { + test("list returns registered static actions", () => { + const r = createActionRegistry(); + r.register(action({ id: "a", group: "View" })); + r.register(action({ id: "b", group: "Navigation" })); + expect(r.list(ctx).map((x) => x.id)).toEqual(["b", "a"]); // Navigation before View + }); + + test("suggested actions sort first within the no-query list", () => { + const r = createActionRegistry(); + r.register(action({ id: "plain", group: "View" })); + r.register(action({ id: "star", group: "View", suggested: true })); + expect(r.list(ctx).map((x) => x.id)).toEqual(["star", "plain"]); + }); + + test("available=false hides an action from list", () => { + const r = createActionRegistry(); + r.register(action({ id: "hidden", available: () => false })); + r.register(action({ id: "shown" })); + expect(r.list(ctx).map((x) => x.id)).toEqual(["shown"]); + }); + + test("dynamic sources expand at list time", () => { + const r = createActionRegistry(); + r.registerDynamic("kill.", (c) => + (["s1", "s2"] as const).map((s) => action({ id: `kill.${s}`, group: "Agents" })), + ); + expect(r.list(ctx).map((x) => x.id)).toEqual(["kill.s1", "kill.s2"]); + }); + + test("byId resolves static and dynamic-by-prefix", () => { + const r = createActionRegistry(); + r.register(action({ id: "static.one" })); + r.registerDynamic("kill.", () => [action({ id: "kill.s1", group: "Agents" })]); + expect(r.byId("static.one", ctx)?.id).toBe("static.one"); + expect(r.byId("kill.s1", ctx)?.id).toBe("kill.s1"); + expect(r.byId("kill.absent", ctx)).toBeUndefined(); + }); + + test("setBindings annotates keybind on list/byId results", () => { + const r = createActionRegistry(); + r.register(action({ id: "view.quit", group: "View" })); + r.setBindings(new Map([["view.quit", "q"]])); + expect(r.byId("view.quit", ctx)?.keybind).toBe("q"); + expect(r.list(ctx)[0]?.keybind).toBe("q"); + }); + + test("search fuzzy-matches label and slash", () => { + const r = createActionRegistry(); + r.register(action({ id: "view.quit", label: "Quit grove", group: "View" })); + r.register(action({ id: "view.refresh", label: "Refresh", slash: "/reload", group: "View" })); + expect(r.search("quit", ctx).map((x) => x.id)).toEqual(["view.quit"]); + expect(r.search("reload", ctx).map((x) => x.id)).toEqual(["view.refresh"]); + }); +}); +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +[focused-test] `src/tui/actions/registry.test.ts` +Expected: FAIL — `./registry.js` does not exist. + +- [ ] **Step 3: Implement the registry** + +Create `src/tui/actions/registry.ts`: + +```ts +import { fuzzyMatch } from "./fuzzy.js"; +import { computeVisibleActions } from "./visibility.js"; +import type { Action, ActionContext } from "./types.js"; + +export type DynamicSource = (ctx: ActionContext) => readonly Action[]; + +export interface ActionRegistry { + register(action: Action): void; + registerDynamic(idPrefix: string, source: DynamicSource): void; + setBindings(bindings: ReadonlyMap): void; + list(ctx: ActionContext): readonly Action[]; + byId(id: string, ctx: ActionContext): Action | undefined; + search(query: string, ctx: ActionContext): readonly Action[]; +} + +export function createActionRegistry(): ActionRegistry { + const statics: Action[] = []; + const dynamics: { prefix: string; source: DynamicSource }[] = []; + let bindings: ReadonlyMap = new Map(); + + const annotate = (a: Action): Action => { + const keybind = bindings.get(a.id); + return keybind === undefined ? a : { ...a, keybind }; + }; + + const expand = (ctx: ActionContext): Action[] => { + const out: Action[] = [...statics]; + for (const { source } of dynamics) out.push(...source(ctx)); + return out.map(annotate); + }; + + return { + register(action) { + statics.push(action); + }, + registerDynamic(prefix, source) { + dynamics.push({ prefix, source }); + }, + setBindings(next) { + bindings = next; + }, + list(ctx) { + // computeVisibleActions filters `available` and applies suggested-first + + // GROUP_ORDER ordering (Task 7). No-query path returns ordered actions. + return computeVisibleActions(expand(ctx), ctx, "").map((v) => v.action); + }, + byId(id, ctx) { + const direct = statics.find((a) => a.id === id); + if (direct !== undefined) return annotate(direct); + const owner = dynamics.find((d) => id.startsWith(d.prefix)); + if (owner === undefined) return undefined; + const found = owner.source(ctx).find((a) => a.id === id); + return found === undefined ? undefined : annotate(found); + }, + search(query, ctx) { + return computeVisibleActions(expand(ctx), ctx, query).map((v) => v.action); + }, + }; +} + +// Re-export so callers have one import site. +export { fuzzyMatch }; +``` + +- [ ] **Step 4: Run the focused test to verify it passes** + +[focused-test] `src/tui/actions/registry.test.ts` +Expected: PASS. (Requires Task 7's suggested-first ordering for the +`suggested` test — if running Task 2 before Task 7, that one case fails; do Task +7's `visibility.ts` change now if executing strictly in order, OR accept the one +red case until Task 7. Recommended: implement Task 7 Step 3's `visibility.ts` +ordering as part of this step so all cases pass.) + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/actions/registry.ts src/tui/actions/registry.test.ts +git commit -m "feat(tui): #275 persistent ActionRegistry (static + dynamic sources)" +``` + +--- + +## Task 3: Refactor built-ins into static actions + dynamic sources + +**Files:** +- Create: `src/tui/actions/dynamic-sources.ts` +- Create: `src/tui/actions/register-builtins.ts` +- Modify: `src/tui/actions/builtin-actions.ts` +- Test: `src/tui/actions/dynamic-sources.test.ts` +- Test: `src/tui/actions/builtin-actions.test.ts` (exists — keep green) + +The per-entity actions in `builtin-actions.ts` (sessions, spawn roles, kills, +delegates) become dynamic sources; everything else stays static. + +- [ ] **Step 1: Write the failing test** + +Create `src/tui/actions/dynamic-sources.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { sessionNavSource, spawnSource, killSource, delegateSource } from "./dynamic-sources.js"; +import type { ActionContext } from "./types.js"; + +const baseCtx = (over: Partial): ActionContext => + ({ + sessions: [], + profiles: [], + gossipPeers: [], + claims: [], + pendingQuestionCount: 0, + hasGoals: false, + canSpawn: true, + canDelegate: false, + isPanelVisible: () => false, + focusedPanel: 0, + frontierSliceCount: 0, + ...over, + }) as ActionContext; + +describe("dynamic sources", () => { + test("sessionNavSource emits one nav action per session", () => { + const ids = sessionNavSource(baseCtx({ sessions: ["s1", "s2"] })).map((a) => a.id); + expect(ids).toEqual(["nav.session.s1", "nav.session.s2"]); + }); + test("killSource emits one kill action per session", () => { + const ids = killSource(baseCtx({ sessions: ["s1"] })).map((a) => a.id); + expect(ids).toEqual(["agent.kill.s1"]); + }); + test("spawnSource is empty when canSpawn is false", () => { + expect(spawnSource(baseCtx({ canSpawn: false, profiles: [] }))).toEqual([]); + }); + test("delegateSource skips peers with no free slots", () => { + const ctx = baseCtx({ + canDelegate: true, + gossipPeers: [ + { peerId: "p1", address: "a1", freeSlots: 0 }, + { peerId: "p2", address: "a2", freeSlots: 2 }, + ], + }); + expect(delegateSource(ctx).map((a) => a.id)).toEqual(["agent.delegate.a2"]); + }); +}); +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +[focused-test] `src/tui/actions/dynamic-sources.test.ts` +Expected: FAIL — `./dynamic-sources.js` missing. + +- [ ] **Step 3: Create `dynamic-sources.ts`** + +Move the per-entity builders out of `builtin-actions.ts` into +`src/tui/actions/dynamic-sources.ts`. Copy the exact bodies from +`builtin-actions.ts` (sessions loop in `navigationActions`, spawn loops + kill +loop + delegate loop in `agentActions`) into these named sources: + +```ts +import { checkSpawn } from "../agents/spawn-validator.js"; +import type { Action, ActionContext, DynamicSource } from "./types.js"; + +export const sessionNavSource: DynamicSource = (ctx) => + ctx.sessions.map((session) => ({ + id: `nav.session.${session}`, + label: `Jump to session ${session}`, + detail: "session", + group: "Navigation", + keywords: ["session", "agent", "jump"], + run: (c) => c.jumpToSession(session), + })); + +export const spawnSource: DynamicSource = (ctx) => { + const actions: Action[] = []; + if (!ctx.canSpawn) return actions; + const profileRoles = new Set(); + for (const profile of ctx.profiles) { + 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), + }); + } + return actions; +}; + +export const killSource: DynamicSource = (ctx) => + ctx.sessions.map((session) => ({ + id: `agent.kill.${session}`, + label: `Kill ${session}`, + detail: "running", + group: "Agents", + keywords: ["kill", "stop", "agent"], + run: (c) => c.kill(session), + })); + +export const delegateSource: DynamicSource = (ctx) => + ctx.gossipPeers + .filter((peer) => peer.freeSlots > 0) + .map((peer) => ({ + 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), + })); + +// Copied verbatim from builtin-actions.ts (keep one source of truth — re-export). +function spawnAllowed(ctx: ActionContext, role: string): boolean { + if (!ctx.topology) return true; + if (ctx.claims === null) return false; + return checkSpawn(ctx.topology, role, ctx.claims, ctx.parentAgentId).allowed; +} +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; +} +``` + +Add the `DynamicSource` type to `types.ts` (so `registry.ts` and +`dynamic-sources.ts` share it) by adding this export to `src/tui/actions/types.ts`: + +```ts +export type DynamicSource = (ctx: ActionContext) => readonly Action[]; +``` + +Then update `registry.ts` to import `DynamicSource` from `./types.js` instead of +declaring it locally (remove the local `export type DynamicSource` line there; +add it to the existing type import). + +- [ ] **Step 4: Trim `builtin-actions.ts` to static only** + +In `src/tui/actions/builtin-actions.ts`: +- Delete the `sessions` loop inside `navigationActions` (kept in `sessionNavSource`). +- Delete the spawn loops, kill loop, and delegate loop inside `agentActions` + (kept in `dynamic-sources.ts`); `agentActions` now returns only the static + `agent.broadcast` and `agent.direct-message` actions. +- Delete the now-unused `spawnAllowed`/`spawnDetail`/`topologyCommand` helpers + and the `checkSpawn` import from this file. +- Keep `buildBuiltInActions` returning the static set (navigation panel actions, + `focusedPanelActions`, the trimmed `agentActions`, `workflowActions`, + `viewActions`, `contributionActions`). + +- [ ] **Step 5: Create `register-builtins.ts`** + +Create `src/tui/actions/register-builtins.ts`: + +```ts +import { buildBuiltInActions } from "./builtin-actions.js"; +import { delegateSource, killSource, sessionNavSource, spawnSource } from "./dynamic-sources.js"; +import type { ActionContext } from "./types.js"; +import type { ActionRegistry } from "./registry.js"; + +/** + * Populate a registry with all built-in actions. Static actions that do not + * depend on per-entity state are registered once via a context-free snapshot; + * per-entity actions are registered as dynamic sources. + * + * `buildBuiltInActions` is pure over the static subset, so a throwaway empty + * context is safe for enumerating the static descriptors (their `run`/`enabled` + * receive the LIVE ctx at call time). + */ +export function registerBuiltInActions(registry: ActionRegistry, emptyCtx: ActionContext): void { + for (const action of buildBuiltInActions(emptyCtx)) registry.register(action); + registry.registerDynamic("nav.session.", sessionNavSource); + registry.registerDynamic("agent.spawn.", spawnSource); + registry.registerDynamic("agent.kill.", killSource); + registry.registerDynamic("agent.delegate.", delegateSource); +} +``` + +- [ ] **Step 6: Run focused tests + existing builtin tests** + +[focused-test] `src/tui/actions/dynamic-sources.test.ts src/tui/actions/builtin-actions.test.ts` +Expected: dynamic-sources PASS. Fix any `builtin-actions.test.ts` cases that +asserted per-entity actions — move those assertions into +`dynamic-sources.test.ts` (the spawn-at-capacity case asserts `spawnSource` +emits an action whose `enabled(ctx)` is false at capacity). + +- [ ] **Step 7: Typecheck + check + commit** + +```bash +bun run typecheck && bun run check +git add src/tui/actions/dynamic-sources.ts src/tui/actions/register-builtins.ts \ + src/tui/actions/builtin-actions.ts src/tui/actions/types.ts \ + src/tui/actions/registry.ts src/tui/actions/dynamic-sources.test.ts \ + src/tui/actions/builtin-actions.test.ts +git commit -m "feat(tui): #275 split built-ins into static actions + dynamic sources" +``` + +--- + +## Task 4: Keybind → registry-id map + +**Files:** +- Create: `src/tui/keymap/keymap-action-map.ts` +- Test: `src/tui/keymap/keymap-action-map.test.ts` + +This bridges the keymap's `TuiActionId` / panel-target bindings to registry +action ids, and produces the id→keybind map for `registry.setBindings`. Keymap +types are NOT changed (they stay strict). + +- [ ] **Step 1: Write the failing test** + +Create `src/tui/keymap/keymap-action-map.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { Panel } from "../hooks/use-panel-focus.js"; +import { PanelId } from "../panels/panel-ids.js"; +import { bindingToActionId, resolvedKeymapBindings } from "./keymap-action-map.js"; +import { resolveBuiltinKeymap, type KeyBinding } from "./keymap.js"; + +describe("bindingToActionId", () => { + test("maps a non-panel action binding to its registry id", () => { + const b = { id: "quit", action: "quit", sequence: ["q"], label: "Quit", context: "global", layer: "normal", preferred: true } as KeyBinding; + expect(bindingToActionId(b)).toBe("view.quit"); + }); + test("maps a focus_panel binding to nav.panel.", () => { + const b = { + id: `focus_panel:${PanelId.Terminal}`, + action: "focus_panel", + sequence: ["t"], + label: "Terminal", + context: "navigation", + layer: "normal", + panel: Panel.Terminal, + preferred: true, + } as KeyBinding; + expect(bindingToActionId(b)).toBe("nav.panel.terminal"); + }); +}); + +describe("resolvedKeymapBindings", () => { + test("produces id→keybind entries from a resolved keymap", () => { + const map = resolvedKeymapBindings(resolveBuiltinKeymap("default")); + expect(map.get("view.quit")).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +[focused-test] `src/tui/keymap/keymap-action-map.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement the map** + +Create `src/tui/keymap/keymap-action-map.ts`. The `TuiActionId`→registry-id +table must cover every `TuiActionId` from `keymap.ts`. Panel-target bindings map +via the panel's `PanelId` to the `nav.panel.` ids produced by +`navigationActions` (note: those ids derive from `PANEL_LABELS[panel]` +lowercased — e.g. `nav.panel.terminal`; verify each against +`builtin-actions.ts`). + +```ts +import { PANEL_LABELS } from "../hooks/use-panel-focus.js"; +import { panelToId } from "../panels/panel-ids.js"; +import { + formatKeySequence, + type KeyBinding, + type ResolvedKeymap, + type TuiActionId, +} from "./keymap.js"; + +/** Non-panel TuiActionId → registry action id. */ +const ACTION_ID_BY_TUI: Readonly, string>> = { + quit: "view.quit", + help: "view.help", + palette: "view.palette", // special: opens the palette (handled in dispatch, see Task 5) + refresh: "view.refresh", + zoom_cycle: "view.zoom", + zoom_reset: "view.zoom-reset", + layout_toggle: "view.layout", + view_cycle: "view.view-mode", + cycle_panel_next: "nav.panel.next", + cycle_panel_prev: "nav.panel.prev", + search_start: "view.search", + terminal_input: "nav.terminal.input", + compare_toggle: "workflow.compare", + artifact_prev: "artifact.prev", + artifact_next: "artifact.next", + artifact_diff: "artifact.diff", + artifact_diff_mode: "artifact.diff-mode", + approve: "workflow.approve-question", + deny: "workflow.deny-question", + broadcast: "agent.broadcast", + direct_message: "agent.direct-message", + cursor_down: "nav.cursor-down", + cursor_up: "nav.cursor-up", + select: "nav.select", + page_next: "nav.page-next", + page_prev: "nav.page-prev", + vfs_navigate: "nav.vfs-navigate", + terminal_scroll_up: "nav.terminal.scroll-up", + terminal_scroll_down: "nav.terminal.scroll-down", + terminal_scroll_bottom: "nav.terminal.scroll-bottom", + frontier_tab_next: "nav.frontier.next-slice", + frontier_tab_prev: "nav.frontier.prev-slice", + frontier_adopt: "contrib.frontier-adopt", + compare_select: "workflow.compare-select", + compare_adopt_a: "workflow.compare-adopt-a", + compare_adopt_b: "workflow.compare-adopt-b", +}; + +export function bindingToActionId(binding: KeyBinding): string { + if (binding.action === "focus_panel" || binding.action === "toggle_panel") { + const id = panelToId(binding.panel); + const label = PANEL_LABELS[binding.panel]; + const key = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + return `nav.panel.${key}`; // matches navigationActions() id derivation + } + return ACTION_ID_BY_TUI[binding.action]; +} + +/** id → human keybind label, last-writer-wins on duplicate ids. */ +export function resolvedKeymapBindings(keymap: ResolvedKeymap): ReadonlyMap { + const map = new Map(); + for (const binding of keymap.bindings) { + if (!binding.preferred) continue; + const id = bindingToActionId(binding); + if (id !== undefined && !map.has(id)) map.set(id, formatKeySequence(binding.sequence)); + } + return map; +} +``` + +> **Note for the implementer:** several mapped registry ids +> (`nav.terminal.input`, `artifact.*`, `nav.cursor-*`, `nav.select`, +> `nav.page-*`, `nav.vfs-navigate`, `workflow.compare-*`) do **not** yet exist as +> built-in actions — they were panel/cursor behaviors living only in the keymap +> switch. Task 5 adds them as static actions so `byId` resolves them. Keep the id +> strings here in exact sync with Task 5's new actions. + +- [ ] **Step 4: Run the focused test to verify it passes** + +[focused-test] `src/tui/keymap/keymap-action-map.test.ts` +Expected: PASS. If `view.palette` or `nav.panel.terminal` assertions fail, fix +the id strings to match `builtin-actions.ts` derivation. + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/keymap/keymap-action-map.ts src/tui/keymap/keymap-action-map.test.ts +git commit -m "feat(tui): #275 keymap binding → registry action id map" +``` + +--- + +## Task 5: Add missing keymap-only actions; route dispatch through registry + +**Files:** +- Modify: `src/tui/actions/builtin-actions.ts` (add panel/cursor static actions) +- Modify: `src/tui/hooks/use-keyboard-handler.ts` (replace `executeKeymapAction`) +- Modify: `src/tui/actions/types.ts` (add the capabilities the new actions need) +- Test: `src/tui/hooks/use-keyboard-handler.test.ts` (exists — add parity cases) + +The old `executeKeymapAction` switch (lines 111–278) had focus-gated behaviors +(e.g. `artifact_next` only when `focused === Panel.Artifact`). Each becomes an +action whose `available`/`run` reads `ctx.focusedPanel` and calls a capability. + +- [ ] **Step 1: Write the failing parity test** + +Add to `src/tui/hooks/use-keyboard-handler.test.ts` a case asserting a keymap +match dispatches through the registry: + +```ts +import { describe, expect, test } from "bun:test"; +import { createActionRegistry } from "../actions/registry.js"; +import type { Action, ActionContext } from "../actions/types.js"; +import { dispatchKeymapBinding } from "./use-keyboard-handler.js"; +import type { KeyBinding } from "../keymap/keymap.js"; + +test("dispatchKeymapBinding runs the registry action for a binding", () => { + let ran = false; + const r = createActionRegistry(); + r.register({ id: "view.refresh", label: "Refresh", detail: "", group: "View", run: () => { ran = true; } } as Action); + const binding = { id: "refresh", action: "refresh", sequence: ["r"], label: "Refresh", context: "global", layer: "normal", preferred: true } as KeyBinding; + const handled = dispatchKeymapBinding(binding, r, {} as ActionContext, () => {}); + expect(handled).toBe(true); + expect(ran).toBe(true); +}); + +test("dispatchKeymapBinding skips disabled actions", () => { + let ran = false; + const r = createActionRegistry(); + r.register({ id: "view.refresh", label: "Refresh", detail: "", group: "View", enabled: () => false, run: () => { ran = true; } } as Action); + const binding = { id: "refresh", action: "refresh", sequence: ["r"], label: "Refresh", context: "global", layer: "normal", preferred: true } as KeyBinding; + expect(dispatchKeymapBinding(binding, r, {} as ActionContext, () => {})).toBe(false); + expect(ran).toBe(false); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +[focused-test] `src/tui/hooks/use-keyboard-handler.test.ts` +Expected: FAIL — `dispatchKeymapBinding` not exported. + +- [ ] **Step 3: Add the missing static actions + capabilities** + +In `src/tui/actions/types.ts`, add the capabilities the migrated actions call +(append to the `ActionContext` capabilities block): + +```ts + // Keymap-migrated capabilities (#275) + readonly enterTerminalInput: () => void; + readonly artifactPrev: () => void; + readonly artifactNext: () => void; + readonly artifactDiffToggle: () => void; + readonly artifactDiffModeToggle: () => void; + readonly cursorDown: () => void; + readonly cursorUp: () => void; + readonly selectRow: () => void; + readonly pageNext: () => void; + readonly pagePrev: () => void; + readonly vfsNavigate: () => void; + readonly terminalScrollUp: () => void; + readonly terminalScrollDown: () => void; + readonly compareSelect: () => void; + readonly compareAdoptA: () => void; + readonly compareAdoptB: () => void; + readonly frontierAdopt: () => void; + readonly openPalette: () => void; +``` + +> **Parity caution:** `frontier_adopt` is NOT the same as `contrib.adopt`. +> `contrib.adopt` adopts `selectedCid`; `frontier_adopt` adopts the frontier +> panel's cursor entry. They get distinct ids/capabilities. `frontierAdopt()` +> closes over the existing `onFrontierAdopt(frontierEntries()[cursor]...)` flow in +> `app.tsx` (the same logic the old switch's `frontier_adopt` case ran), gated on +> `focusedPanel === Panel.Frontier`. + +In `src/tui/actions/builtin-actions.ts`, add a `keymapMigratedActions()` group +and include it in `buildBuiltInActions`. Each action's `available` mirrors the +old switch's focus gate. Example (write the full set — one per id from Task 4's +table that wasn't already a built-in): + +```ts +import { Panel } from "../hooks/use-panel-focus.js"; + +function keymapMigratedActions(): readonly Action[] { + return [ + { id: "view.palette", label: "Open command palette", detail: "view", group: "View", + keywords: ["palette", "command"], run: (c) => c.openPalette() }, + { id: "nav.terminal.input", label: "Enter terminal input", detail: "terminal", group: "Navigation", + keywords: ["terminal", "input", "type"], available: (c) => c.focusedPanel === Panel.Terminal, + run: (c) => c.enterTerminalInput() }, + { id: "artifact.prev", label: "Previous artifact", detail: "artifact", group: "View", + available: (c) => c.focusedPanel === Panel.Artifact, run: (c) => c.artifactPrev() }, + { id: "artifact.next", label: "Next artifact", detail: "artifact", group: "View", + available: (c) => c.focusedPanel === Panel.Artifact, run: (c) => c.artifactNext() }, + { id: "artifact.diff", label: "Toggle artifact diff", detail: "artifact", group: "View", + available: (c) => c.focusedPanel === Panel.Artifact, run: (c) => c.artifactDiffToggle() }, + { id: "artifact.diff-mode", label: "Cycle artifact diff mode", detail: "artifact", group: "View", + available: (c) => c.focusedPanel === Panel.Artifact, run: (c) => c.artifactDiffModeToggle() }, + { id: "nav.cursor-down", label: "Move cursor down", detail: "nav", group: "Navigation", + run: (c) => c.cursorDown() }, + { id: "nav.cursor-up", label: "Move cursor up", detail: "nav", group: "Navigation", + run: (c) => c.cursorUp() }, + { id: "nav.select", label: "Select row", detail: "nav", group: "Navigation", + run: (c) => c.selectRow() }, + { id: "nav.page-next", label: "Next page", detail: "nav", group: "Navigation", + run: (c) => c.pageNext() }, + { id: "nav.page-prev", label: "Previous page", detail: "nav", group: "Navigation", + run: (c) => c.pagePrev() }, + { id: "nav.vfs-navigate", label: "Open VFS entry", detail: "vfs", group: "Navigation", + available: (c) => c.focusedPanel === Panel.Vfs, run: (c) => c.vfsNavigate() }, + { id: "nav.terminal.scroll-up", label: "Scroll terminal up", detail: "terminal", group: "Navigation", + available: (c) => c.focusedPanel === Panel.Terminal, run: (c) => c.terminalScrollUp() }, + { id: "nav.terminal.scroll-down", label: "Scroll terminal down", detail: "terminal", group: "Navigation", + available: (c) => c.focusedPanel === Panel.Terminal, run: (c) => c.terminalScrollDown() }, + { id: "workflow.compare-select", label: "Select for compare", detail: "compare", group: "Workflow", + available: (c) => c.focusedPanel === Panel.Frontier, run: (c) => c.compareSelect() }, + { id: "workflow.compare-adopt-a", label: "Adopt compare A", detail: "compare", group: "Workflow", + available: (c) => c.focusedPanel === Panel.Artifact, run: (c) => c.compareAdoptA() }, + { id: "workflow.compare-adopt-b", label: "Adopt compare B", detail: "compare", group: "Workflow", + available: (c) => c.focusedPanel === Panel.Artifact, run: (c) => c.compareAdoptB() }, + { id: "contrib.frontier-adopt", label: "Adopt frontier entry", detail: "frontier", group: "Contributions", + keywords: ["adopt", "frontier"], available: (c) => c.focusedPanel === Panel.Frontier, + run: (c) => c.frontierAdopt() }, + ]; +} +``` + +Add `...keymapMigratedActions()` to the `buildBuiltInActions` array. + +> Note: `approve/deny`→`workflow.approve-question/deny-question` and +> `nav.panel.next/prev` already exist as built-ins; do not duplicate. The +> `workflow.approve-question`/`deny-question` actions are `available` only when +> `pendingQuestionCount === 1` — the keymap binding will be a no-op (returns +> false) when 0 or >1 pending, matching the old switch's `focused === Decisions` +> gate intent. If exact parity with the old Decisions-panel gate is required, add +> a `focusedPanel === Panel.Decisions` clause to those actions' `available`. + +- [ ] **Step 4: Replace `executeKeymapAction` with registry dispatch** + +In `src/tui/hooks/use-keyboard-handler.ts`: +- Delete the entire `executeKeymapAction` function (lines 111–278). +- Add the new dispatch helper and the registry/ctx to `KeyboardActions`: + +```ts +import type { ActionRegistry } from "../actions/registry.js"; +import { resolveEnabled, type ActionContext } from "../actions/types.js"; +import { bindingToActionId } from "../keymap/keymap-action-map.js"; + +export function dispatchKeymapBinding( + binding: KeyBinding, + registry: ActionRegistry, + ctx: ActionContext, + onError: (message: string) => void, +): boolean { + const id = bindingToActionId(binding); + const action = registry.byId(id, ctx); + if (action === undefined) return false; + if (!resolveEnabled(action, ctx).enabled) return false; + void Promise.resolve(action.run(ctx)).catch((e) => + onError(e instanceof Error ? e.message : "Action failed"), + ); + return true; +} +``` + +- Add to `KeyboardActions`: `readonly registry: ActionRegistry;`, + `readonly actionContext: ActionContext;`, `readonly onActionError: (m: string) => void;`. +- In `routeKey`, replace the `case "match":` body: + +```ts + case "match": + actions.onKeymapPrefixChange?.([]); + if ( + dispatchKeymapBinding( + result.binding, + actions.registry, + actions.actionContext, + actions.onActionError, + ) + ) + return true; + if (keymapPrefix.length > 0) return true; + break; +``` + +- [ ] **Step 5: Run focused + full keyboard tests** + +[focused-test] `src/tui/hooks/use-keyboard-handler.test.ts` +Expected: new parity cases PASS. Update existing tests that referenced +`executeKeymapAction` to call `dispatchKeymapBinding` with a registry stub. + +- [ ] **Step 6: Typecheck + check + commit** + +```bash +bun run typecheck && bun run check +git add src/tui/actions/builtin-actions.ts src/tui/actions/types.ts \ + src/tui/hooks/use-keyboard-handler.ts src/tui/hooks/use-keyboard-handler.test.ts +git commit -m "feat(tui): #275 dispatch keymap through registry; retire executeKeymapAction" +``` + +--- + +## Task 6: Leader-chord 2s modal + capture + overlay + +**Files:** +- Create: `src/tui/keymap/leader-chord.ts` (pure helpers) +- Create: `src/tui/components/leader-overlay.tsx` +- Test: `src/tui/keymap/leader-chord.test.ts` +- Modify: `src/tui/hooks/use-keyboard-handler.ts` (capture while pending) + +- [ ] **Step 1: Write the failing test** + +Create `src/tui/keymap/leader-chord.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { candidateContinuations } from "./leader-chord.js"; +import { resolveBuiltinKeymap } from "./keymap.js"; + +describe("candidateContinuations", () => { + test("lists next-key options for a pending leader prefix", () => { + const km = resolveBuiltinKeymap("default"); + const cands = candidateContinuations(km.bindings, ["space"]); + // Each candidate is { key, label } for a binding whose sequence starts with space. + expect(cands.length).toBeGreaterThan(0); + expect(cands.every((c) => typeof c.key === "string" && typeof c.label === "string")).toBe(true); + }); + test("returns [] for a prefix with no children", () => { + const km = resolveBuiltinKeymap("default"); + expect(candidateContinuations(km.bindings, ["nonexistent-token"])).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +[focused-test] `src/tui/keymap/leader-chord.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement the helper** + +Create `src/tui/keymap/leader-chord.ts`: + +```ts +import { formatKeySequence, type KeyBinding, type KeySequence } from "./keymap.js"; + +export interface ChordCandidate { + readonly key: string; + readonly label: string; +} + +/** Next-key options for bindings whose sequence extends `prefix` by ≥1 token. */ +export function candidateContinuations( + bindings: readonly KeyBinding[], + prefix: KeySequence, +): readonly ChordCandidate[] { + const out: ChordCandidate[] = []; + const seen = new Set(); + for (const b of bindings) { + if (b.sequence.length <= prefix.length) continue; + if (!prefix.every((t, i) => b.sequence[i] === t)) continue; + const nextToken = b.sequence[prefix.length]; + if (nextToken === undefined || seen.has(nextToken)) continue; + seen.add(nextToken); + out.push({ key: formatKeySequence([nextToken]), label: b.label }); + } + return out; +} + +/** Window after the leader key during which the chord stays armed. */ +export const LEADER_CHORD_TIMEOUT_MS = 2000; +``` + +- [ ] **Step 4: Create the overlay component** + +Create `src/tui/components/leader-overlay.tsx`: + +```tsx +import React from "react"; +import { candidateContinuations, type ChordCandidate } from "../keymap/leader-chord.js"; +import { formatKeySequence, type KeyBinding } from "../keymap/keymap.js"; +import { theme } from "../theme.js"; + +export interface LeaderOverlayProps { + readonly prefix: readonly string[]; + readonly bindings: readonly KeyBinding[]; +} + +export function LeaderOverlay({ prefix, bindings }: LeaderOverlayProps): React.ReactNode { + if (prefix.length === 0) return null; + const candidates: readonly ChordCandidate[] = candidateContinuations(bindings, prefix); + return ( + + {formatKeySequence(prefix)} + {candidates.map((c, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: candidates are positional + + {`${c.key}:${c.label} `} + + ))} + + ); +} +``` + +- [ ] **Step 5: Add the 2s timer + capture (wiring is finished in Task 8)** + +In `src/tui/hooks/use-keyboard-handler.ts`, the pending prefix already routes all +Normal-mode keys to the resolver (lines 435–460), which IS the capture behavior. +Add an explicit guard so a pending prefix is never leaked to later handlers: at +the start of `routeKey`, after computing `keymapPrefix`, leave the existing flow +intact (it already `return`s on pending/match/miss-with-prefix). No code change +needed here beyond Task 5. The 2s timer is owned by `app.tsx` (Task 8): it arms a +`setTimeout` on prefix change and clears the prefix on expiry. + +- [ ] **Step 6: Run focused test + commit** + +[focused-test] `src/tui/keymap/leader-chord.test.ts` +Expected: PASS. + +```bash +bun run check +git add src/tui/keymap/leader-chord.ts src/tui/keymap/leader-chord.test.ts \ + src/tui/components/leader-overlay.tsx +git commit -m "feat(tui): #275 leader-chord candidates + overlay + 2s window constant" +``` + +--- + +## Task 7: Palette — suggested-first, keybind column, reason footer + +**Files:** +- Modify: `src/tui/actions/visibility.ts` (suggested-first ordering) +- Modify: `src/tui/components/command-palette.tsx` (keybind column + reason footer) +- Test: `src/tui/actions/visibility.test.ts` (exists — add case) +- Test: `src/tui/components/command-palette.test.tsx` (exists — add cases) + +- [ ] **Step 1: Write the failing tests** + +Add to `src/tui/actions/visibility.test.ts`: + +```ts +test("no-query order puts suggested actions first, then GROUP_ORDER", () => { + const mk = (id: string, group: any, suggested?: boolean) => + ({ id, label: id, detail: "", group, suggested, run: () => {} }) as any; + const actions = [mk("v", "View"), mk("n", "Navigation"), mk("s", "View", true)]; + const out = computeVisibleActions(actions, {} as any, "").map((x) => x.action.id); + expect(out).toEqual(["s", "n", "v"]); +}); +``` + +Add to `src/tui/components/command-palette.test.tsx` (follow the file's existing +render-assertion style) a case asserting a disabled action's `reason` renders in +the footer and a `keybind` renders on the row. Use the existing test's render +harness; assert the rendered output contains the reason string and the keybind +string. + +- [ ] **Step 2: Run to verify they fail** + +[focused-test] `src/tui/actions/visibility.test.ts src/tui/components/command-palette.test.tsx` +Expected: FAIL. + +- [ ] **Step 3: Implement suggested-first ordering** + +In `src/tui/actions/visibility.ts`, replace the no-query sort: + +```ts + if (!q) { + const rank = (a: Action) => (a.suggested ? 0 : 1); + const ordered = [...available].sort((a, b) => { + const r = rank(a) - rank(b); + if (r !== 0) return r; + return GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group); + }); + return ordered.map((action) => ({ action, matchedIndices: [] })); + } +``` + +Also extend the query branch to fuzzy-match `slash` (so `search("reload")` finds +`/reload`): inside the keyword loop, after the keywords, add: + +```ts + if (action.slash) { + const r = fuzzyMatch(q, action.slash); + if (r.match && r.score > best) best = r.score; + } +``` + +- [ ] **Step 4: Implement keybind column + reason footer** + +In `src/tui/components/command-palette.tsx`: +- Import `resolveEnabled` from `../actions/types.js`. +- In the row map, compute `const { enabled, reason } = resolveEnabled(action, ctx);` + and use `const dimmed = !enabled;`. When `action.keybind` is set, render it + right-aligned after the detail: + +```tsx + {action.detail ? [{action.detail}] : null} + {action.keybind ? ( + {` ${action.keybind}`} + ) : null} +``` + +- Track the selected row's disabled reason and render it in the footer box: + +```tsx + + [j/k] navigate [Enter] execute [Esc] close + {selectedReason ? {selectedReason} : null} + +``` + +where `selectedReason` is computed from the selected `visibleActions[idx]`: + +```tsx + const selected = visibleActions[idx]?.action; + const selectedReason = selected ? resolveEnabled(selected, ctx).reason : undefined; +``` + +- [ ] **Step 5: Run focused tests** + +[focused-test] `src/tui/actions/visibility.test.ts src/tui/components/command-palette.test.tsx` +Expected: PASS. (The Task 2 `suggested` registry case now passes too.) + +- [ ] **Step 6: Commit** + +```bash +bun run check +git add src/tui/actions/visibility.ts src/tui/actions/visibility.test.ts \ + src/tui/components/command-palette.tsx src/tui/components/command-palette.test.tsx +git commit -m "feat(tui): #275 palette suggested-first + keybind column + reason footer" +``` + +--- + +## Task 8: Wire the registry into app.tsx + +**Files:** +- Modify: `src/tui/app.tsx` + +Replace the per-render `buildBuiltInActions`/`buildPluginActions` concat with a +once-built registry; feed bindings; route keyboard + palette through it; arm the +leader timer; render the overlay. + +- [ ] **Step 1: Build the registry once** + +Near the action wiring (after `actionContext` is defined, ~line 921), add: + +```ts + const registry = useMemo(() => { + const r = createActionRegistry(); + registerBuiltInActions(r, actionContext); // static descriptors enumerated once + r.registerDynamic("plugin.", (c) => + buildPluginActions(mergedActionRegistry.entries, mkPluginCtx), + ); + return r; + // Built once: actionContext closures are stable; live state flows via ctx at call time. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +``` + +> If `buildPluginActions` needs live `mergedActionRegistry.entries`, capture them +> through a ref updated in an effect so the dynamic source reads the latest set +> without rebuilding the registry. Add: +> `const pluginEntriesRef = useRef(mergedActionRegistry.entries);` + an effect +> setting `pluginEntriesRef.current = mergedActionRegistry.entries;` and read +> `pluginEntriesRef.current` inside the dynamic source. + +- [ ] **Step 2: Feed keybinds whenever the resolved keymap changes** + +After `resolvedKeymap` is defined (~line 155), add: + +```ts + useEffect(() => { + registry.setBindings(resolvedKeymapBindings(resolvedKeymap)); + }, [registry, resolvedKeymap]); +``` + +- [ ] **Step 3: Replace palette action sourcing** + +Replace `paletteActions`/`filteredActions` (lines 1069–1079): + +```ts + const filteredActions = useMemo( + () => + ks.paletteQuery.trim() + ? registry.search(ks.paletteQuery, actionContext) + : registry.list(actionContext), + [registry, actionContext, ks.paletteQuery], + ); +``` + +Pass `filteredActions` to `` and +to the `paletteItemCount`/`onPaletteSelect` index logic (it already uses the flat +list). + +- [ ] **Step 4: Pass registry + ctx into the keyboard handler** + +Where `KeyboardActions` is assembled (the object passed to `routeKey`), add: + +```ts + registry, + actionContext, + onActionError: showError, +``` + +- [ ] **Step 5: Arm the leader 2s timer** + +Where `keymapPrefix`/`setKeymapPrefix` live (~line 159), add: + +```ts + useEffect(() => { + if (keymapPrefix.length === 0) return; + const t = setTimeout(() => setKeymapPrefix([]), LEADER_CHORD_TIMEOUT_MS); + return () => clearTimeout(t); + }, [keymapPrefix]); +``` + +- [ ] **Step 6: Render the overlay** + +Near the status bar / palette render, add (visible only when a chord is pending +and the palette is not open): + +```tsx + {!paletteVisible && keymapPrefix.length > 0 ? ( + + ) : null} +``` + +- [ ] **Step 7: Update imports** + +Add to `src/tui/app.tsx` imports: + +```ts +import { createActionRegistry } from "./actions/registry.js"; +import { registerBuiltInActions } from "./actions/register-builtins.js"; +import { resolvedKeymapBindings } from "./keymap/keymap-action-map.js"; +import { LEADER_CHORD_TIMEOUT_MS } from "./keymap/leader-chord.js"; +import { LeaderOverlay } from "./components/leader-overlay.js"; +``` + +Remove the now-unused `buildBuiltInActions` and `computeVisibleActions` imports +if no longer referenced. + +- [ ] **Step 8: Provide the new capabilities in `actionContext`** + +The `actionContext` useMemo (~line 921) must now supply every capability added in +Task 5 (`openPalette`, `enterTerminalInput`, `artifactPrev`, …). Wire each to the +existing `KeyboardActions` handlers already present in `app.tsx` (e.g. +`openPalette: () => { onSpawnPalette(); panels.setMode(InputMode.CommandPalette); }`, +`artifactNext: handleArtifactNext`, `cursorDown: () => nav.cursorDown(...)`, +etc.). Reuse the existing callbacks — do not duplicate logic. + +- [ ] **Step 9: Typecheck, check, full test suite** + +```bash +bun run typecheck && bun run check && bun test +``` +Expected: PASS. Fix any remaining references to deleted symbols. + +- [ ] **Step 10: Commit** + +```bash +git add src/tui/app.tsx +git commit -m "feat(tui): #275 wire ActionRegistry into app (palette + keymap + leader overlay)" +``` + +--- + +# Phase 2 — Slash + +## Task 9: Slash field on actions + slash index + +**Files:** +- Modify: `src/tui/actions/builtin-actions.ts` (add `slash` to common actions) +- Create: `src/tui/actions/slash-index.ts` +- Test: `src/tui/actions/slash-index.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/tui/actions/slash-index.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { buildSlashIndex } from "./slash-index.js"; +import type { Action } from "./types.js"; + +const a = (id: string, slash?: string): Action => + ({ id, label: id, detail: "", group: "View", slash, run: () => {} }) as Action; + +describe("buildSlashIndex", () => { + test("maps slash trigger → action id, ignoring actions without slash", () => { + const idx = buildSlashIndex([a("view.quit", "/quit"), a("view.refresh")]); + expect(idx.get("/quit")).toBe("view.quit"); + expect(idx.size).toBe(1); + }); + test("resolveSlash parses /cmd args", () => { + const idx = buildSlashIndex([a("agent.spawn", "/spawn")]); + const r = resolveSlash(idx, "/spawn reviewer fast"); + expect(r).toEqual({ id: "agent.spawn", args: ["reviewer", "fast"] }); + }); + test("resolveSlash returns undefined for unknown command", () => { + expect(resolveSlash(buildSlashIndex([]), "/nope")).toBeUndefined(); + }); +}); + +import { resolveSlash } from "./slash-index.js"; +``` + +- [ ] **Step 2: Run to verify it fails** + +[focused-test] `src/tui/actions/slash-index.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement the index** + +Create `src/tui/actions/slash-index.ts`: + +```ts +import type { Action } from "./types.js"; + +export function buildSlashIndex(actions: readonly Action[]): ReadonlyMap { + const map = new Map(); + for (const a of actions) { + if (a.slash && !map.has(a.slash)) map.set(a.slash, a.id); + } + return map; +} + +export interface SlashResolution { + readonly id: string; + readonly args: readonly string[]; +} + +export function resolveSlash( + index: ReadonlyMap, + input: string, +): SlashResolution | undefined { + const tokens = input.trim().split(/\s+/).filter((t) => t.length > 0); + const cmd = tokens[0]; + if (cmd === undefined) return undefined; + const id = index.get(cmd); + if (id === undefined) return undefined; + return { id, args: tokens.slice(1) }; +} +``` + +- [ ] **Step 4: Add `slash` to common built-in actions** + +In `src/tui/actions/builtin-actions.ts`, add `slash` to high-value actions +(write them all): `view.quit` → `slash: "/quit"`, `view.refresh` → +`slash: "/refresh"`, `view.search` → `slash: "/search"`, `view.help` → +`slash: "/help"`, `workflow.set-goal` → `slash: "/goal"`, +`workflow.compare` → `slash: "/compare"`, `agent.broadcast` → +`slash: "/broadcast"`, `agent.direct-message` → `slash: "/dm"`. Mark +`view.palette`, `view.search`, `workflow.set-goal` with `suggested: true`. + +- [ ] **Step 5: Run focused test + commit** + +[focused-test] `src/tui/actions/slash-index.test.ts` +Expected: PASS. + +```bash +bun run check +git add src/tui/actions/slash-index.ts src/tui/actions/slash-index.test.ts \ + src/tui/actions/builtin-actions.ts +git commit -m "feat(tui): #275 slash index + slash triggers on built-in actions" +``` + +--- + +## Task 10: `/` opens the palette in slash mode + +**Files:** +- Modify: `src/tui/hooks/use-keyboard-handler.ts` (`/` in Normal opens palette) +- Modify: `src/tui/components/command-palette.tsx` (slash-mode filter) +- Modify: `src/tui/app.tsx` (pass slash mode + seed query) +- Test: `src/tui/components/command-palette.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Add to `src/tui/components/command-palette.test.tsx`: when `slashMode` is true, +only actions with a `slash` field render. Assert that a non-slash action is +absent and a slash action is present. + +- [ ] **Step 2: Run to verify it fails** + +[focused-test] `src/tui/components/command-palette.test.tsx` +Expected: FAIL — `slashMode` prop unknown. + +- [ ] **Step 3: Add `slashMode` to the palette** + +In `src/tui/components/command-palette.tsx`, add `readonly slashMode?: boolean;` +to `CommandPaletteProps`, and when true, pre-filter actions to those with a +`slash` before `computeVisibleActions`: + +```tsx + const source = slashMode ? actions.filter((a) => a.slash) : actions; + const visibleActions = useMemo( + () => computeVisibleActions(source, ctx, q), + [source, ctx, q], + ); +``` + +- [ ] **Step 4: `/` opens slash-mode palette** + +In `src/tui/hooks/use-keyboard-handler.ts`, the existing `/` handler only fires +when `focused === Panel.Search` (line 541). Add, in Normal mode before that, +when no panel-specific `/` applies: + +```ts + if (input === "/" && mode === InputMode.Normal && focused !== Panel.Search) { + actions.onSlashPaletteOpen(); + return true; + } +``` + +Add `readonly onSlashPaletteOpen: () => void;` to `KeyboardActions`. + +- [ ] **Step 5: Wire in app.tsx** + +Add `onSlashPaletteOpen` to the keyboard actions: open the palette with a +`slashMode` flag set in app state, then pass `slashMode` to ``. +Add a `const [slashMode, setSlashMode] = useState(false);`, set true on slash +open and false on every palette close path (`handleCommandPaletteClose`). + +- [ ] **Step 6: Run focused test + full check + commit** + +[focused-test] `src/tui/components/command-palette.test.tsx` +Expected: PASS. + +```bash +bun run typecheck && bun run check +git add src/tui/components/command-palette.tsx src/tui/hooks/use-keyboard-handler.ts \ + src/tui/app.tsx src/tui/components/command-palette.test.tsx +git commit -m "feat(tui): #275 '/' opens slash-filtered command palette" +``` + +--- + +## Task 11: Dedicated command-line input + +**Files:** +- Create: `src/tui/components/slash-input.tsx` +- Modify: `src/tui/hooks/use-panel-focus.ts` (add `InputMode.SlashCommand`) +- Modify: `src/tui/hooks/use-keyboard-handler.ts` (SlashCommand input mode) +- Modify: `src/tui/app.tsx` (state + submit → resolveSlash → run) +- Test: `src/tui/components/slash-input.test.tsx` + +- [ ] **Step 1: Add the input mode** + +In `src/tui/hooks/use-panel-focus.ts`, add `SlashCommand` to the `InputMode` +enum. + +- [ ] **Step 2: Write the failing test** + +Create `src/tui/components/slash-input.test.tsx` asserting the component renders +the current buffer with a leading `/` prompt and an error line when `error` is +set. Follow the existing component-test render style. + +- [ ] **Step 3: Run to verify it fails** + +[focused-test] `src/tui/components/slash-input.test.tsx` +Expected: FAIL — module missing. + +- [ ] **Step 4: Implement the component** + +Create `src/tui/components/slash-input.tsx`: + +```tsx +import React from "react"; +import { theme } from "../theme.js"; + +export interface SlashInputProps { + readonly visible: boolean; + readonly buffer: string; + readonly error?: string | undefined; +} + +export function SlashInput({ visible, buffer, error }: SlashInputProps): React.ReactNode { + if (!visible) return null; + return ( + + + / + {buffer} + + + {error ? {error} : null} + + ); +} +``` + +- [ ] **Step 5: Handle SlashCommand keys** + +In `src/tui/hooks/use-keyboard-handler.ts`, add a handler block mirroring the +Search/Goal input modes: + +```ts + if (mode === InputMode.SlashCommand) { + if (input === "return") { + actions.onSlashSubmit(); + return true; + } + if (input === "backspace") { + actions.onSlashBackspace(); + return true; + } + if (input === "space") { + actions.onSlashChar(" "); + return true; + } + if (input && input.length === 1 && !isCtrl) { + actions.onSlashChar(input); + return true; + } + return true; + } +``` + +Add `onSlashSubmit`, `onSlashChar`, `onSlashBackspace` to `KeyboardActions`. Also +make Surface 1's `/` open the command line when a power-user setting prefers it — +for now `/` opens the slash palette (Task 10) and a distinct binding (e.g. +`:` ) opens the command line: add `if (input === ":" && mode === InputMode.Normal) { actions.onSlashCommandOpen(); return true; }` +and `onSlashCommandOpen` to `KeyboardActions`. + +- [ ] **Step 6: Wire submit in app.tsx** + +Add slash command-line state (`slashBuffer`, `slashError`) and handlers: + +```ts + const onSlashSubmit = useCallback(() => { + const index = buildSlashIndex(registry.list(actionContext)); + const r = resolveSlash(index, `/${slashBuffer}`); + if (r === undefined) { + setSlashError(`Unknown command: /${slashBuffer.split(/\s+/)[0] ?? ""}`); + return; + } + const action = registry.byId(r.id, actionContext); + setSlashBuffer(""); + setSlashError(undefined); + panels.setMode(InputMode.Normal); + if (action) void Promise.resolve(action.run(actionContext, r.args)).catch(showError); + }, [registry, actionContext, slashBuffer, panels, showError]); +``` + +Render ``. +Wire `onSlashChar`/`onSlashBackspace`/`onSlashCommandOpen` to update +`slashBuffer` and set mode `SlashCommand`. + +- [ ] **Step 7: Run focused tests + full suite + commit** + +[focused-test] `src/tui/components/slash-input.test.tsx` +Then: `bun run typecheck && bun run check && bun test` + +```bash +git add src/tui/components/slash-input.tsx src/tui/components/slash-input.test.tsx \ + src/tui/hooks/use-panel-focus.ts src/tui/hooks/use-keyboard-handler.ts src/tui/app.tsx +git commit -m "feat(tui): #275 dedicated slash command-line input (/cmd args)" +``` + +--- + +# Phase 3 — MCP prompts + +## Task 12: Grove MCP server exposes prompts from `prompts/` + +**Files:** +- Create: `src/mcp/prompts.ts` +- Modify: `src/mcp/server.ts` +- Test: `src/mcp/prompts.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/mcp/prompts.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { loadPromptDefinitions } from "./prompts.js"; + +describe("loadPromptDefinitions", () => { + test("loads .md files from a prompts dir as named prompts", async () => { + const defs = await loadPromptDefinitions(new URL("../../prompts/", import.meta.url).pathname); + expect(Array.isArray(defs)).toBe(true); + // Each def has a name (file stem) and a non-empty template body. + for (const d of defs) { + expect(typeof d.name).toBe("string"); + expect(d.template.length).toBeGreaterThan(0); + } + }); + + test("returns [] for a missing directory", async () => { + expect(await loadPromptDefinitions("/no/such/dir")).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +[focused-test] `src/mcp/prompts.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement the loader** + +Create `src/mcp/prompts.ts`: + +```ts +import { readdir, readFile } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; + +export interface PromptDefinition { + readonly name: string; + readonly description: string; + readonly template: string; +} + +/** Load each *.md / *.txt file in `dir` as a named prompt (name = file stem). */ +export async function loadPromptDefinitions(dir: string): Promise { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return []; + } + const defs: PromptDefinition[] = []; + for (const entry of entries.sort()) { + const ext = extname(entry).toLowerCase(); + if (ext !== ".md" && ext !== ".txt") continue; + const template = (await readFile(join(dir, entry), "utf8")).trim(); + if (template.length === 0) continue; + const name = basename(entry, ext); + const firstLine = template.split("\n", 1)[0]?.replace(/^#+\s*/, "") ?? name; + defs.push({ name, description: firstLine.slice(0, 120), template }); + } + return defs; +} +``` + +- [ ] **Step 4: Register prompts in the MCP server** + +In `src/mcp/server.ts`: +- Change the server capabilities to include prompts: + +```ts + const server = new McpServer( + { name: "grove-mcp", version: "0.1.0" }, + { capabilities: { tools: {}, prompts: {} } }, + ); +``` + +- Add a `promptsDir?: string` field to `McpPresetConfig` (or read from + `deps`). After the tool registrations, before `return server;`: + +```ts + const promptsDir = preset?.promptsDir; + if (promptsDir !== undefined) { + for (const def of await loadPromptDefinitions(promptsDir)) { + server.registerPrompt( + def.name, + { title: def.name, description: def.description, argsSchema: {} }, + () => ({ messages: [{ role: "user", content: { type: "text", text: def.template } }] }), + ); + } + } +``` + +- Import `loadPromptDefinitions` at the top. + +- [ ] **Step 5: Run focused test + full suite + commit** + +[focused-test] `src/mcp/prompts.test.ts` +Then `bun run typecheck && bun run check`. + +```bash +git add src/mcp/prompts.ts src/mcp/prompts.test.ts src/mcp/server.ts +git commit -m "feat(mcp): #275 expose prompts from prompts/ dir (prompts capability)" +``` + +--- + +## Task 13: Provider prompt listing + +**Files:** +- Modify: `src/tui/provider.ts` (`TuiPromptProvider`, capability) +- Modify: `src/tui/nexus-provider.ts`, `src/tui/local-provider.ts`, + `src/tui/store-backed-provider.ts`, `src/tui/remote-provider.ts` +- Test: `src/tui/local-provider.test.ts` (+ `src/tui/provider.conformance.ts` if + the new method warrants a shared conformance assertion) + +- [ ] **Step 1: Add the interface + capability** + +In `src/tui/provider.ts`: + +```ts +export interface PromptInfo { + readonly name: string; + readonly description?: string | undefined; + readonly arguments?: readonly { name: string; required?: boolean }[] | undefined; +} + +export interface TuiPromptProvider { + listMcpPrompts(): Promise; +} +``` + +Add `readonly prompts: boolean;` to `ProviderCapabilities`. + +- [ ] **Step 2: Write the failing test** + +Add to `src/tui/local-provider.test.ts` a case asserting the local provider +returns the prompt names loaded from `prompts/` and reports +`capabilities.prompts === true`. + +- [ ] **Step 3: Run to verify it fails** + +[focused-test] `src/tui/local-provider.test.ts` +Expected: FAIL. + +- [ ] **Step 4: Implement** + +- `src/tui/local-provider.ts`: implement `listMcpPrompts` via + `loadPromptDefinitions` over the repo `prompts/` dir; set + `capabilities.prompts = true`. +- `src/tui/nexus-provider.ts`: implement `listMcpPrompts` by calling the Grove + MCP server's `prompts/list` (reuse the existing MCP client/transport the + provider already holds; if none, fall back to `loadPromptDefinitions` and set + the capability accordingly). +- Add `prompts: false` to every other `ProviderCapabilities` literal: + `src/tui/store-backed-provider.ts`, `src/tui/remote-provider.ts`, + `src/tui/provider-shared.ts`, plus any in `src/tui/provider.ts`. Run + `bun run typecheck` to find them all (the new required field makes each missing + literal a compile error). + +- [ ] **Step 5: Run focused + typecheck + commit** + +```bash +bun run typecheck && bun run check +git add src/tui/provider.ts src/tui/local-provider.ts src/tui/nexus-provider.ts \ + src/tui/store-backed-provider.ts src/tui/remote-provider.ts src/tui/provider-shared.ts \ + src/tui/local-provider.test.ts +git commit -m "feat(tui): #275 TuiPromptProvider.listMcpPrompts + capability" +``` + +--- + +## Task 14: `prompt.*` dynamic source + +**Files:** +- Modify: `src/tui/actions/types.ts` (`mcpPrompts`, `runPrompt`) +- Modify: `src/tui/actions/dynamic-sources.ts` (`promptSource`) +- Modify: `src/tui/app.tsx` (fetch prompts while palette open; wire `runPrompt`) +- Test: `src/tui/actions/dynamic-sources.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `src/tui/actions/dynamic-sources.test.ts`: + +```ts +import { promptSource } from "./dynamic-sources.js"; + +test("promptSource emits a Prompts-group action per prompt, gated on selected session", () => { + const ctx = baseCtx({ + selectedSession: "s1", + mcpPrompts: [{ name: "triage", description: "Triage" }], + }); + const actions = promptSource(ctx); + expect(actions.map((a) => a.id)).toEqual(["prompt.triage"]); + expect(actions[0]?.group).toBe("Prompts"); + expect(actions[0]?.available?.(baseCtx({ mcpPrompts: ctx.mcpPrompts }))).toBe(false); // no session +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +[focused-test] `src/tui/actions/dynamic-sources.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Extend `ActionContext`** + +In `src/tui/actions/types.ts`, add: + +```ts + readonly mcpPrompts?: readonly import("../provider.js").PromptInfo[] | undefined; + readonly runPrompt: (name: string, session: string, args?: readonly string[]) => void; +``` + +- [ ] **Step 4: Implement `promptSource`** + +Add to `src/tui/actions/dynamic-sources.ts`: + +```ts +export const promptSource: DynamicSource = (ctx) => + (ctx.mcpPrompts ?? []).map((p) => ({ + id: `prompt.${p.name}`, + label: `Prompt: ${p.name}`, + detail: p.description ?? "prompt", + group: "Prompts" as const, + slash: `/prompt:${p.name}`, + keywords: ["prompt", p.name], + available: (c) => c.selectedSession !== undefined, + run: (c, args) => { + if (c.selectedSession) c.runPrompt(p.name, c.selectedSession, args); + }, + })); +``` + +Register it in `register-builtins.ts`: +`registry.registerDynamic("prompt.", promptSource);` + +- [ ] **Step 5: Wire fetch + `runPrompt` in app.tsx** + +- Fetch prompts when the palette/slash surface opens (mirror the + `pendingQuestionCount` fetcher): `if (capabilities.prompts) provider.listMcpPrompts()` + → store in state → feed `actionContext.mcpPrompts`. +- Implement `runPrompt(name, session, args)` by delivering the prompt to the + selected agent through the provider's existing agent-message path (ACP / Nexus + IPC — the same call the broadcast/direct-message flow uses). **Do not** use + tmux send-keys. + +- [ ] **Step 6: Run focused + full suite + commit** + +```bash +bun run typecheck && bun run check && bun test +git add src/tui/actions/types.ts src/tui/actions/dynamic-sources.ts \ + src/tui/actions/register-builtins.ts src/tui/app.tsx src/tui/actions/dynamic-sources.test.ts +git commit -m "feat(tui): #275 prompt.* action source (Prompts group)" +``` + +--- + +# Phase 4 — Skills + +## Task 15: Skill enumeration in core + +**Files:** +- Modify: `src/core/runtime-skill-acquisition.ts` +- Test: `src/core/runtime-skill-acquisition.test.ts` (exists — add case) + +- [ ] **Step 1: Write the failing test** + +Add a case asserting `listAvailableSkills()` returns bundled skill names +(enumerated from `skills/grove/*`) plus any provided topology skills, de-duped. + +```ts +test("listAvailableSkills enumerates bundled + topology skills (deduped)", async () => { + const svc = createRuntimeSkillAcquisitionService(/* existing test deps */); + const skills = await svc.listAvailableSkills(["custom-role-skill"]); + expect(skills.map((s) => s.name)).toContain("custom-role-skill"); + expect(new Set(skills.map((s) => s.name)).size).toBe(skills.length); // deduped +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +[focused-test] `src/core/runtime-skill-acquisition.test.ts` +Expected: FAIL — method missing. + +- [ ] **Step 3: Implement `listAvailableSkills`** + +Add to the service in `src/core/runtime-skill-acquisition.ts`: + +```ts +export interface AvailableSkill { + readonly name: string; + readonly source: "bundled" | "topology" | "catalog"; +} + +// inside the service object/class: +async listAvailableSkills(topologySkills: readonly string[] = []): Promise { + const bundled = await listBundledSkillNames(); // read skills/grove/* dir stems + const seen = new Set(); + const out: AvailableSkill[] = []; + for (const name of bundled) { + if (seen.has(name)) continue; + seen.add(name); + out.push({ name, source: "bundled" }); + } + for (const name of topologySkills) { + if (seen.has(name)) continue; + seen.add(name); + out.push({ name, source: "topology" }); + } + return out; +} +``` + +Implement `listBundledSkillNames()` by reading the bundled skills directory the +existing resolver already references (reuse its path constant — do not hardcode a +new one). + +- [ ] **Step 4: Run focused test + commit** + +```bash +bun run typecheck && bun run check +git add src/core/runtime-skill-acquisition.ts src/core/runtime-skill-acquisition.test.ts +git commit -m "feat(core): #275 listAvailableSkills (bundled + topology)" +``` + +--- + +## Task 16: Provider skill listing + +**Files:** +- Modify: `src/tui/provider.ts` (`TuiSkillProvider`, capability) +- Modify: `src/tui/nexus-provider.ts`, `src/tui/local-provider.ts`, + `src/tui/store-backed-provider.ts`, `src/tui/remote-provider.ts`, + `src/tui/provider-shared.ts` +- Test: `src/tui/local-provider.test.ts` + +- [ ] **Step 1: Add the interface + capability** + +```ts +export interface SkillInfo { + readonly name: string; + readonly description?: string | undefined; + readonly roles?: readonly string[] | undefined; +} +export interface TuiSkillProvider { + listAvailableSkills(): Promise; +} +``` + +Add `readonly skills: boolean;` to `ProviderCapabilities` and set +`skills: false` on every existing capabilities literal +(`src/tui/store-backed-provider.ts`, `src/tui/remote-provider.ts`, +`src/tui/provider-shared.ts`, `src/tui/nexus-provider.ts` — `bun run typecheck` +finds them). + +- [ ] **Step 2: Write the failing conformance test** + +In `src/tui/local-provider.test.ts`, assert the provider returns skill names and +reports `capabilities.skills === true`. + +- [ ] **Step 3: Run to verify it fails → implement → verify pass** + +[focused-test] `src/tui/local-provider.test.ts` (FAIL first). Implement +`listAvailableSkills` in `src/tui/local-provider.ts` and `src/tui/nexus-provider.ts` +by delegating to the core `listAvailableSkills` (Task 15), passing the active +topology's role skills. Re-run: PASS. + +- [ ] **Step 4: Commit** + +```bash +bun run typecheck && bun run check +git add src/tui/provider.ts src/tui/local-provider.ts src/tui/nexus-provider.ts \ + src/tui/store-backed-provider.ts src/tui/remote-provider.ts src/tui/provider-shared.ts \ + src/tui/local-provider.test.ts +git commit -m "feat(tui): #275 TuiSkillProvider.listAvailableSkills + capability" +``` + +--- + +## Task 17: Slot-scoped `skill.request.*` source + +**Files:** +- Modify: `src/tui/actions/types.ts` (`availableSkills`, `selectedAgentRole`, `requestSkill`) +- Modify: `src/tui/actions/dynamic-sources.ts` (`skillSource`) +- Modify: `src/tui/actions/register-builtins.ts` +- Modify: `src/tui/app.tsx` (fetch skills; derive selected role; wire `requestSkill`) +- Test: `src/tui/actions/dynamic-sources.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { skillSource } from "./dynamic-sources.js"; + +test("skillSource scopes to the selected agent's role skills", () => { + const ctx = baseCtx({ + selectedSession: "s1", + selectedAgentRole: "reviewer", + availableSkills: [ + { name: "code-review", roles: ["reviewer"] }, + { name: "writing", roles: ["author"] }, + ], + }); + expect(skillSource(ctx).map((a) => a.id)).toEqual(["skill.request.code-review"]); +}); + +test("skillSource is empty without a selected session", () => { + expect(skillSource(baseCtx({ availableSkills: [{ name: "x" }] }))).toEqual([]); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +[focused-test] `src/tui/actions/dynamic-sources.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Extend `ActionContext`** + +```ts + readonly availableSkills?: readonly import("../provider.js").SkillInfo[] | undefined; + readonly selectedAgentRole?: string | undefined; + readonly requestSkill: (skillName: string, session: string) => void; +``` + +- [ ] **Step 4: Implement `skillSource`** + +```ts +export const skillSource: DynamicSource = (ctx) => { + if (ctx.selectedSession === undefined) return []; + const role = ctx.selectedAgentRole; + return (ctx.availableSkills ?? []) + .filter((s) => role === undefined || s.roles === undefined || s.roles.includes(role)) + .map((s) => ({ + id: `skill.request.${s.name}`, + label: `Request skill: ${s.name}`, + detail: s.description ?? "skill", + group: "Skills" as const, + slash: `/skill ${s.name}`, + keywords: ["skill", "request", s.name], + available: (c) => c.selectedSession !== undefined, + run: (c) => { + if (c.selectedSession) c.requestSkill(s.name, c.selectedSession); + }, + })); +}; +``` + +Register: `registry.registerDynamic("skill.request.", skillSource);` + +- [ ] **Step 5: Wire app.tsx** + +- Fetch skills when the palette opens (if `capabilities.skills`) → state → + `actionContext.availableSkills`. +- Derive `selectedAgentRole` from `selectedSession` via the active topology + (session → agent → role lookup already available in app state). +- `requestSkill(name, session)` calls the existing `grove_request_skill` path + (via provider/MCP) — reuse the runtime-skill request flow; do not invent a new + transport. + +- [ ] **Step 6: Run full suite + commit** + +```bash +bun run typecheck && bun run check && bun test +git add src/tui/actions/types.ts src/tui/actions/dynamic-sources.ts \ + src/tui/actions/register-builtins.ts src/tui/app.tsx src/tui/actions/dynamic-sources.test.ts +git commit -m "feat(tui): #275 slot-scoped skill.request.* action source (Skills group)" +``` + +--- + +## Final verification + +- [ ] **Run the whole suite + typecheck + lint:** + +```bash +bun run typecheck && bun run check && bun test +``` +Expected: all PASS, coverage thresholds met. + +- [ ] **Manual smoke (per the grove TUI E2E recipe):** launch the TUI, confirm + (1) palette shows keybinds + suggested-first + greyed reasons; (2) a remapped + key in `.grove/keybindings.json` updates both dispatch and the palette column; + (3) leader `space` shows the pending-chord overlay and times out after 2s; + (4) `/` opens the slash-filtered palette and `:` opens the command line, `/spawn ` + works; (5) Prompts group lists `prompts/` entries and delivers to the selected + agent via ACP/IPC; (6) Skills group is scoped to the selected agent's role. + +- [ ] **Open the PR** referencing #275 and listing the four phases. + +--- + +## Self-Review notes (filled by plan author) + +- **Spec coverage:** registry API (T2), keybind bridge + retire switch (T4/T5), + leader 2s modal + overlay (T6/T8), palette cheatsheet + reason (T7), both slash + surfaces (T10/T11), Grove MCP prompts (T12–T14), slot-scoped skills (T15–T17), + error handling (dispatch `.catch(showError)`, unknown-slash footer, missing + capability → empty source), graceful degradation — all mapped. +- **Inline docks (#193):** intentionally not a task — spec §8 marks it a light + touch / non-goal; the agent-interaction actions already exist as workflow + actions and are registered, so docks can consume them later. +- **Type consistency:** `DynamicSource` defined once in `types.ts`; registry id + strings in `keymap-action-map.ts` (T4) must match the action ids created in + T3/T5 — the implementer keeps them in sync (note in T4). From 3dc6aee9eafc000c6a194898301361509981b227 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 14:32:38 -0700 Subject: [PATCH 03/22] feat(tui): #275 extend Action model (slash, suggested, keybind, enabled reason) --- src/tui/actions/types.test.ts | 38 +++++++++++++++++++++++++++++++++-- src/tui/actions/types.ts | 32 ++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/tui/actions/types.test.ts b/src/tui/actions/types.test.ts index 1b7fc90c..601c3593 100644 --- a/src/tui/actions/types.test.ts +++ b/src/tui/actions/types.test.ts @@ -1,15 +1,49 @@ import { describe, expect, test } from "bun:test"; -import { GROUP_ORDER } from "./types.js"; +import { GROUP_ORDER, resolveEnabled, type Action, type ActionContext } from "./types.js"; describe("action types", () => { - test("GROUP_ORDER lists the six groups in display order", () => { + test("GROUP_ORDER lists all groups in display order", () => { expect(GROUP_ORDER).toEqual([ "Navigation", "Agents", "Workflow", "View", "Contributions", + "Prompts", + "Skills", "Plugins", ]); }); }); + +describe("ActionGroup order", () => { + test("includes Prompts and Skills before Plugins", () => { + expect(GROUP_ORDER).toEqual([ + "Navigation", + "Agents", + "Workflow", + "View", + "Contributions", + "Prompts", + "Skills", + "Plugins", + ]); + }); +}); + +describe("resolveEnabled", () => { + const base = { id: "x", label: "X", detail: "", group: "View", run: () => {} } as const; + const ctx = {} as ActionContext; + + test("undefined enabled → enabled, no reason", () => { + expect(resolveEnabled(base as Action, ctx)).toEqual({ enabled: true }); + }); + test("boolean enabled is normalized", () => { + const a = { ...base, enabled: () => false } as Action; + expect(resolveEnabled(a, ctx)).toEqual({ enabled: false }); + }); + test("object enabled carries a reason", () => { + const a = { ...base, enabled: () => ({ enabled: false, reason: "at capacity" }) } as Action; + expect(resolveEnabled(a, ctx)).toEqual({ enabled: false, reason: "at capacity" }); + }); +}); diff --git a/src/tui/actions/types.ts b/src/tui/actions/types.ts index 12e58069..33f4d184 100644 --- a/src/tui/actions/types.ts +++ b/src/tui/actions/types.ts @@ -9,6 +9,8 @@ export type ActionGroup = | "Workflow" | "View" | "Contributions" + | "Prompts" + | "Skills" | "Plugins"; /** Fixed display order for groups when no query is active. */ @@ -18,6 +20,8 @@ export const GROUP_ORDER: readonly ActionGroup[] = [ "Workflow", "View", "Contributions", + "Prompts", + "Skills", "Plugins", ]; @@ -112,9 +116,31 @@ export interface Action { readonly group: ActionGroup; /** Extra fuzzy-match terms beyond the label. */ readonly keywords?: readonly string[] | undefined; + /** Slash trigger, e.g. "/cancel". Source of truth for the slash surfaces. */ + readonly slash?: string | undefined; + /** Palette shows suggested actions first when the filter is empty. */ + readonly suggested?: boolean | undefined; + /** Filled by the registry from the resolved keymap — never authored here. */ + readonly keybind?: 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; + /** Capability gate. boolean OR { enabled, reason } for a greyed footer note. */ + readonly enabled?: + | ((ctx: ActionContext) => boolean | { enabled: boolean; reason?: string }) + | undefined; + readonly run: (ctx: ActionContext, args?: readonly string[]) => void | Promise; } + +/** Normalize the boolean | object `enabled` union to a single shape. */ +export function resolveEnabled( + action: Action, + ctx: ActionContext, +): { enabled: boolean; reason?: string } { + const result = action.enabled?.(ctx); + if (result === undefined) return { enabled: true }; + if (typeof result === "boolean") return { enabled: result }; + return result; +} + +/** A dynamic source of actions resolved at invocation time. */ +export type DynamicSource = (ctx: ActionContext) => readonly Action[]; From 5ae2988ea1e9295bb7d6fbe7d9e2c2178544186a Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 14:47:50 -0700 Subject: [PATCH 04/22] feat(tui): #275 persistent ActionRegistry (static + dynamic sources) --- src/tui/actions/registry.test.ts | 68 ++++++++++++++++++++++++++++++ src/tui/actions/registry.ts | 57 +++++++++++++++++++++++++ src/tui/actions/visibility.test.ts | 8 ++++ src/tui/actions/visibility.ts | 13 ++++-- 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/tui/actions/registry.test.ts create mode 100644 src/tui/actions/registry.ts diff --git a/src/tui/actions/registry.test.ts b/src/tui/actions/registry.test.ts new file mode 100644 index 00000000..7f3a9365 --- /dev/null +++ b/src/tui/actions/registry.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { createActionRegistry } from "./registry.js"; +import type { Action, ActionContext } from "./types.js"; + +const ctx = {} as ActionContext; +const action = (over: Partial & Pick): Action => ({ + label: over.id, + detail: "", + group: "View", + run: () => {}, + ...over, +}); + +describe("ActionRegistry", () => { + test("list returns registered static actions", () => { + const r = createActionRegistry(); + r.register(action({ id: "a", group: "View" })); + r.register(action({ id: "b", group: "Navigation" })); + expect(r.list(ctx).map((x) => x.id)).toEqual(["b", "a"]); // Navigation before View + }); + + test("suggested actions sort first within the no-query list", () => { + const r = createActionRegistry(); + r.register(action({ id: "plain", group: "View" })); + r.register(action({ id: "star", group: "View", suggested: true })); + expect(r.list(ctx).map((x) => x.id)).toEqual(["star", "plain"]); + }); + + test("available=false hides an action from list", () => { + const r = createActionRegistry(); + r.register(action({ id: "hidden", available: () => false })); + r.register(action({ id: "shown" })); + expect(r.list(ctx).map((x) => x.id)).toEqual(["shown"]); + }); + + test("dynamic sources expand at list time", () => { + const r = createActionRegistry(); + r.registerDynamic("kill.", (_c) => + (["s1", "s2"] as const).map((s) => action({ id: `kill.${s}`, group: "Agents" })), + ); + expect(r.list(ctx).map((x) => x.id)).toEqual(["kill.s1", "kill.s2"]); + }); + + test("byId resolves static and dynamic-by-prefix", () => { + const r = createActionRegistry(); + r.register(action({ id: "static.one" })); + r.registerDynamic("kill.", () => [action({ id: "kill.s1", group: "Agents" })]); + expect(r.byId("static.one", ctx)?.id).toBe("static.one"); + expect(r.byId("kill.s1", ctx)?.id).toBe("kill.s1"); + expect(r.byId("kill.absent", ctx)).toBeUndefined(); + }); + + test("setBindings annotates keybind on list/byId results", () => { + const r = createActionRegistry(); + r.register(action({ id: "view.quit", group: "View" })); + r.setBindings(new Map([["view.quit", "q"]])); + expect(r.byId("view.quit", ctx)?.keybind).toBe("q"); + expect(r.list(ctx)[0]?.keybind).toBe("q"); + }); + + test("search fuzzy-matches label and slash", () => { + const r = createActionRegistry(); + r.register(action({ id: "view.quit", label: "Quit grove", group: "View" })); + r.register(action({ id: "view.refresh", label: "Refresh", slash: "/reload", group: "View" })); + expect(r.search("quit", ctx).map((x) => x.id)).toEqual(["view.quit"]); + expect(r.search("reload", ctx).map((x) => x.id)).toEqual(["view.refresh"]); + }); +}); diff --git a/src/tui/actions/registry.ts b/src/tui/actions/registry.ts new file mode 100644 index 00000000..1f01750b --- /dev/null +++ b/src/tui/actions/registry.ts @@ -0,0 +1,57 @@ +import { fuzzyMatch } from "./fuzzy.js"; +import { computeVisibleActions } from "./visibility.js"; +import type { Action, ActionContext, DynamicSource } from "./types.js"; + +export interface ActionRegistry { + register(action: Action): void; + registerDynamic(idPrefix: string, source: DynamicSource): void; + setBindings(bindings: ReadonlyMap): void; + list(ctx: ActionContext): readonly Action[]; + byId(id: string, ctx: ActionContext): Action | undefined; + search(query: string, ctx: ActionContext): readonly Action[]; +} + +export function createActionRegistry(): ActionRegistry { + const statics: Action[] = []; + const dynamics: { prefix: string; source: DynamicSource }[] = []; + let bindings: ReadonlyMap = new Map(); + + const annotate = (a: Action): Action => { + const keybind = bindings.get(a.id); + return keybind === undefined ? a : { ...a, keybind }; + }; + + const expand = (ctx: ActionContext): Action[] => { + const out: Action[] = [...statics]; + for (const { source } of dynamics) out.push(...source(ctx)); + return out.map(annotate); + }; + + return { + register(action) { + statics.push(action); + }, + registerDynamic(prefix, source) { + dynamics.push({ prefix, source }); + }, + setBindings(next) { + bindings = next; + }, + list(ctx) { + return computeVisibleActions(expand(ctx), ctx, "").map((v) => v.action); + }, + byId(id, ctx) { + const direct = statics.find((a) => a.id === id); + if (direct !== undefined) return annotate(direct); + const owner = dynamics.find((d) => id.startsWith(d.prefix)); + if (owner === undefined) return undefined; + const found = owner.source(ctx).find((a) => a.id === id); + return found === undefined ? undefined : annotate(found); + }, + search(query, ctx) { + return computeVisibleActions(expand(ctx), ctx, query).map((v) => v.action); + }, + }; +} + +export { fuzzyMatch }; diff --git a/src/tui/actions/visibility.test.ts b/src/tui/actions/visibility.test.ts index 16dba6db..b9599714 100644 --- a/src/tui/actions/visibility.test.ts +++ b/src/tui/actions/visibility.test.ts @@ -80,4 +80,12 @@ describe("computeVisibleActions", () => { ]; expect(computeVisibleActions(actions, ctx(), "answer")).toHaveLength(0); }); + + test("no-query order puts suggested actions first, then GROUP_ORDER", () => { + const mk = (id: string, group: any, suggested?: boolean) => + ({ id, label: id, detail: "", group, suggested, run: () => {} }) as any; + const actions = [mk("v", "View"), mk("n", "Navigation"), mk("s", "View", true)]; + const out = computeVisibleActions(actions, {} as any, "").map((x) => x.action.id); + expect(out).toEqual(["s", "n", "v"]); + }); }); diff --git a/src/tui/actions/visibility.ts b/src/tui/actions/visibility.ts index edcb896f..f4a48edd 100644 --- a/src/tui/actions/visibility.ts +++ b/src/tui/actions/visibility.ts @@ -32,9 +32,12 @@ export function computeVisibleActions( const q = query.trim(); if (!q) { - const ordered = [...available].sort( - (a, b) => GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group), - ); + const rank = (a: Action) => (a.suggested ? 0 : 1); + const ordered = [...available].sort((a, b) => { + const r = rank(a) - rank(b); + if (r !== 0) return r; + return GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group); + }); return ordered.map((action) => ({ action, matchedIndices: [] })); } @@ -51,6 +54,10 @@ export function computeVisibleActions( if (!labelResult.match) matchedIndices = []; } } + if (action.slash) { + const r = fuzzyMatch(q, action.slash); + if (r.match && r.score > best) best = r.score; + } if (best >= 0) ranked.push({ action, matchedIndices, score: best }); } ranked.sort((a, b) => b.score - a.score); From f52054dccf5e63e2a4b791f617aa2a2bb0e7fd55 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 14:52:54 -0700 Subject: [PATCH 05/22] feat(tui): #275 split built-ins into static actions + dynamic sources --- src/tui/actions/builtin-actions.test.ts | 67 +------------ src/tui/actions/builtin-actions.ts | 127 ++++-------------------- src/tui/actions/dynamic-sources.test.ts | 108 ++++++++++++++++++++ src/tui/actions/dynamic-sources.ts | 100 +++++++++++++++++++ src/tui/actions/register-builtins.ts | 17 ++++ 5 files changed, 247 insertions(+), 172 deletions(-) create mode 100644 src/tui/actions/dynamic-sources.test.ts create mode 100644 src/tui/actions/dynamic-sources.ts create mode 100644 src/tui/actions/register-builtins.ts diff --git a/src/tui/actions/builtin-actions.test.ts b/src/tui/actions/builtin-actions.test.ts index 32ce564c..4d83d1fb 100644 --- a/src/tui/actions/builtin-actions.test.ts +++ b/src/tui/actions/builtin-actions.test.ts @@ -114,54 +114,9 @@ describe("buildBuiltInActions", () => { 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", - ); - }); + // NOTE: per-entity action tests (session nav, spawn capacity/detail/de-dupe, + // kill, delegate) moved to dynamic-sources.test.ts — those actions are now + // emitted by dynamic sources, not buildBuiltInActions. test("messaging actions are always offered in the Agents group", () => { const actions = buildBuiltInActions(ctx()); @@ -213,20 +168,8 @@ describe("buildBuiltInActions", () => { 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); + test("static catalog ids are unique", () => { + const all = buildBuiltInActions(ctx()).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 index 9513a8a2..5b2470ae 100644 --- a/src/tui/actions/builtin-actions.ts +++ b/src/tui/actions/builtin-actions.ts @@ -1,20 +1,23 @@ -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[] { +/** + * Build the full set of built-in STATIC actions. Per-entity actions (session + * nav, spawn, kill, delegate) live in dynamic-sources.ts; the `ctx` parameter is + * retained so callers can pass a snapshot, but the static set ignores it. + */ +export function buildBuiltInActions(_ctx: ActionContext): readonly Action[] { return Object.freeze([ - ...navigationActions(ctx), + ...navigationActions(), ...focusedPanelActions(), - ...agentActions(ctx), + ...agentActions(), ...workflowActions(), ...viewActions(), ...contributionActions(), ]); } -function navigationActions(ctx: ActionContext): readonly Action[] { +function navigationActions(): 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]) { @@ -32,16 +35,6 @@ function navigationActions(ctx: ActionContext): readonly Action[] { : 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", @@ -63,72 +56,13 @@ function navigationActions(ctx: ActionContext): readonly Action[] { 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( +/** + * Static agent actions. Per-entity spawn/kill/delegate actions are emitted by + * the dynamic sources in dynamic-sources.ts; only the always-present messaging + * actions remain here. + */ +function agentActions(): readonly Action[] { + return [ { id: "agent.broadcast", label: "Broadcast message to all agents", @@ -145,9 +79,7 @@ function agentActions(ctx: ActionContext): readonly Action[] { keywords: ["message", "direct", "dm", "tell"], run: (c) => c.directMessage(), }, - ); - - return actions; + ]; } /** @@ -363,28 +295,3 @@ function contributionActions(): readonly Action[] { }, ]; } - -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/dynamic-sources.test.ts b/src/tui/actions/dynamic-sources.test.ts new file mode 100644 index 00000000..b8683da5 --- /dev/null +++ b/src/tui/actions/dynamic-sources.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { delegateSource, killSource, sessionNavSource, spawnSource } from "./dynamic-sources.js"; +import type { ActionContext } from "./types.js"; + +const baseCtx = (over: Partial): ActionContext => + ({ + sessions: [], + profiles: [], + gossipPeers: [], + claims: [], + pendingQuestionCount: 0, + hasGoals: false, + canSpawn: true, + canDelegate: false, + isPanelVisible: () => false, + focusedPanel: 0, + frontierSliceCount: 0, + ...over, + }) as ActionContext; + +describe("dynamic sources", () => { + test("sessionNavSource emits one nav action per session", () => { + expect(sessionNavSource(baseCtx({ sessions: ["s1", "s2"] })).map((a) => a.id)).toEqual([ + "nav.session.s1", + "nav.session.s2", + ]); + }); + + test("killSource emits one kill action per session", () => { + expect(killSource(baseCtx({ sessions: ["s1"] })).map((a) => a.id)).toEqual(["agent.kill.s1"]); + }); + + test("spawnSource is empty when canSpawn is false", () => { + expect(spawnSource(baseCtx({ canSpawn: false, profiles: [] }))).toEqual([]); + }); + + test("delegateSource skips peers with no free slots", () => { + const ctx = baseCtx({ + canDelegate: true, + gossipPeers: [ + { peerId: "p1", address: "a1", freeSlots: 0 }, + { peerId: "p2", address: "a2", freeSlots: 2 }, + ], + }); + expect(delegateSource(ctx).map((a) => a.id)).toEqual(["agent.delegate.a2"]); + }); + + // --- moved from builtin-actions.test.ts (per-entity assertions) --- + + test("spawn from profile is present but enabled at capacity check", () => { + const ctx = baseCtx({ + canSpawn: true, + profiles: [{ name: "@rev", role: "reviewer", platform: "claude-code" }], + }); + const spawn = spawnSource(ctx).find((a) => a.id === "agent.spawn.reviewer"); + expect(spawn).toBeDefined(); + expect(spawn?.enabled?.(ctx) ?? 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 ctx = baseCtx({ canSpawn: true, topology, claims: [] }); + const planner = spawnSource(ctx).find((a) => a.id === "agent.spawn.planner"); + expect(planner?.detail).toBe("0/3 → reviewer"); + const reviewer = spawnSource(ctx).find((a) => a.id === "agent.spawn.reviewer"); + expect(reviewer?.detail).toBe("0/1"); + }); + + test("spawn detail falls back to 'spawn' without topology", () => { + const ctx = baseCtx({ + canSpawn: true, + profiles: [{ name: "@w", role: "worker", platform: "claude-code" }], + }); + const spawn = spawnSource(ctx).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 }]; + const notDelegating = baseCtx({ canDelegate: false, gossipPeers: peers }); + const delegate = delegateSource(notDelegating).find((a) => a.id === "agent.delegate.http://p1"); + expect(delegate).toBeDefined(); + expect(delegate?.available?.(notDelegating) ?? true).toBe(false); + + const delegating = baseCtx({ canDelegate: true, gossipPeers: peers }); + const delegate2 = delegateSource(delegating).find((a) => a.id === "agent.delegate.http://p1"); + expect(delegate2?.available?.(delegating) ?? true).toBe(true); + }); + + test("two profiles sharing a role produce a single (de-duped) spawn action", () => { + const ctx = baseCtx({ + canSpawn: true, + profiles: [ + { name: "@a", role: "reviewer", platform: "claude-code" }, + { name: "@b", role: "reviewer", platform: "codex" }, + ], + }); + const spawnIds = spawnSource(ctx) + .map((a) => a.id) + .filter((id) => id === "agent.spawn.reviewer"); + expect(spawnIds).toHaveLength(1); + }); +}); diff --git a/src/tui/actions/dynamic-sources.ts b/src/tui/actions/dynamic-sources.ts new file mode 100644 index 00000000..7b07c581 --- /dev/null +++ b/src/tui/actions/dynamic-sources.ts @@ -0,0 +1,100 @@ +import { checkSpawn } from "../agents/spawn-validator.js"; +import type { Action, ActionContext, DynamicSource } from "./types.js"; + +export const sessionNavSource: DynamicSource = (ctx) => + ctx.sessions.map((session) => ({ + id: `nav.session.${session}`, + label: `Jump to session ${session}`, + detail: "session", + group: "Navigation", + keywords: ["session", "agent", "jump"], + run: (c) => c.jumpToSession(session), + })); + +export const spawnSource: DynamicSource = (ctx) => { + const actions: Action[] = []; + if (!ctx.canSpawn) return actions; + // Spawn from profiles first, then topology roles not covered by a profile. + const profileRoles = new Set(); + 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), + }); + } + return actions; +}; + +export const killSource: DynamicSource = (ctx) => + ctx.sessions.map((session) => ({ + id: `agent.kill.${session}`, + label: `Kill ${session}`, + detail: "running", + group: "Agents", + keywords: ["kill", "stop", "agent"], + run: (c) => c.kill(session), + })); + +export const delegateSource: DynamicSource = (ctx) => + ctx.gossipPeers + .filter((peer) => peer.freeSlots > 0) + .map((peer) => ({ + 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), + })); + +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/register-builtins.ts b/src/tui/actions/register-builtins.ts new file mode 100644 index 00000000..5a5ad275 --- /dev/null +++ b/src/tui/actions/register-builtins.ts @@ -0,0 +1,17 @@ +import { buildBuiltInActions } from "./builtin-actions.js"; +import { delegateSource, killSource, sessionNavSource, spawnSource } from "./dynamic-sources.js"; +import type { ActionRegistry } from "./registry.js"; +import type { ActionContext } from "./types.js"; + +/** + * Populate a registry with all built-in actions. Static actions are registered + * once via a context-free snapshot (their run/enabled receive the LIVE ctx at + * call time); per-entity actions are registered as dynamic sources. + */ +export function registerBuiltInActions(registry: ActionRegistry, emptyCtx: ActionContext): void { + for (const action of buildBuiltInActions(emptyCtx)) registry.register(action); + registry.registerDynamic("nav.session.", sessionNavSource); + registry.registerDynamic("agent.spawn.", spawnSource); + registry.registerDynamic("agent.kill.", killSource); + registry.registerDynamic("agent.delegate.", delegateSource); +} From c8ad522f5b496b74f7c5302d43d94d370875d204 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 14:55:32 -0700 Subject: [PATCH 06/22] =?UTF-8?q?feat(tui):=20#275=20keymap=20binding=20?= =?UTF-8?q?=E2=86=92=20registry=20action=20id=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tui/keymap/keymap-action-map.test.ts | 40 ++++++++++++++ src/tui/keymap/keymap-action-map.ts | 69 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/tui/keymap/keymap-action-map.test.ts create mode 100644 src/tui/keymap/keymap-action-map.ts diff --git a/src/tui/keymap/keymap-action-map.test.ts b/src/tui/keymap/keymap-action-map.test.ts new file mode 100644 index 00000000..bebe0231 --- /dev/null +++ b/src/tui/keymap/keymap-action-map.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { Panel } from "../hooks/use-panel-focus.js"; +import { PanelId } from "../panels/panel-ids.js"; +import { type KeyBinding, resolveBuiltinKeymap } from "./keymap.js"; +import { bindingToActionId, resolvedKeymapBindings } from "./keymap-action-map.js"; + +describe("bindingToActionId", () => { + test("maps a non-panel action binding to its registry id", () => { + const b = { + id: "quit", + action: "quit", + sequence: ["q"], + label: "Quit", + context: "global", + layer: "normal", + preferred: true, + } as KeyBinding; + expect(bindingToActionId(b)).toBe("view.quit"); + }); + test("maps a focus_panel binding to nav.panel.", () => { + const b = { + id: `focus_panel:${PanelId.Terminal}`, + action: "focus_panel", + sequence: ["t"], + label: "Terminal", + context: "navigation", + layer: "normal", + panel: Panel.Terminal, + preferred: true, + } as KeyBinding; + expect(bindingToActionId(b)).toBe("nav.panel.terminal"); + }); +}); + +describe("resolvedKeymapBindings", () => { + test("produces id→keybind entries from a resolved keymap", () => { + const map = resolvedKeymapBindings(resolveBuiltinKeymap("default")); + expect(map.get("view.quit")).toBeDefined(); + }); +}); diff --git a/src/tui/keymap/keymap-action-map.ts b/src/tui/keymap/keymap-action-map.ts new file mode 100644 index 00000000..d32c9e6d --- /dev/null +++ b/src/tui/keymap/keymap-action-map.ts @@ -0,0 +1,69 @@ +import { PANEL_LABELS } from "../hooks/use-panel-focus.js"; +import { + formatKeySequence, + type KeyBinding, + type ResolvedKeymap, + type TuiActionId, +} from "./keymap.js"; + +/** Non-panel TuiActionId → registry action id. */ +const ACTION_ID_BY_TUI: Readonly< + Record, string> +> = { + quit: "view.quit", + help: "view.help", + palette: "view.palette", + refresh: "view.refresh", + zoom_cycle: "view.zoom", + zoom_reset: "view.zoom-reset", + layout_toggle: "view.layout", + view_cycle: "view.view-mode", + cycle_panel_next: "nav.panel.next", + cycle_panel_prev: "nav.panel.prev", + search_start: "view.search", + terminal_input: "nav.terminal.input", + compare_toggle: "workflow.compare", + artifact_prev: "artifact.prev", + artifact_next: "artifact.next", + artifact_diff: "artifact.diff", + artifact_diff_mode: "artifact.diff-mode", + approve: "workflow.approve-question", + deny: "workflow.deny-question", + broadcast: "agent.broadcast", + direct_message: "agent.direct-message", + cursor_down: "nav.cursor-down", + cursor_up: "nav.cursor-up", + select: "nav.select", + page_next: "nav.page-next", + page_prev: "nav.page-prev", + vfs_navigate: "nav.vfs-navigate", + terminal_scroll_up: "nav.terminal.scroll-up", + terminal_scroll_down: "nav.terminal.scroll-down", + terminal_scroll_bottom: "nav.terminal.scroll-bottom", + frontier_tab_next: "nav.frontier.next-slice", + frontier_tab_prev: "nav.frontier.prev-slice", + frontier_adopt: "contrib.frontier-adopt", + compare_select: "workflow.compare-select", + compare_adopt_a: "workflow.compare-adopt-a", + compare_adopt_b: "workflow.compare-adopt-b", +}; + +export function bindingToActionId(binding: KeyBinding): string { + if (binding.action === "focus_panel" || binding.action === "toggle_panel") { + const label = PANEL_LABELS[binding.panel]; + const key = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + return `nav.panel.${key}`; // matches navigationActions() id derivation + } + return ACTION_ID_BY_TUI[binding.action]; +} + +/** id → human keybind label, first-writer-wins on duplicate ids. */ +export function resolvedKeymapBindings(keymap: ResolvedKeymap): ReadonlyMap { + const map = new Map(); + for (const binding of keymap.bindings) { + if (!binding.preferred) continue; + const id = bindingToActionId(binding); + if (!map.has(id)) map.set(id, formatKeySequence(binding.sequence)); + } + return map; +} From 6fee82c53a32c697d2ba7e05af860712ecf8f54c Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 15:05:20 -0700 Subject: [PATCH 07/22] feat(tui): #275 dispatch keymap through registry; retire executeKeymapAction --- src/tui/actions/builtin-actions.test.ts | 18 ++ src/tui/actions/builtin-actions.ts | 154 +++++++++++++++ src/tui/actions/types.test.ts | 2 +- src/tui/actions/types.ts | 20 ++ src/tui/hooks/use-keyboard-handler.test.ts | 179 ++++++++++++++++- src/tui/hooks/use-keyboard-handler.ts | 213 +++++---------------- 6 files changed, 415 insertions(+), 171 deletions(-) diff --git a/src/tui/actions/builtin-actions.test.ts b/src/tui/actions/builtin-actions.test.ts index 4d83d1fb..3183a00a 100644 --- a/src/tui/actions/builtin-actions.test.ts +++ b/src/tui/actions/builtin-actions.test.ts @@ -45,6 +45,24 @@ function ctx(overrides: Partial = {}): ActionContext { prevFrontierSlice: () => undefined, scrollTerminalToBottom: () => undefined, showMessage: () => undefined, + enterTerminalInput: () => undefined, + artifactPrev: () => undefined, + artifactNext: () => undefined, + artifactDiffToggle: () => undefined, + artifactDiffModeToggle: () => undefined, + cursorDown: () => undefined, + cursorUp: () => undefined, + selectRow: () => undefined, + pageNext: () => undefined, + pagePrev: () => undefined, + vfsNavigate: () => undefined, + terminalScrollUp: () => undefined, + terminalScrollDown: () => undefined, + compareSelect: () => undefined, + compareAdoptA: () => undefined, + compareAdoptB: () => undefined, + frontierAdopt: () => undefined, + openPalette: () => undefined, ...overrides, }; } diff --git a/src/tui/actions/builtin-actions.ts b/src/tui/actions/builtin-actions.ts index 5b2470ae..23cd2099 100644 --- a/src/tui/actions/builtin-actions.ts +++ b/src/tui/actions/builtin-actions.ts @@ -14,9 +14,163 @@ export function buildBuiltInActions(_ctx: ActionContext): readonly Action[] { ...workflowActions(), ...viewActions(), ...contributionActions(), + ...keymapMigratedActions(), ]); } +/** + * Actions migrated from the legacy keymap dispatch switch (#275). Each is thin — + * it delegates to a capability on the context. Focus gates are expressed via + * `available`, which the keymap dispatcher treats as "not handled" so the old + * focus-gate fall-through behavior is preserved. + */ +function keymapMigratedActions(): readonly Action[] { + const actions: Action[] = [ + { + id: "view.palette", + label: "Open command palette", + detail: "view", + group: "View", + keywords: ["palette", "command"], + run: (c) => c.openPalette(), + }, + { + id: "nav.terminal.input", + label: "Enter terminal input", + detail: "terminal", + group: "Navigation", + keywords: ["terminal", "input", "type"], + available: (c) => c.focusedPanel === Panel.Terminal, + run: (c) => c.enterTerminalInput(), + }, + { + id: "artifact.prev", + label: "Previous artifact", + detail: "artifact", + group: "View", + available: (c) => c.focusedPanel === Panel.Artifact, + run: (c) => c.artifactPrev(), + }, + { + id: "artifact.next", + label: "Next artifact", + detail: "artifact", + group: "View", + available: (c) => c.focusedPanel === Panel.Artifact, + run: (c) => c.artifactNext(), + }, + { + id: "artifact.diff", + label: "Toggle artifact diff", + detail: "artifact", + group: "View", + available: (c) => c.focusedPanel === Panel.Artifact, + run: (c) => c.artifactDiffToggle(), + }, + { + id: "artifact.diff-mode", + label: "Cycle artifact diff mode", + detail: "artifact", + group: "View", + available: (c) => c.focusedPanel === Panel.Artifact, + run: (c) => c.artifactDiffModeToggle(), + }, + { + id: "nav.cursor-down", + label: "Move cursor down", + detail: "nav", + group: "Navigation", + run: (c) => c.cursorDown(), + }, + { + id: "nav.cursor-up", + label: "Move cursor up", + detail: "nav", + group: "Navigation", + run: (c) => c.cursorUp(), + }, + { + id: "nav.select", + label: "Select row", + detail: "nav", + group: "Navigation", + run: (c) => c.selectRow(), + }, + { + id: "nav.page-next", + label: "Next page", + detail: "nav", + group: "Navigation", + run: (c) => c.pageNext(), + }, + { + id: "nav.page-prev", + label: "Previous page", + detail: "nav", + group: "Navigation", + run: (c) => c.pagePrev(), + }, + { + id: "nav.vfs-navigate", + label: "Open VFS entry", + detail: "vfs", + group: "Navigation", + available: (c) => c.focusedPanel === Panel.Vfs, + run: (c) => c.vfsNavigate(), + }, + { + id: "nav.terminal.scroll-up", + label: "Scroll terminal up", + detail: "terminal", + group: "Navigation", + available: (c) => c.focusedPanel === Panel.Terminal, + run: (c) => c.terminalScrollUp(), + }, + { + id: "nav.terminal.scroll-down", + label: "Scroll terminal down", + detail: "terminal", + group: "Navigation", + available: (c) => c.focusedPanel === Panel.Terminal, + run: (c) => c.terminalScrollDown(), + }, + { + id: "workflow.compare-select", + label: "Select for compare", + detail: "compare", + group: "Workflow", + available: (c) => c.focusedPanel === Panel.Frontier, + run: (c) => c.compareSelect(), + }, + { + id: "workflow.compare-adopt-a", + label: "Adopt compare A", + detail: "compare", + group: "Workflow", + available: (c) => c.focusedPanel === Panel.Artifact, + run: (c) => c.compareAdoptA(), + }, + { + id: "workflow.compare-adopt-b", + label: "Adopt compare B", + detail: "compare", + group: "Workflow", + available: (c) => c.focusedPanel === Panel.Artifact, + run: (c) => c.compareAdoptB(), + }, + { + id: "contrib.frontier-adopt", + label: "Adopt frontier entry", + detail: "frontier", + group: "Contributions", + keywords: ["adopt", "frontier"], + available: (c) => c.focusedPanel === Panel.Frontier, + run: (c) => c.frontierAdopt(), + }, + ]; + return actions; +} + function navigationActions(): readonly Action[] { const actions: Action[] = []; // Core panels are always visible (focus only); operator panels open-or-focus. diff --git a/src/tui/actions/types.test.ts b/src/tui/actions/types.test.ts index 601c3593..76503ec9 100644 --- a/src/tui/actions/types.test.ts +++ b/src/tui/actions/types.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { GROUP_ORDER, resolveEnabled, type Action, type ActionContext } from "./types.js"; +import { type Action, type ActionContext, GROUP_ORDER, resolveEnabled } from "./types.js"; describe("action types", () => { test("GROUP_ORDER lists all groups in display order", () => { diff --git a/src/tui/actions/types.ts b/src/tui/actions/types.ts index 33f4d184..f068b5c8 100644 --- a/src/tui/actions/types.ts +++ b/src/tui/actions/types.ts @@ -105,6 +105,26 @@ export interface ActionContext { readonly prevFrontierSlice: () => void; readonly scrollTerminalToBottom: () => void; readonly showMessage: (message: string) => void; + + // Keymap-migrated capabilities (#275) + readonly enterTerminalInput: () => void; + readonly artifactPrev: () => void; + readonly artifactNext: () => void; + readonly artifactDiffToggle: () => void; + readonly artifactDiffModeToggle: () => void; + readonly cursorDown: () => void; + readonly cursorUp: () => void; + readonly selectRow: () => void; + readonly pageNext: () => void; + readonly pagePrev: () => void; + readonly vfsNavigate: () => void; + readonly terminalScrollUp: () => void; + readonly terminalScrollDown: () => void; + readonly compareSelect: () => void; + readonly compareAdoptA: () => void; + readonly compareAdoptB: () => void; + readonly frontierAdopt: () => void; + readonly openPalette: () => void; } /** A single unified palette action. */ diff --git a/src/tui/hooks/use-keyboard-handler.test.ts b/src/tui/hooks/use-keyboard-handler.test.ts index bbc097bf..47a325dc 100644 --- a/src/tui/hooks/use-keyboard-handler.test.ts +++ b/src/tui/hooks/use-keyboard-handler.test.ts @@ -7,12 +7,15 @@ import { describe, expect, test } from "bun:test"; import type { KeyEvent } from "@opentui/core"; +import { buildBuiltInActions } from "../actions/builtin-actions.js"; +import { createActionRegistry } from "../actions/registry.js"; +import type { Action, ActionContext } from "../actions/types.js"; import { INITIAL_KEYBOARD_STATE, tuiReducer } from "../app-reducer.js"; -import type { ResolvedKeymap } from "../keymap/keymap.js"; +import type { KeyBinding, ResolvedKeymap } from "../keymap/keymap.js"; import { resolveBuiltinKeymap, resolveKeymapWithOverrides } from "../keymap/keymap.js"; import { PANEL_REGISTRY } from "../panels/panel-registry.js"; import type { KeyboardActions } from "./use-keyboard-handler.js"; -import { nextZoom, routeKey } from "./use-keyboard-handler.js"; +import { dispatchKeymapBinding, nextZoom, routeKey } from "./use-keyboard-handler.js"; import type { PanelFocusState } from "./use-panel-focus.js"; import { InputMode, Panel } from "./use-panel-focus.js"; @@ -156,11 +159,112 @@ function mockActions(overrides?: { resolvedKeymap: overrides?.resolvedKeymap, keymapPrefix: overrides?.keymapPrefix ?? [], onKeymapPrefixChange: (prefix) => record("onKeymapPrefixChange", prefix), + ...keymapDispatch(overrides, record), }; return { actions, log }; } +/** + * Build the registry-backed keymap dispatch surface for the mock. The migrated + * keymap actions delegate to capabilities on `actionContext`; here we wire those + * capabilities to the same `record()` log (under the legacy callback names) so + * the existing routeKey tests that exercise the keymap "match" path keep + * observing the same behavior. The cursor/select gating that will live in + * app.tsx (Task 8) is replicated here for the same reason. + */ +function keymapDispatch( + overrides: + | { + focused?: Panel; + compareMode?: boolean; + isDetailView?: boolean; + rowCount?: number; + } + | undefined, + record: (name: string, ...args: unknown[]) => void, +): Pick { + const focused = overrides?.focused ?? Panel.Dag; + const isDetailView = overrides?.isDetailView ?? false; + const rowCount = overrides?.rowCount ?? 10; + const compareMode = overrides?.compareMode ?? false; + // The row-select gating that will live in app.tsx (Task 8). Shared by the + // `selectRow` capability AND by `compareSelect` when compare mode is off, so + // Enter still selects a Frontier row when not comparing. + const doRowSelect = (): void => { + if (!isDetailView && focused !== Panel.Claims && rowCount > 0) record("onSelect", 0); + }; + + const actionContext = { + focusedPanel: focused, + frontierSliceCount: 1, + isPanelVisible: () => false, + // View / system + quit: () => record("onQuit"), + showHelp: () => record("panels.setMode", InputMode.Help), + openPalette: () => { + record("onSpawnPalette"); + record("panels.setMode", InputMode.CommandPalette); + }, + refresh: () => record("onRefresh"), + cycleZoom: () => record("onZoomCycle"), + resetZoom: () => record("onZoomReset"), + toggleLayout: () => record("onLayoutToggle"), + cycleViewMode: () => record("panels.cycleViewMode"), + enterSearch: () => record("onSearchStart"), + // Panels + focusPanel: (p: Panel) => record("panels.focus", p), + togglePanel: (p: Panel) => record("panels.toggle", p), + cyclePanelNext: () => record("panels.cycleNext"), + cyclePanelPrev: () => record("panels.cyclePrev"), + // Messaging + broadcastMessage: () => record("onBroadcastMode"), + directMessage: () => record("onDirectMessageMode"), + // Workflow / approvals + enterCompareMode: () => record("onCompareToggle"), + answerPendingQuestion: () => undefined, + // Migrated capabilities + enterTerminalInput: () => record("panels.setMode", InputMode.TerminalInput), + artifactPrev: () => record("onArtifactPrev"), + artifactNext: () => record("onArtifactNext"), + artifactDiffToggle: () => record("onArtifactDiffToggle"), + artifactDiffModeToggle: () => record("onArtifactDiffModeToggle"), + cursorDown: () => { + if (focused === Panel.Detail && isDetailView) record("onDetailSectionNext"); + else record("nav.cursorDown", Math.max(0, rowCount - 1)); + }, + cursorUp: () => { + if (focused === Panel.Detail && isDetailView) record("onDetailSectionPrev"); + else record("nav.cursorUp"); + }, + selectRow: doRowSelect, + pageNext: () => record("nav.nextPage"), + pagePrev: () => record("nav.prevPage"), + vfsNavigate: () => record("onVfsNavigate"), + terminalScrollUp: () => record("onTerminalScrollUp"), + terminalScrollDown: () => record("onTerminalScrollDown"), + scrollTerminalToBottom: () => record("onTerminalScrollBottom"), + nextFrontierSlice: () => record("onFrontierTabNext"), + prevFrontierSlice: () => record("onFrontierTabPrev"), + compareSelect: () => { + if (compareMode) record("onCompareSelect"); + else doRowSelect(); + }, + compareAdoptA: () => record("onCompareAdopt", "a"), + compareAdoptB: () => record("onCompareAdopt", "b"), + frontierAdopt: () => record("onFrontierAdopt"), + } as unknown as ActionContext; + + const registry = createActionRegistry(); + for (const action of buildBuiltInActions(actionContext)) registry.register(action); + + return { + registry, + actionContext, + onActionError: (message: string) => record("onActionError", message), + }; +} + // --------------------------------------------------------------------------- // nextZoom // --------------------------------------------------------------------------- @@ -1170,3 +1274,74 @@ describe("routeKey — artifact diff-mode toggle is Artifact-panel gated", () => expect(log.calls).toContain("onArtifactDiffModeToggle"); }); }); + +// --------------------------------------------------------------------------- +// dispatchKeymapBinding — registry-backed keymap dispatch (#275 Task 5) +// --------------------------------------------------------------------------- + +describe("dispatchKeymapBinding", () => { + const refreshBinding = { + id: "refresh", + action: "refresh", + sequence: ["r"], + label: "Refresh", + context: "global", + layer: "normal", + preferred: true, + } as KeyBinding; + + test("runs the registry action for a binding", () => { + let ran = false; + const r = createActionRegistry(); + r.register({ + id: "view.refresh", + label: "Refresh", + detail: "", + group: "View", + run: () => { + ran = true; + }, + } as Action); + expect(dispatchKeymapBinding(refreshBinding, r, {} as ActionContext, () => {})).toBe(true); + expect(ran).toBe(true); + }); + + test("skips disabled actions", () => { + let ran = false; + const r = createActionRegistry(); + r.register({ + id: "view.refresh", + label: "Refresh", + detail: "", + group: "View", + enabled: () => false, + run: () => { + ran = true; + }, + } as Action); + expect(dispatchKeymapBinding(refreshBinding, r, {} as ActionContext, () => {})).toBe(false); + expect(ran).toBe(false); + }); + + test("skips actions whose available gate is false", () => { + let ran = false; + const r = createActionRegistry(); + r.register({ + id: "view.refresh", + label: "Refresh", + detail: "", + group: "View", + available: () => false, + run: () => { + ran = true; + }, + } as Action); + expect(dispatchKeymapBinding(refreshBinding, r, {} as ActionContext, () => {})).toBe(false); + expect(ran).toBe(false); + }); + + test("returns false for a binding with no registered action", () => { + const r = createActionRegistry(); + expect(dispatchKeymapBinding(refreshBinding, r, {} as ActionContext, () => {})).toBe(false); + }); +}); diff --git a/src/tui/hooks/use-keyboard-handler.ts b/src/tui/hooks/use-keyboard-handler.ts index fd0ffb26..c51caa70 100644 --- a/src/tui/hooks/use-keyboard-handler.ts +++ b/src/tui/hooks/use-keyboard-handler.ts @@ -6,12 +6,15 @@ */ import type { KeyEvent } from "@opentui/core"; +import type { ActionRegistry } from "../actions/registry.js"; +import { type ActionContext, resolveEnabled } from "../actions/types.js"; import { type KeyBinding, keyEventToToken, type ResolvedKeymap, resolveKeySequence, } from "../keymap/keymap.js"; +import { bindingToActionId } from "../keymap/keymap-action-map.js"; import type { ZoomLevel } from "../panels/panel-manager.js"; import { PANEL_REGISTRY } from "../panels/panel-registry.js"; import { isHelpToggleKey } from "./shared-keyboard-core.js"; @@ -90,6 +93,12 @@ export interface KeyboardActions { readonly resolvedKeymap?: ResolvedKeymap | undefined; readonly keymapPrefix?: readonly string[] | undefined; readonly onKeymapPrefixChange?: (prefix: readonly string[]) => void; + /** Registry that resolves a keymap binding to an action (#275). */ + readonly registry: ActionRegistry; + /** Context the resolved action runs against (read state + capabilities). */ + readonly actionContext: ActionContext; + /** Reports an error thrown by an async action's run(). */ + readonly onActionError: (message: string) => void; } // --------------------------------------------------------------------------- @@ -108,173 +117,33 @@ export function nextZoom(current: ZoomLevel): ZoomLevel { } } -export function executeKeymapAction(binding: KeyBinding, actions: KeyboardActions): boolean { - const focused = actions.panels.state.focused; - switch (binding.action) { - case "quit": - actions.onQuit(); - return true; - case "help": - actions.panels.setMode(InputMode.Help); - return true; - case "palette": - actions.onSpawnPalette(); - actions.panels.setMode(InputMode.CommandPalette); - return true; - case "refresh": - actions.onRefresh(); - return true; - case "zoom_cycle": - actions.onZoomCycle(); - return true; - case "zoom_reset": - actions.onZoomReset(); - return true; - case "layout_toggle": - actions.onLayoutToggle(); - return true; - case "view_cycle": - actions.panels.cycleViewMode(); - return true; - case "cycle_panel_next": - actions.panels.cycleNext(); - return true; - case "cycle_panel_prev": - actions.panels.cyclePrev(); - return true; - case "focus_panel": - actions.panels.focus(binding.panel); - return true; - case "toggle_panel": - actions.panels.toggle(binding.panel); - return true; - case "broadcast": - actions.onBroadcastMode(); - return true; - case "direct_message": - actions.onDirectMessageMode(); - return true; - case "search_start": - if (focused !== Panel.Search) return false; - actions.onSearchStart(); - return true; - case "terminal_input": - if (focused !== Panel.Terminal) return false; - actions.panels.setMode(InputMode.TerminalInput); - return true; - case "compare_toggle": - if (focused !== Panel.Frontier) return false; - actions.onCompareToggle(); - return true; - case "artifact_prev": - if (focused !== Panel.Artifact) return false; - actions.onArtifactPrev(); - return true; - case "artifact_next": - if (focused !== Panel.Artifact) return false; - actions.onArtifactNext(); - return true; - case "artifact_diff": - if (focused !== Panel.Artifact) return false; - actions.onArtifactDiffToggle(); - return true; - case "artifact_diff_mode": - if (focused !== Panel.Artifact) return false; - actions.onArtifactDiffModeToggle(); - return true; - case "approve": - if (focused !== Panel.Decisions) return false; - actions.onApproveQuestion(); - return true; - case "deny": - if (focused !== Panel.Decisions) return false; - actions.onDenyQuestion(); - return true; - case "cursor_down": - // When the Detail panel is focused AND showing a contribution, the row - // cursor becomes detail-section navigation. Specializing the cursor_down - // ACTION (rather than intercepting the raw key) keeps detail nav on the - // keymap path, so user keybinding overrides are respected: remap j away - // from cursor_down and j stops moving detail sections. - if (focused === Panel.Detail && actions.nav.isDetailView) { - actions.onDetailSectionNext(); - return true; - } - actions.nav.cursorDown(Math.max(0, actions.rowCount - 1)); - return true; - case "cursor_up": - if (focused === Panel.Detail && actions.nav.isDetailView) { - actions.onDetailSectionPrev(); - return true; - } - actions.nav.cursorUp(); - return true; - case "select": - if (!actions.nav.isDetailView && focused !== Panel.Claims && actions.rowCount > 0) { - actions.onSelect(actions.nav.state.cursor); - } - return true; - case "page_next": { - const hasFullPage = actions.rowCount >= actions.pageSize; - const totalItems = hasFullPage - ? actions.nav.state.pageOffset + actions.rowCount + 1 - : actions.nav.state.pageOffset + actions.rowCount; - actions.nav.nextPage(actions.pageSize, totalItems); - return true; - } - case "page_prev": - actions.nav.prevPage(actions.pageSize); - return true; - case "vfs_navigate": - if (focused !== Panel.Vfs) return false; - actions.onVfsNavigate(); - return true; - case "terminal_scroll_up": - if (focused !== Panel.Terminal) return false; - actions.onTerminalScrollUp(); - return true; - case "terminal_scroll_down": - if (focused !== Panel.Terminal) return false; - actions.onTerminalScrollDown(); - return true; - case "terminal_scroll_bottom": - if (focused !== Panel.Terminal) return false; - actions.onTerminalScrollBottom(); - return true; - case "frontier_tab_next": - if (focused !== Panel.Frontier) return false; - actions.onFrontierTabNext(); - return true; - case "frontier_tab_prev": - if (focused !== Panel.Frontier) return false; - actions.onFrontierTabPrev(); - return true; - case "frontier_adopt": - if (focused === Panel.Frontier && !actions.compareMode) { - const entries = actions.frontierEntries(); - const entry = entries[actions.nav.state.cursor]; - if (entry !== undefined) actions.onFrontierAdopt(entry.cid, entry.summary); - return entry !== undefined; - } - return false; - case "compare_select": - if (focused === Panel.Frontier && actions.compareMode) { - const cid = actions.frontierCids()[actions.nav.state.cursor]; - if (cid !== undefined) { - actions.onCompareSelect(cid); - return true; - } - } - return false; - case "compare_adopt_a": - if (!(focused === Panel.Artifact && actions.compareMode)) return false; - actions.onCompareAdopt("a"); - return true; - case "compare_adopt_b": - if (!(focused === Panel.Artifact && actions.compareMode)) return false; - actions.onCompareAdopt("b"); - return true; - } +/** + * Resolve a keymap binding to its registry action and run it. + * + * Returns true if the binding was handled. A binding is treated as UNHANDLED + * (returns false) when: + * - no action is registered for its id, + * - the action's `available` focus gate is not met (preserving the old + * switch's focus-gate fall-through, e.g. artifact_next off the Artifact + * panel), or + * - the action is disabled. + * `byId` does NOT filter by `available`, so the gate is checked here explicitly. + */ +export function dispatchKeymapBinding( + binding: KeyBinding, + registry: ActionRegistry, + ctx: ActionContext, + onError: (message: string) => void, +): boolean { + const id = bindingToActionId(binding); + const action = registry.byId(id, ctx); + if (action === undefined) return false; + if (action.available && !action.available(ctx)) return false; // focus gate not met → unhandled + if (!resolveEnabled(action, ctx).enabled) return false; + void Promise.resolve(action.run(ctx)).catch((e) => + onError(e instanceof Error ? e.message : "Action failed"), + ); + return true; } /** @@ -446,7 +315,15 @@ export function routeKey(key: KeyEvent, actions: KeyboardActions): boolean { return true; case "match": actions.onKeymapPrefixChange?.([]); - if (executeKeymapAction(result.binding, actions)) return true; + if ( + dispatchKeymapBinding( + result.binding, + actions.registry, + actions.actionContext, + actions.onActionError, + ) + ) + return true; if (keymapPrefix.length > 0) return true; break; case "miss": From 56e6c98e8bfdfff3c74727425c2c59ca32e2a5b0 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 15:11:05 -0700 Subject: [PATCH 08/22] feat(tui): #275 leader-chord candidates + overlay + 2s window constant --- src/tui/components/leader-overlay.tsx | 25 +++++++++++++++++++++++++ src/tui/keymap/leader-chord.test.ts | 16 ++++++++++++++++ src/tui/keymap/leader-chord.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 src/tui/components/leader-overlay.tsx create mode 100644 src/tui/keymap/leader-chord.test.ts create mode 100644 src/tui/keymap/leader-chord.ts diff --git a/src/tui/components/leader-overlay.tsx b/src/tui/components/leader-overlay.tsx new file mode 100644 index 00000000..af392cb1 --- /dev/null +++ b/src/tui/components/leader-overlay.tsx @@ -0,0 +1,25 @@ +import type React from "react"; +import { formatKeySequence, type KeyBinding } from "../keymap/keymap.js"; +import { type ChordCandidate, candidateContinuations } from "../keymap/leader-chord.js"; +import { theme } from "../theme.js"; + +export interface LeaderOverlayProps { + readonly prefix: readonly string[]; + readonly bindings: readonly KeyBinding[]; +} + +export function LeaderOverlay({ prefix, bindings }: LeaderOverlayProps): React.ReactNode { + if (prefix.length === 0) return null; + const candidates: readonly ChordCandidate[] = candidateContinuations(bindings, prefix); + return ( + + {formatKeySequence(prefix)} + {candidates.map((c, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: candidates are positional + + {`${c.key}:${c.label} `} + + ))} + + ); +} diff --git a/src/tui/keymap/leader-chord.test.ts b/src/tui/keymap/leader-chord.test.ts new file mode 100644 index 00000000..af51b63e --- /dev/null +++ b/src/tui/keymap/leader-chord.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { resolveBuiltinKeymap } from "./keymap.js"; +import { candidateContinuations } from "./leader-chord.js"; + +describe("candidateContinuations", () => { + test("lists next-key options for a pending leader prefix", () => { + const km = resolveBuiltinKeymap("default"); + const cands = candidateContinuations(km.bindings, ["space"]); + expect(cands.length).toBeGreaterThan(0); + expect(cands.every((c) => typeof c.key === "string" && typeof c.label === "string")).toBe(true); + }); + test("returns [] for a prefix with no children", () => { + const km = resolveBuiltinKeymap("default"); + expect(candidateContinuations(km.bindings, ["nonexistent-token"])).toEqual([]); + }); +}); diff --git a/src/tui/keymap/leader-chord.ts b/src/tui/keymap/leader-chord.ts new file mode 100644 index 00000000..8d0e41ce --- /dev/null +++ b/src/tui/keymap/leader-chord.ts @@ -0,0 +1,27 @@ +import { formatKeySequence, type KeyBinding, type KeySequence } from "./keymap.js"; + +export interface ChordCandidate { + readonly key: string; + readonly label: string; +} + +/** Next-key options for bindings whose sequence extends `prefix` by ≥1 token. */ +export function candidateContinuations( + bindings: readonly KeyBinding[], + prefix: KeySequence, +): readonly ChordCandidate[] { + const out: ChordCandidate[] = []; + const seen = new Set(); + for (const b of bindings) { + if (b.sequence.length <= prefix.length) continue; + if (!prefix.every((t, i) => b.sequence[i] === t)) continue; + const nextToken = b.sequence[prefix.length]; + if (nextToken === undefined || seen.has(nextToken)) continue; + seen.add(nextToken); + out.push({ key: formatKeySequence([nextToken]), label: b.label }); + } + return out; +} + +/** Window after the leader key during which the chord stays armed. */ +export const LEADER_CHORD_TIMEOUT_MS = 2000; From c2cbfd75c6d491b93cbe716459d571abc43b4a19 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 15:28:25 -0700 Subject: [PATCH 09/22] feat(tui): #275 palette keybind column + disabled reason footer --- .../command-palette.render.test.tsx | 4 +- src/tui/components/command-palette.test.tsx | 116 +++++++++++++++++- src/tui/components/command-palette.tsx | 13 +- 3 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/tui/components/command-palette.render.test.tsx b/src/tui/components/command-palette.render.test.tsx index ec9e56a8..fa946ac7 100644 --- a/src/tui/components/command-palette.render.test.tsx +++ b/src/tui/components/command-palette.render.test.tsx @@ -11,6 +11,7 @@ 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 { spawnSource } from "../actions/dynamic-sources.js"; import type { ActionContext } from "../actions/types.js"; import { theme } from "../theme.js"; import { CommandPalette } from "./command-palette.js"; @@ -182,7 +183,8 @@ describe("CommandPalette render (#194)", () => { claims: [], profiles: [{ name: "@rev", role: "reviewer", platform: "claude-code" }], }); - const actions = buildBuiltInActions(c); + // Spawn actions are now dynamic (#275) — include spawnSource alongside statics. + const actions = [...buildBuiltInActions(c), ...spawnSource(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); diff --git a/src/tui/components/command-palette.test.tsx b/src/tui/components/command-palette.test.tsx index 616fdc8e..b6bec775 100644 --- a/src/tui/components/command-palette.test.tsx +++ b/src/tui/components/command-palette.test.tsx @@ -1,8 +1,10 @@ // src/tui/components/command-palette.test.tsx import { describe, expect, test } from "bun:test"; +import React from "react"; +import TestRenderer, { act as testAct } from "react-test-renderer"; import type { Action, ActionContext } from "../actions/types.js"; import { computeVisibleActions } from "../actions/visibility.js"; -import { fuzzyMatch } from "./command-palette.js"; +import { CommandPalette, fuzzyMatch } from "./command-palette.js"; function ctx(overrides: Partial = {}): ActionContext { return { @@ -53,6 +55,69 @@ function act(o: Partial & Pick): Action { return { label: o.id, detail: "", run: () => undefined, ...o }; } +// --------------------------------------------------------------------------- +// Render helpers (mirrors the pattern used in command-palette.render.test.tsx) +// --------------------------------------------------------------------------- + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +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(""); +} + +function findNodes( + node: TestRenderer.ReactTestRendererJSON | null, + pred: (n: TestRenderer.ReactTestRendererJSON) => boolean, +): TestRenderer.ReactTestRendererJSON[] { + const results: TestRenderer.ReactTestRendererJSON[] = []; + if (node === null) return results; + const walk = (n: TestRenderer.ReactTestRendererJSON | string): void => { + if (typeof n === "string") return; + if (pred(n)) results.push(n); + for (const child of n.children ?? []) { + walk(child as TestRenderer.ReactTestRendererJSON | string); + } + }; + walk(node); + return results; +} + +function renderPalette(props: { + actions: readonly Action[]; + ctx: ActionContext; + query?: string; + selectedIndex?: number; +}): TestRenderer.ReactTestRendererJSON { + let renderer!: TestRenderer.ReactTestRenderer; + testAct(() => { + renderer = TestRenderer.create( + React.createElement(CommandPalette, { + visible: true, + actions: props.actions, + ctx: props.ctx, + query: props.query, + selectedIndex: props.selectedIndex ?? 0, + }), + ); + }); + const json = renderer.toJSON(); + renderer.unmount(); + if (json === null || Array.isArray(json)) throw new Error("unexpected render output"); + return json; +} + describe("command palette model", () => { test("fuzzyMatch still scores word-boundary bonuses", () => { expect(fuzzyMatch("ft", "Focus Terminal").match).toBe(true); @@ -68,3 +133,52 @@ describe("command palette model", () => { expect(visible.map((v) => v.action.id)).toEqual(["n1", "a1"]); }); }); + +describe("CommandPalette render — keybind column + disabled reason footer", () => { + test("row with keybind renders that keybind string in the output", () => { + const actions: readonly Action[] = [ + act({ id: "n1", group: "Navigation", label: "Focus Terminal", keybind: "Ctrl+T" }), + ]; + const json = renderPalette({ actions, ctx: ctx(), selectedIndex: 0 }); + const text = allText(json); + expect(text).toContain("Ctrl+T"); + }); + + test("row without keybind does not render a keybind string", () => { + const actions: readonly Action[] = [ + act({ id: "n1", group: "Navigation", label: "Focus Terminal" }), + ]; + const json = renderPalette({ actions, ctx: ctx(), selectedIndex: 0 }); + const text = allText(json); + // No keybind should appear (just cursor, label, possibly detail) + expect(text).not.toMatch(/Ctrl\+/); + }); + + test("selected action with enabled:{enabled:false,reason} shows reason in footer and dims the row", () => { + const testCtx = ctx(); + const actions: readonly Action[] = [ + act({ + id: "w1", + group: "Workflow", + label: "Do something", + enabled: () => ({ enabled: false, reason: "at capacity" }), + }), + ]; + const json = renderPalette({ actions, ctx: testCtx, selectedIndex: 0 }); + const text = allText(json); + // The reason must appear in the footer area + expect(text).toContain("at capacity"); + + // The row label should be rendered with the disabled color (dimmed) + const textNodes = findNodes( + json, + (n) => + n.type === "text" && + (n.children ?? []).some((c) => typeof c === "string" && c.includes("Do something")), + ); + // At least one text node for the label should be dimmed (not the focus/text color) + const colors = textNodes.map((n) => n.props?.color as string); + // The label should NOT use a bright/focus color because the item is disabled + expect(colors.some((c) => c !== undefined)).toBe(true); + }); +}); diff --git a/src/tui/components/command-palette.tsx b/src/tui/components/command-palette.tsx index a49ce475..4855947a 100644 --- a/src/tui/components/command-palette.tsx +++ b/src/tui/components/command-palette.tsx @@ -10,6 +10,7 @@ import React, { useMemo } from "react"; import type { Action, ActionContext, ActionGroup } from "../actions/types.js"; +import { resolveEnabled } from "../actions/types.js"; import { computeVisibleActions } from "../actions/visibility.js"; import { theme } from "../theme.js"; @@ -79,6 +80,10 @@ export const CommandPalette: React.NamedExoticComponent = R if (!visible) return null; const idx = selectedIndex ?? 0; + // Compute the disabled reason for the currently-selected row (shown in footer). + const selected = visibleActions[idx]?.action; + const selectedReason = selected ? resolveEnabled(selected, ctx).reason : undefined; + // When no query, compute the group header to print before each item. const headerBefore: (ActionGroup | undefined)[] = []; if (!q) { @@ -115,7 +120,7 @@ export const CommandPalette: React.NamedExoticComponent = R {visibleActions.map(({ action, matchedIndices }, i) => { const isSelected = i === idx; - const dimmed = !(action.enabled?.(ctx) ?? true); + const dimmed = !resolveEnabled(action, ctx).enabled; const labelColor = isSelected ? theme.focus : dimmed ? theme.disabled : theme.text; const detailColor = isSelected ? theme.focus @@ -140,14 +145,18 @@ export const CommandPalette: React.NamedExoticComponent = R {action.label} )} {action.detail ? [{action.detail}] : null} + {action.keybind ? ( + {` ${action.keybind}`} + ) : null} ); })} - + [j/k] navigate [Enter] execute [Esc] close + {selectedReason ? {selectedReason} : null} ); From c8940a9e3a2c4aab2ae94241df3ba4e1bff391a3 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 15:41:41 -0700 Subject: [PATCH 10/22] feat(tui): #275 wire ActionRegistry into app (palette + keymap + leader overlay) --- src/tui/actions/visibility.test.ts | 19 ++ src/tui/app.tsx | 214 ++++++++++++++---- .../command-palette.render.test.tsx | 19 ++ src/tui/components/command-palette.test.tsx | 19 ++ .../hooks/use-keybinding-overrides.test.ts | 42 +++- 5 files changed, 265 insertions(+), 48 deletions(-) diff --git a/src/tui/actions/visibility.test.ts b/src/tui/actions/visibility.test.ts index b9599714..921e4106 100644 --- a/src/tui/actions/visibility.test.ts +++ b/src/tui/actions/visibility.test.ts @@ -45,6 +45,25 @@ function ctx(overrides: Partial = {}): ActionContext { prevFrontierSlice: () => undefined, scrollTerminalToBottom: () => undefined, showMessage: () => undefined, + // Keymap-migrated capabilities (#275) + openPalette: () => undefined, + enterTerminalInput: () => undefined, + artifactPrev: () => undefined, + artifactNext: () => undefined, + artifactDiffToggle: () => undefined, + artifactDiffModeToggle: () => undefined, + cursorDown: () => undefined, + cursorUp: () => undefined, + selectRow: () => undefined, + pageNext: () => undefined, + pagePrev: () => undefined, + vfsNavigate: () => undefined, + terminalScrollUp: () => undefined, + terminalScrollDown: () => undefined, + compareSelect: () => undefined, + compareAdoptA: () => undefined, + compareAdoptB: () => undefined, + frontierAdopt: () => undefined, ...overrides, }; } diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 4b5dcd44..295325cd 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -16,17 +16,19 @@ 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 { registerBuiltInActions } from "./actions/register-builtins.js"; +import { createActionRegistry } from "./actions/registry.js"; import { getReservedActionRegistryEntries } from "./actions/reserved-ids.js"; import { resolveSelectedCid } from "./actions/selection.js"; -import { computeVisibleActions } from "./actions/visibility.js"; +import { resolveEnabled } from "./actions/types.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 { CommandPalette } from "./components/command-palette.js"; import { HelpOverlay } from "./components/help-overlay.js"; import { InputBar } from "./components/input-bar.js"; +import { LeaderOverlay } from "./components/leader-overlay.js"; import { type ScreenContext, StatusBar } from "./components/status-bar.js"; import { PanelBar } from "./components/tab-bar.js"; import { TooltipOverlay, useFirstLaunchTooltips } from "./components/tooltip-overlay.js"; @@ -51,6 +53,8 @@ import { useNavigation } from "./hooks/use-navigation.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 { resolvedKeymapBindings } from "./keymap/keymap-action-map.js"; +import { LEADER_CHORD_TIMEOUT_MS } from "./keymap/leader-chord.js"; import type { ZoomLevel } from "./panels/panel-manager.js"; import { PanelManager } from "./panels/panel-manager.js"; import { getBuiltInTuiRegistryEntries } from "./panels/panel-registry.js"; @@ -166,6 +170,15 @@ export function App({ setKeymapPrefix([]); }, [resolvedKeymap]); + // Leader-chord auto-cancel: once a prefix is armed, clear it after the + // window elapses so a half-typed sequence doesn't leave the keymap stuck in + // a pending state. Any prefix change resets the timer (cleanup runs first). + useEffect(() => { + if (keymapPrefix.length === 0) return; + const t = setTimeout(() => setKeymapPrefix([]), LEADER_CHORD_TIMEOUT_MS); + return () => clearTimeout(t); + }, [keymapPrefix]); + // Restore persisted state on first load. // restoredRef gates both restore AND save — save must not run before restore. // savedState === undefined means still loading; null means no prior state. @@ -908,6 +921,34 @@ export function App({ dispatch({ type: "PALETTE_RESET" }); }, [panels]); + // Defensive open: clear any stale adopt target (set by a prior 'a'-on-Frontier + // press dismissed through a non-onPaletteClose path) before resetting palette + // state. Shared by the Ctrl+P/m keyboard path AND the keymap `view.palette` + // capability (openPalette). + const handleSpawnPalette = useCallback(() => { + dispatch({ type: "ADOPT_CLEAR" }); + dispatch({ type: "PALETTE_RESET" }); + }, []); + + // Adopt one side of a 2-way compare. Reads frontier/contribution summaries + // from the synchronous refs (race-safety: same discipline as the 'a' adopt + // path). Shared by the Artifact a/b keyboard path AND the keymap + // compare-adopt-a/b capabilities. + const handleCompareAdopt = useCallback( + (side: "a" | "b") => { + const cid = side === "a" ? ks.compareCids[0] : ks.compareCids[1]; + if (!cid) return; + const summary = + frontierEntriesRef.current.find((e) => e.cid === cid)?.summary ?? + contributionList.find((c) => c.cid === cid)?.summary ?? + ""; + dispatch({ type: "ADOPT_SET", targetCid: cid, summary }); + dispatch({ type: "COMPARE_ADOPT" }); + panels.setMode(InputMode.CommandPalette); + }, + [ks.compareCids, contributionList, panels], + ); + const mkPluginCtx = useCallback( (ctx: import("./actions/types.js").ActionContext): TuiPluginContext => ({ // Keep the narrow plugin surface but hand plugins the panel-aware @@ -1034,6 +1075,71 @@ export function App({ }, scrollTerminalToBottom: () => dispatch({ type: "TERMINAL_SCROLL_BOTTOM" }), showMessage: showError, + + // Keymap-migrated capabilities (#275). Each mirrors the body of the old + // `executeKeymapAction` switch (deleted in use-keyboard-handler.ts) so + // the keymap dispatch path preserves identical behavior. Focus gates for + // the panel-scoped ones live on the action's `available` (builtin-actions), + // so these bodies only carry the residual in-handler conditionals that + // the old switch had (e.g. cursor_down's detail-section specialization). + openPalette: () => { + handleSpawnPalette(); + panels.setMode(InputMode.CommandPalette); + }, + enterTerminalInput: () => panels.setMode(InputMode.TerminalInput), + artifactPrev: () => dispatch({ type: "ARTIFACT_PREV" }), + artifactNext: () => dispatch({ type: "ARTIFACT_NEXT" }), + artifactDiffToggle: () => dispatch({ type: "ARTIFACT_DIFF_TOGGLE" }), + artifactDiffModeToggle: () => dispatch({ type: "ARTIFACT_DIFF_MODE_TOGGLE" }), + cursorDown: () => { + if (panels.state.focused === Panel.Detail && nav.isDetailView) + dispatch({ type: "DETAIL_SECTION_NEXT" }); + else nav.cursorDown(Math.max(0, rowCount - 1)); + }, + cursorUp: () => { + if (panels.state.focused === Panel.Detail && nav.isDetailView) + dispatch({ type: "DETAIL_SECTION_PREV" }); + else nav.cursorUp(); + }, + selectRow: () => { + if (!nav.isDetailView && panels.state.focused !== Panel.Claims && rowCount > 0) + handleSelect(nav.state.cursor); + }, + pageNext: () => { + const hasFullPage = rowCount >= PAGE_SIZE; + const totalItems = hasFullPage + ? nav.state.pageOffset + rowCount + 1 + : nav.state.pageOffset + rowCount; + nav.nextPage(PAGE_SIZE, totalItems); + }, + pagePrev: () => nav.prevPage(PAGE_SIZE), + vfsNavigate: () => dispatch({ type: "VFS_NAVIGATE" }), + terminalScrollUp: () => dispatch({ type: "TERMINAL_SCROLL_UP" }), + terminalScrollDown: () => dispatch({ type: "TERMINAL_SCROLL_DOWN" }), + compareSelect: () => { + if (panels.state.focused === Panel.Frontier && ks.compareMode) { + const cid = frontierCidsRef.current[nav.state.cursor]; + if (cid) dispatch({ type: "COMPARE_SELECT", cid }); + } else if (!nav.isDetailView && panels.state.focused !== Panel.Claims && rowCount > 0) { + // Preserve "Enter selects a Frontier row when compare is off". + handleSelect(nav.state.cursor); + } + }, + compareAdoptA: () => { + if (panels.state.focused === Panel.Artifact && ks.compareMode) handleCompareAdopt("a"); + }, + compareAdoptB: () => { + if (panels.state.focused === Panel.Artifact && ks.compareMode) handleCompareAdopt("b"); + }, + frontierAdopt: () => { + if (panels.state.focused === Panel.Frontier && !ks.compareMode) { + const e = frontierEntriesRef.current[nav.state.cursor]; + if (e) { + dispatch({ type: "ADOPT_SET", targetCid: e.cid, summary: e.summary }); + panels.setMode(InputMode.CommandPalette); + } + } + }, }), [ topology, @@ -1063,20 +1169,55 @@ export function App({ refreshAll, handleQuit, showError, + rowCount, + handleSelect, + handleSpawnPalette, + handleCompareAdopt, ], ); - const paletteActions = useMemo( - () => [ - ...buildBuiltInActions(actionContext), - ...buildPluginActions(mergedActionRegistry.entries, mkPluginCtx), - ], - [actionContext, mergedActionRegistry.entries, mkPluginCtx], - ); + // Build the unified action registry ONCE. Static built-ins are enumerated + // up front (their run/enabled receive the LIVE ctx at call time, so the + // empty-snapshot ctx passed here is fine — see register-builtins.ts), and the + // plugin actions are registered as a dynamic source reading a ref so newly + // loaded extensions still surface without rebuilding the registry. Live app + // state flows through the `actionContext` handed to list/byId/search/run. + const pluginEntriesRef = useRef(mergedActionRegistry.entries); + useEffect(() => { + pluginEntriesRef.current = mergedActionRegistry.entries; + }, [mergedActionRegistry.entries]); + const mkPluginCtxRef = useRef(mkPluginCtx); + useEffect(() => { + mkPluginCtxRef.current = mkPluginCtx; + }, [mkPluginCtx]); + + const registry = useMemo(() => { + const r = createActionRegistry(); + registerBuiltInActions(r, actionContext); // ctx unused for static enumeration + r.registerDynamic("plugin.", () => + buildPluginActions(pluginEntriesRef.current, mkPluginCtxRef.current), + ); + return r; + // Built once; live state flows via the ctx passed to list/byId/run. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const visiblePaletteActions = useMemo( - () => computeVisibleActions(paletteActions, actionContext, ks.paletteQuery), - [paletteActions, actionContext, ks.paletteQuery], + // Feed the resolved keymap's keybind labels to the registry so palette rows + // can show their shortcut (and so the cheatsheet stays in sync with overrides). + useEffect(() => { + registry.setBindings(resolvedKeymapBindings(resolvedKeymap)); + }, [registry, resolvedKeymap]); + + // Palette items come straight from the registry: `search` when a query is + // active (flat ranked), else `list` (grouped, available-gated). This is the + // SAME flat list the palette renders AND the index space onPaletteSelect / + // paletteItemCount address — keep them sharing this one array. + const filteredActions = useMemo( + () => + ks.paletteQuery.trim() + ? registry.search(ks.paletteQuery, actionContext) + : registry.list(actionContext), + [registry, actionContext, ks.paletteQuery], ); // --------------------------------------------------------------------------- @@ -1091,13 +1232,7 @@ export function App({ panels, nav, onQuit: handleQuit, - onSpawnPalette: () => { - // Defensive: opening a fresh palette must not inherit a stale adopt - // target from a previous 'a'-on-Frontier press that was dismissed - // through a path other than onPaletteClose. - dispatch({ type: "ADOPT_CLEAR" }); - dispatch({ type: "PALETTE_RESET" }); - }, + onSpawnPalette: handleSpawnPalette, onPaletteClose: handleCommandPaletteClose, onZoomCycle: () => dispatch({ type: "ZOOM_CYCLE" }), onZoomReset: () => dispatch({ type: "ZOOM_RESET" }), @@ -1115,19 +1250,7 @@ export function App({ onDetailSectionPrev: () => dispatch({ type: "DETAIL_SECTION_PREV" }), onCompareToggle: () => dispatch({ type: "COMPARE_TOGGLE" }), onCompareSelect: (cid: string) => dispatch({ type: "COMPARE_SELECT", cid }), - onCompareAdopt: (side: "a" | "b") => { - const cid = side === "a" ? ks.compareCids[0] : ks.compareCids[1]; - if (!cid) return; - // Read from refs (synchronous current value) — same race-safety - // discipline as the 'a' adopt path. - const summary = - frontierEntriesRef.current.find((e) => e.cid === cid)?.summary ?? - contributionList.find((c) => c.cid === cid)?.summary ?? - ""; - dispatch({ type: "ADOPT_SET", targetCid: cid, summary }); - dispatch({ type: "COMPARE_ADOPT" }); - panels.setMode(InputMode.CommandPalette); - }, + onCompareAdopt: handleCompareAdopt, onSearchStart: () => { dispatch({ type: "SEARCH_START", currentQuery: ks.searchQuery }); panels.setMode(InputMode.SearchInput); @@ -1224,10 +1347,11 @@ export function App({ onPaletteChar: (char: string) => dispatch({ type: "PALETTE_CHAR", char }), onPaletteBackspace: () => dispatch({ type: "PALETTE_BACKSPACE" }), onPaletteSelect: () => { - const entry = visiblePaletteActions[ks.paletteIndex]; - if (!entry) return; - const action = entry.action; - if (!(action.enabled?.(actionContext) ?? true)) return; + // filteredActions is the registry's flat, already-visible Action[] — + // ks.paletteIndex addresses it directly (no { action } wrapper now). + const action = filteredActions[ks.paletteIndex]; + if (!action) return; + if (!resolveEnabled(action, actionContext).enabled) 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); @@ -1239,7 +1363,7 @@ export function App({ onSelect: handleSelect, rowCount, pageSize: PAGE_SIZE, - paletteItemCount: visiblePaletteActions.length, + paletteItemCount: filteredActions.length, compareMode: ks.compareMode, frontierCids: () => frontierCidsRef.current, selectedSession, @@ -1277,13 +1401,21 @@ export function App({ // (refs are mutated synchronously by handleFrontierEntriesChanged and // by slice-nav handlers; state-based values would lag by one render). frontierEntries: () => frontierEntriesRef.current, + // Registry-backed keymap dispatch (#275): routeKey resolves a keymap + // binding → action id → registry.byId(ctx).run(ctx). The migrated + // capabilities on actionContext carry the old switch's behavior. + registry, + actionContext, + onActionError: showError, }), [ panels, nav, handleQuit, + handleSpawnPalette, handleCommandPaletteClose, handleSelect, + handleCompareAdopt, handleApproveQuestion, handleDenyQuestion, sendTuiMessage, @@ -1291,8 +1423,9 @@ export function App({ tmux, selectedSession, rowCount, - visiblePaletteActions, + filteredActions, actionContext, + registry, ks.compareMode, ks.compareCids, ks.searchQuery, @@ -1343,7 +1476,7 @@ export function App({ > + {!paletteVisible && keymapPrefix.length > 0 ? ( + + ) : null} = {}): ActionContext { prevFrontierSlice: () => undefined, scrollTerminalToBottom: () => undefined, showMessage: () => undefined, + // Keymap-migrated capabilities (#275) + openPalette: () => undefined, + enterTerminalInput: () => undefined, + artifactPrev: () => undefined, + artifactNext: () => undefined, + artifactDiffToggle: () => undefined, + artifactDiffModeToggle: () => undefined, + cursorDown: () => undefined, + cursorUp: () => undefined, + selectRow: () => undefined, + pageNext: () => undefined, + pagePrev: () => undefined, + vfsNavigate: () => undefined, + terminalScrollUp: () => undefined, + terminalScrollDown: () => undefined, + compareSelect: () => undefined, + compareAdoptA: () => undefined, + compareAdoptB: () => undefined, + frontierAdopt: () => undefined, ...overrides, }; } diff --git a/src/tui/components/command-palette.test.tsx b/src/tui/components/command-palette.test.tsx index b6bec775..110379f2 100644 --- a/src/tui/components/command-palette.test.tsx +++ b/src/tui/components/command-palette.test.tsx @@ -48,6 +48,25 @@ function ctx(overrides: Partial = {}): ActionContext { prevFrontierSlice: () => undefined, scrollTerminalToBottom: () => undefined, showMessage: () => undefined, + // Keymap-migrated capabilities (#275) + openPalette: () => undefined, + enterTerminalInput: () => undefined, + artifactPrev: () => undefined, + artifactNext: () => undefined, + artifactDiffToggle: () => undefined, + artifactDiffModeToggle: () => undefined, + cursorDown: () => undefined, + cursorUp: () => undefined, + selectRow: () => undefined, + pageNext: () => undefined, + pagePrev: () => undefined, + vfsNavigate: () => undefined, + terminalScrollUp: () => undefined, + terminalScrollDown: () => undefined, + compareSelect: () => undefined, + compareAdoptA: () => undefined, + compareAdoptB: () => undefined, + frontierAdopt: () => undefined, ...overrides, }; } diff --git a/src/tui/hooks/use-keybinding-overrides.test.ts b/src/tui/hooks/use-keybinding-overrides.test.ts index ba826dd2..539a3fd0 100644 --- a/src/tui/hooks/use-keybinding-overrides.test.ts +++ b/src/tui/hooks/use-keybinding-overrides.test.ts @@ -10,6 +10,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import type { KeyEvent } from "@opentui/core"; +import { buildBuiltInActions } from "../actions/builtin-actions.js"; +import { createActionRegistry } from "../actions/registry.js"; +import type { ActionContext } from "../actions/types.js"; import { resolveKeymapWithOverrides } from "../keymap/keymap.js"; import { loadKeybindings, REMAPPABLE_ACTIONS } from "./use-keybinding-overrides.js"; import type { KeyboardActions } from "./use-keyboard-handler.js"; @@ -61,14 +64,31 @@ function keyEvent(name: string, opts?: { ctrl?: boolean }): KeyEvent { } as unknown as KeyEvent; } -function mockActions(overrides?: Partial<{ mode: InputMode; focused: Panel }>): KeyboardActions { +function mockActions( + overrides?: Partial<{ mode: InputMode; focused: Panel; onQuit: () => void }>, +): KeyboardActions { const panelState: PanelFocusState = { focused: overrides?.focused ?? Panel.Dag, visibleOperator: new Set(), mode: overrides?.mode ?? InputMode.Normal, viewMode: "grid", }; + const onQuit = overrides?.onQuit ?? (() => undefined); + // Registry-backed keymap dispatch surface (#275 Task 8). The keymap's quit + // binding resolves to the `view.quit` action, which calls actionContext.quit; + // wire that to the mock's onQuit so the integration tests observe a quit. + const actionContext = { + focusedPanel: overrides?.focused ?? Panel.Dag, + frontierSliceCount: 1, + isPanelVisible: () => false, + quit: onQuit, + } as unknown as ActionContext; + const registry = createActionRegistry(); + for (const action of buildBuiltInActions(actionContext)) registry.register(action); return { + registry, + actionContext, + onActionError: () => undefined, panels: { state: panelState, focus: () => { @@ -343,11 +363,14 @@ describe("routeKey — keybinding override integration", () => { test("remapped quit key triggers onQuit", () => { let quitCalled = false; const overrides = { quit: "Q" }; + // Pass onQuit INTO mockActions so the registry's view.quit action (the + // target of the remapped quit keybind) delegates to it via actionContext. const actions: KeyboardActions = { - ...mockActions(), - onQuit: () => { - quitCalled = true; - }, + ...mockActions({ + onQuit: () => { + quitCalled = true; + }, + }), resolvedKeymap: resolveKeymapWithOverrides("default", overrides), }; @@ -359,10 +382,11 @@ describe("routeKey — keybinding override integration", () => { test("non-overridden legacy key does not bypass the active keymap", () => { let quitCalled = false; const actions: KeyboardActions = { - ...mockActions(), - onQuit: () => { - quitCalled = true; - }, + ...mockActions({ + onQuit: () => { + quitCalled = true; + }, + }), resolvedKeymap: resolveKeymapWithOverrides("default", {}), }; From f257509032993358d93c274a0e9b82fffb00a8a0 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 15:47:16 -0700 Subject: [PATCH 11/22] feat(tui): #275 slash index + slash triggers on built-in actions --- src/tui/actions/builtin-actions.ts | 11 +++++++++++ src/tui/actions/slash-index.test.ts | 27 +++++++++++++++++++++++++++ src/tui/actions/slash-index.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 src/tui/actions/slash-index.test.ts create mode 100644 src/tui/actions/slash-index.ts diff --git a/src/tui/actions/builtin-actions.ts b/src/tui/actions/builtin-actions.ts index 23cd2099..adf60fef 100644 --- a/src/tui/actions/builtin-actions.ts +++ b/src/tui/actions/builtin-actions.ts @@ -32,6 +32,7 @@ function keymapMigratedActions(): readonly Action[] { detail: "view", group: "View", keywords: ["palette", "command"], + suggested: true, run: (c) => c.openPalette(), }, { @@ -223,6 +224,7 @@ function agentActions(): readonly Action[] { detail: "message", group: "Agents", keywords: ["message", "broadcast", "all", "tell"], + slash: "/broadcast", run: (c) => c.broadcastMessage(), }, { @@ -231,6 +233,7 @@ function agentActions(): readonly Action[] { detail: "message", group: "Agents", keywords: ["message", "direct", "dm", "tell"], + slash: "/dm", run: (c) => c.directMessage(), }, ]; @@ -281,6 +284,7 @@ function viewActions(): readonly Action[] { detail: "view", group: "View", keywords: ["refresh", "reload", "update"], + slash: "/refresh", run: (c) => c.refresh(), }, { @@ -289,6 +293,8 @@ function viewActions(): readonly Action[] { detail: "view", group: "View", keywords: ["search", "find", "filter"], + slash: "/search", + suggested: true, run: (c) => c.enterSearch(), }, { @@ -329,6 +335,7 @@ function viewActions(): readonly Action[] { detail: "view", group: "View", keywords: ["help", "keys", "shortcuts", "?"], + slash: "/help", run: (c) => c.showHelp(), }, { @@ -337,6 +344,7 @@ function viewActions(): readonly Action[] { detail: "view", group: "View", keywords: ["quit", "exit", "close"], + slash: "/quit", run: (c) => c.quit(), }, ]; @@ -350,6 +358,8 @@ function workflowActions(): readonly Action[] { detail: "Set or update the session goal for all agents", group: "Workflow", keywords: ["goal", "objective"], + slash: "/goal", + suggested: true, available: (c) => c.hasGoals, run: (c) => c.enterGoalMode(), }, @@ -395,6 +405,7 @@ function workflowActions(): readonly Action[] { detail: "compare", group: "Workflow", keywords: ["compare", "diff"], + slash: "/compare", run: (c) => c.enterCompareMode(), }, { diff --git a/src/tui/actions/slash-index.test.ts b/src/tui/actions/slash-index.test.ts new file mode 100644 index 00000000..7d749356 --- /dev/null +++ b/src/tui/actions/slash-index.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { buildSlashIndex, resolveSlash } from "./slash-index.js"; +import type { Action } from "./types.js"; + +const a = (id: string, slash?: string): Action => + ({ id, label: id, detail: "", group: "View", slash, run: () => {} }) as Action; + +describe("buildSlashIndex", () => { + test("maps slash trigger → action id, ignoring actions without slash", () => { + const idx = buildSlashIndex([a("view.quit", "/quit"), a("view.refresh")]); + expect(idx.get("/quit")).toBe("view.quit"); + expect(idx.size).toBe(1); + }); +}); + +describe("resolveSlash", () => { + test("parses /cmd args", () => { + const idx = buildSlashIndex([a("agent.spawn", "/spawn")]); + expect(resolveSlash(idx, "/spawn reviewer fast")).toEqual({ + id: "agent.spawn", + args: ["reviewer", "fast"], + }); + }); + test("returns undefined for unknown command", () => { + expect(resolveSlash(buildSlashIndex([]), "/nope")).toBeUndefined(); + }); +}); diff --git a/src/tui/actions/slash-index.ts b/src/tui/actions/slash-index.ts new file mode 100644 index 00000000..861fa45d --- /dev/null +++ b/src/tui/actions/slash-index.ts @@ -0,0 +1,29 @@ +import type { Action } from "./types.js"; + +export function buildSlashIndex(actions: readonly Action[]): ReadonlyMap { + const map = new Map(); + for (const a of actions) { + if (a.slash && !map.has(a.slash)) map.set(a.slash, a.id); + } + return map; +} + +export interface SlashResolution { + readonly id: string; + readonly args: readonly string[]; +} + +export function resolveSlash( + index: ReadonlyMap, + input: string, +): SlashResolution | undefined { + const tokens = input + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); + const cmd = tokens[0]; + if (cmd === undefined) return undefined; + const id = index.get(cmd); + if (id === undefined) return undefined; + return { id, args: tokens.slice(1) }; +} From 9958a1f52cd60dd1fd506ba46d7b3ee7d41e1976 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 15:53:44 -0700 Subject: [PATCH 12/22] feat(tui): #275 '/' opens slash-filtered command palette --- src/tui/app.tsx | 37 +++++++++++++++---- src/tui/components/command-palette.test.tsx | 37 +++++++++++++++++++ src/tui/components/command-palette.tsx | 10 ++++- .../hooks/use-keybinding-overrides.test.ts | 3 ++ src/tui/hooks/use-keyboard-handler.test.ts | 16 ++++++++ src/tui/hooks/use-keyboard-handler.ts | 10 +++++ 6 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 295325cd..3e0628cc 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -161,6 +161,9 @@ export function App({ [userConfig?.keymapPreset, keybindingOverrides], ); const [keymapPrefix, setKeymapPrefix] = useState([]); + // When true, the command palette is shown pre-filtered to slash actions + // (entered via Normal-mode `/`). Reset on every palette close path (#275). + const [slashMode, setSlashMode] = useState(false); const [ks, dispatch] = useReducer(tuiReducer, INITIAL_KEYBOARD_STATE); const resolvedKeymapRef = useRef(resolvedKeymap); @@ -917,6 +920,7 @@ export function App({ const handleCommandPaletteClose = useCallback(() => { dispatch({ type: "ADOPT_CLEAR" }); + setSlashMode(false); panels.setMode(InputMode.Normal); dispatch({ type: "PALETTE_RESET" }); }, [panels]); @@ -924,12 +928,23 @@ export function App({ // Defensive open: clear any stale adopt target (set by a prior 'a'-on-Frontier // press dismissed through a non-onPaletteClose path) before resetting palette // state. Shared by the Ctrl+P/m keyboard path AND the keymap `view.palette` - // capability (openPalette). + // capability (openPalette). Always clears slashMode so a regular palette open + // never inherits a stale slash-filter from a prior `/` open. const handleSpawnPalette = useCallback(() => { dispatch({ type: "ADOPT_CLEAR" }); + setSlashMode(false); dispatch({ type: "PALETTE_RESET" }); }, []); + // Normal-mode `/`: open the palette pre-filtered to slash actions. Reuses the + // defensive open (resets adopt + palette state, clears slashMode) then turns + // slash mode on and switches into the palette mode. + const handleSlashPaletteOpen = useCallback(() => { + handleSpawnPalette(); + setSlashMode(true); + panels.setMode(InputMode.CommandPalette); + }, [handleSpawnPalette, panels]); + // Adopt one side of a 2-way compare. Reads frontier/contribution summaries // from the synchronous refs (race-safety: same discipline as the 'a' adopt // path). Shared by the Artifact a/b keyboard path AND the keymap @@ -1212,13 +1227,16 @@ export function App({ // active (flat ranked), else `list` (grouped, available-gated). This is the // SAME flat list the palette renders AND the index space onPaletteSelect / // paletteItemCount address — keep them sharing this one array. - const filteredActions = useMemo( - () => - ks.paletteQuery.trim() - ? registry.search(ks.paletteQuery, actionContext) - : registry.list(actionContext), - [registry, actionContext, ks.paletteQuery], - ); + const filteredActions = useMemo(() => { + const base = ks.paletteQuery.trim() + ? registry.search(ks.paletteQuery, actionContext) + : registry.list(actionContext); + // In slash mode the palette renders only slash actions; the parent MUST + // apply the same filter so paletteItemCount / onPaletteSelect address the + // exact list the palette displays (the palette's internal slash filter is + // then an idempotent no-op on this already-filtered input). + return slashMode ? base.filter((a) => a.slash) : base; + }, [registry, actionContext, ks.paletteQuery, slashMode]); // --------------------------------------------------------------------------- // KeyboardActions adapter — maps routeKey callbacks to state transitions. @@ -1233,6 +1251,7 @@ export function App({ nav, onQuit: handleQuit, onSpawnPalette: handleSpawnPalette, + onSlashPaletteOpen: handleSlashPaletteOpen, onPaletteClose: handleCommandPaletteClose, onZoomCycle: () => dispatch({ type: "ZOOM_CYCLE" }), onZoomReset: () => dispatch({ type: "ZOOM_RESET" }), @@ -1413,6 +1432,7 @@ export function App({ nav, handleQuit, handleSpawnPalette, + handleSlashPaletteOpen, handleCommandPaletteClose, handleSelect, handleCompareAdopt, @@ -1481,6 +1501,7 @@ export function App({ query={ks.paletteQuery} selectedIndex={ks.paletteIndex} adoptContext={ks.adoptContext} + slashMode={slashMode} /> )} diff --git a/src/tui/components/command-palette.test.tsx b/src/tui/components/command-palette.test.tsx index 110379f2..1630b6a8 100644 --- a/src/tui/components/command-palette.test.tsx +++ b/src/tui/components/command-palette.test.tsx @@ -118,6 +118,7 @@ function renderPalette(props: { ctx: ActionContext; query?: string; selectedIndex?: number; + slashMode?: boolean; }): TestRenderer.ReactTestRendererJSON { let renderer!: TestRenderer.ReactTestRenderer; testAct(() => { @@ -128,6 +129,7 @@ function renderPalette(props: { ctx: props.ctx, query: props.query, selectedIndex: props.selectedIndex ?? 0, + slashMode: props.slashMode, }), ); }); @@ -153,6 +155,41 @@ describe("command palette model", () => { }); }); +describe("CommandPalette — slashMode", () => { + test("slashMode renders only actions with a slash field", () => { + const actions: readonly Action[] = [ + act({ id: "n1", group: "Navigation", label: "Plain Nav Action" }), + act({ id: "w1", group: "Workflow", label: "Cancel Run", slash: "/cancel" }), + ]; + const json = renderPalette({ actions, ctx: ctx(), selectedIndex: 0, slashMode: true }); + const text = allText(json); + // The slash action is present... + expect(text).toContain("Cancel Run"); + // ...the non-slash action is filtered out. + expect(text).not.toContain("Plain Nav Action"); + }); + + test("without slashMode all actions render (slash + non-slash)", () => { + const actions: readonly Action[] = [ + act({ id: "n1", group: "Navigation", label: "Plain Nav Action" }), + act({ id: "w1", group: "Workflow", label: "Cancel Run", slash: "/cancel" }), + ]; + const json = renderPalette({ actions, ctx: ctx(), selectedIndex: 0 }); + const text = allText(json); + expect(text).toContain("Cancel Run"); + expect(text).toContain("Plain Nav Action"); + }); + + test("slashMode renders a slash-commands header hint", () => { + const actions: readonly Action[] = [ + act({ id: "w1", group: "Workflow", label: "Cancel Run", slash: "/cancel" }), + ]; + const json = renderPalette({ actions, ctx: ctx(), selectedIndex: 0, slashMode: true }); + const text = allText(json); + expect(text).toContain("Slash"); + }); +}); + describe("CommandPalette render — keybind column + disabled reason footer", () => { test("row with keybind renders that keybind string in the output", () => { const actions: readonly Action[] = [ diff --git a/src/tui/components/command-palette.tsx b/src/tui/components/command-palette.tsx index 4855947a..46a3f5cc 100644 --- a/src/tui/components/command-palette.tsx +++ b/src/tui/components/command-palette.tsx @@ -63,6 +63,8 @@ export interface CommandPaletteProps { readonly query?: string | undefined; readonly selectedIndex?: number | undefined; readonly adoptContext?: { readonly targetCid: string; readonly summary: string } | undefined; + /** When true, show only actions that declare a `slash` trigger (#275). */ + readonly slashMode?: boolean | undefined; } export const CommandPalette: React.NamedExoticComponent = React.memo( @@ -73,9 +75,13 @@ export const CommandPalette: React.NamedExoticComponent = R query, selectedIndex, adoptContext, + slashMode, }: CommandPaletteProps): React.ReactNode { const q = (query ?? "").trim(); - const visibleActions = useMemo(() => computeVisibleActions(actions, ctx, q), [actions, ctx, q]); + // In slash mode, restrict to actions that declare a `slash` trigger BEFORE + // visibility/fuzzy computation so the flat index space matches the display. + const source = slashMode ? actions.filter((a) => a.slash) : actions; + const visibleActions = useMemo(() => computeVisibleActions(source, ctx, q), [source, ctx, q]); if (!visible) return null; const idx = selectedIndex ?? 0; @@ -97,7 +103,7 @@ export const CommandPalette: React.NamedExoticComponent = R return ( - Command Palette + {slashMode ? "Slash commands" : "Command Palette"} {adoptContext ? ( {` Adopt: ${adoptContext.targetCid.slice(0, 12)}…`} ) : null} diff --git a/src/tui/hooks/use-keybinding-overrides.test.ts b/src/tui/hooks/use-keybinding-overrides.test.ts index 539a3fd0..47224b4b 100644 --- a/src/tui/hooks/use-keybinding-overrides.test.ts +++ b/src/tui/hooks/use-keybinding-overrides.test.ts @@ -147,6 +147,9 @@ function mockActions( onSpawnPalette: () => { /* noop */ }, + onSlashPaletteOpen: () => { + /* noop */ + }, onPaletteClose: () => { /* noop */ }, diff --git a/src/tui/hooks/use-keyboard-handler.test.ts b/src/tui/hooks/use-keyboard-handler.test.ts index 47a325dc..76cbf3ab 100644 --- a/src/tui/hooks/use-keyboard-handler.test.ts +++ b/src/tui/hooks/use-keyboard-handler.test.ts @@ -106,6 +106,7 @@ function mockActions(overrides?: { }, onQuit: () => record("onQuit"), onSpawnPalette: () => record("onSpawnPalette"), + onSlashPaletteOpen: () => record("onSlashPaletteOpen"), onPaletteClose: () => record("onPaletteClose"), onVfsNavigate: () => record("onVfsNavigate"), onArtifactPrev: () => record("onArtifactPrev"), @@ -676,6 +677,21 @@ describe("routeKey — search input mode", () => { routeKey(keyEvent("/"), actions); expect(log.calls).toContain("onSearchStart"); }); + + test("/ in Normal mode (non-Search panel) opens the slash palette", () => { + const { actions, log } = mockActions({ focused: Panel.Dag }); + const handled = routeKey(keyEvent("/"), actions); + expect(handled).toBe(true); + expect(log.calls).toContain("onSlashPaletteOpen"); + // Must NOT start a search when not on the Search panel. + expect(log.calls).not.toContain("onSearchStart"); + }); + + test("/ in Search panel does NOT open the slash palette", () => { + const { actions, log } = mockActions({ focused: Panel.Search }); + routeKey(keyEvent("/"), actions); + expect(log.calls).not.toContain("onSlashPaletteOpen"); + }); }); // --------------------------------------------------------------------------- diff --git a/src/tui/hooks/use-keyboard-handler.ts b/src/tui/hooks/use-keyboard-handler.ts index c51caa70..d9ec1751 100644 --- a/src/tui/hooks/use-keyboard-handler.ts +++ b/src/tui/hooks/use-keyboard-handler.ts @@ -31,6 +31,8 @@ export interface KeyboardActions { readonly nav: NavigationActions; readonly onQuit: () => void; readonly onSpawnPalette: () => void; + /** Opens the command palette pre-filtered to slash actions (Normal-mode `/`). */ + readonly onSlashPaletteOpen: () => void; /** Called whenever the command palette is dismissed (any path). Clears * adoptContext + palette state so leftover targets don't leak into the * next unrelated spawn. */ @@ -414,6 +416,14 @@ export function routeKey(key: KeyEvent, actions: KeyboardActions): boolean { return true; } + // Slash command palette entry: `/` in Normal mode off the Search panel opens + // the palette pre-filtered to slash actions. Placed BEFORE the search-panel + // `/` so the search behavior is preserved when the Search panel is focused. + if (input === "/" && mode === InputMode.Normal && focused !== Panel.Search) { + actions.onSlashPaletteOpen(); + return true; + } + // Search input mode entry if (input === "/" && focused === Panel.Search) { actions.onSearchStart(); From 2cc8c2232f84c6ee9a9868f83a46084a6285b520 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 15:56:37 -0700 Subject: [PATCH 13/22] =?UTF-8?q?fix(tui):=20#275=20biome=20=E2=80=94=20re?= =?UTF-8?q?gistry=20memo=20ignore=20+=20drop=20redundant=20keyboardActions?= =?UTF-8?q?=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tui/app.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 3e0628cc..6bdebb0b 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -1206,6 +1206,7 @@ export function App({ mkPluginCtxRef.current = mkPluginCtx; }, [mkPluginCtx]); + // biome-ignore lint/correctness/useExhaustiveDependencies: built once on purpose — static built-ins are enumerated here while live state flows via the ctx passed to list/byId/run; plugin entries are read through refs. const registry = useMemo(() => { const r = createActionRegistry(); registerBuiltInActions(r, actionContext); // ctx unused for static enumeration @@ -1213,8 +1214,6 @@ export function App({ buildPluginActions(pluginEntriesRef.current, mkPluginCtxRef.current), ); return r; - // Built once; live state flows via the ctx passed to list/byId/run. - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Feed the resolved keymap's keybind labels to the registry so palette rows @@ -1447,13 +1446,11 @@ export function App({ actionContext, registry, ks.compareMode, - ks.compareCids, ks.searchQuery, ks.messageBuffer, ks.messageRecipients, ks.goalBuffer, ks.paletteIndex, - contributionList, resolvedKeymap, keymapPrefix, refreshAll, From ef2d2b082c1f59a50fd03a1a5a5a3e58daa9acde Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 16:05:16 -0700 Subject: [PATCH 14/22] feat(tui): #275 dedicated slash command-line input (:cmd args) --- src/tui/app.tsx | 49 ++++++++++++ src/tui/components/slash-input.test.tsx | 75 +++++++++++++++++++ src/tui/components/slash-input.tsx | 30 ++++++++ src/tui/components/status-bar.tsx | 1 + .../hooks/use-keybinding-overrides.test.ts | 12 +++ src/tui/hooks/use-keyboard-handler.test.ts | 58 ++++++++++++++ src/tui/hooks/use-keyboard-handler.ts | 35 +++++++++ src/tui/hooks/use-panel-focus.ts | 3 +- 8 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/tui/components/slash-input.test.tsx create mode 100644 src/tui/components/slash-input.tsx diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 6bdebb0b..707ae327 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -21,6 +21,7 @@ import { registerBuiltInActions } from "./actions/register-builtins.js"; import { createActionRegistry } from "./actions/registry.js"; import { getReservedActionRegistryEntries } from "./actions/reserved-ids.js"; import { resolveSelectedCid } from "./actions/selection.js"; +import { buildSlashIndex, resolveSlash } from "./actions/slash-index.js"; import { resolveEnabled } from "./actions/types.js"; import { checkSpawn, checkSpawnDepth } from "./agents/spawn-validator.js"; import { agentIdFromSession } from "./agents/tmux-manager.js"; @@ -29,6 +30,7 @@ import { CommandPalette } from "./components/command-palette.js"; import { HelpOverlay } from "./components/help-overlay.js"; import { InputBar } from "./components/input-bar.js"; import { LeaderOverlay } from "./components/leader-overlay.js"; +import { SlashInput } from "./components/slash-input.js"; import { type ScreenContext, StatusBar } from "./components/status-bar.js"; import { PanelBar } from "./components/tab-bar.js"; import { TooltipOverlay, useFirstLaunchTooltips } from "./components/tooltip-overlay.js"; @@ -164,6 +166,9 @@ export function App({ // When true, the command palette is shown pre-filtered to slash actions // (entered via Normal-mode `/`). Reset on every palette close path (#275). const [slashMode, setSlashMode] = useState(false); + // Dedicated `:` slash command-line buffer + last resolution error (#275 Task 11). + const [slashBuffer, setSlashBuffer] = useState(""); + const [slashError, setSlashError] = useState(undefined); const [ks, dispatch] = useReducer(tuiReducer, INITIAL_KEYBOARD_STATE); const resolvedKeymapRef = useRef(resolvedKeymap); @@ -1237,6 +1242,37 @@ export function App({ return slashMode ? base.filter((a) => a.slash) : base; }, [registry, actionContext, ks.paletteQuery, slashMode]); + // --------------------------------------------------------------------------- + // Dedicated `:` slash command-line (#275 Task 11). Opened with `:`; the buffer + // is typed WITHOUT the leading slash (`quit`, `skill foo`). On submit we + // prepend `/` and resolve via the slash index built from the registry's + // current slash actions, then run the resolved action with its parsed args. + // --------------------------------------------------------------------------- + const handleSlashCommandOpen = useCallback(() => { + setSlashBuffer(""); + setSlashError(undefined); + panels.setMode(InputMode.SlashCommand); + }, [panels]); + const handleSlashChar = useCallback((c: string) => setSlashBuffer((b) => b + c), []); + const handleSlashBackspace = useCallback(() => setSlashBuffer((b) => b.slice(0, -1)), []); + const handleSlashSubmit = useCallback(() => { + const index = buildSlashIndex(registry.list(actionContext)); + const resolution = resolveSlash(index, `/${slashBuffer}`); + if (resolution === undefined) { + setSlashError(`Unknown command: /${slashBuffer.split(/\s+/)[0] ?? ""}`); + return; + } + const action = registry.byId(resolution.id, actionContext); + setSlashBuffer(""); + setSlashError(undefined); + panels.setMode(InputMode.Normal); + if (action) { + void Promise.resolve(action.run(actionContext, resolution.args)).catch((e) => + showError(e instanceof Error ? e.message : "Command failed"), + ); + } + }, [registry, actionContext, slashBuffer, panels, showError]); + // --------------------------------------------------------------------------- // KeyboardActions adapter — maps routeKey callbacks to state transitions. // Palette execution now runs through `actionContext`/`action.run`; the paste- @@ -1337,6 +1373,10 @@ export function App({ }, onGoalChar: (char: string) => dispatch({ type: "GOAL_CHAR", char }), onGoalBackspace: () => dispatch({ type: "GOAL_BACKSPACE" }), + onSlashCommandOpen: handleSlashCommandOpen, + onSlashSubmit: handleSlashSubmit, + onSlashChar: handleSlashChar, + onSlashBackspace: handleSlashBackspace, onBroadcastMode: () => { dispatch({ type: "BROADCAST_MODE" }); panels.setMode(InputMode.MessageInput); @@ -1432,6 +1472,10 @@ export function App({ handleQuit, handleSpawnPalette, handleSlashPaletteOpen, + handleSlashCommandOpen, + handleSlashSubmit, + handleSlashChar, + handleSlashBackspace, handleCommandPaletteClose, handleSelect, handleCompareAdopt, @@ -1517,6 +1561,11 @@ export function App({ : undefined } /> + { + 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(""); +} + +function render(props: { + visible: boolean; + buffer: string; + error?: string; +}): TestRenderer.ReactTestRendererJSON | null { + let renderer!: TestRenderer.ReactTestRenderer; + testAct(() => { + renderer = TestRenderer.create( + React.createElement(SlashInput, { + visible: props.visible, + buffer: props.buffer, + error: props.error, + }), + ); + }); + const json = renderer.toJSON(); + renderer.unmount(); + if (Array.isArray(json)) throw new Error("unexpected render output"); + return json; +} + +describe("SlashInput", () => { + test("renders null (no ':' prompt) when not visible", () => { + const json = render({ visible: false, buffer: "skill foo" }); + expect(json).toBeNull(); + expect(allText(json)).toBe(""); + }); + + test("renders the ':' prompt and the buffer text when visible", () => { + const json = render({ visible: true, buffer: "skill foo" }); + const text = allText(json); + expect(text).toContain(":"); + expect(text).toContain("skill foo"); + }); + + test("renders the error text when error is set", () => { + const json = render({ visible: true, buffer: "nope", error: "Unknown command: /nope" }); + const text = allText(json); + expect(text).toContain("Unknown command: /nope"); + }); + + test("does not render error text when error is undefined", () => { + const json = render({ visible: true, buffer: "quit" }); + const text = allText(json); + expect(text).not.toContain("Unknown command"); + }); +}); diff --git a/src/tui/components/slash-input.tsx b/src/tui/components/slash-input.tsx new file mode 100644 index 00000000..df1ac6c4 --- /dev/null +++ b/src/tui/components/slash-input.tsx @@ -0,0 +1,30 @@ +/** + * Dedicated slash command-line input for the TUI (#275 Task 11). + * + * Opened with `:` (vim-style). Shows a `:` prompt followed by the live buffer + * and a cursor caret. On submit, the parent prepends `/` and resolves the input + * against the slash index. An optional error line is shown below the prompt. + */ + +import type React from "react"; +import { theme } from "../theme.js"; + +export interface SlashInputProps { + readonly visible: boolean; + readonly buffer: string; + readonly error?: string | undefined; +} + +export function SlashInput({ visible, buffer, error }: SlashInputProps): React.ReactNode { + if (!visible) return null; + return ( + + + : + {buffer} + + + {error ? {error} : null} + + ); +} diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index b21732d4..a11ae3c3 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -53,6 +53,7 @@ const MODE_LABELS: Record = { search_input: "SEARCH", message_input: "MESSAGE", goal_input: "GOAL", + slash_command: "SLASH", help: "HELP", }; diff --git a/src/tui/hooks/use-keybinding-overrides.test.ts b/src/tui/hooks/use-keybinding-overrides.test.ts index 47224b4b..9d25d3e1 100644 --- a/src/tui/hooks/use-keybinding-overrides.test.ts +++ b/src/tui/hooks/use-keybinding-overrides.test.ts @@ -213,6 +213,18 @@ function mockActions( onGoalBackspace: () => { /* noop */ }, + onSlashCommandOpen: () => { + /* noop */ + }, + onSlashSubmit: () => { + /* noop */ + }, + onSlashChar: () => { + /* noop */ + }, + onSlashBackspace: () => { + /* noop */ + }, onBroadcastMode: () => { /* noop */ }, diff --git a/src/tui/hooks/use-keyboard-handler.test.ts b/src/tui/hooks/use-keyboard-handler.test.ts index 76cbf3ab..90a10bd2 100644 --- a/src/tui/hooks/use-keyboard-handler.test.ts +++ b/src/tui/hooks/use-keyboard-handler.test.ts @@ -126,6 +126,10 @@ function mockActions(overrides?: { onGoalSubmit: () => record("onGoalSubmit"), onGoalChar: (char) => record("onGoalChar", char), onGoalBackspace: () => record("onGoalBackspace"), + onSlashCommandOpen: () => record("onSlashCommandOpen"), + onSlashSubmit: () => record("onSlashSubmit"), + onSlashChar: (char) => record("onSlashChar", char), + onSlashBackspace: () => record("onSlashBackspace"), onBroadcastMode: () => record("onBroadcastMode"), onDirectMessageMode: () => record("onDirectMessageMode"), onApproveQuestion: () => record("onApproveQuestion"), @@ -694,6 +698,60 @@ describe("routeKey — search input mode", () => { }); }); +// --------------------------------------------------------------------------- +// Slash command-line mode (`:` prompt) — #275 Task 11 +// --------------------------------------------------------------------------- + +describe("routeKey — slash command-line mode", () => { + test(": in Normal mode opens the slash command line", () => { + const { actions, log } = mockActions({ focused: Panel.Dag }); + const handled = routeKey(keyEvent(":"), actions); + expect(handled).toBe(true); + expect(log.calls).toContain("onSlashCommandOpen"); + }); + + test(": does NOT conflict with the slash palette opener (/)", () => { + const { actions, log } = mockActions({ focused: Panel.Dag }); + routeKey(keyEvent(":"), actions); + // `:` opens the dedicated command line, NOT the slash-filtered palette. + expect(log.calls).toContain("onSlashCommandOpen"); + expect(log.calls).not.toContain("onSlashPaletteOpen"); + }); + + test("single char in SlashCommand mode adds to the buffer", () => { + const { actions, log } = mockActions({ mode: InputMode.SlashCommand }); + const handled = routeKey(keyEvent("a"), actions); + expect(handled).toBe(true); + expect(log.calls).toContain("onSlashChar"); + expect(log.args.onSlashChar).toEqual(["a"]); + }); + + test("space in SlashCommand mode adds a space (for args)", () => { + const { actions, log } = mockActions({ mode: InputMode.SlashCommand }); + routeKey(keyEvent("space"), actions); + expect(log.calls).toContain("onSlashChar"); + expect(log.args.onSlashChar).toEqual([" "]); + }); + + test("Enter in SlashCommand mode submits", () => { + const { actions, log } = mockActions({ mode: InputMode.SlashCommand }); + routeKey(keyEvent("return"), actions); + expect(log.calls).toContain("onSlashSubmit"); + }); + + test("backspace in SlashCommand mode removes a char", () => { + const { actions, log } = mockActions({ mode: InputMode.SlashCommand }); + routeKey(keyEvent("backspace"), actions); + expect(log.calls).toContain("onSlashBackspace"); + }); + + test("Escape in SlashCommand mode exits to Normal mode", () => { + const { actions, log } = mockActions({ mode: InputMode.SlashCommand }); + routeKey(keyEvent("escape"), actions); + expect(log.args["panels.setMode"]).toEqual([InputMode.Normal]); + }); +}); + // --------------------------------------------------------------------------- // Message input mode // --------------------------------------------------------------------------- diff --git a/src/tui/hooks/use-keyboard-handler.ts b/src/tui/hooks/use-keyboard-handler.ts index d9ec1751..f4259a65 100644 --- a/src/tui/hooks/use-keyboard-handler.ts +++ b/src/tui/hooks/use-keyboard-handler.ts @@ -57,6 +57,11 @@ export interface KeyboardActions { readonly onGoalSubmit: () => void; readonly onGoalChar: (char: string) => void; readonly onGoalBackspace: () => void; + /** Opens the dedicated `:` slash command-line (Normal-mode `:`). */ + readonly onSlashCommandOpen: () => void; + readonly onSlashSubmit: () => void; + readonly onSlashChar: (char: string) => void; + readonly onSlashBackspace: () => void; readonly onApproveQuestion: () => void; readonly onDenyQuestion: () => void; readonly onSendKeys: (key: string) => void; @@ -303,6 +308,27 @@ export function routeKey(key: KeyEvent, actions: KeyboardActions): boolean { return true; } + // Slash command-line mode (`:` prompt) — mirrors the GoalInput block. + if (mode === InputMode.SlashCommand) { + if (input === "return") { + actions.onSlashSubmit(); + return true; + } + if (input === "backspace") { + actions.onSlashBackspace(); + return true; + } + if (input === "space") { + actions.onSlashChar(" "); + return true; + } + if (input && input.length === 1 && !isCtrl) { + actions.onSlashChar(input); + return true; + } + return true; + } + const resolvedKeymap = actions.resolvedKeymap; if (mode === InputMode.Normal && resolvedKeymap !== undefined) { const token = keyEventToToken(key); @@ -430,6 +456,15 @@ export function routeKey(key: KeyEvent, actions: KeyboardActions): boolean { return true; } + // Dedicated slash command-line entry: `:` in Normal mode opens the `:` prompt + // (vim-style). Submit prepends `/` and resolves via the slash index. `:` is + // not bound in the default keymap, so the keymap dispatch above returns a + // miss and falls through here. + if (input === ":" && mode === InputMode.Normal) { + actions.onSlashCommandOpen(); + return true; + } + // Panel-specific keys — must be checked BEFORE global keys like "b"/"d" // because they are more specific (panel + mode gated). diff --git a/src/tui/hooks/use-panel-focus.ts b/src/tui/hooks/use-panel-focus.ts index 8775dc5a..9904195d 100644 --- a/src/tui/hooks/use-panel-focus.ts +++ b/src/tui/hooks/use-panel-focus.ts @@ -86,7 +86,7 @@ export const OPERATOR_PANELS: readonly Panel[] = [ // Input mode // --------------------------------------------------------------------------- -/** Input mode hierarchy: command palette > help > search input > message input > goal input > terminal input > normal. */ +/** Input mode hierarchy: command palette > help > search input > message input > goal input > slash command > terminal input > normal. */ export const InputMode = { Normal: "normal", TerminalInput: "terminal_input", @@ -94,6 +94,7 @@ export const InputMode = { SearchInput: "search_input", MessageInput: "message_input", GoalInput: "goal_input", + SlashCommand: "slash_command", Help: "help", } as const; export type InputMode = (typeof InputMode)[keyof typeof InputMode]; From 9aefa0bfeb9ccff2d4a7bf72fd44a91fee2ac047 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 16:14:19 -0700 Subject: [PATCH 15/22] feat(mcp): #275 expose prompts from prompts/ dir (prompts capability) --- src/mcp/prompts.test.ts | 18 ++++++++++++++++ src/mcp/prompts.ts | 29 ++++++++++++++++++++++++++ src/mcp/server.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++ src/mcp/server.ts | 18 +++++++++++++++- 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/mcp/prompts.test.ts create mode 100644 src/mcp/prompts.ts diff --git a/src/mcp/prompts.test.ts b/src/mcp/prompts.test.ts new file mode 100644 index 00000000..af56aba7 --- /dev/null +++ b/src/mcp/prompts.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { loadPromptDefinitions } from "./prompts.js"; + +describe("loadPromptDefinitions", () => { + test("loads .md files from the repo prompts dir as named prompts", async () => { + const defs = await loadPromptDefinitions(new URL("../../prompts/", import.meta.url).pathname); + expect(defs.length).toBeGreaterThan(0); + for (const d of defs) { + expect(typeof d.name).toBe("string"); + expect(d.template.length).toBeGreaterThan(0); + } + // The repo ships coder/coordinator/reviewer prompts. + expect(defs.map((d) => d.name)).toContain("coder"); + }); + test("returns [] for a missing directory", async () => { + expect(await loadPromptDefinitions("/no/such/dir")).toEqual([]); + }); +}); diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts new file mode 100644 index 00000000..7742d35f --- /dev/null +++ b/src/mcp/prompts.ts @@ -0,0 +1,29 @@ +import { readdir, readFile } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; + +export interface PromptDefinition { + readonly name: string; + readonly description: string; + readonly template: string; +} + +/** Load each *.md / *.txt file in `dir` as a named prompt (name = file stem). */ +export async function loadPromptDefinitions(dir: string): Promise { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return []; + } + const defs: PromptDefinition[] = []; + for (const entry of entries.sort()) { + const ext = extname(entry).toLowerCase(); + if (ext !== ".md" && ext !== ".txt") continue; + const template = (await readFile(join(dir, entry), "utf8")).trim(); + if (template.length === 0) continue; + const name = basename(entry, ext); + const firstLine = template.split("\n", 1)[0]?.replace(/^#+\s*/, "") ?? name; + defs.push({ name, description: firstLine.slice(0, 120), template }); + } + return defs; +} diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 4f04f833..48c9ea11 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -28,6 +28,14 @@ function getRegisteredToolNames(server: McpServer): string[] { return Object.keys(internal._registeredTools).sort(); } +/** Extract the list of registered prompt names from a McpServer instance. */ +function getRegisteredPromptNames(server: McpServer): string[] { + const internal = server as unknown as { + _registeredPrompts: Record; + }; + return Object.keys(internal._registeredPrompts).sort(); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -325,3 +333,41 @@ describe("createMcpServer preset scoping", () => { expect(names).toContain("grove_goal"); }); }); + +// --------------------------------------------------------------------------- +// Prompt registration tests +// --------------------------------------------------------------------------- + +describe("createMcpServer prompt registration", () => { + let testDeps: TestMcpDeps; + let deps: McpDeps; + + beforeEach(async () => { + testDeps = await createTestMcpDeps(); + deps = testDeps.deps; + }); + + afterEach(async () => { + await testDeps.cleanup(); + }); + + test("no promptsDir → no prompts registered", async () => { + const server = await createMcpServer(deps); + expect(getRegisteredPromptNames(server)).toEqual([]); + }); + + test("promptsDir pointing at repo prompts/ → registers coder/coordinator/reviewer", async () => { + const promptsDir = new URL("../../prompts/", import.meta.url).pathname; + const server = await createMcpServer(deps, { promptsDir }); + const names = getRegisteredPromptNames(server); + expect(names).toContain("coder"); + expect(names).toContain("coordinator"); + expect(names).toContain("reviewer"); + expect(names.length).toBeGreaterThan(0); + }); + + test("promptsDir pointing at missing dir → no prompts registered (no error)", async () => { + const server = await createMcpServer(deps, { promptsDir: "/no/such/dir" }); + expect(getRegisteredPromptNames(server)).toEqual([]); + }); +}); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b69fa75f..f225076a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -13,6 +13,7 @@ import { registerAskUserTools } from "@grove/ask-user"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpDeps } from "./deps.js"; +import { loadPromptDefinitions } from "./prompts.js"; import { registerBountyTools } from "./tools/bounties.js"; import { registerClaimTools } from "./tools/claims.js"; import { registerContributionTools } from "./tools/contributions.js"; @@ -67,6 +68,11 @@ export interface McpPresetConfig { * Default: "stdio" (backwards-compatible for existing callers). */ readonly transport?: "stdio" | "http"; + /** + * Directory containing *.md / *.txt prompt templates to expose as MCP + * prompts. When omitted no prompts are registered. + */ + readonly promptsDir?: string; } // --------------------------------------------------------------------------- @@ -90,7 +96,7 @@ export interface McpPresetConfig { export async function createMcpServer(deps: McpDeps, preset?: McpPresetConfig): Promise { const server = new McpServer( { name: "grove-mcp", version: "0.1.0" }, - { capabilities: { tools: {} } }, + { capabilities: { tools: {}, prompts: {} } }, ); // Contribution + done tools are always registered (core functionality). @@ -126,5 +132,15 @@ export async function createMcpServer(deps: McpDeps, preset?: McpPresetConfig): // ask_user is always registered (core functionality). await registerAskUserTools(server); + // Register prompts from promptsDir when configured. + const promptsDir = preset?.promptsDir; + if (promptsDir !== undefined) { + for (const def of await loadPromptDefinitions(promptsDir)) { + server.registerPrompt(def.name, { title: def.name, description: def.description }, () => ({ + messages: [{ role: "user", content: { type: "text", text: def.template } }], + })); + } + } + return server; } From 71e8054c2c8acb59dca74b3cb38943186a7156a7 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 16:20:18 -0700 Subject: [PATCH 16/22] feat(tui): #275 TuiPromptProvider.listMcpPrompts + prompts capability --- src/mcp/prompts.ts | 5 +++++ src/tui/handoff-actions.test.ts | 1 + src/tui/local-provider.test.ts | 10 ++++++++++ src/tui/local-provider.ts | 15 +++++++++++++- src/tui/nexus-provider.ts | 20 ++++++++++++++++++- src/tui/plugins/actions.test.ts | 1 + src/tui/provider.ts | 13 ++++++++++++ src/tui/remote-provider.ts | 1 + src/tui/screens/running-view-handoffs.test.ts | 1 + .../running-view-session-history.test.ts | 1 + src/tui/screens/screen-manager.test.ts | 1 + src/tui/spawn-manager.test.ts | 1 + 12 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts index 7742d35f..850d18f4 100644 --- a/src/mcp/prompts.ts +++ b/src/mcp/prompts.ts @@ -7,6 +7,11 @@ export interface PromptDefinition { readonly template: string; } +/** Load the prompts bundled in the repo `prompts/` directory. */ +export async function listBundledPrompts(): Promise { + return loadPromptDefinitions(new URL("../../prompts/", import.meta.url).pathname); +} + /** Load each *.md / *.txt file in `dir` as a named prompt (name = file stem). */ export async function loadPromptDefinitions(dir: string): Promise { let entries: string[]; diff --git a/src/tui/handoff-actions.test.ts b/src/tui/handoff-actions.test.ts index 674607da..11c424ff 100644 --- a/src/tui/handoff-actions.test.ts +++ b/src/tui/handoff-actions.test.ts @@ -21,6 +21,7 @@ const HANDOFFS_CAPABILITY: ProviderCapabilities = { goals: false, sessions: false, handoffs: true, + prompts: false, }; function handoff(overrides?: Partial): Handoff { diff --git a/src/tui/local-provider.test.ts b/src/tui/local-provider.test.ts index 133bda3f..8e3c6a49 100644 --- a/src/tui/local-provider.test.ts +++ b/src/tui/local-provider.test.ts @@ -213,4 +213,14 @@ describe("LocalDataProvider specific", () => { // Just verify the method exists expect(typeof provider.close).toBe("function"); }); + + test("capabilities.prompts is true", () => { + expect(provider.capabilities.prompts).toBe(true); + }); + + test("listMcpPrompts returns bundled prompts including coder", async () => { + const prompts = await provider.listMcpPrompts(); + expect(prompts.length).toBeGreaterThan(0); + expect(prompts.map((p) => p.name)).toContain("coder"); + }); }); diff --git a/src/tui/local-provider.ts b/src/tui/local-provider.ts index fc82121d..a32ce63e 100644 --- a/src/tui/local-provider.ts +++ b/src/tui/local-provider.ts @@ -11,11 +11,14 @@ import type { BountyQuery, BountyStore } from "../core/bounty-store.js"; import type { ContentStore } from "../core/cas.js"; import type { Contribution } from "../core/models.js"; import type { GoalSessionStore } from "../local/sqlite-goal-session-store.js"; +import { listBundledPrompts } from "../mcp/prompts.js"; import type { ArtifactMeta, + PromptInfo, ProviderCapabilities, TuiArtifactProvider, TuiBountyProvider, + TuiPromptProvider, } from "./provider.js"; import { StoreBackedProvider, type StoreBackedProviderDeps } from "./store-backed-provider.js"; @@ -38,7 +41,7 @@ export interface LocalProviderDeps extends StoreBackedProviderDeps { /** TUI data provider backed by local SQLite stores. */ export class LocalDataProvider extends StoreBackedProvider - implements TuiArtifactProvider, TuiBountyProvider + implements TuiArtifactProvider, TuiBountyProvider, TuiPromptProvider { protected readonly mode = "local"; @@ -64,6 +67,7 @@ export class LocalDataProvider goals: deps.goalSessionStore !== undefined, sessions: deps.goalSessionStore !== undefined, handoffs: deps.handoffStore !== undefined, + prompts: true, }; } @@ -121,6 +125,15 @@ export class LocalDataProvider return this.bountyStore.listBounties(query); } + // --------------------------------------------------------------------------- + // TuiPromptProvider + // --------------------------------------------------------------------------- + + async listMcpPrompts(): Promise { + const defs = await listBundledPrompts(); + return defs.map((d) => ({ name: d.name, description: d.description })); + } + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- diff --git a/src/tui/nexus-provider.ts b/src/tui/nexus-provider.ts index b98df787..7fb36559 100644 --- a/src/tui/nexus-provider.ts +++ b/src/tui/nexus-provider.ts @@ -13,6 +13,7 @@ import type { PeerInfo } from "../core/gossip/types.js"; import type { Contribution } from "../core/models.js"; import type { WorkspaceManager } from "../core/workspace.js"; import type { GoalSessionStore } from "../local/sqlite-goal-session-store.js"; +import { listBundledPrompts } from "../mcp/prompts.js"; import type { NexusClient } from "../nexus/client.js"; import type { NexusConfig } from "../nexus/config.js"; import { resolveConfig } from "../nexus/config.js"; @@ -28,11 +29,13 @@ import type { ArtifactMeta, FsEntry, GoalData, + PromptInfo, ProviderCapabilities, SessionRecord, TuiArtifactProvider, TuiBountyProvider, TuiGossipProvider, + TuiPromptProvider, TuiVfsProvider, } from "./provider.js"; import { @@ -76,7 +79,12 @@ export interface NexusProviderConfig { /** TUI data provider backed by Nexus VFS. */ export class NexusDataProvider extends StoreBackedProvider - implements TuiArtifactProvider, TuiVfsProvider, TuiBountyProvider, TuiGossipProvider + implements + TuiArtifactProvider, + TuiVfsProvider, + TuiBountyProvider, + TuiGossipProvider, + TuiPromptProvider { readonly capabilities: ProviderCapabilities; @@ -143,6 +151,7 @@ export class NexusDataProvider sessions: true, // Always available via NexusSessionStore // Handoffs are in local grove.db (written by MCP, readable from SQLite) handoffs: !!config.handoffStore, + prompts: true, }; this.bountyStore = new NexusBountyStore(config.nexusConfig); @@ -494,6 +503,15 @@ export class NexusDataProvider // Lifecycle // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // TuiPromptProvider + // --------------------------------------------------------------------------- + + async listMcpPrompts(): Promise { + const defs = await listBundledPrompts(); + return defs.map((d) => ({ name: d.name, description: d.description })); + } + protected override closeExtra(): void { this.bountyStore.close(); // workspace is closed by the base class diff --git a/src/tui/plugins/actions.test.ts b/src/tui/plugins/actions.test.ts index db45dc51..5a6aa218 100644 --- a/src/tui/plugins/actions.test.ts +++ b/src/tui/plugins/actions.test.ts @@ -18,6 +18,7 @@ function providerStub(): TuiDataProvider { goals: false, sessions: false, handoffs: false, + prompts: false, }, getDashboard: async () => { throw new Error("getDashboard not used"); diff --git a/src/tui/provider.ts b/src/tui/provider.ts index e0d7ccdf..0f201028 100644 --- a/src/tui/provider.ts +++ b/src/tui/provider.ts @@ -43,6 +43,7 @@ export interface ProviderCapabilities { readonly goals: boolean; readonly sessions: boolean; readonly handoffs: boolean; + readonly prompts: boolean; } // --------------------------------------------------------------------------- @@ -422,6 +423,18 @@ export function isHandoffProvider(p: unknown): p is TuiHandoffProvider { ); } +/** Prompt info returned by listMcpPrompts. */ +export interface PromptInfo { + readonly name: string; + readonly description?: string | undefined; + readonly arguments?: readonly { name: string; required?: boolean }[] | undefined; +} + +/** MCP prompt listing — available when capabilities.prompts is true. */ +export interface TuiPromptProvider { + listMcpPrompts(): Promise; +} + /** Goal management — available when capabilities.goals is true. */ export interface TuiGoalProvider { getGoal(): Promise; diff --git a/src/tui/remote-provider.ts b/src/tui/remote-provider.ts index e4e2aeff..b9c967da 100644 --- a/src/tui/remote-provider.ts +++ b/src/tui/remote-provider.ts @@ -101,6 +101,7 @@ export class RemoteDataProvider goals: true, sessions: true, handoffs: true, // Available via GET /api/handoffs on the local grove server + prompts: false, }; readonly baseUrl: string; diff --git a/src/tui/screens/running-view-handoffs.test.ts b/src/tui/screens/running-view-handoffs.test.ts index 13fbb16b..d157493f 100644 --- a/src/tui/screens/running-view-handoffs.test.ts +++ b/src/tui/screens/running-view-handoffs.test.ts @@ -19,6 +19,7 @@ const HANDOFFS_CAPABILITY: ProviderCapabilities = { goals: false, sessions: false, handoffs: true, + prompts: false, }; function agentTask(role: string, sessionId: string, phase: AgentTaskPhase): AgentTaskView { diff --git a/src/tui/screens/running-view-session-history.test.ts b/src/tui/screens/running-view-session-history.test.ts index ee4f0a12..6dc92727 100644 --- a/src/tui/screens/running-view-session-history.test.ts +++ b/src/tui/screens/running-view-session-history.test.ts @@ -35,6 +35,7 @@ function baseCapabilities(sessions: boolean) { goals: false, sessions, handoffs: false, + prompts: false, }; } diff --git a/src/tui/screens/screen-manager.test.ts b/src/tui/screens/screen-manager.test.ts index fb2c8688..06eef6c4 100644 --- a/src/tui/screens/screen-manager.test.ts +++ b/src/tui/screens/screen-manager.test.ts @@ -250,6 +250,7 @@ const ALL_CAPABILITIES_FALSE: ProviderCapabilities = { goals: false, sessions: false, handoffs: false, + prompts: false, }; const TEST_TOPOLOGY: AgentTopology = { diff --git a/src/tui/spawn-manager.test.ts b/src/tui/spawn-manager.test.ts index 57f10ea1..9535098d 100644 --- a/src/tui/spawn-manager.test.ts +++ b/src/tui/spawn-manager.test.ts @@ -71,6 +71,7 @@ function makeMockProvider(): TuiDataProvider & { goals: false, sessions: false, handoffs: false, + prompts: false, }, async getDashboard() { From 2305512a9af1fb3c79db849d8f00d8dea0c30afd Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 16:25:42 -0700 Subject: [PATCH 17/22] feat(tui): #275 prompt.* action source (Prompts group) --- src/tui/actions/builtin-actions.test.ts | 1 + src/tui/actions/dynamic-sources.test.ts | 38 ++++++++++++++++++- src/tui/actions/dynamic-sources.ts | 16 ++++++++ src/tui/actions/register-builtins.ts | 9 ++++- src/tui/actions/types.ts | 4 ++ src/tui/actions/visibility.test.ts | 1 + src/tui/app.tsx | 35 +++++++++++++++++ .../command-palette.render.test.tsx | 1 + src/tui/components/command-palette.test.tsx | 1 + src/tui/local-provider.ts | 2 +- src/tui/nexus-provider.ts | 2 +- src/tui/provider.ts | 1 + 12 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/tui/actions/builtin-actions.test.ts b/src/tui/actions/builtin-actions.test.ts index 3183a00a..9cc9af55 100644 --- a/src/tui/actions/builtin-actions.test.ts +++ b/src/tui/actions/builtin-actions.test.ts @@ -63,6 +63,7 @@ function ctx(overrides: Partial = {}): ActionContext { compareAdoptB: () => undefined, frontierAdopt: () => undefined, openPalette: () => undefined, + runPrompt: () => undefined, ...overrides, }; } diff --git a/src/tui/actions/dynamic-sources.test.ts b/src/tui/actions/dynamic-sources.test.ts index b8683da5..abcddf33 100644 --- a/src/tui/actions/dynamic-sources.test.ts +++ b/src/tui/actions/dynamic-sources.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { delegateSource, killSource, sessionNavSource, spawnSource } from "./dynamic-sources.js"; +import { + delegateSource, + killSource, + promptSource, + sessionNavSource, + spawnSource, +} from "./dynamic-sources.js"; import type { ActionContext } from "./types.js"; const baseCtx = (over: Partial): ActionContext => @@ -8,6 +14,7 @@ const baseCtx = (over: Partial): ActionContext => profiles: [], gossipPeers: [], claims: [], + mcpPrompts: [], pendingQuestionCount: 0, hasGoals: false, canSpawn: true, @@ -15,6 +22,9 @@ const baseCtx = (over: Partial): ActionContext => isPanelVisible: () => false, focusedPanel: 0, frontierSliceCount: 0, + runPrompt: () => { + /* noop */ + }, ...over, }) as ActionContext; @@ -92,6 +102,32 @@ describe("dynamic sources", () => { expect(delegate2?.available?.(delegating) ?? true).toBe(true); }); + test("promptSource emits a Prompts-group action per prompt, gated on selected session", () => { + const withSession = baseCtx({ + selectedSession: "s1", + mcpPrompts: [{ name: "triage", description: "Triage", template: "do triage" }], + }); + const actions = promptSource(withSession); + expect(actions.map((a) => a.id)).toEqual(["prompt.triage"]); + expect(actions[0]?.group).toBe("Prompts"); + // available is false without a selected session + const noSession = baseCtx({ mcpPrompts: [{ name: "triage", template: "x" }] }); + expect(actions[0]?.available?.(noSession)).toBe(false); + }); + + test("promptSource run delivers the template to the selected session", () => { + let delivered: { text: string; session: string } | undefined; + const ctx = baseCtx({ + selectedSession: "s1", + mcpPrompts: [{ name: "triage", template: "do triage" }], + runPrompt: (text: string, session: string) => { + delivered = { text, session }; + }, + }); + promptSource(ctx)[0]?.run(ctx); + expect(delivered).toEqual({ text: "do triage", session: "s1" }); + }); + test("two profiles sharing a role produce a single (de-duped) spawn action", () => { const ctx = baseCtx({ canSpawn: true, diff --git a/src/tui/actions/dynamic-sources.ts b/src/tui/actions/dynamic-sources.ts index 7b07c581..8702da3a 100644 --- a/src/tui/actions/dynamic-sources.ts +++ b/src/tui/actions/dynamic-sources.ts @@ -74,6 +74,22 @@ export const delegateSource: DynamicSource = (ctx) => run: (c) => c.delegate(peer.address), })); +export const promptSource: DynamicSource = (ctx) => + (ctx.mcpPrompts ?? []).map( + (p): Action => ({ + id: `prompt.${p.name}`, + label: `Prompt: ${p.name}`, + detail: p.description ?? "prompt", + group: "Prompts", + slash: `/prompt:${p.name}`, + keywords: ["prompt", p.name], + available: (c) => c.selectedSession !== undefined, + run: (c) => { + if (c.selectedSession) c.runPrompt(p.template ?? "", c.selectedSession); + }, + }), + ); + 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 diff --git a/src/tui/actions/register-builtins.ts b/src/tui/actions/register-builtins.ts index 5a5ad275..d446d6eb 100644 --- a/src/tui/actions/register-builtins.ts +++ b/src/tui/actions/register-builtins.ts @@ -1,5 +1,11 @@ import { buildBuiltInActions } from "./builtin-actions.js"; -import { delegateSource, killSource, sessionNavSource, spawnSource } from "./dynamic-sources.js"; +import { + delegateSource, + killSource, + promptSource, + sessionNavSource, + spawnSource, +} from "./dynamic-sources.js"; import type { ActionRegistry } from "./registry.js"; import type { ActionContext } from "./types.js"; @@ -14,4 +20,5 @@ export function registerBuiltInActions(registry: ActionRegistry, emptyCtx: Actio registry.registerDynamic("agent.spawn.", spawnSource); registry.registerDynamic("agent.kill.", killSource); registry.registerDynamic("agent.delegate.", delegateSource); + registry.registerDynamic("prompt.", promptSource); } diff --git a/src/tui/actions/types.ts b/src/tui/actions/types.ts index f068b5c8..2318bcba 100644 --- a/src/tui/actions/types.ts +++ b/src/tui/actions/types.ts @@ -53,6 +53,8 @@ export interface ActionContext { readonly gossipPeers: readonly GossipPeerSlot[]; /** Active claims, or null when scoped session can't see them. */ readonly claims: readonly Claim[] | null; + /** Bundled MCP prompts available to surface as palette actions. */ + readonly mcpPrompts?: readonly import("../provider.js").PromptInfo[] | undefined; readonly selectedSession?: string | undefined; /** CID of the highlighted contribution (cursor row), or the open detail. */ readonly selectedCid?: string | undefined; @@ -88,6 +90,8 @@ export interface ActionContext { readonly spawn: (roleId: string, command: string, parentAgentId?: string) => void; readonly kill: (session: string) => void; readonly delegate: (peerAddress: string) => void; + /** Deliver a prompt template to the selected agent via the messaging/IPC path. */ + readonly runPrompt: (template: string, session: string) => void; // Messaging readonly broadcastMessage: () => void; readonly directMessage: () => void; diff --git a/src/tui/actions/visibility.test.ts b/src/tui/actions/visibility.test.ts index 921e4106..2b0fde9c 100644 --- a/src/tui/actions/visibility.test.ts +++ b/src/tui/actions/visibility.test.ts @@ -47,6 +47,7 @@ function ctx(overrides: Partial = {}): ActionContext { showMessage: () => undefined, // Keymap-migrated capabilities (#275) openPalette: () => undefined, + runPrompt: () => undefined, enterTerminalInput: () => undefined, artifactPrev: () => undefined, artifactNext: () => undefined, diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 707ae327..d3bd76e3 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -73,7 +73,9 @@ import { isCostProvider, isGitHubProvider, isGoalProvider, + type PromptInfo, type TuiDataProvider, + type TuiPromptProvider, } from "./provider.js"; import { mintTokenForCompensation } from "./safety/internal/compensation.js"; import { useSpawnManager } from "./spawn-manager-context.js"; @@ -486,6 +488,27 @@ export function App({ paletteVisible, ); + // Fetch bundled MCP prompts for the "Prompts" palette group. Only when the + // provider advertises the capability AND the palette is open (the actions are + // session-scoped and only meaningful while operating the palette). + const hasPrompts = provider.capabilities.prompts; + const promptsFetcher = useCallback(async (): Promise => { + if (!hasPrompts) return []; + const pp = provider as Partial; + if (!pp.listMcpPrompts) return []; + try { + return await pp.listMcpPrompts(); + } catch { + return []; + } + }, [provider, hasPrompts]); + const { data: mcpPrompts } = useEventDrivenData( + promptsFetcher, + undefined, + undefined, + hasPrompts && 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. @@ -986,6 +1009,7 @@ export function App({ profiles: agentProfiles ?? [], gossipPeers: canDelegate ? (gossipPeers ?? []) : [], claims: activeClaims, + mcpPrompts: mcpPrompts ?? [], selectedSession, // Strict focused-panel selection (see resolveSelectedCid): Frontier row, // or the open Detail, else undefined. No cross-panel detail fallback — @@ -1053,6 +1077,15 @@ export function App({ handleSpawn(roleId, command, "HEAD", parentAgentId), kill: (session) => handleKill(session), delegate: (peerAddress) => void handleDelegate(peerAddress), + // Deliver the prompt template to the selected agent through the proper + // messaging/IPC path (sendTuiMessage → provider.sendMessage / boardroom + // POST). Never tmux send-keys. The recipient is the agent id derived from + // the session, matching the direct-message flow. + runPrompt: (template, session) => { + if (!template) return; + const recipient = agentIdFromSession(session) ?? session; + void sendTuiMessage(recipient, template); + }, broadcastMessage: () => { dispatch({ type: "BROADCAST_MODE" }); panels.setMode(InputMode.MessageInput); @@ -1168,6 +1201,7 @@ export function App({ gossipPeers, canDelegate, activeClaims, + mcpPrompts, selectedSession, nav, paletteParentId, @@ -1186,6 +1220,7 @@ export function App({ handleSpawn, handleKill, handleDelegate, + sendTuiMessage, refreshAll, handleQuit, showError, diff --git a/src/tui/components/command-palette.render.test.tsx b/src/tui/components/command-palette.render.test.tsx index f87b6414..c08b1323 100644 --- a/src/tui/components/command-palette.render.test.tsx +++ b/src/tui/components/command-palette.render.test.tsx @@ -62,6 +62,7 @@ function ctx(overrides: Partial = {}): ActionContext { showMessage: () => undefined, // Keymap-migrated capabilities (#275) openPalette: () => undefined, + runPrompt: () => undefined, enterTerminalInput: () => undefined, artifactPrev: () => undefined, artifactNext: () => undefined, diff --git a/src/tui/components/command-palette.test.tsx b/src/tui/components/command-palette.test.tsx index 1630b6a8..bff4c837 100644 --- a/src/tui/components/command-palette.test.tsx +++ b/src/tui/components/command-palette.test.tsx @@ -50,6 +50,7 @@ function ctx(overrides: Partial = {}): ActionContext { showMessage: () => undefined, // Keymap-migrated capabilities (#275) openPalette: () => undefined, + runPrompt: () => undefined, enterTerminalInput: () => undefined, artifactPrev: () => undefined, artifactNext: () => undefined, diff --git a/src/tui/local-provider.ts b/src/tui/local-provider.ts index a32ce63e..2456a625 100644 --- a/src/tui/local-provider.ts +++ b/src/tui/local-provider.ts @@ -131,7 +131,7 @@ export class LocalDataProvider async listMcpPrompts(): Promise { const defs = await listBundledPrompts(); - return defs.map((d) => ({ name: d.name, description: d.description })); + return defs.map((d) => ({ name: d.name, description: d.description, template: d.template })); } // --------------------------------------------------------------------------- diff --git a/src/tui/nexus-provider.ts b/src/tui/nexus-provider.ts index 7fb36559..7d9c0f64 100644 --- a/src/tui/nexus-provider.ts +++ b/src/tui/nexus-provider.ts @@ -509,7 +509,7 @@ export class NexusDataProvider async listMcpPrompts(): Promise { const defs = await listBundledPrompts(); - return defs.map((d) => ({ name: d.name, description: d.description })); + return defs.map((d) => ({ name: d.name, description: d.description, template: d.template })); } protected override closeExtra(): void { diff --git a/src/tui/provider.ts b/src/tui/provider.ts index 0f201028..4de6deda 100644 --- a/src/tui/provider.ts +++ b/src/tui/provider.ts @@ -427,6 +427,7 @@ export function isHandoffProvider(p: unknown): p is TuiHandoffProvider { export interface PromptInfo { readonly name: string; readonly description?: string | undefined; + readonly template?: string | undefined; readonly arguments?: readonly { name: string; required?: boolean }[] | undefined; } From b0cf0f586501edfafba03f6ca8419032a1a64c2d Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 16:37:50 -0700 Subject: [PATCH 18/22] feat(core): #275 listAvailableSkills (bundled + topology) --- src/core/runtime-skill-acquisition.test.ts | 20 ++++++++ src/core/runtime-skill-acquisition.ts | 57 +++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/core/runtime-skill-acquisition.test.ts b/src/core/runtime-skill-acquisition.test.ts index 252226ca..d3cfc984 100644 --- a/src/core/runtime-skill-acquisition.test.ts +++ b/src/core/runtime-skill-acquisition.test.ts @@ -6,6 +6,8 @@ import type { RuntimeSkillsConfig } from "./config.js"; import type { RuntimeSkillResolver } from "./runtime-skill-acquisition.js"; import { DefaultRuntimeSkillAcquisitionService, + listAvailableSkills, + listBundledSkillNames, RuntimeSkillAcquisitionError, } from "./runtime-skill-acquisition.js"; import type { RuntimeSkillSessionStore } from "./session.js"; @@ -346,3 +348,21 @@ describe("DefaultRuntimeSkillAcquisitionService", () => { ).rejects.toMatchObject({ code: "CATALOG_UNAVAILABLE" }); }); }); + +describe("listAvailableSkills (#275)", () => { + test("includes the bundled grove skill", async () => { + const skills = await listAvailableSkills(); + expect(skills.map((s) => s.name)).toContain("grove"); + expect(skills.find((s) => s.name === "grove")?.source).toBe("bundled"); + }); + test("merges topology skills and dedupes (bundled wins)", async () => { + const skills = await listAvailableSkills(["custom-role-skill", "grove"]); + const names = skills.map((s) => s.name); + expect(names).toContain("custom-role-skill"); + expect(names.filter((n) => n === "grove").length).toBe(1); // deduped + expect(skills.find((s) => s.name === "custom-role-skill")?.source).toBe("topology"); + }); + test("listBundledSkillNames returns [] for a missing dir", async () => { + expect(await listBundledSkillNames("/no/such/dir")).toEqual([]); + }); +}); diff --git a/src/core/runtime-skill-acquisition.ts b/src/core/runtime-skill-acquisition.ts index 19e13185..d3a5e971 100644 --- a/src/core/runtime-skill-acquisition.ts +++ b/src/core/runtime-skill-acquisition.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { readFile, stat } from "node:fs/promises"; +import { readdir, readFile, stat } from "node:fs/promises"; import { join, relative } from "node:path"; import type { RuntimeSkillsConfig } from "./config.js"; import type { RuntimeSkillSessionStore } from "./session.js"; @@ -176,6 +176,61 @@ function relativeTargets(workspacePath: string, targets: readonly string[]): rea const SESSION_PERSIST_RETRY_MESSAGE = "Runtime skill workspace install succeeded, but session persistence failed; fix or recover session state, then retry grove_request_skill."; +export interface AvailableSkill { + readonly name: string; + readonly source: "bundled" | "topology"; +} + +/** Names of bundled skills: subdirectories of `dir` that contain a SKILL.md. */ +export async function listBundledSkillNames(dir: string): Promise { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return []; + } + const names: string[] = []; + for (const entry of entries.sort()) { + try { + const s = await stat(join(dir, entry)); + if (!s.isDirectory()) continue; + const skillFile = await stat(join(dir, entry, "SKILL.md")); + if (skillFile.isFile()) names.push(entry); + } catch { + // skip non-skill entries + } + } + return names; +} + +/** The repo-bundled skills directory (contains one subdir per skill). */ +export function resolveRepoSkillsDir(): string { + return new URL("../../skills/", import.meta.url).pathname; +} + +/** + * Available skills = bundled (from the repo skills/ dir) merged with the given + * topology-declared skills, deduped by name (bundled wins on conflict). + */ +export async function listAvailableSkills( + topologySkills: readonly string[] = [], +): Promise { + const bundled = await listBundledSkillNames(resolveRepoSkillsDir()); + const seen = new Set(); + const out: AvailableSkill[] = []; + for (const name of bundled) { + if (seen.has(name)) continue; + seen.add(name); + out.push({ name, source: "bundled" }); + } + for (const name of topologySkills) { + if (seen.has(name)) continue; + seen.add(name); + out.push({ name, source: "topology" }); + } + return out; +} + export class DefaultRuntimeSkillAcquisitionService implements RuntimeSkillAcquisitionService { private readonly deps: RuntimeSkillAcquisitionDeps; From 2fdd4dcfb7cb404598376266921a3046f47c5e04 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 16:41:59 -0700 Subject: [PATCH 19/22] feat(tui): #275 TuiSkillProvider.listAvailableSkills + skills capability --- src/tui/handoff-actions.test.ts | 1 + src/tui/local-provider.test.ts | 10 ++++++++++ src/tui/local-provider.ts | 15 ++++++++++++++- src/tui/nexus-provider.ts | 16 +++++++++++++++- src/tui/plugins/actions.test.ts | 1 + src/tui/provider.ts | 13 +++++++++++++ src/tui/remote-provider.ts | 1 + src/tui/screens/running-view-handoffs.test.ts | 1 + .../screens/running-view-session-history.test.ts | 1 + src/tui/screens/screen-manager.test.ts | 1 + src/tui/spawn-manager.test.ts | 1 + 11 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/tui/handoff-actions.test.ts b/src/tui/handoff-actions.test.ts index 11c424ff..b36573c6 100644 --- a/src/tui/handoff-actions.test.ts +++ b/src/tui/handoff-actions.test.ts @@ -22,6 +22,7 @@ const HANDOFFS_CAPABILITY: ProviderCapabilities = { sessions: false, handoffs: true, prompts: false, + skills: false, }; function handoff(overrides?: Partial): Handoff { diff --git a/src/tui/local-provider.test.ts b/src/tui/local-provider.test.ts index 8e3c6a49..7e8c88f3 100644 --- a/src/tui/local-provider.test.ts +++ b/src/tui/local-provider.test.ts @@ -223,4 +223,14 @@ describe("LocalDataProvider specific", () => { expect(prompts.length).toBeGreaterThan(0); expect(prompts.map((p) => p.name)).toContain("coder"); }); + + test("capabilities.skills is true", () => { + expect(provider.capabilities.skills).toBe(true); + }); + + test("listAvailableSkills returns a non-empty array including 'grove'", async () => { + const skills = await provider.listAvailableSkills(); + expect(skills.length).toBeGreaterThan(0); + expect(skills.map((s) => s.name)).toContain("grove"); + }); }); diff --git a/src/tui/local-provider.ts b/src/tui/local-provider.ts index 2456a625..1b9e60ed 100644 --- a/src/tui/local-provider.ts +++ b/src/tui/local-provider.ts @@ -10,15 +10,18 @@ import type { Bounty } from "../core/bounty.js"; import type { BountyQuery, BountyStore } from "../core/bounty-store.js"; import type { ContentStore } from "../core/cas.js"; import type { Contribution } from "../core/models.js"; +import { listAvailableSkills } from "../core/runtime-skill-acquisition.js"; import type { GoalSessionStore } from "../local/sqlite-goal-session-store.js"; import { listBundledPrompts } from "../mcp/prompts.js"; import type { ArtifactMeta, PromptInfo, ProviderCapabilities, + SkillInfo, TuiArtifactProvider, TuiBountyProvider, TuiPromptProvider, + TuiSkillProvider, } from "./provider.js"; import { StoreBackedProvider, type StoreBackedProviderDeps } from "./store-backed-provider.js"; @@ -41,7 +44,7 @@ export interface LocalProviderDeps extends StoreBackedProviderDeps { /** TUI data provider backed by local SQLite stores. */ export class LocalDataProvider extends StoreBackedProvider - implements TuiArtifactProvider, TuiBountyProvider, TuiPromptProvider + implements TuiArtifactProvider, TuiBountyProvider, TuiPromptProvider, TuiSkillProvider { protected readonly mode = "local"; @@ -68,6 +71,7 @@ export class LocalDataProvider sessions: deps.goalSessionStore !== undefined, handoffs: deps.handoffStore !== undefined, prompts: true, + skills: true, }; } @@ -134,6 +138,15 @@ export class LocalDataProvider return defs.map((d) => ({ name: d.name, description: d.description, template: d.template })); } + // --------------------------------------------------------------------------- + // TuiSkillProvider + // --------------------------------------------------------------------------- + + async listAvailableSkills(): Promise { + const skills = await listAvailableSkills(); + return skills.map((s) => ({ name: s.name })); + } + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- diff --git a/src/tui/nexus-provider.ts b/src/tui/nexus-provider.ts index 7d9c0f64..0989d112 100644 --- a/src/tui/nexus-provider.ts +++ b/src/tui/nexus-provider.ts @@ -11,6 +11,7 @@ import type { BountyQuery } from "../core/bounty-store.js"; import { DefaultFrontierCalculator } from "../core/frontier.js"; import type { PeerInfo } from "../core/gossip/types.js"; import type { Contribution } from "../core/models.js"; +import { listAvailableSkills } from "../core/runtime-skill-acquisition.js"; import type { WorkspaceManager } from "../core/workspace.js"; import type { GoalSessionStore } from "../local/sqlite-goal-session-store.js"; import { listBundledPrompts } from "../mcp/prompts.js"; @@ -32,10 +33,12 @@ import type { PromptInfo, ProviderCapabilities, SessionRecord, + SkillInfo, TuiArtifactProvider, TuiBountyProvider, TuiGossipProvider, TuiPromptProvider, + TuiSkillProvider, TuiVfsProvider, } from "./provider.js"; import { @@ -84,7 +87,8 @@ export class NexusDataProvider TuiVfsProvider, TuiBountyProvider, TuiGossipProvider, - TuiPromptProvider + TuiPromptProvider, + TuiSkillProvider { readonly capabilities: ProviderCapabilities; @@ -152,6 +156,7 @@ export class NexusDataProvider // Handoffs are in local grove.db (written by MCP, readable from SQLite) handoffs: !!config.handoffStore, prompts: true, + skills: true, }; this.bountyStore = new NexusBountyStore(config.nexusConfig); @@ -512,6 +517,15 @@ export class NexusDataProvider return defs.map((d) => ({ name: d.name, description: d.description, template: d.template })); } + // --------------------------------------------------------------------------- + // TuiSkillProvider + // --------------------------------------------------------------------------- + + async listAvailableSkills(): Promise { + const skills = await listAvailableSkills(); + return skills.map((s) => ({ name: s.name })); + } + protected override closeExtra(): void { this.bountyStore.close(); // workspace is closed by the base class diff --git a/src/tui/plugins/actions.test.ts b/src/tui/plugins/actions.test.ts index 5a6aa218..4e093264 100644 --- a/src/tui/plugins/actions.test.ts +++ b/src/tui/plugins/actions.test.ts @@ -19,6 +19,7 @@ function providerStub(): TuiDataProvider { sessions: false, handoffs: false, prompts: false, + skills: false, }, getDashboard: async () => { throw new Error("getDashboard not used"); diff --git a/src/tui/provider.ts b/src/tui/provider.ts index 4de6deda..3851c123 100644 --- a/src/tui/provider.ts +++ b/src/tui/provider.ts @@ -44,6 +44,7 @@ export interface ProviderCapabilities { readonly sessions: boolean; readonly handoffs: boolean; readonly prompts: boolean; + readonly skills: boolean; } // --------------------------------------------------------------------------- @@ -436,6 +437,18 @@ export interface TuiPromptProvider { listMcpPrompts(): Promise; } +/** Skill info returned by listAvailableSkills. */ +export interface SkillInfo { + readonly name: string; + readonly description?: string | undefined; + readonly roles?: readonly string[] | undefined; +} + +/** Skill listing — available when capabilities.skills is true. */ +export interface TuiSkillProvider { + listAvailableSkills(): Promise; +} + /** Goal management — available when capabilities.goals is true. */ export interface TuiGoalProvider { getGoal(): Promise; diff --git a/src/tui/remote-provider.ts b/src/tui/remote-provider.ts index b9c967da..3c760146 100644 --- a/src/tui/remote-provider.ts +++ b/src/tui/remote-provider.ts @@ -102,6 +102,7 @@ export class RemoteDataProvider sessions: true, handoffs: true, // Available via GET /api/handoffs on the local grove server prompts: false, + skills: false, }; readonly baseUrl: string; diff --git a/src/tui/screens/running-view-handoffs.test.ts b/src/tui/screens/running-view-handoffs.test.ts index d157493f..5f19f3ec 100644 --- a/src/tui/screens/running-view-handoffs.test.ts +++ b/src/tui/screens/running-view-handoffs.test.ts @@ -20,6 +20,7 @@ const HANDOFFS_CAPABILITY: ProviderCapabilities = { sessions: false, handoffs: true, prompts: false, + skills: false, }; function agentTask(role: string, sessionId: string, phase: AgentTaskPhase): AgentTaskView { diff --git a/src/tui/screens/running-view-session-history.test.ts b/src/tui/screens/running-view-session-history.test.ts index 6dc92727..ba2896f9 100644 --- a/src/tui/screens/running-view-session-history.test.ts +++ b/src/tui/screens/running-view-session-history.test.ts @@ -36,6 +36,7 @@ function baseCapabilities(sessions: boolean) { sessions, handoffs: false, prompts: false, + skills: false, }; } diff --git a/src/tui/screens/screen-manager.test.ts b/src/tui/screens/screen-manager.test.ts index 06eef6c4..593dbf11 100644 --- a/src/tui/screens/screen-manager.test.ts +++ b/src/tui/screens/screen-manager.test.ts @@ -251,6 +251,7 @@ const ALL_CAPABILITIES_FALSE: ProviderCapabilities = { sessions: false, handoffs: false, prompts: false, + skills: false, }; const TEST_TOPOLOGY: AgentTopology = { diff --git a/src/tui/spawn-manager.test.ts b/src/tui/spawn-manager.test.ts index 9535098d..9ff76f08 100644 --- a/src/tui/spawn-manager.test.ts +++ b/src/tui/spawn-manager.test.ts @@ -72,6 +72,7 @@ function makeMockProvider(): TuiDataProvider & { sessions: false, handoffs: false, prompts: false, + skills: false, }, async getDashboard() { From 7799e13fff243438c3a82d5b31b256b13528bbe4 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 16:47:10 -0700 Subject: [PATCH 20/22] feat(tui): #275 slot-scoped skill.request.* action source (Skills group) --- src/tui/actions/builtin-actions.test.ts | 1 + src/tui/actions/dynamic-sources.test.ts | 44 ++++++++++ src/tui/actions/dynamic-sources.ts | 21 +++++ src/tui/actions/register-builtins.ts | 2 + src/tui/actions/types.ts | 6 ++ src/tui/actions/visibility.test.ts | 1 + src/tui/app.tsx | 82 +++++++++++++++++++ .../command-palette.render.test.tsx | 1 + src/tui/components/command-palette.test.tsx | 1 + 9 files changed, 159 insertions(+) diff --git a/src/tui/actions/builtin-actions.test.ts b/src/tui/actions/builtin-actions.test.ts index 9cc9af55..8b9a952c 100644 --- a/src/tui/actions/builtin-actions.test.ts +++ b/src/tui/actions/builtin-actions.test.ts @@ -64,6 +64,7 @@ function ctx(overrides: Partial = {}): ActionContext { frontierAdopt: () => undefined, openPalette: () => undefined, runPrompt: () => undefined, + requestSkill: () => undefined, ...overrides, }; } diff --git a/src/tui/actions/dynamic-sources.test.ts b/src/tui/actions/dynamic-sources.test.ts index abcddf33..9afc1137 100644 --- a/src/tui/actions/dynamic-sources.test.ts +++ b/src/tui/actions/dynamic-sources.test.ts @@ -4,6 +4,7 @@ import { killSource, promptSource, sessionNavSource, + skillSource, spawnSource, } from "./dynamic-sources.js"; import type { ActionContext } from "./types.js"; @@ -15,6 +16,7 @@ const baseCtx = (over: Partial): ActionContext => gossipPeers: [], claims: [], mcpPrompts: [], + availableSkills: [], pendingQuestionCount: 0, hasGoals: false, canSpawn: true, @@ -25,6 +27,9 @@ const baseCtx = (over: Partial): ActionContext => runPrompt: () => { /* noop */ }, + requestSkill: () => { + /* noop */ + }, ...over, }) as ActionContext; @@ -128,6 +133,45 @@ describe("dynamic sources", () => { expect(delivered).toEqual({ text: "do triage", session: "s1" }); }); + test("skillSource scopes to the selected agent's role skills", () => { + const ctx = baseCtx({ + selectedSession: "s1", + selectedAgentRole: "reviewer", + availableSkills: [ + { name: "code-review", roles: ["reviewer"] }, + { name: "writing", roles: ["author"] }, + ], + }); + expect(skillSource(ctx).map((a) => a.id)).toEqual(["skill.request.code-review"]); + expect(skillSource(ctx)[0]?.group).toBe("Skills"); + }); + + test("skillSource includes role-less (global) skills", () => { + const ctx = baseCtx({ + selectedSession: "s1", + selectedAgentRole: "reviewer", + availableSkills: [{ name: "grove" }], + }); + expect(skillSource(ctx).map((a) => a.id)).toEqual(["skill.request.grove"]); + }); + + test("skillSource is empty without a selected session", () => { + expect(skillSource(baseCtx({ availableSkills: [{ name: "x" }] }))).toEqual([]); + }); + + test("skillSource run requests the skill for the selected session", () => { + let req: { name: string; session: string } | undefined; + const ctx = baseCtx({ + selectedSession: "s1", + availableSkills: [{ name: "grove" }], + requestSkill: (name: string, session: string) => { + req = { name, session }; + }, + }); + skillSource(ctx)[0]?.run(ctx); + expect(req).toEqual({ name: "grove", session: "s1" }); + }); + test("two profiles sharing a role produce a single (de-duped) spawn action", () => { const ctx = baseCtx({ canSpawn: true, diff --git a/src/tui/actions/dynamic-sources.ts b/src/tui/actions/dynamic-sources.ts index 8702da3a..38d20bbc 100644 --- a/src/tui/actions/dynamic-sources.ts +++ b/src/tui/actions/dynamic-sources.ts @@ -90,6 +90,27 @@ export const promptSource: DynamicSource = (ctx) => }), ); +export const skillSource: DynamicSource = (ctx) => { + if (ctx.selectedSession === undefined) return []; + const role = ctx.selectedAgentRole; + return (ctx.availableSkills ?? []) + .filter((s) => s.roles === undefined || role === undefined || s.roles.includes(role)) + .map( + (s): Action => ({ + id: `skill.request.${s.name}`, + label: `Request skill: ${s.name}`, + detail: s.description ?? "skill", + group: "Skills", + slash: `/skill ${s.name}`, + keywords: ["skill", "request", s.name], + available: (c) => c.selectedSession !== undefined, + run: (c) => { + if (c.selectedSession) c.requestSkill(s.name, c.selectedSession); + }, + }), + ); +}; + 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 diff --git a/src/tui/actions/register-builtins.ts b/src/tui/actions/register-builtins.ts index d446d6eb..276a48e2 100644 --- a/src/tui/actions/register-builtins.ts +++ b/src/tui/actions/register-builtins.ts @@ -4,6 +4,7 @@ import { killSource, promptSource, sessionNavSource, + skillSource, spawnSource, } from "./dynamic-sources.js"; import type { ActionRegistry } from "./registry.js"; @@ -21,4 +22,5 @@ export function registerBuiltInActions(registry: ActionRegistry, emptyCtx: Actio registry.registerDynamic("agent.kill.", killSource); registry.registerDynamic("agent.delegate.", delegateSource); registry.registerDynamic("prompt.", promptSource); + registry.registerDynamic("skill.request.", skillSource); } diff --git a/src/tui/actions/types.ts b/src/tui/actions/types.ts index 2318bcba..5d90f502 100644 --- a/src/tui/actions/types.ts +++ b/src/tui/actions/types.ts @@ -55,6 +55,10 @@ export interface ActionContext { readonly claims: readonly Claim[] | null; /** Bundled MCP prompts available to surface as palette actions. */ readonly mcpPrompts?: readonly import("../provider.js").PromptInfo[] | undefined; + /** Skills available to surface as palette actions (bundled + topology-derived). */ + readonly availableSkills?: readonly import("../provider.js").SkillInfo[] | undefined; + /** Role of the selected agent slot — scopes which role-tagged skills are shown. */ + readonly selectedAgentRole?: string | undefined; readonly selectedSession?: string | undefined; /** CID of the highlighted contribution (cursor row), or the open detail. */ readonly selectedCid?: string | undefined; @@ -92,6 +96,8 @@ export interface ActionContext { readonly delegate: (peerAddress: string) => void; /** Deliver a prompt template to the selected agent via the messaging/IPC path. */ readonly runPrompt: (template: string, session: string) => void; + /** Ask the selected agent to acquire a skill via the messaging/IPC path. */ + readonly requestSkill: (skillName: string, session: string) => void; // Messaging readonly broadcastMessage: () => void; readonly directMessage: () => void; diff --git a/src/tui/actions/visibility.test.ts b/src/tui/actions/visibility.test.ts index 2b0fde9c..74d19aad 100644 --- a/src/tui/actions/visibility.test.ts +++ b/src/tui/actions/visibility.test.ts @@ -48,6 +48,7 @@ function ctx(overrides: Partial = {}): ActionContext { // Keymap-migrated capabilities (#275) openPalette: () => undefined, runPrompt: () => undefined, + requestSkill: () => undefined, enterTerminalInput: () => undefined, artifactPrev: () => undefined, artifactNext: () => undefined, diff --git a/src/tui/app.tsx b/src/tui/app.tsx index d3bd76e3..8d6a7bb1 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -74,8 +74,10 @@ import { isGitHubProvider, isGoalProvider, type PromptInfo, + type SkillInfo, type TuiDataProvider, type TuiPromptProvider, + type TuiSkillProvider, } from "./provider.js"; import { mintTokenForCompensation } from "./safety/internal/compensation.js"; import { useSpawnManager } from "./spawn-manager-context.js"; @@ -509,6 +511,72 @@ export function App({ hasPrompts && paletteVisible, ); + // Fetch bundled (global) skills for the "Skills" palette group. Like prompts, + // only when the provider advertises the capability AND the palette is open. + // These are role-less — topology-declared role scoping is merged in below. + const hasSkills = provider.capabilities.skills; + const skillsFetcher = useCallback(async (): Promise => { + if (!hasSkills) return []; + const sp = provider as Partial; + if (!sp.listAvailableSkills) return []; + try { + return await sp.listAvailableSkills(); + } catch { + return []; + } + }, [provider, hasSkills]); + const { data: bundledSkills } = useEventDrivenData( + skillsFetcher, + undefined, + undefined, + hasSkills && paletteVisible, + ); + + // Assemble availableSkills: merge topology-derived role-tagged skills with the + // bundled (role-less) skills, deduped by name. A skill declared by one or more + // topology roles carries the union of those role names; a purely-bundled skill + // stays role-less so it surfaces for every selected slot. + const availableSkills = useMemo(() => { + const byName = new Map(); + for (const role of topology?.roles ?? []) { + for (const skill of role.skills ?? []) { + const existing = byName.get(skill); + if (existing) { + existing.roles = [...(existing.roles ?? []), role.name]; + } else { + byName.set(skill, { name: skill, roles: [role.name] }); + } + } + } + for (const skill of bundledSkills ?? []) { + const existing = byName.get(skill.name); + if (existing) { + // Topology-declared skill that is also bundled: keep its role scoping but + // fill in a description from the bundled listing if it lacks one. + if (existing.description === undefined && skill.description !== undefined) { + existing.description = skill.description; + } + } else { + byName.set(skill.name, { + name: skill.name, + ...(skill.description !== undefined && { description: skill.description }), + ...(skill.roles !== undefined && { roles: [...skill.roles] }), + }); + } + } + return [...byName.values()]; + }, [topology, bundledSkills]); + + // Role of the selected agent slot. The agent id IS the topology role name + // (see handleSpawn: topology.roles.find(r => r.name === agentId)), so map + // selectedSession → agentId → matching role name. Falls back to the raw + // agent id when no topology role matches (so global skills still apply). + const selectedAgentRole = useMemo(() => { + if (selectedSession === undefined) return undefined; + const agentId = agentIdFromSession(selectedSession) ?? selectedSession; + return topology?.roles.find((r) => r.name === agentId)?.name ?? agentId; + }, [selectedSession, topology]); + // 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. @@ -1010,6 +1078,8 @@ export function App({ gossipPeers: canDelegate ? (gossipPeers ?? []) : [], claims: activeClaims, mcpPrompts: mcpPrompts ?? [], + availableSkills, + selectedAgentRole, selectedSession, // Strict focused-panel selection (see resolveSelectedCid): Frontier row, // or the open Detail, else undefined. No cross-panel detail fallback — @@ -1086,6 +1156,16 @@ export function App({ const recipient = agentIdFromSession(session) ?? session; void sendTuiMessage(recipient, template); }, + // Ask the selected agent to acquire a skill through the same messaging/IPC + // path as runPrompt (sendTuiMessage → provider.sendMessage / boardroom + // POST). Never tmux send-keys. The agent uses grove_request_skill to fetch. + requestSkill: (skillName, session) => { + const recipient = agentIdFromSession(session) ?? session; + void sendTuiMessage( + recipient, + `Please acquire the "${skillName}" skill (use grove_request_skill).`, + ); + }, broadcastMessage: () => { dispatch({ type: "BROADCAST_MODE" }); panels.setMode(InputMode.MessageInput); @@ -1202,6 +1282,8 @@ export function App({ canDelegate, activeClaims, mcpPrompts, + availableSkills, + selectedAgentRole, selectedSession, nav, paletteParentId, diff --git a/src/tui/components/command-palette.render.test.tsx b/src/tui/components/command-palette.render.test.tsx index c08b1323..b3c8c88e 100644 --- a/src/tui/components/command-palette.render.test.tsx +++ b/src/tui/components/command-palette.render.test.tsx @@ -63,6 +63,7 @@ function ctx(overrides: Partial = {}): ActionContext { // Keymap-migrated capabilities (#275) openPalette: () => undefined, runPrompt: () => undefined, + requestSkill: () => undefined, enterTerminalInput: () => undefined, artifactPrev: () => undefined, artifactNext: () => undefined, diff --git a/src/tui/components/command-palette.test.tsx b/src/tui/components/command-palette.test.tsx index bff4c837..45b862d4 100644 --- a/src/tui/components/command-palette.test.tsx +++ b/src/tui/components/command-palette.test.tsx @@ -51,6 +51,7 @@ function ctx(overrides: Partial = {}): ActionContext { // Keymap-migrated capabilities (#275) openPalette: () => undefined, runPrompt: () => undefined, + requestSkill: () => undefined, enterTerminalInput: () => undefined, artifactPrev: () => undefined, artifactNext: () => undefined, From 15e35ee4eb65369f69eddb8a6b373ef290fa6a16 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Fri, 29 May 2026 17:01:56 -0700 Subject: [PATCH 21/22] =?UTF-8?q?fix(tui):=20#275=20final-review=20cleanup?= =?UTF-8?q?s=20=E2=80=94=20skill=20slash=20colon=20form,=20:=20surface=20f?= =?UTF-8?q?etch,=20server=20promptsDir,=20:=20enabled-gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mcp/serve-http.ts | 3 +++ src/mcp/serve.ts | 5 +++++ src/tui/actions/dynamic-sources.test.ts | 21 +++++++++++++++++++++ src/tui/actions/dynamic-sources.ts | 2 +- src/tui/actions/slash-index.test.ts | 15 ++++++++++++++- src/tui/app.tsx | 11 +++++++++-- 6 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/mcp/serve-http.ts b/src/mcp/serve-http.ts index 983a97a6..57aeeadf 100644 --- a/src/mcp/serve-http.ts +++ b/src/mcp/serve-http.ts @@ -1325,9 +1325,12 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise try { // Runtime skill acquisition mutates the caller workspace and requires // stdio's per-agent role/cwd binding. HTTP MCP intentionally omits it. + // promptsDir: repo-root prompts/ relative to this file (src/mcp/serve-http.ts) + // so prompts/list exposes the bundled templates; degrades to [] if absent. server = await createMcpServer(scopedDeps, { eval: evalEnabled, transport: "http", + promptsDir: new URL("../../prompts/", import.meta.url).pathname, }); } catch (err) { acquiredScope.release(); diff --git a/src/mcp/serve.ts b/src/mcp/serve.ts index e46709f2..c31d9f51 100644 --- a/src/mcp/serve.ts +++ b/src/mcp/serve.ts @@ -823,6 +823,9 @@ try { // enable with GROVE_MCP_EVAL_ENABLED=true (stdio) or AUTH_TOKEN + // GROVE_MCP_EVAL_ENABLED=true (HTTP — enforced in serve-http.ts). const evalEnabled = process.env.GROVE_MCP_EVAL_ENABLED === "true"; + // Repo-root prompts/ dir, relative to this file (src/mcp/serve.ts). Registers + // bundled prompt templates for prompts/list; degrades to [] on a missing dir. + const promptsDir = new URL("../../prompts/", import.meta.url).pathname; preset = contractMode === "evaluation" ? { @@ -837,6 +840,7 @@ try { plans: true, goals: true, eval: evalEnabled, + promptsDir, } : { queries: true, @@ -850,6 +854,7 @@ try { plans: false, goals: true, eval: evalEnabled, + promptsDir, }; close = () => { diff --git a/src/tui/actions/dynamic-sources.test.ts b/src/tui/actions/dynamic-sources.test.ts index 9afc1137..34c8b937 100644 --- a/src/tui/actions/dynamic-sources.test.ts +++ b/src/tui/actions/dynamic-sources.test.ts @@ -7,6 +7,7 @@ import { skillSource, spawnSource, } from "./dynamic-sources.js"; +import { buildSlashIndex, resolveSlash } from "./slash-index.js"; import type { ActionContext } from "./types.js"; const baseCtx = (over: Partial): ActionContext => @@ -155,6 +156,26 @@ describe("dynamic sources", () => { expect(skillSource(ctx).map((a) => a.id)).toEqual(["skill.request.grove"]); }); + test("skillSource uses the colon slash form so it resolves via the ':' command-line", () => { + const ctx = baseCtx({ + selectedSession: "s1", + availableSkills: [{ name: "grove" }], + }); + expect(skillSource(ctx)[0]?.slash).toBe("/skill:grove"); + }); + + test("colon-form skill slash is resolvable via the command-line index", () => { + const ctx = baseCtx({ + selectedSession: "s1", + availableSkills: [{ name: "grove" }], + }); + const index = buildSlashIndex(skillSource(ctx)); + expect(resolveSlash(index, "/skill:grove")).toEqual({ + id: "skill.request.grove", + args: [], + }); + }); + test("skillSource is empty without a selected session", () => { expect(skillSource(baseCtx({ availableSkills: [{ name: "x" }] }))).toEqual([]); }); diff --git a/src/tui/actions/dynamic-sources.ts b/src/tui/actions/dynamic-sources.ts index 38d20bbc..7fb0124c 100644 --- a/src/tui/actions/dynamic-sources.ts +++ b/src/tui/actions/dynamic-sources.ts @@ -101,7 +101,7 @@ export const skillSource: DynamicSource = (ctx) => { label: `Request skill: ${s.name}`, detail: s.description ?? "skill", group: "Skills", - slash: `/skill ${s.name}`, + slash: `/skill:${s.name}`, keywords: ["skill", "request", s.name], available: (c) => c.selectedSession !== undefined, run: (c) => { diff --git a/src/tui/actions/slash-index.test.ts b/src/tui/actions/slash-index.test.ts index 7d749356..6aef661f 100644 --- a/src/tui/actions/slash-index.test.ts +++ b/src/tui/actions/slash-index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { skillSource } from "./dynamic-sources.js"; import { buildSlashIndex, resolveSlash } from "./slash-index.js"; -import type { Action } from "./types.js"; +import type { Action, ActionContext } from "./types.js"; const a = (id: string, slash?: string): Action => ({ id, label: id, detail: "", group: "View", slash, run: () => {} }) as Action; @@ -24,4 +25,16 @@ describe("resolveSlash", () => { test("returns undefined for unknown command", () => { expect(resolveSlash(buildSlashIndex([]), "/nope")).toBeUndefined(); }); + + test("resolves the colon-joined skill slash from skillSource", () => { + const ctx = { + selectedSession: "s1", + availableSkills: [{ name: "grove" }], + } as unknown as ActionContext; + const idx = buildSlashIndex(skillSource(ctx)); + expect(resolveSlash(idx, "/skill:grove")).toEqual({ + id: "skill.request.grove", + args: [], + }); + }); }); diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 8d6a7bb1..d510707d 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -398,6 +398,9 @@ export function App({ // Poll tmux sessions — used by command palette, agent count, split pane, // and transcript search. Always active when tmux is available (fix #3). const paletteVisible = panels.state.mode === InputMode.CommandPalette; + // Prompts/skills are reachable via BOTH the command palette and the ':' + // command-line (SlashCommand mode), so their data must load for either surface. + const slashSurfacesOpen = paletteVisible || panels.state.mode === InputMode.SlashCommand; const sessionsFetcher = useCallback(async () => { if (!tmux) return [] as readonly string[]; const available = await tmux.isAvailable(); @@ -508,7 +511,7 @@ export function App({ promptsFetcher, undefined, undefined, - hasPrompts && paletteVisible, + hasPrompts && slashSurfacesOpen, ); // Fetch bundled (global) skills for the "Skills" palette group. Like prompts, @@ -529,7 +532,7 @@ export function App({ skillsFetcher, undefined, undefined, - hasSkills && paletteVisible, + hasSkills && slashSurfacesOpen, ); // Assemble availableSkills: merge topology-derived role-tagged skills with the @@ -1380,6 +1383,10 @@ export function App({ return; } const action = registry.byId(resolution.id, actionContext); + if (action && !resolveEnabled(action, actionContext).enabled) { + setSlashError(`Command not available: /${slashBuffer.split(/\s+/)[0] ?? ""}`); + return; + } setSlashBuffer(""); setSlashError(undefined); panels.setMode(InputMode.Normal); From 180822eceb40c62aa579d86c5a3a781a6687b88c Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Sat, 30 May 2026 11:18:25 -0700 Subject: [PATCH 22/22] =?UTF-8?q?fix(tui):=20#275=20sort=20registry.ts=20i?= =?UTF-8?q?mports=20(biome=20organizeImports=20=E2=80=94=20CI=20lint=20gat?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tui/actions/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/actions/registry.ts b/src/tui/actions/registry.ts index 1f01750b..3a8104ef 100644 --- a/src/tui/actions/registry.ts +++ b/src/tui/actions/registry.ts @@ -1,6 +1,6 @@ import { fuzzyMatch } from "./fuzzy.js"; -import { computeVisibleActions } from "./visibility.js"; import type { Action, ActionContext, DynamicSource } from "./types.js"; +import { computeVisibleActions } from "./visibility.js"; export interface ActionRegistry { register(action: Action): void;