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.
This commit is contained in:
can1357
2026-08-20 04:15:03 +02:00
parent 8e93f76d4a
commit ac5500beda
13 changed files with 600 additions and 111 deletions
+1 -1
View File
@@ -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.
+104 -47
View File
@@ -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<string, RunningChecker>();
readonly #agents = new Map<string, RunningAgent>();
/** Lifetime token/cost totals per agent; survives row removal for the header sums. */
readonly #totals = new Map<string, { tokens: number; cost: number }>();
#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<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);
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,
};
+82 -53
View File
@@ -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<CleanseTargetChoice>;
/** Free-form request prompt when no runnable checker was discovered; `null` cancels. */
promptRequest?(): Promise<string | null>;
}
/** Detect project diagnostics, dispatch one bounded repair batch, and verify it. */
export async function runCleanseCommand(options: CleanseCommandOptions = {}): Promise<CleanseCommandResult> {
/**
* 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<CleanseCommandResult> {
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<CleanseCommandResult> {
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 ?? "<project>"}: ${group.diagnostics.length}\n`);
ui.printError(`- ${group.file ?? "<project>"}: ${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`);
}
}
@@ -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;
@@ -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<CleanseTargetChoice> {
const items: SelectItem[] = [
+2 -2
View File
@@ -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 {
@@ -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`);
}
}
}
@@ -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
Lint/type errors piling up? `omp cleanse` (or /cleanse right here) hunts project diagnostics and fixes them with parallel subagents — esc cancels
@@ -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 <agents>] [-m <model>]";
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<void> {
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<void> {
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<CleanseTargetChoice> {
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<string | null> {
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;
}
@@ -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;
@@ -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<void> {
return this.#cleanseController.start(args);
}
hasActiveCleanse(): boolean {
return this.#cleanseController.hasActiveRun();
}
handleCleanseEscape(): boolean {
return this.#cleanseController.handleEscape();
}
cycleThinkingLevel(): void {
this.#inputController.cycleThinkingLevel();
}
+4
View File
@@ -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<void>;
hasActiveOmfg(): boolean;
handleOmfgEscape(): boolean;
handleCleanseCommand(args: string): Promise<void>;
hasActiveCleanse(): boolean;
handleCleanseEscape(): boolean;
cycleThinkingLevel(): void;
cycleRoleModel(direction?: "forward" | "backward"): Promise<void>;
toggleToolOutputExpansion(): void;
@@ -267,6 +267,17 @@ export const BUILTIN_LIFECYCLE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> =
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",