feat(read): compact resource read rendering
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed `read` tool rendering to collapse Pi documentation, AGENTS/CLAUDE context files, and `SKILL.md` contents by default in interactive output.
|
||||||
|
|
||||||
## [0.72.1] - 2026-05-02
|
## [0.72.1] - 2026-05-02
|
||||||
|
|
||||||
## [0.72.0] - 2026-05-01
|
## [0.72.0] - 2026-05-01
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from "node:path";
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
|
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
|
||||||
import { Text } from "@mariozechner/pi-tui";
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
import { constants } from "fs";
|
import { constants } from "fs";
|
||||||
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
|
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
|
||||||
import { type Static, Type } from "typebox";
|
import { type Static, Type } from "typebox";
|
||||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
import { getReadmePath } from "../../config.js";
|
||||||
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
|
import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.js";
|
||||||
|
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.js";
|
||||||
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
|
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
|
||||||
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
|
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
|
||||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
||||||
@@ -26,6 +28,27 @@ export interface ReadToolDetails {
|
|||||||
truncation?: TruncationResult;
|
truncation?: TruncationResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CompactReadClassification {
|
||||||
|
kind: "docs" | "resource" | "skill";
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadRenderState {
|
||||||
|
hideCall?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReadCallText extends Text {
|
||||||
|
constructor(private state: ReadRenderState) {
|
||||||
|
super("", 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render(width: number): string[] {
|
||||||
|
return this.state.hideCall ? [] : super.render(width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPACT_RESOURCE_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pluggable operations for the read tool.
|
* Pluggable operations for the read tool.
|
||||||
* Override these to delegate file reading to remote systems (for example SSH).
|
* Override these to delegate file reading to remote systems (for example SSH).
|
||||||
@@ -54,7 +77,7 @@ export interface ReadToolOptions {
|
|||||||
|
|
||||||
function formatReadCall(
|
function formatReadCall(
|
||||||
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
|
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
|
||||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
theme: Theme,
|
||||||
): string {
|
): string {
|
||||||
const rawPath = str(args?.file_path ?? args?.path);
|
const rawPath = str(args?.file_path ?? args?.path);
|
||||||
const path = rawPath !== null ? shortenPath(rawPath) : null;
|
const path = rawPath !== null ? shortenPath(rawPath) : null;
|
||||||
@@ -85,15 +108,87 @@ function getNonVisionImageNote(model: Model<Api> | undefined): string | undefine
|
|||||||
return "[Current model does not support images. The image will be omitted from this request.]";
|
return "[Current model does not support images. The image will be omitted from this request.]";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPosixPath(filePath: string): string {
|
||||||
|
return filePath.split(sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPiDocsClassification(absolutePath: string): CompactReadClassification | undefined {
|
||||||
|
const packageRoot = dirname(getReadmePath());
|
||||||
|
const relativePath = relative(resolvePath(packageRoot), resolvePath(absolutePath));
|
||||||
|
if (
|
||||||
|
relativePath === "" ||
|
||||||
|
relativePath === ".." ||
|
||||||
|
relativePath.startsWith(`..${sep}`) ||
|
||||||
|
isAbsolute(relativePath)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = toPosixPath(relativePath);
|
||||||
|
if (label === "README.md" || label.startsWith("docs/") || label.startsWith("examples/")) {
|
||||||
|
return { kind: "docs", label };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompactReadClassification(
|
||||||
|
args: { path?: string; file_path?: string } | undefined,
|
||||||
|
cwd: string,
|
||||||
|
): CompactReadClassification | undefined {
|
||||||
|
const rawPath = str(args?.file_path ?? args?.path);
|
||||||
|
if (!rawPath) return undefined;
|
||||||
|
|
||||||
|
const absolutePath = resolveReadPath(rawPath, cwd);
|
||||||
|
const fileName = basename(absolutePath);
|
||||||
|
if (fileName === "SKILL.md") {
|
||||||
|
return { kind: "skill", label: basename(dirname(absolutePath)) || fileName };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docsClassification = getPiDocsClassification(absolutePath);
|
||||||
|
if (docsClassification) return docsClassification;
|
||||||
|
|
||||||
|
if (COMPACT_RESOURCE_FILE_NAMES.has(fileName)) {
|
||||||
|
return { kind: "resource", label: fileName };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactReadResult(classification: CompactReadClassification, theme: Theme): string {
|
||||||
|
if (classification.kind === "skill") {
|
||||||
|
return (
|
||||||
|
theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) +
|
||||||
|
theme.fg("customMessageText", classification.label) +
|
||||||
|
theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
theme.fg("toolTitle", theme.bold(`read ${classification.kind}`)) +
|
||||||
|
" " +
|
||||||
|
theme.fg("accent", classification.label) +
|
||||||
|
theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatReadResult(
|
function formatReadResult(
|
||||||
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
|
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
|
||||||
result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails },
|
result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails },
|
||||||
options: ToolRenderResultOptions,
|
options: ToolRenderResultOptions,
|
||||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
theme: Theme,
|
||||||
showImages: boolean,
|
showImages: boolean,
|
||||||
|
cwd: string,
|
||||||
|
isError: boolean,
|
||||||
): string {
|
): string {
|
||||||
|
if (!options.expanded && !isError) {
|
||||||
|
const classification = getCompactReadClassification(args, cwd);
|
||||||
|
if (classification) {
|
||||||
|
return formatCompactReadResult(classification, theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rawPath = str(args?.file_path ?? args?.path);
|
const rawPath = str(args?.file_path ?? args?.path);
|
||||||
const output = getTextOutput(result as any, showImages);
|
const output = getTextOutput(result, showImages);
|
||||||
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
||||||
const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
|
const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
|
||||||
const lines = trimTrailingEmptyLines(renderedLines);
|
const lines = trimTrailingEmptyLines(renderedLines);
|
||||||
@@ -121,7 +216,7 @@ function formatReadResult(
|
|||||||
export function createReadToolDefinition(
|
export function createReadToolDefinition(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
options?: ReadToolOptions,
|
options?: ReadToolOptions,
|
||||||
): ToolDefinition<typeof readSchema, ReadToolDetails | undefined> {
|
): ToolDefinition<typeof readSchema, ReadToolDetails | undefined, ReadRenderState> {
|
||||||
const autoResizeImages = options?.autoResizeImages ?? true;
|
const autoResizeImages = options?.autoResizeImages ?? true;
|
||||||
const ops = options?.operations ?? defaultReadOperations;
|
const ops = options?.operations ?? defaultReadOperations;
|
||||||
return {
|
return {
|
||||||
@@ -256,13 +351,22 @@ export function createReadToolDefinition(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
renderCall(args, theme, context) {
|
renderCall(args, theme, context) {
|
||||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
const text =
|
||||||
|
context.lastComponent instanceof ReadCallText ? context.lastComponent : new ReadCallText(context.state);
|
||||||
|
context.state.hideCall = false;
|
||||||
text.setText(formatReadCall(args, theme));
|
text.setText(formatReadCall(args, theme));
|
||||||
return text;
|
return text;
|
||||||
},
|
},
|
||||||
renderResult(result, options, theme, context) {
|
renderResult(result, options, theme, context) {
|
||||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||||
text.setText(formatReadResult(context.args, result as any, options, theme, context.showImages));
|
const hideCall =
|
||||||
|
!options.expanded &&
|
||||||
|
!context.isError &&
|
||||||
|
getCompactReadClassification(context.args, context.cwd) !== undefined;
|
||||||
|
text.setText(
|
||||||
|
formatReadResult(context.args, result, options, theme, context.showImages, context.cwd, context.isError),
|
||||||
|
);
|
||||||
|
context.state.hideCall = hideCall;
|
||||||
return text;
|
return text;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
import { Text, type TUI } from "@mariozechner/pi-tui";
|
import { Text, type TUI } from "@mariozechner/pi-tui";
|
||||||
import stripAnsi from "strip-ansi";
|
import stripAnsi from "strip-ansi";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
import { beforeAll, describe, expect, test } from "vitest";
|
import { beforeAll, describe, expect, test } from "vitest";
|
||||||
|
import { getReadmePath } from "../src/config.js";
|
||||||
import type { ToolDefinition } from "../src/core/extensions/types.js";
|
import type { ToolDefinition } from "../src/core/extensions/types.js";
|
||||||
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js";
|
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js";
|
||||||
import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.js";
|
import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.js";
|
||||||
@@ -145,7 +147,7 @@ describe("ToolExecutionComponent parity", () => {
|
|||||||
const component = new ToolExecutionComponent(
|
const component = new ToolExecutionComponent(
|
||||||
"read",
|
"read",
|
||||||
"tool-4b",
|
"tool-4b",
|
||||||
{ path: "README.md" },
|
{ path: "notes.txt" },
|
||||||
{},
|
{},
|
||||||
overrideDefinition,
|
overrideDefinition,
|
||||||
createFakeTui(),
|
createFakeTui(),
|
||||||
@@ -313,7 +315,7 @@ describe("ToolExecutionComponent parity", () => {
|
|||||||
const component = new ToolExecutionComponent(
|
const component = new ToolExecutionComponent(
|
||||||
"read",
|
"read",
|
||||||
"tool-8",
|
"tool-8",
|
||||||
{ path: "README.md" },
|
{ path: "notes.txt" },
|
||||||
{},
|
{},
|
||||||
createReadToolDefinition(process.cwd()),
|
createReadToolDefinition(process.cwd()),
|
||||||
createFakeTui(),
|
createFakeTui(),
|
||||||
@@ -328,4 +330,58 @@ describe("ToolExecutionComponent parity", () => {
|
|||||||
expect(rendered).toContain("two");
|
expect(rendered).toContain("two");
|
||||||
expect(rendered).not.toContain("two\n\n");
|
expect(rendered).not.toContain("two\n\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(), "AGENTS.md"),
|
||||||
|
content: "Hidden resource instructions",
|
||||||
|
compact: "read resource AGENTS.md",
|
||||||
|
hidden: "Hidden 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user