feat(read): compact resource read rendering

This commit is contained in:
Armin Ronacher
2026-05-02 20:36:07 +02:00
Unverified
parent 7268e9a9fd
commit 588639fa97
3 changed files with 174 additions and 10 deletions
+4
View File
@@ -2,6 +2,10 @@
## [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.0] - 2026-05-01
+112 -8
View File
@@ -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 { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
import { Text } from "@mariozechner/pi-tui";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
import { getReadmePath } from "../../config.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 { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
@@ -26,6 +28,27 @@ export interface ReadToolDetails {
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.
* Override these to delegate file reading to remote systems (for example SSH).
@@ -54,7 +77,7 @@ export interface ReadToolOptions {
function formatReadCall(
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: Theme,
): string {
const rawPath = str(args?.file_path ?? args?.path);
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.]";
}
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(
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails },
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: Theme,
showImages: boolean,
cwd: string,
isError: boolean,
): 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 output = getTextOutput(result as any, showImages);
const output = getTextOutput(result, showImages);
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
const lines = trimTrailingEmptyLines(renderedLines);
@@ -121,7 +216,7 @@ function formatReadResult(
export function createReadToolDefinition(
cwd: string,
options?: ReadToolOptions,
): ToolDefinition<typeof readSchema, ReadToolDetails | undefined> {
): ToolDefinition<typeof readSchema, ReadToolDetails | undefined, ReadRenderState> {
const autoResizeImages = options?.autoResizeImages ?? true;
const ops = options?.operations ?? defaultReadOperations;
return {
@@ -256,13 +351,22 @@ export function createReadToolDefinition(
);
},
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));
return text;
},
renderResult(result, options, theme, context) {
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;
},
};
@@ -1,7 +1,9 @@
import { join } from "node:path";
import { Text, type TUI } from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import { Type } from "typebox";
import { beforeAll, describe, expect, test } from "vitest";
import { getReadmePath } from "../src/config.js";
import type { ToolDefinition } from "../src/core/extensions/types.js";
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js";
import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.js";
@@ -145,7 +147,7 @@ describe("ToolExecutionComponent parity", () => {
const component = new ToolExecutionComponent(
"read",
"tool-4b",
{ path: "README.md" },
{ path: "notes.txt" },
{},
overrideDefinition,
createFakeTui(),
@@ -313,7 +315,7 @@ describe("ToolExecutionComponent parity", () => {
const component = new ToolExecutionComponent(
"read",
"tool-8",
{ path: "README.md" },
{ path: "notes.txt" },
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
@@ -328,4 +330,58 @@ describe("ToolExecutionComponent parity", () => {
expect(rendered).toContain("two");
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);
});
}
});