refactor(packages/coding-agent): migrated search args to paths arrays
- Switched search/ast_grep/ast_edit/find inputs from scalar path fields to required `paths` arrays. - Reworked path resolution to normalize and expand each `paths` entry via explicit helpers in `src/tools/path-utils.ts`. - Updated search and find tool-call rendering to display explicit `paths` values in mode overlays and export views. - Updated tool prompt docs and examples to document `paths` array inputs for search, grep, find, and AST tools. - Raised the default search match limit from 20 to 500 and updated limit-reached messaging.
This commit is contained in:
@@ -180,7 +180,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
|
||||
const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
|
||||
const toolResultMessage = await executeTool(this.options, "search", toolCallId, {
|
||||
pattern: args.pattern,
|
||||
path: searchPath,
|
||||
paths: [searchPath],
|
||||
i: args.caseInsensitive || undefined,
|
||||
});
|
||||
return toolResultMessage;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -416,9 +416,9 @@
|
||||
return `[bash: ${cmd}${rawCmd.length > 50 ? '...' : ''}]`;
|
||||
}
|
||||
case 'grep':
|
||||
return `[grep: /${args.pattern || ''}/ in ${shortenPath(String(args.path || '.'))}]`;
|
||||
return `[grep: /${args.pattern || ''}/ in ${shortenPath(String((args.paths || [args.path || '.']).join(', ')))}]`;
|
||||
case 'find':
|
||||
return `[find: ${args.pattern || ''} in ${shortenPath(String(args.path || '.'))}]`;
|
||||
return `[find: ${shortenPath(String((args.paths || [args.pattern || '.']).join(', ')))}]`;
|
||||
case 'ls':
|
||||
return `[ls: ${shortenPath(String(args.path || '.'))}]`;
|
||||
default: {
|
||||
|
||||
@@ -519,11 +519,14 @@ export class SessionObserverOverlayComponent extends Container {
|
||||
case "edit":
|
||||
return args.path ? `path: ${args.path}` : "";
|
||||
case "search":
|
||||
return [args.pattern ? `pattern: ${args.pattern}` : "", args.path ? `path: ${args.path}` : ""]
|
||||
return [
|
||||
args.pattern ? `pattern: ${args.pattern}` : "",
|
||||
Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
case "find":
|
||||
return args.pattern ? `pattern: ${args.pattern}` : "";
|
||||
return Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "";
|
||||
case "bash": {
|
||||
const cmd = args.command;
|
||||
return typeof cmd === "string" ? replaceTabs(cmd) : "";
|
||||
|
||||
@@ -667,13 +667,12 @@ class TreeList implements Component {
|
||||
}
|
||||
case "search": {
|
||||
const pattern = String(args.pattern || "");
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
return `[search: /${pattern}/ in ${path}]`;
|
||||
const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.path || ".");
|
||||
return `[search: /${pattern}/ in ${shortenPath(paths)}]`;
|
||||
}
|
||||
case "find": {
|
||||
const pattern = String(args.pattern || "");
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
return `[find: ${pattern} in ${path}]`;
|
||||
const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.pattern || ".");
|
||||
return `[find: ${shortenPath(paths)}]`;
|
||||
}
|
||||
case "ls": {
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
|
||||
@@ -2,8 +2,8 @@ Performs structural AST-aware rewrites via native ast-grep.
|
||||
|
||||
<instruction>
|
||||
- Use for codemods and structural rewrites where plain text replace is unsafe
|
||||
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
|
||||
- Language is inferred from `path`; narrow `path` to one language for deterministic rewrites
|
||||
- `paths` is required and accepts an array of files, directories, globs, or internal URLs
|
||||
- Language is inferred from `paths`; narrow each call to one language for deterministic rewrites
|
||||
- Metavariables captured in `pat` (`$A`, `$$$ARGS`) are substituted into that entry's `out` template
|
||||
- **Patterns match AST structure, not text.** `$NAME` = one node (captured); `$_` = one without binding; `$$$NAME` = zero-or-more (lazy — stops at next matchable element); `$$$` = zero-or-more without binding. Use `$$$NAME`, **NOT** `$$NAME` — the two-dollar form is invalid. Metavariable names are UPPERCASE and **MUST** be the whole AST node — partial text like `prefix$VAR` or `"hello $NAME"` does NOT work
|
||||
- When the same metavariable appears twice, both occurrences **MUST** match identical code (`$A == $A` matches `x == x`, not `x == y`)
|
||||
@@ -20,17 +20,17 @@ Performs structural AST-aware rewrites via native ast-grep.
|
||||
|
||||
<examples>
|
||||
# Rename a call site across TypeScript files
|
||||
`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"path":"src/**/*.ts"}`
|
||||
`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"paths":["src/**/*.ts"]}`
|
||||
# Delete matching calls
|
||||
`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"path":"src/**/*.ts"}`
|
||||
`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"paths":["src/**/*.ts"]}`
|
||||
# Rewrite import source path
|
||||
`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"path":"src/**/*.ts"}`
|
||||
`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"paths":["src/**/*.ts"]}`
|
||||
# Modernize to optional chaining (same metavariable enforces identity)
|
||||
`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"path":"src/**/*.ts"}`
|
||||
`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"paths":["src/**/*.ts"]}`
|
||||
# Swap two arguments using captures
|
||||
`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"path":"tests/**/*.ts"}`
|
||||
`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"paths":["tests/**/*.ts"]}`
|
||||
# Python — convert print calls to logging
|
||||
`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"path":"src/**/*.py"}`
|
||||
`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"paths":["src/**/*.py"]}`
|
||||
</examples>
|
||||
|
||||
<critical>
|
||||
|
||||
@@ -2,8 +2,8 @@ Performs structural code search using AST matching via native ast-grep.
|
||||
|
||||
<instruction>
|
||||
- Use when syntax shape matters more than raw text (calls, declarations, specific language constructs)
|
||||
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
|
||||
- Language is inferred from `path`; narrow `path` to one language when mixed-language trees could cause parse noise
|
||||
- `paths` is required and accepts an array of files, directories, globs, or internal URLs
|
||||
- Language is inferred from `paths`; narrow each call to one language when mixed-language trees could cause parse noise
|
||||
- `pat` is a single AST pattern. Run separate calls for distinct unrelated patterns
|
||||
- **Patterns match AST structure, not text** — whitespace/formatting is ignored
|
||||
- `$NAME` captures one node; `$_` matches one without binding; `$$$NAME` captures zero-or-more (lazy — stops at next matchable element); `$$$` matches zero-or-more without binding. Use `$$$NAME`, **NOT** `$$NAME` — the two-dollar form is invalid and produces a parse error
|
||||
@@ -13,7 +13,7 @@ Performs structural code search using AST matching via native ast-grep.
|
||||
- C++ qualified calls used as expression statements need the statement semicolon in the pattern: use `ns::doThing($ARG);`, `$CALLEE($ARG);`, or wrap a statement snippet. Without `;`, tree-sitter-cpp may parse `ns::doThing($ARG)` as declaration-like syntax and return no matches
|
||||
- For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }`
|
||||
- Declaration forms are structurally distinct — top-level `function foo`, class method `foo()`, and `const foo = () => {}` are different AST shapes; search the right form before concluding absence
|
||||
- Loosest existence check: `pat: "executeBash"` with a narrow `path`
|
||||
- Loosest existence check: `pat: "executeBash"` with narrow `paths`
|
||||
</instruction>
|
||||
|
||||
<output>
|
||||
@@ -24,19 +24,19 @@ Performs structural code search using AST matching via native ast-grep.
|
||||
|
||||
<examples>
|
||||
# Search TypeScript files under src
|
||||
`{"pat":"console.log($$$)","path":"src/**/*.ts"}`
|
||||
`{"pat":"console.log($$$)","paths":["src/**/*.ts"]}`
|
||||
# Named imports from a specific package
|
||||
`{"pat":"import { $$$IMPORTS } from \"react\"","path":"src/**/*.ts"}`
|
||||
`{"pat":"import { $$$IMPORTS } from \"react\"","paths":["src/**/*.ts"]}`
|
||||
# Arrow functions assigned to a const
|
||||
`{"pat":"const $NAME = ($$$ARGS) => $BODY","path":"src/utils/**/*.ts"}`
|
||||
`{"pat":"const $NAME = ($$$ARGS) => $BODY","paths":["src/utils/**/*.ts"]}`
|
||||
# Method call on any object, ignoring method name with `$_`
|
||||
`{"pat":"logger.$_($$$ARGS)","path":"src/**/*.ts"}`
|
||||
`{"pat":"logger.$_($$$ARGS)","paths":["src/**/*.ts"]}`
|
||||
# Loosest existence check for a symbol in one file
|
||||
`{"pat":"processItems","path":"src/worker.ts"}`
|
||||
`{"pat":"processItems","paths":["src/worker.ts"]}`
|
||||
</examples>
|
||||
|
||||
<critical>
|
||||
- Avoid repo-root scans — narrow `path` first
|
||||
- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `path` before concluding "no matches"
|
||||
- Avoid repo-root scans — narrow `paths` first
|
||||
- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `paths` before concluding "no matches"
|
||||
- For broad/open-ended exploration across subsystems, use Task tool with explore subagent first
|
||||
</critical>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
Finds files using fast pattern matching that works with any codebase size.
|
||||
|
||||
<instruction>
|
||||
- `paths` is required and accepts an array of globs, files, or directories
|
||||
- You **SHOULD** perform multiple searches in parallel when potentially useful
|
||||
</instruction>
|
||||
|
||||
@@ -10,7 +11,7 @@ Matching file paths sorted by modification time (most recent first). Truncated a
|
||||
|
||||
<examples>
|
||||
# Find files
|
||||
`{"pattern": "src/**/*.ts", "limit": 1000}`
|
||||
`{"paths": ["src/**/*.ts"], "limit": 1000}`
|
||||
</examples>
|
||||
|
||||
<avoid>
|
||||
|
||||
@@ -2,13 +2,13 @@ Searches files using powerful regex matching.
|
||||
|
||||
<instruction>
|
||||
- Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go)
|
||||
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
|
||||
- `paths` is required and accepts an array of files, directories, globs, or internal URLs
|
||||
- Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern`
|
||||
</instruction>
|
||||
|
||||
<output>
|
||||
{{#if IS_HASHLINE_MODE}}
|
||||
- Text output is anchor-prefixed: `*123th|content` (match) or ` 123th|content` (context, leading space). The 2-letter ID is a content fingerprint.
|
||||
- Text output is anchor-prefixed: `*5th|content` (match) or ` 9x}|content` (context, leading space). The 2-char suffix is a content fingerprint.
|
||||
{{else}}
|
||||
{{#if IS_LINE_NUMBER_MODE}}
|
||||
- Text output is line-number-prefixed
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
hasGlobPathChars,
|
||||
normalizePathLikeInput,
|
||||
parseSearchPath,
|
||||
resolveMultiSearchPath,
|
||||
resolveExplicitSearchPaths,
|
||||
resolveToCwd,
|
||||
} from "./path-utils";
|
||||
import {
|
||||
@@ -47,9 +47,10 @@ const astEditSchema = Type.Object({
|
||||
minItems: 1,
|
||||
description: "rewrite ops",
|
||||
}),
|
||||
path: Type.String({
|
||||
description: "file, directory, glob, or comma-separated paths to rewrite",
|
||||
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
|
||||
paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to rewrite" }), {
|
||||
minItems: 1,
|
||||
description: "files, directories, globs, or internal URLs to rewrite",
|
||||
examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -201,42 +202,46 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
|
||||
|
||||
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
||||
let searchPath: string | undefined;
|
||||
let scopePath: string | undefined;
|
||||
let searchPath: string;
|
||||
let scopePath: string;
|
||||
let globFilter: string | undefined;
|
||||
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
|
||||
const rawPath = normalizePathLikeInput(params.path);
|
||||
if (rawPath.length === 0) {
|
||||
throw new ToolError("`path` must be a non-empty path or glob");
|
||||
const rawPaths = params.paths.map(normalizePathLikeInput);
|
||||
if (rawPaths.some(rawPath => rawPath.length === 0)) {
|
||||
throw new ToolError("`paths` must contain non-empty paths or globs");
|
||||
}
|
||||
if (rawPath) {
|
||||
const internalRouter = this.session.internalRouter;
|
||||
if (internalRouter?.canHandle(rawPath)) {
|
||||
if (hasGlobPathChars(rawPath)) {
|
||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
||||
}
|
||||
const resource = await internalRouter.resolve(rawPath);
|
||||
if (!resource.sourcePath) {
|
||||
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
|
||||
}
|
||||
searchPath = resource.sourcePath;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
} else {
|
||||
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
|
||||
if (multiSearchPath) {
|
||||
searchPath = multiSearchPath.basePath;
|
||||
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
|
||||
multiTargets = multiSearchPath.targets;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
} else {
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = parsedPath.glob;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
}
|
||||
const internalRouter = this.session.internalRouter;
|
||||
const resolvedPathInputs: string[] = [];
|
||||
for (const rawPath of rawPaths) {
|
||||
if (!internalRouter?.canHandle(rawPath)) {
|
||||
resolvedPathInputs.push(rawPath);
|
||||
continue;
|
||||
}
|
||||
if (hasGlobPathChars(rawPath)) {
|
||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
||||
}
|
||||
const resource = await internalRouter.resolve(rawPath);
|
||||
if (!resource.sourcePath) {
|
||||
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
|
||||
}
|
||||
resolvedPathInputs.push(resource.sourcePath);
|
||||
}
|
||||
const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
|
||||
if (resolvedPathInputs.length === 1) {
|
||||
const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = parsedPath.glob;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
} else {
|
||||
const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
|
||||
if (!multiSearchPath) {
|
||||
throw new ToolError("`paths` must contain at least one path or glob");
|
||||
}
|
||||
searchPath = multiSearchPath.basePath;
|
||||
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
|
||||
multiTargets = multiSearchPath.targets;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
}
|
||||
const resolvedSearchPath = searchPath;
|
||||
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
@@ -350,7 +355,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
count: fileReplacementCounts.get(filePath) ?? 0,
|
||||
}));
|
||||
if (result.limitReached) {
|
||||
outputLines.push("", "Limit reached; narrow path.");
|
||||
outputLines.push("", "Limit reached; narrow paths.");
|
||||
}
|
||||
if (dedupedParseErrors.length) {
|
||||
outputLines.push("", ...formatParseErrors(dedupedParseErrors));
|
||||
@@ -441,7 +446,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
|
||||
interface AstEditRenderArgs {
|
||||
ops?: Array<{ pat?: string; out?: string }>;
|
||||
path?: string;
|
||||
paths?: string[];
|
||||
}
|
||||
|
||||
const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
|
||||
@@ -450,7 +455,7 @@ export const astEditToolRenderer = {
|
||||
inline: true,
|
||||
renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const meta: string[] = [];
|
||||
if (args.path) meta.push(`in ${args.path}`);
|
||||
if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
|
||||
const rewriteCount = args.ops?.length ?? 0;
|
||||
if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
hasGlobPathChars,
|
||||
normalizePathLikeInput,
|
||||
parseSearchPath,
|
||||
resolveMultiSearchPath,
|
||||
resolveExplicitSearchPaths,
|
||||
resolveToCwd,
|
||||
} from "./path-utils";
|
||||
import {
|
||||
@@ -38,9 +38,10 @@ import { toolResult } from "./tool-result";
|
||||
|
||||
const astGrepSchema = Type.Object({
|
||||
pat: Type.String({ description: "ast pattern", examples: ["console.log($$$)"] }),
|
||||
path: Type.String({
|
||||
description: "file, directory, glob, or comma-separated paths to search",
|
||||
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
|
||||
paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to search" }), {
|
||||
minItems: 1,
|
||||
description: "files, directories, globs, or internal URLs to search",
|
||||
examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
|
||||
}),
|
||||
skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
|
||||
});
|
||||
@@ -146,16 +147,21 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
throw new ToolError("skip must be a non-negative number");
|
||||
}
|
||||
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
||||
let searchPath: string | undefined;
|
||||
let scopePath: string | undefined;
|
||||
let searchPath: string;
|
||||
let scopePath: string;
|
||||
let globFilter: string | undefined;
|
||||
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
|
||||
const rawPath = normalizePathLikeInput(params.path);
|
||||
if (rawPath.length === 0) {
|
||||
throw new ToolError("`path` must be a non-empty path or glob");
|
||||
const rawPaths = params.paths.map(normalizePathLikeInput);
|
||||
if (rawPaths.some(rawPath => rawPath.length === 0)) {
|
||||
throw new ToolError("`paths` must contain non-empty paths or globs");
|
||||
}
|
||||
const internalRouter = this.session.internalRouter;
|
||||
if (internalRouter?.canHandle(rawPath)) {
|
||||
const resolvedPathInputs: string[] = [];
|
||||
for (const rawPath of rawPaths) {
|
||||
if (!internalRouter?.canHandle(rawPath)) {
|
||||
resolvedPathInputs.push(rawPath);
|
||||
continue;
|
||||
}
|
||||
if (hasGlobPathChars(rawPath)) {
|
||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
||||
}
|
||||
@@ -163,24 +169,25 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
if (!resource.sourcePath) {
|
||||
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
|
||||
}
|
||||
searchPath = resource.sourcePath;
|
||||
resolvedPathInputs.push(resource.sourcePath);
|
||||
}
|
||||
if (resolvedPathInputs.length === 1) {
|
||||
const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = parsedPath.glob;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
} else {
|
||||
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
|
||||
if (multiSearchPath) {
|
||||
searchPath = multiSearchPath.basePath;
|
||||
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
|
||||
multiTargets = multiSearchPath.targets;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
} else {
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = parsedPath.glob;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
|
||||
if (!multiSearchPath) {
|
||||
throw new ToolError("`paths` must contain at least one path or glob");
|
||||
}
|
||||
searchPath = multiSearchPath.basePath;
|
||||
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
|
||||
multiTargets = multiSearchPath.targets;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
}
|
||||
|
||||
const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
|
||||
const resolvedSearchPath = searchPath;
|
||||
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
@@ -241,7 +248,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
const noMatchMessage = dedupedParseErrors.length
|
||||
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path` before concluding absence."
|
||||
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `paths` before concluding absence."
|
||||
: "No matches found";
|
||||
const parseMessage = dedupedParseErrors.length
|
||||
? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
|
||||
@@ -307,7 +314,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
displayContent: displayLines.join("\n"),
|
||||
};
|
||||
if (result.limitReached) {
|
||||
outputLines.push("", "Result limit reached; narrow path pattern or increase limit.");
|
||||
outputLines.push("", "Result limit reached; narrow paths or increase limit.");
|
||||
}
|
||||
if (dedupedParseErrors.length) {
|
||||
outputLines.push("", ...formatParseErrors(dedupedParseErrors));
|
||||
@@ -324,7 +331,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
|
||||
interface AstGrepRenderArgs {
|
||||
pat?: string;
|
||||
path?: string;
|
||||
paths?: string[];
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
@@ -334,7 +341,7 @@ export const astGrepToolRenderer = {
|
||||
inline: true,
|
||||
renderCall(args: AstGrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const meta: string[] = [];
|
||||
if (args.path) meta.push(`in ${args.path}`);
|
||||
if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
|
||||
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
|
||||
|
||||
const description = args.pat ?? "?";
|
||||
@@ -368,7 +375,7 @@ export const astGrepToolRenderer = {
|
||||
const header = renderStatusLine({ icon: "warning", title: "AST Grep", description, meta }, uiTheme);
|
||||
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
|
||||
if (details?.parseErrors?.length) {
|
||||
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence"));
|
||||
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
|
||||
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
|
||||
for (const err of capped) {
|
||||
lines.push(uiTheme.fg("warning", ` - ${err}`));
|
||||
@@ -420,7 +427,7 @@ export const astGrepToolRenderer = {
|
||||
|
||||
const extraLines: string[] = [];
|
||||
if (limitReached) {
|
||||
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path pattern or increase limit"));
|
||||
extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
|
||||
}
|
||||
if (details?.parseErrors?.length) {
|
||||
const total = details.parseErrors.length;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
formatPathRelativeToCwd,
|
||||
normalizePathLikeInput,
|
||||
parseFindPattern,
|
||||
resolveMultiFindPattern,
|
||||
resolveExplicitFindPatterns,
|
||||
resolveToCwd,
|
||||
} from "./path-utils";
|
||||
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
|
||||
@@ -35,9 +35,10 @@ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
|
||||
const findSchema = Type.Object({
|
||||
pattern: Type.String({
|
||||
description: "glob including search path",
|
||||
examples: ["src/**/*.ts", "lib/*.json", "apps/,packages/", "*.ts"],
|
||||
paths: Type.Array(Type.String({ description: "glob including search path" }), {
|
||||
minItems: 1,
|
||||
description: "globs including search paths",
|
||||
examples: [["src/**/*.ts"], ["lib/*.json"], ["apps/", "packages/"], ["*.ts"]],
|
||||
}),
|
||||
hidden: Type.Optional(Type.Boolean({ description: "include hidden files", default: true })),
|
||||
limit: Type.Optional(Type.Number({ description: "max results", default: 1000 })),
|
||||
@@ -104,17 +105,17 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
||||
onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
|
||||
_context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<FindToolDetails>> {
|
||||
const { pattern, limit, hidden } = params;
|
||||
const { paths, limit, hidden } = params;
|
||||
|
||||
return untilAborted(signal, async () => {
|
||||
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
||||
const normalizedPattern = normalizePathLikeInput(pattern).replace(/\\/g, "/");
|
||||
if (!normalizedPattern) {
|
||||
throw new ToolError("Pattern must not be empty");
|
||||
const normalizedPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
|
||||
if (normalizedPatterns.some(pattern => pattern.length === 0)) {
|
||||
throw new ToolError("`paths` must contain non-empty globs or paths");
|
||||
}
|
||||
|
||||
const multiPattern = await resolveMultiFindPattern(normalizedPattern, this.session.cwd);
|
||||
const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPattern);
|
||||
const multiPattern = await resolveExplicitFindPatterns(normalizedPatterns, this.session.cwd);
|
||||
const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPatterns[0] ?? ".");
|
||||
const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false);
|
||||
const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*";
|
||||
const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd);
|
||||
@@ -292,7 +293,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
||||
// =============================================================================
|
||||
|
||||
interface FindRenderArgs {
|
||||
pattern: string;
|
||||
paths?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -305,7 +306,7 @@ export const findToolRenderer = {
|
||||
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
|
||||
|
||||
const text = renderStatusLine(
|
||||
{ icon: "pending", title: "Find", description: args.pattern || "*", meta },
|
||||
{ icon: "pending", title: "Find", description: args.paths?.join(", ") || "*", meta },
|
||||
uiTheme,
|
||||
);
|
||||
return new Text(text, 0, 0);
|
||||
@@ -342,7 +343,7 @@ export const findToolRenderer = {
|
||||
{
|
||||
icon: "success",
|
||||
title: "Find",
|
||||
description: args?.pattern,
|
||||
description: args?.paths?.join(", "),
|
||||
meta: [formatCount("file", lines.length)],
|
||||
},
|
||||
uiTheme,
|
||||
@@ -381,7 +382,7 @@ export const findToolRenderer = {
|
||||
|
||||
if (fileCount === 0) {
|
||||
const header = renderStatusLine(
|
||||
{ icon: "warning", title: "Find", description: args?.pattern, meta: ["0 files"] },
|
||||
{ icon: "warning", title: "Find", description: args?.paths?.join(", "), meta: ["0 files"] },
|
||||
uiTheme,
|
||||
);
|
||||
return new Text([header, formatEmptyMessage("No files found", uiTheme)].join("\n"), 0, 0);
|
||||
@@ -390,7 +391,7 @@ export const findToolRenderer = {
|
||||
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
|
||||
if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
|
||||
const header = renderStatusLine(
|
||||
{ icon: truncated ? "warning" : "success", title: "Find", description: args?.pattern, meta },
|
||||
{ icon: truncated ? "warning" : "success", title: "Find", description: args?.paths?.join(", "), meta },
|
||||
uiTheme,
|
||||
);
|
||||
|
||||
|
||||
@@ -47,15 +47,6 @@ function fileExists(filePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAtPrefix(filePath: string): string {
|
||||
if (!filePath.startsWith("@")) return filePath;
|
||||
|
||||
@@ -300,77 +291,6 @@ export function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): st
|
||||
return `${normalizedPrefix}/${normalizedSuffix}`;
|
||||
}
|
||||
|
||||
type TopLevelSeparator = "comma" | "whitespace";
|
||||
|
||||
function splitTopLevel(value: string, separator: TopLevelSeparator): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let braceDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
let parenDepth = 0;
|
||||
let quote: '"' | "'" | undefined;
|
||||
let escaped = false;
|
||||
|
||||
const pushCurrent = () => {
|
||||
const normalized = current.trim();
|
||||
if (normalized.length > 0) {
|
||||
parts.push(normalized);
|
||||
}
|
||||
current = "";
|
||||
};
|
||||
|
||||
for (const char of value) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\") {
|
||||
current += char;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) {
|
||||
quote = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{") braceDepth += 1;
|
||||
else if (char === "}" && braceDepth > 0) braceDepth -= 1;
|
||||
else if (char === "[") bracketDepth += 1;
|
||||
else if (char === "]" && bracketDepth > 0) bracketDepth -= 1;
|
||||
else if (char === "(") parenDepth += 1;
|
||||
else if (char === ")" && parenDepth > 0) parenDepth -= 1;
|
||||
|
||||
const topLevel = braceDepth === 0 && bracketDepth === 0 && parenDepth === 0;
|
||||
const isWhitespace = /\s/.test(char);
|
||||
if (topLevel && separator === "comma" && char === ",") {
|
||||
pushCurrent();
|
||||
continue;
|
||||
}
|
||||
if (topLevel && separator === "whitespace" && isWhitespace) {
|
||||
pushCurrent();
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
pushCurrent();
|
||||
return parts.length > 1 ? parts : [value.trim()];
|
||||
}
|
||||
|
||||
function normalizePosixPath(filePath: string): string {
|
||||
return filePath.replace(/\\/g, "/");
|
||||
}
|
||||
@@ -418,121 +338,12 @@ function toScopeDisplay(items: string[], cwd: string): string {
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function looksLikeDelimitedPathToken(token: string): boolean {
|
||||
return (
|
||||
TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix)) ||
|
||||
token.startsWith(".") ||
|
||||
token.startsWith("/") ||
|
||||
token.startsWith("~") ||
|
||||
token.startsWith("@") ||
|
||||
token.includes("/") ||
|
||||
token.includes("\\") ||
|
||||
hasGlobPathChars(token) ||
|
||||
/\.[^./\\]+$/.test(token)
|
||||
);
|
||||
}
|
||||
|
||||
async function areDelimitedTokensResolvable(
|
||||
tokens: string[],
|
||||
cwd: string,
|
||||
parseBasePath: (value: string) => string,
|
||||
allowBareExistingTokens: boolean,
|
||||
): Promise<boolean> {
|
||||
for (const token of tokens) {
|
||||
if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!allowBareExistingTokens && !looksLikeDelimitedPathToken(token)) {
|
||||
// Bare names like "packages" don't look like path tokens syntactically,
|
||||
// but may still be valid directory names. Check existence before rejecting.
|
||||
const resolvedExactPath = resolveToCwd(token, cwd);
|
||||
if (!(await pathExists(resolvedExactPath))) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const basePath = parseBasePath(token);
|
||||
const resolvedBasePath = resolveToCwd(basePath, cwd);
|
||||
if (await pathExists(resolvedBasePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!allowBareExistingTokens) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedExactPath = resolveToCwd(token, cwd);
|
||||
if (!(await pathExists(resolvedExactPath))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function filterResolvableTokens(
|
||||
tokens: string[],
|
||||
cwd: string,
|
||||
parseBasePath: (value: string) => string,
|
||||
): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const token of tokens) {
|
||||
if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) continue;
|
||||
const basePath = parseBasePath(token);
|
||||
const resolvedBasePath = resolveToCwd(basePath, cwd);
|
||||
if (await pathExists(resolvedBasePath)) {
|
||||
out.push(token);
|
||||
continue;
|
||||
}
|
||||
const resolvedExactPath = resolveToCwd(token, cwd);
|
||||
if (await pathExists(resolvedExactPath)) {
|
||||
out.push(token);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function splitDelimitedSearchInput(
|
||||
rawInput: string,
|
||||
cwd: string,
|
||||
parseBasePath: (value: string) => string,
|
||||
): Promise<string[] | undefined> {
|
||||
const trimmed = rawInput.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const resolvedExactPath = resolveToCwd(trimmed, cwd);
|
||||
if (await pathExists(resolvedExactPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const commaSeparated = splitTopLevel(trimmed, "comma");
|
||||
if (commaSeparated.length > 1) {
|
||||
const resolvable = await filterResolvableTokens(commaSeparated, cwd, parseBasePath);
|
||||
if (resolvable.length >= 1) {
|
||||
return [...new Set(resolvable)];
|
||||
}
|
||||
}
|
||||
|
||||
const whitespaceSeparated = splitTopLevel(trimmed, "whitespace");
|
||||
if (
|
||||
whitespaceSeparated.length > 1 &&
|
||||
(await areDelimitedTokensResolvable(whitespaceSeparated, cwd, parseBasePath, false))
|
||||
) {
|
||||
return [...new Set(whitespaceSeparated)];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveMultiSearchPath(
|
||||
rawPath: string,
|
||||
async function resolveSearchPathItems(
|
||||
pathItems: string[],
|
||||
cwd: string,
|
||||
suffixGlob?: string,
|
||||
): Promise<ResolvedMultiSearchPath | undefined> {
|
||||
const pathItems = await splitDelimitedSearchInput(rawPath, cwd, value => parseSearchPath(value).basePath);
|
||||
if (!pathItems || pathItems.length < 1) {
|
||||
if (pathItems.length < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -580,12 +391,19 @@ export async function resolveMultiSearchPath(
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMultiFindPattern(
|
||||
rawPattern: string,
|
||||
export async function resolveExplicitSearchPaths(
|
||||
pathItems: string[],
|
||||
cwd: string,
|
||||
suffixGlob?: string,
|
||||
): Promise<ResolvedMultiSearchPath | undefined> {
|
||||
return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob);
|
||||
}
|
||||
|
||||
async function resolveFindPatternItems(
|
||||
patternItems: string[],
|
||||
cwd: string,
|
||||
): Promise<ResolvedMultiFindPattern | undefined> {
|
||||
const patternItems = await splitDelimitedSearchInput(rawPattern, cwd, value => parseFindPattern(value).basePath);
|
||||
if (!patternItems || patternItems.length <= 1) {
|
||||
if (patternItems.length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -617,6 +435,13 @@ export async function resolveMultiFindPattern(
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveExplicitFindPatterns(
|
||||
patternItems: string[],
|
||||
cwd: string,
|
||||
): Promise<ResolvedMultiFindPattern | undefined> {
|
||||
return resolveFindPatternItems([...new Set(patternItems)], cwd);
|
||||
}
|
||||
|
||||
export function resolveReadPath(filePath: string, cwd: string): string {
|
||||
const resolved = resolveToCwd(filePath, cwd);
|
||||
const shellEscapedVariant = tryShellEscapedPath(resolved);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
hasGlobPathChars,
|
||||
normalizePathLikeInput,
|
||||
parseSearchPath,
|
||||
resolveMultiSearchPath,
|
||||
resolveExplicitSearchPaths,
|
||||
resolveToCwd,
|
||||
} from "./path-utils";
|
||||
import {
|
||||
@@ -37,9 +37,10 @@ import { toolResult } from "./tool-result";
|
||||
|
||||
const searchSchema = Type.Object({
|
||||
pattern: Type.String({ description: "regex pattern", examples: ["function\\s+\\w+", "TODO"] }),
|
||||
path: Type.String({
|
||||
description: "file, directory, glob, comma-separated paths, or internal URL to search",
|
||||
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
|
||||
paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to search" }), {
|
||||
minItems: 1,
|
||||
description: "files, directories, globs, or internal URLs to search",
|
||||
examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
|
||||
}),
|
||||
i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })),
|
||||
gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })),
|
||||
@@ -48,7 +49,7 @@ const searchSchema = Type.Object({
|
||||
|
||||
export type SearchToolInput = Static<typeof searchSchema>;
|
||||
|
||||
const DEFAULT_MATCH_LIMIT = 20;
|
||||
const DEFAULT_MATCH_LIMIT = 500;
|
||||
|
||||
export interface SearchToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
@@ -93,7 +94,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
||||
_onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
|
||||
_toolContext?: AgentToolContext,
|
||||
): Promise<AgentToolResult<SearchToolDetails>> {
|
||||
const { pattern, path: searchDir, i, gitignore, skip } = params;
|
||||
const { pattern, paths, i, gitignore, skip } = params;
|
||||
|
||||
return untilAborted(signal, async () => {
|
||||
const normalizedPattern = pattern.trim();
|
||||
@@ -119,12 +120,17 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
||||
let exactFilePaths: string[] | undefined;
|
||||
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
|
||||
let globFilter: string | undefined;
|
||||
const rawPath = normalizePathLikeInput(searchDir);
|
||||
if (rawPath.length === 0) {
|
||||
throw new ToolError("`path` must be a non-empty path or glob");
|
||||
const rawPaths = paths.map(normalizePathLikeInput);
|
||||
if (rawPaths.some(rawPath => rawPath.length === 0)) {
|
||||
throw new ToolError("`paths` must contain non-empty paths or globs");
|
||||
}
|
||||
const internalRouter = this.session.internalRouter;
|
||||
if (internalRouter?.canHandle(rawPath)) {
|
||||
const resolvedPathInputs: string[] = [];
|
||||
for (const rawPath of rawPaths) {
|
||||
if (!internalRouter?.canHandle(rawPath)) {
|
||||
resolvedPathInputs.push(rawPath);
|
||||
continue;
|
||||
}
|
||||
if (hasGlobPathChars(rawPath)) {
|
||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
||||
}
|
||||
@@ -132,29 +138,30 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
||||
if (!resource.sourcePath) {
|
||||
throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
|
||||
}
|
||||
searchPath = resource.sourcePath;
|
||||
resolvedPathInputs.push(resource.sourcePath);
|
||||
}
|
||||
if (resolvedPathInputs.length === 1) {
|
||||
const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = parsedPath.glob;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
} else {
|
||||
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
|
||||
if (multiSearchPath) {
|
||||
searchPath = multiSearchPath.basePath;
|
||||
exactFilePaths = multiSearchPath.exactFilePaths;
|
||||
multiTargets = multiSearchPath.targets;
|
||||
globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
} else {
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = parsedPath.glob;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
|
||||
if (!multiSearchPath) {
|
||||
throw new ToolError("`paths` must contain at least one path or glob");
|
||||
}
|
||||
searchPath = multiSearchPath.basePath;
|
||||
exactFilePaths = multiSearchPath.exactFilePaths;
|
||||
multiTargets = multiSearchPath.targets;
|
||||
globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
}
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
const stat = await Bun.file(searchPath).stat();
|
||||
isDirectory = stat.isDirectory();
|
||||
} catch {
|
||||
const hint = scopePath.includes(",") ? ` (comma-separated paths must each exist relative to cwd)` : "";
|
||||
const hint = rawPaths.length > 1 ? " (`paths` entries must each exist relative to cwd)" : "";
|
||||
throw new ToolError(`Path not found: ${scopePath}${hint}`);
|
||||
}
|
||||
|
||||
@@ -275,7 +282,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
||||
: result.matches.slice(0, effectiveLimit);
|
||||
const matchLimitReached = result.matches.length > effectiveLimit;
|
||||
const nextSkip = normalizedSkip + selectedMatches.length;
|
||||
const limitMessage = `Result limit reached; narrow path or use skip=${nextSkip}.`;
|
||||
const limitMessage = `Result limit reached; narrow paths or use skip=${nextSkip}.`;
|
||||
const { record: recordFile, list: fileList } = createFileRecorder();
|
||||
const fileMatchCounts = new Map<string, number>();
|
||||
if (selectedMatches.length === 0) {
|
||||
@@ -395,7 +402,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
||||
|
||||
interface SearchRenderArgs {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
paths?: string[];
|
||||
i?: boolean;
|
||||
gitignore?: boolean;
|
||||
skip?: number;
|
||||
@@ -407,7 +414,7 @@ export const searchToolRenderer = {
|
||||
inline: true,
|
||||
renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const meta: string[] = [];
|
||||
if (args.path) meta.push(`in ${args.path}`);
|
||||
if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
|
||||
if (args.i) meta.push("case:insensitive");
|
||||
if (args.gitignore === false) meta.push("gitignore:false");
|
||||
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
|
||||
|
||||
@@ -1175,7 +1175,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-11", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
paths: [testFile],
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1184,14 +1184,14 @@ function b() {
|
||||
expect(output).toMatch(/\*2\|match line/);
|
||||
});
|
||||
|
||||
it("should accept wildcard patterns in the path parameter", async () => {
|
||||
it("should accept wildcard patterns in paths", async () => {
|
||||
fs.writeFileSync(path.join(testDir, "schema-review-alpha.test.ts"), "review target\n");
|
||||
fs.writeFileSync(path.join(testDir, "schema-review-beta.test.ts"), "review target\n");
|
||||
fs.writeFileSync(path.join(testDir, "schema-other.test.ts"), "review target\n");
|
||||
|
||||
const result = await searchTool.execute("test-call-11-path-glob", {
|
||||
pattern: "review target",
|
||||
path: `${testDir}/schema-review-*.test.ts`,
|
||||
paths: [`${testDir}/schema-review-*.test.ts`],
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1200,7 +1200,7 @@ function b() {
|
||||
expect(output).not.toContain("schema-other.test.ts");
|
||||
expect(result.details?.fileCount).toBe(2);
|
||||
});
|
||||
it("should accept nested wildcard filters in the path parameter", async () => {
|
||||
it("should accept nested wildcard filters in paths", async () => {
|
||||
const packageDir = path.join(testDir, "node_modules", ".bun");
|
||||
const aiDir = path.join(packageDir, "ai@6.0.119+build123", "node_modules", "ai");
|
||||
const nestedDir = path.join(aiDir, "nested");
|
||||
@@ -1212,7 +1212,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-11-path-and-glob", {
|
||||
pattern: "providerOptions",
|
||||
path: `${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`,
|
||||
paths: [`${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`],
|
||||
gitignore: false,
|
||||
});
|
||||
|
||||
@@ -1235,7 +1235,7 @@ function b() {
|
||||
);
|
||||
const result = await contextSearchTool.execute("test-call-12", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
paths: [testFile],
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1252,7 +1252,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-12-skip", {
|
||||
pattern: "needle",
|
||||
path: testFile,
|
||||
paths: [testFile],
|
||||
skip: 1,
|
||||
});
|
||||
|
||||
@@ -1270,7 +1270,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-13-round-robin", {
|
||||
pattern: "needle",
|
||||
path: testDir,
|
||||
paths: [testDir],
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1290,7 +1290,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-14-grouped-headings", {
|
||||
pattern: "needle",
|
||||
path: testDir,
|
||||
paths: [testDir],
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1314,7 +1314,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-15-directory-headings", {
|
||||
pattern: "Claude Opus",
|
||||
path: testDir,
|
||||
paths: [testDir],
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1333,7 +1333,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-15-gitignore-default", {
|
||||
pattern: "needle",
|
||||
path: scenarioDir,
|
||||
paths: [scenarioDir],
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1351,7 +1351,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-16-gitignore-off", {
|
||||
pattern: "needle",
|
||||
path: scenarioDir,
|
||||
paths: [scenarioDir],
|
||||
gitignore: false,
|
||||
});
|
||||
|
||||
@@ -1373,7 +1373,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-16-fifo-dir", {
|
||||
pattern: "needle",
|
||||
path: scenarioDir,
|
||||
paths: [scenarioDir],
|
||||
gitignore: false,
|
||||
});
|
||||
|
||||
@@ -1386,18 +1386,18 @@ function b() {
|
||||
expect(result.details?.matchCount).toBe(1);
|
||||
});
|
||||
it("should apply the fixed default match cap", async () => {
|
||||
const lines = Array.from({ length: 60 }, (_, i) => `needle ${i + 1}`);
|
||||
const lines = Array.from({ length: 600 }, (_, i) => `needle ${i + 1}`);
|
||||
fs.writeFileSync(path.join(testDir, "default-limit.txt"), lines.join("\n"));
|
||||
|
||||
const result = await searchTool.execute("test-call-14-default-limit", {
|
||||
pattern: "needle",
|
||||
path: testDir,
|
||||
paths: [testDir],
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("Result limit reached; narrow path or use skip=20.");
|
||||
expect(result.details?.matchCount).toBe(20);
|
||||
expect(result.details?.matchLimitReached).toBe(20);
|
||||
expect(output).toContain("Result limit reached; narrow paths or use skip=500.");
|
||||
expect(result.details?.matchCount).toBe(500);
|
||||
expect(result.details?.matchLimitReached).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1407,7 +1407,7 @@ function b() {
|
||||
fs.writeFileSync(testFile, "single");
|
||||
|
||||
const result = await findTool.execute("test-call-13a", {
|
||||
pattern: testFile,
|
||||
paths: [testFile],
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
@@ -1425,7 +1425,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(testDir, "visible.txt"), "visible");
|
||||
|
||||
const result = await findTool.execute("test-call-13", {
|
||||
pattern: `${testDir}/**/*.txt`,
|
||||
paths: [`${testDir}/**/*.txt`],
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
@@ -1445,7 +1445,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(testDir, "kept.txt"), "kept");
|
||||
|
||||
const result = await findTool.execute("test-call-14", {
|
||||
pattern: `${testDir}/**/*.txt`,
|
||||
paths: [`${testDir}/**/*.txt`],
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1470,7 +1470,7 @@ function b() {
|
||||
fs.utimesSync(newerFile, newerTime, newerTime);
|
||||
|
||||
const result = await findTool.execute("test-call-14b", {
|
||||
pattern: `${testDir}/**/auth-actions.spec.ts`,
|
||||
paths: [`${testDir}/**/auth-actions.spec.ts`],
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
@@ -1487,7 +1487,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(nestedDir, "daemon-telemetry.ts"), "telemetry\n");
|
||||
|
||||
const result = await findTool.execute("test-call-14c", {
|
||||
pattern: "apps/daemon/src/**/daemon-telemetry.ts",
|
||||
paths: ["apps/daemon/src/**/daemon-telemetry.ts"],
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
@@ -1507,7 +1507,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(clientDir, "client.ts"), "client\n");
|
||||
|
||||
const result = await findTool.execute("test-call-14e", {
|
||||
pattern: "apps/daemon/src/**/*.ts,apps/client/src/**/*.ts",
|
||||
paths: ["apps/daemon/src/**/*.ts", "apps/client/src/**/*.ts"],
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
@@ -1528,7 +1528,7 @@ function b() {
|
||||
|
||||
const startedAt = performance.now();
|
||||
const result = await findTool.execute("test-call-14d", {
|
||||
pattern: "**/.env*",
|
||||
paths: ["**/.env*"],
|
||||
});
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { adaptSchemaForStrict } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { HASHLINE_HASH_RE_SRC } from "@oh-my-pi/pi-coding-agent/edit";
|
||||
import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
|
||||
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
|
||||
@@ -72,7 +73,7 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
const result = await tool!.execute("ast-edit-test", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
path: filePath,
|
||||
paths: [filePath],
|
||||
});
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
const lines = text.split("\n");
|
||||
@@ -81,8 +82,8 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
expect(removedLine).toBeDefined();
|
||||
expect(addedLine).toBeDefined();
|
||||
expect(removedLine).toMatch(/^-\d+[a-z]{2}\|/);
|
||||
expect(addedLine).toMatch(/^\+\d+[a-z]{2}\|/);
|
||||
expect(removedLine).toMatch(new RegExp(`^-\\d+${HASHLINE_HASH_RE_SRC}\\|`));
|
||||
expect(addedLine).toMatch(new RegExp(`^\\+\\d+${HASHLINE_HASH_RE_SRC}\\|`));
|
||||
expect(removedLine?.split("|", 1)[0].length).toBe(addedLine?.split("|", 1)[0].length);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
@@ -108,7 +109,7 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
const previewResult = await tool!.execute("ast-edit-preview", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
path: filePath,
|
||||
paths: [filePath],
|
||||
});
|
||||
expect(previewResult.details).toBeDefined();
|
||||
expect((previewResult.details as { applied?: boolean }).applied).toBe(false);
|
||||
@@ -153,7 +154,7 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
const previewResult = await tool!.execute("ast-edit-preview", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
path: filePath,
|
||||
paths: [filePath],
|
||||
});
|
||||
expect((previewResult.details as { totalReplacements?: number } | undefined)?.totalReplacements).toBe(1);
|
||||
|
||||
@@ -203,7 +204,7 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
const previewResult = await tool!.execute("ast-edit-glob", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
path: `${packagesDir}/pkg-*/src/**/*.ts`,
|
||||
paths: [`${packagesDir}/pkg-*/src/**/*.ts`],
|
||||
});
|
||||
|
||||
const text = previewResult.content.find(content => content.type === "text")?.text ?? "";
|
||||
@@ -259,7 +260,7 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
const previewResult = await tool!.execute("ast-edit-tlaplus", {
|
||||
ops: [{ pat: "Init", out: "Start" }],
|
||||
path: filePath,
|
||||
paths: [filePath],
|
||||
});
|
||||
|
||||
const text = previewResult.content.find(content => content.type === "text")?.text ?? "";
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("ast_grep parse errors", () => {
|
||||
|
||||
const result = await tool!.execute("ast-grep-parse", {
|
||||
pat: "someUnlikelyCall($A)",
|
||||
path: filePath,
|
||||
paths: [filePath],
|
||||
});
|
||||
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
@@ -64,7 +64,7 @@ describe("ast_grep parse errors", () => {
|
||||
|
||||
const result = await tool!.execute("ast-grep-glob", {
|
||||
pat: "providerOptions",
|
||||
path: `${packagesDir}/pkg-*/src/**/*.ts`,
|
||||
paths: [`${packagesDir}/pkg-*/src/**/*.ts`],
|
||||
});
|
||||
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
@@ -96,7 +96,7 @@ describe("ast_grep parse errors", () => {
|
||||
|
||||
const result = await tool!.execute("ast-grep-tlaplus", {
|
||||
pat: "Inc",
|
||||
path: filePath,
|
||||
paths: [filePath],
|
||||
});
|
||||
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { resolveMultiSearchPath } from "@oh-my-pi/pi-coding-agent/tools/path-utils";
|
||||
import { resolveExplicitSearchPaths } from "@oh-my-pi/pi-coding-agent/tools/path-utils";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
@@ -26,17 +26,17 @@ function getText(result: { content: Array<{ type: string; text?: string }> }): s
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
describe.skipIf(isWindows)("resolveMultiSearchPath cross-tree degeneracy", () => {
|
||||
describe.skipIf(isWindows)("resolveExplicitSearchPaths cross-tree degeneracy", () => {
|
||||
it("returns per-path targets when commonBasePath collapses to filesystem root", async () => {
|
||||
// Two real top-level directories that exist on every Unix host. Their only
|
||||
// shared ancestor is `/`. A naive shared-base scan would walk the entire
|
||||
// filesystem; the resolver must surface explicit `targets` so callers can
|
||||
// fan out instead.
|
||||
const cwd = os.tmpdir();
|
||||
const resolved = await resolveMultiSearchPath("/tmp,/usr", cwd);
|
||||
const resolved = await resolveExplicitSearchPaths(["/tmp", "/usr"], cwd);
|
||||
|
||||
expect(resolved).toBeDefined();
|
||||
if (!resolved) throw new Error("expected resolveMultiSearchPath to resolve");
|
||||
if (!resolved) throw new Error("expected resolveExplicitSearchPaths to resolve");
|
||||
expect(resolved.basePath).toBe(path.parse(resolved.basePath).root);
|
||||
expect(resolved.targets).toBeDefined();
|
||||
const targetBases = (resolved.targets ?? []).map(target => target.basePath).sort();
|
||||
@@ -76,7 +76,7 @@ describe.skipIf(isWindows)("search across unrelated filesystem trees", () => {
|
||||
const start = performance.now();
|
||||
const result = await tool.execute("search-cross-tree", {
|
||||
pattern: "shared-needle",
|
||||
path: `${dirA},${dirB}`,
|
||||
paths: [dirA, dirB],
|
||||
});
|
||||
const durationMs = performance.now() - start;
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("tool path root alias", () => {
|
||||
|
||||
const result = await tool.execute("search-root-alias", {
|
||||
pattern: "root-alias-needle",
|
||||
path: "/",
|
||||
paths: ["/"],
|
||||
});
|
||||
const details = result.details as { scopePath?: string } | undefined;
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("tool path root alias", () => {
|
||||
if (!tool) throw new Error("Missing find tool");
|
||||
|
||||
const result = await tool.execute("find-root-alias", {
|
||||
pattern: "/",
|
||||
paths: ["/"],
|
||||
});
|
||||
const details = result.details as { scopePath?: string } | undefined;
|
||||
const text = getText(result);
|
||||
@@ -117,7 +117,7 @@ describe("tool path root alias", () => {
|
||||
|
||||
const result = await tool.execute("ast-grep-root-alias", {
|
||||
pat: "rootAliasSymbol",
|
||||
path: "/**/*.ts",
|
||||
paths: ["/**/*.ts"],
|
||||
});
|
||||
const details = result.details as { scopePath?: string } | undefined;
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("tool path root alias", () => {
|
||||
|
||||
const preview = await tool.execute("ast-edit-root-alias", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
path: "/**/*.ts",
|
||||
paths: ["/**/*.ts"],
|
||||
});
|
||||
const details = preview.details as { scopePath?: string; totalReplacements?: number } | undefined;
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("SearchTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "needle",
|
||||
path: "artifact://5",
|
||||
paths: ["artifact://5"],
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -73,7 +73,7 @@ describe("SearchTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "ERROR.*",
|
||||
path: "artifact://3",
|
||||
paths: ["artifact://3"],
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -99,7 +99,7 @@ describe("SearchTool internal URL resolution", () => {
|
||||
const session = createSession({ internalRouter: router });
|
||||
const tool = new SearchTool(session);
|
||||
|
||||
expect(tool.execute("test-call", { pattern: "foo", path: "agent://0" })).rejects.toThrow(
|
||||
expect(tool.execute("test-call", { pattern: "foo", paths: ["agent://0"] })).rejects.toThrow(
|
||||
"Cannot search internal URL without a backing file",
|
||||
);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe("SearchTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "hello",
|
||||
path: "test.txt",
|
||||
paths: ["test.txt"],
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -128,7 +128,7 @@ describe("SearchTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "data",
|
||||
path: "data.log",
|
||||
paths: ["data.log"],
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -140,7 +140,7 @@ describe("SearchTool internal URL resolution", () => {
|
||||
const session = createSession({ internalRouter: router });
|
||||
const tool = new SearchTool(session);
|
||||
|
||||
expect(tool.execute("test-call", { pattern: "foo", path: "artifact://999" })).rejects.toThrow(
|
||||
expect(tool.execute("test-call", { pattern: "foo", paths: ["artifact://999"] })).rejects.toThrow(
|
||||
"Artifact 999 not found",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { HASHLINE_HASH_RE_SRC } from "@oh-my-pi/pi-coding-agent/edit";
|
||||
import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
|
||||
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
|
||||
@@ -56,7 +57,7 @@ async function createSearchFixture(rootDir: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
describe("search tool path lists", () => {
|
||||
describe("tool path arrays", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -68,15 +69,15 @@ describe("search tool path lists", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("search accepts space-separated path lists", async () => {
|
||||
it("search accepts explicit path arrays", async () => {
|
||||
const tools = await createTools(createTestSession(tempDir));
|
||||
const tool = tools.find(entry => entry.name === "search");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("Missing search tool");
|
||||
|
||||
const result = await tool.execute("search-space-paths", {
|
||||
const result = await tool.execute("search-path-array", {
|
||||
pattern: "shared-needle",
|
||||
path: "apps/ packages/ phases/",
|
||||
paths: ["apps/", "packages/", "phases/"],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -98,7 +99,7 @@ describe("search tool path lists", () => {
|
||||
|
||||
const result = await tool.execute("search-space-directory", {
|
||||
pattern: "space-needle",
|
||||
path: "folder with spaces/",
|
||||
paths: ["folder with spaces/"],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -116,7 +117,7 @@ describe("search tool path lists", () => {
|
||||
|
||||
const result = await tool.execute("search-quoted-path", {
|
||||
pattern: "shared-needle",
|
||||
path: '"packages/"',
|
||||
paths: ['"packages/"'],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -136,7 +137,7 @@ describe("search tool path lists", () => {
|
||||
const absoluteAppsPath = path.join(tempDir, "apps");
|
||||
const result = await tool.execute("search-absolute-in-cwd", {
|
||||
pattern: "shared-needle",
|
||||
path: absoluteAppsPath,
|
||||
paths: [absoluteAppsPath],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -174,7 +175,7 @@ describe("search tool path lists", () => {
|
||||
|
||||
const result = await tool.execute("ast-grep-quoted-path", {
|
||||
pat: "providerOptions",
|
||||
path: '"packages/**/*.ts"',
|
||||
paths: ['"packages/**/*.ts"'],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -185,15 +186,15 @@ describe("search tool path lists", () => {
|
||||
expect(details?.scopePath).toBe("packages");
|
||||
});
|
||||
|
||||
it("ast_grep accepts comma-separated path lists", async () => {
|
||||
it("ast_grep accepts explicit path arrays", async () => {
|
||||
const tools = await createTools(createTestSession(tempDir));
|
||||
const tool = tools.find(entry => entry.name === "ast_grep");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("Missing ast_grep tool");
|
||||
|
||||
const result = await tool.execute("ast-grep-comma-paths", {
|
||||
const result = await tool.execute("ast-grep-path-array", {
|
||||
pat: "providerOptions",
|
||||
path: "apps/**/*.ts,packages/**/*.ts,phases/**/*.ts",
|
||||
paths: ["apps/**/*.ts", "packages/**/*.ts", "phases/**/*.ts"],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -207,7 +208,7 @@ describe("search tool path lists", () => {
|
||||
expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts");
|
||||
});
|
||||
|
||||
it("ast_edit applies across a space-separated path list", async () => {
|
||||
it("ast_edit applies across an explicit path array", async () => {
|
||||
const queue = new ToolChoiceQueue();
|
||||
const tools = await createTools(
|
||||
createTestSession(tempDir, {
|
||||
@@ -220,9 +221,9 @@ describe("search tool path lists", () => {
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("Missing ast_edit tool");
|
||||
|
||||
const preview = await tool.execute("ast-edit-space-paths", {
|
||||
const preview = await tool.execute("ast-edit-path-array", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
path: "apps/**/*.ts packages/**/*.ts phases/**/*.ts",
|
||||
paths: ["apps/**/*.ts", "packages/**/*.ts", "phases/**/*.ts"],
|
||||
});
|
||||
const text = getText(preview);
|
||||
const details = preview.details as { totalReplacements?: number; scopePath?: string } | undefined;
|
||||
@@ -252,14 +253,14 @@ describe("search tool path lists", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("find accepts comma-separated path lists", async () => {
|
||||
it("find accepts explicit path arrays", async () => {
|
||||
const tools = await createTools(createTestSession(tempDir));
|
||||
const tool = tools.find(entry => entry.name === "find");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("Missing find tool");
|
||||
|
||||
const result = await tool.execute("find-comma-paths", {
|
||||
pattern: "apps/,packages/,phases/",
|
||||
const result = await tool.execute("find-path-array", {
|
||||
paths: ["apps/", "packages/", "phases/"],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -280,7 +281,7 @@ describe("search tool path lists", () => {
|
||||
if (!tool) throw new Error("Missing find tool");
|
||||
|
||||
const result = await tool.execute("find-quoted-pattern", {
|
||||
pattern: '"packages/"',
|
||||
paths: ['"packages/"'],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -302,7 +303,7 @@ describe("search tool path lists", () => {
|
||||
if (!tool) throw new Error("Missing find tool");
|
||||
|
||||
const result = await tool.execute("find-outside-cwd", {
|
||||
pattern: outsideDir,
|
||||
paths: [outsideDir],
|
||||
});
|
||||
const text = getText(result);
|
||||
const expectedPath = path.join(outsideDir, "outside.txt").replace(/\\/g, "/");
|
||||
@@ -317,15 +318,15 @@ describe("search tool path lists", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("grep accepts bare space-separated directory names (no trailing slash)", async () => {
|
||||
it("grep accepts bare directory name arrays", async () => {
|
||||
const tools = await createTools(createTestSession(tempDir));
|
||||
const tool = tools.find(entry => entry.name === "search");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("Missing search tool");
|
||||
|
||||
const result = await tool.execute("grep-bare-space-paths", {
|
||||
const result = await tool.execute("grep-bare-path-array", {
|
||||
pattern: "shared-needle",
|
||||
path: "apps packages phases",
|
||||
paths: ["apps", "packages", "phases"],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -338,7 +339,7 @@ describe("search tool path lists", () => {
|
||||
expect(details?.scopePath).toBe("apps, packages, phases");
|
||||
});
|
||||
|
||||
it("grep keeps comma-separated explicit files exact", async () => {
|
||||
it("grep keeps explicit files exact", async () => {
|
||||
await fs.mkdir(path.join(tempDir, "nested"), { recursive: true });
|
||||
await Bun.write(path.join(tempDir, "alpha.txt"), "exact-needle alpha\n");
|
||||
await Bun.write(path.join(tempDir, "beta.txt"), "exact-needle beta\n");
|
||||
@@ -350,9 +351,9 @@ describe("search tool path lists", () => {
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("Missing search tool");
|
||||
|
||||
const result = await tool.execute("grep-exact-comma-files", {
|
||||
const result = await tool.execute("grep-exact-file-array", {
|
||||
pattern: "exact-needle",
|
||||
path: "alpha.txt,beta.txt",
|
||||
paths: ["alpha.txt", "beta.txt"],
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -374,7 +375,7 @@ describe("search tool path lists", () => {
|
||||
|
||||
const result = await tool.execute("grep-no-empty-headings", {
|
||||
pattern: "shared-needle",
|
||||
path: "apps/,packages/,phases/",
|
||||
paths: ["apps/", "packages/", "phases/"],
|
||||
});
|
||||
const lines = getText(result).split("\n");
|
||||
|
||||
@@ -404,12 +405,12 @@ describe("search tool path lists", () => {
|
||||
|
||||
const result = await tool.execute("grep-context-label", {
|
||||
pattern: "needle",
|
||||
path: "context.txt",
|
||||
paths: ["context.txt"],
|
||||
});
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toMatch(/ 1(?:[a-z]{2})?\|#if FLAG/);
|
||||
expect(text).toMatch(/\*2(?:[a-z]{2})?\|needle/);
|
||||
expect(text).toMatch(/ 3(?:[a-z]{2})?\|#endif/);
|
||||
expect(text).toMatch(new RegExp(` 1(?:${HASHLINE_HASH_RE_SRC})?\\|#if FLAG`));
|
||||
expect(text).toMatch(new RegExp(`\\*2(?:${HASHLINE_HASH_RE_SRC})?\\|needle`));
|
||||
expect(text).toMatch(new RegExp(` 3(?:${HASHLINE_HASH_RE_SRC})?\\|#endif`));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user