Files
oh-my-pi/packages/coding-agent/src/tools/search.ts
T
can1357 b12e4698a6 feat: added @oh-my-pi/hashline package and migrated hashline tooling
- Added a dedicated @oh-my-pi/hashline package with parser, patcher, filesystem, snapshots, and release metadata.
- Migrated coding-agent hashline and stream entrypoints to @oh-my-pi/hashline and removed old hashline module exports.
- Changed multi-section hashline execution to validate section hashes and flush diagnostics only at the final commit.
- Added session fileSnapshotStore support and rewired edit/read/search/write tools to use it instead of fileReadCache.
2026-05-27 04:03:49 +02:00

939 lines
36 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 { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { computeFileHash, formatHashlineHeader } from "@oh-my-pi/hashline";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { type GrepMatch, GrepOutputMode, type GrepResult, grep } from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from ".";
import {
type ArchiveReader,
type ExtractedArchiveFile,
openArchive,
parseArchivePathCandidates,
} from "./archive-reader";
import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output";
import { formatMatchLine } from "./match-line-format";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
import {
hasGlobPathChars,
isLineInRanges,
type LineRange,
parseLineRanges,
resolveReadPath,
resolveToolSearchScope,
splitPathAndSel,
} from "./path-utils";
import {
createCachedComponent,
formatCodeFrameLine,
formatCount,
formatEmptyMessage,
formatErrorMessage,
PREVIEW_LIMITS,
splitGroupsByBlankLine,
} from "./render-utils";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
const searchPathEntrySchema = z
.string()
.describe(
'file, directory, glob, internal URL, or "<file>:<lines>" selector (e.g. "src/foo.ts:50-100", "src/foo.ts:50+10", "src/foo.ts:50-100,200-300")',
);
const searchSchema = z
.object({
pattern: z.string().describe("regex pattern"),
paths: z
.union([searchPathEntrySchema, z.array(searchPathEntrySchema).min(1)])
.describe(
"file, directory, glob, internal URL, or array of those to search; append `:<lines>` to scope a file to specific line ranges",
),
i: z.boolean().optional().describe("case-insensitive search"),
gitignore: z.boolean().optional().describe("respect gitignore"),
skip: z
.number()
.optional()
.describe("files to skip before collecting results — use to paginate when the prior call hit the file limit"),
})
.strict();
export type SearchToolInput = z.infer<typeof searchSchema>;
export function toPathList(input: string | string[] | undefined): string[] {
return typeof input === "string" ? [input] : (input ?? []);
}
/** Maximum number of distinct files surfaced in a single response. The
* agent paginates further pages via `skip`. */
export const DEFAULT_FILE_LIMIT = 20;
/** Per-file match cap for multi-file searches — keeps a single hot file
* from crowding out diverse hits. Applied in JS after grep returns. */
export const MULTI_FILE_PER_FILE_MATCHES = 20;
/** Per-file match cap for single-file searches — there's no diversity
* concern when the scope is one file. */
export const SINGLE_FILE_MATCHES = 200;
/** Hard safety ceiling on how many matches we fetch from native grep
* before JS-side grouping. Sized to comfortably cover the file window
* (DEFAULT_FILE_LIMIT files × MULTI_FILE_PER_FILE_MATCHES matches) plus
* pagination headroom so the caller can see total file count. */
const INTERNAL_TOTAL_CAP = 2000;
/**
* Detect a `,` that is not inside a `{…}` brace expansion. Used to catch
* `paths: ["a,b"]` mistakes where the caller flattened multiple entries
* into a single string instead of passing a JSON array of strings.
*/
function containsTopLevelComma(entry: string): boolean {
let depth = 0;
for (let i = 0; i < entry.length; i++) {
const ch = entry[i];
if (ch === "\\" && i + 1 < entry.length) {
i++;
continue;
}
if (ch === "{") depth++;
else if (ch === "}") {
if (depth > 0) depth--;
} else if (ch === "," && depth === 0) {
return true;
}
}
return false;
}
/**
* Parsed `paths` entry — a path (possibly archive-shaped) plus an optional
* line-range selector peeled off the trailing `:N-M` (or `:N+K`, `:N,M`, …)
* chunk via {@link splitPathAndSel}.
*/
interface SearchPathSpec {
original: string;
clean: string;
ranges?: [LineRange, ...LineRange[]];
}
function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
const specs: SearchPathSpec[] = [];
for (const entry of rawEntries) {
const split = splitPathAndSel(entry);
let clean = entry;
let ranges: [LineRange, ...LineRange[]] | undefined;
if (split.sel) {
const parsed = parseLineRanges(split.sel);
if (!parsed) {
throw new ToolError(
`paths entry "${entry}" — only line-range selectors like ":50-100" are supported (no ":raw"/":conflicts")`,
);
}
if (hasGlobPathChars(split.path)) {
throw new ToolError(`Line-range selector requires a single file, not a glob: ${entry}`);
}
clean = split.path;
ranges = parsed;
}
if (containsTopLevelComma(clean)) {
throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]');
}
specs.push({ original: entry, clean, ranges });
}
return specs;
}
function mergeRangesInto(map: Map<string, LineRange[]>, absKey: string, ranges: readonly LineRange[]): void {
// Concat-without-merge is correct: `isLineInRanges` scans linearly, so
// duplicates/overlaps only cost a few extra comparisons per match.
const existing = map.get(absKey);
if (existing) {
existing.push(...ranges);
} else {
map.set(absKey, [...ranges]);
}
}
function matchAbsolutePath(matchPath: string, searchPath: string): string {
if (matchPath === "") return searchPath;
if (path.isAbsolute(matchPath)) return matchPath;
return path.resolve(searchPath, matchPath);
}
/**
* Pre-resolve any `paths` entries that point at a member inside an archive
* (e.g. `bundle.zip:src/foo.ts`, `release.tar.gz:notes.md`). Native grep
* cannot read archive members, so we materialize each text member to a
* temp scratch file and substitute that path into the search inputs. After
* grep returns, callers remap `match.path` back to the original
* `archive:member` selector so it round-trips through the `read` tool.
*
* Returns the rewritten paths array (same length/order as input), a map
* from absolute scratch path → original selector, a list of entries we
* could not materialize (binary member, missing archive, etc.), and a
* cleanup hook the caller MUST invoke in a `finally`.
*/
async function resolveArchiveSearchPaths(
paths: string[],
cwd: string,
): Promise<{
resolvedPaths: string[];
displayMap: Map<string, string>;
displaySet: Set<string>;
unreadable: string[];
cleanup: () => Promise<void>;
}> {
const resolvedPaths = paths.slice();
const displayMap = new Map<string, string>();
const displaySet = new Set<string>();
const unreadable: string[] = [];
let tempDir: string | undefined;
const archiveCache = new Map<string, ArchiveReader>();
for (let idx = 0; idx < paths.length; idx++) {
const entry = paths[idx];
const candidates = parseArchivePathCandidates(entry);
// Longest archive prefix first; we want the one whose member portion is non-empty.
const member = candidates.find(c => c.subPath !== "" && c.archivePath !== entry);
if (!member) continue;
const archiveAbs = resolveReadPath(member.archivePath, cwd);
let archive = archiveCache.get(archiveAbs);
if (!archive) {
try {
archive = await openArchive(archiveAbs);
} catch (err) {
unreadable.push(`${entry} (cannot open archive: ${(err as Error).message})`);
continue;
}
archiveCache.set(archiveAbs, archive);
}
let extracted: ExtractedArchiveFile;
try {
extracted = await archive.readFile(member.subPath);
} catch (err) {
unreadable.push(`${entry} (${(err as Error).message})`);
continue;
}
// UTF-8 only — binary members would just produce noise through ripgrep.
if (extracted.bytes.some(byte => byte === 0)) {
unreadable.push(`${entry} (binary archive entry)`);
continue;
}
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(extracted.bytes);
} catch {
unreadable.push(`${entry} (non-UTF-8 archive entry)`);
continue;
}
if (!tempDir) {
tempDir = await mkdtemp(path.join(tmpdir(), "omp-search-archive-"));
}
// Per-entry filename keeps the scratch path unique even when two selectors
// resolve to members with the same basename.
const safeBase = path.basename(member.subPath).replace(/[^\w.-]+/g, "_") || "entry";
const tempPath = path.join(tempDir, `${idx}-${safeBase}`);
await writeFile(tempPath, text);
resolvedPaths[idx] = tempPath;
displayMap.set(tempPath, entry);
displaySet.add(entry);
}
const cleanup = async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
};
return { resolvedPaths, displayMap, displaySet, unreadable, cleanup };
}
export interface SearchToolDetails {
truncation?: TruncationResult;
fileLimitReached?: number;
perFileLimitReached?: number;
linesTruncated?: boolean;
meta?: OutputMeta;
scopePath?: string;
matchCount?: number;
fileCount?: number;
files?: string[];
fileMatches?: Array<{ path: string; count: number }>;
truncated?: boolean;
error?: string;
/** Pre-formatted text for the user-visible TUI render. Mirrors the model-facing
* `result.text` lines but uses a `│` gutter and `*` to mark match lines (vs space for
* context). The TUI uses this directly so it never parses model-facing hashline anchors. */
displayContent?: string;
/** Absolute base directory used during search. Used by the renderer to resolve
* display-relative paths to absolute paths for OSC 8 hyperlinks. */
searchPath?: 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[];
}
type SearchParams = z.infer<typeof searchSchema>;
export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDetails> {
readonly name = "search";
readonly approval = "read" as const;
readonly label = "Search";
readonly loadMode = "discoverable";
readonly summary = "Search file contents using ripgrep (fast text search)";
readonly description: string;
readonly parameters = searchSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
const displayMode = resolveFileDisplayMode(session);
this.description = prompt.render(searchDescription, {
IS_HL_MODE: displayMode.hashLines,
IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
});
}
async execute(
_toolCallId: string,
params: SearchParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
_toolContext?: AgentToolContext,
): Promise<AgentToolResult<SearchToolDetails>> {
const { pattern, paths: rawPaths, i, gitignore, skip } = params;
return untilAborted(signal, async () => {
const normalizedPattern = pattern.trim();
if (!normalizedPattern) {
throw new ToolError("Pattern must not be empty");
}
const normalizedSkip = skip === undefined ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN;
if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
throw new ToolError("Skip must be a non-negative number");
}
const rawEntries = toPathList(rawPaths);
const pathSpecs = parsePathSpecs(rawEntries);
const paths = pathSpecs.map(spec => spec.clean);
const {
resolvedPaths,
displayMap: archiveDisplayMap,
displaySet: archiveDisplaySet,
unreadable: archiveUnreadable,
cleanup: cleanupArchiveScratch,
} = await resolveArchiveSearchPaths(paths, this.session.cwd);
try {
// Build the per-file line-range filter (keyed by absolute path) now that
// archive entries have been materialized to scratch files. Plain entries
// resolve through `resolveReadPath`; archive entries are keyed by the
// scratch path that grep will actually report against.
const rangesByAbsPath = new Map<string, LineRange[]>();
for (let idx = 0; idx < pathSpecs.length; idx++) {
const spec = pathSpecs[idx];
if (!spec.ranges) continue;
const resolved = resolvedPaths[idx];
if (resolved === spec.clean && !archiveDisplayMap.has(resolved)) {
// Non-archive entry; ensure the cleaned path resolves to a regular file.
const absKey = path.resolve(resolveReadPath(spec.clean, this.session.cwd));
const stats = await stat(absKey).catch(() => null);
if (!stats) {
throw new ToolError(`Path not found for line-range selector: ${spec.original}`);
}
if (!stats.isFile()) {
throw new ToolError(`Line-range selector requires a single file: ${spec.original} is a directory`);
}
mergeRangesInto(rangesByAbsPath, absKey, spec.ranges);
} else {
// Archive entry — `resolveArchiveSearchPaths` substituted a scratch path.
const absKey = path.resolve(resolved);
mergeRangesInto(rangesByAbsPath, absKey, spec.ranges);
}
}
if (archiveUnreadable.length > 0 && resolvedPaths.length === archiveUnreadable.length) {
// All inputs were archive selectors we couldn't materialize; surface the
// reason instead of a downstream "path not found" from the scope resolver.
throw new ToolError(
`Cannot search archive member(s): ${archiveUnreadable.join(", ")}. ` +
`Read the file directly with \`read <archive>:<member>\` and grep the returned content, ` +
`or pass a UTF-8 text member.`,
);
}
const normalizedContextBefore = this.session.settings.get("search.contextBefore");
const normalizedContextAfter = this.session.settings.get("search.contextAfter");
const ignoreCase = i ?? false;
const useGitignore = gitignore ?? true;
const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
const effectiveMultiline = patternHasNewline;
const scope = await resolveToolSearchScope({
rawPaths: resolvedPaths,
cwd: this.session.cwd,
internalUrlAction: "search",
trackImmutableSources: true,
surfaceExactFilePaths: true,
multipathStatHint: " (`paths` entries must each exist relative to cwd)",
});
const { searchPath, isDirectory, multiTargets, exactFilePaths, missingPaths, immutableSourcePaths } = scope;
// When the only input was an archive selector, surface that selector instead
// of the temp scratch path the resolver substituted in.
const scopePath =
resolvedPaths.length === 1 && archiveDisplayMap.get(searchPath)
? (archiveDisplayMap.get(searchPath) as string)
: scope.scopePath;
if (missingPaths.length > 0 && missingPaths.length === resolvedPaths.length) {
const archiveHint =
archiveUnreadable.length > 0
? ` (archive members were not searchable: ${archiveUnreadable.join(", ")})`
: "";
throw new ToolError(
`Path not found: ${missingPaths.join(", ")}; pass each path as its own array element${archiveHint}`,
);
}
const { globFilter } = scope;
const baseDisplayMode = resolveFileDisplayMode(this.session);
const effectiveOutputMode = GrepOutputMode.Content;
// Multi-scope = more than one file may match. We fetch up to
// INTERNAL_TOTAL_CAP matches from native grep, then in JS group by
// file, apply a per-file cap (so one hot file doesn't crowd the
// window), and round-robin emit from up to DEFAULT_FILE_LIMIT files.
const isMultiScope = isDirectory || Boolean(exactFilePaths) || Boolean(multiTargets);
const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES;
// Run grep
let result: GrepResult;
try {
if (exactFilePaths || multiTargets) {
const matches: GrepMatch[] = [];
let limitReached = false;
let totalMatches = 0;
let filesSearched = 0;
const targets = exactFilePaths
? exactFilePaths.map(filePath => ({ basePath: filePath, glob: undefined as string | undefined }))
: (multiTargets ?? []);
for (const target of targets) {
const targetResult = await grep(
{
pattern: normalizedPattern,
path: target.basePath,
glob: target.glob,
ignoreCase,
multiline: effectiveMultiline,
hidden: true,
gitignore: useGitignore,
cache: false,
maxCount: INTERNAL_TOTAL_CAP,
contextBefore: normalizedContextBefore,
contextAfter: normalizedContextAfter,
maxColumns: DEFAULT_MAX_COLUMN,
mode: effectiveOutputMode,
},
undefined,
);
limitReached = limitReached || Boolean(targetResult.limitReached);
totalMatches += targetResult.totalMatches;
filesSearched += targetResult.filesSearched;
for (const match of targetResult.matches) {
const absolute = path.resolve(target.basePath, match.path);
const rebased = path.relative(searchPath, absolute).replace(/\\/g, "/");
matches.push({ ...match, path: rebased });
}
}
result = {
matches,
totalMatches: exactFilePaths ? matches.length : totalMatches,
filesWithMatches: new Set(matches.map(match => match.path)).size,
filesSearched: exactFilePaths ? exactFilePaths.length : filesSearched,
limitReached,
};
} else {
result = await grep(
{
pattern: normalizedPattern,
path: searchPath,
glob: globFilter,
ignoreCase,
multiline: effectiveMultiline,
hidden: true,
gitignore: useGitignore,
cache: false,
maxCount: INTERNAL_TOTAL_CAP,
contextBefore: normalizedContextBefore,
contextAfter: normalizedContextAfter,
maxColumns: DEFAULT_MAX_COLUMN,
mode: effectiveOutputMode,
},
undefined,
);
}
} catch (err) {
if (err instanceof Error && err.message.startsWith("regex parse error")) {
throw new ToolError(err.message);
}
throw err;
}
if (rangesByAbsPath.size > 0) {
const filteredMatches: GrepMatch[] = [];
for (const match of result.matches) {
const abs = matchAbsolutePath(match.path, searchPath);
const ranges = rangesByAbsPath.get(abs);
if (!ranges) {
// Path has no line-range constraint (e.g. a peer entry without `:N-M`).
filteredMatches.push(match);
continue;
}
if (!isLineInRanges(match.lineNumber, ranges)) continue;
// Drop context lines that fall outside the allowed ranges; they would
// otherwise leak content the caller explicitly excluded.
const trimBefore = match.contextBefore?.filter(c => isLineInRanges(c.lineNumber, ranges));
const trimAfter = match.contextAfter?.filter(c => isLineInRanges(c.lineNumber, ranges));
filteredMatches.push({
...match,
contextBefore: trimBefore && trimBefore.length > 0 ? trimBefore : undefined,
contextAfter: trimAfter && trimAfter.length > 0 ? trimAfter : undefined,
});
}
result = {
matches: filteredMatches,
totalMatches: filteredMatches.length,
filesWithMatches: new Set(filteredMatches.map(match => match.path)).size,
filesSearched: result.filesSearched,
limitReached: result.limitReached,
};
}
if (archiveDisplayMap.size > 0) {
for (const match of result.matches) {
const abs = matchAbsolutePath(match.path, searchPath);
const display = archiveDisplayMap.get(abs);
if (display) match.path = display;
}
}
const formatPath = (filePath: string): string =>
archiveDisplaySet.has(filePath)
? filePath
: formatResultPath(filePath, isDirectory, searchPath, this.session.cwd);
// Group matches by file in encounter order. Detect per-file overflow
// BEFORE truncation so the renderer can surface that a hot file was
// trimmed for diversity.
const fileOrder: string[] = [];
const matchesByPath = new Map<string, GrepMatch[]>();
for (const match of result.matches) {
if (!matchesByPath.has(match.path)) {
fileOrder.push(match.path);
matchesByPath.set(match.path, []);
}
matchesByPath.get(match.path)!.push(match);
}
let perFileLimitReached = false;
for (const file of fileOrder) {
const list = matchesByPath.get(file)!;
if (list.length > perFileMatchCap) {
perFileLimitReached = true;
list.length = perFileMatchCap;
}
}
const totalFiles = fileOrder.length;
// Single-file scopes can't paginate — there is one file by definition.
const canPaginate = isMultiScope;
const skipFiles = canPaginate ? Math.min(normalizedSkip, totalFiles) : 0;
const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + DEFAULT_FILE_LIMIT) : fileOrder;
const fileLimitReached = canPaginate && totalFiles > skipFiles + DEFAULT_FILE_LIMIT;
const selectedMatches: GrepMatch[] = [];
if (windowFiles.length > 0) {
const lists = windowFiles.map(file => matchesByPath.get(file) ?? []);
const cursors = new Array<number>(lists.length).fill(0);
let anyAdded = true;
while (anyAdded) {
anyAdded = false;
for (let i = 0; i < lists.length; i++) {
if (cursors[i] < lists[i].length) {
selectedMatches.push(lists[i][cursors[i]++]);
anyAdded = true;
}
}
}
}
const nextSkip = skipFiles + windowFiles.length;
const limitMessage = fileLimitReached
? `Showing files ${skipFiles + 1}-${nextSkip} of ${totalFiles}. Use skip=${nextSkip} for the next page, or narrow paths/pattern.`
: "";
const { record: recordFile, list: fileList } = createFileRecorder();
const fileMatchCounts = new Map<string, number>();
const archiveNote =
archiveUnreadable.length > 0
? `Skipped archive entries (search supports text members only): ${archiveUnreadable.join(", ")}`
: undefined;
// Suppress entries we already explained via archiveNote — they would otherwise
// double up (the unreadable selector also failed the scope's existence check).
const archiveUnreadablePaths = new Set(archiveUnreadable.map(s => s.replace(/ \(.*\)$/, "")));
const missingPathsForNote = missingPaths.filter(p => !archiveUnreadablePaths.has(p));
const missingPathsNote =
missingPathsForNote.length > 0 ? `Skipped missing paths: ${missingPathsForNote.join(", ")}` : undefined;
const warningNote =
[missingPathsNote, archiveNote].filter((s): s is string => Boolean(s)).join("\n") || undefined;
if (selectedMatches.length === 0) {
const details: SearchToolDetails = {
scopePath,
searchPath,
matchCount: 0,
fileCount: 0,
files: [],
truncated: false,
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
};
const text = warningNote ? `No matches found\n${warningNote}` : "No matches found";
return toolResult(details).text(text).done();
}
const outputLines: string[] = [];
let linesTruncated = false;
const matchesByFile = new Map<string, GrepMatch[]>();
for (const match of selectedMatches) {
const relativePath = formatPath(match.path);
recordFile(relativePath);
if (!matchesByFile.has(relativePath)) {
matchesByFile.set(relativePath, []);
}
matchesByFile.get(relativePath)!.push(match);
}
const displayLines: string[] = [];
const hashContexts = new Map<string, { absolutePath: string; fileHash: string }>();
if (baseDisplayMode.hashLines) {
for (const relativePath of fileList) {
if (archiveDisplaySet.has(relativePath)) continue;
const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
if (immutableSourcePaths.has(absoluteFilePath)) continue;
try {
const fullText = await Bun.file(absoluteFilePath).text();
const fileHash = computeFileHash(fullText);
hashContexts.set(relativePath, { absolutePath: absoluteFilePath, fileHash });
} catch {
// Best-effort: if the file disappeared between grep and render, fall back to plain line output.
}
}
}
const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
const modelOut: string[] = [];
const displayOut: string[] = [];
const fileMatches = matchesByFile.get(relativePath) ?? [];
const hashContext = hashContexts.get(relativePath);
const useHashLines = hashContext !== undefined;
const lineNumberWidth = fileMatches.reduce((width, match) => {
let nextWidth = Math.max(width, String(match.lineNumber).length);
for (const ctx of match.contextBefore ?? []) {
nextWidth = Math.max(nextWidth, String(ctx.lineNumber).length);
}
for (const ctx of match.contextAfter ?? []) {
nextWidth = Math.max(nextWidth, String(ctx.lineNumber).length);
}
return nextWidth;
}, 0);
const cacheEntries: Array<readonly [number, string]> = [];
let lastEmittedLine: number | undefined;
const gutterPad = " ".repeat(lineNumberWidth + 1);
for (const match of fileMatches) {
const pushLine = (lineNumber: number, line: string, isMatch: boolean, recordable: boolean) => {
if (lastEmittedLine !== undefined && lineNumber > lastEmittedLine + 1) {
modelOut.push("...");
displayOut.push(`${gutterPad}│...`);
}
modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
if (recordable) cacheEntries.push([lineNumber, line] as const);
lastEmittedLine = lineNumber;
};
if (match.contextBefore) {
for (const ctx of match.contextBefore) {
pushLine(ctx.lineNumber, ctx.line, false, true);
}
}
pushLine(match.lineNumber, match.line, true, !match.truncated);
if (match.truncated) {
linesTruncated = true;
}
if (match.contextAfter) {
for (const ctx of match.contextAfter) {
pushLine(ctx.lineNumber, ctx.line, false, true);
}
}
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
}
if (cacheEntries.length > 0 && hashContext) {
getFileSnapshotStore(this.session).recordSparse(hashContext.absolutePath, cacheEntries, {
fileHash: hashContext.fileHash,
});
}
return { model: modelOut, display: displayOut };
};
if (isDirectory) {
const grouped = formatGroupedFiles(fileList, relativePath => {
const rendered = renderMatchesForFile(relativePath);
const hashContext = hashContexts.get(relativePath);
return {
modelLines: rendered.model,
displayLines: rendered.display,
headerSuffix: hashContext ? `#${hashContext.fileHash}` : "",
skip: rendered.model.length === 0,
};
});
outputLines.push(...grouped.model);
displayLines.push(...grouped.display);
} else {
for (const relativePath of fileList) {
const rendered = renderMatchesForFile(relativePath);
if (rendered.model.length === 0) continue;
if (outputLines.length > 0) {
outputLines.push("");
displayLines.push("");
}
const hashContext = hashContexts.get(relativePath);
if (hashContext) {
outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash));
}
outputLines.push(...rendered.model);
displayLines.push(...rendered.display);
}
}
if (limitMessage) {
outputLines.push("", limitMessage);
}
if (warningNote) {
outputLines.push("", warningNote);
}
const rawOutput = outputLines.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
const output = truncation.content;
const displayText = displayLines.join("\n");
const truncated = Boolean(
fileLimitReached || perFileLimitReached || result.limitReached || truncation.truncated || linesTruncated,
);
const details: SearchToolDetails = {
scopePath,
searchPath,
matchCount: selectedMatches.length,
fileCount: fileList.length,
files: fileList,
fileMatches: fileList.map(path => ({
path,
count: fileMatchCounts.get(path) ?? 0,
})),
truncated,
fileLimitReached: fileLimitReached ? DEFAULT_FILE_LIMIT : undefined,
perFileLimitReached: perFileLimitReached ? perFileMatchCap : undefined,
displayContent: displayText,
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
};
if (truncation.truncated) details.truncation = truncation;
if (linesTruncated) details.linesTruncated = true;
const resultBuilder = toolResult(details)
.text(output)
.limits({ columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined });
if (truncation.truncated) {
resultBuilder.truncation(truncation, { direction: "head" });
}
return resultBuilder.done();
} finally {
await cleanupArchiveScratch();
}
});
}
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface SearchRenderArgs {
pattern: string;
paths?: string | string[];
i?: boolean;
gitignore?: boolean;
skip?: number;
}
const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
export const searchToolRenderer = {
inline: true,
renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const paths = toPathList(args.paths);
const meta: string[] = [];
if (paths.length) meta.push(`in ${paths.join(", ")}`);
if (args.i) meta.push("case:insensitive");
if (args.gitignore === false) meta.push("gitignore:false");
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
const text = renderStatusLine(
{ icon: "pending", title: "Search", description: args.pattern || "?", meta },
uiTheme,
);
return new Text(text, 0, 0);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails; isError?: boolean },
options: RenderResultOptions,
uiTheme: Theme,
args?: SearchRenderArgs,
): 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?.matchCount !== undefined || details?.fileCount !== undefined;
if (!hasDetailedData) {
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text;
if (!textContent || textContent === "No matches found") {
return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0);
}
const lines = textContent.split("\n").filter(line => line.trim() !== "");
const description = args?.pattern ?? undefined;
const header = renderStatusLine(
{ icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] },
uiTheme,
);
return createCachedComponent(
() => options.expanded,
width => {
const listLines = renderTreeList(
{
items: lines,
expanded: options.expanded,
maxCollapsed: COLLAPSED_TEXT_LIMIT,
maxCollapsedLines: COLLAPSED_TEXT_LIMIT,
itemType: "item",
renderItem: line => uiTheme.fg("toolOutput", line),
},
uiTheme,
);
return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
}
const matchCount = details?.matchCount ?? 0;
const fileCount = details?.fileCount ?? 0;
const truncation = details?.meta?.truncation;
const limits = details?.meta?.limits;
const truncated = Boolean(details?.truncated || truncation || limits?.columnTruncated);
const missingPathsList = details?.missingPaths ?? [];
const missingNote =
missingPathsList.length > 0
? uiTheme.fg("warning", `skipped missing: ${missingPathsList.join(", ")}`)
: undefined;
if (matchCount === 0) {
const header = renderStatusLine(
{ icon: "warning", title: "Search", description: args?.pattern, meta: ["0 matches"] },
uiTheme,
);
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
if (missingNote) lines.push(missingNote);
return new Text(lines.join("\n"), 0, 0);
}
const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
const meta = [...summaryParts];
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
const description = args?.pattern ?? undefined;
const header = renderStatusLine(
{ icon: truncated ? "warning" : "success", title: "Search", description, meta },
uiTheme,
);
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
const matchGroups = splitGroupsByBlankLine(textContent.split("\n"));
const renderedFileLimit = details?.fileLimitReached;
const renderedPerFileLimit = details?.perFileLimitReached;
const truncationReasons: string[] = [];
if (renderedFileLimit) truncationReasons.push(`first ${renderedFileLimit} files (skip to paginate)`);
if (renderedPerFileLimit) truncationReasons.push(`first ${renderedPerFileLimit} matches per file`);
if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`);
if (truncation?.artifactId) truncationReasons.push(formatFullOutputReference(truncation.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 collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
const searchBase = details?.searchPath;
const matchLines = renderTreeList(
{
items: matchGroups,
expanded: options.expanded,
maxCollapsed: matchGroups.length,
maxCollapsedLines: collapsedMatchLineBudget,
itemType: "match",
renderItem: group => {
// Track directory context within a group for ## file headers.
// `# foo/` is a directory header; `# foo.ts` is a root-level file
// from formatGroupedFiles (single-# when directory is `.`).
let contextDir = searchBase ?? "";
return group.map(line => {
if (line.startsWith("## ")) {
// Strip optional ` (suffix)` and `#hash` before resolving.
const fileName = line
.slice(3)
.trimEnd()
.replace(/\s+\([^)]*\)\s*$/, "")
.replace(/#[0-9a-f]+$/, "");
const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
const styled = uiTheme.fg("dim", line);
return absPath ? fileHyperlink(absPath, styled) : styled;
}
if (line.startsWith("# ")) {
const raw = line
.slice(2)
.trimEnd()
.replace(/\s+\([^)]*\)\s*$/, "");
const isDirectory = raw.endsWith("/");
const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
if (isDirectory) {
if (searchBase) {
contextDir = name === "." ? searchBase : path.join(searchBase, name);
}
return uiTheme.fg("accent", line);
}
// Root-level file emitted by formatGroupedFiles when the directory is `.`.
const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
const styled = uiTheme.fg("accent", line);
return absPath ? fileHyperlink(absPath, styled) : styled;
}
return uiTheme.fg("toolOutput", line);
});
},
},
uiTheme,
);
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
},
mergeCallAndResult: true,
};