import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { ensureBundledBinary, resolvePreferredBinary } from "../install"; import { normalizePathArg } from "../normalize"; import { execResolved } from "../process"; import { clampPageValue, renderPage, splitRgEntries } 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.glob) extras.push(`glob:${args.glob}`); 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 registerRgTool(pi: ExtensionAPI): void { pi.registerTool({ name: "rg", label: "rg", description: "Fast recursive text search using ripgrep with progressive paging. Returns 5 results by default; use offset to continue. Uses bundled rg first, then system rg.", promptSnippet: "Use rg for local text search. Start narrow, read the first page, then continue with next_offset only if needed.", promptGuidelines: [ "Prefer a narrow path, type, or glob instead of broad repository-wide dumps.", "Read the first 5 results, then use offset=next_offset to continue only when needed.", "Use line numbers by default and add context only when surrounding lines matter.", "Pass literal=true when searching plain text containing regex metacharacters like . ( [ ? * + | \\.", ], parameters: Type.Object({ pattern: Type.String({ description: "Search pattern (regex supported)" }), path: Type.Optional(Type.String({ description: "Directory or file to search" })), caseSensitive: Type.Optional(Type.Boolean()), literal: Type.Optional(Type.Boolean({ description: "Treat pattern as literal string, not regex" })), context: Type.Optional(Type.Number()), type: Type.Optional(Type.String({ description: "ripgrep file type, e.g. ts/js/md" })), glob: Type.Optional(Type.String({ description: "Glob filter, e.g. *.test.ts" })), hidden: Type.Optional(Type.Boolean()), noIgnore: Type.Optional(Type.Boolean()), lineNumber: Type.Optional(Type.Boolean()), stats: 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: "rg", 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: "rg", 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: "glob", value: ctx.args?.glob }, { label: "context", value: ctx.args?.context }, { label: "case_sensitive", value: ctx.args?.caseSensitive }, { label: "literal", value: ctx.args?.literal }, { label: "hidden", value: ctx.args?.hidden }, { label: "no_ignore", value: ctx.args?.noIgnore }, { label: "line_number", value: ctx.args?.lineNumber }, { label: "stats", value: ctx.args?.stats }, { label: "offset", value: ctx.args?.offset }, { label: "limit", value: ctx.args?.limit }, ], theme) : undefined; return kylinFrameForResult(ctx, { name: "rg", theme, status: isError ? "error" : "success", paramSummary, resultSummary: isError ? undefined : theme.fg(total > 0 ? "success" : "dim", `${total} matches`) + (details?.hasMore ? theme.fg("dim", ` · ${returned} shown`) : ""), errorMessage: isError ? String(result.content?.[0]?.text ?? "rg failed") : undefined, expanded: opts.expanded, expandedParams, expandedBody, expandedTotalLines: total || expandedBody?.length, startedAt, }); }, async execute(_toolCallId, params, signal, onUpdate) { let rg = await resolvePreferredBinary("rg"); if (!rg) { onUpdate?.({ content: [{ type: "text" as const, text: "Bundled rg missing; downloading current-platform binary..." }] }); rg = await ensureBundledBinary("rg"); } const searchPath = normalizePathArg(params.path) ?? "."; const args: string[] = []; if (params.caseSensitive === true) args.push("-s"); else args.push("-S"); if (params.lineNumber !== false) args.push("-n"); if (params.literal) args.push("-F"); if (params.context !== undefined) args.push("-C", String(params.context)); if (params.type) args.push("-t", params.type); if (params.glob) args.push("-g", params.glob); if (params.hidden) args.push("--hidden"); if (params.noIgnore) args.push("--no-ignore"); if (params.stats) args.push("--stats"); args.push(params.pattern, searchPath); try { const { stdout, stderr } = await execResolved(rg, args, { signal }); const page = renderPage( splitRgEntries(stdout || "", params.context !== undefined && params.context > 0), clampPageValue(params.offset, 0, 0, 1_000_000), clampPageValue(params.limit, 5, 1, 50), "No matches found", ); return { content: [{ type: "text" as const, text: page.text }], details: { binary: rg, stderr: stderr || undefined, offset: page.offset, limit: page.limit, returned: page.returned, total: page.total, hasMore: page.hasMore, nextOffset: page.nextOffset, }, }; } catch (error: any) { if (error.code === 1) { return { content: [{ type: "text" as const, text: "No matches found" }], details: { binary: rg, total: 0, offset: 0, limit: clampPageValue(params.limit, 5, 1, 50), returned: 0, hasMore: false, nextOffset: null }, }; } throw new Error(`rg failed: ${error.message}`); } }, }); }