Files
oh-my-pi/packages/coding-agent/src/tools/find.ts
T

610 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 * as natives from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
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, fileHyperlink, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import type { ToolSession } from ".";
import { applyListLimit } from "./list-limit";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
import {
expandDelimitedPathEntries,
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseFindPattern,
partitionExistingPaths,
resolveExplicitFindPatterns,
resolveToCwd,
} from "./path-utils";
import {
createCachedComponent,
formatCount,
formatEmptyMessage,
formatErrorMessage,
PREVIEW_LIMITS,
} from "./render-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result";
const findSchema = z
.object({
paths: z.array(z.string().describe("glob including search path")).min(1).describe("globs including search paths"),
hidden: z.boolean().default(true).describe("include hidden files").optional(),
gitignore: z.boolean().default(true).describe("respect gitignore").optional(),
limit: z.number().default(200).describe("max results (clamped to 1-200)").optional(),
timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(),
})
.strict();
export type FindToolInput = z.infer<typeof findSchema>;
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 200;
const DEFAULT_GLOB_TIMEOUT_MS = 5000;
const MIN_GLOB_TIMEOUT_MS = 500;
const MAX_GLOB_TIMEOUT_MS = 60_000;
export interface FindToolDetails {
truncation?: TruncationResult;
resultLimitReached?: number;
meta?: OutputMeta;
// Fields for TUI rendering
scopePath?: string;
fileCount?: number;
files?: string[];
truncated?: boolean;
error?: string;
/** Working directory at search time. Used by the renderer to resolve relative
* file paths to absolute paths for OSC 8 hyperlinks. */
cwd?: string;
/** User-supplied paths whose base directory was missing on disk. The tool
* skipped these and continued with the surviving entries; surfaced as a
* non-fatal warning in the renderer and in the model-facing text. */
missingPaths?: 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;
}
interface FindTarget {
searchPath: string;
globPattern: string;
hasGlob: boolean;
}
export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
readonly name = "find";
readonly approval = "read" as const;
readonly summary = "Find files and directories matching a glob pattern";
readonly loadMode = "discoverable";
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: z.infer<typeof findSchema>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<FindToolDetails>> {
const { paths, limit, hidden, gitignore, timeout } = params;
return untilAborted(signal, async () => {
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
const rawPatternInputs = this.#customOps
? paths
: await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern });
const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
const internalRouter = InternalUrlRouter.instance();
const normalizedPatterns: string[] = [];
for (const rawPattern of rawPatterns) {
if (!internalRouter.canHandle(rawPattern)) {
normalizedPatterns.push(rawPattern);
continue;
}
if (hasGlobPathChars(rawPattern)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
}
const resource = await internalRouter.resolve(rawPattern, {
cwd: this.session.cwd,
settings: this.session.settings,
signal,
localProtocolOptions: this.session.localProtocolOptions,
});
if (!resource.sourcePath) {
throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
}
normalizedPatterns.push(resource.sourcePath);
}
if (normalizedPatterns.some(pattern => pattern.length === 0)) {
throw new ToolError("`paths` must contain non-empty globs or paths");
}
// Tolerate missing entries in a multi-path call: skip ones whose base
// directory is gone, and only error if every entry is missing. Single
// missing path keeps the original ENOENT semantics — the user explicitly
// asked about that one path, so silent empty results would be misleading.
let missingPaths: string[] = [];
let effectivePatterns = normalizedPatterns;
if (normalizedPatterns.length > 1 && !this.#customOps) {
const partition = await partitionExistingPaths(normalizedPatterns, this.session.cwd, parseFindPattern);
if (partition.valid.length === 0) {
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
}
effectivePatterns = partition.valid;
missingPaths = partition.missing;
}
const multiPattern = await resolveExplicitFindPatterns(effectivePatterns, this.session.cwd);
const isSingle = !multiPattern;
const targets: FindTarget[] = multiPattern
? multiPattern.targets.map(target => ({
searchPath: resolveToCwd(target.basePath, this.session.cwd),
globPattern: target.globPattern,
hasGlob: target.hasGlob,
}))
: [
(() => {
const parsed = parseFindPattern(effectivePatterns[0] ?? ".");
return {
searchPath: resolveToCwd(parsed.basePath, this.session.cwd),
globPattern: parsed.globPattern,
hasGlob: parsed.hasGlob,
};
})(),
];
const scopePath = multiPattern?.scopePath ?? formatScopePath(targets[0].searchPath);
for (const target of targets) {
if (target.searchPath === "/") {
throw new ToolError("Searching from root directory '/' is not allowed");
}
}
const requestedLimit = limit ?? DEFAULT_LIMIT;
if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) {
throw new ToolError("Limit must be a positive number");
}
const effectiveLimit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(requestedLimit)));
const includeHidden = hidden ?? true;
const useGitignore = gitignore ?? true;
const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_GLOB_TIMEOUT_MS;
const timeoutMs = Math.min(MAX_GLOB_TIMEOUT_MS, Math.max(MIN_GLOB_TIMEOUT_MS, requestedTimeoutMs));
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
const formatMatchPath = (matchPath: string, base: string, fileType?: natives.FileType): string => {
const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(base, matchPath);
return formatPathRelativeToCwd(absolutePath, this.session.cwd, {
trailingSlash: fileType === natives.FileType.Dir || hadTrailingSlash,
});
};
const missingPathsNote =
missingPaths.length > 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : undefined;
const buildResult = (
files: string[],
opts?: { notice?: string; forceTruncated?: boolean },
): AgentToolResult<FindToolDetails> => {
const notice = opts?.notice;
const forceTruncated = opts?.forceTruncated ?? false;
if (files.length === 0) {
const details: FindToolDetails = {
scopePath,
fileCount: 0,
files: [],
truncated: forceTruncated,
cwd: this.session.cwd,
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
};
const parts = ["No files found matching pattern"];
if (notice) parts.push(notice);
if (missingPathsNote) parts.push(missingPathsNote);
return toolResult(details).text(parts.join("\n")).done();
}
const listLimit = applyListLimit(files, { limit: effectiveLimit });
const limited = listLimit.items;
const limitMeta = listLimit.meta;
const baseOutput = formatGroupedPaths(limited);
const trailingNotes: string[] = [];
if (notice) trailingNotes.push(notice);
if (missingPathsNote) trailingNotes.push(missingPathsNote);
const rawOutput = trailingNotes.length > 0 ? `${baseOutput}\n\n${trailingNotes.join("\n")}` : baseOutput;
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
const details: FindToolDetails = {
scopePath,
fileCount: limited.length,
files: limited,
truncated: Boolean(forceTruncated || limitMeta.resultLimit || truncation.truncated),
resultLimitReached: limitMeta.resultLimit?.reached,
truncation: truncation.truncated ? truncation : undefined,
cwd: this.session.cwd,
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
};
const resultBuilder = toolResult(details)
.text(truncation.content)
.limits({ resultLimit: limitMeta.resultLimit?.reached });
if (truncation.truncated) {
resultBuilder.truncation(truncation, { direction: "head" });
}
return resultBuilder.done();
};
// Walk each user path as its own root and run the globs concurrently.
// Collapsing multiple paths to a shared base would force the walker to
// traverse and stat every unrelated sibling under that ancestor; per-path
// roots keep each scan bounded to exactly what the user asked for.
if (this.#customOps?.glob) {
const customOps = this.#customOps;
const perTarget = await Promise.all(
targets.map(async target => {
if (!(await customOps.exists(target.searchPath))) {
if (isSingle) throw new ToolError(`Path not found: ${scopePath}`);
return [] as string[];
}
if (!target.hasGlob && customOps.stat) {
const stat = await customOps.stat(target.searchPath);
if (stat.isFile()) return [formatScopePath(target.searchPath)];
}
const results = await customOps.glob(target.globPattern, target.searchPath, {
ignore: ["**/node_modules/**", "**/.git/**"],
limit: effectiveLimit,
});
return results.map(matchPath => formatMatchPath(matchPath, target.searchPath));
}),
);
const seen = new Set<string>();
const merged: string[] = [];
for (const group of perTarget) {
for (const entry of group) {
if (seen.has(entry)) continue;
seen.add(entry);
merged.push(entry);
}
}
return buildResult(merged);
}
const onUpdateMatches: string[] = [];
const onUpdateMtimes: number[] = [];
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 streamed = new Set<string>();
const makeOnMatch =
(base: string) =>
(err: Error | null, match: natives.GlobMatch | null): void => {
if (err || combinedSignal.aborted || !match?.path) return;
const relativePath = formatMatchPath(match.path, base, match.fileType);
if (streamed.has(relativePath)) return;
streamed.add(relativePath);
onUpdateMatches.push(relativePath);
onUpdateMtimes.push(match.mtime ?? 0);
emitUpdate();
};
let timedOut = false;
const runTarget = async (target: FindTarget): Promise<Array<{ path: string; mtime: number }>> => {
throwIfAborted(signal);
let stat: fs.Stats;
try {
stat = await fs.promises.stat(target.searchPath);
} catch (err) {
if (isEnoent(err)) {
if (isSingle) throw new ToolError(`Path not found: ${scopePath}`);
return [];
}
throw err;
}
if (!target.hasGlob && stat.isFile()) {
return [{ path: formatScopePath(target.searchPath), mtime: stat.mtimeMs }];
}
if (!stat.isDirectory()) {
if (isSingle) throw new ToolError(`Path is not a directory: ${target.searchPath}`);
return [];
}
try {
const result = await untilAborted(combinedSignal, () =>
natives.glob(
{
pattern: target.globPattern,
path: target.searchPath,
hidden: includeHidden,
maxResults: effectiveLimit,
sortByMtime: true,
gitignore: useGitignore,
// parseFindPattern explicitly prepends "**/" when the user's
// pattern begins with a glob (so `*.ts` becomes `**/*.ts`).
// Anything that arrives here without "**/" was scoped to a
// single directory by the user (e.g. `dir/*`); disable the
// native auto-recursion so `dir/*` does not silently match
// `dir/sub/nested.ts`.
recursive: false,
signal: combinedSignal,
},
makeOnMatch(target.searchPath),
),
);
throwIfAborted(signal);
const out: Array<{ path: string; mtime: number }> = [];
for (const match of result.matches) {
if (!match.path) continue;
out.push({
path: formatMatchPath(match.path, target.searchPath, match.fileType),
mtime: match.mtime ?? 0,
});
}
return out;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (timeoutSignal.aborted && !signal?.aborted) {
timedOut = true;
return [];
}
throw new ToolAbortError();
}
throw error;
}
};
const perTarget = await Promise.all(targets.map(runTarget));
if (timedOut) {
// Drain the partial matches accumulated during streaming and return them
// instead of throwing — empty results after a multi-second wait force the
// caller to retry blind, which is the worst possible outcome.
const partial = onUpdateMatches.map((entry, index) => ({ p: entry, m: onUpdateMtimes[index] ?? 0 }));
partial.sort((a, b) => b.m - a.m);
const sortedPaths = partial.map(entry => entry.p);
const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
const notice = `find timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — increase timeout or narrow pattern`;
return buildResult(sortedPaths, { notice, forceTruncated: true });
}
// Merge per-target results: native glob already ranks each target's own
// matches by mtime and caps them at the limit, so a global mtime re-sort
// plus dedup yields the correct top-N across all roots.
const seen = new Set<string>();
const merged: Array<{ path: string; mtime: number }> = [];
for (const group of perTarget) {
for (const entry of group) {
if (seen.has(entry.path)) continue;
seen.add(entry.path);
merged.push(entry);
}
}
merged.sort((a, b) => b.mtime - a.mtime);
return buildResult(merged.map(entry => entry.path));
});
}
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface FindRenderArgs {
paths?: string | string[];
limit?: number;
}
function formatFindRenderPaths(paths: FindRenderArgs["paths"]): string | undefined {
return Array.isArray(paths) ? paths.join(", ") : paths;
}
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
function findStatusIcon(uiTheme: Theme): string {
return uiTheme.fg("toolTitle", uiTheme.symbol("icon.search"));
}
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",
titleColor: "toolTitle",
description: formatFindRenderPaths(args.paths) || "*",
meta,
},
uiTheme,
);
return new Text(text, 1, 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), 1, 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), 1, 0);
}
const lines = textContent.split("\n").filter(l => l.trim());
const header = renderStatusLine(
{
iconOverride: findStatusIcon(uiTheme),
title: "Find",
titleColor: "toolTitle",
description: formatFindRenderPaths(args?.paths),
meta: [formatCount("file", lines.length)],
},
uiTheme,
);
return createCachedComponent(
() => options.expanded,
width => {
const listLines = renderTreeList(
{
items: lines,
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
itemType: "file",
renderItem: line => uiTheme.fg("accent", line),
},
uiTheme,
);
return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
{ paddingX: 1 },
);
}
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 ?? [];
const missingPaths = details?.missingPaths ?? [];
const missingNote =
missingPaths.length > 0 ? uiTheme.fg("warning", `skipped missing: ${missingPaths.join(", ")}`) : undefined;
if (fileCount === 0) {
const header = renderStatusLine(
{
icon: "warning",
title: "Find",
titleColor: "toolTitle",
description: formatFindRenderPaths(args?.paths),
meta: ["0 files"],
},
uiTheme,
);
const lines = [header, formatEmptyMessage("No files found", uiTheme)];
if (missingNote) lines.push(missingNote);
return new Text(lines.join("\n"), 1, 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(
{
...(truncated ? { icon: "warning" as const } : { iconOverride: findStatusIcon(uiTheme) }),
title: "Find",
titleColor: "toolTitle",
description: formatFindRenderPaths(args?.paths),
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(", ")}`));
}
if (missingNote) extraLines.push(missingNote);
return createCachedComponent(
() => options.expanded,
width => {
const cwd = details?.cwd;
const fileLines = renderFileList(
{
files: files.map(entry => ({
path: entry,
isDirectory: entry.endsWith("/"),
absPath: cwd && !entry.endsWith("/") ? path.resolve(cwd, entry) : undefined,
})),
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
hyperlinkFn: fileHyperlink,
},
uiTheme,
);
return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
{ paddingX: 1 },
);
},
mergeCallAndResult: true,
};