import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import type { ReviewProgressEvent, ReviewStage } from "./orchestrator.ts"; const STATUS_KEY = "pi-code-review"; const WIDGET_KEY = "pi-code-review-progress"; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const MAX_MILESTONE_DETAIL_LENGTH = 800; const STAGE_LABELS: Record = { triage: "Triaging change", summary: "Summarizing task", reviewers: "Running independent reviewers", validation: "Validating candidates", aggregation: "Aggregating findings", }; type ProgressUI = Pick; export type ProgressMessageWriter = (content: string) => void; export interface ProgressPresenterOptions { intervalMs?: number; now?: () => number; } function conciseDetail(value: string): string { const normalized = value.replace(/\s+/g, " ").trim(); if (normalized.length <= MAX_MILESTONE_DETAIL_LENGTH) return normalized; return `${normalized.slice(0, MAX_MILESTONE_DETAIL_LENGTH - 1)}…`; } export class ReviewProgressPresenter { private readonly intervalMs: number; private readonly now: () => number; private readonly startedAt: number; private timer: ReturnType | undefined; private frame = 0; private label = "Starting code review"; private completed: number | undefined; private total: number | undefined; private disposed = false; constructor( private readonly ui: ProgressUI, private readonly writeMessage: ProgressMessageWriter, options: ProgressPresenterOptions = {}, ) { this.intervalMs = options.intervalMs ?? 400; this.now = options.now ?? Date.now; this.startedAt = this.now(); this.render(); this.timer = setInterval(() => { this.frame = (this.frame + 1) % SPINNER_FRAMES.length; this.render(); }, this.intervalMs); } update(label: string, completed?: number, total?: number): void { if (this.disposed) return; this.label = label; this.completed = completed; this.total = total; this.render(); } milestone(title: string, details: string[]): void { if (this.disposed) return; const body = details.map(conciseDetail).filter(Boolean).join("\n\n"); try { this.writeMessage( `## Code review progress\n\n**${title}**${body ? `\n\n${body}` : ""}`, ); } catch { // Progress is best-effort and must not interrupt the review. } } handle(event: ReviewProgressEvent): void { switch (event.type) { case "stage": this.update( STAGE_LABELS[event.stage], event.total === undefined ? undefined : 0, event.total, ); break; case "triage-complete": this.milestone("Triage completed", [ `${event.review ? "Review required" : "Review skipped"}: ${event.reason}`, ]); break; case "triage-fallback": this.milestone("Triage fallback", [event.reason]); break; case "summary-complete": this.milestone("Task summary completed", [event.summary]); break; case "reviewer-coverage-retry": { this.update(`Retrying coverage · ${event.reviewer}`); const reasons = [ event.incomplete ? "incomplete receipt" : "", event.missing > 0 ? `${event.missing} missing file${event.missing === 1 ? "" : "s"}` : "", event.unexpected > 0 ? `${event.unexpected} unexpected path${event.unexpected === 1 ? "" : "s"}` : "", ].filter(Boolean); this.milestone("Reviewer coverage retry", [ `${event.reviewer}: ${reasons.join(", ")}`, ]); break; } case "reviewer-recovery-retry": { this.update(`Recovering reviewer · ${event.reviewer}`); this.milestone("Reviewer recovery retry", [ `${event.reviewer} terminated; retrying same-spec attempt ${event.attempt}.`, ]); break; } case "reviewer-complete": this.update(STAGE_LABELS.reviewers, event.completed, event.total); break; case "reviewers-complete": this.milestone("Independent review completed", [ `${event.total}/${event.total} reviewers completed; ${event.candidates} candidate finding${event.candidates === 1 ? "" : "s"} identified.`, ]); break; case "validator-complete": this.update(STAGE_LABELS.validation, event.completed, event.total); break; case "validation-complete": this.milestone("Validation completed", [ `${event.total} candidate${event.total === 1 ? "" : "s"} checked; ${event.accepted} passed independent validation at high confidence.`, ]); break; case "aggregation-complete": this.update( `Review completed · ${event.findings} finding${event.findings === 1 ? "" : "s"}`, ); break; case "failed": this.update( `Review failed during ${STAGE_LABELS[event.stage].toLowerCase()}`, ); break; case "aborted": this.update( `Review cancelled during ${STAGE_LABELS[event.stage].toLowerCase()}`, ); break; } } dispose(): void { if (this.disposed) return; this.disposed = true; if (this.timer) clearInterval(this.timer); this.timer = undefined; this.safely(() => this.ui.setWidget(WIDGET_KEY, undefined)); this.safely(() => this.ui.setStatus(STATUS_KEY, undefined)); } private render(): void { if (this.disposed) return; const elapsedSeconds = Math.max( 0, Math.floor((this.now() - this.startedAt) / 1000), ); const count = this.completed === undefined || this.total === undefined ? "" : ` ${this.completed}/${this.total}`; const text = `${SPINNER_FRAMES[this.frame]} Code review · ${this.label}${count} · ${elapsedSeconds}s`; this.safely(() => this.ui.setWidget(WIDGET_KEY, [text], { placement: "aboveEditor" }), ); this.safely(() => this.ui.setStatus(STATUS_KEY, text)); } private safely(action: () => void): void { try { action(); } catch { // Progress is best-effort and must not interrupt the review. } } }