From f63bdc8e08a179960fcfd1fe982354dfdf84b8fb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 10 Apr 2026 15:38:52 -0400 Subject: [PATCH] convert list tool to Tool.defineEffect (#21899) --- packages/opencode/src/tool/ls.ts | 176 +++++++++++++++++-------------- 1 file changed, 94 insertions(+), 82 deletions(-) diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index b848e969b7..6593157906 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -1,10 +1,12 @@ import z from "zod" +import { Effect } from "effect" +import * as Stream from "effect/Stream" import { Tool } from "./tool" import * as path from "path" import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectoryEffect } from "./external-directory" export const IGNORE_PATTERNS = [ "node_modules/", @@ -35,87 +37,97 @@ export const IGNORE_PATTERNS = [ const LIMIT = 100 -export const ListTool = Tool.define("list", { - description: DESCRIPTION, - parameters: z.object({ - path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(), - ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), - }), - async execute(params, ctx) { - const searchPath = path.resolve(Instance.directory, params.path || ".") - await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) - - await ctx.ask({ - permission: "list", - patterns: [searchPath], - always: ["*"], - metadata: { - path: searchPath, - }, - }) - - const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) - const files = [] - for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) { - files.push(file) - if (files.length >= LIMIT) break - } - - // Build directory structure - const dirs = new Set() - const filesByDir = new Map() - - for (const file of files) { - const dir = path.dirname(file) - const parts = dir === "." ? [] : dir.split("/") - - // Add all parent directories - for (let i = 0; i <= parts.length; i++) { - const dirPath = i === 0 ? "." : parts.slice(0, i).join("/") - dirs.add(dirPath) - } - - // Add file to its directory - if (!filesByDir.has(dir)) filesByDir.set(dir, []) - filesByDir.get(dir)!.push(path.basename(file)) - } - - function renderDir(dirPath: string, depth: number): string { - const indent = " ".repeat(depth) - let output = "" - - if (depth > 0) { - output += `${indent}${path.basename(dirPath)}/\n` - } - - const childIndent = " ".repeat(depth + 1) - const children = Array.from(dirs) - .filter((d) => path.dirname(d) === dirPath && d !== dirPath) - .sort() - - // Render subdirectories first - for (const child of children) { - output += renderDir(child, depth + 1) - } - - // Render files - const files = filesByDir.get(dirPath) || [] - for (const file of files.sort()) { - output += `${childIndent}${file}\n` - } - - return output - } - - const output = `${searchPath}/\n` + renderDir(".", 0) +export const ListTool = Tool.defineEffect( + "list", + Effect.gen(function* () { + const rg = yield* Ripgrep.Service return { - title: path.relative(Instance.worktree, searchPath), - metadata: { - count: files.length, - truncated: files.length >= LIMIT, - }, - output, + description: DESCRIPTION, + parameters: z.object({ + path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(), + ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), + }), + execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) => + Effect.gen(function* () { + const searchPath = path.resolve(Instance.directory, params.path || ".") + yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) + + yield* Effect.promise(() => + ctx.ask({ + permission: "list", + patterns: [searchPath], + always: ["*"], + metadata: { + path: searchPath, + }, + }), + ) + + const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) + const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe( + Stream.take(LIMIT), + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ) + + // Build directory structure + const dirs = new Set() + const filesByDir = new Map() + + for (const file of files) { + const dir = path.dirname(file) + const parts = dir === "." ? [] : dir.split("/") + + // Add all parent directories + for (let i = 0; i <= parts.length; i++) { + const dirPath = i === 0 ? "." : parts.slice(0, i).join("/") + dirs.add(dirPath) + } + + // Add file to its directory + if (!filesByDir.has(dir)) filesByDir.set(dir, []) + filesByDir.get(dir)!.push(path.basename(file)) + } + + function renderDir(dirPath: string, depth: number): string { + const indent = " ".repeat(depth) + let output = "" + + if (depth > 0) { + output += `${indent}${path.basename(dirPath)}/\n` + } + + const childIndent = " ".repeat(depth + 1) + const children = Array.from(dirs) + .filter((d) => path.dirname(d) === dirPath && d !== dirPath) + .sort() + + // Render subdirectories first + for (const child of children) { + output += renderDir(child, depth + 1) + } + + // Render files + const files = filesByDir.get(dirPath) || [] + for (const file of files.sort()) { + output += `${childIndent}${file}\n` + } + + return output + } + + const output = `${searchPath}/\n` + renderDir(".", 0) + + return { + title: path.relative(Instance.worktree, searchPath), + metadata: { + count: files.length, + truncated: files.length >= LIMIT, + }, + output, + } + }).pipe(Effect.orDie, Effect.runPromise), } - }, -}) + }), +)