feat: chat rendering toolcalls, footer overview

This commit is contained in:
2026-08-09 20:24:11 -04:00
parent cb8a88d0bd
commit 259b1d3b2d
14 changed files with 664 additions and 383 deletions

View File

@@ -1,33 +1,58 @@
/**
* footer.test.ts — unit tests for the pipeline-overview footer (task: footer).
* footer.test.ts — unit tests for the pipeline-overview 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.
* The footer is a presentation-only multi-line `belowEditor` widget: with no
* UI it tracks items but writes nothing; with a stub UI it pushes a string[]
* of themed lines via `ui.setWidget` (key, lines, { placement: "belowEditor" })
* and clears on `done()`. The pure {@link renderFooterList} core is asserted
* directly (layout + theming); a light widget-glue test covers the wiring.
*/
import { describe, expect, it } from "bun:test";
import {
createPipelineFooter,
footerPhaseItems,
FOOTER_GLYPH,
footerColor,
renderFooterList,
FOOTER_MARKER,
FOOTER_STATUS_KEY,
type ItemStatus,
type FooterItem,
type FooterTheme,
} 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 }[];
/** A fake theme that wraps text as `<color>:<text>` so assertions can read it. */
function fakeTheme(): FooterTheme {
return { fg: (color, text) => `${color}:${text}` };
}
/** Minimal UI stub capturing `setWidget` calls (key, lines, options). */
function stubUi(theme: FooterTheme = fakeTheme()): {
ui: {
theme: FooterTheme;
setWidget: (
key: string,
content: string[] | undefined,
options?: { placement?: string },
) => void;
};
calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[];
} {
const calls: { key: string; text: string | undefined }[] = [];
const calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[] = [];
return {
calls,
ui: {
setStatus(key, text) {
calls.push({ key, text });
theme,
setWidget(key, content, options) {
calls.push({ key, content, placement: options?.placement });
},
},
};
@@ -38,9 +63,72 @@ function scanPhaseIds(): string[] {
return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX];
}
/** Items for the canonical scan-only pipeline. */
function scanItems(status: FooterItem["status"] = "pending"): FooterItem[] {
return scanPhaseIds().map((id) => ({
label: PHASE_LABELS[id] ?? id,
status,
}));
}
describe("renderFooterList", () => {
it("renders one bulleted, numbered, themed line per phase", () => {
const lines = renderFooterList(scanItems(), -1, fakeTheme());
expect(lines).toHaveLength(3);
// Each line: `• <marker> <n>. <label>` wrapped `<color>:…`.
expect(lines[0]).toBe("dim:• · 1. Recon");
expect(lines[1]).toBe("dim:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes the cursor item as accent (running) and the rest as dim (pending)", () => {
const lines = renderFooterList(scanItems(), 1, fakeTheme());
expect(lines[0]).toBe("dim:• · 1. Recon");
// cursor (index 1) is pending-but-current → accent.
expect(lines[1]).toBe("accent:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes terminal statuses with success/error/warning colors", () => {
const items: FooterItem[] = [
{ label: "Recon", status: "complete" },
{ label: "Scan", status: "running" },
{ label: "Fix", status: "failed" },
{ label: "Verify", status: "skipped" },
];
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toBe("success:• ✓ 1. Recon");
expect(lines[1]).toBe("accent:• ● 2. Scan");
expect(lines[2]).toBe("error:• ✗ 3. Fix");
expect(lines[3]).toBe("warning:• ↷ 4. Verify");
});
it("pads the index to 2 digits when the pipeline has 10+ items", () => {
const items: FooterItem[] = Array.from({ length: 11 }, (_, i) => ({
label: `S${i}`,
status: "pending" as const,
}));
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toContain("01. S0");
expect(lines[10]).toContain("11. S10");
});
});
describe("footerColor", () => {
it("maps each status to its piolium-style color token", () => {
expect(footerColor("complete", false)).toBe("success");
expect(footerColor("failed", false)).toBe("error");
expect(footerColor("skipped", false)).toBe("warning");
expect(footerColor("running", false)).toBe("accent");
expect(footerColor("pending", false)).toBe("dim");
// A pending item under the cursor reads as accent (current).
expect(footerColor("pending", true)).toBe("accent");
});
});
describe("createPipelineFooter", () => {
it("is a no-op without a UI but still tracks item state", () => {
// hasUI false: setStatus must never be called.
// hasUI false: setWidget must never be called.
const footer = createPipelineFooter({ hasUI: false });
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
@@ -52,28 +140,31 @@ describe("createPipelineFooter", () => {
expect(footer.getTitle()).toBe("pygienium smoke");
});
it("renders the full pipeline with the cursor running and clears on done", () => {
it("renders the full pipeline as a belowEditor widget 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>`.
// One setWidget call, under the canonical key, placement belowEditor.
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`);
expect(calls[0]?.placement).toBe("belowEditor");
const lines = calls[0]?.content ?? [];
// Title line first (dim), then one bulleted line per phase.
expect(lines[0]).toBe("dim:pygienium smoke");
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. Recon`);
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. Scanning`);
expect(lines[3]).toBe(`dim:• ${FOOTER_MARKER.pending} 3. 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();
const last = calls[calls.length - 1]!;
expect(last.content).toBeUndefined();
expect(last.placement).toBe("belowEditor");
expect(footer.getItems()).toHaveLength(0);
});
@@ -103,8 +194,6 @@ describe("createPipelineFooter", () => {
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");
@@ -122,32 +211,26 @@ describe("createPipelineFooter", () => {
"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);
footer.setItem(i, "skipped");
}
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
});
it("can be disabled so it never touches the status slot", () => {
it("can be disabled so it never touches the widget slot", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({
ui,
hasUI: true,
enabled: false,
});
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
// enabled:false suppresses every setWidget 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)", () => {
it("writes under a custom widget key (all-run owns its slot)", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({
ui,
@@ -163,10 +246,12 @@ describe("createPipelineFooter", () => {
0,
);
expect(calls[0]?.key).toBe("pygienium-all");
expect(calls[0]?.text).toContain("pygienium: all");
expect(calls[0]?.placement).toBe("belowEditor");
const lines = calls[0]?.content ?? [];
expect(lines[0]).toBe("dim: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`);
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. comments`);
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. dead-code`);
});
});