import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { StringEnum } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { ensureBundledBinary, resolvePreferredBinary } from "../install"; import { normalizePathArg } from "../normalize"; import { execResolved } from "../process"; import { clampPageValue, renderPage, splitOutputLines } from "../paginate"; import { buildExpandedParams, kylinFrameForCall, kylinFrameForResult } from "../../../vera-theme/src/public"; function truncate(text: string, max = 70): string { return text.length > max ? text.slice(0, max - 1) + "…" : text; } function buildParamSummary(args: any, theme: any): string { const pattern = args.pattern ?? "."; const path = args.path ?? "."; const extras: string[] = []; if (args.type) extras.push(`type:${args.type}`); if (args.extension) extras.push(`*.${args.extension}`); if (args.maxdepth !== undefined) extras.push(`depth:${args.maxdepth}`); return ( theme.fg("text", truncate(String(pattern), 48)) + theme.fg("borderMuted", " in ") + theme.fg("dim", truncate(String(path), 40)) + (extras.length > 0 ? theme.fg("borderMuted", " · ") + theme.fg("muted", extras.join(" ")) : "") ); } function textLines(result: any): string[] { const text = String(result.content?.find?.((item: any) => item?.type === "text")?.text ?? ""); return text.split("\n").filter((line) => line.trim() && !line.startsWith("-- page ")); } export function registerFdTool(pi: ExtensionAPI): void { pi.registerTool({ name: "fd", label: "fd", description: "Fast file finder using fd with progressive paging. Returns 5 results by default; use offset to continue. Uses bundled fd first, then system fd.", promptSnippet: "Use fd to find candidate files or directories. Start with the first page, then continue with next_offset only if needed.", promptGuidelines: [ "Prefer a narrow path, type, extension, or maxdepth instead of broad scans.", "Read the first 5 results, then use offset=next_offset to continue only when needed.", "Use fd to locate targets before reading or editing files.", ], parameters: Type.Object({ pattern: Type.Optional(Type.String({ description: "Regex or fixed pattern" })), path: Type.Optional(Type.String()), type: Type.Optional(StringEnum(["f", "d", "l", "x"] as const)), extension: Type.Optional(Type.String({ description: "File extension filter, e.g. ts" })), hidden: Type.Optional(Type.Boolean()), noIgnore: Type.Optional(Type.Boolean()), caseSensitive: Type.Optional(Type.Boolean()), maxdepth: Type.Optional(Type.Number()), exact: Type.Optional(Type.Boolean()), offset: Type.Optional(Type.Number({ minimum: 0, description: "Result offset for progressive paging (default 0)." })), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50, description: "Maximum results to return (default 5)." })), }), renderShell: "self" as const, renderCall(args, theme, ctx) { const state = ctx.state as { startedAt?: number }; if (state.startedAt === undefined) state.startedAt = Date.now(); return kylinFrameForCall(ctx, { name: "fd", theme, status: "pending", paramSummary: buildParamSummary(args, theme), startedAt: state.startedAt, }); }, renderResult(result, opts, theme, ctx) { const details = result.details as any; const state = ctx.state as { startedAt?: number }; const startedAt = state.startedAt; const paramSummary = buildParamSummary(ctx.args, theme); if (opts.isPartial) { return kylinFrameForResult(ctx, { name: "fd", theme, status: "pending", paramSummary, startedAt }); } const isError = Boolean(ctx.isError); const total = Number(details?.total ?? 0); const returned = Number(details?.returned ?? 0); const expandedBody = opts.expanded ? textLines(result) : undefined; if (opts.expanded && expandedBody && details?.stderr) { const stderrText = String(details.stderr).trim(); if (stderrText) { expandedBody.push(""); expandedBody.push("[stderr]"); for (const line of stderrText.split(/\r?\n/)) expandedBody.push(line); } } const expandedParams = opts.expanded ? buildExpandedParams([ { label: "pattern", value: ctx.args?.pattern, highlight: "regex" }, { label: "path", value: ctx.args?.path }, { label: "type", value: ctx.args?.type }, { label: "extension", value: ctx.args?.extension }, { label: "maxdepth", value: ctx.args?.maxdepth }, { label: "hidden", value: ctx.args?.hidden }, { label: "no_ignore", value: ctx.args?.noIgnore }, { label: "case_sensitive", value: ctx.args?.caseSensitive }, { label: "exact", value: ctx.args?.exact }, { label: "offset", value: ctx.args?.offset }, { label: "limit", value: ctx.args?.limit }, ], theme) : undefined; return kylinFrameForResult(ctx, { name: "fd", theme, status: isError ? "error" : "success", paramSummary, resultSummary: isError ? undefined : theme.fg(total > 0 ? "success" : "dim", `${total} files`) + (details?.hasMore ? theme.fg("dim", ` · ${returned} shown`) : ""), errorMessage: isError ? String(result.content?.[0]?.text ?? "fd failed") : undefined, expanded: opts.expanded, expandedParams, expandedBody, expandedTotalLines: total || expandedBody?.length, startedAt, }); }, async execute(_toolCallId, params, signal, onUpdate) { let fd = await resolvePreferredBinary("fd"); if (!fd) { onUpdate?.({ content: [{ type: "text" as const, text: "Bundled fd missing; downloading current-platform binary..." }] }); fd = await ensureBundledBinary("fd"); } const searchPath = normalizePathArg(params.path) ?? "."; const args: string[] = []; if (params.type) args.push("-t", params.type); if (params.extension) args.push("-e", params.extension); if (params.hidden) args.push("-H"); if (params.noIgnore) args.push("-I"); if (params.caseSensitive === true) args.push("-s"); else args.push("-i"); if (params.maxdepth !== undefined) args.push("-d", String(params.maxdepth)); if (params.exact) args.push("-F"); args.push(params.pattern || ".", searchPath); const { stdout, stderr } = await execResolved(fd, args, { signal }); const page = renderPage( splitOutputLines(stdout || ""), clampPageValue(params.offset, 0, 0, 1_000_000), clampPageValue(params.limit, 5, 1, 50), "No files found", ); return { content: [{ type: "text" as const, text: page.text }], details: { binary: fd, stderr: stderr || undefined, offset: page.offset, limit: page.limit, returned: page.returned, total: page.total, hasMore: page.hasMore, nextOffset: page.nextOffset, }, }; }, }); }