diff --git a/packages/cli/src/renderers/__tests__/output-renderer.test.ts b/packages/cli/src/renderers/__tests__/output-renderer.test.ts index 3954783bbe..b1c499aef8 100644 --- a/packages/cli/src/renderers/__tests__/output-renderer.test.ts +++ b/packages/cli/src/renderers/__tests__/output-renderer.test.ts @@ -74,6 +74,56 @@ describe("renderToolPart", () => { expect(text).toContain("Listing files in pochi://skills/widget-guidelines"); }); + it("renders the search limit independently from the match count", () => { + const executingText = renderText({ + type: "tool-searchFiles", + toolCallId: "call-1", + state: "input-available", + input: { + path: ".", + regex: "store", + limit: 20, + }, + } as ToolUIPart); + const completedText = renderText({ + type: "tool-searchFiles", + toolCallId: "call-1", + state: "output-available", + input: { + path: ".", + regex: "store", + limit: 20, + }, + output: { + matches: [{ file: "store.ts", line: 1, context: "const store = {};" }], + isTruncated: false, + }, + } as ToolUIPart); + + expect(executingText).toContain("Searching for store in . (limit: 20)"); + expect(completedText).toContain( + "Searching for store in . (limit: 20), 1 matched", + ); + }); + + it("preserves completed search output when no limit is provided", () => { + const text = renderText({ + type: "tool-searchFiles", + toolCallId: "call-1", + state: "output-available", + input: { + path: ".", + regex: "store", + }, + output: { + matches: [{ file: "store.ts", line: 1, context: "const store = {};" }], + isTruncated: false, + }, + } as ToolUIPart); + + expect(text).toBe("🔍 Searching for store in ."); + }); + it("renders project memory paths in file operation output", () => { const memoryPath = pochiHomePath( "projects", diff --git a/packages/cli/src/renderers/output-renderer.ts b/packages/cli/src/renderers/output-renderer.ts index b97702ed37..70b9c2624e 100644 --- a/packages/cli/src/renderers/output-renderer.ts +++ b/packages/cli/src/renderers/output-renderer.ts @@ -272,12 +272,22 @@ export function renderToolPart( } if (part.type === "tool-searchFiles") { - const { regex = "", path = ".", filePattern = "" } = part.input || {}; + const { + regex = "", + path = ".", + filePattern = "", + limit, + } = part.input || {}; const searchDesc = filePattern ? `${chalk.bold(regex)} in ${chalk.bold(filePattern)} files` : `${chalk.bold(regex)}`; + const limitDesc = limit !== undefined ? ` (limit: ${limit})` : ""; + const matchDesc = + limit !== undefined && part.state === "output-available" && !hasError + ? `, ${part.output.matches.length} matched` + : ""; return { - text: `🔍 Searching for ${searchDesc} in ${formatCliDisplayPath(path)}`, + text: `🔍 Searching for ${searchDesc} in ${formatCliDisplayPath(path)}${limitDesc}${matchDesc}`, stop: hasError ? "fail" : "succeed", error: errorText, }; diff --git a/packages/cli/src/tools/search-files.ts b/packages/cli/src/tools/search-files.ts index b7470bace0..022acfee7d 100644 --- a/packages/cli/src/tools/search-files.ts +++ b/packages/cli/src/tools/search-files.ts @@ -8,7 +8,10 @@ const logger = getLogger("searchFiles"); export const searchFiles = (context: ToolCallOptions): ToolFunctionType => - async ({ path, regex, filePattern }, { abortSignal, cwd }) => { + async ( + { path, regex, filePattern, limit, case_sensitive: caseSensitive }, + { abortSignal, cwd }, + ) => { const rgPath = context.rg; if (!rgPath || !fs.existsSync(rgPath)) { logger.error("Ripgrep not found at path", rgPath); @@ -20,6 +23,8 @@ export const searchFiles = rgPath, cwd, filePattern, + limit, + caseSensitive, abortSignal, ); }; diff --git a/packages/common/src/tool-utils/__tests__/ripgrep.test.ts b/packages/common/src/tool-utils/__tests__/ripgrep.test.ts index 4620bb5f84..9ccddacf45 100644 --- a/packages/common/src/tool-utils/__tests__/ripgrep.test.ts +++ b/packages/common/src/tool-utils/__tests__/ripgrep.test.ts @@ -83,7 +83,7 @@ describe("searchFilesWithRipgrep", () => { const baseArgs = [ "--json", - "--case-sensitive", + "--ignore-case", "--binary", "--sortr", "modified", @@ -174,6 +174,81 @@ describe("searchFilesWithRipgrep", () => { ); }); + it("should search case-insensitively by default", async () => { + mockSpawnResult({ stdout: "" }); + + await searchFilesWithRipgrep(".", "hello", rgPath, workspacePath); + + expect(spawnMock).toHaveBeenCalledWith( + rgPath, + [ + "--json", + "--ignore-case", + "--binary", + "--sortr", + "modified", + "hello", + workspacePath, + ], + { signal: undefined }, + ); + }); + + it("should search case-sensitively when requested", async () => { + mockSpawnResult({ stdout: "" }); + + await searchFilesWithRipgrep( + ".", + "hello", + rgPath, + workspacePath, + undefined, + undefined, + true, + ); + + expect(spawnMock).toHaveBeenCalledWith( + rgPath, + [ + "--json", + "--case-sensitive", + "--binary", + "--sortr", + "modified", + "hello", + workspacePath, + ], + { signal: undefined }, + ); + }); + + it("should stop at an explicit limit without marking the result truncated", async () => { + const mockRgOutput = Array.from({ length: 3 }, (_, i) => ({ + type: "match", + data: { + path: { text: join(workspacePath, `file${i}.ts`) }, + lines: { text: `line ${i}\n` }, + line_number: i + 1, + }, + })) + .map((o) => JSON.stringify(o)) + .join("\n"); + const child = mockSpawnResult({ stdout: mockRgOutput }); + + const result = await searchFilesWithRipgrep( + ".", + "hello", + rgPath, + workspacePath, + undefined, + 2, + ); + + expect(result.matches).toHaveLength(2); + expect(result.isTruncated).toBe(false); + expect(child.kill).toHaveBeenCalled(); + }); + it("should stop rg as soon as the global match limit is exceeded", async () => { const mockRgOutput = Array.from({ length: 501 }, (_, i) => ({ type: "match", diff --git a/packages/common/src/tool-utils/ripgrep.ts b/packages/common/src/tool-utils/ripgrep.ts index e8a229ddc5..03c384f4da 100644 --- a/packages/common/src/tool-utils/ripgrep.ts +++ b/packages/common/src/tool-utils/ripgrep.ts @@ -119,13 +119,16 @@ export async function searchFilesWithRipgrep( rgPath: string, workspacePath: string, filePattern?: string, + limit?: number, + caseSensitive = false, abortSignal?: AbortSignal, ): Promise<{ matches: RipgrepMatch[]; isTruncated: boolean; }> { - logger.debug("searchFiles", path, regex, filePattern); + logger.debug("searchFiles", path, regex, filePattern, limit, caseSensitive); const matches: RipgrepMatch[] = []; + const matchLimit = limit ?? MaxRipgrepItems; let isTruncated = false; let serializedLength = JSON.stringify({ matches: [], @@ -137,11 +140,10 @@ export async function searchFilesWithRipgrep( // quoting with single quotes breaks on Windows because the default shell // (cmd.exe) does not strip single quotes, so rg would receive literal // quotes around the path and fail with "path not found". - // - --case-sensitive matches the original implementation's RegExp usage. // - --binary skips binary files, similar to the original file-type check. const args = [ "--json", - "--case-sensitive", + caseSensitive ? "--case-sensitive" : "--ignore-case", "--binary", "--sortr", "modified", @@ -177,8 +179,8 @@ export async function searchFilesWithRipgrep( return; } - if (matches.length >= MaxRipgrepItems) { - isTruncated = true; + if (matches.length >= matchLimit) { + isTruncated = limit === undefined; stopAfterLimit(); return; } @@ -191,7 +193,7 @@ export async function searchFilesWithRipgrep( remainingLength, ); if (!fittedMatch) { - isTruncated = true; + isTruncated = limit === undefined; stopAfterLimit(); return; } @@ -199,7 +201,12 @@ export async function searchFilesWithRipgrep( matches.push(fittedMatch); serializedLength += separatorLength + JSON.stringify(fittedMatch).length; if (fittedMatch !== match) { - isTruncated = true; + isTruncated = limit === undefined; + stopAfterLimit(); + return; + } + + if (limit !== undefined && matches.length >= matchLimit) { stopAfterLimit(); } }; diff --git a/packages/tools/src/__test__/search-files.test.ts b/packages/tools/src/__test__/search-files.test.ts new file mode 100644 index 0000000000..9e50499732 --- /dev/null +++ b/packages/tools/src/__test__/search-files.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { searchFiles } from "../search-files"; + +const inputSchema = searchFiles.inputSchema as z.ZodType; + +describe("searchFiles", () => { + it.each([true, false])("accepts case_sensitive %p", (caseSensitive) => { + expect( + inputSchema.parse({ + path: ".", + regex: "hello", + case_sensitive: caseSensitive, + }), + ).toMatchObject({ case_sensitive: caseSensitive }); + }); + + it("rejects string values for case_sensitive", () => { + expect(() => + inputSchema.parse({ + path: ".", + regex: "hello", + case_sensitive: "false", + }), + ).toThrow(); + }); + + it("accepts a positive integer limit", () => { + expect( + inputSchema.parse({ path: ".", regex: "hello", limit: 10 }), + ).toMatchObject({ limit: 10 }); + expect(() => + inputSchema.parse({ path: ".", regex: "hello", limit: 0 }), + ).toThrow(); + }); + + it("exposes limit and case_sensitive in the JSON schema", () => { + const properties = inputSchema.toJSONSchema().properties; + + expect(properties).toHaveProperty("limit"); + expect(properties).toHaveProperty("case_sensitive"); + }); + + it("explains that limit is optional because the host enforces a safety limit", () => { + const properties = inputSchema.toJSONSchema().properties; + + expect(properties?.limit?.description).toBe( + "Limit output to the first N matching lines. The host already applies an internal safety limit when omitted, so only specify this when a smaller result set is necessary.", + ); + }); +}); diff --git a/packages/tools/src/search-files.ts b/packages/tools/src/search-files.ts index 1f551ee742..c071299296 100644 --- a/packages/tools/src/search-files.ts +++ b/packages/tools/src/search-files.ts @@ -31,6 +31,20 @@ const toolDef = { .describe( 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}"). If not provided, it will search all files.', ), + limit: z + .number() + .int() + .positive() + .optional() + .describe( + "Limit output to the first N matching lines. The host already applies an internal safety limit when omitted, so only specify this when a smaller result set is necessary.", + ), + case_sensitive: z + .boolean() + .optional() + .describe( + "Make the match case-sensitive. Defaults to false (case-insensitive).", + ), }), outputSchema: z.object({ matches: z diff --git a/packages/vscode-webui/src/features/tools/components/__tests__/path-display.test.tsx b/packages/vscode-webui/src/features/tools/components/__tests__/path-display.test.tsx index 4bae9d52fa..5febafbdef 100644 --- a/packages/vscode-webui/src/features/tools/components/__tests__/path-display.test.tsx +++ b/packages/vscode-webui/src/features/tools/components/__tests__/path-display.test.tsx @@ -69,6 +69,55 @@ describe("tool path display", () => { expect(visibleText(container)).not.toContain("/.pochi/projects/"); }); + it("shows the search limit independently from the match count", () => { + const { container, rerender } = render( + , + ); + + expect(visibleText(container)).toContain("(limit: 20)"); + + rerender( + , + ); + + expect(visibleText(container)).toContain("(limit: 20)"); + expect(visibleText(container)).toContain("1 toolInvocation.matched"); + }); + it("shortens project memory paths in globFiles titles", () => { const { container } = render( > = ({ isExecuting, }) => { const { t } = useTranslation(); - const { path, regex, filePattern } = tool.input || {}; + const { path, regex, filePattern, limit } = tool.input || {}; let resultEl: React.ReactNode; let matches: { file: string; line: number; context: string }[] = []; @@ -41,6 +41,7 @@ export const searchFilesTool: React.FC> = ({ {filePattern} )} + {limit !== undefined ? ` (limit: ${limit})` : null} ); diff --git a/packages/vscode/src/tools/search-files.ts b/packages/vscode/src/tools/search-files.ts index 9031ab46ea..8f1c59c6ed 100644 --- a/packages/vscode/src/tools/search-files.ts +++ b/packages/vscode/src/tools/search-files.ts @@ -6,7 +6,7 @@ import type { ClientTools, ToolFunctionType } from "@getpochi/tools"; const logger = getLogger("searchFiles"); export const searchFiles: ToolFunctionType = async ( - { path, regex, filePattern }, + { path, regex, filePattern, limit, case_sensitive: caseSensitive }, { abortSignal, cwd }, ) => { logger.debug( @@ -23,6 +23,8 @@ export const searchFiles: ToolFunctionType = async ( vscodeRipgrepPath, cwd, filePattern, + limit, + caseSensitive, abortSignal, ); };