diff --git a/package-lock.json b/package-lock.json index 217a29a..d930e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vibe-kit/grok-cli", - "version": "0.0.2", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vibe-kit/grok-cli", - "version": "0.0.2", + "version": "0.0.4", "license": "MIT", "dependencies": { "axios": "^1.6.0", @@ -20,6 +20,7 @@ "ink-markdown": "^1.0.4", "openai": "^5.10.1", "react": "^17.0.2", + "ripgrep-node": "^1.0.0", "tiktoken": "^1.0.21" }, "bin": { @@ -3238,6 +3239,12 @@ "node": ">=0.10.0" } }, + "node_modules/ripgrep-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ripgrep-node/-/ripgrep-node-1.0.0.tgz", + "integrity": "sha512-r/DBVFwrgHLpPvUZquwvRLE+UFLwB6NaeQVAetS3zOlUdyH3i20QBMzBMr7VU9702j0OxuJJ88WxRnAnjrD5DQ==", + "license": "GPL-3.0-or-later" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index fc6d21b..1088370 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "ink-markdown": "^1.0.4", "openai": "^5.10.1", "react": "^17.0.2", + "ripgrep-node": "^1.0.0", "tiktoken": "^1.0.21" }, "devDependencies": { diff --git a/src/agent/grok-agent.ts b/src/agent/grok-agent.ts index 229b0aa..6abf627 100644 --- a/src/agent/grok-agent.ts +++ b/src/agent/grok-agent.ts @@ -1,6 +1,12 @@ import { GrokClient, GrokMessage, GrokToolCall } from "../grok/client"; import { GROK_TOOLS } from "../grok/tools"; -import { TextEditorTool, BashTool, TodoTool, ConfirmationTool } from "../tools"; +import { + TextEditorTool, + BashTool, + TodoTool, + ConfirmationTool, + SearchTool, +} from "../tools"; import { ToolResult } from "../types"; import { EventEmitter } from "events"; import { createTokenCounter, TokenCounter } from "../utils/token-counter"; @@ -31,6 +37,7 @@ export class GrokAgent extends EventEmitter { private bash: BashTool; private todoTool: TodoTool; private confirmationTool: ConfirmationTool; + private search: SearchTool; private chatHistory: ChatEntry[] = []; private messages: GrokMessage[] = []; private tokenCounter: TokenCounter; @@ -43,6 +50,7 @@ export class GrokAgent extends EventEmitter { this.bash = new BashTool(); this.todoTool = new TodoTool(); this.confirmationTool = new ConfirmationTool(); + this.search = new SearchTool(); this.tokenCounter = createTokenCounter("grok-4-latest"); // Load custom instructions @@ -61,6 +69,7 @@ You have access to these tools: - create_file: Create new files with content (ONLY use this for files that don't exist yet) - str_replace_editor: Replace text in existing files (ALWAYS use this to edit or update existing files) - bash: Execute bash commands (use for searching, file discovery, navigation, and system operations) +- search: Unified search tool for finding text content or files (similar to Cursor's search functionality) - create_todo_list: Create a visual todo list for planning and tracking tasks - update_todo_list: Update existing todos in your todo list @@ -74,9 +83,9 @@ IMPORTANT TOOL USAGE RULES: - Use create_file ONLY when creating entirely new files that don't exist SEARCHING AND EXPLORATION: -- Use bash with commands like 'find', 'grep', 'rg' (ripgrep), 'ls', etc. for searching files and content -- Examples: 'find . -name "*.js"', 'grep -r "function" src/', 'rg "import.*react"' -- Use bash for directory navigation, file discovery, and content searching +- Use search for fast, powerful text search across files or finding files by name (unified search tool) +- Examples: search for text content like "import.*react", search for files like "component.tsx" +- Use bash with commands like 'find', 'grep', 'rg', 'ls' for complex file operations and navigation - view_file is best for reading specific files you already know exist When a user asks you to edit, update, modify, or change an existing file: @@ -550,6 +559,19 @@ Current working directory: ${process.cwd()}`, case "update_todo_list": return await this.todoTool.updateTodoList(args.updates); + case "search": + return await this.search.search(args.query, { + searchType: args.search_type, + includePattern: args.include_pattern, + excludePattern: args.exclude_pattern, + caseSensitive: args.case_sensitive, + wholeWord: args.whole_word, + regex: args.regex, + maxResults: args.max_results, + fileTypes: args.file_types, + includeHidden: args.include_hidden, + }); + default: return { success: false, diff --git a/src/grok/tools.ts b/src/grok/tools.ts index c0e6811..175180c 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -62,7 +62,8 @@ export const GROK_TOOLS: GrokTool[] = [ }, old_str: { type: "string", - description: "Text to replace (must match exactly, or will use fuzzy matching for multi-line strings)", + description: + "Text to replace (must match exactly, or will use fuzzy matching for multi-line strings)", }, new_str: { type: "string", @@ -70,7 +71,8 @@ export const GROK_TOOLS: GrokTool[] = [ }, replace_all: { type: "boolean", - description: "Replace all occurrences (default: false, only replaces first occurrence)", + description: + "Replace all occurrences (default: false, only replaces first occurrence)", }, }, required: ["path", "old_str", "new_str"], @@ -94,6 +96,66 @@ export const GROK_TOOLS: GrokTool[] = [ }, }, }, + { + type: "function", + function: { + name: "search", + description: + "Unified search tool for finding text content or files (similar to Cursor's search)", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "Text to search for or file name/path pattern", + }, + search_type: { + type: "string", + enum: ["text", "files", "both"], + description: + "Type of search: 'text' for content search, 'files' for file names, 'both' for both (default: 'both')", + }, + include_pattern: { + type: "string", + description: + "Glob pattern for files to include (e.g. '*.ts', '*.js')", + }, + exclude_pattern: { + type: "string", + description: + "Glob pattern for files to exclude (e.g. '*.log', 'node_modules')", + }, + case_sensitive: { + type: "boolean", + description: + "Whether search should be case sensitive (default: false)", + }, + whole_word: { + type: "boolean", + description: "Whether to match whole words only (default: false)", + }, + regex: { + type: "boolean", + description: "Whether query is a regex pattern (default: false)", + }, + max_results: { + type: "number", + description: "Maximum number of results to return (default: 50)", + }, + file_types: { + type: "array", + items: { type: "string" }, + description: "File types to search (e.g. ['js', 'ts', 'py'])", + }, + include_hidden: { + type: "boolean", + description: "Whether to include hidden files (default: false)", + }, + }, + required: ["query"], + }, + }, + }, { type: "function", function: { diff --git a/src/tools/index.ts b/src/tools/index.ts index b7406b0..ea0df21 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,5 @@ -export { TextEditorTool } from "./text-editor"; export { BashTool } from "./bash"; +export { TextEditorTool } from "./text-editor"; export { TodoTool } from "./todo-tool"; export { ConfirmationTool } from "./confirmation-tool"; +export { SearchTool } from "./search"; diff --git a/src/tools/search.ts b/src/tools/search.ts new file mode 100644 index 0000000..be76806 --- /dev/null +++ b/src/tools/search.ts @@ -0,0 +1,443 @@ +import { spawn } from "child_process"; +import { ToolResult } from "../types"; +import { ConfirmationService } from "../utils/confirmation-service"; +import * as fs from "fs-extra"; +import * as path from "path"; + +export interface SearchResult { + file: string; + line: number; + column: number; + text: string; + match: string; +} + +export interface FileSearchResult { + path: string; + name: string; + score: number; +} + +export interface UnifiedSearchResult { + type: "text" | "file"; + file: string; + line?: number; + column?: number; + text?: string; + match?: string; + score?: number; +} + +export class SearchTool { + private confirmationService = ConfirmationService.getInstance(); + private currentDirectory: string = process.cwd(); + + /** + * Unified search method that can search for text content or find files + */ + async search( + query: string, + options: { + searchType?: "text" | "files" | "both"; + includePattern?: string; + excludePattern?: string; + caseSensitive?: boolean; + wholeWord?: boolean; + regex?: boolean; + maxResults?: number; + fileTypes?: string[]; + excludeFiles?: string[]; + includeHidden?: boolean; + } = {} + ): Promise { + try { + const searchType = options.searchType || "both"; + const results: UnifiedSearchResult[] = []; + + // Search for text content if requested + if (searchType === "text" || searchType === "both") { + const textResults = await this.executeRipgrep(query, options); + results.push( + ...textResults.map((r) => ({ + type: "text" as const, + file: r.file, + line: r.line, + column: r.column, + text: r.text, + match: r.match, + })) + ); + } + + // Search for files if requested + if (searchType === "files" || searchType === "both") { + const fileResults = await this.findFilesByPattern(query, options); + results.push( + ...fileResults.map((r) => ({ + type: "file" as const, + file: r.path, + score: r.score, + })) + ); + } + + if (results.length === 0) { + return { + success: true, + output: `No results found for "${query}"`, + }; + } + + const formattedOutput = this.formatUnifiedResults( + results, + query, + searchType + ); + + return { + success: true, + output: formattedOutput, + }; + } catch (error: any) { + return { + success: false, + error: `Search error: ${error.message}`, + }; + } + } + + /** + * Execute ripgrep command with specified options + */ + private async executeRipgrep( + query: string, + options: { + includePattern?: string; + excludePattern?: string; + caseSensitive?: boolean; + wholeWord?: boolean; + regex?: boolean; + maxResults?: number; + fileTypes?: string[]; + excludeFiles?: string[]; + } + ): Promise { + return new Promise((resolve, reject) => { + const args = [ + "--json", + "--with-filename", + "--line-number", + "--column", + "--no-heading", + "--color=never", + ]; + + // Add case sensitivity + if (!options.caseSensitive) { + args.push("--ignore-case"); + } + + // Add whole word matching + if (options.wholeWord) { + args.push("--word-regexp"); + } + + // Add regex mode + if (!options.regex) { + args.push("--fixed-strings"); + } + + // Add max results limit + if (options.maxResults) { + args.push("--max-count", options.maxResults.toString()); + } + + // Add file type filters + if (options.fileTypes) { + options.fileTypes.forEach((type) => { + args.push("--type", type); + }); + } + + // Add include pattern + if (options.includePattern) { + args.push("--glob", options.includePattern); + } + + // Add exclude pattern + if (options.excludePattern) { + args.push("--glob", `!${options.excludePattern}`); + } + + // Add exclude files + if (options.excludeFiles) { + options.excludeFiles.forEach((file) => { + args.push("--glob", `!${file}`); + }); + } + + // Respect gitignore and common ignore patterns + args.push( + "--no-require-git", + "--follow", + "--glob", + "!.git/**", + "--glob", + "!node_modules/**", + "--glob", + "!.DS_Store", + "--glob", + "!*.log" + ); + + // Add query and search directory + args.push(query, this.currentDirectory); + + const rg = spawn("rg", args); + let output = ""; + let errorOutput = ""; + + rg.stdout.on("data", (data) => { + output += data.toString(); + }); + + rg.stderr.on("data", (data) => { + errorOutput += data.toString(); + }); + + rg.on("close", (code) => { + if (code === 0 || code === 1) { + // 0 = found, 1 = not found + const results = this.parseRipgrepOutput(output); + resolve(results); + } else { + reject(new Error(`Ripgrep failed with code ${code}: ${errorOutput}`)); + } + }); + + rg.on("error", (error) => { + reject(error); + }); + }); + } + + /** + * Parse ripgrep JSON output into SearchResult objects + */ + private parseRipgrepOutput(output: string): SearchResult[] { + const results: SearchResult[] = []; + const lines = output + .trim() + .split("\n") + .filter((line) => line.length > 0); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.type === "match") { + const data = parsed.data; + results.push({ + file: data.path.text, + line: data.line_number, + column: data.submatches[0]?.start || 0, + text: data.lines.text.trim(), + match: data.submatches[0]?.match?.text || "", + }); + } + } catch (e) { + // Skip invalid JSON lines + continue; + } + } + + return results; + } + + /** + * Find files by pattern using a simple file walking approach + */ + private async findFilesByPattern( + pattern: string, + options: { + maxResults?: number; + includeHidden?: boolean; + excludePattern?: string; + } + ): Promise { + const files: FileSearchResult[] = []; + const maxResults = options.maxResults || 50; + const searchPattern = pattern.toLowerCase(); + + const walkDir = async (dir: string, depth: number = 0): Promise => { + if (depth > 10 || files.length >= maxResults) return; // Prevent infinite recursion and limit results + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (files.length >= maxResults) break; + + const fullPath = path.join(dir, entry.name); + const relativePath = path.relative(this.currentDirectory, fullPath); + + // Skip hidden files unless explicitly included + if (!options.includeHidden && entry.name.startsWith(".")) { + continue; + } + + // Skip common directories + if ( + entry.isDirectory() && + [ + "node_modules", + ".git", + ".svn", + ".hg", + "dist", + "build", + ".next", + ".cache", + ].includes(entry.name) + ) { + continue; + } + + // Apply exclude pattern + if ( + options.excludePattern && + relativePath.includes(options.excludePattern) + ) { + continue; + } + + if (entry.isFile()) { + const score = this.calculateFileScore( + entry.name, + relativePath, + searchPattern + ); + if (score > 0) { + files.push({ + path: relativePath, + name: entry.name, + score, + }); + } + } else if (entry.isDirectory()) { + await walkDir(fullPath, depth + 1); + } + } + } catch (error) { + // Skip directories we can't read + } + }; + + await walkDir(this.currentDirectory); + + // Sort by score (descending) and return top results + return files.sort((a, b) => b.score - a.score).slice(0, maxResults); + } + + /** + * Calculate fuzzy match score for file names + */ + private calculateFileScore( + fileName: string, + filePath: string, + pattern: string + ): number { + const lowerFileName = fileName.toLowerCase(); + const lowerFilePath = filePath.toLowerCase(); + + // Exact matches get highest score + if (lowerFileName === pattern) return 100; + if (lowerFileName.includes(pattern)) return 80; + + // Path matches get medium score + if (lowerFilePath.includes(pattern)) return 60; + + // Fuzzy matching - check if all characters of pattern exist in order + let patternIndex = 0; + for ( + let i = 0; + i < lowerFileName.length && patternIndex < pattern.length; + i++ + ) { + if (lowerFileName[i] === pattern[patternIndex]) { + patternIndex++; + } + } + + if (patternIndex === pattern.length) { + // All characters found in order - score based on how close they are + return Math.max(10, 40 - (fileName.length - pattern.length)); + } + + return 0; + } + + /** + * Format unified search results for display + */ + private formatUnifiedResults( + results: UnifiedSearchResult[], + query: string, + searchType: string + ): string { + if (results.length === 0) { + return `No results found for "${query}"`; + } + + let output = `Search results for "${query}":\n`; + + // Separate text and file results + const textResults = results.filter((r) => r.type === "text"); + const fileResults = results.filter((r) => r.type === "file"); + + // Show all unique files (from both text matches and file matches) + const allFiles = new Set(); + + // Add files from text results + textResults.forEach((result) => { + allFiles.add(result.file); + }); + + // Add files from file search results + fileResults.forEach((result) => { + allFiles.add(result.file); + }); + + const fileList = Array.from(allFiles); + const displayLimit = 8; + + // Show files in compact format + fileList.slice(0, displayLimit).forEach((file) => { + // Count matches in this file for text results + const matchCount = textResults.filter((r) => r.file === file).length; + const matchIndicator = matchCount > 0 ? ` (${matchCount} matches)` : ""; + output += ` ${file}${matchIndicator}\n`; + }); + + // Show "+X more" if there are additional results + if (fileList.length > displayLimit) { + const remaining = fileList.length - displayLimit; + output += ` ... +${remaining} more\n`; + } + + return output.trim(); + } + + /** + * Update current working directory + */ + setCurrentDirectory(directory: string): void { + this.currentDirectory = directory; + } + + /** + * Get current working directory + */ + getCurrentDirectory(): string { + return this.currentDirectory; + } +} diff --git a/src/ui/components/chat-history.tsx b/src/ui/components/chat-history.tsx index 3b6a951..c1011c9 100644 --- a/src/ui/components/chat-history.tsx +++ b/src/ui/components/chat-history.tsx @@ -92,7 +92,8 @@ export function ChatHistory({ return "Create"; case "bash": return "Bash"; - + case "search": + return "Search"; case "create_todo_list": return "Created Todo"; case "update_todo_list": @@ -114,6 +115,11 @@ export function ChatHistory({ return ""; } + // Handle search tool specially - show the query + if (toolCall.function.name === "search") { + return args.query || "unknown"; + } + return args.path || args.file_path || args.command || "unknown"; } catch { return "unknown";