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:
@@ -37,7 +37,7 @@ import {
|
||||
} from "./agent-loop";
|
||||
import type { AppendOnlyContextManager } from "./append-only-context";
|
||||
import { isProviderRefusalMessage } from "./replay-policy";
|
||||
import { tokenizerEncodingForModel, Tokenizer } from "./tokenizer";
|
||||
import { Tokenizer, tokenizerEncodingForModel } from "./tokenizer";
|
||||
import type {
|
||||
AgentBeforeModelCall,
|
||||
AgentContext,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
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
|
||||
// agent maps that catalog property to the matching native counter. A wrong
|
||||
|
||||
@@ -35,9 +35,7 @@ function deepSeekTokenizer(modelId: string): ModelTokenizer | undefined {
|
||||
const lower = modelId.toLowerCase();
|
||||
if (lower.includes("distill")) return undefined;
|
||||
if (DEEPSEEK_V3_ALIASES[lower]) return "deepseek-v3";
|
||||
return /(?:^|[-_.:])(?:v?[34]|r1)(?:[-_.:]|$)/.test(lower) && lower.includes("deepseek")
|
||||
? "deepseek-v3"
|
||||
: undefined;
|
||||
return /(?:^|[-_.:])(?:v?[34]|r1)(?:[-_.:]|$)/.test(lower) && lower.includes("deepseek") ? "deepseek-v3" : undefined;
|
||||
}
|
||||
|
||||
function kimiTokenizer(modelId: string): ModelTokenizer | undefined {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
### 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.
|
||||
- `/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.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { discoverAuthStorage } from "../sdk";
|
||||
import { SessionManager } from "../session/session-manager";
|
||||
import { mapWithConcurrencyLimitAllSettled } from "../task/parallel";
|
||||
import { runStructuredSubagent } from "../task/structured-subagent";
|
||||
import type { AgentProgress } from "../task/types";
|
||||
import type { ToolSession } from "../tools";
|
||||
import { EventBus } from "../utils/event-bus";
|
||||
import type { CustomCleanseCheckerSpec } from "./checkers";
|
||||
@@ -49,6 +50,8 @@ const DISCOVERY_SCHEMA = {
|
||||
/** Hooks used by the standalone command to render subagent lifecycle progress. */
|
||||
export interface CleanseAgentHooks {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -168,6 +171,7 @@ export async function createCleanseAgentRuntime(options: {
|
||||
enableLsp: true,
|
||||
enableIrc: true,
|
||||
signal: workerSignal,
|
||||
onProgress: progress => options.hooks?.onProgress?.(name, assignment, progress),
|
||||
});
|
||||
const outcome: CleanseAgentOutcome = {
|
||||
name,
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -68,6 +68,12 @@ export interface CleanseCheckerDescriptor {
|
||||
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. */
|
||||
export interface CleanseDiagnosticSuite {
|
||||
/** Every discovered checker; unaffected by {@link CleanseDiagnosticSuite.select}. */
|
||||
@@ -75,7 +81,7 @@ export interface CleanseDiagnosticSuite {
|
||||
readonly skipped: readonly SkippedCleanseCheck[];
|
||||
/** Narrow subsequent {@link CleanseDiagnosticSuite.run} calls to the named checker ids. */
|
||||
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. */
|
||||
@@ -128,14 +134,24 @@ function createSuite(
|
||||
const wanted = new Set(ids);
|
||||
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[] = [];
|
||||
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(
|
||||
active.filter(plan => !plan.mutates).map(plan => runChecker(plan, projectCwd, allowedFiles, signal)),
|
||||
);
|
||||
const parallelChecks = await Promise.all(active.filter(plan => !plan.mutates).map(execute));
|
||||
const checks = [...mutatingChecks, ...parallelChecks];
|
||||
return {
|
||||
checks,
|
||||
|
||||
@@ -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 { createProgressReporter } from "../cli/progress-reporter";
|
||||
import { shortenPath } from "../tools/render-utils";
|
||||
import { type CleanseAgentHooks, type CleanseAgentRuntime, createCleanseAgentRuntime } from "./agent";
|
||||
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 type { CleanseAgentOutcome, CleanseAssignment, CleanseDiagnosticReport, CleanseLoopResult } from "./types";
|
||||
import type { CleanseDiagnosticReport, CleanseLoopResult } from "./types";
|
||||
|
||||
const DEFAULT_MODEL = "@smol";
|
||||
const DISPLAY_FILE_LIMIT = 50;
|
||||
@@ -42,33 +47,26 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
|
||||
process.once("SIGTERM", abort);
|
||||
let runtime: CleanseAgentRuntime | undefined;
|
||||
let loopResult: CleanseLoopResult | undefined;
|
||||
const progress = createProgressReporter("Repairing");
|
||||
const interactiveFailures: CleanseAgentOutcome[] = [];
|
||||
let interactiveFailuresPrinted = false;
|
||||
const printInteractiveFailures = (): void => {
|
||||
if (!progress.interactive || interactiveFailuresPrinted) return;
|
||||
interactiveFailuresPrinted = true;
|
||||
for (const outcome of interactiveFailures) printAgentOutcome(outcome);
|
||||
};
|
||||
const board = createCleanseStatusBoard();
|
||||
const hooks: CleanseAgentHooks = {
|
||||
onStart(name, assignment) {
|
||||
if (progress.interactive) return;
|
||||
process.stdout.write(`[start] ${name}: ${formatAssignmentFiles(assignment)} (weight ${assignment.weight})\n`);
|
||||
},
|
||||
onFinish(outcome) {
|
||||
progress.complete();
|
||||
if (progress.interactive) {
|
||||
if (!outcome.success) interactiveFailures.push(outcome);
|
||||
return;
|
||||
}
|
||||
printAgentOutcome(outcome);
|
||||
},
|
||||
onStart: (name, assignment) => board.agentStarted(name, assignment),
|
||||
onProgress: (name, _assignment, progress) => board.agentProgress(name, progress),
|
||||
onFinish: (outcome, assignment) => board.agentFinished(outcome, assignment),
|
||||
};
|
||||
const checkerEvents: CleanseCheckerRunEvents = {
|
||||
onCheckerStart: checker => board.checkerStarted(checker),
|
||||
onCheckerEnd: (check, durationMs) => board.checkerFinished(check, durationMs),
|
||||
};
|
||||
const ensureRuntime = async (): Promise<CleanseAgentRuntime> => {
|
||||
if (runtime) return runtime;
|
||||
process.stdout.write(`Resolving model ${model}...\n`);
|
||||
runtime = await createCleanseAgentRuntime({ cwd, model, hooks });
|
||||
process.stdout.write(`Model: ${runtime.model}\nSession: ${shortenPath(runtime.sessionFile)}\n`);
|
||||
board.phase(`Resolving model ${model}...`);
|
||||
try {
|
||||
runtime = await createCleanseAgentRuntime({ cwd, model, hooks });
|
||||
} finally {
|
||||
board.phase(undefined);
|
||||
}
|
||||
board.log(`Model: ${runtime.model}`);
|
||||
board.log(`Session: ${shortenPath(runtime.sessionFile)}`);
|
||||
return runtime;
|
||||
};
|
||||
|
||||
@@ -76,8 +74,9 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
|
||||
let request = options.request?.trim() || undefined;
|
||||
let suite: CleanseDiagnosticSuite | undefined;
|
||||
if (!request) {
|
||||
process.stdout.write("Detecting configured project checkers...\n");
|
||||
board.phase("Detecting configured project checkers...");
|
||||
suite = await discoverCleanseDiagnosticSuite(cwd, { includeTests: options.includeTests });
|
||||
board.phase(undefined);
|
||||
const interactive = options.all !== true && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
||||
if (interactive) {
|
||||
if (suite.checkers.length > 0) {
|
||||
@@ -113,11 +112,15 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
|
||||
}
|
||||
if (request) {
|
||||
const activeRuntime = await ensureRuntime();
|
||||
process.stdout.write(`Discovering checkers for "${request}"...\n`);
|
||||
const specs = await activeRuntime.discoverCheckers(request, abortController.signal);
|
||||
suite = await buildCustomCleanseSuite(cwd, specs);
|
||||
board.phase(`Discovering checkers for "${request}"...`);
|
||||
try {
|
||||
const specs = await activeRuntime.discoverCheckers(request, abortController.signal);
|
||||
suite = await buildCustomCleanseSuite(cwd, specs);
|
||||
} finally {
|
||||
board.phase(undefined);
|
||||
}
|
||||
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) {
|
||||
@@ -130,8 +133,9 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
|
||||
);
|
||||
return { exitCode: 1, status: "unsupported", report, sessionFile: runtime?.sessionFile };
|
||||
}
|
||||
const initialReport = await suite.run(abortController.signal);
|
||||
printCheckReport(initialReport);
|
||||
const initialReport = await suite.run(abortController.signal, checkerEvents);
|
||||
if (board.interactive) printSkippedChecks(initialReport);
|
||||
else printCheckReport(initialReport);
|
||||
if (initialReport.diagnostics.length === 0) {
|
||||
process.stdout.write(
|
||||
`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 agentCount = Math.min(maxAgents, assignments.length);
|
||||
const fileCount = assignments.filter(group => group.file !== undefined).length;
|
||||
process.stdout.write(
|
||||
`Found ${initialReport.diagnostics.length} diagnostic${initialReport.diagnostics.length === 1 ? "" : "s"} across ${fileCount} file${fileCount === 1 ? "" : "s"}; launching ${agentCount} subagent${agentCount === 1 ? "" : "s"}.\n`,
|
||||
board.log(
|
||||
`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 activeSuite = suite;
|
||||
loopResult = await runCleanseLoop(
|
||||
{ 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),
|
||||
onWave(_wave, batch) {
|
||||
process.stdout.write(
|
||||
`Dispatching ${batch.length} weighted assignment${batch.length === 1 ? "" : "s"}...\n`,
|
||||
);
|
||||
progress.start(batch.length);
|
||||
board.log(`Dispatching ${batch.length} weighted assignment${batch.length === 1 ? "" : "s"}...`);
|
||||
board.waveStarted(batch.length);
|
||||
},
|
||||
onReport(_wave, report) {
|
||||
progress.finish();
|
||||
printInteractiveFailures();
|
||||
process.stdout.write(
|
||||
`Verification: ${report.diagnostics.length} diagnostic${report.diagnostics.length === 1 ? "" : "s"} remaining.\n`,
|
||||
board.waveFinished();
|
||||
board.log(
|
||||
`Verification: ${report.diagnostics.length} diagnostic${report.diagnostics.length === 1 ? "" : "s"} remaining.`,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
progress.finish();
|
||||
printInteractiveFailures();
|
||||
board.close();
|
||||
await activeRuntime.close(loopResult);
|
||||
if (loopResult.status === "cancelled") {
|
||||
process.stderr.write("Cleanse cancelled.\n");
|
||||
@@ -188,30 +188,17 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) throw error;
|
||||
const report: CleanseDiagnosticReport = loopResult?.report ?? { checks: [], diagnostics: [], skipped: [] };
|
||||
progress.finish();
|
||||
printInteractiveFailures();
|
||||
board.close();
|
||||
process.stderr.write("Cleanse cancelled.\n");
|
||||
return { exitCode: 130, status: "cancelled", report, sessionFile: runtime?.sessionFile };
|
||||
} finally {
|
||||
progress.finish();
|
||||
printInteractiveFailures();
|
||||
board.close();
|
||||
process.off("SIGINT", abort);
|
||||
process.off("SIGTERM", abort);
|
||||
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 {
|
||||
for (const check of report.checks) {
|
||||
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 {
|
||||
const groups = groupDiagnosticsByFile(report.diagnostics);
|
||||
process.stderr.write(
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -156,7 +156,7 @@ describe("cleanse progress", () => {
|
||||
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 isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
@@ -169,12 +169,29 @@ describe("cleanse progress", () => {
|
||||
const clean = report([]);
|
||||
let runCount = 0;
|
||||
const suite: cleanseCheckers.CleanseDiagnosticSuite = {
|
||||
checkers: [{ id: "mock", label: "mock", language: "Test", command: "mock" }],
|
||||
checkers: [{ id: "mock", label: "mock checker", language: "Test", command: "mock" }],
|
||||
skipped: [],
|
||||
select() {},
|
||||
async run() {
|
||||
async run(_signal, events) {
|
||||
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;
|
||||
@@ -205,11 +222,20 @@ describe("cleanse progress", () => {
|
||||
const result = await runCleanseCommand({ maxAgents: 2, all: true });
|
||||
|
||||
expect(result.status).toBe("clean");
|
||||
const updates = output.filter(chunk => chunk.startsWith("\rRepairing ["));
|
||||
expect(updates).toHaveLength(3);
|
||||
expect(updates[0]).toContain("0/2");
|
||||
expect(updates[1]).toContain("1/2");
|
||||
expect(updates[2]).toContain("2/2");
|
||||
// Strip ANSI control sequences; the board's repaint framing is not the contract.
|
||||
const text = output.join("").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
||||
// Live wave header frames as workers finish.
|
||||
expect(text).toContain("Repairing [");
|
||||
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 {
|
||||
if (isTtyDescriptor) Object.defineProperty(process.stdout, "isTTY", isTtyDescriptor);
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user