feat: implemented live status board utility and agent progress tracking

- Implement a live status board utility for transient multi-line CLI status displays with TTY fallback support.
- Add progress callback support and forward subagent progress events to runner hooks.
- Update cleanse execution flow to track checker runs, agent progress, and status rendering.
- Add comprehensive unit tests for live board repainting and cleanse progress assertions.
This commit is contained in:
can1357
2026-08-20 03:22:29 +02:00
parent 1088cc349c
commit eced7ab08a
11 changed files with 540 additions and 86 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ import {
} from "./agent-loop"; } from "./agent-loop";
import type { AppendOnlyContextManager } from "./append-only-context"; import type { AppendOnlyContextManager } from "./append-only-context";
import { isProviderRefusalMessage } from "./replay-policy"; import { isProviderRefusalMessage } from "./replay-policy";
import { tokenizerEncodingForModel, Tokenizer } from "./tokenizer"; import { Tokenizer, tokenizerEncodingForModel } from "./tokenizer";
import type { import type {
AgentBeforeModelCall, AgentBeforeModelCall,
AgentContext, AgentContext,
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Encoding } from "@oh-my-pi/pi-natives"; import { Encoding } from "@oh-my-pi/pi-natives";
import { tokenizerEncodingForModel, Tokenizer } from "../src/tokenizer"; import { Tokenizer, tokenizerEncodingForModel } from "../src/tokenizer";
// Contract: the catalog resolves model identity once as Model.tokenizer; the // Contract: the catalog resolves model identity once as Model.tokenizer; the
// agent maps that catalog property to the matching native counter. A wrong // agent maps that catalog property to the matching native counter. A wrong
+1 -3
View File
@@ -35,9 +35,7 @@ function deepSeekTokenizer(modelId: string): ModelTokenizer | undefined {
const lower = modelId.toLowerCase(); const lower = modelId.toLowerCase();
if (lower.includes("distill")) return undefined; if (lower.includes("distill")) return undefined;
if (DEEPSEEK_V3_ALIASES[lower]) return "deepseek-v3"; if (DEEPSEEK_V3_ALIASES[lower]) return "deepseek-v3";
return /(?:^|[-_.:])(?:v?[34]|r1)(?:[-_.:]|$)/.test(lower) && lower.includes("deepseek") return /(?:^|[-_.:])(?:v?[34]|r1)(?:[-_.:]|$)/.test(lower) && lower.includes("deepseek") ? "deepseek-v3" : undefined;
? "deepseek-v3"
: undefined;
} }
function kimiTokenizer(modelId: string): ModelTokenizer | undefined { function kimiTokenizer(modelId: string): ModelTokenizer | undefined {
+1
View File
@@ -11,6 +11,7 @@
### Changed ### Changed
- `omp cleanse` now renders a live status board on interactive terminals instead of a single static completion bar: a spinner for model resolution and checker discovery, one row per running checker, and one row per repair subagent showing its latest activity, tool count, token/cost totals, and elapsed time. Finished checkers and agents are promoted to permanent `✓`/`●`/`✗` scrollback lines as they settle. Non-TTY output keeps the previous plain-line protocol.
- Replaced the single `compaction.strategy` / `compaction.remoteEnabled` policy with ordered `compaction.methodOrder` preferences. The default now tries OpenAI-compatible server compaction, snapcompact, handoff, shake, then soft compaction; unavailable or failed methods advance through that list. - Replaced the single `compaction.strategy` / `compaction.remoteEnabled` policy with ordered `compaction.methodOrder` preferences. The default now tries OpenAI-compatible server compaction, snapcompact, handoff, shake, then soft compaction; unavailable or failed methods advance through that list.
- `/settings` rows can now carry a risk note: a warning glyph on the row plus a warning-colored line above the description. `External Thinking` (`externalThinking`, `--external-thinking`) is the first user — providers have flagged the request shape it produces as abuse, up to account-level enforcement, so both the settings entry and `--help` now say so. - `/settings` rows can now carry a risk note: a warning glyph on the row plus a warning-colored line above the description. `External Thinking` (`externalThinking`, `--external-thinking`) is the first user — providers have flagged the request shape it produces as abuse, up to account-level enforcement, so both the settings entry and `--help` now say so.
- The todo HUD header now draws a summed progress bar counting closed/total tasks across every stage. Once all tasks close, the bar smoothly collapses before the row disappears. - The todo HUD header now draws a summed progress bar counting closed/total tasks across every stage. Once all tasks close, the bar smoothly collapses before the row disappears.
@@ -7,6 +7,7 @@ import { discoverAuthStorage } from "../sdk";
import { SessionManager } from "../session/session-manager"; import { SessionManager } from "../session/session-manager";
import { mapWithConcurrencyLimitAllSettled } from "../task/parallel"; import { mapWithConcurrencyLimitAllSettled } from "../task/parallel";
import { runStructuredSubagent } from "../task/structured-subagent"; import { runStructuredSubagent } from "../task/structured-subagent";
import type { AgentProgress } from "../task/types";
import type { ToolSession } from "../tools"; import type { ToolSession } from "../tools";
import { EventBus } from "../utils/event-bus"; import { EventBus } from "../utils/event-bus";
import type { CustomCleanseCheckerSpec } from "./checkers"; import type { CustomCleanseCheckerSpec } from "./checkers";
@@ -49,6 +50,8 @@ const DISCOVERY_SCHEMA = {
/** Hooks used by the standalone command to render subagent lifecycle progress. */ /** Hooks used by the standalone command to render subagent lifecycle progress. */
export interface CleanseAgentHooks { export interface CleanseAgentHooks {
onStart?(name: string, assignment: CleanseAssignment): void; onStart?(name: string, assignment: CleanseAssignment): void;
/** Streaming progress snapshots from a running repair subagent. */
onProgress?(name: string, assignment: CleanseAssignment, progress: AgentProgress): void;
onFinish?(outcome: CleanseAgentOutcome, assignment: CleanseAssignment): void; onFinish?(outcome: CleanseAgentOutcome, assignment: CleanseAssignment): void;
} }
@@ -168,6 +171,7 @@ export async function createCleanseAgentRuntime(options: {
enableLsp: true, enableLsp: true,
enableIrc: true, enableIrc: true,
signal: workerSignal, signal: workerSignal,
onProgress: progress => options.hooks?.onProgress?.(name, assignment, progress),
}); });
const outcome: CleanseAgentOutcome = { const outcome: CleanseAgentOutcome = {
name, name,
+237
View File
@@ -0,0 +1,237 @@
/**
* Live status board for `omp cleanse`.
*
* Interactive terminals get a transient board repainted in place: a phase
* spinner (model resolution, checker discovery), one row per running checker,
* and one row per repair subagent showing its latest intent, current tool,
* tool count, and elapsed time from {@link AgentProgress} snapshots. Finished
* work is promoted to permanent scrollback lines as it settles.
*
* Non-TTY output keeps the original plain-line protocol
* (`[start]`/`[done]`/`[fail]`), so scripted callers see unchanged output.
*/
import { formatDuration, formatNumber, sanitizeText } from "@oh-my-pi/pi-utils";
import chalk from "@oh-my-pi/pi-utils/chalk";
import { createLiveBoard, type LiveBoardOutput } from "../cli/live-board";
import type { AgentProgress } from "../task/types";
import type { CleanseCheckerDescriptor } from "./checkers";
import type { CleanseAgentOutcome, CleanseAssignment, CleanseCheckResult } from "./types";
const BAR_WIDTH = 16;
const ACTIVITY_WIDTH = 96;
const ERROR_WIDTH = 300;
/** Rendering surface for one `omp cleanse` run. */
export interface CleanseStatusBoard {
readonly interactive: boolean;
/** Print a permanent line above the live area (plain write when non-TTY). */
log(text: string): void;
/** Show a transient spinner line; `undefined` clears it. Non-TTY prints the text once. */
phase(text: string | undefined): void;
checkerStarted(checker: CleanseCheckerDescriptor): void;
checkerFinished(check: CleanseCheckResult, durationMs: number): void;
/** Begin a repair wave of `total` subagents; resets the completion bar. */
waveStarted(total: number): void;
/** End the repair wave and drop its live rows. */
waveFinished(): void;
agentStarted(name: string, assignment: CleanseAssignment): void;
agentProgress(name: string, progress: AgentProgress): void;
agentFinished(outcome: CleanseAgentOutcome, assignment: CleanseAssignment): void;
/** Clear the live area and restore the cursor. Idempotent. */
close(): void;
}
interface RunningChecker {
label: string;
startedAt: number;
}
interface RunningAgent {
assignment: CleanseAssignment;
startedAt: number;
progress?: AgentProgress;
}
/** Create the cleanse status board bound to `output` (default `process.stdout`). */
export function createCleanseStatusBoard(
output: LiveBoardOutput = process.stdout,
errors: LiveBoardOutput = process.stderr,
): CleanseStatusBoard {
let phaseText: string | undefined;
const checkers = new Map<string, RunningChecker>();
const agents = new Map<string, RunningAgent>();
/** Lifetime token/cost totals per agent; survives row removal for the header sums. */
const totals = new Map<string, { tokens: number; cost: number }>();
let waveTotal = 0;
let waveDone = 0;
let waveStartedAt = 0;
const render = (spinner: string): string[] => {
const lines: string[] = [];
if (phaseText) lines.push(`${chalk.yellow(spinner)} ${phaseText}`);
for (const checker of checkers.values()) {
const elapsed = formatDuration(Date.now() - checker.startedAt);
lines.push(`${chalk.yellow(spinner)} ${checker.label} ${chalk.dim(`· ${elapsed}`)}`);
}
if (waveTotal > 0) {
lines.push(renderWaveHeader(spinner, waveTotal, waveDone, agents.size, totals, waveStartedAt));
const rows = [...agents.entries()].sort((left, right) => left[1].assignment.index - right[1].assignment.index);
for (const [name, agent] of rows) lines.push(renderAgentRow(spinner, name, agent));
}
return lines;
};
const board = createLiveBoard(render, output);
return {
interactive: board.interactive,
log: board.log,
phase(text) {
if (!board.interactive) {
if (text) output.write(`${text}\n`);
return;
}
phaseText = text;
board.repaint();
},
checkerStarted(checker) {
if (!board.interactive) return;
checkers.set(checker.id, { label: checker.label, startedAt: Date.now() });
board.repaint();
},
checkerFinished(check, durationMs) {
if (!board.interactive) return;
checkers.delete(check.id);
const count = check.diagnostics.length;
const verdict = count === 0 ? chalk.green("clean") : chalk.yellow(`${count} issue${count === 1 ? "" : "s"}`);
const glyph = count === 0 ? chalk.green("✓") : chalk.yellow("●");
board.log(`${glyph} ${check.label} ${verdict} ${chalk.dim(`· ${formatDuration(durationMs)}`)}`);
},
waveStarted(total) {
waveTotal = Math.max(total, 0);
waveDone = 0;
waveStartedAt = Date.now();
agents.clear();
totals.clear();
board.repaint();
},
waveFinished() {
waveTotal = 0;
agents.clear();
board.repaint();
},
agentStarted(name, assignment) {
if (!board.interactive) {
const files = assignment.groups.map(group => group.file ?? "<project>").join(", ");
output.write(`[start] ${name}: ${files} (weight ${assignment.weight})\n`);
return;
}
agents.set(name, { assignment, startedAt: Date.now() });
board.repaint();
},
agentProgress(name, progress) {
totals.set(name, { tokens: progress.tokens, cost: progress.cost });
if (!board.interactive) return;
const agent = agents.get(name);
if (agent) agent.progress = progress;
},
agentFinished(outcome, assignment) {
if (!board.interactive) {
if (outcome.success) {
output.write(`[done] ${outcome.name}${outcome.resolvedModel ? ` (${outcome.resolvedModel})` : ""}\n`);
} else {
errors.write(`[fail] ${outcome.name}: ${oneLine(outcome.error ?? "subagent failed", ERROR_WIDTH)}\n`);
}
return;
}
const agent = agents.get(outcome.name);
agents.delete(outcome.name);
waveDone = Math.min(waveDone + 1, waveTotal);
board.log(renderOutcomeLine(outcome, assignment, agent, totals.get(outcome.name)));
},
close: board.close,
};
}
function renderWaveHeader(
spinner: string,
total: number,
done: number,
running: number,
totals: ReadonlyMap<string, { tokens: number; cost: number }>,
startedAt: number,
): string {
const filled = Math.round(Math.min(done / total, 1) * BAR_WIDTH);
const bar = chalk.cyan("█".repeat(filled)) + chalk.dim("░".repeat(BAR_WIDTH - filled));
let tokens = 0;
let cost = 0;
for (const entry of totals.values()) {
tokens += entry.tokens;
cost += entry.cost;
}
const parts = [`${done}/${total}`];
if (running > 0) parts.push(`${running} running`);
if (tokens > 0) parts.push(`${formatNumber(tokens)} tok`);
if (cost > 0) parts.push(formatCost(cost));
parts.push(formatDuration(Date.now() - startedAt));
return `${chalk.cyan(spinner)} Repairing [${bar}] ${parts.join(chalk.dim(" · "))}`;
}
function renderAgentRow(spinner: string, agentName: string, agent: RunningAgent): string {
const label = agentName.replace(/^Cleanse/, "");
const meta: string[] = [];
const toolCount = agent.progress?.toolCount ?? 0;
if (toolCount > 0) meta.push(`${toolCount} tool${toolCount === 1 ? "" : "s"}`);
meta.push(formatDuration(Date.now() - agent.startedAt));
return (
`${chalk.yellow(spinner)} ${chalk.bold(label)} ${compactFiles(agent.assignment)} ` +
`${chalk.dim("·")} ${agentActivity(agent.progress)} ${chalk.dim(`· ${meta.join(" · ")}`)}`
);
}
function renderOutcomeLine(
outcome: CleanseAgentOutcome,
assignment: CleanseAssignment,
agent: RunningAgent | undefined,
total: { tokens: number; cost: number } | undefined,
): string {
const files = compactFiles(assignment);
if (!outcome.success) {
return `${chalk.red("✗")} ${outcome.name} ${files} ${chalk.red(oneLine(outcome.error ?? "subagent failed", ERROR_WIDTH))}`;
}
const meta: string[] = [];
const toolCount = agent?.progress?.toolCount ?? 0;
if (toolCount > 0) meta.push(`${toolCount} tool${toolCount === 1 ? "" : "s"}`);
if (total && total.tokens > 0) meta.push(`${formatNumber(total.tokens)} tok`);
if (agent) meta.push(formatDuration(Date.now() - agent.startedAt));
const suffix = meta.length > 0 ? ` ${chalk.dim(`· ${meta.join(" · ")}`)}` : "";
return `${chalk.green("✓")} ${outcome.name} ${files}${suffix}`;
}
/** Latest human-readable activity for a repair agent row. */
function agentActivity(progress: AgentProgress | undefined): string {
if (!progress) return chalk.dim("starting");
if (progress.retryState) {
return chalk.yellow(`rate-limited · retry ${progress.retryState.attempt}/${progress.retryState.maxAttempts}`);
}
const intent = oneLine(progress.lastIntent ?? "", ACTIVITY_WIDTH);
if (progress.currentTool) {
const args = oneLine(progress.currentToolArgs ?? "", ACTIVITY_WIDTH);
const tool = chalk.dim(args ? `${progress.currentTool} ${args}` : progress.currentTool);
return intent ? `${intent} ${tool}` : tool;
}
return intent || chalk.dim("thinking");
}
function compactFiles(assignment: CleanseAssignment): string {
const files = assignment.groups.map(group => group.file ?? "<project>");
const first = files[0] ?? "<project>";
return files.length > 1 ? `${first} +${files.length - 1}` : first;
}
function oneLine(text: string, width: number): string {
return sanitizeText(text).replace(/\s+/g, " ").trim().slice(0, width);
}
function formatCost(cost: number): string {
return `$${cost >= 0.095 ? cost.toFixed(2) : cost.toFixed(3)}`;
}
+22 -6
View File
@@ -68,6 +68,12 @@ export interface CleanseCheckerDescriptor {
command: string; command: string;
} }
/** Lifecycle notifications for one {@link CleanseDiagnosticSuite.run} pass, used by the CLI status board. */
export interface CleanseCheckerRunEvents {
onCheckerStart?(checker: CleanseCheckerDescriptor): void;
onCheckerEnd?(check: CleanseCheckResult, durationMs: number): void;
}
/** Re-runnable checker set discovered from one project snapshot. */ /** Re-runnable checker set discovered from one project snapshot. */
export interface CleanseDiagnosticSuite { export interface CleanseDiagnosticSuite {
/** Every discovered checker; unaffected by {@link CleanseDiagnosticSuite.select}. */ /** Every discovered checker; unaffected by {@link CleanseDiagnosticSuite.select}. */
@@ -75,7 +81,7 @@ export interface CleanseDiagnosticSuite {
readonly skipped: readonly SkippedCleanseCheck[]; readonly skipped: readonly SkippedCleanseCheck[];
/** Narrow subsequent {@link CleanseDiagnosticSuite.run} calls to the named checker ids. */ /** Narrow subsequent {@link CleanseDiagnosticSuite.run} calls to the named checker ids. */
select(ids: readonly string[]): void; select(ids: readonly string[]): void;
run(signal?: AbortSignal): Promise<CleanseDiagnosticReport>; run(signal?: AbortSignal, events?: CleanseCheckerRunEvents): Promise<CleanseDiagnosticReport>;
} }
/** Discover configured language checkers without installing missing tools. */ /** Discover configured language checkers without installing missing tools. */
@@ -128,14 +134,24 @@ function createSuite(
const wanted = new Set(ids); const wanted = new Set(ids);
active = plans.filter(plan => wanted.has(plan.id)); active = plans.filter(plan => wanted.has(plan.id));
}, },
async run(signal?: AbortSignal): Promise<CleanseDiagnosticReport> { async run(signal?: AbortSignal, events?: CleanseCheckerRunEvents): Promise<CleanseDiagnosticReport> {
const execute = async (plan: CheckerPlan): Promise<CleanseCheckResult> => {
events?.onCheckerStart?.({
id: plan.id,
label: plan.label,
language: plan.language,
command: plan.command,
});
const startedAt = Date.now();
const check = await runChecker(plan, projectCwd, allowedFiles, signal);
events?.onCheckerEnd?.(check, Date.now() - startedAt);
return check;
};
const mutatingChecks: CleanseCheckResult[] = []; const mutatingChecks: CleanseCheckResult[] = [];
for (const plan of active) { for (const plan of active) {
if (plan.mutates) mutatingChecks.push(await runChecker(plan, projectCwd, allowedFiles, signal)); if (plan.mutates) mutatingChecks.push(await execute(plan));
} }
const parallelChecks = await Promise.all( const parallelChecks = await Promise.all(active.filter(plan => !plan.mutates).map(execute));
active.filter(plan => !plan.mutates).map(plan => runChecker(plan, projectCwd, allowedFiles, signal)),
);
const checks = [...mutatingChecks, ...parallelChecks]; const checks = [...mutatingChecks, ...parallelChecks];
return { return {
checks, checks,
+49 -66
View File
@@ -1,12 +1,17 @@
import { getProjectDir, sanitizeText } from "@oh-my-pi/pi-utils"; import { getProjectDir } from "@oh-my-pi/pi-utils";
import { pickCleanseTarget, promptCleanseRequest } from "../cli/cleanse-picker"; import { pickCleanseTarget, promptCleanseRequest } from "../cli/cleanse-picker";
import { createProgressReporter } from "../cli/progress-reporter";
import { shortenPath } from "../tools/render-utils"; import { shortenPath } from "../tools/render-utils";
import { type CleanseAgentHooks, type CleanseAgentRuntime, createCleanseAgentRuntime } from "./agent"; import { type CleanseAgentHooks, type CleanseAgentRuntime, createCleanseAgentRuntime } from "./agent";
import { groupDiagnosticsByFile } from "./balance"; import { groupDiagnosticsByFile } from "./balance";
import { buildCustomCleanseSuite, type CleanseDiagnosticSuite, discoverCleanseDiagnosticSuite } from "./checkers"; import { createCleanseStatusBoard } from "./board";
import {
buildCustomCleanseSuite,
type CleanseCheckerRunEvents,
type CleanseDiagnosticSuite,
discoverCleanseDiagnosticSuite,
} from "./checkers";
import { runCleanseLoop } from "./loop"; import { runCleanseLoop } from "./loop";
import type { CleanseAgentOutcome, CleanseAssignment, CleanseDiagnosticReport, CleanseLoopResult } from "./types"; import type { CleanseDiagnosticReport, CleanseLoopResult } from "./types";
const DEFAULT_MODEL = "@smol"; const DEFAULT_MODEL = "@smol";
const DISPLAY_FILE_LIMIT = 50; const DISPLAY_FILE_LIMIT = 50;
@@ -42,33 +47,26 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
process.once("SIGTERM", abort); process.once("SIGTERM", abort);
let runtime: CleanseAgentRuntime | undefined; let runtime: CleanseAgentRuntime | undefined;
let loopResult: CleanseLoopResult | undefined; let loopResult: CleanseLoopResult | undefined;
const progress = createProgressReporter("Repairing"); const board = createCleanseStatusBoard();
const interactiveFailures: CleanseAgentOutcome[] = [];
let interactiveFailuresPrinted = false;
const printInteractiveFailures = (): void => {
if (!progress.interactive || interactiveFailuresPrinted) return;
interactiveFailuresPrinted = true;
for (const outcome of interactiveFailures) printAgentOutcome(outcome);
};
const hooks: CleanseAgentHooks = { const hooks: CleanseAgentHooks = {
onStart(name, assignment) { onStart: (name, assignment) => board.agentStarted(name, assignment),
if (progress.interactive) return; onProgress: (name, _assignment, progress) => board.agentProgress(name, progress),
process.stdout.write(`[start] ${name}: ${formatAssignmentFiles(assignment)} (weight ${assignment.weight})\n`); onFinish: (outcome, assignment) => board.agentFinished(outcome, assignment),
}, };
onFinish(outcome) { const checkerEvents: CleanseCheckerRunEvents = {
progress.complete(); onCheckerStart: checker => board.checkerStarted(checker),
if (progress.interactive) { onCheckerEnd: (check, durationMs) => board.checkerFinished(check, durationMs),
if (!outcome.success) interactiveFailures.push(outcome);
return;
}
printAgentOutcome(outcome);
},
}; };
const ensureRuntime = async (): Promise<CleanseAgentRuntime> => { const ensureRuntime = async (): Promise<CleanseAgentRuntime> => {
if (runtime) return runtime; if (runtime) return runtime;
process.stdout.write(`Resolving model ${model}...\n`); board.phase(`Resolving model ${model}...`);
runtime = await createCleanseAgentRuntime({ cwd, model, hooks }); try {
process.stdout.write(`Model: ${runtime.model}\nSession: ${shortenPath(runtime.sessionFile)}\n`); runtime = await createCleanseAgentRuntime({ cwd, model, hooks });
} finally {
board.phase(undefined);
}
board.log(`Model: ${runtime.model}`);
board.log(`Session: ${shortenPath(runtime.sessionFile)}`);
return runtime; return runtime;
}; };
@@ -76,8 +74,9 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
let request = options.request?.trim() || undefined; let request = options.request?.trim() || undefined;
let suite: CleanseDiagnosticSuite | undefined; let suite: CleanseDiagnosticSuite | undefined;
if (!request) { if (!request) {
process.stdout.write("Detecting configured project checkers...\n"); board.phase("Detecting configured project checkers...");
suite = await discoverCleanseDiagnosticSuite(cwd, { includeTests: options.includeTests }); suite = await discoverCleanseDiagnosticSuite(cwd, { includeTests: options.includeTests });
board.phase(undefined);
const interactive = options.all !== true && process.stdin.isTTY === true && process.stdout.isTTY === true; const interactive = options.all !== true && process.stdin.isTTY === true && process.stdout.isTTY === true;
if (interactive) { if (interactive) {
if (suite.checkers.length > 0) { if (suite.checkers.length > 0) {
@@ -113,11 +112,15 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
} }
if (request) { if (request) {
const activeRuntime = await ensureRuntime(); const activeRuntime = await ensureRuntime();
process.stdout.write(`Discovering checkers for "${request}"...\n`); board.phase(`Discovering checkers for "${request}"...`);
const specs = await activeRuntime.discoverCheckers(request, abortController.signal); try {
suite = await buildCustomCleanseSuite(cwd, specs); const specs = await activeRuntime.discoverCheckers(request, abortController.signal);
suite = await buildCustomCleanseSuite(cwd, specs);
} finally {
board.phase(undefined);
}
for (const checker of suite.checkers) { for (const checker of suite.checkers) {
process.stdout.write(`[checker] ${checker.label}: ${checker.command}\n`); board.log(`[checker] ${checker.label}: ${checker.command}`);
} }
} }
if (!suite || suite.checkers.length === 0) { if (!suite || suite.checkers.length === 0) {
@@ -130,8 +133,9 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
); );
return { exitCode: 1, status: "unsupported", report, sessionFile: runtime?.sessionFile }; return { exitCode: 1, status: "unsupported", report, sessionFile: runtime?.sessionFile };
} }
const initialReport = await suite.run(abortController.signal); const initialReport = await suite.run(abortController.signal, checkerEvents);
printCheckReport(initialReport); if (board.interactive) printSkippedChecks(initialReport);
else printCheckReport(initialReport);
if (initialReport.diagnostics.length === 0) { if (initialReport.diagnostics.length === 0) {
process.stdout.write( process.stdout.write(
`Clean: ${initialReport.checks.length} checker${initialReport.checks.length === 1 ? "" : "s"} passed.\n`, `Clean: ${initialReport.checks.length} checker${initialReport.checks.length === 1 ? "" : "s"} passed.\n`,
@@ -142,33 +146,29 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
const assignments = groupDiagnosticsByFile(initialReport.diagnostics); const assignments = groupDiagnosticsByFile(initialReport.diagnostics);
const agentCount = Math.min(maxAgents, assignments.length); const agentCount = Math.min(maxAgents, assignments.length);
const fileCount = assignments.filter(group => group.file !== undefined).length; const fileCount = assignments.filter(group => group.file !== undefined).length;
process.stdout.write( board.log(
`Found ${initialReport.diagnostics.length} diagnostic${initialReport.diagnostics.length === 1 ? "" : "s"} across ${fileCount} file${fileCount === 1 ? "" : "s"}; launching ${agentCount} subagent${agentCount === 1 ? "" : "s"}.\n`, `Found ${initialReport.diagnostics.length} diagnostic${initialReport.diagnostics.length === 1 ? "" : "s"} across ${fileCount} file${fileCount === 1 ? "" : "s"}; launching ${agentCount} subagent${agentCount === 1 ? "" : "s"}.`,
); );
const activeRuntime = await ensureRuntime(); const activeRuntime = await ensureRuntime();
const activeSuite = suite; const activeSuite = suite;
loopResult = await runCleanseLoop( loopResult = await runCleanseLoop(
{ maxAgents, initialReport, signal: abortController.signal }, { maxAgents, initialReport, signal: abortController.signal },
{ {
collect: signal => activeSuite.run(signal), collect: signal => activeSuite.run(signal, checkerEvents),
dispatch: (batch, wave, report, signal) => activeRuntime.dispatch(batch, wave, report, signal), dispatch: (batch, wave, report, signal) => activeRuntime.dispatch(batch, wave, report, signal),
onWave(_wave, batch) { onWave(_wave, batch) {
process.stdout.write( board.log(`Dispatching ${batch.length} weighted assignment${batch.length === 1 ? "" : "s"}...`);
`Dispatching ${batch.length} weighted assignment${batch.length === 1 ? "" : "s"}...\n`, board.waveStarted(batch.length);
);
progress.start(batch.length);
}, },
onReport(_wave, report) { onReport(_wave, report) {
progress.finish(); board.waveFinished();
printInteractiveFailures(); board.log(
process.stdout.write( `Verification: ${report.diagnostics.length} diagnostic${report.diagnostics.length === 1 ? "" : "s"} remaining.`,
`Verification: ${report.diagnostics.length} diagnostic${report.diagnostics.length === 1 ? "" : "s"} remaining.\n`,
); );
}, },
}, },
); );
progress.finish(); board.close();
printInteractiveFailures();
await activeRuntime.close(loopResult); await activeRuntime.close(loopResult);
if (loopResult.status === "cancelled") { if (loopResult.status === "cancelled") {
process.stderr.write("Cleanse cancelled.\n"); process.stderr.write("Cleanse cancelled.\n");
@@ -188,30 +188,17 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
} catch (error) { } catch (error) {
if (!abortController.signal.aborted) throw error; if (!abortController.signal.aborted) throw error;
const report: CleanseDiagnosticReport = loopResult?.report ?? { checks: [], diagnostics: [], skipped: [] }; const report: CleanseDiagnosticReport = loopResult?.report ?? { checks: [], diagnostics: [], skipped: [] };
progress.finish(); board.close();
printInteractiveFailures();
process.stderr.write("Cleanse cancelled.\n"); process.stderr.write("Cleanse cancelled.\n");
return { exitCode: 130, status: "cancelled", report, sessionFile: runtime?.sessionFile }; return { exitCode: 130, status: "cancelled", report, sessionFile: runtime?.sessionFile };
} finally { } finally {
progress.finish(); board.close();
printInteractiveFailures();
process.off("SIGINT", abort); process.off("SIGINT", abort);
process.off("SIGTERM", abort); process.off("SIGTERM", abort);
await runtime?.close(loopResult); await runtime?.close(loopResult);
} }
} }
function printAgentOutcome(outcome: CleanseAgentOutcome): void {
if (outcome.success) {
process.stdout.write(`[done] ${outcome.name}${outcome.resolvedModel ? ` (${outcome.resolvedModel})` : ""}\n`);
return;
}
const error = sanitizeText(outcome.error ?? "subagent failed")
.replace(/\s+/g, " ")
.slice(0, 300);
process.stderr.write(`[fail] ${outcome.name}: ${error}\n`);
}
function printCheckReport(report: CleanseDiagnosticReport): void { function printCheckReport(report: CleanseDiagnosticReport): void {
for (const check of report.checks) { for (const check of report.checks) {
const count = check.diagnostics.length; const count = check.diagnostics.length;
@@ -226,10 +213,6 @@ function printSkippedChecks(report: CleanseDiagnosticReport): void {
} }
} }
function formatAssignmentFiles(assignment: CleanseAssignment): string {
return assignment.groups.map(group => group.file ?? "<project>").join(", ");
}
function printRemaining(report: CleanseDiagnosticReport): void { function printRemaining(report: CleanseDiagnosticReport): void {
const groups = groupDiagnosticsByFile(report.diagnostics); const groups = groupDiagnosticsByFile(report.diagnostics);
process.stderr.write( process.stderr.write(
+123
View File
@@ -0,0 +1,123 @@
/**
* Transient multi-line status board for standalone CLI commands.
*
* Repaints a caller-rendered block of lines in place at ~12.5fps while
* permanent output logged through {@link LiveBoard.log} scrolls above it.
* Non-TTY outputs disable rendering entirely and `log` degrades to plain
* writes, so callers keep one code path for both modes.
*/
import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
const RENDER_INTERVAL_MS = 80;
/** Braille spinner advanced once per repaint tick. */
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/** Output contract for the live board (satisfied by `process.stdout`). */
export interface LiveBoardOutput {
isTTY?: boolean;
columns?: number;
rows?: number;
write(text: string): boolean;
}
/** Live repaint handle returned by {@link createLiveBoard}. */
export interface LiveBoard {
readonly interactive: boolean;
/** Print a permanent line above the board; plain write when non-interactive. */
log(text: string): void;
/** Repaint immediately after a state change instead of waiting for the next tick. */
repaint(): void;
/** Clear the board, stop the timer, and restore the cursor. */
close(): void;
}
/**
* Create a live board whose content comes from `render(spinner, width)` on
* every tick. An empty render result paints nothing and releases the cursor,
* so an idle board never interferes with other terminal UI (e.g. pickers).
*/
export function createLiveBoard(
render: (spinner: string, width: number) => string[],
output: LiveBoardOutput = process.stdout,
): LiveBoard {
const interactive = output.isTTY === true;
let frame = 0;
let lineCount = 0;
let cursorHidden = false;
let closed = false;
let timer: NodeJS.Timeout | undefined;
const dimensions = (): { width: number; maxRows: number } => {
const columns = output.columns ?? 0;
const rows = output.rows ?? 0;
return {
width: Number.isFinite(columns) && columns > 0 ? Math.trunc(columns) : 80,
maxRows: Math.max(4, (Number.isFinite(rows) && rows > 0 ? Math.trunc(rows) : 24) - 2),
};
};
const paint = (lines: string[]): void => {
// Erase each line explicitly and cap it to the terminal width so the
// `\x1b[<n>A` cursor-up always matches the logical line count, even in
// raw-mode terminals without ONLCR where wrapped rows would otherwise
// staircase into scrollback.
if (lines.length === 0 && lineCount === 0) return;
const { width } = dimensions();
let out = lineCount > 0 ? `\x1b[${lineCount}A` : "";
out += "\r";
if (lines.length > 0) {
out += `${lines.map(line => `\x1b[2K${truncateToWidth(replaceTabs(line), width)}`).join("\r\n")}\r\n`;
}
out += "\x1b[0J";
if (lines.length > 0 && !cursorHidden) {
out += "\x1b[?25l";
cursorHidden = true;
} else if (lines.length === 0 && cursorHidden) {
out += "\x1b[?25h";
cursorHidden = false;
}
output.write(out);
lineCount = lines.length;
};
const repaint = (): void => {
if (!interactive || closed) return;
const { width, maxRows } = dimensions();
const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? "*";
let lines = render(spinner, width);
if (lines.length > maxRows) {
lines = [...lines.slice(0, maxRows - 1), `… +${lines.length - (maxRows - 1)} more`];
}
paint(lines);
};
if (interactive) {
timer = setInterval(() => {
frame += 1;
repaint();
}, RENDER_INTERVAL_MS);
timer.unref?.();
}
return {
interactive,
log(text) {
if (closed || !interactive) {
output.write(`${text}\n`);
return;
}
paint([]);
output.write(`${text}\n`);
repaint();
},
repaint,
close() {
if (closed) return;
closed = true;
if (!interactive) return;
clearInterval(timer);
paint([]);
},
};
}
+35 -9
View File
@@ -156,7 +156,7 @@ describe("cleanse progress", () => {
expect(writes[3]).toBe("\n"); expect(writes[3]).toBe("\n");
}); });
test("updates the command's TTY bar as repair workers finish", async () => { test("renders a live repair board and permanent outcome lines on TTY output", async () => {
const output: string[] = []; const output: string[] = [];
const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
@@ -169,12 +169,29 @@ describe("cleanse progress", () => {
const clean = report([]); const clean = report([]);
let runCount = 0; let runCount = 0;
const suite: cleanseCheckers.CleanseDiagnosticSuite = { const suite: cleanseCheckers.CleanseDiagnosticSuite = {
checkers: [{ id: "mock", label: "mock", language: "Test", command: "mock" }], checkers: [{ id: "mock", label: "mock checker", language: "Test", command: "mock" }],
skipped: [], skipped: [],
select() {}, select() {},
async run() { async run(_signal, events) {
runCount += 1; runCount += 1;
return runCount === 1 ? initial : clean; const current = runCount === 1 ? initial : clean;
const descriptor = suite.checkers[0];
if (descriptor) {
events?.onCheckerStart?.(descriptor);
events?.onCheckerEnd?.(
{
id: descriptor.id,
label: descriptor.label,
language: descriptor.language,
cwd: "/repo",
command: descriptor.command,
exitCode: current.diagnostics.length === 0 ? 0 : 1,
diagnostics: current.diagnostics,
},
5,
);
}
return current;
}, },
}; };
let hooks: cleanseAgent.CleanseAgentHooks | undefined; let hooks: cleanseAgent.CleanseAgentHooks | undefined;
@@ -205,11 +222,20 @@ describe("cleanse progress", () => {
const result = await runCleanseCommand({ maxAgents: 2, all: true }); const result = await runCleanseCommand({ maxAgents: 2, all: true });
expect(result.status).toBe("clean"); expect(result.status).toBe("clean");
const updates = output.filter(chunk => chunk.startsWith("\rRepairing [")); // Strip ANSI control sequences; the board's repaint framing is not the contract.
expect(updates).toHaveLength(3); const text = output.join("").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
expect(updates[0]).toContain("0/2"); // Live wave header frames as workers finish.
expect(updates[1]).toContain("1/2"); expect(text).toContain("Repairing [");
expect(updates[2]).toContain("2/2"); expect(text).toContain("0/2");
expect(text).toContain("1/2");
expect(text).toContain("2/2");
// Checker results and agent outcomes promoted to permanent lines.
expect(text).toMatch(/●.*mock checker.*2 issues/);
expect(text).toMatch(/✓.*mock checker.*clean/);
expect(text).toMatch(/✓.*CleanseW1A1/);
expect(text).toMatch(/✓.*CleanseW1A2/);
expect(text).toContain("a.rs");
expect(text).toContain("b.rs");
} finally { } finally {
if (isTtyDescriptor) Object.defineProperty(process.stdout, "isTTY", isTtyDescriptor); if (isTtyDescriptor) Object.defineProperty(process.stdout, "isTTY", isTtyDescriptor);
else Reflect.deleteProperty(process.stdout, "isTTY"); else Reflect.deleteProperty(process.stdout, "isTTY");
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test";
import { createLiveBoard } from "@oh-my-pi/pi-coding-agent/cli/live-board";
describe("live board", () => {
test("repaints in place with cursor-up bookkeeping and interleaves permanent log lines", () => {
const writes: string[] = [];
let lines = ["one", "two"];
const board = createLiveBoard(() => lines, {
isTTY: true,
columns: 40,
rows: 20,
write(text) {
writes.push(text);
return true;
},
});
board.repaint();
expect(writes[0]).toContain("one");
expect(writes[0]).toContain("two");
expect(writes[0]).toContain("\x1b[?25l");
expect(writes[0]?.startsWith("\x1b[")).toBe(false);
board.repaint();
// Cursor-up count must match the previously painted line count.
expect(writes[1]?.startsWith("\x1b[2A")).toBe(true);
lines = ["one"];
board.log("done two");
// log() clears the board, emits the permanent line, then repaints the rest.
expect(writes[2]?.startsWith("\x1b[2A")).toBe(true);
expect(writes[2]).toContain("\x1b[0J");
expect(writes[3]).toBe("done two\n");
expect(writes[4]).toContain("one");
board.close();
const all = writes.join("");
expect(all.endsWith("\x1b[?25h")).toBe(true);
});
test("writes nothing while idle and degrades log to plain lines when non-interactive", () => {
const writes: string[] = [];
const interactiveIdle = createLiveBoard(() => [], {
isTTY: true,
write(text) {
writes.push(text);
return true;
},
});
interactiveIdle.repaint();
interactiveIdle.close();
expect(writes).toEqual([]);
const board = createLiveBoard(() => ["row"], {
isTTY: false,
write(text) {
writes.push(text);
return true;
},
});
board.repaint();
board.log("plain");
board.close();
expect(writes).toEqual(["plain\n"]);
});
});