diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2dab4a42ad0..199133c2446 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -34,9 +34,8 @@ import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" import { BashTool } from "@/tool/bash" -import type { GlobTool } from "@/tool/glob" +import type { RgTool } from "@/tool/rg" import { TodoWriteTool } from "@/tool/todo" -import type { GrepTool } from "@/tool/grep" import type { ListTool } from "@/tool/ls" import type { EditTool } from "@/tool/edit" import type { ApplyPatchTool } from "@/tool/apply_patch" @@ -1430,15 +1429,12 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess - + - - - @@ -1719,11 +1715,21 @@ function Write(props: ToolProps) { ) } -function Glob(props: ToolProps) { +function Glob(props: ToolProps) { + const isContentSearch = !props.input.files_only + const displayValue = props.input.pattern ?? props.input.include + const matchesCount = props.metadata.matches + return ( - - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.count} matches) + + {isContentSearch ? "Grep" : "Glob"} "{displayValue}"{" "} + in {normalizePath(props.input.path)} + ({matchesCount} matches) ) } @@ -1736,15 +1742,6 @@ function Read(props: ToolProps) { ) } -function Grep(props: ToolProps) { - return ( - - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.matches} matches) - - ) -} - function List(props: ToolProps) { const dir = createMemo(() => { if (props.input.path) { diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts deleted file mode 100644 index dda57f6ee1b..00000000000 --- a/packages/opencode/src/tool/glob.ts +++ /dev/null @@ -1,77 +0,0 @@ -import z from "zod" -import path from "path" -import { Tool } from "./tool" -import DESCRIPTION from "./glob.txt" -import { Ripgrep } from "../file/ripgrep" -import { Instance } from "../project/instance" -import { assertExternalDirectory } from "./external-directory" - -export const GlobTool = Tool.define("glob", { - description: DESCRIPTION, - parameters: z.object({ - pattern: z.string().describe("The glob pattern to match files against"), - path: z - .string() - .optional() - .describe( - `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, - ), - }), - async execute(params, ctx) { - await ctx.ask({ - permission: "glob", - patterns: [params.pattern], - always: ["*"], - metadata: { - pattern: params.pattern, - path: params.path, - }, - }) - - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) - await assertExternalDirectory(ctx, search, { kind: "directory" }) - - const limit = 100 - const files = [] - let truncated = false - for await (const file of Ripgrep.files({ - cwd: search, - glob: [params.pattern], - })) { - if (files.length >= limit) { - truncated = true - break - } - const full = path.resolve(search, file) - const stats = await Bun.file(full) - .stat() - .then((x) => x.mtime.getTime()) - .catch(() => 0) - files.push({ - path: full, - mtime: stats, - }) - } - files.sort((a, b) => b.mtime - a.mtime) - - const output = [] - if (files.length === 0) output.push("No files found") - if (files.length > 0) { - output.push(...files.map((f) => f.path)) - if (truncated) { - output.push("") - output.push("(Results are truncated. Consider using a more specific path or pattern.)") - } - } - - return { - title: path.relative(Instance.worktree, search), - metadata: { - count: files.length, - truncated, - }, - output: output.join("\n"), - } - }, -}) diff --git a/packages/opencode/src/tool/glob.txt b/packages/opencode/src/tool/glob.txt deleted file mode 100644 index 627da6cae9d..00000000000 --- a/packages/opencode/src/tool/glob.txt +++ /dev/null @@ -1,6 +0,0 @@ -- Fast file pattern matching tool that works with any codebase size -- Supports glob patterns like "**/*.js" or "src/**/*.ts" -- Returns matching file paths sorted by modification time -- Use this tool when you need to find files by name patterns -- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead -- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts deleted file mode 100644 index 097dedf4aaf..00000000000 --- a/packages/opencode/src/tool/grep.ts +++ /dev/null @@ -1,154 +0,0 @@ -import z from "zod" -import { Tool } from "./tool" -import { Ripgrep } from "../file/ripgrep" - -import DESCRIPTION from "./grep.txt" -import { Instance } from "../project/instance" -import path from "path" -import { assertExternalDirectory } from "./external-directory" - -const MAX_LINE_LENGTH = 2000 - -export const GrepTool = Tool.define("grep", { - description: DESCRIPTION, - parameters: z.object({ - pattern: z.string().describe("The regex 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}")'), - }), - async execute(params, ctx) { - if (!params.pattern) { - throw new Error("pattern is required") - } - - await ctx.ask({ - permission: "grep", - patterns: [params.pattern], - always: ["*"], - metadata: { - pattern: params.pattern, - path: params.path, - include: params.include, - }, - }) - - let searchPath = params.path ?? Instance.directory - searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) - await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) - - const rgPath = await Ripgrep.filepath() - const args = [ - "-nH", - "--hidden", - "--follow", - "--no-messages", - "--field-match-separator=|", - "--regexp", - params.pattern, - ] - if (params.include) { - args.push("--glob", params.include) - } - args.push(searchPath) - - const proc = Bun.spawn([rgPath, ...args], { - stdout: "pipe", - stderr: "pipe", - }) - - const output = await new Response(proc.stdout).text() - const errorOutput = await new Response(proc.stderr).text() - const exitCode = await proc.exited - - // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) - // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc. - // Only fail if exit code is 2 AND no output was produced - if (exitCode === 1 || (exitCode === 2 && !output.trim())) { - return { - title: params.pattern, - metadata: { matches: 0, truncated: false }, - output: "No files found", - } - } - - if (exitCode !== 0 && exitCode !== 2) { - throw new Error(`ripgrep failed: ${errorOutput}`) - } - - const hasErrors = exitCode === 2 - - // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = output.trim().split(/\r?\n/) - const matches = [] - - for (const line of lines) { - if (!line) continue - - const [filePath, lineNumStr, ...lineTextParts] = line.split("|") - if (!filePath || !lineNumStr || lineTextParts.length === 0) continue - - const lineNum = parseInt(lineNumStr, 10) - const lineText = lineTextParts.join("|") - - const file = Bun.file(filePath) - const stats = await file.stat().catch(() => null) - if (!stats) continue - - matches.push({ - path: filePath, - modTime: stats.mtime.getTime(), - lineNum, - lineText, - }) - } - - matches.sort((a, b) => b.modTime - a.modTime) - - const limit = 100 - const truncated = matches.length > limit - const finalMatches = truncated ? matches.slice(0, limit) : matches - - if (finalMatches.length === 0) { - return { - title: params.pattern, - metadata: { matches: 0, truncated: false }, - output: "No files found", - } - } - - const outputLines = [`Found ${finalMatches.length} matches`] - - let currentFile = "" - for (const match of finalMatches) { - if (currentFile !== match.path) { - if (currentFile !== "") { - outputLines.push("") - } - currentFile = match.path - outputLines.push(`${match.path}:`) - } - const truncatedLineText = - match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText - outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) - } - - if (truncated) { - outputLines.push("") - outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)") - } - - if (hasErrors) { - outputLines.push("") - outputLines.push("(Some paths were inaccessible and skipped)") - } - - return { - title: params.pattern, - metadata: { - matches: finalMatches.length, - truncated, - }, - output: outputLines.join("\n"), - } - }, -}) diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt deleted file mode 100644 index adf583695ae..00000000000 --- a/packages/opencode/src/tool/grep.txt +++ /dev/null @@ -1,8 +0,0 @@ -- Fast content search tool that works with any codebase size -- Searches file contents using regular expressions -- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) -- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") -- Returns file paths and line numbers with at least one match sorted by modification time -- Use this tool when you need to find files containing specific patterns -- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. -- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ed6a385023f..8cdf8f672bd 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,8 +1,7 @@ import { QuestionTool } from "./question" import { BashTool } from "./bash" import { EditTool } from "./edit" -import { GlobTool } from "./glob" -import { GrepTool } from "./grep" +import { RgTool } from "./rg" import { BatchTool } from "./batch" import { ReadTool } from "./read" import { TaskTool } from "./task" @@ -99,8 +98,7 @@ export namespace ToolRegistry { ...(["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) ? [QuestionTool] : []), BashTool, ReadTool, - GlobTool, - GrepTool, + RgTool, EditTool, WriteTool, TaskTool, diff --git a/packages/opencode/src/tool/rg.ts b/packages/opencode/src/tool/rg.ts new file mode 100644 index 00000000000..867467c4443 --- /dev/null +++ b/packages/opencode/src/tool/rg.ts @@ -0,0 +1,222 @@ +import z from "zod" +import { Tool } from "./tool" +import { Ripgrep } from "../file/ripgrep" +import { Instance } from "../project/instance" +import path from "path" +import { assertExternalDirectory } from "./external-directory" + +const MAX_LINE_LENGTH = 2000 + +export const RgTool = Tool.define("rg", { + description: `Search file contents or list files using ripgrep. + +Two modes: +1. Content search (default): Search for pattern in file contents + Example: rg({ pattern: "function.*export", include: "*.ts" }) + +2. File listing: List files matching a glob pattern + Example: rg({ pattern: "*.ts", files_only: true }) + +Parameters: +- pattern: Regex pattern for content search, or glob pattern for file listing +- path: Directory to search (default: project root) +- include: File glob filter (e.g. "*.ts", "*.{js,jsx}") +- files_only: If true, list matching files instead of searching content`, + + parameters: z.object({ + pattern: z.string().describe("Regex pattern for content search, or glob pattern when files_only=true"), + path: z.string().optional().describe("Directory to search. Defaults to project root."), + include: z.string().optional().describe('File pattern filter (e.g. "*.ts", "*.{js,tsx}")'), + files_only: z.boolean().optional().describe("If true, list files matching pattern instead of searching content"), + }), + + async execute(params, ctx) { + if (!params.files_only && !params.pattern) { + throw new Error("pattern is required when files_only is false") + } + + if (params.files_only && !params.pattern && !params.include) { + throw new Error("files_only mode requires either 'pattern' or 'include' to specify which files to list") + } + + await ctx.ask({ + permission: params.files_only ? "glob" : "grep", + patterns: params.pattern ? [params.pattern] : [], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + include: params.include, + files_only: params.files_only, + }, + }) + + let searchPath = params.path ?? Instance.directory + searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) + await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) + + const rgPath = await Ripgrep.filepath() + const args: string[] = [] + + if (params.files_only) { + args.push("--files", "--hidden", "--follow") + const globPattern = params.include || params.pattern + if (globPattern) { + args.push("--glob", globPattern) + } + args.push(searchPath) + } else { + args.push( + "-nH", + "--hidden", + "--follow", + "--no-messages", + "--field-match-separator=|", + "--regexp", + params.pattern!, + ) + if (params.include) { + args.push("--glob", params.include) + } + args.push(searchPath) + } + + const proc = Bun.spawn([rgPath, ...args], { + stdout: "pipe", + stderr: "pipe", + }) + + const output = await new Response(proc.stdout).text() + const errorOutput = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + const title = params.pattern ?? params.include ?? "Files matching pattern" + + if (exitCode !== 0 && exitCode !== 1 && exitCode !== 2) { + const errorMsg = errorOutput.toLowerCase().includes("regex") + ? `Invalid regex pattern: ${errorOutput}` + : `ripgrep failed: ${errorOutput}` + throw new Error(errorMsg) + } + + const hasErrors = exitCode === 2 + const noMatches = exitCode === 1 || (exitCode === 2 && !output.trim()) + + if (noMatches) { + return { + title, + metadata: { matches: 0, truncated: false }, + output: "No files found", + } + } + + if (params.files_only) { + const lines = output.trim().split(/\r?\n/) + const matches = [] + + for (const line of lines) { + if (!line) continue + matches.push(line) + } + + const limit = 100 + const truncated = matches.length > limit + const finalMatches = truncated ? matches.slice(0, limit) : matches + + const outputLines = [`Found ${finalMatches.length} files`] + outputLines.push(...finalMatches) + + if (truncated) { + outputLines.push("") + outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)") + } + + if (hasErrors) { + outputLines.push("") + outputLines.push("(Some paths were inaccessible and skipped)") + } + + return { + title, + metadata: { + matches: finalMatches.length, + truncated, + }, + output: outputLines.join("\n"), + } + } + + const lines = output.trim().split(/\r?\n/) + const matches = [] + + for (const line of lines) { + if (!line) continue + + const [filePath, lineNumStr, ...lineTextParts] = line.split("|") + if (!filePath || !lineNumStr || lineTextParts.length === 0) continue + + const lineNum = parseInt(lineNumStr, 10) + const lineText = lineTextParts.join("|") + + const file = Bun.file(filePath) + const stats = await file.stat().catch(() => null) + if (!stats) continue + + matches.push({ + path: filePath, + modTime: stats.mtime.getTime(), + lineNum, + lineText, + }) + } + + matches.sort((a, b) => b.modTime - a.modTime) + + const limit = 100 + const truncated = matches.length > limit + const finalMatches = truncated ? matches.slice(0, limit) : matches + + if (finalMatches.length === 0) { + return { + title, + metadata: { matches: 0, truncated: false }, + output: "No files found", + } + } + + const outputLines = [`Found ${finalMatches.length} matches`] + + let currentFile = "" + for (const match of finalMatches) { + if (currentFile !== match.path) { + if (currentFile !== "") { + outputLines.push("") + } + currentFile = match.path + outputLines.push(`${match.path}:`) + } + const truncatedLineText = + match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText + outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) + } + + if (truncated) { + outputLines.push("") + outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)") + } + + if (hasErrors) { + outputLines.push("") + outputLines.push("(Some paths were inaccessible and skipped)") + } + + return { + title, + metadata: { + matches: finalMatches.length, + truncated, + }, + output: outputLines.join("\n"), + } + }, +}) diff --git a/packages/opencode/src/tool/rg.txt b/packages/opencode/src/tool/rg.txt new file mode 100644 index 00000000000..07350f45cdf --- /dev/null +++ b/packages/opencode/src/tool/rg.txt @@ -0,0 +1,13 @@ +Search file contents or list files using ripgrep. + +Two modes: +1. Content search (default): Search for regex pattern in file contents +2. File listing: List files matching a glob pattern + +Parameters: +- pattern: Regex pattern for content search, or glob pattern when files_only=true +- path: Directory to search (default: project root) +- include: File glob filter (e.g. "*.ts", "*.{js,jsx}") +- files_only: If true, list files matching pattern instead of searching content + +Returns matching files or file contents with line numbers and paths, truncated to 100 results. \ No newline at end of file diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/rg.test.ts similarity index 78% rename from packages/opencode/test/tool/grep.test.ts rename to packages/opencode/test/tool/rg.test.ts index a79d931575c..cb8676fafbb 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/rg.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { GrepTool } from "../../src/tool/grep" +import { RgTool } from "../../src/tool/rg" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -16,13 +16,13 @@ const ctx = { const projectRoot = path.join(__dirname, "../..") -describe("tool.grep", () => { +describe("tool.rg", () => { test("basic search", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const grep = await GrepTool.init() - const result = await grep.execute( + const rg = await RgTool.init() + const result = await rg.execute( { pattern: "export", path: path.join(projectRoot, "src/tool"), @@ -36,6 +36,25 @@ describe("tool.grep", () => { }) }) + test("file listing with files_only", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const rg = await RgTool.init() + const result = await rg.execute( + { + pattern: "*.ts", + path: path.join(projectRoot, "src/tool"), + files_only: true, + }, + ctx, + ) + expect(result.metadata.matches).toBeGreaterThan(0) + expect(result.output).toContain("Found") + }, + }) + }) + test("no matches returns correct output", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -45,8 +64,8 @@ describe("tool.grep", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const grep = await GrepTool.init() - const result = await grep.execute( + const rg = await RgTool.init() + const result = await rg.execute( { pattern: "xyznonexistentpatternxyz123", path: tmp.path, @@ -70,8 +89,8 @@ describe("tool.grep", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const grep = await GrepTool.init() - const result = await grep.execute( + const rg = await RgTool.init() + const result = await rg.execute( { pattern: "line", path: tmp.path,