/** * Audit progress surface: a sticky per-row table above the editor showing * context usage, output activity, in-flight tool calls, turn count, and * elapsed time, grouped by phase. */ import type { ThemeColor, WidgetPlacement } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui"; import { ACTIVITY_METER_WIDTH, ActivityMeter, rateToLevel, TokRateTracker, type ActivityMeterLevel, } from "../activityMeter.ts"; import { normalizeFindingText } from "../dedup.ts"; import { highlightActivity } from "../toolActivity.ts"; import { DEFAULT_METER_SETTINGS, resolveMeterColor, type MeterSettings, type ThinkingLevel } from "../modelConfig.ts"; import { shimmerString, type ShimmerTheme } from "../shimmer.ts"; import { MIN_WAIT_DISPLAY_MS, RunClock } from "../runClock.ts"; import type { Finding, HeadlessProgress } from "../types.ts"; import { wrapText } from "./menuChrome.ts"; export const AUDIT_PROGRESS_WIDGET_KEY = "persona-audit-progress"; const SPINNER_INTERVAL_MS = 100; /** Visible phase groups, in render order. The 8-step workflow collapses into these four. */ export const AUDIT_PHASES = ["Review", "Triage", "Implement", "Verify"] as const; export type AuditPhase = (typeof AUDIT_PHASES)[number]; export type RowState = "queued" | "working" | "done" | "error" | "cancelled"; /** Highest wrapped line count the fix-now rationale may claim under its row. */ const FIX_NOW_RATIONALE_MAX_LINES = 3; /** * Nested Fix Now status rendered as sub-rows under the `fix now · …` row while * it is active: what is being fixed, which phase the flow is in, and the * cancel gesture. Telemetry stays on the row itself; this block carries only * what the row cannot. JSON-safe so it can ride `AuditProgressRow`. */ export interface FixNowDetail { category: string; severity: string; rationale: string; phase: "fixing" | "verifying" | "accepting" | "retrying" | "discarding"; statusText: string; attempt: number; commitPlanned: boolean; awaitingCancelConfirm: boolean; } const TABLE_TITLE = "Persona-audit"; /** Shortest rule run allowed between the title and the right-side run metadata before that side is dropped. */ const MIN_TITLE_SCOPE_GAP = 2; /** Shortest gap allowed between the footer summary and the total before the total moves to its own line. */ const MIN_FOOTER_TOTAL_GAP = 2; /** * Share of the terminal the table may claim. Lower than an overlay's would be: * this sits above the editor for the whole run and cannot be dismissed, so it * has to leave the transcript readable. */ const TABLE_HEIGHT_RATIO = 0.5; const TABLE_FRAMES = ["◐", "◓", "◑", "◒"] as const; /** Border, header, separator, footer and bottom border — the rows a table always costs. */ const TABLE_CHROME_ROWS = 5; /** One horizontal-rule heading row per phase group. */ const PHASE_HEADING_ROWS = 1; /** One blank row between each adjacent phase group. */ const PHASE_SEPARATOR_ROWS = 1; /** Two band text lines plus their separator rule — the extra rows the phase/model band costs when shown. */ const PHASE_BAND_ROWS = 3; /** Narrowest a band column may get before its centered label becomes unreadable; below this the band is dropped. */ const PHASE_BAND_MIN_COL = 12; /** * The two halves of the tall right-chevron drawn between phase columns: a "\" * powerline diagonal (U+E0B9) on the name row stacked over a "/" (U+E0BB) on * the model row. Needs a Powerline/Nerd Font to render; plainer fonts show tofu. */ const PHASE_SEP_TOP = "\u{E0B9}"; const PHASE_SEP_BOTTOM = "\u{E0BB}"; const COLUMN_GAP = 2; const STATUS_COL_WIDTH = 2; const CTX_COL_WIDTH = 14; const ELAPSED_COL_WIDTH = 6; const TURNS_COL_WIDTH = 5; const TOOLS_COL_WIDTH = 5; const COST_COL_WIDTH = 8; const LABEL_COL_MIN = 8; /** Label width below which reviewer names stop being distinguishable from their siblings. */ const LABEL_COL_READABLE = 24; const ACTIVITY_COL_MIN = 10; /** Format a token count as `X.XM` / `X.XK`, or raw when small. */ export function formatTokens(tokens: number): string { if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`; return String(tokens); } /** * Context usage as a right-aligned `${pct}/${window}`. When the context window * cannot be resolved, shows the raw token count instead of a percentage that * would be guesswork. */ export function contextCell(contextTokens: number | undefined, contextWindow: number | undefined): string { const used = contextTokens && contextTokens > 0 ? contextTokens : 0; if (!contextWindow || contextWindow <= 0) { return (used > 0 ? `${formatTokens(used)} tok` : "—").padStart(CTX_COL_WIDTH); } const ratio = Math.max(0, Math.min(1, used / contextWindow)); return `${(ratio * 100).toFixed(1)}%/${formatTokens(contextWindow)}`.padStart(CTX_COL_WIDTH); } /** Format cumulative model cost in USD, or a dash when the model is not in the registry. */ export function formatCost(costUsd: number | undefined): string { if (costUsd === undefined || !Number.isFinite(costUsd)) return "—"; return `$${Math.max(0, costUsd).toFixed(3)}`; } /** Aggregate model-cost telemetry across every progress row. */ export interface TotalCost { costUsd: number; incomplete: boolean; } /** Sum valid non-negative row costs, marking totals partial when telemetry is unavailable or invalid. */ export function totalCost(rows: AuditProgressRow[]): TotalCost { let costUsd = 0; let incomplete = false; for (const row of rows) { if (row.costUsd === undefined || !Number.isFinite(row.costUsd) || row.costUsd < 0) { incomplete = true; } else { costUsd += row.costUsd; } } return { costUsd, incomplete }; } /** Elapsed time as `M:SS`. Minutes keep counting past 60 rather than rolling into an hours field. */ export function formatElapsed(ms: number): string { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`; } /** Working rows animate, render bright, and may carry a tool-activity sub-row. */ function isActive(state: RowState): boolean { return state === "working"; } function defaultStatusText(state: RowState): string { switch (state) { case "queued": return "queued"; case "working": return "working…"; case "done": return "done"; case "error": return "failed"; case "cancelled": return "cancelled"; } } /** One tracked workload in the audit table. */ export interface AuditProgressRow { key: string; phase: AuditPhase; label: string; state: RowState; statusText: string; contextTokens?: number; contextWindow?: number; activity?: string; elapsedMs: number; turns: number; /** Absent on entries persisted before tool-call telemetry was added. */ toolCalls?: number; costUsd?: number; outputTokens: number; outputRevision: number; /** First row of its phase group; used by the render-only phase heading. */ firstOfPhase: boolean; /** Nested Fix Now detail, present only on an active `fix now · …` row. */ fixNowDetail?: FixNowDetail; } /** What the table component reads from. */ export interface AuditProgressView { progressRows(): AuditProgressRow[]; /** Compact live run summary, shown in the footer. */ footerSummary(): string; /** Work duration of the whole run, excluding spans blocked on a user decision. */ totalMs(): number; /** Time the run sat blocked on a user decision. */ waitingMs(): number; /** Aggregate model cost across every row, including whether the total is partial. */ totalCost(): TotalCost; /** Audit scope, shown right-aligned in the title bar. */ readonly scope: string | undefined; /** Short hash of the diff's base commit, shown right-aligned in the title bar after the scope. Undefined outside --diff mode. */ readonly baseHash: string | undefined; /** Model assigned to each phase, for the band above the table header. */ phaseModels(): Partial>; /** Thinking level assigned to each phase, for per-phase meter coloring. */ phaseThinking(): Partial>; /** The phase currently highlighted in the band; undefined highlights none (e.g. a frozen, finished run). */ activePhase(): AuditPhase | undefined; /** Whether any row is still working — lets the ticker skip progressRows() while idle. */ hasActiveRows(): boolean; } /** Custom entry type for the frozen transcript copy left behind on completion (see `pi.appendEntry`). */ export const AUDIT_PROGRESS_ENTRY_TYPE = "persona-audit-table"; /** JSON-serializable capture of a finished run, rendered read-only in the transcript without re-entering LLM context. */ export interface AuditProgressSnapshot { scope?: string; /** Absent on entries persisted before the title bar carried the diff base. */ baseHash?: string; summary: string; /** Absent on entries persisted before the total-time footer existed. */ totalMs?: number; /** Absent on entries persisted before the footer showed excluded user waits. */ waitingMs?: number; phaseModels: Partial>; /** Absent on entries persisted before per-phase meter coloring was added. */ phaseThinking?: Partial>; rows: AuditProgressRow[]; meterLevels: Record; } /** Resolve a model's context window from the identifiers an agent session reported. */ export type ContextWindowResolver = (provider: string | undefined, model: string | undefined) => number | undefined; /** The slice of pi's `TUI` the table needs. Structural so tests can supply a stub. */ export interface ProgressHost { requestRender(force?: boolean): void; terminal?: { rows?: number }; } /** The slice of pi's `Theme` the table needs. Structural so tests can supply a stub. */ export interface ProgressTheme { fg(color: ThemeColor, text: string): string; bold(text: string): string; /** 24-bit ANSI escape for a color, used to shimmer the active phase in the band. Optional so structural test stubs need not implement it; the active phase falls back to its flat highlighted tone when absent. */ getFgAnsi?(color: ThemeColor): string; } /** The slice of pi's extension context the widget needs. Structural so tests can stub it. */ export interface AuditProgressContext { ui: { setWidget( key: string, content: ((tui: ProgressHost, theme: ProgressTheme) => AuditProgressTable) | undefined, options?: { placement?: WidgetPlacement }, ): void; }; } interface RowRecord { key: string; phase: AuditPhase; label: string; state: RowState; statusText?: string; contextTokens?: number; provider?: string; model?: string; activity?: string; turns?: number; toolCalls?: number; costUsd?: number; outputTokens?: number; outputRevision?: number; startedAt?: number; endedAt?: number; fixNowDetail?: FixNowDetail; } export class AuditProgressWidget implements AuditProgressView { readonly scope: string | undefined; readonly baseHash: string | undefined; readonly clock = new RunClock(); private readonly ctx: AuditProgressContext; private readonly resolveContextWindow: ContextWindowResolver; private readonly meter: MeterSettings; /** Memoizes resolveContextWindow by `${provider}\0${model}` — the registry is fixed for the session, so a full model-registry scan needn't repeat for every row on every 100ms tick. */ private readonly contextWindowCache = new Map(); private readonly rows = new Map(); private summary = ""; private mounted = false; private models: Partial> = {}; private thinking: Partial> = {}; private active: AuditPhase | undefined; private activeRowCount = 0; private table: AuditProgressTable | undefined; constructor( ctx: AuditProgressContext, scope?: string, baseHash?: string, resolveContextWindow: ContextWindowResolver = () => undefined, meter: MeterSettings = DEFAULT_METER_SETTINGS, ) { this.ctx = ctx; this.scope = scope; this.baseHash = baseHash; this.resolveContextWindow = resolveContextWindow; this.meter = meter; } /** Mount the table above the editor. Safe to call multiple times. */ mount(): void { if (this.mounted) return; this.mounted = true; this.clock.start(); this.ctx.ui.setWidget( AUDIT_PROGRESS_WIDGET_KEY, (tui, theme) => { this.table = new AuditProgressTable(tui, theme, this, undefined, this.meter); return this.table; }, { placement: "aboveEditor" }, ); } /** Tear down the progress surface, disposing the table's ticker. Safe to call multiple times. */ stop(): void { this.mounted = false; this.clock.stop(); this.table = undefined; this.ctx.ui.setWidget(AUDIT_PROGRESS_WIDGET_KEY, undefined); } /** Register a row. Re-adding a key updates its label and phase rather than duplicating it. */ addRow( phase: AuditPhase, key: string, label: string, init: { state?: RowState; statusText?: string } = {}, ): void { const existing = this.rows.get(key); const state = init.state ?? "queued"; if (existing) { const wasWorking = existing.state === "working"; existing.phase = phase; existing.label = label; existing.state = state; existing.statusText = init.statusText ? normalizeFindingText(init.statusText) : undefined; if (wasWorking && state !== "working") this.activeRowCount--; if (!wasWorking && state === "working") this.activeRowCount++; return; } this.rows.set(key, { key, phase, label, state, statusText: init.statusText ? normalizeFindingText(init.statusText) : undefined, startedAt: state === "working" ? Date.now() : undefined, endedAt: state === "done" || state === "error" || state === "cancelled" ? Date.now() : undefined, }); if (state === "working") this.activeRowCount++; } /** Move a row into `working`, starting its elapsed clock. */ startRow(key: string, statusText?: string): void { const row = this.rows.get(key); if (!row) return; const wasWorking = row.state === "working"; const wasSettled = row.state === "done" || row.state === "error" || row.state === "cancelled"; row.state = "working"; row.statusText = statusText; if (wasSettled) { row.startedAt = Date.now(); row.contextTokens = undefined; row.provider = undefined; row.model = undefined; row.activity = undefined; row.turns = undefined; row.toolCalls = undefined; row.costUsd = undefined; row.outputTokens = undefined; row.outputRevision = undefined; } else { row.startedAt ??= Date.now(); } row.endedAt = undefined; if (!wasWorking) this.activeRowCount++; } /** Fold one streamed telemetry snapshot into a row. */ applyProgress(key: string, progress: HeadlessProgress): void { const row = this.rows.get(key); if (!row) return; row.contextTokens = progress.contextTokens; row.turns = progress.turns; row.toolCalls = progress.toolCalls; row.costUsd = progress.costUsd; row.outputTokens = progress.outputTokens; row.outputRevision = progress.outputRevision; if (progress.activity) row.activity = normalizeFindingText(progress.activity); if (progress.provider) row.provider = progress.provider; if (progress.model) row.model = progress.model; } /** Settle a row, freezing its elapsed reading. */ settleRow(key: string, state: Exclude, statusText?: string): void { const row = this.rows.get(key); if (!row) return; const wasWorking = row.state === "working"; row.state = state; row.statusText = statusText ? normalizeFindingText(statusText) : undefined; row.activity = undefined; row.fixNowDetail = undefined; row.endedAt ??= Date.now(); if (wasWorking) this.activeRowCount--; } /** * Settle every row that never finished. Already-completed rows keep their * result, so a cancelled run still shows which passes actually landed. */ settleOpenRows(state: Exclude, statusText?: string): void { for (const row of this.rows.values()) { if (row.state === "queued" || row.state === "working") this.settleRow(row.key, state, statusText); } } /** Attach the nested Fix Now detail block to a `fix now · …` row. */ setFixNowDetail(key: string, finding: Finding): void { const row = this.rows.get(key); if (!row) return; row.fixNowDetail = { category: finding.category, severity: finding.severity, rationale: finding.rationale, phase: "fixing", statusText: "starting…", attempt: 1, commitPlanned: true, awaitingCancelConfirm: false, }; } /** Move the nested detail into a working phase (also re-entry after a retry). */ updateFixNowPhase(key: string, phase: "fixing" | "verifying", statusText: string, attempt: number): void { const detail = this.rows.get(key)?.fixNowDetail; if (!detail) return; detail.phase = phase; detail.statusText = statusText; detail.attempt = attempt; detail.awaitingCancelConfirm = false; } /** Move the nested detail into its post-decision settling state. */ updateFixNowSettling(key: string, decision: "accept" | "retry" | "discard", commitPlanned: boolean): void { const detail = this.rows.get(key)?.fixNowDetail; if (!detail) return; detail.phase = decision === "accept" ? "accepting" : decision === "retry" ? "retrying" : "discarding"; detail.commitPlanned = commitPlanned; detail.statusText = decision === "accept" ? commitPlanned ? "committing changes" : "saving accepted fix" : decision === "retry" ? "reverting changes before retry" : "reverting changes"; detail.awaitingCancelConfirm = false; } /** Arm or disarm the double-Escape cancel hint in the nested detail. */ setFixNowCancelArmed(key: string, armed: boolean): void { const detail = this.rows.get(key)?.fixNowDetail; if (!detail) return; detail.awaitingCancelConfirm = armed; } /** Remove the nested detail (the fix episode ended; the row settles right after). */ clearFixNowDetail(key: string): void { const row = this.rows.get(key); if (row) row.fixNowDetail = undefined; } /** Replace the compact run summary rendered in the footer. */ setSummary(summary: string): void { this.summary = summary; } footerSummary(): string { return this.summary; } totalMs(): number { return this.clock.activeMs(); } waitingMs(): number { return this.clock.waitingMs(); } totalCost(): TotalCost { return totalCost(this.progressRows()); } /** Assign the model shown in the phase/model band for each phase. */ setPhaseModels(models: Partial>): void { this.models = models; } phaseModels(): Partial> { return this.models; } /** Assign the thinking level used to color each phase's activity meter. */ setPhaseThinking(thinking: Partial>): void { this.thinking = thinking; } phaseThinking(): Partial> { return this.thinking; } /** Mark which phase is highlighted in the band. Undefined highlights none. */ setActivePhase(phase: AuditPhase | undefined): void { this.active = phase; } activePhase(): AuditPhase | undefined { return this.active; } hasActiveRows(): boolean { return this.activeRowCount > 0; } /** Capture a JSON-serializable copy of the current view for transcript persistence. Must be called before `stop()`, which drops the table and its meter traces. */ snapshot(): AuditProgressSnapshot { return { scope: this.scope, baseHash: this.baseHash, summary: this.summary, totalMs: this.totalMs(), waitingMs: this.waitingMs(), phaseModels: this.models, phaseThinking: this.thinking, // Live fix-now detail (cancel hints, settling states) is transient UI // state, not run history — never freeze it into the transcript. rows: this.progressRows().map((row) => ({ ...row, fixNowDetail: undefined })), meterLevels: this.table?.meterLevels() ?? {}, }; } private resolvedContextWindow(provider: string | undefined, model: string | undefined): number | undefined { const key = `${provider}\0${model}`; if (this.contextWindowCache.has(key)) return this.contextWindowCache.get(key); const resolved = this.resolveContextWindow(provider, model); this.contextWindowCache.set(key, resolved); return resolved; } progressRows(): AuditProgressRow[] { const now = Date.now(); const ordered = [...this.rows.values()].sort( (a, b) => AUDIT_PHASES.indexOf(a.phase) - AUDIT_PHASES.indexOf(b.phase), ); return ordered.map((row, index) => ({ key: row.key, phase: row.phase, label: row.label, state: row.state, statusText: row.statusText ?? defaultStatusText(row.state), contextTokens: row.contextTokens, contextWindow: this.resolvedContextWindow(row.provider, row.model), activity: row.activity, elapsedMs: row.startedAt === undefined ? 0 : this.clock.workedBetween(row.startedAt, row.endedAt ?? now), turns: row.turns ?? 0, toolCalls: row.toolCalls ?? 0, costUsd: row.costUsd, outputTokens: row.outputTokens ?? 0, outputRevision: row.outputRevision ?? 0, firstOfPhase: ordered[index - 1]?.phase !== row.phase, fixNowDetail: row.fixNowDetail ? { ...row.fixNowDetail } : undefined, })); } } /** * Folds the Review phase down when the table outgrows its row budget. Review is * the only phase that scales with the reviewer × pass matrix, so a large run * buries the live work under hundreds of settled and queued rows. Done and * queued passes collapse into one summary row each; working, failed and * cancelled passes are always kept, so nothing in flight or broken is hidden. * * Render-only — `progressRows()` and `snapshot()` keep the full list, which is * what leaves frozen transcripts complete. */ export function collapseReviewRows(rows: AuditProgressRow[], maxBaseRows: number): AuditProgressRow[] { if (rows.length <= maxBaseRows) return rows; const start = rows.findIndex((row) => row.phase === "Review"); if (start < 0) return rows; // progressRows() sorts by phase, so the Review rows are one contiguous block. const review = rows.filter((row) => row.phase === "Review"); let done = 0; let queued = 0; const unfoldable: AuditProgressRow[] = []; for (const row of review) { if (row.state === "done") done++; else if (row.state === "queued") queued++; else unfoldable.push(row); } if (done + queued === 0) return rows; const folded = [ ...(done > 0 ? [reviewSummaryRow("done", `${done} ${done === 1 ? "pass" : "passes"} done`)] : []), ...unfoldable, ...(queued > 0 ? [reviewSummaryRow("queued", `${queued} queued`)] : []), ]; const collapsed = [...rows.slice(0, start), ...folded, ...rows.slice(start + review.length)]; return collapsed.map((row, index) => ({ ...row, firstOfPhase: collapsed[index - 1]?.phase !== row.phase, })); } /** An aggregate stand-in for folded passes. Its key has no meter, so it draws the idle trace. */ function reviewSummaryRow(state: "done" | "queued", label: string): AuditProgressRow { return { key: `review:summary:${state}`, phase: "Review", label, state, statusText: defaultStatusText(state), elapsedMs: 0, turns: 0, toolCalls: 0, outputTokens: 0, outputRevision: 0, firstOfPhase: false, }; } /** Count the render-only heading and blank separator rows needed for the phase groups in `rows`. */ function phaseSectionRows(rows: AuditProgressRow[]): number { const headingCount = rows.reduce( (count, row, index) => count + (index === 0 || rows[index - 1]?.phase !== row.phase ? 1 : 0), 0, ); return headingCount * PHASE_HEADING_ROWS + Math.max(0, headingCount - 1) * PHASE_SEPARATOR_ROWS; } /** Column widths for the table, shedding columns as the terminal narrows. */ export function tableColumns( bodyWidth: number, labels: string[], ): { label: number; activity: number; stats: boolean } { const fixed = STATUS_COL_WIDTH + COLUMN_GAP + CTX_COL_WIDTH + COLUMN_GAP + ACTIVITY_METER_WIDTH + COLUMN_GAP; const statsWidth = ELAPSED_COL_WIDTH + COLUMN_GAP + COST_COL_WIDTH + COLUMN_GAP + TOOLS_COL_WIDTH + COLUMN_GAP + TURNS_COL_WIDTH + COLUMN_GAP; // Elapsed, turns, tool calls, and cost go first on a narrow terminal: they are ambient readings, // and are not worth truncating the row label down to an unreadable stub. const stats = bodyWidth - fixed - statsWidth >= LABEL_COL_READABLE + ACTIVITY_COL_MIN; const available = bodyWidth - fixed - (stats ? statsWidth : 0); if (available < LABEL_COL_MIN) return { label: Math.max(1, available), activity: 0, stats }; const widest = labels.reduce((max, label) => Math.max(max, visibleWidth(label)), 0); const label = Math.min(Math.max(LABEL_COL_MIN, widest), Math.max(LABEL_COL_MIN, available - ACTIVITY_COL_MIN)); return { label, activity: available - label, stats }; } /** Pad or truncate a possibly-ANSI-colored cell to exactly `width` columns. */ function cell(text: string, width: number, align: "left" | "right" | "center" = "left"): string { if (width <= 0) return ""; const shown = visibleWidth(text) > width ? truncateToWidth(text, width, "…") : text; const padding = Math.max(0, width - visibleWidth(shown)); if (align === "right") return " ".repeat(padding) + shown; if (align === "center") { const left = Math.floor(padding / 2); return " ".repeat(left) + shown + " ".repeat(padding - left); } return shown + " ".repeat(padding); } /** Strip C0/C1 bytes from untrusted labels so they cannot alter terminal state. */ export function sanitizeTerminalText(text: string): string { return text.replace(/[\x00-\x1f\x7f-\x9f]/g, ""); } /** Like sanitizeTerminalText but keeps tab — git diff lines rely on it for indentation. */ export function sanitizeDiffLine(text: string): string { return text.replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, ""); } /** Shorten a `provider/id` model label (or a bare frontmatter id) to its id, for the space-constrained band. */ function shortModelId(label: string | undefined): string { if (!label) return "default"; const sanitized = sanitizeTerminalText(label); const idx = sanitized.lastIndexOf("/"); return idx === -1 ? sanitized : sanitized.slice(idx + 1); } interface RowMeter { meter: ActivityMeter; tracker: TokRateTracker; revision: number; } export class AuditProgressTable implements Component { private readonly tui: ProgressHost; private readonly theme: ProgressTheme; private readonly view: AuditProgressView; private readonly timer: ReturnType | undefined; private readonly meters = new Map(); private readonly meterSettings: MeterSettings; private readonly frozen: boolean; private readonly createdAt = Date.now(); private spinFrame = 0; /** `frozenMeters`, when given, seeds settled traces and skips the ticker entirely — used for the read-only transcript copy. */ constructor( tui: ProgressHost, theme: ProgressTheme, view: AuditProgressView, frozenMeters?: Record, meterSettings: MeterSettings = DEFAULT_METER_SETTINGS, ) { this.tui = tui; this.theme = theme; this.view = view; this.meterSettings = meterSettings; this.frozen = frozenMeters !== undefined; if (frozenMeters !== undefined) { for (const row of view.progressRows()) { const levels = frozenMeters[row.key]; if (!levels) continue; const meter = new ActivityMeter(meterSettings.direction); meter.setLevels(levels); this.meters.set(row.key, { meter, tracker: new TokRateTracker(), revision: row.outputRevision }); } this.timer = undefined; } else { // One timer drives spinner animation, meter sampling and repaint, so the // meter's 100ms cadence matches the reference widget's. this.timer = setInterval(() => { if (!this.view.hasActiveRows()) return; this.spinFrame = (this.spinFrame + 1) % TABLE_FRAMES.length; const rows = this.view.progressRows(); this.sampleMeters(rows, Date.now()); this.tui.requestRender(); }, SPINNER_INTERVAL_MS); } } /** Snapshot every row's current activity trace, for a frozen transcript copy. */ meterLevels(): Record { const result: Record = {}; for (const [key, entry] of this.meters) { result[key] = entry.meter.levels(); } return result; } private sampleMeters(rows: AuditProgressRow[], now: number): void { for (const row of rows) { let entry = this.meters.get(row.key); if (!entry) { entry = { meter: new ActivityMeter(this.meterSettings.direction), tracker: new TokRateTracker(), revision: row.outputRevision }; this.meters.set(row.key, entry); } if (entry.revision !== row.outputRevision) { entry.revision = row.outputRevision; entry.tracker.reset(); } // Settled rows keep their final trace instead of decaying to idle. if (!isActive(row.state)) continue; entry.meter.push(rateToLevel(entry.tracker.sample(row.outputTokens, now))); } } private renderMeter(key: string, active: boolean, phase: AuditPhase): string { const meter = this.meters.get(key)?.meter; if (!meter) return this.theme.fg("dim", "⢀".repeat(ACTIVITY_METER_WIDTH)); const color = resolveMeterColor(this.meterSettings.color, this.view.phaseThinking()[phase]); return meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.theme, color, !active)); } /** How many lines the table may use, so it never crowds out the editor below it. */ private rowBudget(): number { const rows = this.tui.terminal?.rows ?? 0; if (rows <= 0) return Number.POSITIVE_INFINITY; return Math.max(TABLE_CHROME_ROWS, Math.floor(rows * TABLE_HEIGHT_RATIO)); } /** * Two centered rows — phase names, then their assigned model ids — shown * above the table header, with a two-row powerline chevron standing between * adjacent columns to trace the phase flow. Self-suppresses when no phase has * a model, or the terminal is too narrow to keep each column readable. The * active phase (and the chevrons touching it) shimmers in place of its flat * highlighted tone when the theme can supply truecolor ANSI, matching * pi-topping's "working" loader sweep. */ private phaseModelBand(bodyWidth: number, now: number): string[] { const models = this.view.phaseModels(); if (!AUDIT_PHASES.some((phase) => models[phase])) return []; const sepCount = AUDIT_PHASES.length - 1; const colWidth = Math.floor((bodyWidth - sepCount) / AUDIT_PHASES.length); if (colWidth < PHASE_BAND_MIN_COL) return []; const th = this.theme; const active = this.view.activePhase(); const paint = (lit: boolean, text: string) => { if (!lit) return th.fg("dim", text); return th.getFgAnsi ? shimmerString(text, now - this.createdAt, th as ShimmerTheme, "ltr", "normal", true) : th.fg("text", text); }; // Each boundary carries a two-row powerline chevron: the "\" half on the name // row stacks over the "/" half on the model row. Both rows share one column // layout, so the halves land in the same terminal column and read as a // single tall chevron. A separator lights with either phase it divides, so // the active highlight flows along the pipeline. const bandRow = (sep: string, textFor: (phase: AuditPhase) => string): string => { const parts: string[] = []; AUDIT_PHASES.forEach((phase, i) => { parts.push(paint(phase === active, cell(textFor(phase), colWidth, "center"))); const next = AUDIT_PHASES[i + 1]; if (next !== undefined) parts.push(paint(phase === active || next === active, sep)); }); return parts.join(""); }; const nameRow = bandRow(PHASE_SEP_TOP, (phase) => phase); const modelRow = bandRow(PHASE_SEP_BOTTOM, (phase) => shortModelId(models[phase])); return [nameRow, modelRow]; } render(width: number): string[] { const th = this.theme; const innerWidth = Math.max(20, width); const bodyWidth = Math.max(10, innerWidth - 4); const border = (s: string) => th.fg("border", s); const row = (s: string) => ` ${truncateToWidth(s, bodyWidth, "…", true)} `; const band = this.phaseModelBand(bodyWidth, Date.now()); const sourceRows = this.view.progressRows(); const footer = this.footerLines(bodyWidth, totalCost(sourceRows)); const maxBaseRows = Math.max( 1, this.rowBudget() - TABLE_CHROME_ROWS - (footer.length - 1) - (band.length > 0 ? PHASE_BAND_ROWS : 0) - phaseSectionRows(sourceRows), ); const rows = collapseReviewRows(sourceRows, maxBaseRows); const cols = tableColumns( bodyWidth, rows.map((r) => sanitizeTerminalText(r.label)), ); const spin = TABLE_FRAMES[this.spinFrame % TABLE_FRAMES.length] ?? "◐"; const gap = " ".repeat(COLUMN_GAP); // Turns, tool calls, cost, and elapsed are pinned to the right edge so the // activity column, whose values are by far the longest, keeps every column the others don't need. interface LineParams { icon: string; label: string; ctx: string; meter: string; activity: string; turns: string; toolCalls: string; cost: string; elapsed: string } const line = (p: LineParams) => { const parts = [ `${cell(p.icon, STATUS_COL_WIDTH)}${cell(p.label, cols.label)}`, cell(p.ctx, CTX_COL_WIDTH, "right"), p.meter, ]; if (cols.activity > 0) parts.push(cell(p.activity, cols.activity)); if (cols.stats) { parts.push( cell(p.turns, TURNS_COL_WIDTH, "right"), cell(p.toolCalls, TOOLS_COL_WIDTH, "right"), cell(p.cost, COST_COL_WIDTH, "right"), cell(p.elapsed, ELAPSED_COL_WIDTH, "right"), ); } return row(parts.join(gap)); }; const dim = (s: string) => th.fg("dim", s); const lines: string[] = [this.topBorder(innerWidth, border)]; for (const bandLine of band) lines.push(row(bandLine)); if (band.length > 0) lines.push(border("─".repeat(innerWidth))); lines.push( line({ icon: "", label: dim("AGENT"), ctx: dim("CTX"), meter: dim(cell("MONITOR", ACTIVITY_METER_WIDTH)), activity: dim("ACTIVITY"), turns: dim("TURNS"), toolCalls: dim("TOOLS"), cost: dim("COST"), elapsed: dim("TIME"), }), ); const freeBeforeDetail = Math.max( 0, this.rowBudget() - TABLE_CHROME_ROWS - (footer.length - 1) - rows.length - phaseSectionRows(rows) - (band.length > 0 ? PHASE_BAND_ROWS : 0), ); // Nested fix-now detail consumes the same sub-row budget as tool activity. // Shed order on a short terminal: rationale lines first, then the meta // line, then activity sub-rows — the phase status and its cancel hint are // the last lines standing, and agent rows are never touched. let free = freeBeforeDetail; const detailBlocks = new Map(); for (const r of rows) { const detail = isActive(r.state) ? r.fixNowDetail : undefined; if (!detail) continue; const block = this.fixNowDetailBlock(detail, bodyWidth); const tail = [block.status, block.hint]; const head: string[] = []; let room = free - tail.length; if (room >= 1) { head.push(block.meta); room -= 1; } head.push(...block.rationale.slice(0, Math.max(0, room))); free = Math.max(0, free - head.length - tail.length); detailBlocks.set(r.key, [...head, ...tail]); } const activeRows = rows.filter((r) => isActive(r.state)); let subRowBudget = Math.min(free, activeRows.filter((r) => r.activity).length); // Reserve one line under every active row before it reports activity. When // a tool call arrives, it replaces that line instead of growing the table. // On a short terminal, activity still outranks these cosmetic placeholders. const reservedActivitySlots = free >= activeRows.length; let renderedPhase = false; for (const r of rows) { const active = isActive(r.state); const icon = r.state === "done" ? th.fg("success", "✓") : r.state === "error" || r.state === "cancelled" ? th.fg("error", "✗") : r.state === "queued" ? th.fg("dim", "○") : th.fg("accent", spin); const rowLabel = sanitizeTerminalText(r.label); const statusText = sanitizeTerminalText(r.statusText); const label = active ? th.fg("text", rowLabel) : th.fg("dim", rowLabel); const status = r.state === "error" ? th.fg("error", statusText) : th.fg("dim", statusText); if (r.firstOfPhase) { if (renderedPhase) lines.push(row("")); const heading = `── ${r.phase} `; lines.push(row(dim(heading + "─".repeat(Math.max(0, bodyWidth - visibleWidth(heading)))))); renderedPhase = true; } lines.push( line({ icon, label, ctx: contextCell(r.contextTokens, r.contextWindow), meter: this.renderMeter(r.key, active, r.phase), activity: status, turns: String(r.turns), toolCalls: String(r.toolCalls ?? 0), cost: formatCost(r.costUsd), elapsed: formatElapsed(r.elapsedMs), }), ); if (active && r.activity && subRowBudget > 0) { subRowBudget--; lines.push(row(this.activitySubRow(sanitizeTerminalText(r.activity), bodyWidth))); } else if (active && reservedActivitySlots) { lines.push(row("")); } const detailBlock = detailBlocks.get(r.key); if (detailBlock) { for (const detailLine of detailBlock) lines.push(row(detailLine)); } } lines.push(border("─".repeat(innerWidth))); for (const footerLine of footer) lines.push(row(footerLine)); lines.push(border("═".repeat(innerWidth))); return lines; } /** * Run summary on the left; whole-run cost and work time on the right. The * elapsed time is the run's own duration, not the sum of row clocks, which * overlap whenever passes run concurrently, and excludes user-decision spans. */ private footerLines(bodyWidth: number, cost: TotalCost): string[] { const dim = (s: string) => this.theme.fg("dim", s); const summary = sanitizeTerminalText(this.view.footerSummary()); // An asterisk means at least one row has unavailable or invalid cost telemetry. const waiting = this.view.waitingMs(); const waitingSuffix = waiting >= MIN_WAIT_DISPLAY_MS ? ` (+${formatElapsed(waiting)} waiting)` : ""; const total = `total cost ${formatCost(cost.costUsd)}${cost.incomplete ? "*" : ""} total ${formatElapsed(this.view.totalMs())}${waitingSuffix}`; const rhs = this.frozen ? dim(total) : `${dim(total)} ${dim("ctrl+shift+c: cancel")}`; const gap = bodyWidth - visibleWidth(summary) - visibleWidth(rhs); if (gap >= MIN_FOOTER_TOTAL_GAP) return [dim(summary) + " ".repeat(gap) + rhs]; return [dim(summary), cell(rhs, bodyWidth, "right")]; } /** * Merged-cell tool activity line, indented past the status icon so it aligns * with the row labels above. */ private activitySubRow(activity: string, bodyWidth: number): string { const indent = STATUS_COL_WIDTH; const room = Math.max(0, bodyWidth - indent); return `${" ".repeat(indent)}${cell(`${this.theme.fg("dim", "↳ ")}${highlightActivity(this.theme, activity)}`, room)}`; } /** * The nested fix-now block under a `fix now · …` row: meta, wrapped * rationale (capped, with a trailing ellipsis when cut), phase status, and * the cancel/settling hint. Indented like the activity sub-row; the caller * decides how much of it fits the height budget. */ private fixNowDetailBlock( detail: FixNowDetail, bodyWidth: number, ): { meta: string; rationale: string[]; status: string; hint: string } { const indent = " ".repeat(STATUS_COL_WIDTH); const room = Math.max(2, bodyWidth - STATUS_COL_WIDTH); const th = this.theme; const settling = detail.phase === "accepting" || detail.phase === "retrying" || detail.phase === "discarding"; const meta = indent + th.fg("dim", `${sanitizeTerminalText(detail.category)}/${sanitizeTerminalText(detail.severity)}${detail.attempt > 1 ? ` · attempt ${detail.attempt}` : ""}`); const wrapped = wrapText(sanitizeTerminalText(detail.rationale), room); const truncated = wrapped.length > FIX_NOW_RATIONALE_MAX_LINES; const rationale = wrapped.slice(0, FIX_NOW_RATIONALE_MAX_LINES).map((line, index) => indent + th.fg("dim", truncated && index === FIX_NOW_RATIONALE_MAX_LINES - 1 ? `${line} …` : line), ); const phaseLabel = detail.phase === "fixing" ? "implementing fix" : detail.phase === "verifying" ? "verifying fix" : detail.phase === "accepting" ? "accepting fix" : detail.phase === "retrying" ? "preparing retry" : "discarding fix"; const status = indent + th.fg("accent", `${phaseLabel} · ${sanitizeTerminalText(detail.statusText)}`); const hint = settling ? indent + th.fg("dim", "Please wait…") : detail.awaitingCancelConfirm ? indent + th.fg("warning", "Press Esc again to cancel this fix — clean edits will be reverted") : indent + th.fg("dim", "Esc cancel fix"); return { meta, rationale, status, hint }; } private topBorder(innerWidth: number, border: (s: string) => string): string { const title = ` ${TABLE_TITLE} `; const head = `${border("══")}${this.theme.fg("accent", title)}`; const hash = this.view.baseHash ? `@${sanitizeTerminalText(this.view.baseHash)}` : undefined; const scope = this.view.scope ? sanitizeTerminalText(this.view.scope) : undefined; const rhs = [scope, hash].filter(Boolean).join(" · "); const right = rhs ? ` ${rhs} ` : ""; const rightFill = innerWidth - 4 - visibleWidth(title) - visibleWidth(right); // A narrow terminal drops the right side rather than truncating it: a // cut-off path reads as a different scope, and both path and base hash are // in the report. if (right && rightFill >= MIN_TITLE_SCOPE_GAP) { return `${head}${border("═".repeat(rightFill))}${this.theme.fg("dim", right)}${border("══")}`; } const fill = innerWidth - 2 - visibleWidth(title); if (fill < 0) return border("═".repeat(innerWidth)); return `${head}${border("═".repeat(fill))}`; } invalidate(): void {} dispose(): void { if (this.timer) clearInterval(this.timer); } } /** * Redraw a settled run from a persisted `AuditProgressSnapshot` — the frozen, * read-only transcript copy left behind on completion (see * `AUDIT_PROGRESS_ENTRY_TYPE`). Reuses the live render path so the two views * cannot drift, over a stub host with no terminal so the copy is never * row-clipped, and with no active phase so nothing in a finished run stays * highlighted. */ export function renderAuditSnapshot( snapshot: AuditProgressSnapshot, theme: ProgressTheme, meter: MeterSettings = DEFAULT_METER_SETTINGS, ): Component { const host: ProgressHost = { requestRender: () => {} }; const view: AuditProgressView = { progressRows: () => snapshot.rows, footerSummary: () => snapshot.summary, totalMs: () => snapshot.totalMs ?? 0, waitingMs: () => snapshot.waitingMs ?? 0, totalCost: () => totalCost(snapshot.rows), scope: snapshot.scope, baseHash: snapshot.baseHash, phaseModels: () => snapshot.phaseModels, phaseThinking: () => snapshot.phaseThinking ?? {}, activePhase: () => undefined, hasActiveRows: () => false, }; return new AuditProgressTable(host, theme, view, snapshot.meterLevels, meter); }