From eced7ab08ab011d068e6836f54b44f4889e036fa Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 03:22:29 +0200 Subject: [PATCH] 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. --- packages/agent/src/agent.ts | 2 +- packages/agent/test/tokenizer.test.ts | 2 +- packages/catalog/src/model-tokenizer.ts | 4 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/cleanse/agent.ts | 4 + packages/coding-agent/src/cleanse/board.ts | 237 ++++++++++++++++++ packages/coding-agent/src/cleanse/checkers.ts | 28 ++- packages/coding-agent/src/cleanse/index.ts | 115 ++++----- packages/coding-agent/src/cli/live-board.ts | 123 +++++++++ packages/coding-agent/test/cleanse.test.ts | 44 +++- packages/coding-agent/test/live-board.test.ts | 66 +++++ 11 files changed, 540 insertions(+), 86 deletions(-) create mode 100644 packages/coding-agent/src/cleanse/board.ts create mode 100644 packages/coding-agent/src/cli/live-board.ts create mode 100644 packages/coding-agent/test/live-board.test.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 23d979724..cc893a336 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -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, diff --git a/packages/agent/test/tokenizer.test.ts b/packages/agent/test/tokenizer.test.ts index 007dbdae6..2f5a7d293 100644 --- a/packages/agent/test/tokenizer.test.ts +++ b/packages/agent/test/tokenizer.test.ts @@ -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 diff --git a/packages/catalog/src/model-tokenizer.ts b/packages/catalog/src/model-tokenizer.ts index e621961cd..3fb2e1a27 100644 --- a/packages/catalog/src/model-tokenizer.ts +++ b/packages/catalog/src/model-tokenizer.ts @@ -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 { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e6df33179..13f66cf8b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/src/cleanse/agent.ts b/packages/coding-agent/src/cleanse/agent.ts index ca3816820..5229ca416 100644 --- a/packages/coding-agent/src/cleanse/agent.ts +++ b/packages/coding-agent/src/cleanse/agent.ts @@ -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, diff --git a/packages/coding-agent/src/cleanse/board.ts b/packages/coding-agent/src/cleanse/board.ts new file mode 100644 index 000000000..6e9c2bd8d --- /dev/null +++ b/packages/coding-agent/src/cleanse/board.ts @@ -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(); + const agents = new Map(); + /** Lifetime token/cost totals per agent; survives row removal for the header sums. */ + const totals = new Map(); + 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 ?? "").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, + 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 ?? ""); + const first = files[0] ?? ""; + 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)}`; +} diff --git a/packages/coding-agent/src/cleanse/checkers.ts b/packages/coding-agent/src/cleanse/checkers.ts index 7699fd7ea..fe876fc4e 100644 --- a/packages/coding-agent/src/cleanse/checkers.ts +++ b/packages/coding-agent/src/cleanse/checkers.ts @@ -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; + run(signal?: AbortSignal, events?: CleanseCheckerRunEvents): Promise; } /** 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 { + async run(signal?: AbortSignal, events?: CleanseCheckerRunEvents): Promise { + const execute = async (plan: CheckerPlan): Promise => { + 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, diff --git a/packages/coding-agent/src/cleanse/index.ts b/packages/coding-agent/src/cleanse/index.ts index edcd409e9..60a1c47f1 100644 --- a/packages/coding-agent/src/cleanse/index.ts +++ b/packages/coding-agent/src/cleanse/index.ts @@ -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 => { 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 ?? "").join(", "); -} - function printRemaining(report: CleanseDiagnosticReport): void { const groups = groupDiagnosticsByFile(report.diagnostics); process.stderr.write( diff --git a/packages/coding-agent/src/cli/live-board.ts b/packages/coding-agent/src/cli/live-board.ts new file mode 100644 index 000000000..d894eecc6 --- /dev/null +++ b/packages/coding-agent/src/cli/live-board.ts @@ -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[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([]); + }, + }; +} diff --git a/packages/coding-agent/test/cleanse.test.ts b/packages/coding-agent/test/cleanse.test.ts index 49815ec08..5a4c2e070 100644 --- a/packages/coding-agent/test/cleanse.test.ts +++ b/packages/coding-agent/test/cleanse.test.ts @@ -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"); diff --git a/packages/coding-agent/test/live-board.test.ts b/packages/coding-agent/test/live-board.test.ts new file mode 100644 index 000000000..04d6c2e18 --- /dev/null +++ b/packages/coding-agent/test/live-board.test.ts @@ -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"]); + }); +});