feat(ui): ralpi-style chat progress and pipeline-overview footer

phases.ts becomes a live chat widget (spinner + tool-call tree) and the
check-runner posts per-agent tool-call summaries and an expandable
completion tree through a custom message renderer; footer.ts adds the
static pipeline-overview status strip for single checks and /pygienium-all.
This commit is contained in:
2026-08-09 16:45:29 -04:00
parent d0e8ad5571
commit 5f8a5cbe5f
10 changed files with 1165 additions and 55 deletions

View File

@@ -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-<name>` 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
```

View File

@@ -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<AgentRunResult>;
@@ -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<string, unknown>;
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;
}
}

View File

@@ -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 };

173
src/footer.ts Normal file
View File

@@ -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<ItemStatus, string> = {
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<string, string>,
): FooterItem[] {
return phaseIds.map((id) => ({
label: labels[id] ?? id,
status: "pending" as ItemStatus,
}));
}

View File

@@ -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<void> {
}
}
/** 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<string, unknown>) => {
pi.sendMessage({
customType: "pygienium-progress",
content,
display: true,
details: {
phase: meta?.phase || "info",
...meta,
},
});
};
}
export default async function pygieniumExtension(
pi: ExtensionAPI,
): Promise<void> {
// 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);
},
});
});

View File

@@ -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);

View File

@@ -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-<check>` 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 `<cwd>/.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<CheckRunOutcome> {
const startMs = Date.now();
const outcome = await runCheckImpl(opts);
postCheckCompletion(opts, outcome, Date.now() - startMs);
return outcome;
}
async function runCheckImpl(opts: RunCheckOptions): Promise<CheckRunOutcome> {
// 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<CheckRunOutcome> {
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 `<cwd>/.pygienium/<check>-tmp-*`. Findings and

View File

@@ -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<string, string> = {
recon: "Recon",
@@ -22,62 +66,227 @@ export const PHASE_LABELS: Record<string, string> = {
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<void> = Promise.resolve();
/** Acquire the stdout write lock and execute the write function. */
async function withStdoutLock(fn: () => void): Promise<void> {
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<PhaseLogStatus, string> = {
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 };

View File

@@ -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();
});
});

184
tests/footer.test.ts Normal file
View File

@@ -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 `▶ <label>`, the rest are glued `…<label>`.
expect(calls).toHaveLength(1);
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
expect(calls[0]?.text).toContain("pygienium smoke");
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} Recon`);
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Scanning`);
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Fixing`);
// The cursor item is marked running.
expect(footer.getItems()[0]?.status).toBe("running");
footer.done();
// done() pushes an undefined to clear the slot, then resets state.
const last = calls[calls.length - 1];
expect(last?.text).toBeUndefined();
expect(footer.getItems()).toHaveLength(0);
});
it("demotes the previous running item to pending when the cursor moves", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
// recon → complete, then advance to analysis.
footer.setItem(0, "complete");
footer.setCursor(1);
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("running");
});
it("does not demote a terminal item when the cursor advances past it", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
footer.setCursor(0); // recon running
// Simulate analysis completing (cursor was already moved there) then
// jumping to fix: a completed phase must stay complete, not revert.
footer.setItem(0, "complete");
footer.setCursor(1); // analysis running
footer.setItem(1, "complete");
footer.setCursor(2); // fix running
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("complete");
expect(items[2]?.status).toBe("running");
});
it("marks a skipped gate as every item skipped", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium comments",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
);
// No cursor: all pending. Now a gate-skip marks every phase skipped,
// mirroring the runner's gate branch.
for (let i = 0; i < footer.getItems().length; i++) {
footer.setItem(i, "skipped" as ItemStatus);
}
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
});
it("can be disabled so it never touches the status slot", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({
ui,
hasUI: true,
enabled: false,
});
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
]);
footer.setCursor(0);
footer.done();
// enabled:false suppresses every setStatus call (used by /pygienium-all
// which owns its own footer).
expect(calls).toHaveLength(0);
});
it("writes under a custom status key (all-run owns its slot)", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({
ui,
hasUI: true,
statusKey: "pygienium-all",
});
footer.setPipeline(
"pygienium: all",
[
{ label: "comments", status: "pending" },
{ label: "dead-code", status: "pending" },
],
0,
);
expect(calls[0]?.key).toBe("pygienium-all");
expect(calls[0]?.text).toContain("pygienium: all");
// Cursor on the first check; second still pending (to come).
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} comments`);
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}dead-code`);
});
});
describe("footerPhaseItems", () => {
it("maps phase ids to pending footer items using PHASE_LABELS", () => {
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
expect(items.map((i) => i.label)).toEqual(["Recon", "Scanning", "Fixing"]);
expect(items.every((i) => i.status === "pending")).toBe(true);
});
it("falls back to the raw id for unknown phases", () => {
const items = footerPhaseItems(["custom"], PHASE_LABELS);
expect(items[0]?.label).toBe("custom");
});
});