Unverified 提交 6d7ed897 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

grep tool (#2161)

<!-- CURSOR_SUMMARY --> > [!NOTE] > Introduces fast, scoped code search in local-agent mode and renders results inline in chat. > > - Adds `grep` tool (ripgrep-backed) with include/exclude globs and optional case sensitivity; returns `path:line: text` matches > - New `DyadGrep` component and `DyadMarkdownParser` support for `dyad-grep`; attribute parsing updated to allow hyphenated names > - Extracts ripgrep helpers to `ipc/utils/ripgrep_utils.ts` and refactors `app_handlers` to use them > - Registers tool in local agent tool set > - E2E coverage: new fixture, Playwright spec, and ARIA snapshots validating two searches and rendered output > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 35cd925caf321c1c987b636d9539aed465284f1b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a grep tool to the local agent using ripgrep and a new <dyad-grep> UI to show results in chat. Enables fast, scoped code searches with include/exclude globs and case sensitivity. - **New Features** - New local-agent tool grep (ripgrep-backed) that returns file:line: text matches; case-insensitive by default with include_pattern, exclude_pattern, and case_sensitive options. - New DyadGrep chat component and parser support (<dyad-grep>) with collapsible results, progress/aborted states, and copyable output. - Shared ripgrep utils (executable path, size limit, default exclude globs) extracted and reused; tool registered in tool_definitions. - **Tests** - Added E2E spec for grep in local-agent mode with ARIA snapshots. - New fixture driving two searches and validating rendered results. <sup>Written for commit 35cd925caf321c1c987b636d9539aed465284f1b. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatargemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
上级 381db427
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Search for patterns in codebase using grep tool",
turns: [
{
text: "I'll search for 'createRoot' in the codebase to find where the React app is initialized.",
toolCalls: [
{
name: "grep",
args: {
query: "createRoot",
},
},
],
},
{
text: "Now I'll search specifically in .tsx files for 'App' to find component references.",
toolCalls: [
{
name: "grep",
args: {
query: "App",
include_pattern: "*.tsx",
},
},
],
},
{
text: "I found the matches! The React app is initialized in src/main.tsx using createRoot, and the App component is defined in src/App.tsx and imported in src/main.tsx.",
},
],
};
import { expect } from "@playwright/test";
import { testSkipIfWindows } from "./helpers/test_helper";
/**
* E2E tests for the grep agent tool
* Tests searching file contents with ripgrep in local-agent mode
*/
testSkipIfWindows("local-agent - grep search", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
await po.sendPrompt("tc=local-agent/grep-search");
await po.page.getByTestId("dyad-grep").first().click();
await po.page.getByTestId("dyad-grep").nth(1).click();
await po.snapshotMessages();
await expect(po.page.getByTestId("dyad-grep").first()).toMatchAriaSnapshot();
await expect(po.page.getByTestId("dyad-grep").nth(1)).toMatchAriaSnapshot();
});
......@@ -192,6 +192,39 @@
}
}
},
{
"type": "function",
"function": {
"name": "grep",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The regex pattern to search for"
},
"include_pattern": {
"type": "string",
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)"
},
"exclude_pattern": {
"type": "string",
"description": "Glob pattern for files to exclude"
},
"case_sensitive": {
"type": "boolean",
"description": "Whether the search should be case sensitive (default: false)"
}
},
"required": [
"query"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
......
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=local-agent/grep-search
- paragraph: I'll search for 'createRoot' in the codebase to find where the React app is initialized.
- img
- text: GREP"createRoot"(2 matches)
- img
- paragraph: Now I'll search specifically in .tsx files for 'App' to find component references.
- img
- text: GREP"App" in *.tsx(4 matches)
- img
- paragraph: I found the matches! The React app is initialized in src/main.tsx using createRoot, and the App component is defined in src/App.tsx and imported in src/main.tsx.
- button:
- img
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Undo":
- img
- button "Retry":
- img
\ No newline at end of file
- img
- text: GREP"createRoot"(2 matches)
- img
- text: log
- button "Copy":
- img
- text: log
- code: "src/main.tsx:1: import { createRoot } from \"react-dom/client\"; src/main.tsx:4: createRoot(document.getElementById(\"root\")!).render(&lt;App /&gt;);"
\ No newline at end of file
- img
- text: GREP"App" in *.tsx(4 matches)
- img
- text: log
- button "Copy":
- img
- text: log
- code: "src/main.tsx:2: import App from \"./App.tsx\"; src/main.tsx:4: createRoot(document.getElementById(\"root\")!).render(&lt;App /&gt;); src/App.tsx:1: const App = () =&gt; &lt;div&gt;Minimal imported app&lt;/div&gt;; src/App.tsx:3: export default App;"
\ No newline at end of file
import type React from "react";
import type { ReactNode } from "react";
import { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
Search,
Loader,
CircleX,
} from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
import { CustomTagState } from "./stateTypes";
interface DyadGrepProps {
children?: ReactNode;
node?: {
properties?: {
state?: CustomTagState;
query?: string;
include?: string;
exclude?: string;
"case-sensitive"?: string;
count?: string;
};
};
}
export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => {
const [isContentVisible, setIsContentVisible] = useState(false);
// State handling
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const aborted = state === "aborted";
// Get properties from node
const query = node?.properties?.query || "";
const includePattern = node?.properties?.include || "";
const excludePattern = node?.properties?.exclude || "";
const caseSensitive = node?.properties?.["case-sensitive"] === "true";
const count = node?.properties?.count || "";
const hasResults = count !== "" && count !== "0";
// Build description
let description = `"${query}"`;
if (includePattern) {
description += ` in ${includePattern}`;
}
if (excludePattern) {
description += ` excluding ${excludePattern}`;
}
if (caseSensitive) {
description += " (case-sensitive)";
}
// Build result summary
const resultSummary = count
? `${count} match${count === "1" ? "" : "es"}`
: "";
// Dynamic border styling
const borderClass = inProgress
? "border-(--primary)"
: aborted
? "border-red-500"
: "border-(--primary)/30";
return (
<div
data-testid="dyad-grep"
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${borderClass}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Search size={16} className="text-(--primary)" />
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
<span className="font-bold mr-2 outline-2 outline-(--primary)/20 bg-(--primary)/10 text-(--primary) rounded-md px-1">
GREP
</span>
{description}
{resultSummary && (
<span className="ml-2 text-gray-500">({resultSummary})</span>
)}
</span>
{inProgress && (
<div className="flex items-center text-(--primary) text-xs">
<Loader size={14} className="mr-1 animate-spin" />
<span>Searching...</span>
</div>
)}
{aborted && (
<div className="flex items-center text-red-600 text-xs">
<CircleX size={14} className="mr-1" />
<span>Did not finish</span>
</div>
)}
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-(--primary)/70 hover:text-(--primary)"
/>
) : (
<ChevronsUpDown
size={20}
className="text-(--primary)/70 hover:text-(--primary)"
/>
)}
</div>
</div>
{isContentVisible && (
<div className={`text-xs${hasResults ? " mt-2" : ""}`}>
<CodeHighlight className="language-log">{children}</CodeHighlight>
</div>
)}
</div>
);
};
......@@ -7,6 +7,7 @@ import { DyadDelete } from "./DyadDelete";
import { DyadAddDependency } from "./DyadAddDependency";
import { DyadExecuteSql } from "./DyadExecuteSql";
import { DyadLogs } from "./DyadLogs";
import { DyadGrep } from "./DyadGrep";
import { DyadAddIntegration } from "./DyadAddIntegration";
import { DyadEdit } from "./DyadEdit";
import { DyadSearchReplace } from "./DyadSearchReplace";
......@@ -48,6 +49,7 @@ const DYAD_CUSTOM_TAGS = [
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-grep",
"dyad-search-replace",
"dyad-codebase-context",
"dyad-web-search-result",
......@@ -274,7 +276,7 @@ function parseCustomTags(content: string): ContentPiece[] {
// Parse attributes
const attributes: Record<string, string> = {};
const attrPattern = /(\w+)="([^"]*)"/g;
const attrPattern = /([\w-]+)="([^"]*)"/g;
let attrMatch;
while ((attrMatch = attrPattern.exec(attributesStr)) !== null) {
attributes[attrMatch[1]] = attrMatch[2];
......@@ -498,6 +500,24 @@ function renderCustomTag(
</DyadLogs>
);
case "dyad-grep":
return (
<DyadGrep
node={{
properties: {
state: getState({ isStreaming, inProgress }),
query: attributes.query || "",
include: attributes.include || "",
exclude: attributes.exclude || "",
"case-sensitive": attributes["case-sensitive"] || "",
count: attributes.count || "",
},
}}
>
{content}
</DyadGrep>
);
case "dyad-add-integration":
return (
<DyadAddIntegration
......
......@@ -68,37 +68,11 @@ import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"
import { AppSearchResult } from "@/lib/schemas";
import { getAppPort } from "../../../shared/ports";
import os from "node:os";
const MAX_FILE_SEARCH_SIZE = 1024 * 1024;
const RIPGREP_EXCLUDED_GLOBS = ["!node_modules/**", "!.git/**", "!.next/**"];
// Replace node_modules.asar with node_modules.asar.unpacked for Electron packaged apps
// This is necessary because native binaries are unpacked from the asar archive
function getRgExecutablePath(): string {
const isWindows = os.platform() === "win32";
const executableName = isWindows ? "rg.exe" : "rg";
if (!app.isPackaged) {
// Dev: app.getAppPath() is the project root (same pattern as dugite)
return path.join(
app.getAppPath(),
"node_modules",
"@vscode",
"ripgrep",
"bin",
executableName,
);
}
// Packaged app: ripgrep is bundled via extraResource
// Since we extract "node_modules/@vscode/ripgrep", it's at resources/@vscode/ripgrep
return path.join(
process.resourcesPath,
"@vscode",
"ripgrep",
"bin",
executableName,
);
}
import {
getRgExecutablePath,
MAX_FILE_SEARCH_SIZE,
RIPGREP_EXCLUDED_GLOBS,
} from "../utils/ripgrep_utils";
const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger);
......
/**
* Shared utilities for ripgrep integration
*/
import { app } from "electron";
import path from "node:path";
import os from "node:os";
export const MAX_FILE_SEARCH_SIZE = 1024 * 1024;
export const RIPGREP_EXCLUDED_GLOBS = [
"!node_modules/**",
"!.git/**",
"!.next/**",
];
/**
* Get the path to the ripgrep executable.
* Handles both development and packaged Electron app scenarios.
*/
export function getRgExecutablePath(): string {
const isWindows = os.platform() === "win32";
const executableName = isWindows ? "rg.exe" : "rg";
if (!app.isPackaged) {
// Dev: app.getAppPath() is the project root (same pattern as dugite)
return path.join(
app.getAppPath(),
"node_modules",
"@vscode",
"ripgrep",
"bin",
executableName,
);
}
// Packaged app: ripgrep is bundled via extraResource
// Since we extract "node_modules/@vscode/ripgrep", it's at resources/@vscode/ripgrep
return path.join(
process.resourcesPath,
"@vscode",
"ripgrep",
"bin",
executableName,
);
}
......@@ -24,6 +24,7 @@ import { webSearchTool } from "./tools/web_search";
import { webCrawlTool } from "./tools/web_crawl";
import { updateTodosTool } from "./tools/update_todos";
import { runTypeChecksTool } from "./tools/run_type_checks";
import { grepTool } from "./tools/grep";
import type { LanguageModelV3ToolResultOutput } from "@ai-sdk/provider";
import {
escapeXmlAttr,
......@@ -46,6 +47,7 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
// searchReplaceTool,
readFileTool,
listFilesTool,
grepTool,
getSupabaseProjectInfoTool,
getSupabaseTableSchemaTool,
setChatSummaryTool,
......
import { z } from "zod";
import { spawn } from "node:child_process";
import {
ToolDefinition,
AgentContext,
escapeXmlAttr,
escapeXmlContent,
} from "./types";
import {
getRgExecutablePath,
MAX_FILE_SEARCH_SIZE,
RIPGREP_EXCLUDED_GLOBS,
} from "@/ipc/utils/ripgrep_utils";
import log from "electron-log";
const logger = log.scope("grep");
const grepSchema = z.object({
query: z.string().describe("The regex pattern to search for"),
include_pattern: z
.string()
.optional()
.describe(
"Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
),
exclude_pattern: z
.string()
.optional()
.describe("Glob pattern for files to exclude"),
case_sensitive: z
.boolean()
.optional()
.describe("Whether the search should be case sensitive (default: false)"),
});
interface RipgrepMatch {
path: string;
lineNumber: number;
lineText: string;
}
function buildGrepAttributes(
args: Partial<z.infer<typeof grepSchema>>,
count?: number,
): string {
const attrs: string[] = [];
if (args.query) {
attrs.push(`query="${escapeXmlAttr(args.query)}"`);
}
if (args.include_pattern) {
attrs.push(`include="${escapeXmlAttr(args.include_pattern)}"`);
}
if (args.exclude_pattern) {
attrs.push(`exclude="${escapeXmlAttr(args.exclude_pattern)}"`);
}
if (args.case_sensitive) {
attrs.push(`case-sensitive="true"`);
}
if (count !== undefined) {
attrs.push(`count="${count}"`);
}
return attrs.join(" ");
}
async function runRipgrep({
appPath,
query,
includePat,
excludePat,
caseSensitive,
}: {
appPath: string;
query: string;
includePat?: string;
excludePat?: string;
caseSensitive?: boolean;
}): Promise<RipgrepMatch[]> {
return new Promise((resolve, reject) => {
const results: RipgrepMatch[] = [];
const args: string[] = [
"--json",
"--no-config",
"--max-filesize",
`${MAX_FILE_SEARCH_SIZE}`,
...RIPGREP_EXCLUDED_GLOBS.flatMap((glob) => ["--glob", glob]),
];
// Case sensitivity: default is case-insensitive
if (!caseSensitive) {
args.push("--ignore-case");
}
// Include pattern
if (includePat) {
args.push("--glob", includePat);
}
// Exclude pattern
if (excludePat) {
args.push("--glob", `!${excludePat}`);
}
args.push("--", query, ".");
const rg = spawn(getRgExecutablePath(), args, { cwd: appPath });
let buffer = "";
rg.stdout.on("data", (data) => {
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type !== "match" || !event.data) {
continue;
}
const matchPath = event.data.path?.text as string;
if (!matchPath) continue;
const lineText = event.data.lines?.text as string;
const lineNumber = event.data.line_number as number;
if (typeof lineText !== "string" || typeof lineNumber !== "number") {
continue;
}
// Normalize path (remove leading ./)
const normalizedPath = matchPath.replace(/^\.\//, "");
results.push({
path: normalizedPath,
lineNumber,
lineText: lineText.replace(/\r?\n$/, ""),
});
} catch {
// Skip malformed JSON lines
}
}
});
rg.stderr.on("data", (data) => {
logger.warn("ripgrep stderr", data.toString());
});
rg.on("close", (code) => {
// rg exits with code 1 when no matches are found; treat as success
if (code !== 0 && code !== 1) {
reject(new Error(`ripgrep exited with code ${code}`));
return;
}
resolve(results);
});
rg.on("error", (error) => {
reject(error);
});
});
}
export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
name: "grep",
description: `Search for a regex pattern in the codebase using ripgrep.
- Returns matching lines with file paths and line numbers
- By default, the search is case-insensitive
- Use include_pattern to filter by file type (e.g. '*.tsx')
- Use exclude_pattern to skip certain files (e.g. '*.test.ts')`,
inputSchema: grepSchema,
defaultConsent: "always",
getConsentPreview: (args) => {
let preview = `Search for "${args.query}"`;
if (args.include_pattern) {
preview += ` in ${args.include_pattern}`;
}
return preview;
},
buildXml: (args, isComplete) => {
// When complete, return undefined so execute's onXmlComplete provides the final XML
if (isComplete) {
return undefined;
}
if (!args.query) return undefined;
const attrs = buildGrepAttributes(args);
return `<dyad-grep ${attrs}>Searching...</dyad-grep>`;
},
execute: async (args, ctx: AgentContext) => {
const matches = await runRipgrep({
appPath: ctx.appPath,
query: args.query,
includePat: args.include_pattern,
excludePat: args.exclude_pattern,
caseSensitive: args.case_sensitive,
});
const attrs = buildGrepAttributes(args, matches.length);
if (matches.length === 0) {
ctx.onXmlComplete(`<dyad-grep ${attrs}>No matches found.</dyad-grep>`);
return "No matches found.";
}
// Format output: path:line: content
const lines = matches.map(
(m) => `${m.path}:${m.lineNumber}: ${m.lineText}`,
);
const resultText = lines.join("\n");
ctx.onXmlComplete(
`<dyad-grep ${attrs}>\n${escapeXmlContent(resultText)}\n</dyad-grep>`,
);
return resultText;
},
};
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论