feat(read): compact resource read rendering

This commit is contained in:
Armin Ronacher
2026-05-02 20:36:07 +02:00
parent 7268e9a9fd
commit 588639fa97
3 changed files with 174 additions and 10 deletions

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;
},
};