diff --git a/README.md b/README.md index 8c55394..d696617 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,15 @@ registerCheck(def) ← checks/*.ts self-register on load module-level `Map` of `CheckDefinition`s. `index.ts` iterates it and binds one `/pygienium-` command per entry, so adding a check is a file + one `registerCheck()` line. +- **The footer** (`src/footer.ts`) is the piolium-style pipeline-overview + status strip: a single static line in the TUI footer (via + `ui.setStatus(key, text)`) listing the full ordered pipeline with the cursor + on the current phase and what's to come. For a single check the items are + the phases; for `/pygienium-all` they're the checks (the full todo list), + and the per-check footer is suppressed so two overviews never compete over + the same status slot. The chat widget (`phases.ts`) remains the animated + detail view (spinner + tool-call tree + completion tree); the footer is the + overview — the two never overlap. In print/JSON mode the footer is a no-op. ## Layout @@ -162,12 +171,13 @@ pygienium/ │ ├─ run-state.ts ← persistent, resumable run-state model │ ├─ status.ts ← /pygienium-status formatter (pure) │ ├─ export.ts ← /pygienium-export gatherer + md/json renderer -│ ├─ phases.ts ← footer status-strip UI adapter +│ ├─ phases.ts ← live chat progress widget + completion-tree helpers +│ ├─ footer.ts ← pipeline-overview status strip (TUI footer) │ ├─ modes/check-runner.ts ← the per-check phase pipeline │ └─ checks/ ← one file per check, self-registering │ ├─ registry.ts ← CheckDefinition + registerCheck │ ├─ comments.ts deep-modules.ts dead-code.ts -│ ├─ defensive-guards.ts noop.ts +│ ├─ defensive-guards.ts └─ agents/ ← scanner.md fixer.md deep-modules.md defensive-guards.md ``` diff --git a/src/agent-runner.ts b/src/agent-runner.ts index a2ac6a0..e66f531 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -18,7 +18,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; -import { loadAgents, type AgentDef } from "./agents.js"; +import type { ToolCallEntry } from "./phases.js"; export interface AgentTaskOptions { /** Absolute working directory for the sub-agent. */ @@ -31,6 +31,8 @@ export interface AgentTaskOptions { allowedTools?: string[]; /** Optional explicit agent definition (skips `loadAgents`). */ agent?: AgentDef; + /** Live callback fired as each tool call starts (ralpi-style stream). */ + onToolCall?: (entry: ToolCallEntry) => void; } export interface AgentRunResult { @@ -40,6 +42,8 @@ export interface AgentRunResult { text: string; /** Error message when `ok` is false. */ error?: string; + /** Every tool invocation captured during the run (name + label). */ + toolCalls: ToolCallEntry[]; } export type AgentRunner = (opts: AgentTaskOptions) => Promise; @@ -112,6 +116,7 @@ export async function defaultAgentRunner( try { let text = ""; + const toolCalls: ToolCallEntry[] = []; const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if ( event.type === "message_update" && @@ -119,15 +124,24 @@ export async function defaultAgentRunner( ) { text += event.assistantMessageEvent.delta; } + if (event.type === "tool_execution_start") { + const entry: ToolCallEntry = { + name: event.toolName, + label: formatToolArg(event.toolName, event.args), + }; + toolCalls.push(entry); + opts.onToolCall?.(entry); + } }); await session.prompt(opts.task, { expandPromptTemplates: false }); unsubscribe(); - return { ok: true, text }; + return { ok: true, text, toolCalls }; } catch (err) { return { ok: false, text: "", error: err instanceof Error ? err.message : String(err), + toolCalls: [], }; } finally { try { @@ -170,12 +184,57 @@ export const fakeAgentRunner: AgentRunner = async (opts) => { findings.push((echo[1] ?? "").replace(/^["']|["']$/g, "")); } } - return { ok: true, text: findings.join("\n") }; + return { ok: true, text: findings.join("\n"), toolCalls: [] }; } catch (err) { return { ok: false, text: findings.join("\n"), error: err instanceof Error ? err.message : String(err), + toolCalls: [], }; } }; + +// ─── Tool-call label formatting (ported from ralpi) ───────────────────────────── + +/** Collapse newlines and strip control chars so a tool arg fits one line. */ +function sanitizeLabel(s: string): string { + return s + .replace(/\r?\n/g, " ") + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") + .trim(); +} + +/** Keep the start and end of a long value, inserting an ellipsis in the middle. */ +function truncateMiddle(s: string, maxLen: number): string { + if (s.length <= maxLen) return s; + const half = Math.floor((maxLen - 1) / 2); + return s.slice(0, half) + "…" + s.slice(s.length - half); +} + +/** + * Format a tool call's arguments into a short one-line label, by tool name. + * Mirrors ralpi's `formatToolArg` so the chat tree shows the useful argument + * (command for bash, path for read/write/edit, pattern for grep, …). + */ +export function formatToolArg(name: string, args: unknown): string { + const a = (args ?? {}) as Record; + switch (name) { + case "bash": + return sanitizeLabel(truncateMiddle(String(a.command ?? ""), 70)); + case "write": + case "read": + case "edit": + return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60)); + case "grep": + return sanitizeLabel( + `${a.pattern ?? "?"} — ${truncateMiddle(String(a.path ?? ""), 40)}`, + ); + case "find": + return sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`); + case "ls": + return sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60)); + default: + return name; + } +} diff --git a/src/commands.ts b/src/commands.ts index 3db4aa6..bbf9444 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -9,10 +9,7 @@ * @module pygienium/commands */ -import type { - ExtensionCommandContext, - ExtensionUIContext, -} from "@earendil-works/pi-coding-agent"; +import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { resolve } from "node:path"; import { getAllChecks, @@ -20,6 +17,9 @@ import { type CheckDefinition, } from "./checks/registry.js"; import { runCheck, parseCheckArgs } from "./modes/check-runner.js"; +import { + runCheck, + parseCheckArgs, import { parseAllArgs, runAllChecks, allSummaryPath } from "./modes/all.js"; import { buildPygieniumHelpLines } from "./help.js"; import { @@ -38,12 +38,16 @@ import { exportBundlePath, type ExportFilters, } from "./export.js"; +import type { SendChatMessage } from "./phases.js"; /** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */ export type PygieniumCtx = Pick< ExtensionCommandContext, "cwd" | "mode" | "hasUI" | "ui" ->; +> & { + /** Optional callback to post messages to the chat window. */ + sendChatMessage?: SendChatMessage; +}; function print(ctx: PygieniumCtx, line: string): void { // In TUI mode, also surface the first line as a notification. @@ -162,6 +166,7 @@ export async function handleAllCommand( only: parsed.only, ui: ctx.ui, hasUI: ctx.hasUI, + sendChatMessage: ctx.sendChatMessage, }); const giNote = outcome.gitignoreAppended ? " · .pygienium/ added to .gitignore" @@ -341,6 +346,3 @@ export function registerPygieniumCommands(register: RegisterCommandFn): void { handler: handleExportCommand, }); } - -/** Re-export for index.ts convenience. */ -export type { ExtensionUIContext }; diff --git a/src/footer.ts b/src/footer.ts new file mode 100644 index 0000000..897f6ce --- /dev/null +++ b/src/footer.ts @@ -0,0 +1,173 @@ +/** + * footer.ts — compact pipeline-overview status line in the TUI footer. + * + * While the chat widget (phases.ts) shows the live spinner + tool-call tree + * (detail, surfaced in the chat), the footer shows one STATIC overview line: + * the full ordered pipeline (the checks and/or phases) with the current + * position and what's to come — the piolium-style footer strip. The two + * never overlap: the footer stays a single status-bar slot owned by + * `ui.setStatus(key, text)`. + * + * Presentation-only and mode-aware: in print/JSON mode (no TUI) the footer is + * a no-op — stdout progress stays owned by the phase strip — so it can be + * driven unconditionally from the runners. + * + * Generic on a list of {@link FooterItem}s so both a single-check run + * (items = phases) and a `/pygienium-all` run (items = checks) reuse one + * renderer: the runner decides the granularity, the footer only draws it. + * + * @module pygienium/footer + */ + +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; + +/** Footer/status-bar key pygienium writes its pipeline overview under. */ +export const FOOTER_STATUS_KEY = "pygienium"; + +/** Status of a single pipeline item, carried into the footer line. */ +export type ItemStatus = + | "pending" + | "running" + | "complete" + | "failed" + | "skipped"; + +/** + * Glyph per item status. `…`=pending (to come), `▶`=running (cursor), + * `✓`/`✗`/`-`=terminal. Kept short so a multi-phase pipeline fits one line. + */ +export const FOOTER_GLYPH: Record = { + pending: "…", + running: "▶", + complete: "✓", + failed: "✗", + skipped: "-", +}; + +/** One labelled step in the pipeline overview. */ +export interface FooterItem { + /** Short label (a phase label like "Scanning" or a check label). */ + label: string; + /** Current status of this step. */ + status: ItemStatus; +} + +export interface PipelineFooterOptions { + /** UI context; writes go to `ui.setStatus`. */ + ui?: ExtensionUIContext; + /** Dialog-capable UI available (TUI / RPC). When false, footer is a no-op. */ + hasUI?: boolean; + /** Status-bar key (defaults to {@link FOOTER_STATUS_KEY}). */ + statusKey?: string; + /** + * Whether to render the footer (default true). Set false when an outer + * run (e.g. `/pygienium-all`) already owns the footer, so two overviews + * never compete over the same status slot — mirrors the phase strip's + * `widget` flag. + */ + enabled?: boolean; +} + +export interface PipelineFooter { + /** Declare the full pipeline at once; `cursor` (if given) marks `running`. */ + setPipeline(title: string, items: FooterItem[], cursor?: number): void; + /** Set a single item's status; optionally move the cursor too. */ + setItem(index: number, status: ItemStatus, cursor?: number): void; + /** Advance the cursor to an item (marks it `running`). */ + setCursor(index: number): void; + /** Snapshot of current items (for tests — no UI required). */ + getItems(): FooterItem[]; + /** Snapshot of the last rendered title (for tests). */ + getTitle(): string; + /** Clear the footer slot. Safe to call repeatedly. */ + done(): void; +} + +/** + * Create a pipeline-overview footer. The handle is cheap and stateful; callers + * keep one per run and call {@link PipelineFooter.done} when terminal. + */ +export function createPipelineFooter( + opts: PipelineFooterOptions, +): PipelineFooter { + const key = opts.statusKey ?? FOOTER_STATUS_KEY; + const ui = opts.ui; + const enabled = opts.enabled ?? true; + const hasUI = opts.hasUI ?? false; + + let title = ""; + let items: FooterItem[] = []; + let cursor = -1; + + /** Build and push the single status line, if a UI is available. */ + function render(): void { + if (!enabled || !hasUI) return; + const parts = items.map((item, i) => { + const glyph = FOOTER_GLYPH[item.status] ?? "?"; + // The live cursor gets a space after the glyph for emphasis; the + // rest stay glued (`…Fixing`) so a long pipeline stays compact. + return i === cursor ? `${glyph} ${item.label}` : `${glyph}${item.label}`; + }); + const text = parts.length > 0 ? `${title} · ${parts.join(" · ")}` : title; + ui?.setStatus?.(key, text); + } + + return { + setPipeline(t, its, cur) { + title = t; + items = its.map((it) => ({ ...it })); + cursor = cur ?? -1; + if (cursor >= 0 && items[cursor]) { + items[cursor]!.status = "running"; + } + render(); + }, + setItem(index, status, cur) { + if (index < 0 || index >= items.length) return; + items[index]!.status = status; + if (cur !== undefined) cursor = cur; + render(); + }, + setCursor(index) { + if (index < 0 || index >= items.length) return; + // A previously-running item that didn't reach a terminal status + // (e.g. the runner jumped phases on a skip) demotes back to + // pending so it reads as "to come" rather than stalled. + if (cursor >= 0 && items[cursor]?.status === "running") { + items[cursor]!.status = "pending"; + } + cursor = index; + items[index]!.status = "running"; + render(); + }, + getItems() { + return items.map((it) => ({ ...it })); + }, + getTitle() { + return title; + }, + done() { + if (!enabled || !hasUI) return; + ui?.setStatus?.(key, undefined); + items = []; + cursor = -1; + title = ""; + }, + }; +} + +/** + * Build footer items for a single check's phase list. The runner feeds it the + * ordered phase ids (recon → analysis → [fix] → verify → cleanup) and the + * shared {@link PHASE_LABELS}-shaped map; the footer draws them as the + * pipeline overview. + */ +export function footerPhaseItems( + phaseIds: readonly string[], + labels: Record, +): FooterItem[] { + return phaseIds.map((id) => ({ + label: labels[id] ?? id, + status: "pending" as ItemStatus, + })); +} diff --git a/src/index.ts b/src/index.ts index 2462194..15575aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,15 +21,45 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, + ExtensionCommandContext, ExtensionContext, + MessageRenderer, SessionStartEvent, } from "@earendil-works/pi-coding-agent"; -import { registerPygieniumCommands } from "./commands.js"; +import { Box, Text } from "@earendil-works/pi-tui"; +import { registerPygieniumCommands, type PygieniumCtx } from "./commands.js"; +import { + type SendChatMessage, + type CheckCompletionDetails, + type ToolCallEntry, + PHASE_GLYPH, +} from "./phases.js"; /** Startup hint mirrored after piolium's convention. */ export const PYGIENIUM_STARTUP_HINT = "Pygienium loaded. Run /pygienium-help for available checks and flags."; +/** + * Local structural supertypes for the progress-message renderer params. + * These avoid relying on contextual typing from `MessageRenderer` (which + * requires resolving pi's internal `Theme`/`CustomMessage` cross-references + * via node_modules — not always available in dev environments). Using + * `(...args: any[])` for theme methods makes the type bidirectionally + * compatible under strict function types, so the cast to `MessageRenderer` + * in `registerMessageRenderer` is valid. + */ +interface ProgressMessage { + content: unknown; + details?: unknown; +} +interface ProgressRenderOptions { + expanded: boolean; +} +interface ProgressTheme { + fg: (...args: any[]) => string; + bg: (...args: any[]) => string; +} + export { buildPygieniumHelpLines } from "./help.js"; /** @@ -53,16 +83,142 @@ async function loadCheckModules(): Promise { } } +/** Max tool calls shown collapsed in a chat message (matches ralpi). */ +const RENDERER_MAX_COLLAPSED = 3; + +/** + * Create a callback to send messages to the main chat window. + */ +function makeSendChatMessage(pi: ExtensionAPI): SendChatMessage { + return (content: string, meta?: Record) => { + pi.sendMessage({ + customType: "pygienium-progress", + content, + display: true, + details: { + phase: meta?.phase || "info", + ...meta, + }, + }); + }; +} + export default async function pygieniumExtension( pi: ExtensionAPI, ): Promise { // Self-register every shipped check before wiring commands. await loadCheckModules(); + const sendChatMessage = makeSendChatMessage(pi); + + // Register custom message renderer for pygienium progress messages. + // Renders an expandable phase tree: collapsed shows the header + a hint, + // expanded (Ctrl+O) shows every phase with its status and notes. + const progressRenderer = ( + message: ProgressMessage, + { expanded }: ProgressRenderOptions, + theme: ProgressTheme, + ) => { + const details = message.details as + | { + phase?: string; + completion?: CheckCompletionDetails; + toolCalls?: ToolCallEntry[]; + error?: string; + } + | undefined; + + const lines: string[] = []; + lines.push(String(message.content)); + + const completion = details?.completion; + const toolCalls = details?.toolCalls; + if (completion) { + if (expanded) { + // Expanded: show every phase with status glyph + branch. + const phases = completion.phases; + for (let i = 0; i < phases.length; i++) { + const entry = phases[i]; + if (!entry) continue; + const isLast = i === phases.length - 1; + const branch = isLast ? " └── " : " ├── "; + const glyph = PHASE_GLYPH[entry.status] ?? "?"; + const tag = theme.fg("accent", entry.label); + const note = entry.note ? ` · ${entry.note}` : ""; + lines.push(`${branch}${glyph} ${tag}${note}`); + } + if (completion.error) { + lines.push(theme.fg("error", ` error: ${completion.error}`)); + } + } else { + // Collapsed: summary line + hint. + const done = completion.phases.filter( + (p) => p.status === "complete", + ).length; + const total = completion.phases.length; + const hint = completion.error + ? theme.fg("error", ` ├── ${completion.error}`) + : theme.fg( + "dim", + ` ├── ${done}/${total} phases · press Ctrl+O for detail`, + ); + lines.push(hint); + } + } else if (toolCalls && toolCalls.length > 0) { + const all = toolCalls; + if (expanded) { + for (let i = 0; i < all.length; i++) { + const entry = all[i]!; + const isLast = i === all.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = theme.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } else { + const shown = all.slice(-RENDERER_MAX_COLLAPSED); + const remaining = all.length - shown.length; + if (remaining > 0) { + lines.push(theme.fg("dim", ` ├── ${remaining} more`)); + } + for (let i = 0; i < shown.length; i++) { + const entry = shown[i]!; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = theme.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } + if (details?.error) { + lines.push(theme.fg("error", ` error: ${details.error}`)); + } + } else if (!expanded) { + lines.push(theme.fg("dim", " ├── press Ctrl+O for detail")); + } + + const text = lines.join("\n"); + const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t)); + box.addChild(new Text(text, 0, 0)); + return box; + }; + pi.registerMessageRenderer( + "pygienium-progress", + progressRenderer as MessageRenderer, + ); + registerPygieniumCommands((name, options) => { pi.registerCommand(name, { description: options.description, - handler: options.handler, + handler: (args: string, ctx: ExtensionCommandContext) => { + // Create PygieniumCtx with sendChatMessage callback + const pygieniumCtx: PygieniumCtx = { + cwd: ctx.cwd, + mode: ctx.mode, + hasUI: ctx.hasUI, + ui: ctx.ui, + sendChatMessage, + }; + return options.handler(args, pygieniumCtx); + }, }); }); diff --git a/src/modes/all.ts b/src/modes/all.ts index 9e28855..9dfa877 100644 --- a/src/modes/all.ts +++ b/src/modes/all.ts @@ -25,7 +25,8 @@ import { dirname, join, resolve } from "node:path"; import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import { getAllChecks, type CheckDefinition } from "../checks/registry.js"; import { runCheck } from "./check-runner.js"; -import { createPhaseStrip } from "../phases.js"; +import { createPhaseStrip, type SendChatMessage } from "../phases.js"; +import { createPipelineFooter, type ItemStatus } from "../footer.js"; import { runRecon } from "../recon.js"; import { applyPhaseStatus, @@ -292,6 +293,25 @@ export async function runAllChecks( }); setAllPhase(strip, selected, 0, "recon"); + // Pipeline-overview footer: one static line listing every check with the + // cursor on the active one and what's to come. Detail lives in the chat + // (per-check completion trees); the footer is the overview. Inner + // `runCheck` calls pass `footer: false` so two overviews never compete. + const allFooter = createPipelineFooter({ + ui: opts.ui, + hasUI, + statusKey: "pygienium-all", + }); + const allItems = selected.map((c) => ({ + label: c.label, + status: "pending" as ItemStatus, + })); + allFooter.setPipeline("pygienium: all", allItems, 0); + const footerSettle = (name: string, status: ItemStatus): void => { + const idx = selected.findIndex((c) => c.name === name); + if (idx >= 0) allFooter.setItem(idx, status); + }; + // --- Shared recon (run once before any check) --------------------------- if (!state.recon.complete) { const snapshot = await runRecon(cwd); @@ -314,11 +334,16 @@ export async function runAllChecks( for (let i = 0; i < selected.length; i++) { const check = selected[i]!; setAllPhase(strip, selected, i, "analysis"); + allFooter.setCursor(i); const entry = state.checks[check.name]; // Resumability: skip terminal checks unless --fresh. if (entry && !shouldRunOnResume(entry, fresh)) { skipped.push(check.name); + footerSettle( + check.name, + entry.status === "complete" ? "skipped" : "skipped", + ); strip.log( `pygienium: ${check.label} — already ${entry.status}, skipping`, ); @@ -338,10 +363,30 @@ export async function runAllChecks( ui: opts.ui, hasUI, existingState: state, + sendChatMessage: opts.sendChatMessage, + // The unified `pygienium-all` strip already surfaces this check's + // phase; suppress the per-check widget so two animated spinners + // never compete over the same widget area. + widget: false, + // The all-run footer already owns the pipeline-overview status + // slot; suppress the per-check footer so two overviews never + // compete over the same status line. + footer: false, + // Inner runs must not prime the .gitignore twice — the outer all-run + // already ensured it. (ensureRunStateIgnored is memoized per cwd, + // so this is belt-and-braces.) gitignore: opts.gitignore, }); state = outcome.state; ran.push(check.name); + footerSettle( + check.name, + outcome.status === "complete" + ? "complete" + : outcome.status === "skipped" + ? "skipped" + : "failed", + ); strip.log(`pygienium ${check.label}: ${outcome.status}`); } @@ -350,7 +395,18 @@ export async function runAllChecks( await saveRunState(state); setAllPhase(strip, selected, selected.length - 1, "cleanup"); + // Mark the final check's footer status terminal; the cursor started at 0 + // and the loop advanced it, so the last selected item is the live one. + if (selected.length > 0) { + footerSettle( + selected[selected.length - 1]!.name, + state.checks[selected[selected.length - 1]!.name]?.status === "failed" + ? "failed" + : "complete", + ); + } strip.done(); + allFooter.done(); const summaryPath = await writeAllSummary(state, selected); diff --git a/src/modes/check-runner.ts b/src/modes/check-runner.ts index 9424cfa..9c394dc 100644 --- a/src/modes/check-runner.ts +++ b/src/modes/check-runner.ts @@ -21,9 +21,19 @@ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import type { CheckDefinition, CheckScope } from "../checks/registry.js"; import { runAgentTask } from "../agent-runner.js"; import { runRecon } from "../recon.js"; -import { createPhaseStrip } from "../phases.js"; +import { + createPhaseStrip, + type SendChatMessage, + type CheckCompletionDetails, + type ToolCallEntry, + type PhaseLogEntry, + type PhaseLogStatus, + PHASE_LABELS, +} from "../phases.js"; +import { createPipelineFooter, footerPhaseItems } from "../footer.js"; import { applyPhaseStatus, + ensureRunStateIgnored, initRunState, loadRunState, markCheckStatus, @@ -41,12 +51,6 @@ import { type RunState, } from "../run-state.js"; -/** Parsed command args handed to a `/pygienium-` handler. */ -export interface CheckRunnerArgs { - /** Raw arg string from the slash command. */ - raw: string; -} - /** Resolve a raw arg string into a check scope (target path + flags). */ export function parseCheckArgs(raw: string, cwd: string): CheckScope { const tokens = raw.trim().length > 0 ? raw.trim().split(/\s+/) : []; @@ -82,6 +86,24 @@ export interface RunCheckOptions { hasUI?: boolean; /** Pre-existing run state to update (for `/pygienium-all` and resume). */ existingState?: RunState; + /** Optional callback to post completion messages into the chat. */ + sendChatMessage?: SendChatMessage; + /** + * Render the per-check live widget (default true). Set false when an outer + * strip (e.g. `/pygienium-all`) already shows this check's phase, so two + * spinners don't fight over the widget area. + */ + widget?: boolean; + /** + * Render the pipeline-overview footer status line (default true). Set false + * when an outer run (e.g. `/pygienium-all`) already owns the footer, so two + * overviews never compete over the same status slot. + */ + footer?: boolean; + /** + * Ensure `/.gitignore` excludes `.pygienium/` before this run writes + * state/artifacts (default true). Set false with `--no-gitignore`. + */ gitignore?: boolean; } @@ -89,6 +111,7 @@ export interface RunCheckOptions { export interface CheckRunOutcome { /** Final check status. */ status: "complete" | "failed" | "skipped"; + /** True when this run appended `.pygienium/` to the repo's .gitignore. */ gitignoreAppended?: boolean; /** Findings text from the analysis phase. */ findings?: string; @@ -101,13 +124,33 @@ export interface CheckRunOutcome { } /** - * Run a single check end-to-end, persisting progress to run-state. + * Run a single check end-to-end, persisting progress to run-state, and post a + * ralpi-style completion message into the chat (header + expandable phase + * tree) when a `sendChatMessage` callback is supplied. * * Resumable: if `existingState` already has terminal-ish progress for this * check, the runner resumes the last in-progress phase rather than restarting. */ export async function runCheck( opts: RunCheckOptions, +): Promise { + const startMs = Date.now(); + const outcome = await runCheckImpl(opts); + postCheckCompletion(opts, outcome, Date.now() - startMs); + return outcome; +} + +async function runCheckImpl(opts: RunCheckOptions): Promise { + // Keep pygienium's own output out of the scanned repo's git index unless + // the caller opted out with --no-gitignore. + const gitignoreAppended = + opts.gitignore === false ? false : await ensureRunStateIgnored(opts.cwd); + const outcome = await runCheckImplInner(opts); + return { ...outcome, gitignoreAppended }; +} + +async function runCheckImplInner( + opts: RunCheckOptions, ): Promise { const { check, cwd } = opts; const scope = opts.scope ?? parseCheckArgs(opts.rawArgs ?? "", cwd); @@ -130,8 +173,35 @@ export async function runCheck( ui: opts.ui, hasUI: opts.hasUI ?? false, checkLabel: check.label, + widget: opts.widget, }); + // Pipeline-overview footer: a static one-line view of the full phase list + // with the cursor on the current phase and what's to come. Detail lives in + // the chat (phase strip + completion tree); the footer is the overview. + // Disabled (no-op) when an outer run owns the footer, e.g. /pygienium-all. + const phaseIds = ( + state.checks[check.name]?.phases ?? phasesForCheck(scope.fix) + ).map((p) => p.id); + const footerIdx = new Map(phaseIds.map((id, i) => [id, i] as const)); + const footer = createPipelineFooter({ + ui: opts.ui, + hasUI: opts.hasUI ?? false, + enabled: opts.footer ?? true, + }); + footer.setPipeline( + `pygienium ${check.label}`, + footerPhaseItems(phaseIds, PHASE_LABELS), + ); + const footerEnter = (phaseId: string): void => { + const i = footerIdx.get(phaseId); + if (i !== undefined) footer.setCursor(i); + }; + const footerComplete = (phaseId: string): void => { + const i = footerIdx.get(phaseId); + if (i !== undefined) footer.setItem(i, "complete"); + }; + let findings = ""; let changes = ""; let error: string | undefined; @@ -144,6 +214,7 @@ export async function runCheck( for (const phase of state.checks[check.name]?.phases ?? []) { if (phase.status === "pending") phase.status = "skipped"; } + for (let i = 0; i < phaseIds.length; i++) footer.setItem(i, "skipped"); markCheckStatus(state, check.name, "skipped", gateResult); markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); @@ -155,6 +226,7 @@ export async function runCheck( // --- Phase: recon (shared, run once per run) ------------------------- if (!state.recon.complete) { strip.setPhase(PHASE_RECON); + footerEnter(PHASE_RECON); applyPhaseStatus(state, check.name, PHASE_RECON, "in_progress"); await saveRunState(state); const snapshot = await runRecon(cwd); @@ -165,21 +237,34 @@ export async function runCheck( }; applyPhaseStatus(state, check.name, PHASE_RECON, "complete"); await saveRunState(state); + footerComplete(PHASE_RECON); } else { // Recon already done this run — mark this check's recon complete. applyPhaseStatus(state, check.name, PHASE_RECON, "complete"); + footerComplete(PHASE_RECON); } // --- Phase: analysis ------------------------------------------------- strip.setPhase(PHASE_ANALYSIS); + footerEnter(PHASE_ANALYSIS); applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "in_progress"); await saveRunState(state); const scanTask = await check.buildScanTask(cwd, scope); + const scanStartMs = Date.now(); const scanResult = await runAgentTask({ cwd: scope.target, agentName: check.agentName, task: scanTask, + onToolCall: (entry) => strip.pushToolCall(entry), }); + postAgentToolCalls( + opts, + PHASE_ANALYSIS, + scanResult.toolCalls, + scanResult.ok, + Date.now() - scanStartMs, + scanResult.error, + ); findings = scanResult.text; recordCheckOutput(state, check.name, { findings }); if (!scanResult.ok) { @@ -197,18 +282,30 @@ export async function runCheck( } applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "complete"); await saveRunState(state); + footerComplete(PHASE_ANALYSIS); // --- Phase: fix (only with --fix) ----------------------------------- if (scope.fix) { strip.setPhase(PHASE_FIX); + footerEnter(PHASE_FIX); applyPhaseStatus(state, check.name, PHASE_FIX, "in_progress"); await saveRunState(state); const fixTask = await check.buildFixTask(cwd, scope, findings); + const fixStartMs = Date.now(); const fixResult = await runAgentTask({ cwd: scope.target, agentName: check.fixAgentName ?? "fixer", task: fixTask, + onToolCall: (entry) => strip.pushToolCall(entry), }); + postAgentToolCalls( + opts, + PHASE_FIX, + fixResult.toolCalls, + fixResult.ok, + Date.now() - fixStartMs, + fixResult.error, + ); changes = fixResult.text; recordCheckOutput(state, check.name, { changes }); if (!fixResult.ok) { @@ -232,10 +329,12 @@ export async function runCheck( } applyPhaseStatus(state, check.name, PHASE_FIX, "complete"); await saveRunState(state); + footerComplete(PHASE_FIX); } // --- Phase: verify --------------------------------------------------- strip.setPhase(PHASE_VERIFY); + footerEnter(PHASE_VERIFY); applyPhaseStatus(state, check.name, PHASE_VERIFY, "in_progress"); await saveRunState(state); // Verify is a lightweight self-check. A check may supply a dedicated @@ -260,13 +359,16 @@ export async function runCheck( } applyPhaseStatus(state, check.name, PHASE_VERIFY, "complete"); await saveRunState(state); + footerComplete(PHASE_VERIFY); // --- Phase: cleanup -------------------------------------------------- strip.setPhase(PHASE_CLEANUP); + footerEnter(PHASE_CLEANUP); applyPhaseStatus(state, check.name, PHASE_CLEANUP, "in_progress"); await saveRunState(state); await cleanupTransientArtifacts(cwd, check.name); applyPhaseStatus(state, check.name, PHASE_CLEANUP, "complete"); + footerComplete(PHASE_CLEANUP); markCheckStatus(state, check.name, "complete"); markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); @@ -280,9 +382,144 @@ export async function runCheck( return { status: "failed", error, findings, changes, state }; } finally { strip.done(); + footer.done(); } } +/** Map a run-state `PhaseStatus` to a completion-log status. */ +function phaseLogStatus(status: string | undefined): PhaseLogStatus { + switch (status) { + case "complete": + return "complete"; + case "failed": + return "failed"; + case "skipped": + return "skipped"; + default: + return "running"; + } +} + +/** Glyph for a check's terminal status. */ +function statusGlyph(status: CheckRunOutcome["status"]): string { + switch (status) { + case "complete": + return "✓"; + case "failed": + return "✗"; + default: + return "-"; + } +} + +/** Count non-empty lines in captured findings/changes text. */ +function lineCount(text: string | undefined): number { + if (!text) return 0; + return text.split(/\r?\n/).filter((l) => l.trim().length > 0).length; +} + +/** Format a duration in milliseconds as `1m 2s` / `5s` / `320ms`. */ +function formatDuration(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 1) return `${ms}ms`; + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return rem ? `${m}m ${rem}s` : `${m}m`; +} + +/** Build the expandable phase tree carried in the completion message. */ +function buildPhaseLog( + state: RunState, + checkName: string, + findings?: string, + changes?: string, +): PhaseLogEntry[] { + const phases = state.checks[checkName]?.phases ?? []; + return phases.map((p) => { + const entry: PhaseLogEntry = { + id: p.id, + label: PHASE_LABELS[p.id] ?? p.id, + status: phaseLogStatus(p.status), + }; + if (p.id === PHASE_ANALYSIS && findings) { + entry.note = `findings: ${lineCount(findings)} lines`; + } else if (p.id === PHASE_FIX && changes) { + entry.note = `changes: ${lineCount(changes)} lines`; + } else if (p.error) { + entry.note = p.error; + } + return entry; + }); +} + +/** + * Post a single ralpi-style completion message (header + phase tree) into the + * chat via `sendChatMessage`. No-op when no callback is wired (print/json + * modes). Mirrors ralpi's per-loop completion message. + */ +function postCheckCompletion( + opts: RunCheckOptions, + outcome: CheckRunOutcome, + durationMs: number, +): void { + const send = opts.sendChatMessage; + if (!send) return; + const check = opts.check; + const status = outcome.status; + const glyph = statusGlyph(status); + const fix = Boolean( + (opts.scope ?? parseCheckArgs(opts.rawArgs ?? "", opts.cwd)).fix, + ); + const fixTag = fix ? " --fix" : ""; + const header = `${glyph} pygienium ${check.label}${fixTag} · ${status} (${formatDuration(durationMs)})`; + + const details: CheckCompletionDetails = { + checkLabel: check.label, + status, + fix, + durationMs, + phases: buildPhaseLog( + outcome.state, + check.name, + outcome.findings, + outcome.changes, + ), + error: outcome.error, + }; + + send(header, { phase: "complete", completion: details }); +} + +/** + * Post a ralpi-style per-agent-execution message into the chat: header + the + * tool-call tree captured during that sub-process (analysis or fix). One + * message per agent run, so tool calls are broken down by which execution + * produced them. No-op when no callback is wired or the run made no calls. + */ +function postAgentToolCalls( + opts: RunCheckOptions, + phaseId: string, + toolCalls: ToolCallEntry[], + okc: boolean, + durationMs: number, + error?: string, +): void { + const send = opts.sendChatMessage; + if (!send) return; + if (toolCalls.length === 0) return; + const glyph = okc ? "✓" : "✗"; + const label = (PHASE_LABELS[phaseId] ?? phaseId).toLowerCase(); + const dur = formatDuration(durationMs); + const header = `${glyph} pygienium ${opts.check.label} · ${label} (${dur})`; + send(header, { + phase: "agent", + phaseId, + toolCalls, + error: okc ? undefined : error, + }); +} + /** * Remove transient per-check scratch artifacts (e.g. agent-extracted * manifests) written under `/.pygienium/-tmp-*`. Findings and diff --git a/src/phases.ts b/src/phases.ts index d618a2e..3e4e3cf 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -1,18 +1,62 @@ /** - * phases.ts — phase-strip status UI helper. + * phases.ts — live progress widget + completion-message helpers. * - * Renders the active phase of a check run into pi's footer status bar and - * forwards plain-text progress lines to stdout (so `print` mode `-p` also - * shows progress). The strip is a small, self-contained adapter over - * `ExtensionUIContext.setStatus` — simplified from piolium's phase-strip - * command UI to the subset pygienium needs: a status key, the current phase, - * and a clear on completion. + * Renders the active phase of a check run as a **live widget in the chat + * area** (via `ExtensionUIContext.setWidget`): an animated spinner + header + * line naming the check and current phase. This is the pygienium analogue of + * ralpi's per-loop progress widget — the chat-side *detail* view. + * + * The *overview* view is the footer status strip (see `footer.ts`): a single + * static line listing the full pipeline with the cursor and what's to come. + * The two never overlap: the chat widget is the animated detail, the footer + * is the static overview. + * + * In print/JSON modes (no TUI) the same lines are forwarded to stdout behind + * an async lock so parallel checks don't interleave. + * + * The strip also accumulates a phase log (`getPhaseLog`) that the check-runner + * turns into the expandable completion message rendered by + * `registerMessageRenderer("pygienium-progress")` in `index.ts`. * * @module pygienium/phases */ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; +/** A single tool invocation captured from a sub-agent session, for display. */ +export interface ToolCallEntry { + /** Tool name (e.g. "bash", "read", "edit"). */ + name: string; + /** Short human-readable label derived from the call's arguments. */ + label: string; +} + +/** Callback to post a message into the chat history (see `index.ts` renderer). */ +export type SendChatMessage = ( + content: string, + /** Extra data passed to the message renderer (toolCalls, completion, …). */ + meta?: { + phase?: string; + /** Tool calls captured during this agent execution (ralpi-style tree). */ + toolCalls?: ToolCallEntry[]; + [meta: string]: unknown; + }, +) => void; + +/** Braille spinner frames (matches ralpi's loop widget). */ +export const SPINNER_FRAMES = [ + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", +] as const; + /** Phase display metadata for a check run's phases. */ export const PHASE_LABELS: Record = { recon: "Recon", @@ -22,62 +66,227 @@ export const PHASE_LABELS: Record = { cleanup: "Cleaning up", }; +/** Status of a single phase as recorded in the completion log. */ +export type PhaseLogStatus = "running" | "complete" | "failed" | "skipped"; + +/** One phase entry carried into the completion message's `details.phases`. */ +export interface PhaseLogEntry { + /** Phase id (e.g. "analysis"). */ + id: string; + /** Human-readable label (e.g. "Scanning"). */ + label: string; + /** Terminal/running status. */ + status: PhaseLogStatus; + /** Optional note shown on the branch (e.g. "findings: 12 lines"). */ + note?: string; +} + export interface PhaseStripOptions { - /** Footer status key (defaults to "pygienium"). */ + /** Widget key (defaults to "pygienium"). */ statusKey?: string; /** Check label shown alongside the phase, e.g. "comments". */ checkLabel?: string; /** Whether dialog-capable UI is available. */ hasUI?: boolean; - /** UI context to drive the footer status bar. */ + /** UI context to drive the live chat widget. */ ui?: ExtensionUIContext; + /** + * Render the live progress widget (default true). Set false when an outer + * strip (e.g. the `/pygienium-all` unified strip) already surfaces the same + * phase, so two spinners never fight over the widget area. + */ + widget?: boolean; } /** - * A handle that renders phase progress and clears on completion. Created by - * {@link createPhaseStrip}; pass the result to the check-runner. + * A handle that renders a live progress widget and clears on completion. + * Created by {@link createPhaseStrip}; the check-runner drives it. */ export interface PhaseStrip { - /** Set the current phase id (e.g. "analysis"). */ + /** Set the current phase (id or a pre-rendered header string). */ setPhase(phaseId: string): void; - /** Append a plain-text progress line (forwarded to stdout). */ + /** Annotate the most recent phase (e.g. "findings: 12 lines"). */ + setPhaseNote(note: string): void; + /** Record a tool call from the active sub-agent (live widget tree). */ + pushToolCall(entry: ToolCallEntry): void; + /** Drop the tool-call log (e.g. when moving to a fresh sub-agent). */ + clearToolCalls(): void; + /** Append a plain-text progress line (forwarded to stdout in print mode). */ log(line: string): void; - /** Clear the footer status bar. Call once the run is terminal. */ + /** Snapshot of phase transitions for the completion message. */ + getPhaseLog(): PhaseLogEntry[]; + /** Clear the live widget. Call once the run is terminal. */ done(): void; } -/** Create a phase-strip UI adapter. */ +/** + * Simple write lock for stdout in print mode to prevent interleaved output + * from parallel checks. + */ +let stdoutLock: Promise = Promise.resolve(); + +/** Acquire the stdout write lock and execute the write function. */ +async function withStdoutLock(fn: () => void): Promise { + const prev = stdoutLock; + stdoutLock = prev.then(() => { + fn(); + return Promise.resolve(); + }); + return stdoutLock; +} + +/** Widget container width budget (account for widget padding). */ +const WIDGET_WIDTH = 78; + +/** Max tool calls shown in a live widget before truncating (matches ralpi). */ +const MAX_COLLAPSED_TOOLCALLS = 3; + +/** Create a live phase-strip UI adapter. */ export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip { const statusKey = opts.statusKey ?? "pygienium"; + const widgetKey = `${statusKey}-progress`; const ui = opts.ui; const hasUI = opts.hasUI ?? false; + const widget = opts.widget ?? true; const checkLabel = opts.checkLabel; + const phaseLog: PhaseLogEntry[] = []; + const toolCallLog: ToolCallEntry[] = []; + let disposed = false; - function render(phaseId: string): string { - const label = PHASE_LABELS[phaseId] ?? phaseId; - return checkLabel - ? `pygienium ${checkLabel}: ${label}` - : `pygienium: ${label}`; + // Live-widget state. + let currentHeader = checkLabel + ? `pygienium ${checkLabel}: starting…` + : "pygienium: starting…"; + let frameIndex = 0; + let spinnerTimer: NodeJS.Timeout | undefined; + + function phaseLabel(id: string): string { + return PHASE_LABELS[id] ?? id; + } + + /** Build the current widget content: header + recent tool-call tree. */ + function widgetLines(): string[] { + const frame = SPINNER_FRAMES[frameIndex] ?? SPINNER_FRAMES[0]; + const lines = [`${frame} ${truncate(currentHeader, WIDGET_WIDTH - 2)}`]; + const calls = toolCallLog; + if (calls.length > 0) { + const shown = calls.slice(-MAX_COLLAPSED_TOOLCALLS); + const remaining = calls.length - shown.length; + if (remaining > 0) { + lines.push(` ├── …${remaining} earlier`); + } + for (let i = 0; i < shown.length; i++) { + const entry = shown[i]!; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + lines.push( + truncate(`${branch}[${entry.name}] ${entry.label}`, WIDGET_WIDTH - 2), + ); + } + } + return lines; + } + + function startWidget(): void { + if (!widget || !hasUI || !ui?.setWidget) return; + ui.setWidget(widgetKey, widgetLines()); + if (!spinnerTimer) { + spinnerTimer = setInterval(() => { + if (disposed || !ui?.setWidget) return; + frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; + ui.setWidget(widgetKey, widgetLines()); + }, 100); + } + } + + function clearWidget(): void { + if (spinnerTimer) { + clearInterval(spinnerTimer); + spinnerTimer = undefined; + } + if (widget && hasUI && ui?.setWidget) { + ui.setWidget(widgetKey, undefined); + } + } + + function writeStdout(text: string): void { + if (!disposed && !hasUI) { + process.stdout.write(`${text}\n`); + } } return { setPhase(phaseId) { - const text = render(phaseId); - if (hasUI && ui?.setStatus) { - ui.setStatus(statusKey, text); + if (disposed) return; + toolCallLog.length = 0; + const label = phaseLabel(phaseId); + currentHeader = checkLabel + ? `pygienium ${checkLabel}: ${label}` + : `pygienium: ${phaseId}`; + phaseLog.push({ id: phaseId, label, status: "running" }); + if (widget) { + if (hasUI && ui?.setWidget) { + if (!spinnerTimer) startWidget(); + ui.setWidget(widgetKey, widgetLines()); + } else if (!hasUI) { + withStdoutLock(() => writeStdout(currentHeader)).catch(() => {}); + } } - // In print/json modes (no TUI) write progress to stdout. In TUI mode - // the status bar is the render surface — raw stdout writes would splice - // into the ink renderer, so they are suppressed. - if (!hasUI) process.stdout.write(`${text}\n`); + // Widget suppressed (outer strip owns the UI): record the phase and + // render nothing, so no second spinner competes with the outer one. + }, + setPhaseNote(note) { + const last = phaseLog[phaseLog.length - 1]; + if (!last) return; + last.note = note; + }, + pushToolCall(entry) { + if (disposed) return; + toolCallLog.push(entry); + if (widget && hasUI && ui?.setWidget) { + ui.setWidget(widgetKey, widgetLines()); + } + }, + clearToolCalls() { + toolCallLog.length = 0; }, log(line) { - if (!hasUI) process.stdout.write(`${line}\n`); + if (disposed || hasUI) return; + withStdoutLock(() => writeStdout(line)).catch(() => {}); + }, + getPhaseLog() { + return phaseLog; }, done() { - if (hasUI && ui?.setStatus) { - ui.setStatus(statusKey, undefined); - } + if (disposed) return; + disposed = true; + clearWidget(); }, }; } + +/** Phase-log detail carried into a completion message's `details`. */ +export interface CheckCompletionDetails { + checkLabel: string; + status: "complete" | "failed" | "skipped"; + fix?: boolean; + durationMs?: number; + phases: PhaseLogEntry[]; + error?: string; +} + +/** Re-exported for index.ts renderer convenience. */ +export const PHASE_GLYPH: Record = { + running: "~", + complete: "✓", + failed: "✗", + skipped: "-", +}; + +/** Truncate a string to a display width, appending an ellipsis if cut. */ +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return text.slice(0, Math.max(0, max - 1)) + "…"; +} + +export { truncate }; diff --git a/tests/all.test.ts b/tests/all.test.ts index 3eb5ebe..f4e1a2e 100644 --- a/tests/all.test.ts +++ b/tests/all.test.ts @@ -355,4 +355,28 @@ describe("/pygienium-all orchestrator (task 12)", () => { expect(joined).toContain("Beta"); expect(joined).toContain("all ["); }); + + it("renders only the unified widget in UI mode (no per-check spinner)", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + const calls: Array<[string, string[] | undefined]> = []; + const ui = { + setWidget: (key: string, content: string[] | undefined) => { + calls.push([key, content]); + }, + } as never; // stub ExtensionUIContext (tests have no pi type imports) + + await runAllChecks({ cwd, ui, hasUI: true }); + + const keys = new Set(calls.map(([k]) => k)); + // The unified strip drives the widget area… + expect(keys.has("pygienium-all-progress")).toBe(true); + // …and per-check strips never claim it, so no second spinner can + // flicker/swap against the unified one. + expect(keys.has("pygienium-progress")).toBe(false); + // The widget is cleared when the run completes. + const last = calls[calls.length - 1]!; + expect(last[0]).toBe("pygienium-all-progress"); + expect(last[1]).toBeUndefined(); + }); }); diff --git a/tests/footer.test.ts b/tests/footer.test.ts new file mode 100644 index 0000000..dbd4f71 --- /dev/null +++ b/tests/footer.test.ts @@ -0,0 +1,184 @@ +/** + * footer.test.ts — unit tests for the pipeline-overview footer (task: footer). + * + * The footer is presentation-only state: with no UI it tracks items but writes + * nothing; with a stub UI it renders one status line and clears on `done()`. + * These tests exercise the state machine (cursor demotion, terminal marks) + * without spinning up a runner. + */ +import { describe, expect, it } from "bun:test"; +import { + createPipelineFooter, + footerPhaseItems, + FOOTER_GLYPH, + FOOTER_STATUS_KEY, + type ItemStatus, +} from "../src/footer.js"; +import { PHASE_LABELS } from "../src/phases.js"; +import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js"; + +/** Minimal UI stub capturing `setStatus(key, text)` calls in order. */ +function stubUi(): { + ui: { setStatus: (key: string, text: string | undefined) => void }; + calls: { key: string; text: string | undefined }[]; +} { + const calls: { key: string; text: string | undefined }[] = []; + return { + calls, + ui: { + setStatus(key, text) { + calls.push({ key, text }); + }, + }, + }; +} + +/** Build the canonical phase-id list a scan-only check uses. */ +function scanPhaseIds(): string[] { + return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX]; +} + +describe("createPipelineFooter", () => { + it("is a no-op without a UI but still tracks item state", () => { + // hasUI false: setStatus must never be called. + const footer = createPipelineFooter({ hasUI: false }); + footer.setPipeline("pygienium smoke", [ + { label: "Recon", status: "pending" }, + ]); + footer.setCursor(0); + footer.done(); + // No UI → no observable side effect, but getItems reflects state. + expect(footer.getItems()[0]?.status).toBe("running"); + expect(footer.getTitle()).toBe("pygienium smoke"); + }); + + it("renders the full pipeline with the cursor running and clears on done", () => { + const { ui, calls } = stubUi(); + const footer = createPipelineFooter({ ui, hasUI: true }); + const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS); + + footer.setPipeline("pygienium smoke", items, 0); + + // One setStatus call, under the canonical key, listing every phase: + // cursor gets `▶