import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { taskToolRenderer } from "@oh-my-pi/pi-coding-agent/task/render"; import type { AgentProgress, TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task/types"; function runningProgress(overrides: Partial = {}): AgentProgress { return { index: 0, id: "KeySettingsHotPaths", agent: "task", agentSource: "bundled", status: "running", task: "investigate hot paths", recentTools: [], recentOutput: [], toolCount: 0, tokens: 0, cost: 0, durationMs: 0, ...overrides, }; } function detailsFor(progress: AgentProgress): TaskToolDetails { return { projectAgentsDir: null, results: [], totalDurationMs: 0, progress: [progress] }; } function findRow(component: { render: (w: number) => string[] }, needle: string): string { const row = component .render(120) .join("\n") .split("\n") .find(line => Bun.stripANSI(line).includes(needle)); expect(row).toBeDefined(); return row!; } describe("task progress rendering", () => { beforeEach(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true }); }); afterEach(() => { vi.restoreAllMocks(); resetSettingsForTest(); }); it("keeps the subagent label solid and shimmers the running description", async () => { const theme = (await getThemeByName("dark"))!; expect(theme).toBeDefined(); const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 }; const progress = runningProgress({ id: "CountPackages", description: "List workspace packages" }); const renderRow = (timeMs: number): string => { vi.spyOn(Date, "now").mockReturnValue(timeMs); return findRow( taskToolRenderer.renderResult( { content: [{ type: "text", text: "" }], details: detailsFor(progress) }, options, theme, ), "CountPackages", ); }; const rawRow0 = renderRow(0); const rawRow1 = renderRow(700); const strippedRow = Bun.stripANSI(rawRow0); expect(strippedRow).toContain("• CountPackages: List workspace packages"); expect(strippedRow).not.toContain(theme.status.running); expect(strippedRow).not.toContain(theme.getSpinnerFrames("status")[0]); // The label is one solid bold-accent run, identical across shimmer frames. const label = theme.fg("accent", theme.bold("CountPackages")); expect(rawRow0).toContain(label); expect(rawRow1).toContain(label); // The description shimmers, so the row as a whole animates between frames. expect(rawRow0).not.toBe(rawRow1); }); it("keeps the bullet replacement when shimmer is disabled", async () => { const theme = (await getThemeByName("dark"))!; resetSettingsForTest(); await Settings.init({ inMemory: true, overrides: { "display.shimmer": "disabled" } }); const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 }; const strippedRow = Bun.stripANSI( findRow( taskToolRenderer.renderResult( { content: [{ type: "text", text: "" }], details: detailsFor(runningProgress()) }, options, theme, ), "KeySettingsHotPaths", ), ); expect(strippedRow).toContain("• KeySettingsHotPaths"); expect(strippedRow).not.toContain(theme.status.running); expect(strippedRow).not.toContain(theme.getSpinnerFrames("status")[0]); }); }); describe("task result detail-less state", () => { beforeEach(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true }); }); afterEach(() => { vi.restoreAllMocks(); resetSettingsForTest(); }); it("renders a validation failure with the error glyph, not a success bullet", async () => { const theme = (await getThemeByName("dark"))!; const options: RenderResultOptions = { expanded: false, isPartial: false }; const component = taskToolRenderer.renderResult( { content: [{ type: "text", text: 'Validation failed for tool "task": tasks: Invalid input' }], isError: true, }, options, theme, { agent: "explore", tasks: [] }, ); const stripped = Bun.stripANSI(component.render(120).join("\n")); // A failed task must surface the error glyph and never the "done" bullet. expect(stripped).toContain(theme.status.error); expect(stripped).not.toContain(theme.status.done); expect(stripped).toContain("Task"); expect(stripped).toContain("explore"); expect(stripped).toContain("Validation failed"); }); it("renders a detail-less success with the accent bullet, not an error glyph", async () => { const theme = (await getThemeByName("dark"))!; const options: RenderResultOptions = { expanded: false, isPartial: false }; const component = taskToolRenderer.renderResult({ content: [{ type: "text", text: "done" }] }, options, theme, { agent: "explore", tasks: [], }); const stripped = Bun.stripANSI(component.render(120).join("\n")); expect(stripped).toContain(theme.status.done); expect(stripped).not.toContain(theme.status.error); }); });