mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
528 lines
20 KiB
TypeScript
528 lines
20 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { AgentRecord } from "../src/types.js";
|
|
|
|
// ── Mock wrapTextWithAnsi ──────────────────────────────────────────────
|
|
// We need to control what wrapTextWithAnsi returns to simulate the
|
|
// upstream bug (returning lines wider than requested width).
|
|
// vi.mock is hoisted and intercepts before conversation-viewer.ts binds
|
|
// its import.
|
|
|
|
let wrapOverride: ((text: string, width: number) => string[]) | null = null;
|
|
|
|
vi.mock("@earendil-works/pi-tui", async (importOriginal) => {
|
|
const original = await importOriginal<typeof import("@earendil-works/pi-tui")>();
|
|
return {
|
|
...original,
|
|
wrapTextWithAnsi: (...args: [string, number]) => {
|
|
if (wrapOverride) return wrapOverride(...args);
|
|
return original.wrapTextWithAnsi(...args);
|
|
},
|
|
};
|
|
});
|
|
|
|
// Must import AFTER vi.mock declaration (vitest hoists vi.mock but the
|
|
// dynamic import of the test subject must happen after)
|
|
const { visibleWidth } = await import("@earendil-works/pi-tui");
|
|
const { ConversationViewer } = await import("../src/ui/conversation-viewer.js");
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
function mockTui(rows = 40, columns = 80) {
|
|
return {
|
|
terminal: { rows, columns },
|
|
requestRender: vi.fn(),
|
|
} as any;
|
|
}
|
|
|
|
function mockSession(messages: any[] = []) {
|
|
return {
|
|
messages,
|
|
subscribe: vi.fn(() => vi.fn()),
|
|
dispose: vi.fn(),
|
|
getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheWrite: 0 } }),
|
|
} as any;
|
|
}
|
|
|
|
function mockRecord(overrides: Partial<AgentRecord> = {}): AgentRecord {
|
|
return {
|
|
id: "test-1",
|
|
type: "general-purpose",
|
|
description: "test agent",
|
|
status: "running",
|
|
toolUses: 0,
|
|
startedAt: Date.now(),
|
|
...overrides,
|
|
} as AgentRecord;
|
|
}
|
|
|
|
function ansiTheme() {
|
|
return {
|
|
fg: (_color: string, text: string) => `\x1b[38;5;240m${text}\x1b[0m`,
|
|
bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
|
|
} as any;
|
|
}
|
|
|
|
function assertAllLinesFit(lines: string[], width: number) {
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const vw = visibleWidth(lines[i]);
|
|
expect(vw, `line ${i} exceeds width (${vw} > ${width}): ${JSON.stringify(lines[i])}`).toBeLessThanOrEqual(width);
|
|
}
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────
|
|
|
|
beforeEach(() => {
|
|
wrapOverride = null;
|
|
});
|
|
|
|
describe("ConversationViewer cost display", () => {
|
|
/** The header line, with a cost of `cost` on the record and showCost `on`. */
|
|
function header(on: boolean, cost: number): string {
|
|
const record = mockRecord({
|
|
lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost },
|
|
} as Partial<AgentRecord>);
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, 200), mockSession([]), record, undefined,
|
|
{ fg: (_c: string, t: string) => t, bold: (t: string) => t } as any,
|
|
vi.fn(), undefined, undefined, undefined, on,
|
|
);
|
|
return viewer.render(200).join("\n");
|
|
}
|
|
|
|
it("shows the cost beside the token count when enabled", () => {
|
|
// The viewer opens on finished agents, whose live activity entry is gone —
|
|
// so this reads the record, and would show nothing if it did not.
|
|
const out = header(true, 0.0042);
|
|
expect(out).toContain("1.2k token");
|
|
expect(out).toContain("~$0.0042");
|
|
});
|
|
|
|
it("shows no cost when disabled", () => {
|
|
const out = header(false, 0.0042);
|
|
expect(out).toContain("1.2k token");
|
|
expect(out).not.toContain("$");
|
|
});
|
|
|
|
it("shows no cost for a model with no pricing data", () => {
|
|
expect(header(true, 0)).not.toContain("$");
|
|
});
|
|
});
|
|
|
|
describe("ConversationViewer", () => {
|
|
describe("render width safety", () => {
|
|
const widths = [40, 80, 120, 216];
|
|
|
|
it("no line exceeds width with empty messages", () => {
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession([]), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width with plain text messages", () => {
|
|
const messages = [
|
|
{ role: "user", content: "Hello, how are you?" },
|
|
{ role: "assistant", content: [{ type: "text", text: "I am fine, thank you for asking." }] },
|
|
];
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("keeps bordered rows exact-width at a double-width truncation boundary", () => {
|
|
const width = 40;
|
|
for (let prefixLength = 0; prefixLength < width; prefixLength++) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, width),
|
|
mockSession([]),
|
|
mockRecord({ description: `${"a".repeat(prefixLength)}界more` }),
|
|
undefined,
|
|
ansiTheme(),
|
|
vi.fn(),
|
|
);
|
|
|
|
for (const line of viewer.render(width)) {
|
|
expect(
|
|
visibleWidth(line),
|
|
`prefix ${prefixLength} produced an under-width bordered row: ${JSON.stringify(line)}`,
|
|
).toBe(width);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width when text is longer than viewport", () => {
|
|
const longLine = "A".repeat(500);
|
|
const messages = [
|
|
{ role: "user", content: longLine },
|
|
{ role: "assistant", content: [{ type: "text", text: longLine }] },
|
|
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: longLine }] },
|
|
];
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width with embedded ANSI escape codes in content", () => {
|
|
const ansiText = `\x1b[1mBold heading\x1b[22m and \x1b[31mred text\x1b[0m ${"X".repeat(300)}`;
|
|
const messages = [
|
|
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: ansiText }] },
|
|
];
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width with long URLs", () => {
|
|
const url = "https://example.com/" + "a/b/c/d/e/".repeat(30) + "?q=" + "x".repeat(100);
|
|
const messages = [
|
|
{ role: "assistant", content: [{ type: "text", text: `Check this link: ${url}` }] },
|
|
];
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width with wide table-like content", () => {
|
|
const header = "| " + Array.from({ length: 20 }, (_, i) => `Column${i}`).join(" | ") + " |";
|
|
const dataRow = "| " + Array.from({ length: 20 }, () => "value123").join(" | ") + " |";
|
|
const table = [header, dataRow, dataRow, dataRow].join("\n");
|
|
const messages = [
|
|
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: table }] },
|
|
];
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width with bashExecution messages", () => {
|
|
const messages = [
|
|
{
|
|
role: "bashExecution", command: "cat " + "/very/long/path/".repeat(20) + "file.txt",
|
|
output: "O".repeat(600),
|
|
exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
|
|
},
|
|
];
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width with running activity indicator", () => {
|
|
const activity = {
|
|
activeTools: new Map([["read", "file.ts"], ["grep", "pattern"]]),
|
|
toolUses: 5, tokens: "10k", responseText: "R".repeat(400),
|
|
session: { getSessionStats: () => ({ tokens: { total: 50000 } }) },
|
|
};
|
|
const messages = [
|
|
{ role: "user", content: "do the thing" },
|
|
{ role: "assistant", content: [{ type: "text", text: "working on it" }] },
|
|
];
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord({ status: "running" }), activity as any, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width with tool calls", () => {
|
|
const messages = [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "text", text: "Let me check that." },
|
|
{ type: "toolCall", toolUseId: "t1", name: "very_long_tool_name_" + "x".repeat(200), input: {} },
|
|
],
|
|
},
|
|
];
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width at narrow terminal", () => {
|
|
const messages = [
|
|
{ role: "user", content: "Hello world, this is a normal sentence." },
|
|
{ role: "assistant", content: [{ type: "text", text: "Sure, here's the answer." }] },
|
|
];
|
|
for (const w of [8, 10, 15, 20]) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
|
|
it("no line exceeds width with mixed ANSI + unicode content", () => {
|
|
const text = `\x1b[32m✓\x1b[0m Test passed — 日本語テスト ${"あ".repeat(50)} \x1b[33m⚠\x1b[0m`;
|
|
const messages = [
|
|
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text }] },
|
|
];
|
|
for (const w of widths) {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("safety net against upstream wrapTextWithAnsi bugs", () => {
|
|
// These tests call buildContentLines() directly (via the private method)
|
|
// because render() has its own truncation via row(). The safety net in
|
|
// buildContentLines is what prevents the TUI crash — it must clamp
|
|
// independently of render().
|
|
|
|
/** Call the private buildContentLines method directly. */
|
|
function callBuildContentLines(viewer: InstanceType<typeof ConversationViewer>, width: number): string[] {
|
|
return (viewer as any).buildContentLines(width);
|
|
}
|
|
|
|
it("mock is intercepting wrapTextWithAnsi", async () => {
|
|
const { wrapTextWithAnsi } = await import("@earendil-works/pi-tui");
|
|
wrapOverride = () => ["MOCK_SENTINEL"];
|
|
expect(wrapTextWithAnsi("anything", 10)).toEqual(["MOCK_SENTINEL"]);
|
|
wrapOverride = null;
|
|
});
|
|
|
|
it("clamps overwidth lines from toolResult content", () => {
|
|
const w = 80;
|
|
wrapOverride = () => ["X".repeat(w + 50)];
|
|
|
|
const messages = [
|
|
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
|
|
];
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
|
});
|
|
|
|
it("clamps overwidth lines from user message content", () => {
|
|
const w = 80;
|
|
wrapOverride = () => ["Y".repeat(w + 100)];
|
|
|
|
const messages = [{ role: "user", content: "hello" }];
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
|
});
|
|
|
|
it("clamps overwidth lines from assistant message content", () => {
|
|
const w = 80;
|
|
wrapOverride = () => ["Z".repeat(w + 100)];
|
|
|
|
const messages = [
|
|
{ role: "assistant", content: [{ type: "text", text: "response" }] },
|
|
];
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
|
});
|
|
|
|
it("clamps overwidth lines from bashExecution output", () => {
|
|
const w = 80;
|
|
wrapOverride = () => ["B".repeat(w + 100)];
|
|
|
|
const messages = [
|
|
{
|
|
role: "bashExecution", command: "ls", output: "out",
|
|
exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
|
|
},
|
|
];
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
|
});
|
|
|
|
it("clamps overwidth lines that also contain ANSI codes", () => {
|
|
const w = 80;
|
|
wrapOverride = () => [`\x1b[1m\x1b[31m${"W".repeat(w + 30)}\x1b[0m`];
|
|
|
|
const messages = [
|
|
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
|
|
];
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
|
});
|
|
});
|
|
|
|
describe("stop key", () => {
|
|
const W = 80;
|
|
|
|
it("two-press x stops a running agent (first arms, second aborts)", () => {
|
|
const onStop = vi.fn();
|
|
const tui = mockTui(30, W);
|
|
const viewer = new ConversationViewer(
|
|
tui, mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop,
|
|
);
|
|
|
|
// Idle footer offers the stop affordance.
|
|
expect(viewer.render(W).join("\n")).toContain("x stop");
|
|
|
|
// First press arms (no abort yet) and re-renders.
|
|
viewer.handleInput("x");
|
|
expect(onStop).not.toHaveBeenCalled();
|
|
expect(tui.requestRender).toHaveBeenCalled();
|
|
expect(viewer.render(W).join("\n")).toContain("x again to STOP");
|
|
|
|
// Second press aborts.
|
|
viewer.handleInput("x");
|
|
expect(onStop).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("any other key disarms the confirm", () => {
|
|
const onStop = vi.fn();
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop,
|
|
);
|
|
|
|
viewer.handleInput("x"); // arm
|
|
viewer.handleInput("j"); // scroll → disarm
|
|
expect(viewer.render(W).join("\n")).toContain("x stop");
|
|
expect(viewer.render(W).join("\n")).not.toContain("x again to STOP");
|
|
|
|
viewer.handleInput("x"); // arms again, does NOT stop
|
|
expect(onStop).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not offer or perform stop once the agent is no longer running", () => {
|
|
const onStop = vi.fn();
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, W), mockSession(), mockRecord({ status: "completed" }), undefined, ansiTheme(), vi.fn(), onStop,
|
|
);
|
|
|
|
expect(viewer.render(W).join("\n")).not.toContain("x stop");
|
|
viewer.handleInput("x");
|
|
viewer.handleInput("x");
|
|
expect(onStop).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("no stop affordance when no onStop handler is provided (read-only history)", () => {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
expect(viewer.render(W).join("\n")).not.toContain("x stop");
|
|
expect(() => { viewer.handleInput("x"); viewer.handleInput("x"); }).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("steer composer", () => {
|
|
const W = 80;
|
|
|
|
function makeViewer(opts: { status?: AgentRecord["status"]; onSteer?: (m: string) => void } = {}) {
|
|
const onSteer = opts.onSteer ?? vi.fn();
|
|
const tui = mockTui(30, W);
|
|
const viewer = new ConversationViewer(
|
|
tui, mockSession(), mockRecord({ status: opts.status ?? "running" }),
|
|
undefined, ansiTheme(), vi.fn(), undefined, undefined, onSteer,
|
|
);
|
|
return { viewer, tui, onSteer };
|
|
}
|
|
|
|
it("offers the steer affordance for a running agent and opens on Enter", () => {
|
|
const { viewer } = makeViewer();
|
|
expect(viewer.render(W).join("\n")).toContain("Enter steer");
|
|
|
|
viewer.handleInput("\r"); // Enter
|
|
// Composer is shown (its prompt + send/cancel hint), idle footer is gone.
|
|
const out = viewer.render(W).join("\n");
|
|
expect(out).toContain("Enter send · Esc cancel");
|
|
expect(out).not.toContain("Enter steer");
|
|
});
|
|
|
|
it("typing then Enter sends the trimmed message and closes the composer", () => {
|
|
const { viewer, onSteer } = makeViewer();
|
|
viewer.handleInput("\r"); // open composer
|
|
for (const ch of " hello ") viewer.handleInput(ch);
|
|
viewer.handleInput("\r"); // send
|
|
|
|
expect(onSteer).toHaveBeenCalledWith("hello");
|
|
expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed
|
|
});
|
|
|
|
it("Esc cancels the composer without sending", () => {
|
|
const { viewer, onSteer } = makeViewer();
|
|
viewer.handleInput("\r"); // open composer
|
|
for (const ch of "draft") viewer.handleInput(ch);
|
|
viewer.handleInput("\x1b"); // Esc
|
|
|
|
expect(onSteer).not.toHaveBeenCalled();
|
|
expect(viewer.render(W).join("\n")).not.toContain("Enter send");
|
|
});
|
|
|
|
it("an empty submit just returns (like Esc), without calling onSteer", () => {
|
|
const { viewer, onSteer } = makeViewer();
|
|
viewer.handleInput("\r"); // open composer
|
|
viewer.handleInput("\r"); // empty submit
|
|
expect(onSteer).not.toHaveBeenCalled();
|
|
expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed
|
|
});
|
|
|
|
it("scroll keys are inert while composing (input owns them)", () => {
|
|
const { viewer } = makeViewer();
|
|
viewer.handleInput("\r"); // open composer
|
|
// 'j' would normally scroll, but here it types into the composer.
|
|
viewer.handleInput("j");
|
|
expect(viewer.render(W).join("\n")).toContain("Enter send · Esc cancel");
|
|
});
|
|
|
|
it("no steer affordance once the agent is no longer running", () => {
|
|
const { viewer, onSteer } = makeViewer({ status: "completed" });
|
|
expect(viewer.render(W).join("\n")).not.toContain("Enter steer");
|
|
viewer.handleInput("\r");
|
|
expect(viewer.render(W).join("\n")).not.toContain("Enter send");
|
|
expect(onSteer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("no steer affordance when no onSteer handler is provided", () => {
|
|
const viewer = new ConversationViewer(
|
|
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(),
|
|
);
|
|
expect(viewer.render(W).join("\n")).not.toContain("Enter steer");
|
|
expect(() => viewer.handleInput("\r")).not.toThrow();
|
|
});
|
|
|
|
it("composer rows never exceed width", () => {
|
|
for (const w of [40, 80, 120]) {
|
|
const tui = mockTui(30, w);
|
|
const viewer = new ConversationViewer(
|
|
tui, mockSession(), mockRecord({ status: "running" }),
|
|
undefined, ansiTheme(), vi.fn(), undefined, undefined, vi.fn(),
|
|
);
|
|
viewer.handleInput("\r"); // open composer
|
|
for (const ch of "x".repeat(200)) viewer.handleInput(ch);
|
|
assertAllLinesFit(viewer.render(w), w);
|
|
}
|
|
});
|
|
});
|
|
});
|