mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 08:44:38 +08:00
Tools without a custom call renderer now show their arguments as key=value pairs when collapsed and one key: value line per argument when expanded. MCP tools get a server/tool title and results that collapse to 5 lines.
715 lines
23 KiB
TypeScript
715 lines
23 KiB
TypeScript
import { join, resolve } from "node:path";
|
|
import { resetCapabilitiesCache, setCapabilities, Text, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
import { Type } from "typebox";
|
|
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
|
|
|
const imageConvertMocks = vi.hoisted(() => ({ convertToPng: vi.fn() }));
|
|
|
|
vi.mock("../src/utils/image-convert.ts", () => imageConvertMocks);
|
|
|
|
import { getReadmePath } from "../src/config.ts";
|
|
import type { ToolDefinition } from "../src/core/extensions/types.ts";
|
|
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.ts";
|
|
import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.ts";
|
|
import { withBuiltInRenderers } from "../src/core/tools/renderers/index.ts";
|
|
import { createWriteToolDefinition } from "../src/core/tools/write.ts";
|
|
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts";
|
|
import { initTheme, theme } from "../src/modes/interactive/theme/theme.ts";
|
|
import { stripAnsi } from "../src/utils/ansi.ts";
|
|
|
|
function createBaseToolDefinition(name = "custom_tool"): ToolDefinition {
|
|
return {
|
|
name,
|
|
label: name,
|
|
description: "custom tool",
|
|
parameters: Type.Any(),
|
|
execute: async () => ({
|
|
content: [{ type: "text", text: "ok" }],
|
|
details: {},
|
|
}),
|
|
};
|
|
}
|
|
|
|
function createFakeTui(): TUI {
|
|
return {
|
|
requestRender: () => {},
|
|
} as unknown as TUI;
|
|
}
|
|
|
|
describe("ToolExecutionComponent parity", () => {
|
|
beforeAll(() => {
|
|
initTheme("dark");
|
|
});
|
|
afterEach(() => {
|
|
resetCapabilitiesCache();
|
|
imageConvertMocks.convertToPng.mockReset();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// Issue #8577: ignore conversions that finish after the image was replaced.
|
|
test("keeps the final tool image when a partial image conversion finishes late", async () => {
|
|
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
|
let finishConversion!: (result: { data: string; mimeType: string }) => void;
|
|
const conversion = new Promise<{ data: string; mimeType: string }>((resolve) => {
|
|
finishConversion = resolve;
|
|
});
|
|
imageConvertMocks.convertToPng.mockReturnValue(conversion);
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-image-race",
|
|
{},
|
|
{},
|
|
undefined,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
|
|
component.updateResult(
|
|
{ content: [{ type: "image", data: "partial-jpeg", mimeType: "image/jpeg" }], isError: false },
|
|
true,
|
|
);
|
|
component.updateResult({
|
|
content: [{ type: "image", data: "final-png", mimeType: "image/png" }],
|
|
isError: false,
|
|
});
|
|
expect(component.render(120).join("\n")).toContain("final-png");
|
|
|
|
finishConversion({ data: "converted-partial", mimeType: "image/png" });
|
|
await conversion;
|
|
|
|
const rendered = component.render(120).join("\n");
|
|
expect(rendered).toContain("final-png");
|
|
expect(rendered).not.toContain("converted-partial");
|
|
});
|
|
|
|
test("stacks custom call and result renderers like the old implementation", () => {
|
|
const toolDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition(),
|
|
renderCall: () => new Text("custom call", 0, 0),
|
|
renderResult: () => new Text("custom result", 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-1",
|
|
{},
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call");
|
|
|
|
component.updateResult(
|
|
{
|
|
content: [{ type: "text", text: "done" }],
|
|
details: {},
|
|
isError: false,
|
|
},
|
|
false,
|
|
);
|
|
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("custom call");
|
|
expect(rendered).toContain("custom result");
|
|
});
|
|
|
|
test("self-rendered empty tool rows take no layout space", () => {
|
|
const toolDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition(),
|
|
renderShell: "self",
|
|
renderCall: () => new Text("", 0, 0),
|
|
renderResult: () => new Text("", 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-empty-self-render",
|
|
{},
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
expect(component.render(120)).toEqual([]);
|
|
|
|
component.updateResult(
|
|
{
|
|
content: [],
|
|
details: {},
|
|
isError: false,
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(component.render(120)).toEqual([]);
|
|
});
|
|
|
|
test("uses built-in rendering for built-in overrides without custom renderers", () => {
|
|
const overrideDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition("edit"),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"edit",
|
|
"tool-2",
|
|
{ path: "README.md", oldText: "before", newText: "after" },
|
|
{},
|
|
withBuiltInRenderers("edit", overrideDefinition),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false });
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("edit");
|
|
expect(rendered).toContain("README.md");
|
|
expect(rendered).not.toContain(":1");
|
|
});
|
|
|
|
test("preserves legacy file_path rendering compatibility for built-in tools", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-3",
|
|
{ file_path: "README.md" },
|
|
{},
|
|
undefined,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("read");
|
|
expect(rendered).toContain("README.md");
|
|
});
|
|
|
|
test("bash execute emits an initial empty partial update before output arrives", async () => {
|
|
const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = [];
|
|
const operations: BashOperations = {
|
|
exec: async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
return { exitCode: 0 };
|
|
},
|
|
};
|
|
const tool = createBashToolDefinition(process.cwd(), { operations, exposeSessionEnvironment: false });
|
|
const promise = tool.execute(
|
|
"tool-bash-1",
|
|
{ command: "sleep 10" },
|
|
undefined,
|
|
(update) => updates.push(update as { content: Array<{ type: string; text?: string }>; details?: unknown }),
|
|
{} as never,
|
|
);
|
|
expect(updates).toEqual([{ content: [], details: undefined }]);
|
|
await promise;
|
|
});
|
|
|
|
test("bash renderer does not duplicate final full output truncation details", async () => {
|
|
const operations: BashOperations = {
|
|
exec: async (_command, _cwd, { onData }) => {
|
|
for (let i = 1; i <= 4000; i++) {
|
|
onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`));
|
|
}
|
|
return { exitCode: 0 };
|
|
},
|
|
};
|
|
const tool = createBashToolDefinition(process.cwd(), { operations, exposeSessionEnvironment: false });
|
|
const result = await tool.execute(
|
|
"tool-bash-1b",
|
|
{ command: "generate output" },
|
|
undefined,
|
|
undefined,
|
|
{} as never,
|
|
);
|
|
const component = new ToolExecutionComponent(
|
|
"bash",
|
|
"tool-bash-1b",
|
|
{ command: "generate output" },
|
|
{},
|
|
tool,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.setExpanded(true);
|
|
component.updateResult({ ...result, isError: false }, false);
|
|
|
|
const rendered = stripAnsi(component.render(200).join("\n"));
|
|
expect(rendered.match(/Full output:/g)?.length ?? 0).toBe(1);
|
|
expect(rendered).toMatch(/line-4000[^\n]*\n[^\S\n]*\n \[Full output:/);
|
|
expect(rendered).not.toMatch(/line-4000[^\n]*\n[^\S\n]*\n[^\S\n]*\n \[Full output:/);
|
|
expect(rendered).toContain("Truncated: showing 2000 of 4000 lines");
|
|
expect(rendered).not.toContain("[Showing lines 2001-4000 of 4000. Full output:");
|
|
});
|
|
|
|
// Issue #9628: keep short durations precise and make long shell durations readable.
|
|
test.each([
|
|
{ ms: 0, formatted: "0.0s" },
|
|
{ ms: 4_200, formatted: "4.2s" },
|
|
{ ms: 59_900, formatted: "59.9s" },
|
|
{ ms: 59_999, formatted: "60.0s" },
|
|
{ ms: 60_000, formatted: "1m 0s" },
|
|
{ ms: 90_900, formatted: "1m 30s" },
|
|
{ ms: 1_592_200, formatted: "26m 32s" },
|
|
{ ms: 3_599_999, formatted: "59m 59s" },
|
|
{ ms: 3_600_000, formatted: "1h 0m 0s" },
|
|
{ ms: 7_384_900, formatted: "2h 3m 4s" },
|
|
])("bash renderer formats $ms ms as $formatted while running and after completion", ({ ms, formatted }) => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
const component = new ToolExecutionComponent(
|
|
"bash",
|
|
"tool-bash-duration",
|
|
{ command: "long-running-command" },
|
|
{},
|
|
createBashToolDefinition(process.cwd(), { exposeSessionEnvironment: false }),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.markExecutionStarted();
|
|
component.updateResult({ content: [], isError: false }, true);
|
|
|
|
vi.advanceTimersByTime(ms);
|
|
component.invalidate();
|
|
const running = stripAnsi(component.render(120).join("\n"));
|
|
|
|
component.updateResult({ content: [], isError: false }, false);
|
|
const completed = stripAnsi(component.render(120).join("\n"));
|
|
|
|
vi.advanceTimersByTime(1_000);
|
|
component.invalidate();
|
|
expect(stripAnsi(component.render(120).join("\n"))).toBe(completed);
|
|
expect(running).toContain(`Elapsed ${formatted}`);
|
|
expect(completed).toContain(`Took ${formatted}`);
|
|
});
|
|
|
|
test("does not duplicate built-in headers when passed the active built-in definition", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4",
|
|
{ path: "README.md" },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered.match(/\bread\b/g)?.length ?? 0).toBe(1);
|
|
});
|
|
|
|
// Issue #9996: strict tool schemas make models send null for omitted optional fields.
|
|
test("renders read calls with null offset and limit as full-file reads", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-read-null-range",
|
|
{ path: "src/example.ts", offset: null, limit: null },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("read src/example.ts");
|
|
expect(rendered).not.toContain("src/example.ts:");
|
|
});
|
|
|
|
test("inherits missing built-in result renderer slot from the built-in tool", () => {
|
|
const overrideDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition("read"),
|
|
renderCall: () => new Text("override call", 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4b",
|
|
{ path: "notes.txt" },
|
|
{},
|
|
withBuiltInRenderers("read", overrideDefinition),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
component.setExpanded(true);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("override call");
|
|
expect(rendered).toContain("hello");
|
|
});
|
|
|
|
test("inherits missing built-in call renderer slot from the built-in tool", () => {
|
|
const overrideDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition("read"),
|
|
renderResult: () => new Text("override result", 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4c",
|
|
{ path: "README.md" },
|
|
{},
|
|
withBuiltInRenderers("read", overrideDefinition),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("read");
|
|
expect(rendered).toContain("README.md");
|
|
expect(rendered).toContain("override result");
|
|
});
|
|
|
|
test("uses custom renderers for built-in overrides that reuse built-in definition parameters", () => {
|
|
const builtInDefinition = createReadToolDefinition(process.cwd());
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4d",
|
|
{ path: "README.md" },
|
|
{},
|
|
{
|
|
...builtInDefinition,
|
|
renderCall: () => new Text("override call", 0, 0),
|
|
renderResult: () => new Text("override result", 0, 0),
|
|
},
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("override call");
|
|
expect(rendered).toContain("override result");
|
|
expect(rendered).not.toContain("read README.md");
|
|
});
|
|
|
|
test("uses custom renderers for built-in overrides that reuse wrapped built-in tool parameters", () => {
|
|
const builtInTool = createReadTool(process.cwd());
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4e",
|
|
{ path: "README.md" },
|
|
{},
|
|
{
|
|
...createBaseToolDefinition("read"),
|
|
parameters: builtInTool.parameters,
|
|
renderCall: () => new Text("wrapped override call", 0, 0),
|
|
renderResult: () => new Text("wrapped override result", 0, 0),
|
|
},
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("wrapped override call");
|
|
expect(rendered).toContain("wrapped override result");
|
|
});
|
|
|
|
test("shares renderer state across custom call and result slots", () => {
|
|
type RenderState = { token?: string };
|
|
const toolDefinition: ToolDefinition<any, unknown, RenderState> = {
|
|
...createBaseToolDefinition(),
|
|
renderCall: (_args, _theme, context) => {
|
|
context.state.token ??= "shared-token";
|
|
return new Text(`custom call ${context.state.token}`, 0, 0);
|
|
},
|
|
renderResult: (_result, _options, _theme, context) => {
|
|
return new Text(`custom result ${context.state.token}`, 0, 0);
|
|
},
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-5",
|
|
{},
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("custom call shared-token");
|
|
expect(rendered).toContain("custom result shared-token");
|
|
});
|
|
|
|
test("exposes args in render result context", () => {
|
|
const toolDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition(),
|
|
renderCall: () => new Text("call", 0, 0),
|
|
renderResult: (_result, _options, _theme, context) =>
|
|
new Text(`arg:${String((context.args as { foo: string }).foo)}`, 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-5b",
|
|
{ foo: "bar" },
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("arg:bar");
|
|
});
|
|
|
|
test("shows arguments in the fallback call header", () => {
|
|
const longValue = "x".repeat(200);
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-args",
|
|
{ query: "pi", long: longValue, text: "line one\nline two" },
|
|
{},
|
|
createBaseToolDefinition(),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
|
|
const collapsed = stripAnsi(component.render(300).join("\n"));
|
|
expect(collapsed).toContain('custom_tool query="pi" long="xxx');
|
|
expect(collapsed).toContain("...");
|
|
expect(collapsed).not.toContain(longValue);
|
|
|
|
component.setExpanded(true);
|
|
const expanded = stripAnsi(component.render(300).join("\n"));
|
|
expect(expanded).toContain(" query: pi");
|
|
expect(expanded).toContain(longValue);
|
|
const expandedLines = expanded.split("\n").map((line) => line.trimEnd());
|
|
const textLine = expandedLines.findIndex((line) => line.endsWith(" text: line one"));
|
|
expect(textLine).toBeGreaterThan(-1);
|
|
expect(expandedLines[textLine + 1]).toMatch(/^\s+ {4}line two$/);
|
|
});
|
|
|
|
test("collapses fallback results until expanded", () => {
|
|
const toolDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition(),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-6",
|
|
{ foo: "bar" },
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
const output = Array.from({ length: 15 }, (_, index) => `line-${index + 1}`).join("\n");
|
|
component.updateResult({ content: [{ type: "text", text: output }], details: {}, isError: false }, false);
|
|
|
|
const collapsed = stripAnsi(component.render(120).join("\n"));
|
|
expect(collapsed).toContain("custom_tool");
|
|
expect(collapsed).toContain("line-10");
|
|
expect(collapsed).not.toContain("line-11");
|
|
expect(collapsed).toContain("5 more lines");
|
|
expect(collapsed).toContain("to expand");
|
|
|
|
component.setExpanded(true);
|
|
const expanded = stripAnsi(component.render(120).join("\n"));
|
|
expect(expanded).toContain("line-15");
|
|
expect(expanded).not.toContain("more lines");
|
|
});
|
|
|
|
test("trims trailing blank display lines from write previews", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"write",
|
|
"tool-7",
|
|
{ path: "README.md", content: "one\ntwo\n" },
|
|
{},
|
|
createWriteToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("one");
|
|
expect(rendered).toContain("two");
|
|
expect(rendered).not.toContain("two\n\n");
|
|
});
|
|
|
|
test("trims trailing blank display lines from read results", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-8",
|
|
{ path: "notes.txt" },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult(
|
|
{ content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false },
|
|
false,
|
|
);
|
|
component.setExpanded(true);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("one");
|
|
expect(rendered).toContain("two");
|
|
expect(rendered).not.toContain("two\n\n");
|
|
});
|
|
|
|
test("does not syntax-highlight read errors based on the requested file path", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-read-error-highlighting",
|
|
{ path: "config.exs", offset: 120, limit: 130 },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
const error = "Offset 120 is beyond end of file (96 lines total)";
|
|
component.updateResult({ content: [{ type: "text", text: error }], details: undefined, isError: true }, false);
|
|
|
|
const rendered = component.render(120).join("\n");
|
|
expect(stripAnsi(rendered)).toContain(error);
|
|
expect(rendered).toContain(theme.fg("toolOutput", error));
|
|
});
|
|
|
|
test("expands a collapsed tool result when clicked", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-click-expand",
|
|
{ path: "notes.txt" },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult(
|
|
{ content: [{ type: "text", text: "hidden content" }], details: undefined, isError: false },
|
|
false,
|
|
);
|
|
const width = 120;
|
|
const lines = component.render(width);
|
|
const resultRow = lines.findIndex((line) => stripAnsi(line).includes("notes.txt"));
|
|
expect(resultRow).toBeGreaterThanOrEqual(0);
|
|
const event: TuiMouseEvent = {
|
|
type: "click",
|
|
button: "left",
|
|
x: 2,
|
|
y: resultRow,
|
|
screenX: 2,
|
|
screenY: resultRow,
|
|
width,
|
|
height: lines.length,
|
|
shift: false,
|
|
alt: false,
|
|
ctrl: false,
|
|
clickCount: 1,
|
|
};
|
|
expect(component.handleMouse(event)?.handled).toBe(true);
|
|
expect(stripAnsi(component.render(width).join("\n"))).toContain("hidden content");
|
|
});
|
|
|
|
test("collapses ordinary read results until expanded", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-ordinary-read-collapsed",
|
|
{ path: "notes.txt" },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult(
|
|
{ content: [{ type: "text", text: "hidden content" }], details: undefined, isError: false },
|
|
false,
|
|
);
|
|
|
|
const collapsed = stripAnsi(component.render(120).join("\n"));
|
|
expect(collapsed).toContain("read");
|
|
expect(collapsed).toContain("notes.txt");
|
|
expect(collapsed).not.toContain("hidden content");
|
|
|
|
component.setExpanded(true);
|
|
const expanded = stripAnsi(component.render(120).join("\n"));
|
|
expect(expanded).toContain("hidden content");
|
|
});
|
|
|
|
for (const scenario of [
|
|
{
|
|
title: "SKILL.md",
|
|
path: join(process.cwd(), "attio", "SKILL.md"),
|
|
content: "---\nname: attio\ndescription: CRM helper\n---\n\n# Hidden skill instructions",
|
|
compact: "[skill] attio",
|
|
hidden: "Hidden skill instructions",
|
|
absent: "read skill attio",
|
|
},
|
|
{
|
|
title: "AGENTS.md",
|
|
path: join(process.cwd(), ".pi", "AGENTS.md"),
|
|
content: "Hidden resource instructions",
|
|
compact: "read resource .pi/AGENTS.md",
|
|
hidden: "Hidden resource instructions",
|
|
absent: undefined,
|
|
},
|
|
{
|
|
title: "AGENTS.override.md",
|
|
path: join(process.cwd(), ".pi", "AGENTS.override.md"),
|
|
content: "Hidden override instructions",
|
|
compact: "read resource .pi/AGENTS.override.md",
|
|
hidden: "Hidden override instructions",
|
|
absent: undefined,
|
|
},
|
|
{
|
|
title: "outside AGENTS.md",
|
|
path: resolve(process.cwd(), "..", "AGENTS.md"),
|
|
content: "Hidden outside resource instructions",
|
|
compact: `read resource ${resolve(process.cwd(), "..", "AGENTS.md").replace(/\\/g, "/")}`,
|
|
hidden: "Hidden outside resource instructions",
|
|
absent: undefined,
|
|
},
|
|
{
|
|
title: "Pi documentation",
|
|
path: getReadmePath(),
|
|
content: "Hidden docs content",
|
|
compact: "read docs README.md",
|
|
hidden: "Hidden docs content",
|
|
absent: undefined,
|
|
},
|
|
] as const) {
|
|
test(`renders ${scenario.title} read results compactly until expanded`, () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
`tool-compact-${scenario.title}`,
|
|
{ path: scenario.path },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult(
|
|
{ content: [{ type: "text", text: scenario.content }], details: undefined, isError: false },
|
|
false,
|
|
);
|
|
|
|
const collapsed = stripAnsi(component.render(120).join("\n"));
|
|
expect(collapsed).toContain(scenario.compact);
|
|
expect(collapsed).not.toContain(scenario.hidden);
|
|
if (scenario.absent) {
|
|
expect(collapsed).not.toContain(scenario.absent);
|
|
}
|
|
|
|
component.setExpanded(true);
|
|
const expanded = stripAnsi(component.render(120).join("\n"));
|
|
expect(expanded).toContain(scenario.hidden);
|
|
});
|
|
}
|
|
|
|
for (const scenario of [
|
|
{ title: "SKILL.md", path: join(process.cwd(), "attio", "SKILL.md"), compact: "[skill] attio:120-329" },
|
|
{ title: "Pi documentation", path: getReadmePath(), compact: "read docs README.md:120-329" },
|
|
] as const) {
|
|
test(`shows the read line range in compact ${scenario.title} reads before the expand hint`, () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
`tool-compact-range-${scenario.title}`,
|
|
{ path: scenario.path, offset: 120, limit: 210 },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
|
|
const collapsed = stripAnsi(component.render(120).join("\n"));
|
|
expect(collapsed).toContain(scenario.compact);
|
|
expect(collapsed.indexOf(":120-329")).toBeLessThan(collapsed.indexOf("to expand"));
|
|
});
|
|
}
|
|
});
|