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

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