feat: implemented live status board utility and agent progress tracking

- Implement a live status board utility for transient multi-line CLI status displays with TTY fallback support.
- Add progress callback support and forward subagent progress events to runner hooks.
- Update cleanse execution flow to track checker runs, agent progress, and status rendering.
- Add comprehensive unit tests for live board repainting and cleanse progress assertions.
This commit is contained in:
can1357
2026-08-20 03:22:29 +02:00
parent 1088cc349c
commit eced7ab08a
11 changed files with 540 additions and 86 deletions
+35 -9
View File
@@ -156,7 +156,7 @@ describe("cleanse progress", () => {
expect(writes[3]).toBe("\n");
});
test("updates the command's TTY bar as repair workers finish", async () => {
test("renders a live repair board and permanent outcome lines on TTY output", async () => {
const output: string[] = [];
const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
@@ -169,12 +169,29 @@ describe("cleanse progress", () => {
const clean = report([]);
let runCount = 0;
const suite: cleanseCheckers.CleanseDiagnosticSuite = {
checkers: [{ id: "mock", label: "mock", language: "Test", command: "mock" }],
checkers: [{ id: "mock", label: "mock checker", language: "Test", command: "mock" }],
skipped: [],
select() {},
async run() {
async run(_signal, events) {
runCount += 1;
return runCount === 1 ? initial : clean;
const current = runCount === 1 ? initial : clean;
const descriptor = suite.checkers[0];
if (descriptor) {
events?.onCheckerStart?.(descriptor);
events?.onCheckerEnd?.(
{
id: descriptor.id,
label: descriptor.label,
language: descriptor.language,
cwd: "/repo",
command: descriptor.command,
exitCode: current.diagnostics.length === 0 ? 0 : 1,
diagnostics: current.diagnostics,
},
5,
);
}
return current;
},
};
let hooks: cleanseAgent.CleanseAgentHooks | undefined;
@@ -205,11 +222,20 @@ describe("cleanse progress", () => {
const result = await runCleanseCommand({ maxAgents: 2, all: true });
expect(result.status).toBe("clean");
const updates = output.filter(chunk => chunk.startsWith("\rRepairing ["));
expect(updates).toHaveLength(3);
expect(updates[0]).toContain("0/2");
expect(updates[1]).toContain("1/2");
expect(updates[2]).toContain("2/2");
// Strip ANSI control sequences; the board's repaint framing is not the contract.
const text = output.join("").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
// Live wave header frames as workers finish.
expect(text).toContain("Repairing [");
expect(text).toContain("0/2");
expect(text).toContain("1/2");
expect(text).toContain("2/2");
// Checker results and agent outcomes promoted to permanent lines.
expect(text).toMatch(/●.*mock checker.*2 issues/);
expect(text).toMatch(/✓.*mock checker.*clean/);
expect(text).toMatch(/✓.*CleanseW1A1/);
expect(text).toMatch(/✓.*CleanseW1A2/);
expect(text).toContain("a.rs");
expect(text).toContain("b.rs");
} finally {
if (isTtyDescriptor) Object.defineProperty(process.stdout, "isTTY", isTtyDescriptor);
else Reflect.deleteProperty(process.stdout, "isTTY");
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test";
import { createLiveBoard } from "@oh-my-pi/pi-coding-agent/cli/live-board";
describe("live board", () => {
test("repaints in place with cursor-up bookkeeping and interleaves permanent log lines", () => {
const writes: string[] = [];
let lines = ["one", "two"];
const board = createLiveBoard(() => lines, {
isTTY: true,
columns: 40,
rows: 20,
write(text) {
writes.push(text);
return true;
},
});
board.repaint();
expect(writes[0]).toContain("one");
expect(writes[0]).toContain("two");
expect(writes[0]).toContain("\x1b[?25l");
expect(writes[0]?.startsWith("\x1b[")).toBe(false);
board.repaint();
// Cursor-up count must match the previously painted line count.
expect(writes[1]?.startsWith("\x1b[2A")).toBe(true);
lines = ["one"];
board.log("done two");
// log() clears the board, emits the permanent line, then repaints the rest.
expect(writes[2]?.startsWith("\x1b[2A")).toBe(true);
expect(writes[2]).toContain("\x1b[0J");
expect(writes[3]).toBe("done two\n");
expect(writes[4]).toContain("one");
board.close();
const all = writes.join("");
expect(all.endsWith("\x1b[?25h")).toBe(true);
});
test("writes nothing while idle and degrades log to plain lines when non-interactive", () => {
const writes: string[] = [];
const interactiveIdle = createLiveBoard(() => [], {
isTTY: true,
write(text) {
writes.push(text);
return true;
},
});
interactiveIdle.repaint();
interactiveIdle.close();
expect(writes).toEqual([]);
const board = createLiveBoard(() => ["row"], {
isTTY: false,
write(text) {
writes.push(text);
return true;
},
});
board.repaint();
board.log("plain");
board.close();
expect(writes).toEqual(["plain\n"]);
});
});