612 lines
22 KiB
TypeScript
612 lines
22 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 * 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 { 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);
|
||
// Zero results is useless regardless of notices: the follow-up
|
||
// call has already corrected course by the time compaction runs.
|
||
return toolResult(details).text(parts.join("\n")).useless().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,
|
||
};
|