import { stat } from 'fs/promises'
import { Box, Text } from 'ink'
import React from 'react'
import { z } from 'zod'
import { Cost } from '../../components/Cost'
import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage'
import { Tool } from '../../Tool'
import { getCwd } from '../../utils/state'
import {
getAbsolutePath,
getAbsoluteAndRelativePaths,
} from '../../utils/file.js'
import { ripGrep } from '../../utils/ripgrep'
import { DESCRIPTION, TOOL_NAME_FOR_PROMPT } from './prompt'
import { hasReadPermission } from '../../utils/permissions/filesystem'
const inputSchema = z.strictObject({
pattern: z
.string()
.describe('The regular expression pattern to search for in file contents'),
path: z
.string()
.optional()
.describe(
'The directory to search in. Defaults to the current working directory.',
),
include: z
.string()
.optional()
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
),
})
const MAX_RESULTS = 100
type Input = typeof inputSchema
type Output = {
durationMs: number
numFiles: number
filenames: string[]
}
export const GrepTool = {
name: TOOL_NAME_FOR_PROMPT,
async description() {
return DESCRIPTION
},
userFacingName() {
return 'Search'
},
inputSchema,
isReadOnly() {
return true
},
isConcurrencySafe() {
return true // GrepTool is read-only, safe for concurrent execution
},
async isEnabled() {
return true
},
needsPermissions({ path }) {
return !hasReadPermission(path || getCwd())
},
async prompt() {
return DESCRIPTION
},
renderToolUseMessage({ pattern, path, include }, { verbose }) {
const { absolutePath, relativePath } = getAbsoluteAndRelativePaths(path)
return `pattern: "${pattern}"${relativePath || verbose ? `, path: "${verbose ? absolutePath : relativePath}"` : ''}${include ? `, include: "${include}"` : ''}`
},
renderToolUseRejectedMessage() {
return
},
renderToolResultMessage(output) {
// Handle string content for backward compatibility
if (typeof output === 'string') {
// Convert string to Output type using tmpDeserializeOldLogResult if needed
output = output as unknown as Output
}
return (
⎿ Found
{output.numFiles}
{output.numFiles === 0 || output.numFiles > 1 ? 'files' : 'file'}
)
},
renderResultForAssistant({ numFiles, filenames }) {
if (numFiles === 0) {
return 'No files found'
}
let result = `Found ${numFiles} file${numFiles === 1 ? '' : 's'}\n${filenames.slice(0, MAX_RESULTS).join('\n')}`
if (numFiles > MAX_RESULTS) {
result +=
'\n(Results are truncated. Consider using a more specific path or pattern.)'
}
return result
},
async *call({ pattern, path, include }, { abortController }) {
const start = Date.now()
const absolutePath = getAbsolutePath(path) || getCwd()
const args = ['-li', pattern]
if (include) {
args.push('--glob', include)
}
const results = await ripGrep(args, absolutePath, abortController.signal)
const stats = await Promise.all(results.map(_ => stat(_)))
const matches = results
// Sort by modification time
.map((_, i) => [_, stats[i]!] as const)
.sort((a, b) => {
if (process.env.NODE_ENV === 'test') {
// In tests, we always want to sort by filename, so that results are deterministic
return a[0].localeCompare(b[0])
}
const timeComparison = (b[1].mtimeMs ?? 0) - (a[1].mtimeMs ?? 0)
if (timeComparison === 0) {
// Sort by filename as a tiebreaker
return a[0].localeCompare(b[0])
}
return timeComparison
})
.map(_ => _[0])
const output = {
filenames: matches,
durationMs: Date.now() - start,
numFiles: matches.length,
}
yield {
type: 'result',
resultForAssistant: this.renderResultForAssistant(output),
data: output,
}
},
} satisfies Tool