feat(ui): ralpi-style chat progress and pipeline-overview footer

phases.ts becomes a live chat widget (spinner + tool-call tree) and the
check-runner posts per-agent tool-call summaries and an expandable
completion tree through a custom message renderer; footer.ts adds the
static pipeline-overview status strip for single checks and /pygienium-all.
This commit is contained in:
2026-08-09 16:45:29 -04:00
parent d0e8ad5571
commit 5f8a5cbe5f
10 changed files with 1165 additions and 55 deletions

184
tests/footer.test.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* footer.test.ts — unit tests for the pipeline-overview footer (task: footer).
*
* The footer is presentation-only state: with no UI it tracks items but writes
* nothing; with a stub UI it renders one status line and clears on `done()`.
* These tests exercise the state machine (cursor demotion, terminal marks)
* without spinning up a runner.
*/
import { describe, expect, it } from "bun:test";
import {
createPipelineFooter,
footerPhaseItems,
FOOTER_GLYPH,
FOOTER_STATUS_KEY,
type ItemStatus,
} from "../src/footer.js";
import { PHASE_LABELS } from "../src/phases.js";
import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js";
/** Minimal UI stub capturing `setStatus(key, text)` calls in order. */
function stubUi(): {
ui: { setStatus: (key: string, text: string | undefined) => void };
calls: { key: string; text: string | undefined }[];
} {
const calls: { key: string; text: string | undefined }[] = [];
return {
calls,
ui: {
setStatus(key, text) {
calls.push({ key, text });
},
},
};
}
/** Build the canonical phase-id list a scan-only check uses. */
function scanPhaseIds(): string[] {
return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX];
}
describe("createPipelineFooter", () => {
it("is a no-op without a UI but still tracks item state", () => {
// hasUI false: setStatus must never be called.
const footer = createPipelineFooter({ hasUI: false });
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
]);
footer.setCursor(0);
footer.done();
// No UI → no observable side effect, but getItems reflects state.
expect(footer.getItems()[0]?.status).toBe("running");
expect(footer.getTitle()).toBe("pygienium smoke");
});
it("renders the full pipeline with the cursor running and clears on done", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
footer.setPipeline("pygienium smoke", items, 0);
// One setStatus call, under the canonical key, listing every phase:
// cursor gets `▶ <label>`, the rest are glued `…<label>`.
expect(calls).toHaveLength(1);
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
expect(calls[0]?.text).toContain("pygienium smoke");
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} Recon`);
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Scanning`);
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Fixing`);
// The cursor item is marked running.
expect(footer.getItems()[0]?.status).toBe("running");
footer.done();
// done() pushes an undefined to clear the slot, then resets state.
const last = calls[calls.length - 1];
expect(last?.text).toBeUndefined();
expect(footer.getItems()).toHaveLength(0);
});
it("demotes the previous running item to pending when the cursor moves", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
// recon → complete, then advance to analysis.
footer.setItem(0, "complete");
footer.setCursor(1);
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("running");
});
it("does not demote a terminal item when the cursor advances past it", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
footer.setCursor(0); // recon running
// Simulate analysis completing (cursor was already moved there) then
// jumping to fix: a completed phase must stay complete, not revert.
footer.setItem(0, "complete");
footer.setCursor(1); // analysis running
footer.setItem(1, "complete");
footer.setCursor(2); // fix running
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("complete");
expect(items[2]?.status).toBe("running");
});
it("marks a skipped gate as every item skipped", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium comments",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
);
// No cursor: all pending. Now a gate-skip marks every phase skipped,
// mirroring the runner's gate branch.
for (let i = 0; i < footer.getItems().length; i++) {
footer.setItem(i, "skipped" as ItemStatus);
}
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
});
it("can be disabled so it never touches the status slot", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({
ui,
hasUI: true,
enabled: false,
});
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
]);
footer.setCursor(0);
footer.done();
// enabled:false suppresses every setStatus call (used by /pygienium-all
// which owns its own footer).
expect(calls).toHaveLength(0);
});
it("writes under a custom status key (all-run owns its slot)", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({
ui,
hasUI: true,
statusKey: "pygienium-all",
});
footer.setPipeline(
"pygienium: all",
[
{ label: "comments", status: "pending" },
{ label: "dead-code", status: "pending" },
],
0,
);
expect(calls[0]?.key).toBe("pygienium-all");
expect(calls[0]?.text).toContain("pygienium: all");
// Cursor on the first check; second still pending (to come).
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} comments`);
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}dead-code`);
});
});
describe("footerPhaseItems", () => {
it("maps phase ids to pending footer items using PHASE_LABELS", () => {
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
expect(items.map((i) => i.label)).toEqual(["Recon", "Scanning", "Fixing"]);
expect(items.every((i) => i.status === "pending")).toBe(true);
});
it("falls back to the raw id for unknown phases", () => {
const items = footerPhaseItems(["custom"], PHASE_LABELS);
expect(items[0]?.label).toBe("custom");
});
});