From ac5500bedad94d5fc2c57c99ab6097ffc0ffea19 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 04:15:03 +0200 Subject: [PATCH] feat(coding-agent): introduced interactive cleanse command and mode interface - Introduce CleanseBoardModel and clean up status board rendering logic. - Decouple CLI and interactive modes with CleanseRunUi interface and core runner. - Add CleansePanelComponent and controller to manage interactive cleanse overlay. - Register builtin `/cleanse` slash command and integrate into interactive mode. --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/cleanse/board.ts | 151 ++++++++++----- packages/coding-agent/src/cleanse/index.ts | 135 +++++++------ packages/coding-agent/src/cleanse/types.ts | 18 ++ .../coding-agent/src/cli/cleanse-picker.ts | 8 +- packages/coding-agent/src/cli/live-board.ts | 4 +- .../src/modes/components/cleanse-panel.ts | 179 ++++++++++++++++++ .../src/modes/components/tips.txt | 2 +- .../controllers/cleanse-command-controller.ts | 171 +++++++++++++++++ .../src/modes/controllers/input-controller.ts | 3 + .../src/modes/interactive-mode.ts | 23 +++ packages/coding-agent/src/modes/types.ts | 4 + .../src/slash-commands/builtin-lifecycle.ts | 11 ++ 13 files changed, 600 insertions(+), 111 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/cleanse-panel.ts create mode 100644 packages/coding-agent/src/modes/controllers/cleanse-command-controller.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 40bd81e02..7f1d05363 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,7 +12,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. +- `omp cleanse` and the `/cleanse` slash command now render a live interactive status board with running checkers, repair subagents, tool counts, token/cost totals, and live scrollback in both the CLI and interactive terminal modes - 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/board.ts b/packages/coding-agent/src/cleanse/board.ts index 6e9c2bd8d..bc71da9f9 100644 --- a/packages/coding-agent/src/cleanse/board.ts +++ b/packages/coding-agent/src/cleanse/board.ts @@ -52,35 +52,106 @@ interface RunningAgent { progress?: AgentProgress; } +/** + * Live-view state for one cleanse run, shared by the CLI stdout board and the + * interactive-mode overlay panel so both surfaces render identical rows. + * + * Mutators mirror {@link CleanseStatusBoard}; the finish mutators return the + * permanent line the surface should log above the live area. + */ +export class CleanseBoardModel { + #phaseText: string | undefined; + readonly #checkers = new Map(); + readonly #agents = new Map(); + /** Lifetime token/cost totals per agent; survives row removal for the header sums. */ + readonly #totals = new Map(); + #waveTotal = 0; + #waveDone = 0; + #waveStartedAt = 0; + + phase(text: string | undefined): void { + this.#phaseText = text; + } + + checkerStarted(checker: CleanseCheckerDescriptor): void { + this.#checkers.set(checker.id, { label: checker.label, startedAt: Date.now() }); + } + + /** Drop the checker's live row and build its permanent verdict line. */ + checkerFinished(check: CleanseCheckResult, durationMs: number): string { + this.#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("●"); + return `${glyph} ${check.label} ${verdict} ${chalk.dim(`· ${formatDuration(durationMs)}`)}`; + } + + waveStarted(total: number): void { + this.#waveTotal = Math.max(total, 0); + this.#waveDone = 0; + this.#waveStartedAt = Date.now(); + this.#agents.clear(); + this.#totals.clear(); + } + + waveFinished(): void { + this.#waveTotal = 0; + this.#agents.clear(); + } + + agentStarted(name: string, assignment: CleanseAssignment): void { + this.#agents.set(name, { assignment, startedAt: Date.now() }); + } + + agentProgress(name: string, progress: AgentProgress): void { + this.#totals.set(name, { tokens: progress.tokens, cost: progress.cost }); + const agent = this.#agents.get(name); + if (agent) agent.progress = progress; + } + + /** Drop the agent's live row, advance the wave bar, and build its permanent outcome line. */ + agentFinished(outcome: CleanseAgentOutcome, assignment: CleanseAssignment): string { + const agent = this.#agents.get(outcome.name); + this.#agents.delete(outcome.name); + this.#waveDone = Math.min(this.#waveDone + 1, this.#waveTotal); + return renderOutcomeLine(outcome, assignment, agent, this.#totals.get(outcome.name)); + } + + /** Render the transient live rows for the current spinner frame. */ + renderLive(spinner: string): string[] { + const lines: string[] = []; + if (this.#phaseText) lines.push(`${chalk.yellow(spinner)} ${this.#phaseText}`); + for (const checker of this.#checkers.values()) { + const elapsed = formatDuration(Date.now() - checker.startedAt); + lines.push(`${chalk.yellow(spinner)} ${checker.label} ${chalk.dim(`· ${elapsed}`)}`); + } + if (this.#waveTotal > 0) { + lines.push( + renderWaveHeader( + spinner, + this.#waveTotal, + this.#waveDone, + this.#agents.size, + this.#totals, + this.#waveStartedAt, + ), + ); + const rows = [...this.#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; + } +} + /** 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); + const model = new CleanseBoardModel(); + const board = createLiveBoard(spinner => model.renderLive(spinner), output); return { interactive: board.interactive, @@ -90,33 +161,24 @@ export function createCleanseStatusBoard( if (text) output.write(`${text}\n`); return; } - phaseText = text; + model.phase(text); board.repaint(); }, checkerStarted(checker) { if (!board.interactive) return; - checkers.set(checker.id, { label: checker.label, startedAt: Date.now() }); + model.checkerStarted(checker); 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)}`)}`); + board.log(model.checkerFinished(check, durationMs)); }, waveStarted(total) { - waveTotal = Math.max(total, 0); - waveDone = 0; - waveStartedAt = Date.now(); - agents.clear(); - totals.clear(); + model.waveStarted(total); board.repaint(); }, waveFinished() { - waveTotal = 0; - agents.clear(); + model.waveFinished(); board.repaint(); }, agentStarted(name, assignment) { @@ -125,17 +187,15 @@ export function createCleanseStatusBoard( output.write(`[start] ${name}: ${files} (weight ${assignment.weight})\n`); return; } - agents.set(name, { assignment, startedAt: Date.now() }); + model.agentStarted(name, assignment); 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; + model.agentProgress(name, progress); }, agentFinished(outcome, assignment) { if (!board.interactive) { + model.agentFinished(outcome, assignment); if (outcome.success) { output.write(`[done] ${outcome.name}${outcome.resolvedModel ? ` (${outcome.resolvedModel})` : ""}\n`); } else { @@ -143,10 +203,7 @@ export function createCleanseStatusBoard( } 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))); + board.log(model.agentFinished(outcome, assignment)); }, close: board.close, }; diff --git a/packages/coding-agent/src/cleanse/index.ts b/packages/coding-agent/src/cleanse/index.ts index 60a1c47f1..6c2ef8358 100644 --- a/packages/coding-agent/src/cleanse/index.ts +++ b/packages/coding-agent/src/cleanse/index.ts @@ -3,15 +3,16 @@ import { pickCleanseTarget, promptCleanseRequest } from "../cli/cleanse-picker"; import { shortenPath } from "../tools/render-utils"; import { type CleanseAgentHooks, type CleanseAgentRuntime, createCleanseAgentRuntime } from "./agent"; import { groupDiagnosticsByFile } from "./balance"; -import { createCleanseStatusBoard } from "./board"; +import { type CleanseStatusBoard, createCleanseStatusBoard } from "./board"; import { buildCustomCleanseSuite, + type CleanseCheckerDescriptor, type CleanseCheckerRunEvents, type CleanseDiagnosticSuite, discoverCleanseDiagnosticSuite, } from "./checkers"; import { runCleanseLoop } from "./loop"; -import type { CleanseDiagnosticReport, CleanseLoopResult } from "./types"; +import type { CleanseCommandResult, CleanseDiagnosticReport, CleanseLoopResult, CleanseTargetChoice } from "./types"; const DEFAULT_MODEL = "@smol"; const DISPLAY_FILE_LIMIT = 50; @@ -27,27 +28,37 @@ export interface CleanseCommandOptions { all?: boolean; } -/** Observable completion state returned to the CLI adapter. */ -export interface CleanseCommandResult { - exitCode: number; - status: "clean" | "unresolved" | "unsupported" | "cancelled"; - report: CleanseDiagnosticReport; - sessionFile?: string; +/** Rendering and prompting seam for one cleanse run; satisfied by the CLI streams and the TUI overlay. */ +export interface CleanseRunUi { + board: CleanseStatusBoard; + /** Permanent user-facing summary line. */ + print(text: string): void; + /** Permanent failure/cancellation line. */ + printError(text: string): void; + /** Choose between discovered checkers; omit to run every checker without prompting. */ + pickTarget?(checkers: readonly CleanseCheckerDescriptor[]): Promise; + /** Free-form request prompt when no runnable checker was discovered; `null` cancels. */ + promptRequest?(): Promise; } -/** Detect project diagnostics, dispatch one bounded repair batch, and verify it. */ -export async function runCleanseCommand(options: CleanseCommandOptions = {}): Promise { +/** + * Detect project diagnostics, dispatch one bounded repair batch, and verify it. + * + * Cancellation flows exclusively through `signal`; the caller owns signal + * sources (SIGINT for the CLI, Esc for the interactive overlay). + */ +export async function runCleanse( + options: CleanseCommandOptions, + ui: CleanseRunUi, + signal: AbortSignal, +): Promise { const maxAgents = options.maxAgents ?? 32; if (!Number.isInteger(maxAgents) || maxAgents <= 0) throw new Error("--agents must be a positive integer"); const model = options.model?.trim() || DEFAULT_MODEL; const cwd = getProjectDir(); - const abortController = new AbortController(); - const abort = (): void => abortController.abort(new Error("Cleanse interrupted")); - process.once("SIGINT", abort); - process.once("SIGTERM", abort); let runtime: CleanseAgentRuntime | undefined; let loopResult: CleanseLoopResult | undefined; - const board = createCleanseStatusBoard(); + const board = ui.board; const hooks: CleanseAgentHooks = { onStart: (name, assignment) => board.agentStarted(name, assignment), onProgress: (name, _assignment, progress) => board.agentProgress(name, progress), @@ -77,12 +88,12 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr 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) { + const pickTarget = options.all === true ? undefined : ui.pickTarget; + if (pickTarget) { if (suite.checkers.length > 0) { - const choice = await pickCleanseTarget(suite.checkers); + const choice = await pickTarget(suite.checkers); if (choice.kind === "cancel") { - process.stderr.write("Cleanse cancelled.\n"); + ui.printError("Cleanse cancelled."); return { exitCode: 130, status: "cancelled", @@ -95,9 +106,9 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr suite = undefined; } } else { - printSkippedChecks({ checks: [], diagnostics: [], skipped: [...suite.skipped] }); - process.stdout.write("No supported checker with an available executable was found.\n"); - const answer = await promptCleanseRequest(); + printSkippedChecks(ui, { checks: [], diagnostics: [], skipped: [...suite.skipped] }); + ui.print("No supported checker with an available executable was found."); + const answer = (await ui.promptRequest?.()) ?? null; if (answer === null) { return { exitCode: 1, @@ -114,7 +125,7 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr const activeRuntime = await ensureRuntime(); board.phase(`Discovering checkers for "${request}"...`); try { - const specs = await activeRuntime.discoverCheckers(request, abortController.signal); + const specs = await activeRuntime.discoverCheckers(request, signal); suite = await buildCustomCleanseSuite(cwd, specs); } finally { board.phase(undefined); @@ -125,20 +136,20 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr } if (!suite || suite.checkers.length === 0) { const report: CleanseDiagnosticReport = { checks: [], diagnostics: [], skipped: [...(suite?.skipped ?? [])] }; - printSkippedChecks(report); - process.stderr.write( + printSkippedChecks(ui, report); + ui.printError( request - ? "Checker discovery produced no runnable command.\n" - : "No supported checker with an available executable was found.\n", + ? "Checker discovery produced no runnable command." + : "No supported checker with an available executable was found.", ); return { exitCode: 1, status: "unsupported", report, sessionFile: runtime?.sessionFile }; } - const initialReport = await suite.run(abortController.signal, checkerEvents); - if (board.interactive) printSkippedChecks(initialReport); - else printCheckReport(initialReport); + const initialReport = await suite.run(signal, checkerEvents); + if (board.interactive) printSkippedChecks(ui, initialReport); + else printCheckReport(ui, initialReport); if (initialReport.diagnostics.length === 0) { - process.stdout.write( - `Clean: ${initialReport.checks.length} checker${initialReport.checks.length === 1 ? "" : "s"} passed.\n`, + ui.print( + `Clean: ${initialReport.checks.length} checker${initialReport.checks.length === 1 ? "" : "s"} passed.`, ); return { exitCode: 0, status: "clean", report: initialReport, sessionFile: runtime?.sessionFile }; } @@ -152,10 +163,10 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr const activeRuntime = await ensureRuntime(); const activeSuite = suite; loopResult = await runCleanseLoop( - { maxAgents, initialReport, signal: abortController.signal }, + { maxAgents, initialReport, signal }, { - collect: signal => activeSuite.run(signal, checkerEvents), - dispatch: (batch, wave, report, signal) => activeRuntime.dispatch(batch, wave, report, signal), + collect: loopSignal => activeSuite.run(loopSignal, checkerEvents), + dispatch: (batch, wave, report, loopSignal) => activeRuntime.dispatch(batch, wave, report, loopSignal), onWave(_wave, batch) { board.log(`Dispatching ${batch.length} weighted assignment${batch.length === 1 ? "" : "s"}...`); board.waveStarted(batch.length); @@ -171,7 +182,7 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr board.close(); await activeRuntime.close(loopResult); if (loopResult.status === "cancelled") { - process.stderr.write("Cleanse cancelled.\n"); + ui.printError("Cleanse cancelled."); return { exitCode: 130, status: "cancelled", @@ -180,48 +191,66 @@ export async function runCleanseCommand(options: CleanseCommandOptions = {}): Pr }; } if (loopResult.status === "clean") { - process.stdout.write("Clean: all detected diagnostics are resolved.\n"); + ui.print("Clean: all detected diagnostics are resolved."); return { exitCode: 0, status: "clean", report: loopResult.report, sessionFile: activeRuntime.sessionFile }; } - printRemaining(loopResult.report); + printRemaining(ui, loopResult.report); return { exitCode: 1, status: "unresolved", report: loopResult.report, sessionFile: activeRuntime.sessionFile }; } catch (error) { - if (!abortController.signal.aborted) throw error; + if (!signal.aborted) throw error; const report: CleanseDiagnosticReport = loopResult?.report ?? { checks: [], diagnostics: [], skipped: [] }; board.close(); - process.stderr.write("Cleanse cancelled.\n"); + ui.printError("Cleanse cancelled."); return { exitCode: 130, status: "cancelled", report, sessionFile: runtime?.sessionFile }; } finally { board.close(); - process.off("SIGINT", abort); - process.off("SIGTERM", abort); await runtime?.close(loopResult); } } -function printCheckReport(report: CleanseDiagnosticReport): void { +/** CLI adapter for {@link runCleanse}: stdout board, one-shot pickers, SIGINT/SIGTERM cancellation. */ +export async function runCleanseCommand(options: CleanseCommandOptions = {}): Promise { + const abortController = new AbortController(); + const abort = (): void => abortController.abort(new Error("Cleanse interrupted")); + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true; + const ui: CleanseRunUi = { + board: createCleanseStatusBoard(), + print: text => process.stdout.write(`${text}\n`), + printError: text => process.stderr.write(`${text}\n`), + pickTarget: interactive ? pickCleanseTarget : undefined, + promptRequest: interactive ? promptCleanseRequest : undefined, + }; + try { + return await runCleanse(options, ui, abortController.signal); + } finally { + process.off("SIGINT", abort); + process.off("SIGTERM", abort); + } +} + +function printCheckReport(ui: CleanseRunUi, report: CleanseDiagnosticReport): void { for (const check of report.checks) { const count = check.diagnostics.length; - process.stdout.write(`- ${check.label}: ${count === 0 ? "clean" : `${count} issue${count === 1 ? "" : "s"}`}\n`); + ui.print(`- ${check.label}: ${count === 0 ? "clean" : `${count} issue${count === 1 ? "" : "s"}`}`); } - printSkippedChecks(report); + printSkippedChecks(ui, report); } -function printSkippedChecks(report: CleanseDiagnosticReport): void { +function printSkippedChecks(ui: CleanseRunUi, report: CleanseDiagnosticReport): void { for (const skipped of report.skipped) { - process.stdout.write(`- ${skipped.label}: skipped (${skipped.reason})\n`); + ui.print(`- ${skipped.label}: skipped (${skipped.reason})`); } } -function printRemaining(report: CleanseDiagnosticReport): void { +function printRemaining(ui: CleanseRunUi, report: CleanseDiagnosticReport): void { const groups = groupDiagnosticsByFile(report.diagnostics); - process.stderr.write( - `Unresolved: ${report.diagnostics.length} diagnostic${report.diagnostics.length === 1 ? "" : "s"}.\n`, - ); + ui.printError(`Unresolved: ${report.diagnostics.length} diagnostic${report.diagnostics.length === 1 ? "" : "s"}.`); for (const group of groups.slice(0, DISPLAY_FILE_LIMIT)) { - process.stderr.write(`- ${group.file ?? ""}: ${group.diagnostics.length}\n`); + ui.printError(`- ${group.file ?? ""}: ${group.diagnostics.length}`); } if (groups.length > DISPLAY_FILE_LIMIT) { - process.stderr.write(`- ... ${groups.length - DISPLAY_FILE_LIMIT} more files\n`); + ui.printError(`- ... ${groups.length - DISPLAY_FILE_LIMIT} more files`); } } diff --git a/packages/coding-agent/src/cleanse/types.ts b/packages/coding-agent/src/cleanse/types.ts index 6c653495e..77e05db01 100644 --- a/packages/coding-agent/src/cleanse/types.ts +++ b/packages/coding-agent/src/cleanse/types.ts @@ -54,6 +54,24 @@ export interface CleanseAssignment { weight: number; } +/** Outcome of an interactive cleanse target picker (CLI one-shot TUI or in-session overlay). */ +export type CleanseTargetChoice = + | { kind: "all" } + | { kind: "checker"; id: string } + | { kind: "request"; request: string } + | { kind: "cancel" }; + +/** Terminal status of one cleanse run. */ +export type CleanseRunStatus = "clean" | "unresolved" | "unsupported" | "cancelled"; + +/** Observable completion state returned to the CLI and overlay adapters. */ +export interface CleanseCommandResult { + exitCode: number; + status: CleanseRunStatus; + report: CleanseDiagnosticReport; + sessionFile?: string; +} + /** Settled result from one cleanse subagent. */ export interface CleanseAgentOutcome { name: string; diff --git a/packages/coding-agent/src/cli/cleanse-picker.ts b/packages/coding-agent/src/cli/cleanse-picker.ts index b81a76a89..d827a9892 100644 --- a/packages/coding-agent/src/cli/cleanse-picker.ts +++ b/packages/coding-agent/src/cli/cleanse-picker.ts @@ -7,15 +7,9 @@ */ import { Input, ProcessTerminal, type SelectItem, SelectList, TUI } from "@oh-my-pi/pi-tui"; import type { CleanseCheckerDescriptor } from "../cleanse/checkers"; +import type { CleanseTargetChoice } from "../cleanse/types"; import { getSelectListTheme } from "../modes/theme/theme"; -/** Outcome of the interactive cleanse target picker. */ -export type CleanseTargetChoice = - | { kind: "all" } - | { kind: "checker"; id: string } - | { kind: "request"; request: string } - | { kind: "cancel" }; - /** Pick between running every discovered checker, one specific checker, or a free-form request. */ export async function pickCleanseTarget(checkers: readonly CleanseCheckerDescriptor[]): Promise { const items: SelectItem[] = [ diff --git a/packages/coding-agent/src/cli/live-board.ts b/packages/coding-agent/src/cli/live-board.ts index d894eecc6..f9c7e07c6 100644 --- a/packages/coding-agent/src/cli/live-board.ts +++ b/packages/coding-agent/src/cli/live-board.ts @@ -10,8 +10,8 @@ import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui"; const RENDER_INTERVAL_MS = 80; -/** Braille spinner advanced once per repaint tick. */ -const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +/** Braille spinner advanced once per repaint tick; shared with the interactive cleanse overlay. */ +export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /** Output contract for the live board (satisfied by `process.stdout`). */ export interface LiveBoardOutput { diff --git a/packages/coding-agent/src/modes/components/cleanse-panel.ts b/packages/coding-agent/src/modes/components/cleanse-panel.ts new file mode 100644 index 000000000..e591a1778 --- /dev/null +++ b/packages/coding-agent/src/modes/components/cleanse-panel.ts @@ -0,0 +1,179 @@ +/** + * Anchored overlay panel for `/cleanse`, mounted above the editor like the + * `/omfg` panel. Implements {@link CleanseStatusBoard}, so the shared cleanse + * core renders the exact live view `omp cleanse` shows on stdout: transient + * checker/wave/agent rows from {@link CleanseBoardModel} animate in place while + * permanent log lines accumulate above them. + */ +import { Container, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { CleanseBoardModel, type CleanseStatusBoard } from "../../cleanse/board"; +import type { CleanseCheckerDescriptor } from "../../cleanse/checkers"; +import type { CleanseAgentOutcome, CleanseAssignment, CleanseCheckResult, CleanseRunStatus } from "../../cleanse/types"; +import { SPINNER_FRAMES } from "../../cli/live-board"; +import type { AgentProgress } from "../../task/types"; +import { replaceTabs } from "../../tools/render-utils"; +import { theme } from "../theme/theme"; +import { DynamicBorder } from "./dynamic-border"; + +const SPINNER_INTERVAL_MS = 80; +const MAX_LOG_LINES = 14; + +interface CleansePanelComponentOptions { + /** Free-form request shown in the header; omitted for checker-discovery runs. */ + request?: string; + tui: TUI; +} + +/** Terminal state of the run, mirrored into the footer once the core settles. */ +type CleansePanelOutcome = CleanseRunStatus | "error"; + +export class CleansePanelComponent extends Container implements CleanseStatusBoard { + readonly interactive = true; + + readonly #request: string | undefined; + readonly #tui: TUI; + readonly #model = new CleanseBoardModel(); + readonly #logLines: string[] = []; + #outcome: CleansePanelOutcome | undefined; + #errorMessage: string | undefined; + #frame = 0; + #timer: NodeJS.Timeout | undefined; + #liveClosed = false; + + constructor(options: CleansePanelComponentOptions) { + super(); + this.#request = options.request; + this.#tui = options.tui; + this.#timer = setInterval(() => { + this.#frame = (this.#frame + 1) % SPINNER_FRAMES.length; + this.#rebuild(); + }, SPINNER_INTERVAL_MS); + this.#timer.unref?.(); + this.#rebuild(); + } + + log(text: string): void { + this.#logLines.push(text); + if (this.#logLines.length > MAX_LOG_LINES) this.#logLines.splice(0, this.#logLines.length - MAX_LOG_LINES); + this.#rebuild(); + } + + /** Permanent line styled as a failure (the core's stderr-equivalent). */ + logError(text: string): void { + this.log(theme.fg("error", text)); + } + + phase(text: string | undefined): void { + this.#model.phase(text); + this.#rebuild(); + } + + checkerStarted(checker: CleanseCheckerDescriptor): void { + this.#model.checkerStarted(checker); + this.#rebuild(); + } + + checkerFinished(check: CleanseCheckResult, durationMs: number): void { + this.log(this.#model.checkerFinished(check, durationMs)); + } + + waveStarted(total: number): void { + this.#model.waveStarted(total); + this.#rebuild(); + } + + waveFinished(): void { + this.#model.waveFinished(); + this.#rebuild(); + } + + agentStarted(name: string, assignment: CleanseAssignment): void { + this.#model.agentStarted(name, assignment); + this.#rebuild(); + } + + agentProgress(name: string, progress: AgentProgress): void { + this.#model.agentProgress(name, progress); + } + + agentFinished(outcome: CleanseAgentOutcome, assignment: CleanseAssignment): void { + this.log(this.#model.agentFinished(outcome, assignment)); + } + + /** Stop the live area; the panel stays mounted until the user dismisses it. */ + close(): void { + this.#liveClosed = true; + this.#stopTimer(); + this.#rebuild(); + } + + /** Record the settled run result and switch the footer to its dismiss hint. */ + finish(status: CleanseRunStatus): void { + this.#outcome = status; + this.close(); + } + + /** Record an unexpected failure and switch the footer to its dismiss hint. */ + markError(message: string): void { + this.#outcome = "error"; + this.#errorMessage = message; + this.close(); + } + + /** Release the repaint timer during teardown. */ + override dispose(): void { + this.#stopTimer(); + super.dispose(); + } + + #stopTimer(): void { + if (!this.#timer) return; + clearInterval(this.#timer); + this.#timer = undefined; + } + + #rebuild(): void { + this.clear(); + this.addChild(new DynamicBorder(str => theme.fg("dim", str))); + this.addChild(new Spacer(1)); + const header = this.#request ? `/cleanse ${this.#request}` : "/cleanse"; + this.addChild(new Text(theme.fg("accent", replaceTabs(header)), 1, 0)); + if (this.#logLines.length > 0) { + this.addChild(new Spacer(1)); + for (const line of this.#logLines) this.addChild(new Text(replaceTabs(line), 1, 0)); + } + if (!this.#liveClosed) { + const liveLines = this.#model.renderLive(SPINNER_FRAMES[this.#frame] ?? SPINNER_FRAMES[0]); + if (liveLines.length > 0) { + this.addChild(new Spacer(1)); + for (const line of liveLines) this.addChild(new Text(replaceTabs(line), 1, 0)); + } + } + if (this.#errorMessage) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("error", replaceTabs(this.#errorMessage)), 1, 0)); + } + this.addChild(new Spacer(1)); + this.addChild(new Text(this.#footerLine(), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder(str => theme.fg("dim", str))); + this.#tui.requestRender(); + } + + #footerLine(): string { + switch (this.#outcome) { + case undefined: + return theme.fg("muted", "Esc cancel /cleanse"); + case "clean": + return theme.fg("success", `${theme.status.success} Clean · Esc dismiss`); + case "unresolved": + return theme.fg("warning", `${theme.status.warning} Diagnostics remain · Esc dismiss`); + case "unsupported": + return theme.fg("warning", `${theme.status.warning} No runnable checker · Esc dismiss`); + case "cancelled": + return theme.fg("warning", `${theme.status.warning} Cancelled · Esc dismiss`); + case "error": + return theme.fg("error", `${theme.status.error} Error · Esc dismiss`); + } + } +} diff --git a/packages/coding-agent/src/modes/components/tips.txt b/packages/coding-agent/src/modes/components/tips.txt index ead15bad2..59133df17 100644 --- a/packages/coding-agent/src/modes/components/tips.txt +++ b/packages/coding-agent/src/modes/components/tips.txt @@ -24,4 +24,4 @@ No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y) Press shift+tab to cycle through reasoning effort levels -Lint/type errors piling up? `omp cleanse` hunts project diagnostics and fixes them with parallel subagents \ No newline at end of file +Lint/type errors piling up? `omp cleanse` (or /cleanse right here) hunts project diagnostics and fixes them with parallel subagents — esc cancels \ No newline at end of file diff --git a/packages/coding-agent/src/modes/controllers/cleanse-command-controller.ts b/packages/coding-agent/src/modes/controllers/cleanse-command-controller.ts new file mode 100644 index 000000000..dbfbb851b --- /dev/null +++ b/packages/coding-agent/src/modes/controllers/cleanse-command-controller.ts @@ -0,0 +1,171 @@ +/** + * Owns the `/cleanse` overlay lifecycle: mounts the {@link CleansePanelComponent} + * above the editor, drives the shared cleanse core against it, and maps Esc to + * cancel-then-dismiss (mirroring the `/omfg` panel). + */ +import { runCleanse } from "../../cleanse"; +import type { CleanseCheckerDescriptor } from "../../cleanse/checkers"; +import type { CleanseTargetChoice } from "../../cleanse/types"; +import { CleansePanelComponent } from "../components/cleanse-panel"; +import type { InteractiveModeContext } from "../types"; + +interface CleanseRun { + panel: CleansePanelComponent; + abortController: AbortController; + settled: boolean; +} + +interface ParsedCleanseArgs { + request?: string; + all: boolean; + includeTests: boolean; + maxAgents?: number; + model?: string; + error?: string; +} + +const CLEANSE_USAGE = "Usage: /cleanse [request] [--all] [--tests] [-n ] [-m ]"; +const CUSTOM_REQUEST_OPTION = "Describe what to fix…"; + +export class CleanseCommandController { + #active: CleanseRun | undefined; + + constructor(private readonly ctx: InteractiveModeContext) {} + + hasActiveRun(): boolean { + return this.#active !== undefined; + } + + /** Esc while running cancels the run; Esc on a settled panel dismisses it. */ + handleEscape(): boolean { + const run = this.#active; + if (!run) return false; + if (!run.settled && !run.abortController.signal.aborted) { + run.abortController.abort(new Error("Cleanse interrupted")); + return true; + } + this.#close(run); + return true; + } + + dispose(): void { + const run = this.#active; + if (!run) return; + run.abortController.abort(new Error("Cleanse interrupted")); + this.#close(run); + } + + async start(args: string): Promise { + if (this.#active) { + this.ctx.showStatus("A /cleanse run is already active — Esc cancels it."); + return; + } + const parsed = parseCleanseArgs(args); + if (parsed.error) { + this.ctx.showStatus(parsed.error); + return; + } + const run: CleanseRun = { + panel: new CleansePanelComponent({ request: parsed.request, tui: this.ctx.ui }), + abortController: new AbortController(), + settled: false, + }; + this.#active = run; + this.ctx.cleanseContainer.clear(); + this.ctx.cleanseContainer.addChild(run.panel); + this.ctx.ui.requestRender(); + await this.#run(run, parsed); + } + + async #run(run: CleanseRun, options: ParsedCleanseArgs): Promise { + try { + const result = await runCleanse( + { + request: options.request, + all: options.all, + includeTests: options.includeTests, + maxAgents: options.maxAgents, + model: options.model, + }, + { + board: run.panel, + print: text => run.panel.log(text), + printError: text => run.panel.logError(text), + pickTarget: checkers => this.#pickTarget(checkers), + promptRequest: () => this.#promptRequest(), + }, + run.abortController.signal, + ); + if (this.#active !== run) return; + run.panel.finish(result.status); + // A picker-level cancel settles without any output worth keeping. + if (result.status === "cancelled" && result.report.checks.length === 0) this.#close(run); + } catch (error) { + if (this.#active !== run) return; + run.panel.markError(error instanceof Error ? error.message : String(error)); + } finally { + if (this.#active === run) run.settled = true; + } + } + + async #pickTarget(checkers: readonly CleanseCheckerDescriptor[]): Promise { + const allOption = `Run all ${checkers.length} discovered checker${checkers.length === 1 ? "" : "s"}`; + const labels = checkers.map(checker => `${checker.label} — ${checker.command}`); + const choice = await this.ctx.showHookSelector("Select what to cleanse", [ + allOption, + ...labels, + CUSTOM_REQUEST_OPTION, + ]); + if (choice === undefined) return { kind: "cancel" }; + if (choice === allOption) return { kind: "all" }; + if (choice === CUSTOM_REQUEST_OPTION) { + const request = await this.#promptRequest(); + return request === null ? { kind: "cancel" } : { kind: "request", request }; + } + const checker = checkers[labels.indexOf(choice)]; + return checker ? { kind: "checker", id: checker.id } : { kind: "cancel" }; + } + + async #promptRequest(): Promise { + const answer = await this.ctx.showHookInput("Describe what to detect and fix", 'e.g. "ts errors"'); + const trimmed = answer?.trim(); + return trimmed ? trimmed : null; + } + + #close(run: CleanseRun): void { + if (this.#active !== run) return; + this.#active = undefined; + run.panel.dispose(); + this.ctx.cleanseContainer.clear(); + this.ctx.ui.requestRender(); + } +} + +/** Parse `/cleanse` arguments; flag names mirror the `omp cleanse` CLI. */ +function parseCleanseArgs(args: string): ParsedCleanseArgs { + const tokens = args.split(/\s+/).filter(Boolean); + const requestParts: string[] = []; + const parsed: ParsedCleanseArgs = { all: false, includeTests: false }; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] ?? ""; + if (token === "--all" || token === "-a") { + parsed.all = true; + } else if (token === "--tests" || token === "-t") { + parsed.includeTests = true; + } else if (token === "--agents" || token === "-n") { + const value = Number(tokens[++i]); + if (!Number.isInteger(value) || value <= 0) return { ...parsed, error: CLEANSE_USAGE }; + parsed.maxAgents = value; + } else if (token === "--model" || token === "-m") { + const value = tokens[++i]; + if (!value) return { ...parsed, error: CLEANSE_USAGE }; + parsed.model = value; + } else if (token.startsWith("-") && token.length > 1 && !/^-\d/.test(token)) { + return { ...parsed, error: CLEANSE_USAGE }; + } else { + requestParts.push(token); + } + } + parsed.request = requestParts.join(" ") || undefined; + return parsed; +} diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 1c9ce8465..928c7e622 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -327,6 +327,9 @@ export class InputController { if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) { return; } + if (this.ctx.hasActiveCleanse() && this.ctx.handleCleanseEscape()) { + return; + } if (!this.ctx.focusedAgentId) { const viewSession = this.ctx.viewSession; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 3f1b31b5d..faa7dd15e 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -171,6 +171,7 @@ import { stopSharedSpinnerTicker, type ToolExecutionHandle } from "./components/ import { TranscriptContainer } from "./components/transcript-container"; import { WelcomeComponent, type LspServerInfo as WelcomeLspServerInfo } from "./components/welcome"; import { BtwController } from "./controllers/btw-controller"; +import { CleanseCommandController } from "./controllers/cleanse-command-controller"; import { CommandController } from "./controllers/command-controller"; import { EventController } from "./controllers/event-controller"; import { ExtensionUiController } from "./controllers/extension-ui-controller"; @@ -534,6 +535,7 @@ export class InteractiveMode implements InteractiveModeContext { subagentContainer: Container; btwContainer: Container; omfgContainer: Container; + cleanseContainer: Container; errorBannerContainer: Container; modelCycleContainer: Container; deferredCommandContainer: Container; @@ -692,6 +694,7 @@ export class InteractiveMode implements InteractiveModeContext { readonly #btwController: BtwController; readonly #tanCommandController: TanCommandController; readonly #omfgController: OmfgController; + readonly #cleanseController: CleanseCommandController; readonly #commandController: CommandController; readonly #todoCommandController: TodoCommandController; readonly #liveCommandController: LiveCommandController; @@ -829,6 +832,7 @@ export class InteractiveMode implements InteractiveModeContext { this.subagentContainer = new AnchoredLiveContainer(); this.btwContainer = new AnchoredLiveContainer(); this.omfgContainer = new AnchoredLiveContainer(); + this.cleanseContainer = new AnchoredLiveContainer(); this.errorBannerContainer = new AnchoredLiveContainer(); this.modelCycleContainer = new AnchoredLiveContainer(); this.deferredCommandContainer = new AnchoredLiveContainer(); @@ -911,6 +915,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#btwController = new BtwController(this); this.#tanCommandController = new TanCommandController(this); this.#omfgController = new OmfgController(this); + this.#cleanseController = new CleanseCommandController(this); this.#extensionUiController = new ExtensionUiController(this); this.#eventController = new EventController(this); this.#commandController = new CommandController(this); @@ -1092,6 +1097,7 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.addChild(this.subagentContainer); this.ui.addChild(this.btwContainer); this.ui.addChild(this.omfgContainer); + this.ui.addChild(this.cleanseContainer); this.ui.addChild(this.errorBannerContainer); this.ui.addChild(this.modelCycleContainer); this.ui.addChild(this.deferredCommandContainer); @@ -4372,6 +4378,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#btwController.dispose(); this.#omfgController.dispose(); + this.#cleanseController.dispose(); this.#focusController.dispose(); // Surface an explicit "Closing session…" line so the user sees a reason @@ -4920,6 +4927,7 @@ export class InteractiveMode implements InteractiveModeContext { #prepareSessionSwitch(): void { this.#btwController.dispose(); this.#omfgController.dispose(); + this.#cleanseController.dispose(); this.#extensionUiController.clearExtensionTerminalInputListeners(); this.clearPinnedError(); this.#hidePlanReview(); @@ -4949,6 +4957,7 @@ export class InteractiveMode implements InteractiveModeContext { if (this.#vibeSessionTransitionBlocked()) return; this.#btwController.dispose(); this.#omfgController.dispose(); + this.#cleanseController.dispose(); await this.#commandController.handleForkCommand(); } @@ -5175,6 +5184,7 @@ export class InteractiveMode implements InteractiveModeContext { } this.#btwController.dispose(); this.#omfgController.dispose(); + this.#cleanseController.dispose(); this.resetObserverRegistry(); await this.#selectorController.handleResumeSession(sessionPath, { settingsFlushed: true }); } @@ -5301,6 +5311,7 @@ export class InteractiveMode implements InteractiveModeContext { } this.#btwController.dispose(); this.#omfgController.dispose(); + this.#cleanseController.dispose(); await this.renderInitialMessages({ clearTerminalHistory: true }); this.updateEditorBorderColor(); this.showStatus( @@ -5323,6 +5334,18 @@ export class InteractiveMode implements InteractiveModeContext { return this.#omfgController.handleEscape(); } + handleCleanseCommand(args: string): Promise { + return this.#cleanseController.start(args); + } + + hasActiveCleanse(): boolean { + return this.#cleanseController.hasActiveRun(); + } + + handleCleanseEscape(): boolean { + return this.#cleanseController.handleEscape(); + } + cycleThinkingLevel(): void { this.#inputController.cycleThinkingLevel(); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 541dc45fc..904549d14 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -112,6 +112,7 @@ export interface InteractiveModeContext { subagentContainer: Container; btwContainer: Container; omfgContainer: Container; + cleanseContainer: Container; errorBannerContainer: Container; modelCycleContainer: Container; deferredCommandContainer: Container; @@ -450,6 +451,9 @@ export interface InteractiveModeContext { handleOmfgCommand(complaint: string): Promise; hasActiveOmfg(): boolean; handleOmfgEscape(): boolean; + handleCleanseCommand(args: string): Promise; + hasActiveCleanse(): boolean; + handleCleanseEscape(): boolean; cycleThinkingLevel(): void; cycleRoleModel(direction?: "forward" | "backward"): Promise; toggleToolOutputExpansion(): void; diff --git a/packages/coding-agent/src/slash-commands/builtin-lifecycle.ts b/packages/coding-agent/src/slash-commands/builtin-lifecycle.ts index 8095879e2..9ceaf434b 100644 --- a/packages/coding-agent/src/slash-commands/builtin-lifecycle.ts +++ b/packages/coding-agent/src/slash-commands/builtin-lifecycle.ts @@ -267,6 +267,17 @@ export const BUILTIN_LIFECYCLE_SLASH_COMMANDS: ReadonlyArray = await runtime.ctx.handleOmfgCommand(complaint); }, }, + { + name: "cleanse", + description: "Detect and fix project diagnostics with weighted parallel subagents", + inlineHint: "[request] [--all]", + allowArgs: true, + handleTui: async (command, runtime) => { + const args = command.text.slice(`/${command.name}`.length).trim(); + runtime.ctx.editor.setText(""); + await runtime.ctx.handleCleanseCommand(args); + }, + }, { name: "retry", description: "Retry the last failed agent turn",