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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user