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:
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 };
|
||||
|
||||
Reference in New Issue
Block a user