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