/** * @cleepi/sdd โ€” spec-review cockpit extension. * * v0.3.0 ships the awareness half: discovers `.md` files in the repo, * observes the agent's edits to them, and renders a low-noise status * widget below the editor. `/spec view` and `ctrl+shift+m` are stubbed * until the review pane lands in v0.3.0 (checkpoint B). * * Spec: packages/sdd/docs/DRAFT-001-spec-review-tui/spec.md */ import * as path from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { getMarkdownTheme } from "@earendil-works/pi-coding-agent"; import { discoverSpecFiles, extractMdPathFromTool } from "./discovery"; import { CockpitState } from "./state"; import { CockpitPane } from "./cockpit-pane"; // `__dirname` here resolves to packages/sdd/extensions/spec-review/. The // package root is two levels up. Used so the journal action can find // `templates/journal.md` regardless of where the package is installed. const SDD_PACKAGE_ROOT = path.resolve(__dirname, "..", ".."); const WIDGET_KEY = "cleepi.sdd.cockpit"; export default function (pi: ExtensionAPI) { let state: CockpitState | undefined; let sessionCwd: string = process.cwd(); let paneOpen = false; pi.on("session_start", async (event, ctx) => { const cwd = event.cwd ?? process.cwd(); sessionCwd = cwd; state = new CockpitState(cwd); // Initial scan. Discovery is cheap (stat-only); content is read // lazily when the file is first touched. for (const abs of discoverSpecFiles(cwd)) { state.registerDiscovered(abs); } // Wire the widget to state changes. const renderWidget = () => { if (!state) return; const current = state.current(); const total = state.totalEdits(); if (!current && total === 0 && state.all().length === 0) { ctx.ui.setWidget(WIDGET_KEY, undefined); return; } const label = current ? current.relPath : `${state.all().length} specs tracked`; const editsPart = total > 0 ? ` ยท ${total} edit${total === 1 ? "" : "s"}` : ""; ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => { const line = `๐Ÿ“„ ${theme.fg("accent", label)}${theme.fg("muted", editsPart)}`; return { render: () => [line], invalidate: () => {}, }; }, { placement: "belowEditor" }); }; state.onChange(renderWidget); renderWidget(); }); pi.on("tool_call", async (event) => { if (!state) return; const abs = extractMdPathFromTool(event.toolName, event.input, sessionCwd); if (!abs) return; state.beforeEdit(abs); }); pi.on("tool_result", async (event) => { if (!state) return; if (event.isError) return; const abs = extractMdPathFromTool(event.toolName, event.input, sessionCwd); if (!abs) return; state.afterEdit(abs); }); pi.on("session_shutdown", async (_event, ctx) => { ctx.ui.setWidget(WIDGET_KEY, undefined); state = undefined; }); // Open the cockpit overlay. Re-entry while open is a no-op so the // shortcut doesn't stack overlays. const openPane = async (ctx: { ui: { custom: ( factory: (tui: any, theme: any, keybindings: any, done: (v?: unknown) => void) => any, opts?: any, ) => Promise | unknown; notify: (msg: string, level?: string) => void; setEditorText: (text: string) => void; }; }) => { if (!state) { ctx.ui.notify("spec cockpit: not initialised", "warning"); return; } if (paneOpen) return; paneOpen = true; try { await ctx.ui.custom( (tui: any, theme: any, _kb: any, done: (v?: unknown) => void) => { const pane = new CockpitPane({ state: state!, theme, mdTheme: getMarkdownTheme(), onClose: () => done(), requestRender: () => tui.requestRender(), // Pi clips us at the overlay's maxHeight (we set 100% below), // so the terminal row count is the right ceiling. Leave a small // margin so we never overflow the visible area. getMaxRows: () => { // Pi clips at the overlay's maxHeight ("100%"). Use the full // terminal height; the host clips if there's any chrome we // can't see. const rows = tui?.terminal?.rows ?? 24; return Math.max(8, rows); }, notify: (msg: string, level?: string) => { try { ctx.ui.notify(msg, level ?? "info"); } catch { // pi may complain about an unknown level; fall back to default. ctx.ui.notify(msg); } }, sddPackageRoot: SDD_PACKAGE_ROOT, onQuote: (text: string) => { // Close the overlay first, then push the quote into the // chat editor. Doing it in this order avoids a flash of // the quoted text appearing inside the overlay context. done(); try { ctx.ui.setEditorText(text); } catch { // If the editor isn't accepting input for some reason, // fall back to a notify so the user knows the selection // didn't get lost silently. ctx.ui.notify("spec cockpit: failed to insert quote into editor", "warning"); } }, }); // Live-refresh the file list while the pane is open so new edits // surface without needing to reopen. const unsub = state!.onChange(() => { pane.refreshFiles(); tui.requestRender(); }); // Cleanup on close: pi disposes the component but we still need // to unsubscribe from state. const origInvalidate = pane.invalidate.bind(pane); (pane as any).__unsub = unsub; return { render: (w: number) => pane.render(w), invalidate: () => origInvalidate(), handleInput: (data: string) => pane.handleInput(data), }; }, { overlay: true, overlayOptions: { // top-right so the pane starts flush against the top edge and // grows downward (instead of being vertically centered, which // leaves dead space on both sides of a tall pane). anchor: "top-right", width: "50%", minWidth: 60, maxHeight: "100%", margin: 0, }, }, ); } finally { paneOpen = false; } }; // Command name is `/specs` (plural), not `/spec` โ€” `/spec` is the // existing prompt for scaffolding a new spec and we must not shadow it. // See journal.md for the friction note that drove this rename. pi.registerCommand("specs", { description: "Open the SDD spec cockpit.", handler: async (_args, ctx) => { await openPane(ctx); }, }); // alt+s, not ctrl+shift+m: ctrl+m is the ASCII carriage return (== Enter) // in every terminal, so most terminals collapse ctrl+shift+m into Enter // and pi never sees a distinct key. See journal.md. pi.registerShortcut("alt+s", { description: "Open the SDD spec cockpit", handler: async (ctx) => { await openPane(ctx); }, }); }