/** * 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 = { 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 = Promise.resolve(); /** Acquire the stdout write lock and execute the write function. */ async function withStdoutLock(fn: () => void): Promise { const prev = stdoutLock; stdoutLock = prev.then(() => { fn(); return Promise.resolve(); }); return stdoutLock; } /** Widget container width budget (account for widget padding). */ const WIDGET_WIDTH = 78; /** Max tool calls shown in a live widget before truncating (matches ralpi). */ const MAX_COLLAPSED_TOOLCALLS = 3; /** Create a live phase-strip UI adapter. */ export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip { const statusKey = opts.statusKey ?? "pygienium"; const widgetKey = `${statusKey}-progress`; const ui = opts.ui; const hasUI = opts.hasUI ?? false; const widget = opts.widget ?? true; const checkLabel = opts.checkLabel; const phaseLog: PhaseLogEntry[] = []; const toolCallLog: ToolCallEntry[] = []; let disposed = false; // 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 = { 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 };