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.
293 lines
8.7 KiB
TypeScript
293 lines
8.7 KiB
TypeScript
/**
|
|
* phases.ts — live progress widget + completion-message helpers.
|
|
*
|
|
* 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",
|
|
analysis: "Scanning",
|
|
fix: "Fixing",
|
|
verify: "Verifying",
|
|
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 {
|
|
/** 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 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 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 or a pre-rendered header string). */
|
|
setPhase(phaseId: string): void;
|
|
/** 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;
|
|
/** Snapshot of phase transitions for the completion message. */
|
|
getPhaseLog(): PhaseLogEntry[];
|
|
/** Clear the live widget. Call once the run is terminal. */
|
|
done(): void;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// 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) {
|
|
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(() => {});
|
|
}
|
|
}
|
|
// 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 (disposed || hasUI) return;
|
|
withStdoutLock(() => writeStdout(line)).catch(() => {});
|
|
},
|
|
getPhaseLog() {
|
|
return phaseLog;
|
|
},
|
|
done() {
|
|
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 };
|