mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
feat(read): compact resource read rendering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user