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:
14
README.md
14
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-<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
|
||||
```
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
173
src/footer.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
160
src/index.ts
160
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<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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
271
src/phases.ts
271
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<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 };
|
||||
|
||||
@@ -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
184
tests/footer.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user