a21a542afd
- Extracted prompt rendering and formatting utilities from coding-agent to centralized pi-utils package with new API surface (prompt.render, prompt.format, prompt.registerHelper). - Migrated parseFrontmatter utility from coding-agent to pi-utils package; updated 8 files to import from @oh-my-pi/pi-utils. - Removed 170-line prompt-format.ts module and consolidated 192 lines of Handlebars helper registrations into pi-utils prompt module. - Updated 60+ files across coding-agent and typescript-edit-benchmark to use new prompt.render() and prompt.format() API from pi-utils. - Simplified prompt-templates.ts by delegating core functionality to pi-utils while retaining custom helper registrations (jtdToTypeScript, jsonStringify, etc.).
491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
import { FileType, type GlobMatch, glob } from "@oh-my-pi/pi-natives";
|
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
import { isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
import type { Static } from "@sinclair/typebox";
|
|
import { Type } from "@sinclair/typebox";
|
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
import type { Theme } from "../modes/theme/theme";
|
|
import findDescription from "../prompts/tools/find.md" with { type: "text" };
|
|
import { type TruncationResult, truncateHead } from "../session/streaming-output";
|
|
import {
|
|
Ellipsis,
|
|
Hasher,
|
|
type RenderCache,
|
|
renderFileList,
|
|
renderStatusLine,
|
|
renderTreeList,
|
|
truncateToWidth,
|
|
} from "../tui";
|
|
import type { ToolSession } from ".";
|
|
import { applyListLimit } from "./list-limit";
|
|
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
|
|
import { normalizePathLikeInput, parseFindPattern, resolveMultiFindPattern, resolveToCwd } from "./path-utils";
|
|
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
|
|
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
|
import { toolResult } from "./tool-result";
|
|
|
|
const findSchema = Type.Object({
|
|
pattern: Type.String({ description: "Glob pattern, e.g. '*.ts', 'src/**/*.json', 'lib/*.tsx'" }),
|
|
hidden: Type.Optional(Type.Boolean({ description: "Include hidden files and directories (default: true)" })),
|
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 1000)" })),
|
|
});
|
|
|
|
export type FindToolInput = Static<typeof findSchema>;
|
|
|
|
const DEFAULT_LIMIT = 1000;
|
|
const GLOB_TIMEOUT_MS = 5000;
|
|
|
|
export interface FindToolDetails {
|
|
truncation?: TruncationResult;
|
|
resultLimitReached?: number;
|
|
meta?: OutputMeta;
|
|
// Fields for TUI rendering
|
|
scopePath?: string;
|
|
fileCount?: number;
|
|
files?: string[];
|
|
truncated?: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Pluggable operations for the find tool.
|
|
* Override these to delegate file search to remote systems (e.g., SSH).
|
|
*/
|
|
export interface FindOperations {
|
|
/** Check if path exists */
|
|
exists: (absolutePath: string) => Promise<boolean> | boolean;
|
|
/** Optional stat for distinguishing files vs directories. */
|
|
stat?: (
|
|
absolutePath: string,
|
|
) => Promise<{ isFile(): boolean; isDirectory(): boolean }> | { isFile(): boolean; isDirectory(): boolean };
|
|
/** Find files matching glob pattern. Returns relative paths. */
|
|
glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | string[];
|
|
}
|
|
|
|
export interface FindToolOptions {
|
|
/** Custom operations for find. Default: local filesystem + rg */
|
|
operations?: FindOperations;
|
|
}
|
|
|
|
export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
readonly name = "find";
|
|
readonly label = "Find";
|
|
readonly description: string;
|
|
readonly parameters = findSchema;
|
|
readonly strict = true;
|
|
|
|
readonly #customOps?: FindOperations;
|
|
|
|
constructor(
|
|
private readonly session: ToolSession,
|
|
options?: FindToolOptions,
|
|
) {
|
|
this.#customOps = options?.operations;
|
|
this.description = prompt.render(findDescription);
|
|
}
|
|
|
|
async execute(
|
|
_toolCallId: string,
|
|
params: Static<typeof findSchema>,
|
|
signal?: AbortSignal,
|
|
onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
|
|
_context?: AgentToolContext,
|
|
): Promise<AgentToolResult<FindToolDetails>> {
|
|
const { pattern, limit, hidden } = params;
|
|
|
|
return untilAborted(signal, async () => {
|
|
const formatScopePath = (targetPath: string): string => {
|
|
const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
|
|
return relative.length === 0 ? "." : relative;
|
|
};
|
|
const normalizedPattern = normalizePathLikeInput(pattern).replace(/\\/g, "/");
|
|
if (!normalizedPattern) {
|
|
throw new ToolError("Pattern must not be empty");
|
|
}
|
|
|
|
const multiPattern = await resolveMultiFindPattern(normalizedPattern, this.session.cwd);
|
|
const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPattern);
|
|
const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false);
|
|
const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*";
|
|
const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd);
|
|
const scopePath = multiPattern?.scopePath ?? formatScopePath(searchPath);
|
|
|
|
if (searchPath === "/") {
|
|
throw new ToolError("Searching from root directory '/' is not allowed");
|
|
}
|
|
|
|
const rawLimit = limit ?? DEFAULT_LIMIT;
|
|
const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN;
|
|
if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) {
|
|
throw new ToolError("Limit must be a positive number");
|
|
}
|
|
const includeHidden = hidden ?? true;
|
|
|
|
// If custom operations provided with glob, use that instead of fd
|
|
if (this.#customOps?.glob) {
|
|
if (!(await this.#customOps.exists(searchPath))) {
|
|
throw new ToolError(`Path not found: ${scopePath}`);
|
|
}
|
|
|
|
if (!hasGlob && this.#customOps.stat) {
|
|
const stat = await this.#customOps.stat(searchPath);
|
|
if (stat.isFile()) {
|
|
const files = [scopePath];
|
|
const details: FindToolDetails = {
|
|
scopePath,
|
|
fileCount: 1,
|
|
files,
|
|
truncated: false,
|
|
};
|
|
return toolResult(details).text(files.join("\n")).done();
|
|
}
|
|
}
|
|
|
|
const results = await this.#customOps.glob(globPattern, searchPath, {
|
|
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
limit: effectiveLimit,
|
|
});
|
|
|
|
if (results.length === 0) {
|
|
const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
|
|
return toolResult(details).text("No files found matching pattern").done();
|
|
}
|
|
|
|
// Relativize paths
|
|
const relativized = results.map(p => {
|
|
if (p.startsWith(searchPath)) {
|
|
return p.slice(searchPath.length + 1);
|
|
}
|
|
return path.relative(searchPath, p);
|
|
});
|
|
|
|
const listLimit = applyListLimit(relativized, { limit: effectiveLimit });
|
|
const limited = listLimit.items;
|
|
const limitMeta = listLimit.meta;
|
|
const rawOutput = limited.join("\n");
|
|
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
|
|
const details: FindToolDetails = {
|
|
scopePath,
|
|
fileCount: limited.length,
|
|
files: limited,
|
|
truncated: Boolean(limitMeta.resultLimit || truncation.truncated),
|
|
resultLimitReached: limitMeta.resultLimit?.reached,
|
|
truncation: truncation.truncated ? truncation : undefined,
|
|
};
|
|
|
|
const resultBuilder = toolResult(details)
|
|
.text(truncation.content)
|
|
.limits({ resultLimit: limitMeta.resultLimit?.reached });
|
|
if (truncation.truncated) {
|
|
resultBuilder.truncation(truncation, { direction: "head" });
|
|
}
|
|
|
|
return resultBuilder.done();
|
|
}
|
|
|
|
let searchStat: fs.Stats;
|
|
try {
|
|
searchStat = await fs.promises.stat(searchPath);
|
|
} catch (err) {
|
|
if (isEnoent(err)) {
|
|
throw new ToolError(`Path not found: ${scopePath}`);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (!hasGlob && searchStat.isFile()) {
|
|
const files = [scopePath];
|
|
const details: FindToolDetails = {
|
|
scopePath,
|
|
fileCount: 1,
|
|
files,
|
|
truncated: false,
|
|
};
|
|
return toolResult(details).text(files.join("\n")).done();
|
|
}
|
|
if (!searchStat.isDirectory()) {
|
|
throw new ToolError(`Path is not a directory: ${searchPath}`);
|
|
}
|
|
|
|
let matches: GlobMatch[];
|
|
const onUpdateMatches: string[] = [];
|
|
const updateIntervalMs = 200;
|
|
let lastUpdate = 0;
|
|
const emitUpdate = () => {
|
|
if (!onUpdate) return;
|
|
const now = Date.now();
|
|
if (now - lastUpdate < updateIntervalMs) return;
|
|
lastUpdate = now;
|
|
const details: FindToolDetails = {
|
|
scopePath,
|
|
fileCount: onUpdateMatches.length,
|
|
files: onUpdateMatches.slice(),
|
|
truncated: false,
|
|
};
|
|
onUpdate({
|
|
content: [{ type: "text", text: onUpdateMatches.join("\n") }],
|
|
details,
|
|
});
|
|
};
|
|
const onMatch = onUpdate
|
|
? (err: Error | null, match: GlobMatch | null) => {
|
|
if (err || signal?.aborted || !match) return;
|
|
let relativePath = match.path;
|
|
if (!relativePath) return;
|
|
if (match.fileType === FileType.Dir && !relativePath.endsWith("/")) {
|
|
relativePath += "/";
|
|
}
|
|
onUpdateMatches.push(relativePath);
|
|
emitUpdate();
|
|
}
|
|
: undefined;
|
|
const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
|
|
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
|
|
const doGlob = async (useGitignore: boolean) =>
|
|
untilAborted(combinedSignal, () =>
|
|
glob(
|
|
{
|
|
pattern: globPattern,
|
|
path: searchPath,
|
|
fileType: FileType.File,
|
|
hidden: includeHidden,
|
|
maxResults: effectiveLimit,
|
|
sortByMtime: true,
|
|
gitignore: useGitignore,
|
|
},
|
|
onMatch,
|
|
this.session.searchDb,
|
|
),
|
|
);
|
|
|
|
try {
|
|
let result = await doGlob(true);
|
|
// If gitignore filtering yielded nothing, retry without it
|
|
if (result.matches.length === 0) {
|
|
result = await doGlob(false);
|
|
}
|
|
matches = result.matches;
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
if (timeoutSignal.aborted && !signal?.aborted) {
|
|
const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
|
|
throw new ToolError(`find timed out after ${timeoutSeconds}s`);
|
|
}
|
|
throw new ToolAbortError();
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (matches.length === 0) {
|
|
const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
|
|
return toolResult(details).text("No files found matching pattern").done();
|
|
}
|
|
const relativized: string[] = [];
|
|
|
|
for (const match of matches) {
|
|
throwIfAborted(signal);
|
|
const line = match.path;
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
|
let relativePath = line;
|
|
|
|
const isDirectory = match.fileType === FileType.Dir;
|
|
|
|
if ((isDirectory || hadTrailingSlash) && !relativePath.endsWith("/")) {
|
|
relativePath += "/";
|
|
}
|
|
|
|
relativized.push(relativePath);
|
|
}
|
|
|
|
if (relativized.length === 0) {
|
|
const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
|
|
return toolResult(details).text("No files found matching pattern").done();
|
|
}
|
|
|
|
// Results are already sorted by mtime from native (sortByMtime: true)
|
|
|
|
const listLimit = applyListLimit(relativized, { limit: effectiveLimit });
|
|
const limited = listLimit.items;
|
|
const limitMeta = listLimit.meta;
|
|
|
|
// Apply byte truncation (no line limit since we already have result limit)
|
|
const rawOutput = limited.join("\n");
|
|
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
|
|
const resultOutput = truncation.content;
|
|
const details: FindToolDetails = {
|
|
scopePath,
|
|
fileCount: limited.length,
|
|
files: limited,
|
|
truncated: Boolean(limitMeta.resultLimit || truncation.truncated),
|
|
resultLimitReached: limitMeta.resultLimit?.reached,
|
|
truncation: truncation.truncated ? truncation : undefined,
|
|
};
|
|
|
|
const resultBuilder = toolResult(details)
|
|
.text(resultOutput)
|
|
.limits({ resultLimit: limitMeta.resultLimit?.reached });
|
|
if (truncation.truncated) {
|
|
resultBuilder.truncation(truncation, { direction: "head" });
|
|
}
|
|
|
|
return resultBuilder.done();
|
|
});
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// TUI Renderer
|
|
// =============================================================================
|
|
|
|
interface FindRenderArgs {
|
|
pattern: string;
|
|
limit?: number;
|
|
}
|
|
|
|
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
|
|
|
|
export const findToolRenderer = {
|
|
inline: true,
|
|
renderCall(args: FindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
const meta: string[] = [];
|
|
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
|
|
|
|
const text = renderStatusLine(
|
|
{ icon: "pending", title: "Find", description: args.pattern || "*", meta },
|
|
uiTheme,
|
|
);
|
|
return new Text(text, 0, 0);
|
|
},
|
|
|
|
renderResult(
|
|
result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails; isError?: boolean },
|
|
options: RenderResultOptions,
|
|
uiTheme: Theme,
|
|
args?: FindRenderArgs,
|
|
): Component {
|
|
const details = result.details;
|
|
|
|
if (result.isError || details?.error) {
|
|
const errorText = details?.error || result.content?.find(c => c.type === "text")?.text || "Unknown error";
|
|
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
}
|
|
|
|
const hasDetailedData = details?.fileCount !== undefined;
|
|
const textContent = result.content?.find(c => c.type === "text")?.text;
|
|
|
|
if (!hasDetailedData) {
|
|
if (
|
|
!textContent ||
|
|
textContent.includes("No files matching") ||
|
|
textContent.includes("No files found") ||
|
|
textContent.trim() === ""
|
|
) {
|
|
return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0);
|
|
}
|
|
|
|
const lines = textContent.split("\n").filter(l => l.trim());
|
|
const header = renderStatusLine(
|
|
{
|
|
icon: "success",
|
|
title: "Find",
|
|
description: args?.pattern,
|
|
meta: [formatCount("file", lines.length)],
|
|
},
|
|
uiTheme,
|
|
);
|
|
let cached: RenderCache | undefined;
|
|
return {
|
|
render(width: number): string[] {
|
|
const { expanded } = options;
|
|
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
if (cached?.key === key) return cached.lines;
|
|
const listLines = renderTreeList(
|
|
{
|
|
items: lines,
|
|
expanded,
|
|
maxCollapsed: COLLAPSED_LIST_LIMIT,
|
|
itemType: "file",
|
|
renderItem: line => uiTheme.fg("accent", line),
|
|
},
|
|
uiTheme,
|
|
);
|
|
const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
|
cached = { key, lines: result };
|
|
return result;
|
|
},
|
|
invalidate() {
|
|
cached = undefined;
|
|
},
|
|
};
|
|
}
|
|
|
|
const fileCount = details?.fileCount ?? 0;
|
|
const truncation = details?.truncation ?? details?.meta?.truncation;
|
|
const limits = details?.meta?.limits;
|
|
const truncated = Boolean(details?.truncated || truncation || details?.resultLimitReached || limits?.resultLimit);
|
|
const files = details?.files ?? [];
|
|
|
|
if (fileCount === 0) {
|
|
const header = renderStatusLine(
|
|
{ icon: "warning", title: "Find", description: args?.pattern, meta: ["0 files"] },
|
|
uiTheme,
|
|
);
|
|
return new Text([header, formatEmptyMessage("No files found", uiTheme)].join("\n"), 0, 0);
|
|
}
|
|
const meta: string[] = [formatCount("file", fileCount)];
|
|
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
|
|
if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
|
|
const header = renderStatusLine(
|
|
{ icon: truncated ? "warning" : "success", title: "Find", description: args?.pattern, meta },
|
|
uiTheme,
|
|
);
|
|
|
|
const truncationReasons: string[] = [];
|
|
if (details?.resultLimitReached) truncationReasons.push(`limit ${details.resultLimitReached} results`);
|
|
if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
|
|
if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
|
|
const artifactId = truncation && "artifactId" in truncation ? truncation.artifactId : undefined;
|
|
if (artifactId) truncationReasons.push(formatFullOutputReference(artifactId));
|
|
|
|
const extraLines: string[] = [];
|
|
if (truncationReasons.length > 0) {
|
|
extraLines.push(uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`));
|
|
}
|
|
|
|
let cached: RenderCache | undefined;
|
|
return {
|
|
render(width: number): string[] {
|
|
const { expanded } = options;
|
|
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
if (cached?.key === key) return cached.lines;
|
|
const fileLines = renderFileList(
|
|
{
|
|
files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
|
|
expanded,
|
|
maxCollapsed: COLLAPSED_LIST_LIMIT,
|
|
},
|
|
uiTheme,
|
|
);
|
|
const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
|
cached = { key, lines: result };
|
|
return result;
|
|
},
|
|
invalidate() {
|
|
cached = undefined;
|
|
},
|
|
};
|
|
},
|
|
mergeCallAndResult: true,
|
|
};
|