From 588639fa97567a661dac876dc2b1970c8a3497ae Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 2 May 2026 20:36:07 +0200 Subject: [PATCH] feat(read): compact resource read rendering --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/core/tools/read.ts | 120 ++++++++++++++++-- .../test/tool-execution-component.test.ts | 60 ++++++++- 3 files changed, 174 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f63f4aac6..5b3dc6153 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 8f8e4656c..08e4f8257 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -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 | 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 { +): ToolDefinition { 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; }, }; diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 04a9f7a9b..6c37b65eb 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -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); + }); + } });