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,