61a57c1d21
- Changed render methods to return component-owned `readonly string[]` rows. - Added `RenderStablePrefix` row reuse to avoid repainting unchanged streaming content. - Stabilized streaming gutters and spinner placement to reduce preview jitter and flicker. - Finalized commit-safe transcript behavior for tool-call previews and added streaming edge-case tests.
152 lines
5.1 KiB
TypeScript
152 lines
5.1 KiB
TypeScript
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> = {}): 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) => readonly 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);
|
|
});
|
|
});
|