diff --git a/bun.lock b/bun.lock
index 51cc671ad1e5..1579d0f1cb81 100644
--- a/bun.lock
+++ b/bun.lock
@@ -431,6 +431,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
+ "@ff-labs/fff-bun": "0.8.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
@@ -535,6 +536,16 @@
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
},
+ "optionalDependencies": {
+ "@ff-labs/fff-bin-darwin-arm64": "0.8.1",
+ "@ff-labs/fff-bin-darwin-x64": "0.8.1",
+ "@ff-labs/fff-bin-linux-arm64-gnu": "0.8.1",
+ "@ff-labs/fff-bin-linux-arm64-musl": "0.8.1",
+ "@ff-labs/fff-bin-linux-x64-gnu": "0.8.1",
+ "@ff-labs/fff-bin-linux-x64-musl": "0.8.1",
+ "@ff-labs/fff-bin-win32-arm64": "0.8.1",
+ "@ff-labs/fff-bin-win32-x64": "0.8.1",
+ },
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
@@ -1255,6 +1266,24 @@
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
+ "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.8.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0fCKe30ucz8XiGJ7HOb6lNElJDRlmbG817NIeEDPLB0oQF+Wsu8SMgS03qpg2DkCjGZvESqmqr2aRRJdR+059g=="],
+
+ "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.8.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-9eNlofr43e75x6smqY6e4kClLm+hHdKzkIAy+y3dAv52eKmAcpSlki52MNlNW5JUaXYDv91YVmeCMh7MPtNswA=="],
+
+ "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.8.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-9YMjHq2BcdCSihcOmquVJQsLw14vynwp4ZpJczlG5PrfAYoFzb/t02BEcHU9nJNLyR3qjm0TQolaV8F0OZTUsA=="],
+
+ "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.8.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-fgoy1gFb6q8PkTov0yG9AF2mOhw6ukRavL0mD3Lk0YSj2BtLpW0rgdjqoKhIlERpAFu2Eqjq283uY8abwkrssg=="],
+
+ "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.8.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MVreS6iZhFKRIJQBc6yrZw8bUbrjk3DcX7sYevHlkN1s37UYnvdTpdVO7oWqTCRUWwlTdCGIm9KrbRyscqdn3w=="],
+
+ "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.8.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9IrKHgSaDddOAX9Ye+cqZmYPnMRkwVkRW/Jlzl7KGmpambFGpJSreEQaHxyax8Dal4doJKJJuZvP4ELMGeqOSw=="],
+
+ "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.8.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-qSi74m8kbzeNw9sSzM0jFISoDHv7AjbXjnZ6GlDO9sjIxqf5Y5orKhXrgRtbppl61ygSzXnkvbMl4dXQkVx7OQ=="],
+
+ "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.8.1", "", { "os": "win32", "cpu": "x64" }, "sha512-EpOqbNNya9GmOEDG1TVXPjQg2WXTDK1r5bi7uGoFx2mXhDMCyJv5XVmuj2roovTkFKf1VPzK7YmJPAJxgyQv6g=="],
+
+ "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.8.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.8.4", "@ff-labs/fff-bin-darwin-x64": "0.8.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.8.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.8.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.8.4", "@ff-labs/fff-bin-linux-x64-musl": "0.8.4", "@ff-labs/fff-bin-win32-arm64": "0.8.4", "@ff-labs/fff-bin-win32-x64": "0.8.4" }, "peerDependencies": { "bun": ">=1.0.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "fff-demo": "examples/search.ts", "fff-grep": "examples/grep.ts" } }, "sha512-Vzoz5gCusrXHlSurft6fcADnL//hlJdVVdAseiHpMJA1vd6Kt6C/I2tIcR9BlHOv9eMt2QBGM68g1kDPSboadQ=="],
+
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@@ -1649,6 +1678,38 @@
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
+ "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="],
+
+ "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="],
+
+ "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="],
+
+ "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="],
+
+ "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="],
+
+ "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="],
+
+ "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="],
+
+ "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="],
+
+ "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="],
+
+ "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="],
+
+ "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="],
+
+ "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="],
+
+ "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="],
+
+ "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="],
+
+ "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="],
+
+ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="],
+
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.96.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lzeIEMu/v6Y+La5JSesq4hvyKtKBq84cgQpKYTYM/yGuNk2tfd5Ha31hnC+mTh48lp/5vZH+WBfjVUjjINCfug=="],
"@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.96.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i0LkJAUXb4BeBFrJQbMKQPoxf8+cFEffDyLSb7NEzzKuPcH8qrVsnEItoOzeAdYam8Sr6qCHVwmBNEQzl7PWpw=="],
@@ -2719,6 +2780,8 @@
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
+ "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="],
+
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
diff --git a/bunfig.toml b/bunfig.toml
index 47c4ac53965b..e3f06df89848 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
-minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid"]
+minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "@ff-labs/fff-node", "@ff-labs/fff-bun"]
[test]
root = "./do-not-run-tests-from-root"
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 559924ca9e6f..a0679f1a4846 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -30,6 +30,11 @@
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
},
+ "#fff": {
+ "bun": "./src/file/fff.bun.ts",
+ "node": "./src/file/fff.node.ts",
+ "default": "./src/file/fff.bun.ts"
+ },
"#pty": {
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
@@ -96,6 +101,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
+ "@ff-labs/fff-bun": "0.8.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
@@ -167,6 +173,16 @@
"yargs": "18.0.0",
"zod": "catalog:"
},
+ "optionalDependencies": {
+ "@ff-labs/fff-bin-darwin-arm64": "0.8.1",
+ "@ff-labs/fff-bin-darwin-x64": "0.8.1",
+ "@ff-labs/fff-bin-linux-arm64-gnu": "0.8.1",
+ "@ff-labs/fff-bin-linux-arm64-musl": "0.8.1",
+ "@ff-labs/fff-bin-linux-x64-gnu": "0.8.1",
+ "@ff-labs/fff-bin-linux-x64-musl": "0.8.1",
+ "@ff-labs/fff-bin-win32-arm64": "0.8.1",
+ "@ff-labs/fff-bin-win32-x64": "0.8.1"
+ },
"overrides": {
"drizzle-orm": "catalog:"
}
diff --git a/packages/opencode/script/bench-search.ts b/packages/opencode/script/bench-search.ts
new file mode 100644
index 000000000000..5c8fbf08e449
--- /dev/null
+++ b/packages/opencode/script/bench-search.ts
@@ -0,0 +1,119 @@
+import { Effect } from "effect"
+import { Fff } from "#fff"
+import { AppRuntime } from "@/effect/app-runtime"
+import { Search } from "@/file/search"
+import { InstanceStore } from "@/project/instance-store"
+
+const dir = process.cwd()
+
+const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"]
+const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"]
+const GLOB_QUERIES = ["**/*.test.ts"]
+
+const FILE_LIMIT = 100
+const GREP_LIMIT = 50
+const GLOB_LIMIT = 50
+
+const run = (effect: Effect.Effect) =>
+ AppRuntime.runPromise(
+ InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)),
+ ) as Promise
+
+// --- raw Fff picker ---
+const t0 = performance.now()
+const made = Fff.create({ basePath: dir, aiMode: true })
+if (!made.ok) {
+ console.error("Fff.create failed:", made.error)
+ process.exit(1)
+}
+const picker = made.value
+console.log(`picker create: ${(performance.now() - t0).toFixed(1)}ms`)
+
+const tw = performance.now()
+const deadline = tw + 2500
+while (picker.isScanning() && performance.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, 25))
+}
+console.log(`wait for scan (poll): ${(performance.now() - tw).toFixed(1)}ms`)
+
+// warmup grep to let the content index build
+const tWarmup = performance.now()
+picker.grep("_warmup_", { mode: "regex", maxMatchesPerFile: 1, timeBudgetMs: 1_500 })
+console.log(`grep warmup: ${(performance.now() - tWarmup).toFixed(1)}ms`)
+
+console.log()
+console.log("--- raw picker (warm) ---")
+
+for (const q of FILE_QUERIES) {
+ const t = performance.now()
+ const r = picker.fileSearch(q, { pageSize: Math.max(FILE_LIMIT, 100) })
+ const count = r.ok ? r.value.items.length : "err"
+ console.log(`[picker] fileSearch "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} results)`)
+}
+
+for (const q of GREP_QUERIES) {
+ const t = performance.now()
+ const r = picker.grep(q, { mode: "regex", pageSize: GREP_LIMIT, timeBudgetMs: 1_500 })
+ const count = r.ok ? r.value.items.length : "err"
+ console.log(`[picker] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} matches)`)
+}
+
+picker.destroy()
+
+// --- Ripgrep service (via Search with file:["."] to force rg path) ---
+console.log()
+console.log("--- Ripgrep (via Search service) ---")
+
+// warmup
+await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_rg_", limit: 1, file: ["."] })))
+
+for (const q of GREP_QUERIES) {
+ const t = performance.now()
+ const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT, file: ["."] })))
+ console.log(
+ `[ripgrep] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} total, limit is per-file not total)`,
+ )
+}
+
+// --- Search service: init breakdown ---
+console.log()
+
+// 1) runtime + InstanceState + picker create + scan poll
+const tRuntime = performance.now()
+await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: "_warmup_file_", limit: 1 })))
+console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`)
+
+// 2) grep warmup (content index cold-start inside the Search service picker)
+const tGrepWarmup = performance.now()
+await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_grep_", limit: 1 })))
+console.log(`[Search] init grep (content index warmup): ${(performance.now() - tGrepWarmup).toFixed(1)}ms`)
+
+console.log()
+console.log("--- Search service (warm) ---")
+
+for (const q of FILE_QUERIES) {
+ const t = performance.now()
+ const r = await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: q, limit: FILE_LIMIT })))
+ console.log(
+ `[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r?.length ?? "undefined (cache fallback)"} results)`,
+ )
+}
+
+for (const q of GREP_QUERIES) {
+ const t = performance.now()
+ const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT })))
+ console.log(
+ `[Search.search] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} matches, engine=${r.engine})`,
+ )
+}
+
+for (const q of GLOB_QUERIES) {
+ const t = performance.now()
+ const r = await run(Search.Service.use((svc) => svc.glob({ cwd: dir, pattern: q, limit: GLOB_LIMIT })))
+ console.log(
+ `[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.files.length} files, truncated=${r.truncated})`,
+ )
+}
+
+process.exit(0)
+
diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts
index d9bb252ea988..fc665b843b57 100644
--- a/packages/opencode/src/cli/cmd/debug/file.ts
+++ b/packages/opencode/src/cli/cmd/debug/file.ts
@@ -1,7 +1,7 @@
import { EOL } from "os"
import { Effect } from "effect"
import { File } from "../../../file"
-import { Ripgrep } from "@/file/ripgrep"
+import { Search } from "@/file/search"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
@@ -70,7 +70,7 @@ const FileTreeCommand = effectCmd({
default: process.cwd(),
}),
handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
- const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
+ const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
console.log(JSON.stringify(tree, null, 2))
}),
})
diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts
index 8d1cbd2b1eae..eb7405ed7839 100644
--- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts
+++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts
@@ -1,6 +1,6 @@
import { EOL } from "os"
import { Effect, Stream } from "effect"
-import { Ripgrep } from "../../../file/ripgrep"
+import { Search } from "../../../file/search"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
import { InstanceRef } from "@/effect/instance-ref"
@@ -22,7 +22,7 @@ const TreeCommand = effectCmd({
handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
- const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
+ const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
process.stdout.write(tree + EOL)
}),
})
@@ -47,8 +47,8 @@ const FilesCommand = effectCmd({
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
- const rg = yield* Ripgrep.Service
- const files = yield* rg
+ const search = yield* Search.Service
+ const files = yield* search
.files({
cwd: ctx.directory,
glob: args.glob ? [args.glob] : undefined,
@@ -85,7 +85,7 @@ const SearchCommand = effectCmd({
const ctx = yield* InstanceRef
if (!ctx) return
const results = yield* Effect.orDie(
- Ripgrep.Service.use((svc) =>
+ Search.Service.use((svc) =>
svc.search({
cwd: ctx.directory,
pattern: args.pattern,
diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts
index 2bef35ed075d..b901c0246ef9 100644
--- a/packages/opencode/src/effect/app-runtime.ts
+++ b/packages/opencode/src/effect/app-runtime.ts
@@ -8,7 +8,7 @@ import { Auth } from "@/auth"
import { Account } from "@/account/account"
import { Config } from "@/config/config"
import { Git } from "@/git"
-import { Ripgrep } from "@/file/ripgrep"
+import { Search } from "@/file/search"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
@@ -67,7 +67,7 @@ export const AppLayer = Layer.mergeAll(
Account.defaultLayer,
Config.defaultLayer,
Git.defaultLayer,
- Ripgrep.defaultLayer,
+ Search.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
diff --git a/packages/opencode/src/file/fff.bun.ts b/packages/opencode/src/file/fff.bun.ts
new file mode 100644
index 000000000000..e1560101d6c1
--- /dev/null
+++ b/packages/opencode/src/file/fff.bun.ts
@@ -0,0 +1,86 @@
+import {
+ FileFinder,
+ type FileItem,
+ type GrepCursor,
+ type GrepMatch,
+ type GrepResult,
+ type InitOptions,
+ type SearchResult,
+} from "@ff-labs/fff-bun"
+
+export type Result = { ok: true; value: T } | { ok: false; error: string }
+
+export type Init = InitOptions
+
+export interface Search {
+ items: FileItem[]
+ scores: SearchResult["scores"]
+ totalMatched: number
+ totalFiles: number
+}
+
+export type File = FileItem
+export type Cursor = GrepCursor | null
+export type Hit = GrepMatch
+
+export interface Grep {
+ items: GrepResult["items"]
+ totalMatched: number
+ totalFilesSearched: number
+ totalFiles: number
+ filteredFileCount: number
+ nextCursor: Cursor
+ regexFallbackError?: string
+}
+
+export interface Picker {
+ destroy(): void
+ isScanning(): boolean
+ refreshGitStatus(): Result
+ fileSearch(
+ query: string,
+ opts?: {
+ currentFile?: string
+ pageIndex?: number
+ pageSize?: number
+ },
+ ): Result
+ grep(
+ query: string,
+ opts?: {
+ mode?: "plain" | "regex" | "fuzzy"
+ maxMatchesPerFile?: number
+ timeBudgetMs?: number
+ beforeContext?: number
+ afterContext?: number
+ cursor?: Cursor
+ pageSize?: number
+ },
+ ): Result
+ trackQuery(query: string, file: string): Result
+ getHistoricalQuery(offset: number): Result
+}
+
+export function available() {
+ return FileFinder.isAvailable()
+}
+
+export function create(opts: Init): Result {
+ const made = FileFinder.create(opts)
+ if (!made.ok) return made
+ const pick = made.value
+ return {
+ ok: true,
+ value: {
+ destroy: () => pick.destroy(),
+ isScanning: () => pick.isScanning(),
+ refreshGitStatus: () => pick.refreshGitStatus(),
+ fileSearch: (query, next) => pick.fileSearch(query, next),
+ grep: (query, next) => pick.grep(query, next),
+ trackQuery: (query, file) => pick.trackQuery(query, file),
+ getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
+ },
+ }
+}
+
+export * as Fff from "./fff.bun"
diff --git a/packages/opencode/src/file/fff.node.ts b/packages/opencode/src/file/fff.node.ts
new file mode 100644
index 000000000000..82b9dd56b99f
--- /dev/null
+++ b/packages/opencode/src/file/fff.node.ts
@@ -0,0 +1,86 @@
+export type Result = { ok: true; value: T } | { ok: false; error: string }
+
+export interface Init {
+ basePath: string
+ frecencyDbPath?: string
+ historyDbPath?: string
+ useUnsafeNoLock?: boolean
+ disableMmapCache?: boolean
+ disableContentIndexing?: boolean
+ disableWatch?: boolean
+ aiMode?: boolean
+}
+
+export interface File {
+ relativePath: string
+ fileName: string
+ modified: number
+}
+
+export interface Search {
+ items: File[]
+ scores: Array<{ total: number }>
+ totalMatched: number
+ totalFiles: number
+}
+
+export type Cursor = null
+
+export interface Hit {
+ relativePath: string
+ fileName: string
+ lineNumber: number
+ byteOffset: number
+ lineContent: string
+ matchRanges: [number, number][]
+ contextBefore?: string[]
+ contextAfter?: string[]
+}
+
+export interface Grep {
+ items: Hit[]
+ totalMatched: number
+ totalFilesSearched: number
+ totalFiles: number
+ filteredFileCount: number
+ nextCursor: Cursor
+ regexFallbackError?: string
+}
+
+export interface Picker {
+ destroy(): void
+ isScanning(): boolean
+ refreshGitStatus(): Result
+ fileSearch(
+ query: string,
+ opts?: {
+ currentFile?: string
+ pageIndex?: number
+ pageSize?: number
+ },
+ ): Result
+ grep(
+ query: string,
+ opts?: {
+ mode?: "plain" | "regex" | "fuzzy"
+ maxMatchesPerFile?: number
+ timeBudgetMs?: number
+ beforeContext?: number
+ afterContext?: number
+ cursor?: Cursor
+ pageSize?: number
+ },
+ ): Result
+ trackQuery(query: string, file: string): Result
+ getHistoricalQuery(offset: number): Result
+}
+
+export function available() {
+ return false
+}
+
+export function create(_opts: Init): Result {
+ return { ok: false, error: "fff unavailable on node runtime" }
+}
+
+export * as Fff from "./fff.node"
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index 0992289fe29b..9bf864646635 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -14,7 +14,7 @@ import { Global } from "@opencode-ai/core/global"
import { containsPath } from "../project/instance-context"
import * as Log from "@opencode-ai/core/util/log"
import { Protected } from "./protected"
-import { Ripgrep } from "./ripgrep"
+import { Search } from "./search"
import { NonNegativeInt, type DeepMutable } from "@opencode-ai/core/schema"
export const Info = Schema.Struct({
@@ -333,7 +333,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service
- const rg = yield* Ripgrep.Service
+ const searchSvc = yield* Search.Service
const git = yield* Git.Service
const scope = yield* Scope.Scope
@@ -375,7 +375,7 @@ export const layer = Layer.effect(
next.dirs = Array.from(dirs).toSorted()
} else {
- const files = yield* rg.files({ cwd: ctx.directory }).pipe(
+ const files = yield* searchSvc.files({ cwd: ctx.directory }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
@@ -502,6 +502,7 @@ export const layer = Layer.effect(
using _ = log.time("read", { file })
const ctx = yield* InstanceState.context
const full = path.join(ctx.directory, file)
+ const trackOpen = searchSvc.open({ cwd: ctx.directory, file }).pipe(Effect.ignore)
if (!containsPath(full, ctx)) {
throw new Error("Access denied: path escapes project directory")
@@ -509,21 +510,23 @@ export const layer = Layer.effect(
if (isImageByExtension(file)) {
const exists = yield* appFs.existsSafe(full)
- if (exists) {
- const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
- return {
- type: "text" as const,
- content: Buffer.from(bytes).toString("base64"),
- mimeType: getImageMimeType(file),
- encoding: "base64" as const,
- }
+ if (!exists) return { type: "text" as const, content: "" }
+ yield* trackOpen
+ const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
+ return {
+ type: "text" as const,
+ content: Buffer.from(bytes).toString("base64"),
+ mimeType: getImageMimeType(file),
+ encoding: "base64" as const,
}
- return { type: "text" as const, content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
- if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
+ if (isBinaryByExtension(file) && !knownText) {
+ yield* trackOpen
+ return { type: "binary" as const, content: "" }
+ }
const exists = yield* appFs.existsSafe(full)
if (!exists) return { type: "text" as const, content: "" }
@@ -534,6 +537,7 @@ export const layer = Layer.effect(
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
if (encode) {
+ yield* trackOpen
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
return {
type: "text" as const,
@@ -554,6 +558,7 @@ export const layer = Layer.effect(
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
}
if (diff.trim()) {
+ yield* trackOpen
const original = yield* git.show(ctx.directory, "HEAD", file)
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
@@ -561,9 +566,11 @@ export const layer = Layer.effect(
})
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
}
+ yield* trackOpen
return { type: "text" as const, content }
}
+ yield* trackOpen
return { type: "text" as const, content }
})
@@ -615,14 +622,34 @@ export const layer = Layer.effect(
dirs?: boolean
type?: "file" | "directory"
}) {
- yield* ensure()
- const { cache } = yield* InstanceState.get(state)
-
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
+ yield* ensure()
+ const { cache } = yield* InstanceState.get(state)
+
+ if (query && kind === "file") {
+ const ctx = yield* InstanceState.context
+ const files = yield* searchSvc.file({
+ cwd: ctx.directory,
+ query,
+ limit,
+ }).pipe(Effect.orDie)
+ if (files === undefined) {
+ log.info("search", { query, kind, mode: "cache" })
+ } else {
+ const cacheFiles = new Set(cache.files)
+ const sorted = fuzzysort.go(query, files.filter((file) => cacheFiles.has(file)), { limit }).map((item) => item.target)
+ if (sorted.length > 0) {
+ log.info("search", { query, kind, results: sorted.length, mode: "fff" })
+ return sorted
+ }
+ log.info("search", { query, kind, mode: "cache" })
+ }
+ }
+
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
@@ -646,7 +673,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
- Layer.provide(Ripgrep.defaultLayer),
+ Layer.provide(Search.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
)
diff --git a/packages/opencode/src/file/search.ts b/packages/opencode/src/file/search.ts
new file mode 100644
index 000000000000..905df30a5402
--- /dev/null
+++ b/packages/opencode/src/file/search.ts
@@ -0,0 +1,483 @@
+import path from "path"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { Glob } from "@opencode-ai/core/util/glob"
+import { Clock, Context, Deferred, Effect, Layer, Option } from "effect"
+import type { PlatformError } from "effect/PlatformError"
+import * as Stream from "effect/Stream"
+import * as InstanceState from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
+import { Global } from "@opencode-ai/core/global"
+import * as Log from "@opencode-ai/core/util/log"
+import { Fff } from "#fff"
+import { Ripgrep } from "./ripgrep"
+
+const log = Log.create({ service: "file.search" })
+const root = path.join(Global.Path.cache, "fff")
+
+export type Item = Ripgrep.Item
+export type SearchError = PlatformError | globalThis.Error
+
+export interface Result {
+ readonly items: Item[]
+ readonly partial: boolean
+ readonly hasNextPage: boolean
+ readonly engine: "fff" | "ripgrep"
+ readonly regexFallbackError?: string
+}
+
+export interface FileInput {
+ readonly cwd: string
+ readonly query: string
+ readonly limit?: number
+ readonly current?: string
+}
+
+export interface GlobInput {
+ readonly cwd: string
+ readonly pattern: string
+ readonly limit?: number
+ readonly signal?: AbortSignal
+}
+
+interface Query {
+ readonly dir: string
+ readonly text: string
+ readonly files: string[]
+}
+
+interface State {
+ readonly pick: Map
+ readonly wait: Map>
+ readonly recent: Query[]
+}
+
+export interface Interface {
+ readonly files: Ripgrep.Interface["files"]
+ readonly tree: Ripgrep.Interface["tree"]
+ readonly search: (input: Ripgrep.SearchInput) => Effect.Effect
+ readonly file: (input: FileInput) => Effect.Effect
+ readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError>
+ readonly open: (input: { cwd?: string; file: string }) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@opencode/Search") {}
+
+function key(dir: string) {
+ return Buffer.from(dir).toString("base64url")
+}
+
+function fffSync(action: string, run: () => A) {
+ return Effect.try({
+ try: run,
+ catch: (cause) => new Error(`fff ${action} failed`, { cause }),
+ })
+}
+
+function normalize(text: string) {
+ return text.replaceAll("\\", "/")
+}
+
+function blocked(rel: string) {
+ return normalize(rel).split("/").includes(".git")
+}
+
+function basename(file: string) {
+ return normalize(file).split("/").at(-1) ?? file
+}
+
+function allow(glob: string[] | undefined, rel: string, file: string) {
+ if (!glob?.length) return true
+ const include = glob.filter((item) => !item.startsWith("!"))
+ const exclude = glob.filter((item) => item.startsWith("!")).map((item) => item.slice(1))
+ if (include.length > 0 && !include.some((item) => Glob.match(item, rel) || Glob.match(item, file))) return false
+ if (exclude.some((item) => Glob.match(item, rel) || Glob.match(item, file))) return false
+ return true
+}
+
+function include(pattern: string) {
+ const value = pattern.trim().replaceAll("\\", "/")
+ if (!value) return "*"
+ const flat = value.replaceAll("**/", "").replaceAll("/**", "/")
+ const idx = flat.lastIndexOf("/")
+ if (idx < 0) return flat
+ const dir = flat.slice(0, idx + 1)
+ const glob = flat.slice(idx + 1)
+ if (!glob) return dir
+ return `${dir} ${glob}`
+}
+
+// fff supports glob narrowing for any search out of the box
+function fffGlobbedQuery(query: string, glob?: string | string[]) {
+ if (query && glob) {
+ const resolvedGlob = Array.isArray(glob) ? glob.join(" ") : glob
+ return `${resolvedGlob} ${query}`
+ }
+
+ return query ?? glob
+}
+
+function remember(state: State, dir: string, text: string, files: string[]) {
+ if (!files.length) return
+ const next = Array.from(new Set(files.map(AppFileSystem.resolve))).slice(0, 64)
+ if (!next.length) return
+ const idx = state.recent.findIndex((item) => item.dir === dir && item.text === text)
+ if (idx >= 0) state.recent.splice(idx, 1)
+ state.recent.unshift({ dir, text, files: next })
+ if (state.recent.length > 32) state.recent.length = 32
+}
+
+function item(hit: Fff.Hit): Item {
+ const line = Buffer.from(hit.lineContent)
+ return {
+ path: { text: normalize(hit.relativePath) },
+ lines: { text: hit.lineContent },
+ line_number: hit.lineNumber,
+ absolute_offset: hit.byteOffset,
+ submatches: hit.matchRanges
+ .map(([start, end]) => {
+ const text = line.subarray(start, end).toString("utf8")
+ if (!text) return undefined
+ return {
+ match: { text },
+ start,
+ end,
+ }
+ })
+ .filter((row): row is Item["submatches"][number] => Boolean(row)),
+ }
+}
+
+export const layer: Layer.Layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const rg = yield* Ripgrep.Service
+ const state = yield* InstanceState.make(
+ Effect.fn("Search.state")(() =>
+ Effect.gen(function* () {
+ const next = {
+ pick: new Map(),
+ wait: new Map>(),
+ recent: [] as Query[],
+ }
+ yield* Effect.addFinalizer(() =>
+ Effect.forEach(next.pick.values(), (pick) => fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore), {
+ discard: true,
+ }),
+ )
+ return next
+ }),
+ ),
+ )
+
+ const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
+ const out = yield* rg.search(input)
+ return {
+ items: out.items,
+ partial: out.partial,
+ hasNextPage: false,
+ engine: "ripgrep" as const,
+ }
+ })
+
+ const picker = Effect.fn("Search.picker")(function* (cwd: string) {
+ const available = yield* fffSync("check availability", () => Fff.available()).pipe(
+ Effect.catch((error) => {
+ log.warn("fff availability check failed", { error })
+ return Effect.succeed(false)
+ }),
+ )
+ if (!available) return undefined
+
+ const dir = AppFileSystem.resolve(cwd)
+ const current = yield* InstanceState.get(state)
+ const existing = current.pick.get(dir)
+ if (existing) return existing
+
+ const pending = current.wait.get(dir)
+ if (pending) return yield* Deferred.await(pending)
+
+ const gate = yield* Deferred.make()
+ current.wait.set(dir, gate)
+ return yield* Effect.gen(function* () {
+ yield* fs.ensureDir(root)
+ const id = key(dir)
+ const made = yield* fffSync("create picker", () =>
+ Fff.create({
+ basePath: dir,
+ frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
+ historyDbPath: path.join(root, `${id}.history.mdb`),
+ aiMode: true,
+ }),
+ )
+ if (!made.ok) {
+ log.warn("fff init failed", { dir, error: made.error })
+ const err = new Error(made.error)
+ yield* Deferred.fail(gate, err)
+ return yield* Effect.fail(err)
+ }
+
+ const pick = made.value
+
+ const ready = yield* Effect.gen(function* () {
+ const start = yield* Clock.currentTimeMillis
+ while (true) {
+ if (!(yield* fffSync("check scan status", () => pick.isScanning()))) return true
+ const now = yield* Clock.currentTimeMillis
+ if (now - start >= 5_000) return false
+ yield* Effect.sleep("25 millis")
+ }
+ })
+
+ if (!ready) {
+ yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
+ const err = new Error("fff scan timed out")
+ log.warn("fff scan timed out", { dir })
+ yield* Deferred.fail(gate, err)
+ return yield* Effect.fail(err)
+ }
+
+ const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
+ if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error })
+
+ current.pick.set(dir, pick)
+ yield* Deferred.succeed(gate, pick)
+ return pick
+ }).pipe(
+ Effect.ensuring(
+ Effect.gen(function* () {
+ if (current.wait.get(dir) === gate) current.wait.delete(dir)
+ yield* Deferred.fail(gate, new Error("fff init interrupted")).pipe(Effect.ignore)
+ }),
+ ),
+ )
+ })
+
+ const files: Interface["files"] = (input) => rg.files(input)
+ const tree: Interface["tree"] = (input) => rg.tree(input)
+
+ const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
+ const query = input.query.trim()
+ if (!query) return []
+
+ const pick = yield* picker(input.cwd).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ if (!pick) return undefined
+
+ const dir = AppFileSystem.resolve(input.cwd)
+ const out = yield* fffSync("file search", () =>
+ pick.fileSearch(query, {
+ pageIndex: 0,
+ currentFile: input.current, // supports both relative and absolute (relative preferred)
+ pageSize: Math.max(input.limit ?? 100, 100),
+ }),
+ ).pipe(
+ Effect.catch((error) => {
+ log.warn("fff file search failed", { dir, query, error })
+ return Effect.succeed | undefined>(undefined)
+ }),
+ )
+ if (!out) return undefined
+ if (!out.ok) {
+ log.warn("fff file search failed", { dir, query, error: out.error })
+ return undefined
+ }
+
+ const rows: string[] = Array.from(
+ new Set(
+ out.value.items.flatMap((item, idx): string[] => {
+ const score = out.value.scores[idx]
+ if (!score || score.total <= 0) return []
+ return [normalize(item.relativePath)]
+ }),
+ ),
+ )
+ const current = yield* InstanceState.get(state)
+ remember(
+ current,
+ dir,
+ query,
+ rows.map((row) => path.join(dir, row)),
+ )
+ return rows.slice(0, input.limit ?? 100)
+ })
+
+ const search: Interface["search"] = Effect.fn("Search.search")(function* (input) {
+ input.signal?.throwIfAborted()
+ if (input.file?.length) return yield* rip(input)
+
+ const pick = yield* picker(input.cwd).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ if (!pick) return yield* rip(input)
+
+ const dir = AppFileSystem.resolve(input.cwd)
+ const limit = input.limit ?? 100
+
+ const out = yield* fffSync("grep", () =>
+ pick.grep(fffGlobbedQuery(input.pattern, input.glob), {
+ mode: "regex",
+ pageSize: limit,
+ timeBudgetMs: 1_500,
+ }),
+ ).pipe(
+ Effect.catch((error) => {
+ log.warn("fff grep failed", { dir, pattern: input.pattern, error })
+ return Effect.succeed | undefined>(undefined)
+ }),
+ )
+ if (!out) return yield* rip(input)
+ if (!out.ok) {
+ log.warn("fff grep failed", { dir, pattern: input.pattern, error: out.error })
+ return yield* rip(input)
+ }
+
+ const rows: Item[] = out.value.items
+ .filter((hit) => allow(input.glob, normalize(hit.relativePath), normalize(hit.fileName)))
+ .map(item)
+ const regexFallbackError = out.value.regexFallbackError
+
+ if (!rows.length && input.glob?.length) return yield* rip(input)
+
+ const current = yield* InstanceState.get(state)
+ remember(current, dir, input.pattern, Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))))
+
+ return {
+ items: rows,
+ partial: false,
+ hasNextPage: !!out.value.nextCursor,
+ engine: "fff" as const,
+ regexFallbackError,
+ }
+ })
+
+ const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
+ input.signal?.throwIfAborted()
+
+ const dir = AppFileSystem.resolve(input.cwd)
+ const limit = input.limit ?? 100
+ const pick = yield* picker(dir).pipe(Effect.catch(() => Effect.succeed(undefined)))
+
+ if (pick) {
+ const out = yield* fffSync("glob file search", () =>
+ pick.fileSearch(include(input.pattern), {
+ pageIndex: 0,
+ pageSize: Math.max(limit * 4, 200),
+ }),
+ ).pipe(
+ Effect.catch((error) => {
+ log.warn("fff glob failed", { dir, pattern: input.pattern, error })
+ return Effect.succeed | undefined>(undefined)
+ }),
+ )
+
+ if (out?.ok) {
+ const rows: string[] = Array.from(
+ new Set(
+ out.value.items
+ .map((item) => normalize(item.relativePath))
+ .filter((file) => !blocked(file))
+ .filter((file) => Glob.match(input.pattern, file) || Glob.match(input.pattern, basename(file))),
+ ),
+ )
+
+ if (rows.length > 0) {
+ const current = yield* InstanceState.get(state)
+ remember(
+ current,
+ dir,
+ input.pattern,
+ rows.map((row) => path.join(dir, row)),
+ )
+
+ return {
+ files: rows.slice(0, limit).map((row) => path.join(dir, row)),
+ truncated: rows.length > limit,
+ }
+ }
+ } else if (out) {
+ log.warn("fff glob failed", { dir, pattern: input.pattern, error: out.error })
+ }
+ }
+
+ const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe(
+ Stream.take(limit + 1),
+ Stream.runCollect,
+ Effect.map((chunk) => [...chunk]),
+ )
+ const truncated = rows.length > limit
+ if (truncated) rows.length = limit
+
+ const output = yield* Effect.forEach(
+ rows,
+ Effect.fnUntraced(function* (file) {
+ const full = path.join(dir, file)
+ const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ const time =
+ info?.mtime.pipe(
+ Option.map((item) => item.getTime()),
+ Option.getOrElse(() => 0),
+ ) ?? 0
+ return { file: full, time }
+ }),
+ { concurrency: 16 },
+ )
+ output.sort((a, b) => b.time - a.time)
+ return {
+ files: output.map((item) => item.file),
+ truncated,
+ }
+ })
+
+ const open: Interface["open"] = Effect.fn("Search.open")(function* (input) {
+ const current = yield* InstanceState.get(state)
+ const file = input.cwd
+ ? AppFileSystem.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
+ : AppFileSystem.resolve(input.file)
+ const idx = current.recent.findIndex((item) => item.files.includes(file))
+ if (idx < 0) return
+
+ const row = current.recent[idx]
+ current.recent.splice(idx, 1)
+ const pick = current.pick.get(row.dir)
+ if (!pick) return
+
+ const out = yield* fffSync("track query", () => pick.trackQuery(row.text, file)).pipe(
+ Effect.catch((error) => {
+ log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error })
+ return Effect.succeed | undefined>(undefined)
+ }),
+ )
+ if (!out) return
+ if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
+ })
+
+ return Service.of({ files, tree, search, file, glob, open })
+ }),
+)
+
+export const defaultLayer: Layer.Layer = layer.pipe(
+ Layer.provide(Ripgrep.defaultLayer),
+ Layer.provide(AppFileSystem.defaultLayer),
+)
+
+const { runPromise } = makeRuntime(Service, defaultLayer)
+
+export function tree(input: Ripgrep.TreeInput) {
+ return runPromise((svc) => svc.tree(input))
+}
+
+export function search(input: Ripgrep.SearchInput) {
+ return runPromise((svc) => svc.search(input))
+}
+
+export function file(input: FileInput) {
+ return runPromise((svc) => svc.file(input))
+}
+
+export function glob(input: GlobInput) {
+ return runPromise((svc) => svc.glob(input))
+}
+
+export function open(input: { cwd?: string; file: string }) {
+ return runPromise((svc) => svc.open(input))
+}
+
+export * as Search from "./search"
diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts
index ce58331ea328..6601accb4038 100644
--- a/packages/opencode/src/tool/glob.ts
+++ b/packages/opencode/src/tool/glob.ts
@@ -1,9 +1,8 @@
import path from "path"
-import { Effect, Option, Schema } from "effect"
-import * as Stream from "effect/Stream"
+import { Effect, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
-import { Ripgrep } from "../file/ripgrep"
+import { Search } from "../file/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt"
import * as Tool from "./tool"
@@ -19,9 +18,9 @@ export const Parameters = Schema.Struct({
export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
- const rg = yield* Ripgrep.Service
const fs = yield* AppFileSystem.Service
const reference = yield* Reference.Service
+ const searchSvc = yield* Search.Service
return {
description: DESCRIPTION,
@@ -52,36 +51,18 @@ export const GlobTool = Tool.define(
})
const limit = 100
- let truncated = false
- const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe(
- Stream.mapEffect((file) =>
- Effect.gen(function* () {
- const full = path.resolve(search, file)
- const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
- const mtime =
- info?.mtime.pipe(
- Option.map((date) => date.getTime()),
- Option.getOrElse(() => 0),
- ) ?? 0
- return { path: full, mtime }
- }),
- ),
- Stream.take(limit + 1),
- Stream.runCollect,
- Effect.map((chunk) => [...chunk]),
- )
-
- if (files.length > limit) {
- truncated = true
- files.length = limit
- }
- files.sort((a, b) => b.mtime - a.mtime)
+ const files = yield* searchSvc.glob({
+ cwd: search,
+ pattern: params.pattern,
+ limit,
+ signal: ctx.abort,
+ })
const output = []
- if (files.length === 0) output.push("No files found")
- if (files.length > 0) {
- output.push(...files.map((file) => file.path))
- if (truncated) {
+ if (files.files.length === 0) output.push("No files found")
+ if (files.files.length > 0) {
+ output.push(...files.files)
+ if (files.truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
@@ -92,8 +73,8 @@ export const GlobTool = Tool.define(
return {
title: path.relative(ins.worktree, search),
metadata: {
- count: files.length,
- truncated,
+ count: files.files.length,
+ truncated: files.truncated,
},
output: output.join("\n"),
}
diff --git a/packages/opencode/src/tool/glob.txt b/packages/opencode/src/tool/glob.txt
index 627da6cae9d7..9c01f3d50f9a 100644
--- a/packages/opencode/src/tool/glob.txt
+++ b/packages/opencode/src/tool/glob.txt
@@ -1,6 +1,6 @@
- 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
+- Returns matching file paths
- 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
index 01aa6a0b72b4..84a08f3c7648 100644
--- a/packages/opencode/src/tool/grep.ts
+++ b/packages/opencode/src/tool/grep.ts
@@ -1,9 +1,8 @@
import path from "path"
-import { Schema } from "effect"
-import { Effect, Option } from "effect"
+import { Effect, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
-import { Ripgrep } from "../file/ripgrep"
+import { Search } from "../file/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./grep.txt"
import * as Tool from "./tool"
@@ -25,7 +24,7 @@ export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
- const rg = yield* Ripgrep.Service
+ const searchSvc = yield* Search.Service
const reference = yield* Reference.Service
return {
@@ -69,7 +68,7 @@ export const GrepTool = Tool.define(
const cwd = info?.type === "Directory" ? search : path.dirname(search)
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
- const result = yield* rg.search({
+ const result = yield* searchSvc.search({
cwd,
pattern: params.pattern,
glob: params.include ? [params.include] : undefined,
@@ -85,38 +84,15 @@ export const GrepTool = Tool.define(
line: item.line_number,
text: item.lines.text,
}))
- const times = new Map(
- (yield* Effect.forEach(
- [...new Set(rows.map((row) => row.path))],
- Effect.fnUntraced(function* (file) {
- const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
- if (!info || info.type === "Directory") return undefined
- return [
- file,
- info.mtime.pipe(
- Option.map((time) => time.getTime()),
- Option.getOrElse(() => 0),
- ) ?? 0,
- ] as const
- }),
- { concurrency: 16 },
- )).filter((entry): entry is readonly [string, number] => Boolean(entry)),
- )
- const matches = rows.flatMap((row) => {
- const mtime = times.get(row.path)
- if (mtime === undefined) return []
- return [{ ...row, mtime }]
- })
-
- matches.sort((a, b) => b.mtime - a.mtime)
const limit = 100
- const truncated = matches.length > limit
- const final = truncated ? matches.slice(0, limit) : matches
+ const truncated = rows.length > limit
+ const final = truncated ? rows.slice(0, limit) : rows
if (final.length === 0) return empty
- const total = matches.length
- const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
+ const total = rows.length
+ const hasMore = truncated || result.hasNextPage
+ const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
let current = ""
for (const match of final) {
@@ -137,11 +113,23 @@ export const GrepTool = Tool.define(
)
}
+ if (result.hasNextPage) {
+ output.push("")
+ output.push(
+ `(Results truncated. Consider using a more specific path or pattern.)`,
+ )
+ }
+
if (result.partial) {
output.push("")
output.push("(Some paths were inaccessible and skipped)")
}
+ if (result.regexFallbackError) {
+ output.push("")
+ output.push(`(Regex fallback: ${result.regexFallbackError})`)
+ }
+
return {
title: params.pattern,
metadata: {
diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt
index adf583695aef..c075da1e6b46 100644
--- a/packages/opencode/src/tool/grep.txt
+++ b/packages/opencode/src/tool/grep.txt
@@ -2,7 +2,7 @@
- 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
+- Returns file paths and line numbers with matching lines
- 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/read.ts b/packages/opencode/src/tool/read.ts
index 33bff77b9f37..ba304a601d8c 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
import { InstanceState } from "@/effect/instance-state"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
+import { Search } from "../file/search"
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
import { Reference } from "@/reference/reference"
@@ -43,6 +44,7 @@ export const ReadTool = Tool.define(
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const reference = yield* Reference.Service
+ const search = yield* Search.Service
const scope = yield* Scope.Scope
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
@@ -87,6 +89,7 @@ export const ReadTool = Tool.define(
})
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
+ yield* search.open({ file: filepath }).pipe(Effect.ignore)
yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope))
})
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 6ef6d39a65a5..5374ff84de9c 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -37,7 +37,7 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Ripgrep } from "../file/ripgrep"
+import { Search } from "../file/search"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
@@ -103,7 +103,7 @@ export const layer: Layer.Layer<
| Bus.Service
| HttpClient.HttpClient
| ChildProcessSpawner
- | Ripgrep.Service
+ | Search.Service
| Format.Service
| Truncate.Service
| RuntimeFlags.Service
@@ -396,7 +396,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
- Layer.provide(Ripgrep.defaultLayer),
+ Layer.provide(Search.defaultLayer),
Layer.provide(Truncate.defaultLayer),
)
.pipe(Layer.provide(RuntimeFlags.defaultLayer)),
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index 8730f0278920..3b7a2c5e2cf3 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -2,7 +2,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
-import { Ripgrep } from "../file/ripgrep"
+import { Search } from "../file/search"
import { Skill } from "../skill"
import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt"
@@ -15,7 +15,7 @@ export const SkillTool = Tool.define(
"skill",
Effect.gen(function* () {
const skill = yield* Skill.Service
- const rg = yield* Ripgrep.Service
+ const searchSvc = yield* Search.Service
return {
description: DESCRIPTION,
@@ -36,7 +36,7 @@ export const SkillTool = Tool.define(
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
- const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
+ const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts
index b7d531c63d29..f997fd753ae0 100644
--- a/packages/opencode/test/file/index.test.ts
+++ b/packages/opencode/test/file/index.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { $ } from "bun"
import { Cause, Effect, Exit, Layer } from "effect"
+import { setTimeout as sleep } from "node:timers/promises"
import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
@@ -756,6 +757,8 @@ describe("file/index Filesystem patterns", () => {
expect(yield* search({ query: "fresh", type: "file" })).toEqual([])
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8"))
+ // fff guarantees eventual search consistency within 100ms after FS change
+ yield* Effect.promise(() => sleep(100))
expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts")
}),
diff --git a/packages/opencode/test/file/search.test.ts b/packages/opencode/test/file/search.test.ts
new file mode 100644
index 000000000000..36a880a89c7c
--- /dev/null
+++ b/packages/opencode/test/file/search.test.ts
@@ -0,0 +1,154 @@
+import { afterEach, describe, expect } from "bun:test"
+import path from "path"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { Effect, Layer } from "effect"
+import { Fff } from "#fff"
+import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { Search } from "../../src/file/search"
+import { Global } from "@opencode-ai/core/global"
+import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+afterEach(async () => {
+ await disposeAllInstances()
+})
+
+const it = testEffect(Layer.mergeAll(Search.defaultLayer, CrossSpawnSpawner.defaultLayer))
+
+function db(dir: string) {
+ const id = Buffer.from(AppFileSystem.resolve(dir)).toString("base64url")
+ return {
+ frecency: path.join(Global.Path.cache, "fff", `${id}.frecency.mdb`),
+ history: path.join(Global.Path.cache, "fff", `${id}.history.mdb`),
+ }
+}
+
+describe("file.search", () => {
+ it.live("uses fff for Bun-backed grep", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ expect(Fff.available()).toBe(true)
+ yield* Effect.promise(() => Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n"))
+
+ const search = yield* Search.Service
+ const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
+
+ expect(result.engine).toBe("fff")
+ expect(result.items).toHaveLength(1)
+ expect(result.items[0]?.path.text).toBe("src/match.ts")
+ }),
+ ),
+ )
+
+ it.live("keeps fuzzy file abbreviation matches", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ expect(Fff.available()).toBe(true)
+ yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "hello\n"))
+
+ const search = yield* Search.Service
+ const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
+
+ expect(results).toContain("README.md")
+ }),
+ ),
+ )
+
+ it.live("keeps paging grep results without an explicit limit", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ expect(Fff.available()).toBe(true)
+ yield* Effect.promise(() =>
+ Bun.write(
+ path.join(dir, "matches.txt"),
+ Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""),
+ ),
+ )
+
+ const search = yield* Search.Service
+ const result = yield* search.search({ cwd: dir, pattern: "needle" })
+
+ expect(result.items).toHaveLength(150)
+ }),
+ ),
+ )
+
+ it.live("uses byte ranges for UTF-8 grep submatches", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ expect(Fff.available()).toBe(true)
+ yield* Effect.promise(() => Bun.write(path.join(dir, "unicode.txt"), "éneedle\n"))
+
+ const search = yield* Search.Service
+ const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
+
+ expect(result.items[0]?.submatches[0]?.match.text).toBe("needle")
+ }),
+ ),
+ )
+
+ it.live("post-filters fff grep include matches", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ expect(Fff.available()).toBe(true)
+ yield* Effect.promise(() => Bun.write(path.join(dir, "src", "match.ts"), "needle\n"))
+ yield* Effect.promise(() => Bun.write(path.join(dir, "src", "match.txt"), "needle\n"))
+
+ const search = yield* Search.Service
+ const result = yield* search.search({ cwd: dir, pattern: "needle", glob: ["*.ts"], limit: 10 })
+
+ expect(result.engine).toBe("fff")
+ expect(result.items.map((item) => item.path.text)).toEqual(["src/match.ts"])
+ }),
+ ),
+ )
+
+ it.live("post-filters fff glob matches", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ expect(Fff.available()).toBe(true)
+ yield* Effect.promise(() => Bun.write(path.join(dir, "src", "match.ts"), "export const value = 1\n"))
+ yield* Effect.promise(() => Bun.write(path.join(dir, "src", "match.txt"), "hello\n"))
+
+ const search = yield* Search.Service
+ const result = yield* search.glob({ cwd: dir, pattern: "**/*.ts", limit: 10 })
+
+ expect(result.files).toEqual([path.join(dir, "src", "match.ts")])
+ }),
+ ),
+ )
+
+ it.live("records query history when a searched file is opened", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ expect(Fff.available()).toBe(true)
+ yield* Effect.promise(() => Bun.write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n"))
+ yield* Effect.promise(() => Bun.write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n"))
+
+ const search = yield* Search.Service
+ const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
+
+ expect(results).toContain("alpha-target-two.ts")
+
+ yield* search.open({ cwd: dir, file: "alpha-target-two.ts" })
+ yield* Effect.promise(() => disposeAllInstances())
+
+ const picker = Fff.create({
+ basePath: dir,
+ frecencyDbPath: db(dir).frecency,
+ historyDbPath: db(dir).history,
+ aiMode: true,
+ })
+ expect(picker.ok).toBe(true)
+ if (!picker.ok) return
+
+ const history = picker.value.getHistoricalQuery(0)
+ picker.value.destroy()
+
+ expect(history.ok).toBe(true)
+ if (!history.ok) return
+ expect(history.value).toBe("alpha target two")
+ }),
+ ),
+ )
+})
diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts
index b2403b9fb2ba..b837ea372be1 100644
--- a/packages/opencode/test/server/httpapi-file.test.ts
+++ b/packages/opencode/test/server/httpapi-file.test.ts
@@ -64,6 +64,7 @@ describe("file HttpApi", () => {
request(FilePaths.findSymbol, tmp.path, { query: "hello" }),
])
+ console.log(files);
expect(text.status).toBe(200)
expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 }))
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 4c4647457814..e857f4eca0af 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -45,7 +45,7 @@ import { Truncate } from "@/tool/truncate"
import * as Log from "@opencode-ai/core/util/log"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import * as Database from "../../src/storage/db"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Search } from "../../src/file/search"
import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
@@ -193,7 +193,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer),
- Layer.provide(Ripgrep.defaultLayer),
+ Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provideMerge(todo),
diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts
index 89ed11613e15..8f04a84d7600 100644
--- a/packages/opencode/test/session/snapshot-tool-race.test.ts
+++ b/packages/opencode/test/session/snapshot-tool-race.test.ts
@@ -56,7 +56,7 @@ import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Search } from "../../src/file/search"
import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
@@ -142,7 +142,7 @@ function makeHttp() {
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer),
- Layer.provide(Ripgrep.defaultLayer),
+ Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provideMerge(todo),
diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts
index bfe9b75d4826..bac58a2b07d4 100644
--- a/packages/opencode/test/tool/glob.test.ts
+++ b/packages/opencode/test/tool/glob.test.ts
@@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
import { GlobTool } from "../../src/tool/glob"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Search } from "../../src/file/search"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Truncate } from "@/tool/truncate"
@@ -30,7 +30,7 @@ const toolLayer = (flags: Partial = {}) =>
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
- Ripgrep.defaultLayer,
+ Search.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
Git.defaultLayer,
diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts
index 027d5201cb16..ace02dc816a0 100644
--- a/packages/opencode/test/tool/grep.test.ts
+++ b/packages/opencode/test/tool/grep.test.ts
@@ -10,7 +10,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Search } from "../../src/file/search"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { testEffect } from "../lib/effect"
import { Reference } from "@/reference/reference"
@@ -33,7 +33,7 @@ const toolLayer = (flags: Partial = {}) =>
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
- Ripgrep.defaultLayer,
+ Search.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
Git.defaultLayer,
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index f8c656ccfb7a..38d5f70b057a 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -7,6 +7,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
+import { Search } from "../../src/file/search"
import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -52,6 +53,7 @@ const readLayer = (flags: Partial = {}) =>
Instruction.defaultLayer,
LSP.defaultLayer,
referenceLayer(flags),
+ Search.defaultLayer,
Truncate.defaultLayer,
)
diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts
index d3549e66f340..1eaebfcd9edd 100644
--- a/packages/opencode/test/tool/registry.test.ts
+++ b/packages/opencode/test/tool/registry.test.ts
@@ -25,7 +25,7 @@ import { Instruction } from "@/session/instruction"
import { Bus } from "@/bus"
import { FetchHttpClient } from "effect/unstable/http"
import { Format } from "@/format"
-import { Ripgrep } from "@/file/ripgrep"
+import { Search } from "@/file/search"
import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
import { Reference } from "@/reference/reference"
@@ -66,7 +66,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) =>
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(node),
- Layer.provide(Ripgrep.defaultLayer),
+ Layer.provide(Search.defaultLayer),
Layer.provide(Truncate.defaultLayer),
)
.pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))