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

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