import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { 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 { recordFileSnapshot } from "../edit/file-snapshot-store"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { LocalProtocolOptions } from "../internal-urls/local-protocol"; import { InternalUrlRouter } from "../internal-urls/router"; import type { InternalResource, ResolveContext } from "../internal-urls/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, truncateLine } from "../session/streaming-output"; import { Ellipsis, fileHyperlink, getTreeBranch, getTreeContinuePrefix, renderStatusLine, renderTreeList, truncateToWidth, tryResolveInternalUrlSync, uriHyperlink, } 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 { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output"; import { formatMatchLine } from "./match-line-format"; import type { OutputMeta } from "./output-meta"; import { expandDelimitedPathEntries, hasGlobPathChars, isLineInRanges, type LineRange, parseLineRanges, type ResolvedSearchTarget, resolveReadPath, resolveToolSearchScope, selectorLineRanges, splitInternalUrlSel, splitPathAndSel, } from "./path-utils"; import { createCachedComponent, formatCodeFrameLine, formatCount, formatEmptyMessage, formatErrorMessage, formatMoreItems, PREVIEW_LIMITS, replaceTabs, } from "./render-utils"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; const searchPathEntrySchema = z .string() .describe( 'file, directory, glob, internal URL, or ":" 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)]) .optional() .describe( 'file, directory, glob, internal URL, or array of those to search; append `:` to scope a file to specific line ranges. Omitted or empty -> searches the workspace root (".")', ), i: z.boolean().optional().describe("case-insensitive search"), gitignore: z.boolean().optional().describe("respect gitignore"), skip: z .number() .nullable() .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; 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; /** Mirrors `MAX_FILE_BYTES` in `crates/pi-natives/src/grep.rs`. Native grep * silently returns no matches for files larger than this; surface a warning * when the caller explicitly targeted such a file so they know to chunk it. */ const NATIVE_GREP_MAX_FILE_BYTES = 4 * 1024 * 1024; /** Wall-clock budget for a single native grep invocation. Without it, an * aborted or runaway search (huge tree, network mount) keeps burning CPU on * the native thread pool after the JS promise is abandoned. */ const SEARCH_GREP_TIMEOUT_MS = 30_000; /** * 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) { // Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter, // which peels selector-shaped tails only for selector-capable schemes and // leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their // verbatim/index display modes (`raw`, `conflicts`) carry no meaning for // content search, so we accept them — searching the whole resource — and // still honor any embedded line range as a match filter. const internalSplit = splitInternalUrlSel(entry); if (internalSplit.sel !== undefined) { specs.push({ original: entry, clean: internalSplit.path, ranges: selectorLineRanges(internalSplit.sel), }); continue; } 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; } specs.push({ original: entry, clean, ranges }); } return specs; } function mergeRangesInto(map: Map, 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; displaySet: Set; unreadable: string[]; cleanup: () => Promise; }> { const resolvedPaths = paths.slice(); const displayMap = new Map(); const displaySet = new Set(); const unreadable: string[] = []; let tempDir: string | undefined; const archiveCache = new Map(); 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 }; } interface VirtualSearchResource { path: string; content: string; ranges?: readonly LineRange[]; } interface InternalSearchInputResolution { paths: string[]; resolvedPathsByInput: string[]; virtualResources: VirtualSearchResource[]; virtualPathSet: Set; virtualInputIndexes: Set; immutableSourcePaths: Set; virtualScopePath?: string; } interface IndexedContentLines { lines: string[]; starts: number[]; } const OMP_ROOT_URL_RE = /^omp:\/\/(?:\/?|docs\/?)$/i; function normalizeSearchLine(line: string): string { return line.endsWith("\r") ? line.slice(0, -1) : line; } function splitSearchLines(content: string): string[] { const lines = content.split("\n"); if (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } return lines.map(normalizeSearchLine); } function indexSearchLines(content: string): IndexedContentLines { const rawLines = content.split("\n"); if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") { rawLines.pop(); } const lines: string[] = []; const starts: number[] = []; let offset = 0; for (const rawLine of rawLines) { starts.push(offset); lines.push(normalizeSearchLine(rawLine)); offset += rawLine.length + 1; } return { lines, starts }; } function findLineIndex(starts: readonly number[], offset: number): number { if (starts.length === 0) return -1; let low = 0; let high = starts.length - 1; while (low <= high) { const mid = Math.floor((low + high) / 2); if (starts[mid] <= offset) { low = mid + 1; } else { high = mid - 1; } } return Math.max(0, high); } function lineAllowed(lineNumber: number, ranges: readonly LineRange[] | undefined): boolean { return !ranges || isLineInRanges(lineNumber, ranges); } function makeContextLine(lines: readonly string[], lineIndex: number): { lineNumber: number; line: string } { const { text } = truncateLine(lines[lineIndex] ?? "", DEFAULT_MAX_COLUMN); return { lineNumber: lineIndex + 1, line: text }; } function makeVirtualMatch( resource: VirtualSearchResource, lines: readonly string[], lineIndex: number, contextBefore: number, contextAfter: number, lastEmittedLine: number, nextMatchLine: number, ): GrepMatch { const lineNumber = lineIndex + 1; const { text, wasTruncated } = truncateLine(lines[lineIndex] ?? "", DEFAULT_MAX_COLUMN); const match: GrepMatch = { path: resource.path, lineNumber, line: text, }; if (wasTruncated) match.truncated = true; if (contextBefore > 0) { const before: NonNullable = []; // Start after the previous match's last emitted line so adjacent matches // never repeat or rewind context lines (mirrors native grep's sink). const start = Math.max(0, lineIndex - contextBefore, lastEmittedLine); for (let idx = start; idx < lineIndex; idx++) { const contextLineNumber = idx + 1; if (lineAllowed(contextLineNumber, resource.ranges)) { before.push(makeContextLine(lines, idx)); } } if (before.length > 0) match.contextBefore = before; } if (contextAfter > 0) { const after: NonNullable = []; // Stop before the next match line; it is emitted as a match itself. const end = Math.min(lines.length - 1, lineIndex + contextAfter, nextMatchLine - 2); for (let idx = lineIndex + 1; idx <= end; idx++) { const contextLineNumber = idx + 1; if (lineAllowed(contextLineNumber, resource.ranges)) { after.push(makeContextLine(lines, idx)); } } if (after.length > 0) match.contextAfter = after; } return match; } /** Build matches for ascending matched line indexes with forward-only, * deduplicated context windows (line numbers never repeat or go backwards * within one resource). */ function buildVirtualMatches( resource: VirtualSearchResource, lines: readonly string[], matchedIndexes: readonly number[], contextBefore: number, contextAfter: number, maxCount: number, ): GrepMatch[] { const matches: GrepMatch[] = []; let lastEmittedLine = 0; for (let i = 0; i < matchedIndexes.length && matches.length < maxCount; i++) { const lineIndex = matchedIndexes[i]; const nextMatchLine = i + 1 < matchedIndexes.length ? matchedIndexes[i + 1] + 1 : Number.POSITIVE_INFINITY; const match = makeVirtualMatch( resource, lines, lineIndex, contextBefore, contextAfter, lastEmittedLine, nextMatchLine, ); const after = match.contextAfter; lastEmittedLine = after && after.length > 0 ? after[after.length - 1].lineNumber : match.lineNumber; matches.push(match); } return matches; } function compileVirtualRegex(pattern: string, ignoreCase: boolean, multiline: boolean): RegExp { const flags = `${ignoreCase ? "i" : ""}${multiline ? "gm" : ""}`; try { return new RegExp(pattern, flags); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new ToolError(`Invalid regex: ${message.replace(/^Invalid regular expression:\s*/i, "")}`); } } function searchVirtualResourceLines( resource: VirtualSearchResource, regex: RegExp, contextBefore: number, contextAfter: number, maxCount: number, ): { matches: GrepMatch[]; totalMatches: number; limitReached: boolean } { const lines = splitSearchLines(resource.content); const matchedIndexes: number[] = []; for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const lineNumber = lineIndex + 1; if (!lineAllowed(lineNumber, resource.ranges)) continue; regex.lastIndex = 0; if (!regex.test(lines[lineIndex] ?? "")) continue; matchedIndexes.push(lineIndex); } const matches = buildVirtualMatches(resource, lines, matchedIndexes, contextBefore, contextAfter, maxCount); return { matches, totalMatches: matchedIndexes.length, limitReached: matchedIndexes.length > matches.length }; } function searchVirtualResourceMultiline( resource: VirtualSearchResource, regex: RegExp, contextBefore: number, contextAfter: number, maxCount: number, ): { matches: GrepMatch[]; totalMatches: number; limitReached: boolean } { const indexed = indexSearchLines(resource.content); const matchedLines = new Set(); const matchedIndexes: number[] = []; while (true) { const match = regex.exec(resource.content); if (match === null) break; const lineIndex = findLineIndex(indexed.starts, match.index); if (lineIndex >= 0) { const lineNumber = lineIndex + 1; if (!matchedLines.has(lineNumber) && lineAllowed(lineNumber, resource.ranges)) { matchedLines.add(lineNumber); matchedIndexes.push(lineIndex); } } if (match[0].length === 0) { regex.lastIndex++; } } const matches = buildVirtualMatches(resource, indexed.lines, matchedIndexes, contextBefore, contextAfter, maxCount); return { matches, totalMatches: matchedIndexes.length, limitReached: matchedIndexes.length > matches.length }; } function searchVirtualResources( resources: readonly VirtualSearchResource[], pattern: string, ignoreCase: boolean, multiline: boolean, contextBefore: number, contextAfter: number, maxCount: number, ): GrepResult { if (resources.length === 0) { return { matches: [], totalMatches: 0, filesWithMatches: 0, filesSearched: 0, limitReached: false }; } const regex = compileVirtualRegex(pattern, ignoreCase, multiline); const matches: GrepMatch[] = []; const filesWithMatches = new Set(); let totalMatches = 0; let limitReached = false; for (const resource of resources) { const remaining = Math.max(maxCount - matches.length, 0); const resourceResult = multiline ? searchVirtualResourceMultiline(resource, regex, contextBefore, contextAfter, remaining) : searchVirtualResourceLines(resource, regex, contextBefore, contextAfter, remaining); if (resourceResult.totalMatches > 0) { filesWithMatches.add(resource.path); } totalMatches += resourceResult.totalMatches; limitReached = limitReached || resourceResult.limitReached; matches.push(...resourceResult.matches); } return { matches, totalMatches, filesWithMatches: filesWithMatches.size, filesSearched: resources.length, limitReached, }; } function mergeGrepResults(left: GrepResult, right: GrepResult, maxCount: number): GrepResult { if (left.matches.length === 0) return right; if (right.matches.length === 0) return left; const combinedMatches = [...left.matches, ...right.matches]; const matches = combinedMatches.length > maxCount ? combinedMatches.slice(0, maxCount) : combinedMatches; return { matches, totalMatches: left.totalMatches + right.totalMatches, filesWithMatches: new Set(matches.map(match => match.path)).size, filesSearched: left.filesSearched + right.filesSearched, limitReached: left.limitReached || right.limitReached || matches.length < combinedMatches.length, }; } async function expandVirtualInternalResource( rawPath: string, resource: InternalResource, internalRouter: InternalUrlRouter, context: ResolveContext, ranges: readonly LineRange[] | undefined, ): Promise { if (OMP_ROOT_URL_RE.test(rawPath)) { const completions = await internalRouter.complete("omp", ""); if (completions && completions.length > 0) { const resources: VirtualSearchResource[] = []; const seen = new Set(); for (const completion of completions) { if (seen.has(completion.value)) continue; seen.add(completion.value); const docUrl = `omp://${completion.value}`; const doc = await internalRouter.resolve(docUrl, context); if (!doc.sourcePath) { resources.push({ path: docUrl, content: doc.content, ranges }); } } if (resources.length > 0) return resources; } } return [{ path: rawPath, content: resource.content, ranges }]; } async function resolveInternalSearchInputs(opts: { pathSpecs: readonly SearchPathSpec[]; resolvedPaths: string[]; cwd: string; settings: unknown; signal?: AbortSignal; archiveDisplayMap: ReadonlyMap; localProtocolOptions?: LocalProtocolOptions; }): Promise { const internalRouter = InternalUrlRouter.instance(); const paths = opts.resolvedPaths.slice(); const virtualResources: VirtualSearchResource[] = []; const virtualPathSet = new Set(); const virtualInputIndexes = new Set(); const immutableSourcePaths = new Set(); let virtualScopePath: string | undefined; const context: ResolveContext = { cwd: opts.cwd, settings: opts.settings, signal: opts.signal, localProtocolOptions: opts.localProtocolOptions, }; for (let idx = 0; idx < paths.length; idx++) { const rawPath = paths[idx]; if (!rawPath || opts.archiveDisplayMap.has(rawPath) || !internalRouter.canHandle(rawPath)) { continue; } if (hasGlobPathChars(rawPath)) { throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); } const resource = await internalRouter.resolve(rawPath, context); if (resource.sourcePath) { paths[idx] = resource.sourcePath; if (resource.immutable) { immutableSourcePaths.add(path.resolve(resource.sourcePath)); } continue; } const ranges = opts.pathSpecs[idx]?.ranges; const expanded = await expandVirtualInternalResource(rawPath, resource, internalRouter, context, ranges); virtualInputIndexes.add(idx); for (const virtual of expanded) { virtualResources.push(virtual); virtualPathSet.add(virtual.path); } virtualScopePath = virtualScopePath ? `${virtualScopePath}, ${rawPath}` : rawPath; } return { resolvedPathsByInput: paths, paths: paths.filter((_, idx) => !virtualInputIndexes.has(idx)), virtualResources, virtualPathSet, virtualInputIndexes, immutableSourcePaths, virtualScopePath, }; } 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; /** Session cwd at search time. The renderer resolves the display-relative * (cwd-relative) header/match paths against this for OSC 8 hyperlinks; * `searchPath` is the scope label target, not the display-path base. */ 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[]; } type SearchParams = z.infer; export class SearchTool implements AgentTool { 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, _toolContext?: AgentToolContext, ): Promise> { const { pattern, paths: rawPaths, i, gitignore, skip } = params; return untilAborted(signal, async () => { // Preserve the pattern verbatim — leading/trailing whitespace is // meaningful in regexes (indentation anchors, trailing-space matches). if (!pattern.trim()) { throw new ToolError("Pattern must not be empty"); } const normalizedPattern = pattern; const normalizedSkip = skip === undefined || skip === null ? 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 scopedPaths = toPathList(rawPaths); const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."]; const rawEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd); 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 { const internalResolution = await resolveInternalSearchInputs({ pathSpecs, resolvedPaths, cwd: this.session.cwd, settings: this.session.settings, signal, archiveDisplayMap, localProtocolOptions: this.session.localProtocolOptions, }); const searchablePaths = internalResolution.paths; const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution; // 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(); for (let idx = 0; idx < pathSpecs.length; idx++) { const spec = pathSpecs[idx]; if (!spec.ranges) continue; if (virtualInputIndexes.has(idx)) continue; const resolved = internalResolution.resolvedPathsByInput[idx]; if (!resolved) continue; if (resolved === spec.clean && !archiveDisplayMap.has(resolved)) { // Non-archive entry; ensure the cleaned path resolves to a regular file. const absKey = path.resolve(resolveReadPath(resolved, 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 && searchablePaths.length === archiveUnreadable.length && virtualResources.length === 0 ) { // 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 member with \`read :\` and inspect the returned text, ` + `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; let searchPath: string; let scopePath: string; let globFilter: string | undefined; let isDirectory: boolean; let multiTargets: ResolvedSearchTarget[] | undefined; let exactFilePaths: string[] | undefined; let missingPaths: string[]; const immutableSourcePaths = new Set(internalResolution.immutableSourcePaths); if (searchablePaths.length > 0) { const scope = await resolveToolSearchScope({ rawPaths: searchablePaths, cwd: this.session.cwd, internalUrlAction: "search", trackImmutableSources: true, surfaceExactFilePaths: true, fanOutFileTargets: true, multipathStatHint: " (`paths` entries must each exist relative to cwd)", settings: this.session.settings, signal, localProtocolOptions: this.session.localProtocolOptions, }); searchPath = scope.searchPath; isDirectory = scope.isDirectory; multiTargets = scope.multiTargets; exactFilePaths = scope.exactFilePaths; missingPaths = scope.missingPaths; globFilter = scope.globFilter; for (const immutablePath of scope.immutableSourcePaths) { immutableSourcePaths.add(immutablePath); } // When the only input was an archive selector, surface that selector instead // of the temp scratch path the resolver substituted in. const physicalScopePath = searchablePaths.length === 1 && archiveDisplayMap.get(searchPath) ? (archiveDisplayMap.get(searchPath) as string) : scope.scopePath; scopePath = internalResolution.virtualScopePath ? `${physicalScopePath}, ${internalResolution.virtualScopePath}` : physicalScopePath; } else { searchPath = this.session.cwd; scopePath = internalResolution.virtualScopePath ?? "."; globFilter = undefined; isDirectory = false; multiTargets = undefined; exactFilePaths = undefined; missingPaths = []; } if ( missingPaths.length > 0 && missingPaths.length === searchablePaths.length && virtualResources.length === 0 ) { 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 baseDisplayMode = resolveFileDisplayMode(this.session); const effectiveOutputMode = GrepOutputMode.Content; const isMultiScope = isDirectory || Boolean(exactFilePaths) || Boolean(multiTargets) || (virtualResources.length > 0 && (virtualResources.length > 1 || searchablePaths.length > 0)); const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES; // Run grep let result: GrepResult = { matches: [], totalMatches: 0, filesWithMatches: 0, filesSearched: 0, limitReached: false, }; let skippedOversizedCount = 0; try { if (searchablePaths.length > 0) { if (exactFilePaths || multiTargets) { const matches: GrepMatch[] = []; const seenMatchKeys = new Set(); 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, maxCountPerFile: perFileMatchCap + 1, signal, timeoutMs: SEARCH_GREP_TIMEOUT_MS, }, undefined, ); skippedOversizedCount += targetResult.skippedOversized ?? 0; 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); // Overlapping targets (a directory plus a file nested // inside it) surface the same physical line twice; // keep the first occurrence. const matchKey = `${absolute}\0${match.lineNumber}`; if (seenMatchKeys.has(matchKey)) { totalMatches = Math.max(0, totalMatches - 1); continue; } seenMatchKeys.add(matchKey); 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, maxCountPerFile: perFileMatchCap + 1, signal, timeoutMs: SEARCH_GREP_TIMEOUT_MS, }, undefined, ); skippedOversizedCount = result.skippedOversized ?? 0; } } } catch (err) { if (err instanceof Error && /^regex(?: parse)? error/i.test(err.message)) { throw new ToolError(err.message.replace(/^regex(?: parse)? error:?\s*/i, "Invalid regex: ")); } if (err instanceof Error && err.message.includes("Aborted: Timeout")) { throw new ToolError( `Search timed out after ${SEARCH_GREP_TIMEOUT_MS / 1000}s; narrow paths or pattern, or scope with \`find\` first`, ); } throw err; } const virtualResult = searchVirtualResources( virtualResources, normalizedPattern, ignoreCase, effectiveMultiline, normalizedContextBefore, normalizedContextAfter, INTERNAL_TOTAL_CAP, ); result = mergeGrepResults(result, virtualResult, INTERNAL_TOTAL_CAP); 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) || virtualPathSet.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(); 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; // When native grep stopped at its internal cap, files past the cap were // never surfaced — the file total is only a lower bound. const totalFilesLabel = result.limitReached ? `${totalFiles}+` : `${totalFiles}`; // 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(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 ${totalFilesLabel}. Use skip=${nextSkip} for the next page, or narrow paths/pattern.` : ""; const { record: recordFile, list: fileList } = createFileRecorder(); const fileMatchCounts = new Map(); // Detect explicit file targets that exceed the native grep size cap. // Native silently returns no matches above the cap; without this note the // caller sees "no matches" for a literal pattern that visibly exists. const oversizedNote = await (async (): Promise => { const explicitFileTargets: string[] = []; if (exactFilePaths) { explicitFileTargets.push(...exactFilePaths); } else if (searchablePaths.length > 0 && !isDirectory && !multiTargets) { explicitFileTargets.push(searchPath); } if (explicitFileTargets.length === 0) return undefined; const oversized: string[] = []; await Promise.all( explicitFileTargets.map(async target => { try { const st = await stat(target); if (st.isFile() && st.size > NATIVE_GREP_MAX_FILE_BYTES) { oversized.push(path.relative(this.session.cwd, target) || target); } } catch { // Stat failures here are surfaced by other code paths. } }), ); if (oversized.length === 0) return undefined; const limitMb = Math.floor(NATIVE_GREP_MAX_FILE_BYTES / (1024 * 1024)); return `Skipped oversized files (>${limitMb}MB grep limit; split the file or narrow with \`read\`): ${oversized.join(", ")}`; })(); // Directory/multi-target scopes: native grep counts oversized skips but // cannot name them; explicit-file scopes are covered (with names) above. const oversizedScanNote = !oversizedNote && skippedOversizedCount > 0 ? `Skipped ${skippedOversizedCount} oversized file(s) (>${Math.floor(NATIVE_GREP_MAX_FILE_BYTES / (1024 * 1024))}MB grep limit); target them directly with \`read\`` : undefined; 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, oversizedNote, oversizedScanNote] .filter((s): s is string => Boolean(s)) .join("\n") || undefined; if (selectedMatches.length === 0) { const details: SearchToolDetails = { scopePath, searchPath, cwd: this.session.cwd, matchCount: 0, fileCount: 0, files: [], truncated: false, missingPaths: missingPaths.length > 0 ? missingPaths : undefined, }; const skipPastEnd = canPaginate && normalizedSkip > 0 && totalFiles > 0 && skipFiles >= totalFiles; const noMatchText = skipPastEnd ? `No more results (${totalFilesLabel} files total; skip=${normalizedSkip} is past the end)` : "No matches found"; const text = warningNote ? `${noMatchText}\n${warningNote}` : noMatchText; return toolResult(details).text(text).done(); } const outputLines: string[] = []; let linesTruncated = false; const matchesByFile = new Map(); 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(); if (baseDisplayMode.hashLines) { for (const relativePath of fileList) { if (archiveDisplaySet.has(relativePath) || virtualPathSet.has(relativePath)) continue; const absoluteFilePath = path.resolve(this.session.cwd, relativePath); if (immutableSourcePaths.has(absoluteFilePath)) continue; // Mint a whole-file content tag so any anchor validates while the // file is unchanged; over-cap / unreadable files get no tag (and // therefore plain, non-editable line output). const tag = await recordFileSnapshot(this.session, absoluteFilePath); if (tag) hashContexts.set(relativePath, { tag }); } } 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); let lastEmittedLine: number | undefined; const gutterPad = " ".repeat(lineNumberWidth + 1); for (const match of fileMatches) { const pushLine = (lineNumber: number, line: string, isMatch: 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)); lastEmittedLine = lineNumber; }; if (match.contextBefore) { for (const ctx of match.contextBefore) { pushLine(ctx.lineNumber, ctx.line, false); } } pushLine(match.lineNumber, match.line, true); if (match.truncated) { linesTruncated = true; } if (match.contextAfter) { for (const ctx of match.contextAfter) { pushLine(ctx.lineNumber, ctx.line, false); } } fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1); } return { model: modelOut, display: displayOut }; }; const useGroupedOutput = isDirectory || isMultiScope; if (useGroupedOutput) { const grouped = formatGroupedFiles(fileList, relativePath => { const rendered = renderMatchesForFile(relativePath); const hashContext = hashContexts.get(relativePath); return { modelLines: rendered.model, displayLines: rendered.display, headerSuffix: hashContext?.tag ? `#${hashContext.tag}` : "", 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?.tag) { outputLines.push(formatHashlineHeader(relativePath, hashContext.tag)); } 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, cwd: this.session.cwd, 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; /** Line budget for the expanded view. Larger than collapsed so expanding * reveals more matches with context, but still bounded so a single hot file * whose matches span the whole file can't dump its entire length. */ const EXPANDED_TEXT_LIMIT = PREVIEW_LIMITS.EXPANDED_LINES * 2; const SEARCH_CODE_FRAME_LINE_RE = /^\s*\*?(\d+)│/; function searchScopeMeta(details: SearchToolDetails | undefined): string | undefined { if (!details?.scopePath) return undefined; const label = details.searchPath ? fileHyperlink(details.searchPath, details.scopePath) : details.scopePath; return `in ${label}`; } function linkUrlLikeSearchHeader(raw: string, styled: string): { line: string; absPath?: string } { const resolvedPath = tryResolveInternalUrlSync(raw); if (resolvedPath) return { line: fileHyperlink(resolvedPath, styled), absPath: resolvedPath }; return { line: uriHyperlink(raw, styled) }; } function parseSearchDisplayLineNumber(line: string): number | undefined { const match = SEARCH_CODE_FRAME_LINE_RE.exec(line); if (!match) return undefined; return Number.parseInt(match[1]!, 10); } const SEARCH_MATCH_LINE_RE = /^\s*\*\d+(?:│|[:|])/; interface RenderedSearchLine { raw: string; styled: string; } function isSearchMatchLine(line: string): boolean { return SEARCH_MATCH_LINE_RE.test(line); } function isSearchHeaderLine(line: string): boolean { return /^#+ /.test(line); } const URL_HEADER_PREFIX_RE = /^#+\s+/; function renderSearchDisplayLines( lines: readonly string[], headerBase: string | undefined, fileScope: string | undefined, uiTheme: Theme, ): RenderedSearchLine[] { const contexts = classifyGroupedLines(lines, headerBase, fileScope); // `classifyGroupedLines` can't resolve internal URLs (TUI-only), so track the // resolved URL target here and use it for the body lines that follow. let urlFile: string | undefined; return lines.map((line, index) => { const ctx = contexts[index]!; if (ctx.kind === "dir") { urlFile = undefined; const styled = uiTheme.fg("accent", line); return { raw: line, styled: ctx.headerPath ? fileHyperlink(ctx.headerPath, styled) : styled }; } if (ctx.kind === "file") { if (ctx.isUrl) { const raw = line .replace(URL_HEADER_PREFIX_RE, "") .trimEnd() .replace(/\s+\([^)]*\)\s*$/, ""); const linked = linkUrlLikeSearchHeader(raw, uiTheme.fg("accent", line)); urlFile = linked.absPath; return { raw: line, styled: linked.line }; } urlFile = undefined; // Root-level files keep the bright accent; nested file headers are dimmed. const styled = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", line); return { raw: line, styled: ctx.headerPath ? fileHyperlink(ctx.headerPath, styled) : styled }; } const styled = uiTheme.fg("toolOutput", line); const lineNumber = parseSearchDisplayLineNumber(line); const filePath = ctx.filePath ?? urlFile; return { raw: line, styled: filePath && lineNumber !== undefined ? fileHyperlink(filePath, styled, { line: lineNumber }) : styled, }; }); } function compactSearchPreviewGroup(group: RenderedSearchLine[]): RenderedSearchLine[] { const compact = group.filter(line => isSearchHeaderLine(line.raw) || isSearchMatchLine(line.raw)); return compact.length > 0 ? compact : group; } function countPreviewMatches(lines: readonly RenderedSearchLine[], hasMarkedMatches: boolean): number { if (hasMarkedMatches) return lines.reduce((count, line) => count + (isSearchMatchLine(line.raw) ? 1 : 0), 0); return lines.reduce((count, line) => count + (!isSearchHeaderLine(line.raw) && line.raw.length > 0 ? 1 : 0), 0); } function renderBudgetedSearchGroups( groups: RenderedSearchLine[][], maxLines: number, matchCount: number, uiTheme: Theme, compact: boolean, ): string[] { if (maxLines <= 0) return []; const renderedGroups = groups .map(group => (compact ? compactSearchPreviewGroup(group) : group)) .filter(group => group.length > 0); if (renderedGroups.length === 0) return []; let totalLines = 0; let totalMarkedMatches = 0; let totalFallbackMatches = 0; for (const group of renderedGroups) { totalLines += group.length; totalMarkedMatches += countPreviewMatches(group, true); totalFallbackMatches += countPreviewMatches(group, false); } const hasMarkedMatches = totalMarkedMatches > 0; const needsSummary = totalLines > maxLines; const contentBudget = needsSummary ? Math.max(maxLines - 1, 0) : maxLines; const visibleGroups: RenderedSearchLine[][] = []; let visibleLineCount = 0; let visibleMatches = 0; for (const group of renderedGroups) { if (visibleLineCount >= contentBudget) break; const available = contentBudget - visibleLineCount; const take = Math.min(group.length, available); if (take <= 0) break; const visibleGroup = group.slice(0, take); visibleGroups.push(visibleGroup); visibleLineCount += visibleGroup.length; visibleMatches += countPreviewMatches(visibleGroup, hasMarkedMatches); } const totalMatches = hasMarkedMatches ? totalMarkedMatches : Math.max(matchCount, totalFallbackMatches); const hiddenMatches = Math.max(totalMatches - visibleMatches, 0); const hiddenLines = Math.max(totalLines - visibleLineCount, 0); const hasSummary = needsSummary && (hiddenMatches > 0 || hiddenLines > 0); const lines: string[] = []; for (let i = 0; i < visibleGroups.length; i++) { const group = visibleGroups[i]!; const isLast = !hasSummary && i === visibleGroups.length - 1; const prefix = `${uiTheme.fg("dim", getTreeBranch(isLast, uiTheme))} `; const continuePrefix = uiTheme.fg("dim", getTreeContinuePrefix(isLast, uiTheme)); lines.push(`${prefix}${replaceTabs(group[0]!.styled)}`); for (let j = 1; j < group.length; j++) { lines.push(`${continuePrefix}${replaceTabs(group[j]!.styled)}`); } } if (hasSummary) { const hiddenLabel = hiddenMatches > 0 ? formatMoreItems(hiddenMatches, "match") : formatMoreItems(hiddenLines, "line"); lines.push(`${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", hiddenLabel)}`); } return lines; } function searchStatusIcon(uiTheme: Theme): string { return uiTheme.fg("toolTitle", uiTheme.symbol("icon.search")); } 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", titleColor: "toolTitle", description: args.pattern || "?", meta }, uiTheme, ); return new Text(text, 1, 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), 1, 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), 1, 0); } const lines = textContent.split("\n").filter(line => line.trim() !== ""); const description = args?.pattern ?? undefined; const header = renderStatusLine( { iconOverride: searchStatusIcon(uiTheme), title: "Search", titleColor: "toolTitle", 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)); }, { paddingX: 1 }, ); } 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 meta = ["0 matches"]; const scopeMeta = searchScopeMeta(details); if (scopeMeta) meta.push(scopeMeta); const header = renderStatusLine( { icon: "warning", title: "Search", titleColor: "toolTitle", description: args?.pattern, meta }, uiTheme, ); const lines = [header, formatEmptyMessage("No matches found", uiTheme)]; if (missingNote) lines.push(missingNote); return new Text(lines.join("\n"), 1, 0); } const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)]; const meta = [...summaryParts]; const scopeMeta = searchScopeMeta(details); if (scopeMeta) meta.push(scopeMeta); if (truncated) meta.push(uiTheme.fg("warning", "truncated")); const description = args?.pattern ?? undefined; const header = renderStatusLine( { ...(truncated ? { icon: "warning" as const } : { iconOverride: searchStatusIcon(uiTheme) }), title: "Search", titleColor: "toolTitle", description, meta, }, uiTheme, ); const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; const allLines = textContent.split("\n"); // Resolve hyperlinks once over the whole output so a nested directory stack // reconstructs correctly across blank-line group boundaries. // Header/match display paths are cwd-relative, so resolve them against cwd // (falling back to searchPath for legacy results that predate `cwd`); the // scoped file's absolute path seeds body lines in single-file searches. const renderedLines = renderSearchDisplayLines( allLines, details?.cwd ?? details?.searchPath, details?.searchPath, uiTheme, ); const matchGroups = groupLineIndicesByBlank(allLines).map(indices => indices.map(i => renderedLines[i]!)); const extraLines: string[] = []; if (missingNote) extraLines.push(missingNote); return createCachedComponent( () => options.expanded, width => { const budget = Math.max( (options.expanded ? EXPANDED_TEXT_LIMIT : COLLAPSED_TEXT_LIMIT) - extraLines.length, 0, ); const matchLines = renderBudgetedSearchGroups(matchGroups, budget, matchCount, uiTheme, !options.expanded); return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, { paddingX: 1 }, ); }, mergeCallAndResult: true, };