diff --git a/docs/superpowers/plans/2026-05-26-tui-plugin-extensibility.md b/docs/superpowers/plans/2026-05-26-tui-plugin-extensibility.md new file mode 100644 index 000000000..efd0316a4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-tui-plugin-extensibility.md @@ -0,0 +1,1423 @@ +# TUI Plugin Extensibility 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:** Add a trusted local-code TUI extension surface so callers can register custom default-visible panels and custom command palette actions without editing core workflow-specific TUI code. + +**Architecture:** Extend the existing `src/tui/plugins/` registry foundation with action registrations and extension bundles, then wire `App`, `CommandPalette`, and `PanelManager` to consume merged registries. Built-ins remain first-class and unchanged when no extensions are passed; plugin mistakes produce diagnostics and skipped entries. + +**Tech Stack:** Bun 1.3.x, bun:test, TypeScript strict mode, React/OpenTUI, Biome. + +--- + +## File Structure + +- Modify `src/tui/plugins/types.ts`: public `TuiExtension`, `TuiActionRegistration`, and `showMessage` context field. +- Modify `src/tui/plugins/registry.ts`: shared ID validation, panel registration collection, action registration collection, and action merge helper. +- Modify `src/tui/plugins/registry.test.ts`: tests for extension collection and action registry behavior. +- Create `src/tui/plugins/actions.ts`: small action execution helper used by `App`. +- Create `src/tui/plugins/actions.test.ts`: tests for action callback execution and failure propagation. +- Modify `src/tui/components/command-palette.tsx`: add plugin-action palette item kind, built-in fixed action registry entries, and plugin action item builder. +- Create `src/tui/components/command-palette.test.tsx`: tests for plugin action palette item projection. +- Create `src/tui/panels/plugin-panels.ts`: pure helper selecting default-visible plugin operator panels. +- Create `src/tui/panels/plugin-panels.test.ts`: tests for plugin panel visibility selection. +- Modify `src/tui/panels/panel-manager.tsx`: render default-visible plugin panels with normal panel chrome. +- Modify `src/tui/app.tsx`: accept `extensions`, merge panel/action registries, build `TuiPluginContext`, pass plugin panel entries to `PanelManager`, append plugin actions to palette items, and execute plugin actions. +- Create `docs/tui/tui-extensions.md`: extension author guide with examples and safety notes. + +## Test Command Pattern + +Partial `bun test` runs inherit coverage thresholds from `bunfig.toml`. Use this focused-test wrapper for red/green steps unless the step explicitly says to run full verification: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/plugins/registry.test.ts +rm "$tmp" +``` + +### Task 1: Extend Plugin Types and Registries + +**Files:** +- Modify: `src/tui/plugins/types.ts` +- Modify: `src/tui/plugins/registry.ts` +- Test: `src/tui/plugins/registry.test.ts` + +- [ ] **Step 1: Write the failing registry tests** + +Extend `src/tui/plugins/registry.test.ts` with action and extension coverage. Keep the existing panel tests, replace the current imports with these imports, then add the helpers and test blocks. + +```ts +import { describe, expect, test } from "bun:test"; +import { Panel } from "../hooks/use-panel-focus.js"; +import { + collectTuiActionRegistrations, + collectTuiPanelRegistrations, + mergeTuiRegistrations, + mergeTuiActionRegistrations, + type TuiActionRegistryEntry, + type TuiRegistryEntry, +} from "./registry.js"; +import type { + TuiActionRegistration, + TuiExtension, + TuiPanelRegistration, +} from "./types.js"; + +function builtInAction(id: string, order: number): TuiActionRegistryEntry { + return { + id, + label: id, + detail: `${id} detail`, + order, + source: "builtin", + builtInAction: id, + }; +} + +function action(id: string, order?: number): TuiActionRegistration { + return { + id, + label: id, + detail: `${id} detail`, + ...(order === undefined ? {} : { order }), + run: () => undefined, + }; +} + +describe("TuiExtension collection", () => { + test("flattens panel and action registrations from extensions", () => { + const panel = plugin("audit-panel", 20); + const refresh = action("audit-refresh", 30); + const extension: TuiExtension = { + id: "audit", + name: "Audit", + version: "1.0.0", + panels: [panel], + actions: [refresh], + }; + + expect(collectTuiPanelRegistrations([extension])).toEqual([panel]); + expect(collectTuiActionRegistrations([extension])).toEqual([refresh]); + }); + + test("returns empty frozen arrays when extensions are absent", () => { + expect(collectTuiPanelRegistrations()).toEqual([]); + expect(collectTuiActionRegistrations()).toEqual([]); + }); +}); + +describe("mergeTuiActionRegistrations", () => { + test("orders built-in and plugin action entries by order then id", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10), builtInAction("set-goal", 30)], + plugins: [action("audit-refresh", 20), action("audit-export", 20)], + }); + + expect(result.diagnostics).toEqual([]); + expect(result.entries.map((entry) => entry.id)).toEqual([ + "register-agent", + "audit-export", + "audit-refresh", + "set-goal", + ]); + }); + + test("skips plugin actions that duplicate built-in action IDs", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10)], + plugins: [action("register-agent", 20)], + }); + + expect(result.entries.map((entry) => entry.id)).toEqual(["register-agent"]); + expect(result.diagnostics).toEqual([ + { + id: "register-agent", + severity: "error", + message: "Duplicate TUI action id: register-agent", + }, + ]); + }); + + test("skips plugin actions with unsafe IDs", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10)], + plugins: [action("bad/id", 20), action("UpperCase", 30)], + }); + + expect(result.entries.map((entry) => entry.id)).toEqual(["register-agent"]); + expect(result.diagnostics).toEqual([ + { + id: "bad/id", + severity: "error", + message: "Invalid TUI action id: bad/id", + }, + { + id: "UpperCase", + severity: "error", + message: "Invalid TUI action id: UpperCase", + }, + ]); + }); + + test("uses default plugin action order 1000 when order is omitted", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10)], + plugins: [action("audit-refresh")], + }); + + expect(result.entries.map((entry) => [entry.id, entry.order])).toEqual([ + ["register-agent", 10], + ["audit-refresh", 1000], + ]); + }); + + test("uses default action order and reports diagnostics for invalid orders", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10)], + plugins: [ + action("nan-action", Number.NaN), + action("infinite-action", Number.POSITIVE_INFINITY), + ], + }); + + expect(result.entries.map((entry) => [entry.id, entry.order])).toEqual([ + ["register-agent", 10], + ["infinite-action", 1000], + ["nan-action", 1000], + ]); + expect(result.diagnostics).toEqual([ + { + id: "nan-action", + severity: "error", + message: "Invalid TUI action order for nan-action; using default order 1000", + }, + { + id: "infinite-action", + severity: "error", + message: "Invalid TUI action order for infinite-action; using default order 1000", + }, + ]); + }); + + test("throws when built-in action IDs are unsafe or duplicated", () => { + expect(() => + mergeTuiActionRegistrations({ + builtIns: [builtInAction("bad/id", 10)], + }), + ).toThrow("Built-in TUI action has invalid id: bad/id"); + + expect(() => + mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10), builtInAction("register-agent", 20)], + }), + ).toThrow("Built-in TUI action id is duplicated: register-agent"); + }); +}); +``` + +- [ ] **Step 2: Run the failing tests** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/plugins/registry.test.ts +rm "$tmp" +``` + +Expected: FAIL with missing exports for `collectTuiActionRegistrations`, `collectTuiPanelRegistrations`, `mergeTuiActionRegistrations`, `TuiActionRegistryEntry`, `TuiActionRegistration`, and `TuiExtension`. + +- [ ] **Step 3: Add the plugin types** + +Modify `src/tui/plugins/types.ts` so the public contracts include action registrations and extension bundles. Keep the existing exports and add these fields/types. + +```ts +export interface TuiPluginContext { + readonly provider: TuiDataProvider; + readonly topology?: AgentTopology | undefined; + readonly selectedSession?: string | undefined; + readonly selectedCid?: string | undefined; + readonly density: "comfortable" | "compact"; + readonly showMessage: (message: string) => void; +} + +export interface TuiPanelRegistration { + readonly id: string; + readonly label: string; + readonly slot: TuiSlot; + readonly defaultVisible?: boolean | undefined; + readonly order?: number | undefined; + readonly component: React.ComponentType; +} + +export interface TuiActionRegistration { + readonly id: string; + readonly label: string; + readonly detail: string; + readonly order?: number | undefined; + readonly enabled?: ((context: TuiPluginContext) => boolean) | undefined; + readonly run: (context: TuiPluginContext) => void | Promise; +} + +export interface TuiExtension { + readonly id: string; + readonly name: string; + readonly version: string; + readonly panels?: readonly TuiPanelRegistration[] | undefined; + readonly actions?: readonly TuiActionRegistration[] | undefined; +} +``` + +- [ ] **Step 4: Add action registry helpers** + +Modify `src/tui/plugins/registry.ts`. Keep `TuiRegistryEntry` for panel compatibility, add action entries and extension collectors, and refactor the order helper to accept a kind label. + +```ts +import type { + TuiActionRegistration, + TuiExtension, + TuiPanelRegistration, + TuiSlot, +} from "./types.js"; + +const DEFAULT_PLUGIN_ORDER = 1000; +const SAFE_TUI_ID = /^[a-z][a-z0-9.-]*$/; + +export interface TuiActionRegistryEntry { + readonly id: string; + readonly label: string; + readonly detail: string; + readonly order: number; + readonly source: "builtin" | "plugin"; + readonly builtInAction?: string | undefined; + readonly registration?: TuiActionRegistration | undefined; +} + +export interface MergeTuiActionRegistrationsInput { + readonly builtIns: readonly TuiActionRegistryEntry[]; + readonly plugins?: readonly TuiActionRegistration[] | undefined; +} + +export interface MergeTuiActionRegistrationsResult { + readonly entries: readonly TuiActionRegistryEntry[]; + readonly diagnostics: readonly TuiRegistryDiagnostic[]; +} + +export function isSafeTuiId(id: string): boolean { + return SAFE_TUI_ID.test(id); +} + +export function isSafeTuiPanelId(id: string): boolean { + return isSafeTuiId(id); +} + +export function collectTuiPanelRegistrations( + extensions?: readonly TuiExtension[] | undefined, +): readonly TuiPanelRegistration[] { + return Object.freeze((extensions ?? []).flatMap((extension) => extension.panels ?? [])); +} + +export function collectTuiActionRegistrations( + extensions?: readonly TuiExtension[] | undefined, +): readonly TuiActionRegistration[] { + return Object.freeze((extensions ?? []).flatMap((extension) => extension.actions ?? [])); +} + +export function mergeTuiActionRegistrations( + input: MergeTuiActionRegistrationsInput, +): MergeTuiActionRegistrationsResult { + const entries: TuiActionRegistryEntry[] = []; + const diagnostics: TuiRegistryDiagnostic[] = []; + const seen = new Set(); + + for (const entry of input.builtIns) { + if (!isSafeTuiId(entry.id)) { + throw new Error(`Built-in TUI action has invalid id: ${entry.id}`); + } + if (seen.has(entry.id)) { + throw new Error(`Built-in TUI action id is duplicated: ${entry.id}`); + } + seen.add(entry.id); + entries.push(entry); + } + + for (const registration of input.plugins ?? []) { + if (!isSafeTuiId(registration.id)) { + diagnostics.push({ + id: registration.id, + severity: "error", + message: `Invalid TUI action id: ${registration.id}`, + }); + continue; + } + if (seen.has(registration.id)) { + diagnostics.push({ + id: registration.id, + severity: "error", + message: `Duplicate TUI action id: ${registration.id}`, + }); + continue; + } + seen.add(registration.id); + const order = resolvePluginOrder(registration.id, registration.order, "action", diagnostics); + entries.push({ + id: registration.id, + label: registration.label, + detail: registration.detail, + order, + source: "plugin", + registration, + }); + } + + entries.sort(compareRegistryEntries); + return { + entries: Object.freeze(entries), + diagnostics: Object.freeze(diagnostics), + }; +} + +function compareRegistryEntries( + a: { readonly id: string; readonly order: number; readonly source: "builtin" | "plugin" }, + b: { readonly id: string; readonly order: number; readonly source: "builtin" | "plugin" }, +): number { + const orderDelta = a.order - b.order; + if (orderDelta !== 0) return orderDelta; + const sourceDelta = sourceRank(a.source) - sourceRank(b.source); + if (sourceDelta !== 0) return sourceDelta; + return a.id.localeCompare(b.id); +} + +function resolvePluginOrder( + id: string, + order: number | undefined, + kind: "panel" | "action", + diagnostics: TuiRegistryDiagnostic[], +): number { + if (order === undefined) return DEFAULT_PLUGIN_ORDER; + if (Number.isFinite(order)) return order; + + diagnostics.push({ + id, + severity: "error", + message: `Invalid TUI ${kind} order for ${id}; using default order ${DEFAULT_PLUGIN_ORDER}`, + }); + return DEFAULT_PLUGIN_ORDER; +} +``` + +In the existing `mergeTuiRegistrations`, replace the old inline sort with `entries.sort(compareRegistryEntries)` and call `resolvePluginOrder(registration.id, registration.order, "panel", diagnostics)`. + +- [ ] **Step 5: Run registry tests green** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/plugins/registry.test.ts +rm "$tmp" +``` + +Expected: PASS for all `src/tui/plugins/registry.test.ts` tests. + +- [ ] **Step 6: Commit registry contracts** + +Run: + +```bash +git add src/tui/plugins/types.ts src/tui/plugins/registry.ts src/tui/plugins/registry.test.ts +git commit -m "feat(tui): add extension registry contracts" +``` + +### Task 2: Project Plugin Actions Into Palette Items + +**Files:** +- Modify: `src/tui/components/command-palette.tsx` +- Test: `src/tui/components/command-palette.test.tsx` + +- [ ] **Step 1: Write failing palette item tests** + +Create `src/tui/components/command-palette.test.tsx`. + +```tsx +import { describe, expect, mock, test } from "bun:test"; +import type { TuiDataProvider } from "../provider.js"; +import type { TuiActionRegistration, TuiPluginContext } from "../plugins/types.js"; +import { mergeTuiActionRegistrations } from "../plugins/registry.js"; +import { + buildPluginPaletteItems, + getBuiltInPaletteActionRegistryEntries, +} from "./command-palette.js"; + +function providerStub(): TuiDataProvider { + return { + capabilities: { + outcomes: false, + artifacts: false, + vfs: false, + messaging: false, + costTracking: false, + askUser: false, + github: false, + bounties: false, + gossip: false, + goals: false, + sessions: false, + handoffs: false, + }, + getDashboard: async () => { + throw new Error("getDashboard not used"); + }, + getContributions: async () => [], + getContribution: async () => undefined, + getClaims: async () => [], + getFrontier: async () => ({ + byMetric: {}, + byAdoption: [], + byRecency: [], + byReviewScore: [], + byReproduction: [], + }), + getActivity: async () => [], + getDag: async () => ({ contributions: [] }), + getHotThreads: async () => [], + close: () => undefined, + }; +} + +function context(): TuiPluginContext { + return { + provider: providerStub(), + density: "compact", + showMessage: () => undefined, + }; +} + +function action(overrides: Partial = {}): TuiActionRegistration { + return { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: () => undefined, + ...overrides, + }; +} + +describe("plugin palette items", () => { + test("includes fixed built-in action IDs for duplicate protection", () => { + expect(getBuiltInPaletteActionRegistryEntries().map((entry) => entry.id)).toEqual([ + "set-goal", + "register-agent", + ]); + }); + + test("projects enabled plugin actions into palette items", () => { + const refresh = action(); + const merged = mergeTuiActionRegistrations({ + builtIns: getBuiltInPaletteActionRegistryEntries(), + plugins: [refresh], + }); + + const items = buildPluginPaletteItems(merged.entries, context()); + + expect(items.map((item) => [item.kind, item.id, item.label, item.detail, item.enabled])).toEqual([ + ["plugin-action", "audit-refresh", "Refresh audit panel", "audit", true], + ]); + expect(items[0]?.pluginAction).toBe(refresh); + }); + + test("projects disabled plugin actions as non-executable palette items", () => { + const refresh = action({ enabled: () => false }); + const merged = mergeTuiActionRegistrations({ + builtIns: getBuiltInPaletteActionRegistryEntries(), + plugins: [refresh], + }); + + const items = buildPluginPaletteItems(merged.entries, context()); + + expect(items[0]?.enabled).toBe(false); + }); + + test("evaluates enabled predicate with the plugin context", () => { + const enabled = mock((ctx: TuiPluginContext) => ctx.density === "compact"); + const refresh = action({ enabled }); + const merged = mergeTuiActionRegistrations({ + builtIns: getBuiltInPaletteActionRegistryEntries(), + plugins: [refresh], + }); + + const items = buildPluginPaletteItems(merged.entries, context()); + + expect(items[0]?.enabled).toBe(true); + expect(enabled).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run the failing palette tests** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/components/command-palette.test.tsx +rm "$tmp" +``` + +Expected: FAIL because `buildPluginPaletteItems` and `getBuiltInPaletteActionRegistryEntries` are not exported, and `PaletteItem` has no `plugin-action` kind. + +- [ ] **Step 3: Add plugin palette item support** + +Modify `src/tui/components/command-palette.tsx`. + +Add imports: + +```ts +import type { TuiActionRegistryEntry } from "../plugins/registry.js"; +import type { TuiActionRegistration, TuiPluginContext } from "../plugins/types.js"; +``` + +Change `PaletteItem`: + +```ts +export interface PaletteItem { + readonly kind: "spawn" | "kill" | "register" | "delegate" | "goal" | "plugin-action"; + /** For spawn: role name. For kill: session name. For delegate: peer address. For plugin-action: action id. */ + readonly id: string; + readonly label: string; + readonly enabled: boolean; + readonly detail: string; + readonly pluginAction?: TuiActionRegistration | undefined; +} +``` + +Add fixed built-in action entries and plugin projection helpers below `LoadedProfile`: + +```ts +const BUILT_IN_PALETTE_ACTIONS: readonly TuiActionRegistryEntry[] = Object.freeze([ + Object.freeze({ + id: "set-goal", + label: "Set goal", + detail: "Set or update the session goal for all agents", + order: 0, + source: "builtin" as const, + builtInAction: "goal", + }), + Object.freeze({ + id: "register-agent", + label: "[r] Register new agent profile", + detail: "agents.json", + order: 10, + source: "builtin" as const, + builtInAction: "register", + }), +]); + +export function getBuiltInPaletteActionRegistryEntries(): readonly TuiActionRegistryEntry[] { + return BUILT_IN_PALETTE_ACTIONS; +} + +export function buildPluginPaletteItems( + entries: readonly TuiActionRegistryEntry[], + context: TuiPluginContext, +): readonly PaletteItem[] { + const items: PaletteItem[] = []; + for (const entry of entries) { + if (entry.source !== "plugin" || entry.registration === undefined) continue; + const enabled = entry.registration.enabled?.(context) ?? true; + items.push({ + kind: "plugin-action", + id: entry.id, + label: entry.label, + detail: entry.detail, + enabled, + pluginAction: entry.registration, + }); + } + return Object.freeze(items); +} +``` + +- [ ] **Step 4: Run palette tests green** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/components/command-palette.test.tsx +rm "$tmp" +``` + +Expected: PASS for all command palette plugin item tests. + +- [ ] **Step 5: Commit palette projection** + +Run: + +```bash +git add src/tui/components/command-palette.tsx src/tui/components/command-palette.test.tsx +git commit -m "feat(tui): project plugin actions into palette" +``` + +### Task 3: Execute Plugin Actions From App + +**Files:** +- Create: `src/tui/plugins/actions.ts` +- Create: `src/tui/plugins/actions.test.ts` +- Modify: `src/tui/app.tsx` + +- [ ] **Step 1: Write failing action execution tests** + +Create `src/tui/plugins/actions.test.ts`. + +```ts +import { describe, expect, test } from "bun:test"; +import type { TuiDataProvider } from "../provider.js"; +import { runTuiActionRegistration } from "./actions.js"; +import type { TuiActionRegistration, TuiPluginContext } from "./types.js"; + +function context(): TuiPluginContext { + return { + provider: {} as TuiDataProvider, + density: "comfortable", + showMessage: () => undefined, + }; +} + +describe("runTuiActionRegistration", () => { + test("runs synchronous action callbacks with plugin context", async () => { + let receivedDensity = ""; + const action: TuiActionRegistration = { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: (ctx) => { + receivedDensity = ctx.density; + }, + }; + + await runTuiActionRegistration(action, context()); + + expect(receivedDensity).toBe("comfortable"); + }); + + test("runs asynchronous action callbacks", async () => { + let completed = false; + const action: TuiActionRegistration = { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: async () => { + await Promise.resolve(); + completed = true; + }, + }; + + await runTuiActionRegistration(action, context()); + + expect(completed).toBe(true); + }); + + test("propagates action failures to the caller", async () => { + const action: TuiActionRegistration = { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: () => { + throw new Error("audit failed"); + }, + }; + + await expect(runTuiActionRegistration(action, context())).rejects.toThrow("audit failed"); + }); +}); +``` + +- [ ] **Step 2: Run the failing action tests** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/plugins/actions.test.ts +rm "$tmp" +``` + +Expected: FAIL because `src/tui/plugins/actions.ts` does not exist. + +- [ ] **Step 3: Add the action execution helper** + +Create `src/tui/plugins/actions.ts`. + +```ts +import type { TuiActionRegistration, TuiPluginContext } from "./types.js"; + +export async function runTuiActionRegistration( + action: TuiActionRegistration, + context: TuiPluginContext, +): Promise { + await action.run(context); +} +``` + +- [ ] **Step 4: Run action tests green** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/plugins/actions.test.ts +rm "$tmp" +``` + +Expected: PASS for all action execution tests. + +- [ ] **Step 5: Wire extensions and plugin action execution into `App`** + +Modify `src/tui/app.tsx`. + +Add imports: + +```ts +import { + buildPaletteItems, + buildPluginPaletteItems, + CommandPalette, + fuzzyMatch, + getBuiltInPaletteActionRegistryEntries, +} from "./components/command-palette.js"; +import { + collectTuiActionRegistrations, + mergeTuiActionRegistrations, +} from "./plugins/registry.js"; +import { runTuiActionRegistration } from "./plugins/actions.js"; +import type { TuiExtension, TuiPluginContext } from "./plugins/types.js"; +``` + +Add `extensions` to `AppProps`: + +```ts + /** Trusted local-code TUI extensions. Grove does not dynamically load these. */ + readonly extensions?: readonly TuiExtension[] | undefined; +``` + +Destructure it in `App`: + +```ts +export function App({ + provider, + intervalMs, + tmux, + topology, + presetName, + groveDir, + userConfig, + eventBus, + screenContext, + extensions, +}: AppProps): React.ReactNode { +``` + +After `showError`, build plugin action registrations and context: + +```ts + const pluginActionRegistrations = useMemo( + () => collectTuiActionRegistrations(extensions), + [extensions], + ); + const mergedActionRegistry = useMemo( + () => + mergeTuiActionRegistrations({ + builtIns: getBuiltInPaletteActionRegistryEntries(), + plugins: pluginActionRegistrations, + }), + [pluginActionRegistrations], + ); + const pluginContext = useMemo( + () => ({ + provider, + topology, + selectedSession, + selectedCid: nav.detailCid, + density: ks.layoutMode === "tab" ? "compact" : "comfortable", + showMessage: showError, + }), + [provider, topology, selectedSession, nav.detailCid, ks.layoutMode, showError], + ); +``` + +Add a diagnostic reporting effect: + +```ts + useEffect(() => { + for (const diagnostic of mergedActionRegistry.diagnostics) { + showError(diagnostic.message); + } + }, [mergedActionRegistry.diagnostics, showError]); +``` + +Split core and plugin palette items: + +```ts + const corePaletteItems = useMemo( + () => + buildPaletteItems( + topology, + activeClaims ?? [], + paletteSessions ?? [], + tmux !== undefined, + canSpawn, + true, + paletteParentId, + canDelegate ? (gossipPeers ?? undefined) : undefined, + agentProfiles ?? undefined, + hasGoals, + ), + [ + topology, + activeClaims, + paletteSessions, + tmux, + canSpawn, + canDelegate, + paletteParentId, + gossipPeers, + agentProfiles, + hasGoals, + ], + ); + const pluginPaletteItems = useMemo( + () => buildPluginPaletteItems(mergedActionRegistry.entries, pluginContext), + [mergedActionRegistry.entries, pluginContext], + ); + const paletteItems = useMemo( + () => [...corePaletteItems, ...pluginPaletteItems], + [corePaletteItems, pluginPaletteItems], + ); +``` + +In `onPaletteSelect`, add a plugin-action branch before resetting the palette: + +```ts + } else if (item.kind === "plugin-action" && item.pluginAction !== undefined) { + void runTuiActionRegistration(item.pluginAction, pluginContext).catch((err: unknown) => { + showError(err instanceof Error ? err.message : "Plugin action failed"); + }); +``` + +Add `pluginContext` and `mergedActionRegistry.entries` to memo dependencies where TypeScript requires them. + +- [ ] **Step 6: Run focused action and type tests** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/plugins/actions.test.ts src/tui/components/command-palette.test.tsx src/tui/plugins/registry.test.ts +rm "$tmp" +bun run typecheck +``` + +Expected: focused tests PASS; `bun run typecheck` PASS. + +- [ ] **Step 7: Commit App action wiring** + +Run: + +```bash +git add src/tui/app.tsx src/tui/plugins/actions.ts src/tui/plugins/actions.test.ts +git commit -m "feat(tui): execute plugin palette actions" +``` + +### Task 4: Render Default-Visible Plugin Panels + +**Files:** +- Create: `src/tui/panels/plugin-panels.ts` +- Create: `src/tui/panels/plugin-panels.test.ts` +- Modify: `src/tui/panels/panel-manager.tsx` + +- [ ] **Step 1: Write failing plugin panel selection tests** + +Create `src/tui/panels/plugin-panels.test.ts`. + +```ts +import { describe, expect, test } from "bun:test"; +import type React from "react"; +import { Panel } from "../hooks/use-panel-focus.js"; +import type { TuiRegistryEntry } from "../plugins/registry.js"; +import type { TuiPluginContext } from "../plugins/types.js"; +import { getDefaultVisiblePluginPanelEntries } from "./plugin-panels.js"; + +const NullPanel: React.ComponentType = () => null; + +function builtInEntry(id: string, order: number, panel: Panel): TuiRegistryEntry { + return { + id, + label: id, + slot: "operator-panel", + order, + source: "builtin", + builtInPanel: panel, + }; +} + +function pluginEntry(id: string, defaultVisible?: boolean): TuiRegistryEntry { + return { + id, + label: id, + slot: "operator-panel", + order: 1000, + source: "plugin", + registration: { + id, + label: id, + slot: "operator-panel", + ...(defaultVisible === undefined ? {} : { defaultVisible }), + component: NullPanel, + }, + }; +} + +describe("getDefaultVisiblePluginPanelEntries", () => { + test("returns only plugin operator panels with defaultVisible true", () => { + const entries: readonly TuiRegistryEntry[] = [ + builtInEntry("dag", 0, Panel.Dag), + pluginEntry("audit-panel", true), + pluginEntry("hidden-panel", false), + pluginEntry("implicit-hidden"), + ]; + + expect(getDefaultVisiblePluginPanelEntries(entries).map((entry) => entry.id)).toEqual([ + "audit-panel", + ]); + }); + + test("skips plugin entries without registrations", () => { + const entries: readonly TuiRegistryEntry[] = [ + { + id: "broken-panel", + label: "Broken", + slot: "operator-panel", + order: 1000, + source: "plugin", + }, + ]; + + expect(getDefaultVisiblePluginPanelEntries(entries)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the failing plugin panel tests** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/panels/plugin-panels.test.ts +rm "$tmp" +``` + +Expected: FAIL because `src/tui/panels/plugin-panels.ts` does not exist. + +- [ ] **Step 3: Add the plugin panel selection helper** + +Create `src/tui/panels/plugin-panels.ts`. + +```ts +import type { TuiRegistryEntry } from "../plugins/registry.js"; + +export function getDefaultVisiblePluginPanelEntries( + entries: readonly TuiRegistryEntry[], +): readonly TuiRegistryEntry[] { + return Object.freeze( + entries.filter( + (entry) => + entry.source === "plugin" && + entry.slot === "operator-panel" && + entry.registration !== undefined && + entry.registration.defaultVisible === true, + ), + ); +} +``` + +- [ ] **Step 4: Run plugin panel helper tests green** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/panels/plugin-panels.test.ts +rm "$tmp" +``` + +Expected: PASS for all plugin panel selection tests. + +- [ ] **Step 5: Render plugin panels in `PanelManager`** + +Modify `src/tui/panels/panel-manager.tsx`. + +Add imports: + +```ts +import type { TuiRegistryEntry } from "../plugins/registry.js"; +import type { TuiPluginContext } from "../plugins/types.js"; +import { getDefaultVisiblePluginPanelEntries } from "./plugin-panels.js"; +``` + +Add props to `PanelManagerProps`: + +```ts + /** Merged built-in and plugin panel entries. Defaults to built-ins only. */ + readonly registryEntries?: readonly TuiRegistryEntry[] | undefined; + /** Context passed to trusted plugin panel components. */ + readonly pluginContext?: TuiPluginContext | undefined; +``` + +Change `PanelChrome` to accept a title string: + +```tsx +function PanelChrome({ + title, + focused, + children, +}: { + readonly title: string; + readonly focused: boolean; + readonly children: React.ReactNode; +}): React.ReactNode { + const borderColor = focused ? theme.focus : theme.inactive; + const titleColor = focused ? theme.focus : theme.secondary; + return ( + + + + {` ${title} `} + + + {children} + + ); +} +``` + +When rendering built-ins, pass `title={PANEL_LABELS[def.panel]}`. In tab mode: + +```tsx + + {renderPanel(panelState.focused)} + +``` + +Inside `PanelManager`, destructure `registryEntries` and `pluginContext`, then derive plugin panels: + +```ts + const pluginPanelEntries = getDefaultVisiblePluginPanelEntries(registryEntries ?? []); +``` + +After the existing built-in grid row mapping, render plugin rows: + +```tsx + {pluginContext !== undefined + ? pluginPanelEntries.map((entry) => { + const Component = entry.registration?.component; + if (Component === undefined) return null; + return ( + + + + + + ); + }) + : null} +``` + +The built-in row grouping still calls `getRowGroups()` with the built-in registry. This preserves current built-in layout and appends default-visible plugin panels after built-ins. + +- [ ] **Step 6: Wire merged panel entries through `App`** + +Modify `src/tui/app.tsx`. + +Add imports: + +```ts +import { + collectTuiPanelRegistrations, + mergeTuiRegistrations, +} from "./plugins/registry.js"; +import { getBuiltInTuiRegistryEntries } from "./panels/panel-registry.js"; +``` + +After the action registry memo from Task 3, build the merged panel registry: + +```ts + const pluginPanelRegistrations = useMemo( + () => collectTuiPanelRegistrations(extensions), + [extensions], + ); + const mergedPanelRegistry = useMemo( + () => + mergeTuiRegistrations({ + builtIns: getBuiltInTuiRegistryEntries(), + plugins: pluginPanelRegistrations, + }), + [pluginPanelRegistrations], + ); +``` + +Add a panel diagnostic reporting effect: + +```ts + useEffect(() => { + for (const diagnostic of mergedPanelRegistry.diagnostics) { + showError(diagnostic.message); + } + }, [mergedPanelRegistry.diagnostics, showError]); +``` + +Pass panel registry and context to `PanelManager`: + +```tsx + registryEntries={mergedPanelRegistry.entries} + pluginContext={pluginContext} +``` + +- [ ] **Step 7: Run focused panel tests and typecheck** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test src/tui/panels/plugin-panels.test.ts src/tui/panels/panel-registry.test.ts +rm "$tmp" +bun run typecheck +``` + +Expected: focused tests PASS; `bun run typecheck` PASS. + +- [ ] **Step 8: Commit plugin panel rendering** + +Run: + +```bash +git add src/tui/app.tsx src/tui/panels/panel-manager.tsx src/tui/panels/plugin-panels.ts src/tui/panels/plugin-panels.test.ts +git commit -m "feat(tui): render plugin operator panels" +``` + +### Task 5: Document the Trusted Extension Surface + +**Files:** +- Create: `docs/tui/tui-extensions.md` + +- [ ] **Step 1: Create the TUI extension guide** + +Create `docs/tui/tui-extensions.md`. + +````markdown +# TUI Extensions + +Grove's first TUI extension surface is trusted local code. The TUI does not +load arbitrary module paths, remote plugins, or package manifests. Application +code passes typed extension objects into the TUI. + +## Extension Shape + +```ts +import type { TuiExtension } from "../src/tui/plugins/types.js"; + +export const auditExtension: TuiExtension = { + id: "audit", + name: "Audit tools", + version: "1.0.0", + panels: [ + { + id: "audit-panel", + label: "Audit", + slot: "operator-panel", + defaultVisible: true, + component: AuditPanel, + }, + ], + actions: [ + { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: (context) => { + context.showMessage("Audit refresh requested"); + }, + }, + ], +}; +``` + +## Panel Registrations + +Panel IDs must be lowercase and may contain lowercase letters, numbers, dots, +and hyphens. Plugin panels use the `operator-panel` slot. The first +implementation renders plugin panels when `defaultVisible` is `true`. + +Panel components receive `TuiPluginContext`: + +```ts +interface TuiPluginContext { + readonly provider: TuiDataProvider; + readonly topology?: AgentTopology | undefined; + readonly selectedSession?: string | undefined; + readonly selectedCid?: string | undefined; + readonly density: "comfortable" | "compact"; + readonly showMessage: (message: string) => void; +} +``` + +## Command Palette Actions + +Actions appear in the command palette alongside built-in actions. Disabled +actions stay visible but cannot execute. + +```ts +{ + id: "audit-export", + label: "Export audit summary", + detail: "audit", + enabled: (context) => context.selectedCid !== undefined, + run: async (context) => { + context.showMessage(`Exporting ${context.selectedCid}`); + }, +} +``` + +## Safety Model + +Extensions are trusted local code running in the TUI process. Grove validates +registration IDs, rejects duplicates, and limits the context object, but it does +not sandbox extension JavaScript. Do not load untrusted code as a TUI extension. + +## Compatibility + +Built-in IDs are reserved. A plugin entry that duplicates a built-in ID is +skipped and reported as a diagnostic. Optional context fields may be added in +future versions; existing context fields should keep their meaning. +```` + +- [ ] **Step 2: Check docs formatting** + +Run: + +```bash +bunx biome check docs/tui/tui-extensions.md +``` + +Expected: PASS for the new guide. + +- [ ] **Step 3: Commit documentation** + +Run: + +```bash +git add docs/tui/tui-extensions.md +git commit -m "docs(tui): document local extension surface" +``` + +### Task 6: Final Verification + +**Files:** +- Verify all changed files. + +- [ ] **Step 1: Run focused tests without coverage thresholds** + +Run: + +```bash +tmp=$(mktemp) +printf '[test]\ncoverage = false\n' > "$tmp" +bun --config "$tmp" test \ + src/tui/plugins/registry.test.ts \ + src/tui/plugins/actions.test.ts \ + src/tui/components/command-palette.test.tsx \ + src/tui/panels/plugin-panels.test.ts \ + src/tui/panels/panel-registry.test.ts +rm "$tmp" +``` + +Expected: PASS for every focused TUI extension test. + +- [ ] **Step 2: Run typecheck** + +Run: + +```bash +bun run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Run full tests** + +Run: + +```bash +bun test --timeout 60000 +bun test --cwd packages/ask-user --timeout 60000 +``` + +Expected: PASS. If the root test command fails only because the existing global coverage threshold is evaluated against a partial run, rerun the exact focused command from Step 1 and report the coverage-threshold behavior in the final summary. + +- [ ] **Step 4: Run Biome check** + +Run: + +```bash +bun run check +``` + +Expected: PASS or known pre-existing warnings only. If pre-existing warnings remain, record their file paths and confirm no new diagnostics point at files changed for this issue. + +- [ ] **Step 5: Run whitespace check** + +Run: + +```bash +git diff --check +``` + +Expected: PASS with no output. + +- [ ] **Step 6: Review changed files** + +Run: + +```bash +git status --short +git diff --stat origin/main...HEAD +git diff -- src/tui/plugins src/tui/components/command-palette.tsx src/tui/panels src/tui/app.tsx docs/tui/tui-extensions.md +``` + +Expected: only issue #189 extension-surface files are changed beyond the committed spec and plan docs. + +## Self-Review Checklist + +- Spec coverage: Tasks 1-4 cover extension contracts, registry validation, plugin action palette projection/execution, and default-visible plugin panel rendering. Task 5 covers documentation. Task 6 covers verification. +- Type consistency: `TuiExtension`, `TuiPluginContext`, `TuiActionRegistration`, `TuiActionRegistryEntry`, `buildPluginPaletteItems`, and `runTuiActionRegistration` use the same names across tasks. +- Scope control: The plan does not add dynamic plugin loading, remote plugins, sandboxing, marketplace packaging, or plugin keyboard toggles. diff --git a/docs/superpowers/specs/2026-05-26-tui-plugin-extensibility-design.md b/docs/superpowers/specs/2026-05-26-tui-plugin-extensibility-design.md new file mode 100644 index 000000000..e8c4c1d06 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-tui-plugin-extensibility-design.md @@ -0,0 +1,231 @@ +# TUI Plugin Extensibility Design + +## Issue + +GitHub issue: https://github.com/windoliver/grove/issues/189 + +Grove's TUI should let teams add workflow-specific operator panels and command +palette actions without editing the core TUI for each workflow. The first +plugin surface is trusted local code. It does not load arbitrary remote modules, +does not read plugin paths from config, and does not grant plugins special +filesystem or process authority beyond what their own JavaScript code already +has in the local process. + +## Goals + +- Let local or third-party code register at least one custom panel. +- Let local or third-party code register at least one custom command palette + action. +- Keep built-in panel and palette behavior unchanged when no extensions are + provided. +- Make registrations data-driven, deterministic, and testable. +- Document the safety and compatibility contract for extension authors. + +## Non-Goals + +- Runtime plugin discovery from config or package manifests. +- Remote plugin loading. +- Sandbox enforcement inside the TUI process. +- A marketplace or plugin packaging format. +- Replacing provider capability checks with plugin-specific provider types. + +## Architecture + +The TUI receives an optional list of trusted `TuiExtension` objects from its +caller. Each extension can contribute panel registrations and command action +registrations. Grove merges these registrations with built-in entries through +pure registry helpers that validate IDs, reject duplicates, apply stable +ordering, and return diagnostics for invalid plugin entries. + +The rendering and palette layers consume the merged registries. Built-in entries +continue to route to existing core components and handlers. Plugin panel entries +render their registered React component with a constrained `TuiPluginContext`. +Plugin action entries render as command palette items and execute their +registered callback with the same constrained context. + +## Public Contracts + +### TuiExtension + +`TuiExtension` is the bundle a trusted local plugin provides: + +- `id`: stable lowercase extension ID. +- `name`: display name for diagnostics and documentation. +- `version`: semantic version string supplied by the extension. +- `panels`: optional `readonly TuiPanelRegistration[]`. +- `actions`: optional `readonly TuiActionRegistration[]`. + +The extension object is plain data plus local callback/component references. It +is passed directly to the TUI by application code or tests. + +### TuiPanelRegistration + +Existing panel registration remains the panel contract: + +- `id`: stable lowercase panel ID matching Grove's safe ID pattern. +- `label`: display label. +- `slot`: initially `operator-panel` for rendered panels. +- `defaultVisible`: whether the panel should be visible on first render. +- `order`: optional sort key, defaulting after built-ins. +- `component`: React component receiving `TuiPluginContext`. + +Plugin panels are operator panels. They are not added to the numeric `Panel` +enum. This avoids expanding keyboard focus state for every local extension and +keeps built-in keyboard shortcuts stable. Plugin panels are rendered after +built-ins in registry order and participate in row grouping through plugin +registry metadata, not through the built-in enum. + +### TuiActionRegistration + +Command palette action registrations use a separate action contract: + +- `id`: stable lowercase action ID. +- `label`: command palette label. +- `detail`: short detail text for the palette's right-hand metadata. +- `order`: optional sort key, defaulting after built-ins. +- `enabled`: optional predicate receiving `TuiPluginContext`. +- `run`: callback receiving `TuiPluginContext` and returning `void` or + `Promise`. + +The palette treats disabled plugin actions like disabled built-in entries: +visible, dimmed, and not executable from Enter. + +### TuiPluginContext + +Plugins receive a narrow read/action context: + +- `provider` +- `topology` +- `selectedSession` +- `selectedCid` +- `density` +- `showMessage(message: string)` + +The context intentionally excludes raw reducer dispatch, panel state mutation, +tmux manager internals, renderer lifecycle, and direct access to core palette +handlers. Future fields can be added in a backward-compatible way. + +## Registry Behavior + +The existing `src/tui/plugins/registry.ts` becomes the shared home for panel and +action merge helpers. + +Panel behavior: + +- Built-in panel entries are validated first. +- Plugin panel entries with invalid IDs are skipped with diagnostics. +- Plugin panel entries that duplicate built-ins or earlier plugin entries are + skipped with diagnostics. +- Entries sort by `order`, then source rank, then ID. + +Action behavior: + +- Built-in action entries are validated first. +- Plugin action entries use the same ID validation and duplicate handling. +- Built-in actions keep their current ordering. +- Plugin actions default to order `1000`. + +Diagnostics are returned to the caller instead of thrown for plugin mistakes. +Invalid built-in entries still throw, because those are programmer errors in +core code. + +## Panel Rendering Flow + +`PanelManager` receives optional merged panel registry entries. When no entries +are provided, it uses the built-in registry and renders the exact current TUI. + +For built-in entries, `PanelManager` renders the existing switch-based panel +views. For plugin entries, it renders the registered component inside standard +panel chrome: + +1. Build the current `TuiPluginContext`. +2. Select visible entries from merged registry entries. +3. Keep built-in visibility driven by existing `PanelFocusState`. +4. Render plugin panels whose registration has `defaultVisible: true` or whose + visibility is enabled by future extension state. +5. Wrap plugin panel content in the same title/focus chrome used by built-ins. + +The first implementation only guarantees default-visible plugin panels. Toggling +arbitrary plugin panels from keyboard shortcuts is deferred until the built-in +focus state is generalized from numeric enum values to stable string IDs. + +## Command Palette Flow + +Built-in palette items keep their current spawn, kill, register, delegate, and +goal behavior. A new action registry layer converts plugin actions into palette +items with kind `plugin-action`. + +Execution flow: + +1. `App` builds the current `TuiPluginContext`. +2. `buildPaletteItems` includes built-in items and plugin action items. +3. Fuzzy filtering and selection continue to operate on one item list. +4. Enter on a built-in item follows the existing handler. +5. Enter on a plugin action checks `enabled`, calls `run(context)`, catches + errors, and reports failures through `showMessage`. +6. The palette closes and resets after a successful or failed plugin action, + matching built-in action behavior. + +## Error Handling + +- Invalid plugin IDs are skipped with diagnostics. +- Duplicate plugin IDs are skipped with diagnostics. +- Plugin panel render failures are contained by React's existing render path as + much as the current TUI supports; the plugin contract documents that plugin + code is trusted and can still crash the local process. +- Plugin action failures are caught and shown through `showMessage`. +- A bad extension cannot remove or replace built-in panels/actions by using the + same ID. + +## Safety and Compatibility + +This is a trusted local-code extension model. Grove does not claim to sandbox +plugin code. Safety comes from the limited TUI contract, deterministic registry +validation, and preserving built-in behavior when plugins are invalid or absent. + +Compatibility expectations: + +- Built-in IDs are stable. +- Plugin IDs must be stable and lowercase. +- Optional fields can be added to `TuiPluginContext`. +- Existing context fields will not be repurposed incompatibly without a major + compatibility note. +- Plugin registration diagnostics are part of the testable contract. + +## Testing Plan + +- Extend registry tests for action registration ordering, duplicate rejection, + unsafe IDs, and invalid order diagnostics. +- Add panel registry or panel manager tests proving plugin panel entries can be + merged and rendered without modifying built-in panel definitions. +- Add command palette tests proving plugin action items appear, filter through + existing fuzzy search, respect disabled state, and execute through the plugin + callback. +- Add app-level focused tests where practical for palette selection of a plugin + action. +- Run `bun run typecheck`. +- Run focused TUI tests with coverage disabled where config coverage thresholds + make partial test runs exit nonzero despite passing test cases. +- Run `bun run check` before final delivery. + +## Documentation Plan + +Add a TUI extensions guide under `docs/tui/` that shows: + +- How to define a trusted local extension. +- How to register a default-visible custom panel. +- How to register a custom command palette action. +- What the plugin context contains. +- What Grove does not sandbox. +- Compatibility expectations for IDs, ordering, and future context fields. + +## Acceptance Mapping + +- Third-party or local extensions can register at least one custom panel and one + custom action through `TuiExtension`. +- Core TUI source does not need workflow-specific edits after the registration + surface is wired. +- Extension boundaries are documented in `docs/tui/`. +- Registry and palette behavior are covered by focused tests. +- Baseline stability is preserved by duplicate rejection, invalid ID diagnostics, + and built-in-first merge behavior. diff --git a/docs/tui/tui-extensions.md b/docs/tui/tui-extensions.md new file mode 100644 index 000000000..bf1084159 --- /dev/null +++ b/docs/tui/tui-extensions.md @@ -0,0 +1,87 @@ +# TUI Extensions + +Grove's first TUI extension surface is trusted local code. The TUI does not +load arbitrary module paths, remote plugins, or package manifests. Application +code passes typed extension objects into the TUI. + +## Extension Shape + +```ts +import type { TuiExtension } from "../../src/tui/plugins/types.js"; + +export const auditExtension: TuiExtension = { + id: "audit", + name: "Audit tools", + version: "1.0.0", + panels: [ + { + id: "audit-panel", + label: "Audit", + slot: "operator-panel", + defaultVisible: true, + component: AuditPanel, + }, + ], + actions: [ + { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: (context) => { + context.showMessage("Audit refresh requested"); + }, + }, + ], +}; +``` + +## Panel Registrations + +Panel IDs must start with a lowercase letter. After that, they may contain +lowercase letters, numbers, dots, and hyphens. Plugin panels use the +`operator-panel` slot. The first implementation renders default-visible plugin +panels only in unsuppressed grid layout. They do not render in tab layout, full +zoom, or medium and small responsive layouts, and they are not +keyboard-toggleable or focusable. + +Panel components receive `TuiPluginContext`: + +```ts +interface TuiPluginContext { + readonly provider: TuiDataProvider; + readonly topology?: AgentTopology | undefined; + readonly selectedSession?: string | undefined; + readonly selectedCid?: string | undefined; + readonly density: "comfortable" | "compact"; + readonly showMessage: (message: string) => void; +} +``` + +## Command Palette Actions + +Actions appear in the command palette alongside built-ins. Disabled actions stay +visible but cannot execute. + +```ts +{ + id: "audit-export", + label: "Export audit summary", + detail: "audit", + enabled: (context) => context.selectedCid !== undefined, + run: async (context) => { + context.showMessage(`Exporting ${context.selectedCid}`); + }, +} +``` + +## Safety Model + +Extensions run in the TUI process as trusted local code. Grove validates IDs, +rejects duplicates, and limits the context object, but does not sandbox +extension JavaScript. Do not load untrusted code as a TUI extension. + +## Compatibility + +Built-in IDs are reserved. Duplicate plugin entries are skipped and reported as +diagnostics. Optional context fields may be added in future versions; existing +context fields should keep their meaning. diff --git a/src/tui/app.tsx b/src/tui/app.tsx index ab797848d..5d5760317 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -18,7 +18,13 @@ import { safeCleanup } from "../shared/safe-cleanup.js"; import { checkSpawn, checkSpawnDepth } from "./agents/spawn-validator.js"; import { agentIdFromSession } from "./agents/tmux-manager.js"; import { INITIAL_KEYBOARD_STATE, tuiReducer } from "./app-reducer.js"; -import { buildPaletteItems, CommandPalette, fuzzyMatch } from "./components/command-palette.js"; +import { + buildPaletteItems, + buildPluginPaletteItems, + CommandPalette, + fuzzyMatch, + getBuiltInPaletteActionRegistryEntries, +} from "./components/command-palette.js"; import { HelpOverlay } from "./components/help-overlay.js"; import { InputBar } from "./components/input-bar.js"; import { type ScreenContext, StatusBar } from "./components/status-bar.js"; @@ -47,6 +53,15 @@ import { useTuiStatePersistence } from "./hooks/use-session-persistence.js"; import { resolveKeymapWithOverrides } from "./keymap/keymap.js"; import type { ZoomLevel } from "./panels/panel-manager.js"; import { PanelManager } from "./panels/panel-manager.js"; +import { getBuiltInTuiRegistryEntries } from "./panels/panel-registry.js"; +import { runTuiActionRegistration } from "./plugins/actions.js"; +import { + collectTuiActionRegistrations, + collectTuiPanelRegistrations, + mergeTuiActionRegistrations, + mergeTuiRegistrations, +} from "./plugins/registry.js"; +import type { TuiExtension, TuiPluginContext } from "./plugins/types.js"; import { type DashboardData, type GitHubPRSummary, @@ -85,6 +100,8 @@ export interface AppProps { readonly newSessionPreset?: string | undefined; /** Pre-fetched dashboard data — populates the first render before polling hooks fire. */ readonly initialDashboard?: import("./provider.js").DashboardData | undefined; + /** Trusted local-code TUI extensions. Grove does not dynamically load these. */ + readonly extensions?: readonly TuiExtension[] | undefined; /** * Which top-level screen is active. Drives the StatusBar `[INSPECT]` chip * so users always know whether the inspect overlay is on screen (#191). @@ -116,6 +133,7 @@ export function App({ userConfig, eventBus, screenContext, + extensions, }: AppProps): React.ReactNode { const renderer = useRenderer(); const nav = useNavigation(); @@ -224,6 +242,54 @@ export function App({ errorTimerRef.current = setTimeout(() => setLastError(undefined), 5_000); }, []); + const pluginActionRegistrations = useMemo( + () => collectTuiActionRegistrations(extensions), + [extensions], + ); + const mergedActionRegistry = useMemo( + () => + mergeTuiActionRegistrations({ + builtIns: getBuiltInPaletteActionRegistryEntries(), + plugins: pluginActionRegistrations, + }), + [pluginActionRegistrations], + ); + const pluginPanelRegistrations = useMemo( + () => collectTuiPanelRegistrations(extensions), + [extensions], + ); + const mergedPanelRegistry = useMemo( + () => + mergeTuiRegistrations({ + builtIns: getBuiltInTuiRegistryEntries(), + plugins: pluginPanelRegistrations, + }), + [pluginPanelRegistrations], + ); + const pluginContext = useMemo( + () => ({ + provider, + topology, + selectedSession, + selectedCid: nav.detailCid, + density: ks.layoutMode === "tab" ? "compact" : "comfortable", + showMessage: showError, + }), + [provider, topology, selectedSession, nav.detailCid, ks.layoutMode, showError], + ); + + useEffect(() => { + for (const diagnostic of mergedActionRegistry.diagnostics) { + showError(diagnostic.message); + } + }, [mergedActionRegistry.diagnostics, showError]); + + useEffect(() => { + for (const diagnostic of mergedPanelRegistry.diagnostics) { + showError(diagnostic.message); + } + }, [mergedPanelRegistry.diagnostics, showError]); + useEffect(() => { let cancelled = false; const projectRoot = groveDir !== undefined ? dirname(groveDir) : undefined; @@ -521,7 +587,7 @@ export function App({ }, [eventBus, topology, provider, triggerGlobalRefresh]); const hasGoals = isGoalProvider(provider); - const paletteItems = useMemo( + const corePaletteItems = useMemo( () => buildPaletteItems( topology, @@ -548,6 +614,14 @@ export function App({ hasGoals, ], ); + const pluginPaletteItems = useMemo( + () => buildPluginPaletteItems(mergedActionRegistry.entries, pluginContext), + [mergedActionRegistry.entries, pluginContext], + ); + const paletteItems = useMemo( + () => [...corePaletteItems, ...pluginPaletteItems], + [corePaletteItems, pluginPaletteItems], + ); // Filtered + ranked palette items — matches CommandPalette's rendering order // so Enter always executes the visually selected item. @@ -972,6 +1046,10 @@ export function App({ })(); } else if (item.kind === "delegate") { void handleDelegate(item.id); + } else if (item.kind === "plugin-action" && item.pluginAction !== undefined) { + void runTuiActionRegistration(item.pluginAction, pluginContext).catch((err: unknown) => { + showError(err instanceof Error ? err.message : "Plugin action failed"); + }); } else if (item.kind === "goal") { panels.setMode(InputMode.GoalInput); dispatch({ type: "GOAL_INPUT_MODE" }); @@ -1051,6 +1129,7 @@ export function App({ agentProfiles, topology, paletteParentId, + pluginContext, resolvedKeymap, keymapPrefix, refreshAll, @@ -1156,6 +1235,8 @@ export function App({ terminalBuffers={terminalBuffers ?? undefined} layoutMode={ks.layoutMode} presetName={presetName} + registryEntries={mergedPanelRegistry.entries} + pluginContext={pluginContext} /> { + throw new Error("getDashboard not used"); + }, + getContributions: async () => [], + getContribution: async () => undefined, + getClaims: async () => [], + getFrontier: async () => ({ + byMetric: {}, + byAdoption: [], + byRecency: [], + byReviewScore: [], + byReproduction: [], + }), + getActivity: async () => [], + getDag: async () => ({ contributions: [] }), + getHotThreads: async () => [], + close: () => undefined, + }; +} + +function context(): TuiPluginContext { + return { + provider: providerStub(), + density: "compact", + showMessage: () => undefined, + }; +} + +function action(overrides: Partial = {}): TuiActionRegistration { + return { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: () => undefined, + ...overrides, + }; +} + +describe("plugin palette items", () => { + test("includes fixed built-in action IDs for duplicate protection", () => { + expect(getBuiltInPaletteActionRegistryEntries().map((entry) => entry.id)).toEqual([ + "set-goal", + "register-agent", + ]); + }); + + test("projects enabled plugin actions into palette items", () => { + const refresh = action(); + const merged = mergeTuiActionRegistrations({ + builtIns: getBuiltInPaletteActionRegistryEntries(), + plugins: [refresh], + }); + + const items = buildPluginPaletteItems(merged.entries, context()); + + expect( + items.map((item) => [item.kind, item.id, item.label, item.detail, item.enabled]), + ).toEqual([["plugin-action", "audit-refresh", "Refresh audit panel", "audit", true]]); + expect(items[0]?.pluginAction).toBe(refresh); + }); + + test("projects disabled plugin actions as non-executable palette items", () => { + const refresh = action({ enabled: () => false }); + const merged = mergeTuiActionRegistrations({ + builtIns: getBuiltInPaletteActionRegistryEntries(), + plugins: [refresh], + }); + + const items = buildPluginPaletteItems(merged.entries, context()); + + expect(items[0]?.enabled).toBe(false); + }); + + test("evaluates enabled predicate with the plugin context", () => { + const enabled = mock((ctx: TuiPluginContext) => ctx.density === "compact"); + const refresh = action({ enabled }); + const merged = mergeTuiActionRegistrations({ + builtIns: getBuiltInPaletteActionRegistryEntries(), + plugins: [refresh], + }); + + const items = buildPluginPaletteItems(merged.entries, context()); + + expect(items[0]?.enabled).toBe(true); + expect(enabled).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tui/components/command-palette.tsx b/src/tui/components/command-palette.tsx index 52e04bb2d..ff58b9fa3 100644 --- a/src/tui/components/command-palette.tsx +++ b/src/tui/components/command-palette.tsx @@ -14,6 +14,8 @@ import type { Claim } from "../../core/models.js"; import type { AgentTopology } from "../../core/topology.js"; import { checkSpawn } from "../agents/spawn-validator.js"; import type { TmuxManager } from "../agents/tmux-manager.js"; +import type { TuiActionRegistryEntry } from "../plugins/registry.js"; +import type { TuiActionRegistration, TuiPluginContext } from "../plugins/types.js"; import { theme } from "../theme.js"; // --------------------------------------------------------------------------- @@ -101,12 +103,13 @@ function renderHighlighted( /** A single actionable entry in the palette. */ export interface PaletteItem { - readonly kind: "spawn" | "kill" | "register" | "delegate" | "goal"; - /** For spawn: role name. For kill: session name. For delegate: peerId. Optional for goal. */ + readonly kind: "spawn" | "kill" | "register" | "delegate" | "goal" | "plugin-action"; + /** For spawn: role name. For kill: session name. For delegate: peer address. For plugin-action: action id. */ readonly id: string; readonly label: string; readonly enabled: boolean; readonly detail: string; + readonly pluginAction?: TuiActionRegistration | undefined; } /** Props for the CommandPalette component. */ @@ -146,6 +149,49 @@ export interface LoadedProfile { readonly command?: string | undefined; } +const BUILT_IN_PALETTE_ACTIONS: readonly TuiActionRegistryEntry[] = Object.freeze([ + Object.freeze({ + id: "set-goal", + label: "Set goal", + detail: "Set or update the session goal for all agents", + order: 0, + source: "builtin" as const, + builtInAction: "goal", + }), + Object.freeze({ + id: "register-agent", + label: "[r] Register new agent profile", + detail: "agents.json", + order: 10, + source: "builtin" as const, + builtInAction: "register", + }), +]); + +export function getBuiltInPaletteActionRegistryEntries(): readonly TuiActionRegistryEntry[] { + return BUILT_IN_PALETTE_ACTIONS; +} + +export function buildPluginPaletteItems( + entries: readonly TuiActionRegistryEntry[], + context: TuiPluginContext, +): readonly PaletteItem[] { + const items: PaletteItem[] = []; + for (const entry of entries) { + if (entry.source !== "plugin" || entry.registration === undefined) continue; + const enabled = entry.registration.enabled?.(context) ?? true; + items.push({ + kind: "plugin-action", + id: entry.id, + label: entry.label, + detail: entry.detail, + enabled, + pluginAction: entry.registration, + }); + } + return Object.freeze(items); +} + /** Build the unified list of palette items from topology roles and tmux sessions. */ export function buildPaletteItems( topology: AgentTopology | undefined, diff --git a/src/tui/panels/panel-manager.tsx b/src/tui/panels/panel-manager.tsx index 52df3e731..6064c68c2 100644 --- a/src/tui/panels/panel-manager.tsx +++ b/src/tui/panels/panel-manager.tsx @@ -30,6 +30,8 @@ import { useEventDrivenData } from "../hooks/use-event-driven-data.js"; import type { NavigationActions } from "../hooks/use-navigation.js"; import type { PanelFocusState } from "../hooks/use-panel-focus.js"; import { isPanelVisible, PANEL_LABELS, Panel } from "../hooks/use-panel-focus.js"; +import type { TuiRegistryEntry } from "../plugins/registry.js"; +import type { TuiPluginContext } from "../plugins/types.js"; import type { ContributionDetail, TuiDataProvider } from "../provider.js"; import { theme } from "../theme.js"; import { ActivityPanelView } from "../views/activity-panel.js"; @@ -64,6 +66,10 @@ import { panelRowGroup, type ZoomLevel, } from "./panel-registry.js"; +import { + getDefaultVisiblePluginPanelEntries, + shouldRenderDefaultVisiblePluginPanels, +} from "./plugin-panels.js"; // Re-export for backwards compatibility export type { ZoomLevel, LayoutMode }; @@ -121,15 +127,19 @@ export interface PanelManagerProps { readonly layoutMode?: LayoutMode | undefined; /** Preset name — used for per-preset panel visibility filtering. */ readonly presetName?: string | undefined; + /** Merged built-in and plugin panel entries. Defaults to built-ins only. */ + readonly registryEntries?: readonly TuiRegistryEntry[] | undefined; + /** Context passed to trusted plugin panel components. */ + readonly pluginContext?: TuiPluginContext | undefined; } /** Wraps a panel view with a titled border and focus indication. */ function PanelChrome({ - panel, + title, focused, children, }: { - readonly panel: Panel; + readonly title: string; readonly focused: boolean; readonly children: React.ReactNode; }): React.ReactNode { @@ -147,7 +157,7 @@ function PanelChrome({ > - {` ${PANEL_LABELS[panel]} `} + {` ${title} `} {children} @@ -188,6 +198,8 @@ export const PanelManager: React.NamedExoticComponent = React terminalBuffers, layoutMode, presetName, + registryEntries, + pluginContext, }: PanelManagerProps): React.ReactNode { const isFocused = (p: Panel) => panelState.focused === p; const zoom = zoomLevel ?? "normal"; @@ -205,6 +217,12 @@ export const PanelManager: React.NamedExoticComponent = React const focusedRowGroup = panelRowGroup(panelState.focused); const allowedPanels = getPresetPanels(presetName); + const pluginPanelEntries = getDefaultVisiblePluginPanelEntries(registryEntries ?? []); + const showPluginPanels = shouldRenderDefaultVisiblePluginPanels({ + layoutMode: effectiveMode, + zoomLevel: zoom, + isMedium, + }); // If detail view is active, show it in the Detail panel const showDetail = nav.isDetailView && nav.detailCid; @@ -501,7 +519,7 @@ export const PanelManager: React.NamedExoticComponent = React {height < 15 ? ( Terminal too small — resize for full view ) : null} - + {renderPanel(panelState.focused)} @@ -538,13 +556,30 @@ export const PanelManager: React.NamedExoticComponent = React flexGrow={getRowFlex(rowGroup, focusedRowGroup, zoom, rowGroup === 0 ? 2 : 1)} > {visibleInRow.map((def) => ( - + {renderPanel(def.panel)} ))} ); })} + {pluginContext !== undefined && showPluginPanels + ? pluginPanelEntries.map((entry) => { + const Component = entry.registration?.component; + if (Component === undefined) return null; + return ( + + + {React.createElement(Component, pluginContext)} + + + ); + }) + : null} ); }, diff --git a/src/tui/panels/plugin-panels.test.ts b/src/tui/panels/plugin-panels.test.ts new file mode 100644 index 000000000..397a90886 --- /dev/null +++ b/src/tui/panels/plugin-panels.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import type React from "react"; +import { Panel } from "../hooks/use-panel-focus.js"; +import type { TuiRegistryEntry } from "../plugins/registry.js"; +import type { TuiPluginContext, TuiSlot } from "../plugins/types.js"; +import { + getDefaultVisiblePluginPanelEntries, + shouldRenderDefaultVisiblePluginPanels, +} from "./plugin-panels.js"; + +const NullPanel: React.ComponentType = () => null; + +function builtInEntry(id: string, order: number, panel: Panel): TuiRegistryEntry { + return { + id, + label: id, + slot: "operator-panel", + order, + source: "builtin", + builtInPanel: panel, + }; +} + +function pluginEntry( + id: string, + defaultVisible?: boolean, + slot: TuiSlot = "operator-panel", +): TuiRegistryEntry { + return { + id, + label: id, + slot, + order: 1000, + source: "plugin", + registration: { + id, + label: id, + slot, + ...(defaultVisible === undefined ? {} : { defaultVisible }), + component: NullPanel, + }, + }; +} + +describe("getDefaultVisiblePluginPanelEntries", () => { + test("returns only plugin operator panels with defaultVisible true", () => { + const entries: readonly TuiRegistryEntry[] = [ + builtInEntry("dag", 0, Panel.Dag), + pluginEntry("audit-panel", true), + pluginEntry("hidden-panel", false), + pluginEntry("implicit-hidden"), + pluginEntry("footer-panel", true, "footer"), + ]; + + expect(getDefaultVisiblePluginPanelEntries(entries).map((entry) => entry.id)).toEqual([ + "audit-panel", + ]); + }); + + test("skips plugin entries without registrations", () => { + const entries: readonly TuiRegistryEntry[] = [ + { + id: "broken-panel", + label: "Broken", + slot: "operator-panel", + order: 1000, + source: "plugin", + }, + ]; + + expect(getDefaultVisiblePluginPanelEntries(entries)).toEqual([]); + }); +}); + +describe("shouldRenderDefaultVisiblePluginPanels", () => { + test("renders only in unsuppressed grid layout", () => { + expect( + shouldRenderDefaultVisiblePluginPanels({ + layoutMode: "grid", + zoomLevel: "normal", + isMedium: false, + }), + ).toBe(true); + }); + + test("does not render in tab layout", () => { + expect( + shouldRenderDefaultVisiblePluginPanels({ + layoutMode: "tab", + zoomLevel: "normal", + isMedium: false, + }), + ).toBe(false); + }); + + test("does not render in full zoom", () => { + expect( + shouldRenderDefaultVisiblePluginPanels({ + layoutMode: "grid", + zoomLevel: "full", + isMedium: false, + }), + ).toBe(false); + }); + + test("does not render in medium layout", () => { + expect( + shouldRenderDefaultVisiblePluginPanels({ + layoutMode: "grid", + zoomLevel: "normal", + isMedium: true, + }), + ).toBe(false); + }); +}); diff --git a/src/tui/panels/plugin-panels.ts b/src/tui/panels/plugin-panels.ts new file mode 100644 index 000000000..a45591107 --- /dev/null +++ b/src/tui/panels/plugin-panels.ts @@ -0,0 +1,30 @@ +import type { TuiRegistryEntry } from "../plugins/registry.js"; +import type { LayoutMode, ZoomLevel } from "./panel-registry.js"; + +export interface ShouldRenderDefaultVisiblePluginPanelsInput { + readonly layoutMode: LayoutMode; + readonly zoomLevel: ZoomLevel; + readonly isMedium: boolean; +} + +export function getDefaultVisiblePluginPanelEntries( + entries: readonly TuiRegistryEntry[], +): readonly TuiRegistryEntry[] { + return Object.freeze( + entries.filter( + (entry) => + entry.source === "plugin" && + entry.slot === "operator-panel" && + entry.registration !== undefined && + entry.registration.defaultVisible === true, + ), + ); +} + +export function shouldRenderDefaultVisiblePluginPanels({ + layoutMode, + zoomLevel, + isMedium, +}: ShouldRenderDefaultVisiblePluginPanelsInput): boolean { + return layoutMode === "grid" && zoomLevel !== "full" && !isMedium; +} diff --git a/src/tui/plugins/actions.test.ts b/src/tui/plugins/actions.test.ts new file mode 100644 index 000000000..db45dc51c --- /dev/null +++ b/src/tui/plugins/actions.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import type { TuiDataProvider } from "../provider.js"; +import { runTuiActionRegistration } from "./actions.js"; +import type { TuiActionRegistration, TuiPluginContext } from "./types.js"; + +function providerStub(): TuiDataProvider { + return { + capabilities: { + outcomes: false, + artifacts: false, + vfs: false, + messaging: false, + costTracking: false, + askUser: false, + github: false, + bounties: false, + gossip: false, + goals: false, + sessions: false, + handoffs: false, + }, + getDashboard: async () => { + throw new Error("getDashboard not used"); + }, + getContributions: async () => [], + getContribution: async () => undefined, + getClaims: async () => [], + getFrontier: async () => ({ + byMetric: {}, + byAdoption: [], + byRecency: [], + byReviewScore: [], + byReproduction: [], + }), + getActivity: async () => [], + getDag: async () => ({ contributions: [] }), + getHotThreads: async () => [], + close: () => undefined, + }; +} + +function context(): TuiPluginContext { + return { + provider: providerStub(), + density: "comfortable", + showMessage: () => undefined, + }; +} + +describe("runTuiActionRegistration", () => { + test("runs synchronous action callbacks with plugin context", async () => { + let receivedDensity = ""; + const action: TuiActionRegistration = { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: (ctx) => { + receivedDensity = ctx.density; + }, + }; + + await runTuiActionRegistration(action, context()); + + expect(receivedDensity).toBe("comfortable"); + }); + + test("runs asynchronous action callbacks", async () => { + let completed = false; + const action: TuiActionRegistration = { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: async () => { + await Promise.resolve(); + completed = true; + }, + }; + + await runTuiActionRegistration(action, context()); + + expect(completed).toBe(true); + }); + + test("propagates action failures to the caller", async () => { + const action: TuiActionRegistration = { + id: "audit-refresh", + label: "Refresh audit panel", + detail: "audit", + run: () => { + throw new Error("audit failed"); + }, + }; + + await expect(runTuiActionRegistration(action, context())).rejects.toThrow("audit failed"); + }); +}); diff --git a/src/tui/plugins/actions.ts b/src/tui/plugins/actions.ts new file mode 100644 index 000000000..7d70203da --- /dev/null +++ b/src/tui/plugins/actions.ts @@ -0,0 +1,8 @@ +import type { TuiActionRegistration, TuiPluginContext } from "./types.js"; + +export async function runTuiActionRegistration( + action: TuiActionRegistration, + context: TuiPluginContext, +): Promise { + await action.run(context); +} diff --git a/src/tui/plugins/registry.test.ts b/src/tui/plugins/registry.test.ts index 78bb607fa..1b21be59f 100644 --- a/src/tui/plugins/registry.test.ts +++ b/src/tui/plugins/registry.test.ts @@ -1,7 +1,14 @@ import { describe, expect, test } from "bun:test"; import { Panel } from "../hooks/use-panel-focus.js"; -import { mergeTuiRegistrations, type TuiRegistryEntry } from "./registry.js"; -import type { TuiPanelRegistration } from "./types.js"; +import { + collectTuiActionRegistrations, + collectTuiPanelRegistrations, + mergeTuiActionRegistrations, + mergeTuiRegistrations, + type TuiActionRegistryEntry, + type TuiRegistryEntry, +} from "./registry.js"; +import type { TuiActionRegistration, TuiExtension, TuiPanelRegistration } from "./types.js"; const NullPanel = () => null; @@ -26,6 +33,27 @@ function plugin(id: string, order?: number): TuiPanelRegistration { }; } +function builtInAction(id: string, order: number): TuiActionRegistryEntry { + return { + id, + label: id, + detail: `${id} detail`, + order, + source: "builtin", + builtInAction: id, + }; +} + +function action(id: string, order?: number): TuiActionRegistration { + return { + id, + label: id, + detail: `${id} detail`, + ...(order === undefined ? {} : { order }), + run: () => undefined, + }; +} + describe("mergeTuiRegistrations", () => { test("orders built-in and plugin entries by order then id", () => { const result = mergeTuiRegistrations({ @@ -150,3 +178,138 @@ describe("mergeTuiRegistrations", () => { ).toThrow("Built-in TUI panel id is duplicated: dag"); }); }); + +describe("TuiExtension collection", () => { + test("flattens panel and action registrations from extensions", () => { + const panel = plugin("audit-panel", 20); + const refresh = action("audit-refresh", 30); + const extension: TuiExtension = { + id: "audit", + name: "Audit", + version: "1.0.0", + panels: [panel], + actions: [refresh], + }; + + expect(collectTuiPanelRegistrations([extension])).toEqual([panel]); + expect(collectTuiActionRegistrations([extension])).toEqual([refresh]); + }); + + test("returns empty frozen arrays when extensions are absent", () => { + const panels = collectTuiPanelRegistrations(); + const actions = collectTuiActionRegistrations(); + + expect(panels).toEqual([]); + expect(Object.isFrozen(panels)).toBe(true); + expect(actions).toEqual([]); + expect(Object.isFrozen(actions)).toBe(true); + }); +}); + +describe("mergeTuiActionRegistrations", () => { + test("orders built-in and plugin action entries by order then id", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10), builtInAction("set-goal", 30)], + plugins: [action("audit-refresh", 20), action("audit-export", 20)], + }); + + expect(result.diagnostics).toEqual([]); + expect(result.entries.map((entry) => entry.id)).toEqual([ + "register-agent", + "audit-export", + "audit-refresh", + "set-goal", + ]); + }); + + test("skips plugin actions that duplicate built-in action IDs", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10)], + plugins: [action("register-agent", 20)], + }); + + expect(result.entries.map((entry) => entry.id)).toEqual(["register-agent"]); + expect(result.diagnostics).toEqual([ + { + id: "register-agent", + severity: "error", + message: "Duplicate TUI action id: register-agent", + }, + ]); + }); + + test("skips plugin actions with unsafe IDs", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10)], + plugins: [action("bad/id", 20), action("UpperCase", 30)], + }); + + expect(result.entries.map((entry) => entry.id)).toEqual(["register-agent"]); + expect(result.diagnostics).toEqual([ + { + id: "bad/id", + severity: "error", + message: "Invalid TUI action id: bad/id", + }, + { + id: "UpperCase", + severity: "error", + message: "Invalid TUI action id: UpperCase", + }, + ]); + }); + + test("uses default plugin action order 1000 when order is omitted", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10)], + plugins: [action("audit-refresh")], + }); + + expect(result.entries.map((entry) => [entry.id, entry.order])).toEqual([ + ["register-agent", 10], + ["audit-refresh", 1000], + ]); + }); + + test("uses default action order and reports diagnostics for invalid orders", () => { + const result = mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10)], + plugins: [ + action("nan-action", Number.NaN), + action("infinite-action", Number.POSITIVE_INFINITY), + ], + }); + + expect(result.entries.map((entry) => [entry.id, entry.order])).toEqual([ + ["register-agent", 10], + ["infinite-action", 1000], + ["nan-action", 1000], + ]); + expect(result.diagnostics).toEqual([ + { + id: "nan-action", + severity: "error", + message: "Invalid TUI action order for nan-action; using default order 1000", + }, + { + id: "infinite-action", + severity: "error", + message: "Invalid TUI action order for infinite-action; using default order 1000", + }, + ]); + }); + + test("throws when built-in action IDs are unsafe or duplicated", () => { + expect(() => + mergeTuiActionRegistrations({ + builtIns: [builtInAction("bad/id", 10)], + }), + ).toThrow("Built-in TUI action has invalid id: bad/id"); + + expect(() => + mergeTuiActionRegistrations({ + builtIns: [builtInAction("register-agent", 10), builtInAction("register-agent", 20)], + }), + ).toThrow("Built-in TUI action id is duplicated: register-agent"); + }); +}); diff --git a/src/tui/plugins/registry.ts b/src/tui/plugins/registry.ts index 8d4dfeb2d..a22a99232 100644 --- a/src/tui/plugins/registry.ts +++ b/src/tui/plugins/registry.ts @@ -1,8 +1,13 @@ import type { Panel } from "../hooks/use-panel-focus.js"; -import type { TuiPanelRegistration, TuiSlot } from "./types.js"; +import type { + TuiActionRegistration, + TuiExtension, + TuiPanelRegistration, + TuiSlot, +} from "./types.js"; const DEFAULT_PLUGIN_ORDER = 1000; -const SAFE_PANEL_ID = /^[a-z][a-z0-9.-]*$/; +const SAFE_TUI_ID = /^[a-z][a-z0-9.-]*$/; export interface TuiRegistryEntry { readonly id: string; @@ -14,6 +19,16 @@ export interface TuiRegistryEntry { readonly registration?: TuiPanelRegistration | undefined; } +export interface TuiActionRegistryEntry { + readonly id: string; + readonly label: string; + readonly detail: string; + readonly order: number; + readonly source: "builtin" | "plugin"; + readonly builtInAction?: string | undefined; + readonly registration?: TuiActionRegistration | undefined; +} + export interface TuiRegistryDiagnostic { readonly id: string; readonly severity: "error"; @@ -30,8 +45,36 @@ export interface MergeTuiRegistrationsResult { readonly diagnostics: readonly TuiRegistryDiagnostic[]; } +export interface MergeTuiActionRegistrationsInput { + readonly builtIns: readonly TuiActionRegistryEntry[]; + readonly plugins?: readonly TuiActionRegistration[] | undefined; +} + +export interface MergeTuiActionRegistrationsResult { + readonly entries: readonly TuiActionRegistryEntry[]; + readonly diagnostics: readonly TuiRegistryDiagnostic[]; +} + +export function isSafeTuiId(id: string): boolean { + return SAFE_TUI_ID.test(id); +} + export function isSafeTuiPanelId(id: string): boolean { - return SAFE_PANEL_ID.test(id); + return isSafeTuiId(id); +} + +export function collectTuiPanelRegistrations( + extensions?: readonly TuiExtension[] | undefined, +): readonly TuiPanelRegistration[] { + const registrations = (extensions ?? []).flatMap((extension) => extension.panels ?? []); + return Object.freeze(registrations); +} + +export function collectTuiActionRegistrations( + extensions?: readonly TuiExtension[] | undefined, +): readonly TuiActionRegistration[] { + const registrations = (extensions ?? []).flatMap((extension) => extension.actions ?? []); + return Object.freeze(registrations); } export function mergeTuiRegistrations( @@ -42,7 +85,7 @@ export function mergeTuiRegistrations( const seen = new Set(); for (const entry of input.builtIns) { - if (!isSafeTuiPanelId(entry.id)) { + if (!isSafeTuiId(entry.id)) { throw new Error(`Built-in TUI panel has invalid id: ${entry.id}`); } if (seen.has(entry.id)) { @@ -53,7 +96,7 @@ export function mergeTuiRegistrations( } for (const registration of input.plugins ?? []) { - if (!isSafeTuiPanelId(registration.id)) { + if (!isSafeTuiId(registration.id)) { diagnostics.push({ id: registration.id, severity: "error", @@ -70,7 +113,7 @@ export function mergeTuiRegistrations( continue; } seen.add(registration.id); - const order = resolvePluginOrder(registration, diagnostics); + const order = resolvePluginOrder(registration.id, registration.order, "panel", diagnostics); entries.push({ id: registration.id, label: registration.label, @@ -81,13 +124,7 @@ export function mergeTuiRegistrations( }); } - entries.sort((a, b) => { - const orderDelta = a.order - b.order; - if (orderDelta !== 0) return orderDelta; - const sourceDelta = sourceRank(a.source) - sourceRank(b.source); - if (sourceDelta !== 0) return sourceDelta; - return a.id.localeCompare(b.id); - }); + entries.sort(compareRegistryEntries); return { entries: Object.freeze(entries), @@ -95,21 +132,89 @@ export function mergeTuiRegistrations( }; } +export function mergeTuiActionRegistrations( + input: MergeTuiActionRegistrationsInput, +): MergeTuiActionRegistrationsResult { + const entries: TuiActionRegistryEntry[] = []; + const diagnostics: TuiRegistryDiagnostic[] = []; + const seen = new Set(); + + for (const entry of input.builtIns) { + if (!isSafeTuiId(entry.id)) { + throw new Error(`Built-in TUI action has invalid id: ${entry.id}`); + } + if (seen.has(entry.id)) { + throw new Error(`Built-in TUI action id is duplicated: ${entry.id}`); + } + seen.add(entry.id); + entries.push(entry); + } + + for (const registration of input.plugins ?? []) { + if (!isSafeTuiId(registration.id)) { + diagnostics.push({ + id: registration.id, + severity: "error", + message: `Invalid TUI action id: ${registration.id}`, + }); + continue; + } + if (seen.has(registration.id)) { + diagnostics.push({ + id: registration.id, + severity: "error", + message: `Duplicate TUI action id: ${registration.id}`, + }); + continue; + } + seen.add(registration.id); + const order = resolvePluginOrder(registration.id, registration.order, "action", diagnostics); + entries.push({ + id: registration.id, + label: registration.label, + detail: registration.detail, + order, + source: "plugin", + registration, + }); + } + + entries.sort(compareRegistryEntries); + + return { + entries: Object.freeze(entries), + diagnostics: Object.freeze(diagnostics), + }; +} + +function compareRegistryEntries( + a: Pick, + b: Pick, +): number { + const orderDelta = a.order - b.order; + if (orderDelta !== 0) return orderDelta; + const sourceDelta = sourceRank(a.source) - sourceRank(b.source); + if (sourceDelta !== 0) return sourceDelta; + return a.id.localeCompare(b.id); +} + function sourceRank(source: "builtin" | "plugin"): number { return source === "builtin" ? 0 : 1; } function resolvePluginOrder( - registration: TuiPanelRegistration, + id: string, + order: number | undefined, + kind: "action" | "panel", diagnostics: TuiRegistryDiagnostic[], ): number { - if (registration.order === undefined) return DEFAULT_PLUGIN_ORDER; - if (Number.isFinite(registration.order)) return registration.order; + if (order === undefined) return DEFAULT_PLUGIN_ORDER; + if (Number.isFinite(order)) return order; diagnostics.push({ - id: registration.id, + id, severity: "error", - message: `Invalid TUI panel order for ${registration.id}; using default order ${DEFAULT_PLUGIN_ORDER}`, + message: `Invalid TUI ${kind} order for ${id}; using default order ${DEFAULT_PLUGIN_ORDER}`, }); return DEFAULT_PLUGIN_ORDER; } diff --git a/src/tui/plugins/types.ts b/src/tui/plugins/types.ts index 48167c8b7..707ca7de3 100644 --- a/src/tui/plugins/types.ts +++ b/src/tui/plugins/types.ts @@ -22,6 +22,7 @@ export interface TuiPluginContext { readonly selectedSession?: string | undefined; readonly selectedCid?: string | undefined; readonly density: "comfortable" | "compact"; + readonly showMessage: (message: string) => void; } export interface TuiPanelRegistration { @@ -32,3 +33,20 @@ export interface TuiPanelRegistration { readonly order?: number | undefined; readonly component: React.ComponentType; } + +export interface TuiActionRegistration { + readonly id: string; + readonly label: string; + readonly detail: string; + readonly order?: number | undefined; + readonly enabled?: ((context: TuiPluginContext) => boolean) | undefined; + readonly run: (context: TuiPluginContext) => void | Promise; +} + +export interface TuiExtension { + readonly id: string; + readonly name: string; + readonly version: string; + readonly panels?: readonly TuiPanelRegistration[] | undefined; + readonly actions?: readonly TuiActionRegistration[] | undefined; +} diff --git a/tests/e2e/tui-extension-review-loop.tmux.ts b/tests/e2e/tui-extension-review-loop.tmux.ts new file mode 100644 index 000000000..94c5fc735 --- /dev/null +++ b/tests/e2e/tui-extension-review-loop.tmux.ts @@ -0,0 +1,503 @@ +/** + * Tmux-driven E2E smoke for the TUI extension surface (#189). + * + * Validates the operator path against a real Grove review-loop data flow: + * 1. Create a Codex coder -> Claude reviewer topology in real SQLite stores. + * 2. Submit coder work, verify a reviewer handoff is created. + * 3. Submit reviewer review, verify the handoff becomes replied. + * 4. Submit the reviewer done marker. + * 5. Mount the real TUI with a trusted local extension that contributes: + * - a default-visible operator panel showing the handoff/done state + * - a command-palette action + * 6. Drive Ctrl+P / filter / Enter through tmux and assert the action message + * appears in the captured pane. + * + * NOT wired into `bun test` -- run as: + * bun ./tests/e2e/tui-extension-review-loop.tmux.ts + * + * Flags: + * --keep Leave tmux session + workdir for inspection + * --attach Print `tmux attach` command + * --timeout Overall budget (default 60000) + */ + +import { execSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; + +const SOCKET = "grove-tui-ext-e2e"; +const SESSION = "grove-tui-ext-e2e"; +const PROJECT_ROOT = join(import.meta.dir, "..", ".."); + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + keep: { type: "boolean", default: false }, + attach: { type: "boolean", default: false }, + timeout: { type: "string", default: "60000" }, + }, +}); + +const KEEP = values.keep; +const ATTACH = values.attach; +const BUDGET_MS = Number.parseInt(values.timeout as string, 10); + +function capturePane(): string { + const out = spawnSync( + "tmux", + ["-L", SOCKET, "capture-pane", "-t", SESSION, "-p", "-S", "-5000"], + { + encoding: "utf-8", + }, + ); + return out.stdout; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForPane( + predicate: (pane: string) => boolean, + phase: string, + maxMs = 30_000, +): Promise { + const deadline = Date.now() + maxMs; + let last = ""; + while (Date.now() < deadline) { + last = capturePane(); + if (predicate(last)) { + console.log(`[${phase}] matched`); + return last; + } + await sleep(400); + } + console.error(`\n---- pane dump (${phase}) ----\n${last}\n---- end pane dump ----`); + throw new Error(`[${phase}] predicate did not match within ${String(maxMs)}ms`); +} + +function sendKeys(...keys: string[]): void { + const args = ["-L", SOCKET, "send-keys", "-t", SESSION, ...keys]; + const result = spawnSync("tmux", args, { encoding: "utf-8" }); + if (result.status !== 0) { + throw new Error(`tmux send-keys failed: ${result.stderr || result.stdout}`); + } +} + +const workDir = mkdtempSync(join(tmpdir(), "grove-tui-ext-e2e-")); +const driverPath = join(PROJECT_ROOT, `.tmp-tui-extension-review-loop-${process.pid}.tsx`); + +function cleanup(): void { + if (!KEEP) { + try { + execSync(`tmux -L ${SOCKET} kill-server 2>/dev/null`, { stdio: "ignore" }); + } catch { + /* already dead */ + } + } + if (!KEEP && existsSync(driverPath)) { + rmSync(driverPath, { force: true }); + } + if (!KEEP && existsSync(workDir)) { + rmSync(workDir, { recursive: true, force: true }); + } else if (KEEP) { + console.log(`[cleanup] kept workDir=${workDir}`); + console.log(`[cleanup] kept driver=${driverPath}`); + } +} + +process.on("SIGINT", () => { + cleanup(); + process.exit(130); +}); + +function makeDriver(projectRoot: string): string { + return ` +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import React from "react"; + +const { LocalEventBus } = await import("${projectRoot}/src/core/local-event-bus.js"); +const { HandoffStatus } = await import("${projectRoot}/src/core/handoff.js"); +const { TopologyRouter } = await import("${projectRoot}/src/core/topology-router.js"); +const { contributeOperation } = await import("${projectRoot}/src/core/operations/contribute.js"); +const { initSqliteDb, SqliteContributionStore } = await import("${projectRoot}/src/local/sqlite-store.js"); +const { SqliteHandoffStore } = await import("${projectRoot}/src/local/sqlite-handoff-store.js"); +const { App } = await import("${projectRoot}/src/tui/app.js"); +const { SpawnManager } = await import("${projectRoot}/src/tui/spawn-manager.js"); +const { SpawnManagerContext } = await import("${projectRoot}/src/tui/spawn-manager-context.js"); + +const workDir = process.env.E2E_WORKDIR; +if (!workDir) throw new Error("E2E_WORKDIR required"); +mkdirSync(workDir, { recursive: true }); +mkdirSync(join(process.cwd(), ".grove"), { recursive: true }); +writeFileSync(join(process.cwd(), ".grove", "tui-state.json"), '{"tooltipsDismissed":true}\\n'); + +const topology = Object.freeze({ + structure: "graph", + roles: Object.freeze([ + Object.freeze({ + name: "coder", + description: "Codex coder", + platform: "codex", + edges: Object.freeze([ + Object.freeze({ target: "reviewer", edgeType: "delegates", replyTimeoutSeconds: 30 }), + ]), + }), + Object.freeze({ + name: "reviewer", + description: "Claude reviewer", + platform: "claude-code", + endsSession: true, + }), + ]), +}); + +const db = initSqliteDb(join(workDir, "grove.db")); +const contributionStore = new SqliteContributionStore(db); +const handoffStore = new SqliteHandoffStore(db); +const eventBus = new LocalEventBus(); +const topologyRouter = new TopologyRouter(topology, eventBus); +const deps = { contributionStore, handoffStore, topologyRouter, eventBus }; + +async function expectOk(result, label) { + if (!result.ok) throw new Error(label + " failed: " + result.error.message); + return result.value; +} + +async function waitForHandoff(predicate, label) { + const deadline = Date.now() + 5000; + let last = []; + while (Date.now() < deadline) { + last = await handoffStore.list(); + const found = last.find(predicate); + if (found) return found; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(label + " not observed; last=" + JSON.stringify(last)); +} + +const work = await expectOk( + await contributeOperation( + { + kind: "work", + summary: "Codex coder implemented a TUI extension smoke", + agent: { agentId: "codex-coder", role: "coder", platform: "codex" }, + context: { workspacePath: join(workDir, "codex-workspace") }, + }, + deps, + ), + "coder work", +); + +const createdHandoff = await waitForHandoff( + (handoff) => handoff.sourceCid === work.cid && handoff.toRole === "reviewer", + "reviewer handoff", +); + +const review = await expectOk( + await contributeOperation( + { + kind: "review", + summary: "Claude reviewer approved the TUI extension smoke", + relations: [{ targetCid: work.cid, relationType: "reviews" }], + scores: { correctness: { value: 0.95, direction: "maximize" } }, + agent: { agentId: "claude-reviewer", role: "reviewer", platform: "claude-code" }, + }, + deps, + ), + "reviewer review", +); + +const repliedHandoff = await waitForHandoff( + (handoff) => handoff.handoffId === createdHandoff.handoffId && handoff.status === HandoffStatus.Replied, + "replied handoff", +); + +const done = await expectOk( + await contributeOperation( + { + kind: "discussion", + summary: "[DONE] Claude reviewer approved", + context: { ephemeral: true, done: true, reason: "Approved through tmux TUI extension E2E" }, + agent: { agentId: "claude-reviewer", role: "reviewer", platform: "claude-code" }, + }, + deps, + ), + "reviewer done", +); +const storedDone = await contributionStore.get(done.cid); +const doneSignaled = storedDone?.context?.done === true; +if (!doneSignaled) throw new Error("stored done marker did not preserve context.done=true"); + +const contributions = await contributionStore.list({ limit: 20 }); +const handoffs = await handoffStore.list(); +console.log("[flow] codex work cid=" + work.cid); +console.log("[flow] claude review cid=" + review.cid); +console.log("[flow] handoff status=" + repliedHandoff.status + " id=" + repliedHandoff.handoffId); +console.log("[flow] done marker cid=" + done.cid + " done=" + String(doneSignaled)); + +const capabilities = Object.freeze({ + outcomes: false, + artifacts: false, + vfs: false, + messaging: false, + costTracking: false, + askUser: false, + github: false, + bounties: false, + gossip: false, + goals: false, + sessions: false, + handoffs: true, +}); + +const frontierEntries = contributions + .filter((contribution) => contribution.context?.ephemeral !== true) + .map((contribution, index) => ({ + cid: contribution.cid, + summary: contribution.summary, + value: contributions.length - index, + contribution, + })); + +const provider = { + capabilities, + async getDashboard() { + return { + metadata: { + name: "tui-extension-review-loop-e2e", + contributionCount: contributions.length, + activeClaimCount: 0, + mode: "exploration", + backendLabel: "tmux e2e", + goal: "Validate Codex -> Claude review-loop TUI extension", + }, + activeClaims: [], + recentContributions: contributions, + frontierSummary: { topByMetric: [], topByAdoption: [] }, + }; + }, + async getContributions() { + return contributions; + }, + async getContribution(cid) { + const contribution = contributions.find((item) => item.cid === cid); + if (!contribution) return undefined; + return { contribution, ancestors: [], children: [], thread: [] }; + }, + async getClaims() { + return []; + }, + async getFrontier() { + return { + byMetric: {}, + byAdoption: [], + byRecency: frontierEntries, + byReviewScore: frontierEntries.filter((entry) => entry.contribution.kind === "review"), + byReproduction: [], + }; + }, + async getActivity() { + return contributions; + }, + async getDag() { + return { contributions }; + }, + async getHotThreads() { + return []; + }, + async getHandoffs() { + return handoffs; + }, + async markHandoffDelivered() {}, + async cancelHandoff() {}, + async manualResolveHandoff() {}, + async resendHandoff() {}, + async rerouteHandoff() {}, + close() {}, +}; + +function ReviewLoopPanel() { + return React.createElement( + "box", + { flexDirection: "column", paddingLeft: 1 }, + React.createElement("text", { color: "#7dd3fc", bold: true }, "Review Handoff Extension"), + React.createElement("text", null, "codex -> claude"), + React.createElement("text", null, "handoff status: " + repliedHandoff.status), + React.createElement("text", null, "review cid: " + review.cid.slice(0, 18)), + React.createElement("text", null, "done marker: " + String(doneSignaled)), + ); +} + +const extension = Object.freeze({ + id: "review-loop", + name: "Review Loop Smoke", + version: "1.0.0", + panels: Object.freeze([ + Object.freeze({ + id: "review-loop-panel", + label: "Review Loop", + slot: "operator-panel", + defaultVisible: true, + order: 900, + component: ReviewLoopPanel, + }), + ]), + actions: Object.freeze([ + Object.freeze({ + id: "mark-handoff-done", + label: "Mark handoff done", + detail: "codex -> claude", + order: 20, + run: (context) => { + context.showMessage("handoff done via plugin action"); + }, + }), + ]), +}); + +const renderer = await createCliRenderer({ + exitOnCtrlC: true, + useAlternateScreen: false, + useThread: false, +}); +const root = createRoot(renderer); +const spawnManager = new SpawnManager(provider, undefined, (message) => { + console.log("[spawn-error] " + message); +}, []); + +root.render( + React.createElement( + SpawnManagerContext, + { value: spawnManager }, + React.createElement(App, { + provider, + intervalMs: 1000, + topology, + extensions: [extension], + screenContext: "inspect", + }), + ), +); + +let driverCleaned = false; +function cleanupDriver() { + if (driverCleaned) return; + driverCleaned = true; + eventBus.close(); + db.close(); + console.log("\\nTUI_DRIVER_DONE"); +} + +setTimeout(() => { + renderer.destroy(); + cleanupDriver(); +}, 30000); + +renderer.start(); +await renderer.idle(); +cleanupDriver(); +`; +} + +async function main(): Promise { + try { + execSync(`tmux -L ${SOCKET} kill-server 2>/dev/null`, { stdio: "ignore" }); + } catch { + /* already dead */ + } + + writeFileSync(driverPath, makeDriver(PROJECT_ROOT), "utf8"); + console.log(`[setup] workDir=${workDir}`); + console.log(`[setup] driver=${driverPath}`); + + const command = [ + `cd ${JSON.stringify(workDir)}`, + [ + `PATH="/Users/tafeng/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"`, + `E2E_WORKDIR=${JSON.stringify(workDir)}`, + "GROVE_NO_ALT_SCREEN=1", + `bun run ${JSON.stringify(driverPath)} 2>&1`, + ].join(" "), + "cat", + ].join(" && "); + + const started = spawnSync( + "tmux", + [ + "-L", + SOCKET, + "new-session", + "-d", + "-s", + SESSION, + "-x", + "180", + "-y", + "50", + "sh", + "-lc", + command, + ], + { encoding: "utf-8" }, + ); + if (started.status !== 0) { + throw new Error(`tmux new-session failed: ${started.stderr || started.stdout}`); + } + + console.log(`[tmux] started. Attach: tmux -L ${SOCKET} attach -t ${SESSION}`); + if (ATTACH) { + console.log(`\nAttach in another terminal:\n tmux -L ${SOCKET} attach -t ${SESSION}\n`); + } + + await waitForPane( + (pane) => + pane.includes("[flow] codex work cid=") && + pane.includes("[flow] claude review cid=") && + pane.includes("[flow] handoff status=replied") && + pane.includes("[flow] done marker cid="), + "review-loop core flow", + Math.min(BUDGET_MS, 20_000), + ); + + await waitForPane((pane) => pane.includes("[NORMAL] [INSPECT]"), "tui ready", 10_000); + sendKeys("+"); + await waitForPane( + (pane) => + pane.includes("Review Loop") && + pane.includes("claude") && + pane.includes("handoff status: replied") && + pane.includes("done marker: true"), + "plugin panel rendered", + Math.min(BUDGET_MS, 20_000), + ); + + sendKeys("C-p"); + await waitForPane((pane) => pane.includes("Command Palette"), "palette opened", 10_000); + sendKeys("handoff"); + await waitForPane( + (pane) => pane.includes("Mark handoff done") && pane.includes("codex -> claude"), + "plugin action visible", + 10_000, + ); + sendKeys("Enter"); + await waitForPane( + (pane) => pane.includes("handoff done via plugin action"), + "plugin action executed", + 10_000, + ); + + console.log("\nPASS tui extension review-loop tmux e2e"); +} + +main() + .catch((err) => { + console.error("\nFAIL tui extension review-loop tmux e2e:", err); + process.exitCode = 1; + }) + .finally(() => cleanup()); diff --git a/tests/tui/codex-claude-handoff-e2e.ts b/tests/tui/codex-claude-handoff-e2e.ts index 427c26f99..d4b876cc9 100644 --- a/tests/tui/codex-claude-handoff-e2e.ts +++ b/tests/tui/codex-claude-handoff-e2e.ts @@ -21,6 +21,7 @@ import { execSync, spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseArgs } from "node:util"; @@ -206,6 +207,25 @@ async function waitForPane(predicate: (pane: string) => boolean, phase: string, throw new Error(`[${phase}] predicate did not match within ${maxMs}ms`); } +async function findFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("unable to allocate free TCP port"))); + return; + } + const port = address.port; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + // ─── main ───────────────────────────────────────────────────────────── const workDir = mkdtempSync(join(tmpdir(), "grove-acp-e2e-")); const repoDir = join(workDir, "repo"); @@ -239,21 +259,9 @@ async function main() { /* already dead */ } - // 1b. Free the default grove ports (4515 HTTP, 4015 MCP) from any orphan - // listener — the new TUI's per-service identity gate refuses to adopt a - // port held by a process this run did not spawn, which is correct but - // fatal for the harness. - for (const port of [4515, 4015]) { - try { - execSync( - `lsof -tiTCP:${port} -sTCP:LISTEN 2>/dev/null | xargs -r kill -TERM 2>/dev/null; sleep 0.3; ` + - `lsof -tiTCP:${port} -sTCP:LISTEN 2>/dev/null | xargs -r kill -KILL 2>/dev/null`, - { stdio: "ignore", shell: "/bin/sh" } as Parameters[1], - ); - } catch { - /* nothing to kill */ - } - } + const serverPort = await findFreePort(); + const mcpPort = await findFreePort(); + console.log(`[setup] allocated ports: server=${serverPort} mcp=${mcpPort}`); // 2. Create a bare repo the coder can work inside. execSync(`git init -q ${repoDir} && cd ${repoDir} && git commit --allow-empty -q -m init`, { @@ -304,7 +312,7 @@ async function main() { // the coder cannot write hello.txt and the loop never produces a contribution. const launchCmd = [ `cd ${repoDir}`, - `${nexusImageEnv}GROVE_SERVICE_HEALTH_TIMEOUT_MS=${healthTimeoutMs} GROVE_ALLOW_ALL_PERMISSIONS=1 GROVE_DEBUG_LOG=${debugPath} bun run ${PROJECT_ROOT}/src/cli/main.ts up --grove ${groveDir}${nexusSourceArgs} 2>&1 | tee ${stdoutLog}`, + `${nexusImageEnv}GROVE_SERVICE_HEALTH_TIMEOUT_MS=${healthTimeoutMs} GROVE_ALLOW_ALL_PERMISSIONS=1 GROVE_DEBUG_LOG=${debugPath} GROVE_SERVER_PORT=${serverPort} MCP_PORT=${mcpPort} bun run ${PROJECT_ROOT}/src/cli/main.ts up --grove ${groveDir}${nexusSourceArgs} 2>&1 | tee ${stdoutLog}`, `echo [EXIT $?]`, `cat`, // keep pane alive ].join(" ; ");