diff --git a/src/hooks/use-enhanced-input.ts b/src/hooks/use-enhanced-input.ts index 299f27d..da6e8c5 100644 --- a/src/hooks/use-enhanced-input.ts +++ b/src/hooks/use-enhanced-input.ts @@ -36,6 +36,7 @@ export interface EnhancedInputHook { isMultiline: boolean; setInput: (text: string) => void; setCursorPosition: (position: number) => void; + setInputWithCursor: (text: string, position: number) => void; clearInput: () => void; insertAtCursor: (text: string) => void; resetHistory: () => void; @@ -81,6 +82,14 @@ export function useEnhancedInput({ setCursorPositionState(Math.max(0, Math.min(input.length, position))); }, [input.length]); + const setInputWithCursor = useCallback((text: string, position: number) => { + setInputState(text); + setCursorPositionState(Math.max(0, Math.min(text.length, position))); + if (!isNavigatingHistory()) { + setOriginalInput(text); + } + }, [isNavigatingHistory, setOriginalInput]); + const clearInput = useCallback(() => { setInputState(""); setCursorPositionState(0); @@ -290,6 +299,7 @@ export function useEnhancedInput({ isMultiline: isMultilineRef.current, setInput, setCursorPosition, + setInputWithCursor, clearInput, insertAtCursor, resetHistory, diff --git a/src/hooks/use-input-handler.ts b/src/hooks/use-input-handler.ts index 847d137..a9f1de9 100644 --- a/src/hooks/use-input-handler.ts +++ b/src/hooks/use-input-handler.ts @@ -6,6 +6,7 @@ import { useEnhancedInput, Key } from "./use-enhanced-input"; import { filterCommandSuggestions } from "../ui/components/command-suggestions"; import { loadModelConfig, updateCurrentModel } from "../utils/model-config"; +import { FileFinder, FileSuggestion } from "../utils/file-finder"; interface UseInputHandlerProps { agent: GrokAgent; @@ -30,6 +31,12 @@ interface ModelOption { model: string; } + +interface AttachedFile { + path: string; + content: string; +} + export function useInputHandler({ agent, chatHistory, @@ -52,6 +59,13 @@ export function useInputHandler({ const sessionFlags = confirmationService.getSessionFlags(); return sessionFlags.allOperations; }); + + // File selection state + const [showFilePicker, setShowFilePicker] = useState(false); + const [selectedFileIndex, setSelectedFileIndex] = useState(0); + const [fileQuery, setFileQuery] = useState(""); + const [attachedFiles, setAttachedFiles] = useState([]); + const [fileFinder] = useState(() => new FileFinder()); const handleSpecialKey = (key: Key): boolean => { // Don't handle input if confirmation dialog is active @@ -87,6 +101,12 @@ export function useInputHandler({ setSelectedModelIndex(0); return true; } + if (showFilePicker) { + setShowFilePicker(false); + setSelectedFileIndex(0); + setFileQuery(""); + return true; + } if (isProcessing || isStreaming) { agent.abortCurrentOperation(); setIsProcessing(false); @@ -166,6 +186,40 @@ export function useInputHandler({ return true; } } + + // Handle file picker navigation + if (showFilePicker) { + const fileSuggestions = fileFinder.getFileSuggestions(fileQuery); + + if (fileSuggestions.length === 0) { + setShowFilePicker(false); + setSelectedFileIndex(0); + setFileQuery(""); + return false; + } + + if (key.upArrow) { + setSelectedFileIndex((prev) => + prev === 0 ? fileSuggestions.length - 1 : prev - 1 + ); + return true; + } + if (key.downArrow) { + setSelectedFileIndex((prev) => (prev + 1) % fileSuggestions.length); + return true; + } + if (key.tab || key.return) { + const selectedFile = fileSuggestions[selectedFileIndex]; + if (!selectedFile.isDirectory) { + // Attach file to prompt + attachFileToPrompt(selectedFile.relativePath); + } + setShowFilePicker(false); + setSelectedFileIndex(0); + setFileQuery(""); + return true; + } + } return false; // Let default handling proceed }; @@ -189,10 +243,66 @@ export function useInputHandler({ if (newInput.startsWith("/")) { setShowCommandSuggestions(true); setSelectedCommandIndex(0); + setShowFilePicker(false); + setFileQuery(""); } else { setShowCommandSuggestions(false); setSelectedCommandIndex(0); } + + // Handle @ file selection (only the current @-token up to whitespace) + const searchPos = Math.max(0, cursorPosition - 1); + const atIndex = newInput.lastIndexOf("@", searchPos); + if (atIndex !== -1) { + const segment = newInput.slice(atIndex + 1, cursorPosition); + const containsWhitespace = /\s/.test(segment); + + if (!containsWhitespace) { + setShowFilePicker(true); + setFileQuery(segment); + setSelectedFileIndex(0); + setShowCommandSuggestions(false); + return; + } + } + setShowFilePicker(false); + setFileQuery(""); + }; + + const attachFileToPrompt = async (relativePath: string) => { + try { + const content = await fileFinder.readFileContent(relativePath); + if (content) { + const newAttachedFile: AttachedFile = { + path: relativePath, + content + }; + setAttachedFiles(prev => [...prev, newAttachedFile]); + + // Replace @query with @filename in input and position cursor at end + const searchPos = Math.max(0, cursorPosition - 1); + const atIndex = input.lastIndexOf("@", searchPos); + if (atIndex !== -1) { + const beforeAt = input.slice(0, atIndex); + // Find end of current token (first whitespace after '@') + const rest = input.slice(atIndex + 1); + const whitespaceRel = rest.search(/\s/); + const tokenEnd = whitespaceRel === -1 ? input.length : atIndex + 1 + whitespaceRel; + + const afterQuery = input.slice(tokenEnd); + const insertion = `@${relativePath} `; + const newInput = beforeAt + insertion + afterQuery; + const newCursor = beforeAt.length + insertion.length; + setInputWithCursor(newInput, newCursor); + } + } + } catch (error) { + console.error("Error attaching file:", error); + } + }; + + const removeAttachedFile = (pathToRemove: string) => { + setAttachedFiles(prev => prev.filter(file => file.path !== pathToRemove)); }; const { @@ -200,6 +310,7 @@ export function useInputHandler({ cursorPosition, setInput, setCursorPosition, + setInputWithCursor, clearInput, resetHistory, handleInput, @@ -608,12 +719,26 @@ Respond with ONLY the commit message, no additional text.`; }; const processUserMessage = async (userInput: string) => { + // Display user input without file content in chat history const userEntry: ChatEntry = { type: "user", content: userInput, timestamp: new Date(), }; setChatHistory((prev) => [...prev, userEntry]); + + // Prepare the actual message content with attached files for AI processing + let messageContent = userInput; + + if (attachedFiles.length > 0) { + messageContent += "\n\n--- Attached Files ---\n"; + for (const file of attachedFiles) { + messageContent += `\n**File: ${file.path}**\n\`\`\`\n${file.content}\n\`\`\`\n`; + } + } + + // Clear attached files after processing + setAttachedFiles([]); setIsProcessing(true); clearInput(); @@ -622,7 +747,7 @@ Respond with ONLY the commit message, no additional text.`; setIsStreaming(true); let streamingEntry: ChatEntry | null = null; - for await (const chunk of agent.processUserMessageStream(userInput)) { + for await (const chunk of agent.processUserMessageStream(messageContent)) { switch (chunk.type) { case "content": if (chunk.content) { @@ -748,5 +873,12 @@ Respond with ONLY the commit message, no additional text.`; availableModels, agent, autoEditEnabled, + // File picker state + showFilePicker, + selectedFileIndex, + fileQuery, + attachedFiles, + fileSuggestions: fileFinder.getFileSuggestions(fileQuery), + removeAttachedFile, }; } diff --git a/src/ui/components/chat-interface.tsx b/src/ui/components/chat-interface.tsx index b4490ca..b65d8a7 100644 --- a/src/ui/components/chat-interface.tsx +++ b/src/ui/components/chat-interface.tsx @@ -7,6 +7,7 @@ import { CommandSuggestions } from "./command-suggestions"; import { ModelSelection } from "./model-selection"; import { ChatHistory } from "./chat-history"; import { ChatInput } from "./chat-input"; +import { FilePicker } from "./file-picker"; import { MCPStatus } from "./mcp-status"; import ConfirmationDialog from "./confirmation-dialog"; import { @@ -44,6 +45,13 @@ function ChatInterfaceWithAgent({ agent }: { agent: GrokAgent }) { commandSuggestions, availableModels, autoEditEnabled, + // File picker state + showFilePicker, + selectedFileIndex, + fileQuery, + attachedFiles, + fileSuggestions, + removeAttachedFile, } = useInputHandler({ agent, chatHistory, @@ -166,12 +174,15 @@ function ChatInterfaceWithAgent({ agent }: { agent: GrokAgent }) { 2. Be specific for the best results. - 3. Create GROK.md files to customize your interactions with Grok. + 3. Use @ to attach files to your prompt (e.g., "@package.json"). - 4. Press Shift+Tab to toggle auto-edit mode. + 4. Create GROK.md files to customize your interactions with Grok. - 5. /help for more information. + + 5. Press Shift+Tab to toggle auto-edit mode. + + 6. /help for more information. )} @@ -243,6 +254,13 @@ function ChatInterfaceWithAgent({ agent }: { agent: GrokAgent }) { isVisible={showModelSelection} currentModel={agent.getCurrentModel()} /> + + )} diff --git a/src/ui/components/file-picker.tsx b/src/ui/components/file-picker.tsx new file mode 100644 index 0000000..7b9e2ab --- /dev/null +++ b/src/ui/components/file-picker.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { Box, Text } from "ink"; +import path from "path"; +import { FileSuggestion } from "../../utils/file-finder"; + +interface FilePickerProps { + suggestions: FileSuggestion[]; + selectedIndex: number; + query: string; + isVisible: boolean; +} + +export function FilePicker({ + suggestions, + selectedIndex, + query, + isVisible, +}: FilePickerProps) { + if (!isVisible || suggestions.length === 0) { + return null; + } + + const maxVisible = 8; + const startIndex = Math.max(0, selectedIndex - Math.floor(maxVisible / 2)); + const endIndex = Math.min(suggestions.length, startIndex + maxVisible); + const adjustedStartIndex = Math.max(0, endIndex - maxVisible); + + const visibleSuggestions = suggestions.slice(adjustedStartIndex, endIndex); + const adjustedSelectedIndex = selectedIndex - adjustedStartIndex; + + return ( + + + + File Selection {query && `(${query})`} + + {suggestions.length > maxVisible && ( + + {selectedIndex + 1}/{suggestions.length} files + + )} + + + {adjustedStartIndex > 0 && ( + + + ↑ {adjustedStartIndex} more above + + + )} + + {visibleSuggestions.map((suggestion, index) => { + const isSelected = index === adjustedSelectedIndex; + const displayPath = suggestion.isDirectory ? `${suggestion.relativePath}/` : suggestion.relativePath; + + return ( + + + {isSelected ? "❯ " : " "} + {displayPath} + + + ); + })} + + {endIndex < suggestions.length && ( + + + ↓ {suggestions.length - endIndex} more below + + + )} + + + + ↑/↓ Navigate • Tab/Enter Select • Esc Cancel + + + + ); +} \ No newline at end of file diff --git a/src/utils/file-finder.ts b/src/utils/file-finder.ts new file mode 100644 index 0000000..8319cd6 --- /dev/null +++ b/src/utils/file-finder.ts @@ -0,0 +1,203 @@ +import * as fs from "fs"; +import * as path from "path"; + +export interface FileSuggestion { + file: string; + relativePath: string; + isDirectory: boolean; +} + +export class FileFinder { + private fileCache: Map = new Map(); + private maxDepth: number = 4; + private maxFiles: number = 5000; + private includeDotfiles: boolean = false; + + constructor(private workingDirectory: string = process.cwd()) { + const depthEnv = parseInt(process.env.GROK_FILEPICKER_MAX_DEPTH || "", 10); + const filesEnv = parseInt(process.env.GROK_FILEPICKER_MAX_FILES || "", 10); + const includeDotsEnv = (process.env.GROK_FILEPICKER_INCLUDE_DOTFILES || "").toLowerCase(); + + if (!Number.isNaN(depthEnv) && depthEnv > 0) this.maxDepth = depthEnv; + if (!Number.isNaN(filesEnv) && filesEnv > 0) this.maxFiles = filesEnv; + this.includeDotfiles = includeDotsEnv === "1" || includeDotsEnv === "true"; + } + + /** + * Get file suggestions based on query + */ + getFileSuggestions(query: string): FileSuggestion[] { + const cacheKey = this.workingDirectory; + + // Use cached results if available + if (!this.fileCache.has(cacheKey)) { + this.buildFileCache(); + } + + const allFiles = this.fileCache.get(cacheKey) || []; + + if (!query) { + return allFiles.slice(0, 20); // Return first 20 files when no query + } + + // Filter files based on query + const filtered = allFiles.filter(file => + file.relativePath.toLowerCase().includes(query.toLowerCase()) || + path.basename(file.relativePath).toLowerCase().includes(query.toLowerCase()) + ); + + // Sort by relevance (exact matches first, then partial matches) + return filtered.sort((a, b) => { + const aBasename = path.basename(a.relativePath).toLowerCase(); + const bBasename = path.basename(b.relativePath).toLowerCase(); + const queryLower = query.toLowerCase(); + + // Exact basename matches first + if (aBasename === queryLower && bBasename !== queryLower) return -1; + if (bBasename === queryLower && aBasename !== queryLower) return 1; + + // Basename starts with query + if (aBasename.startsWith(queryLower) && !bBasename.startsWith(queryLower)) return -1; + if (bBasename.startsWith(queryLower) && !aBasename.startsWith(queryLower)) return 1; + + // Path starts with query + if (a.relativePath.toLowerCase().startsWith(queryLower) && + !b.relativePath.toLowerCase().startsWith(queryLower)) return -1; + if (b.relativePath.toLowerCase().startsWith(queryLower) && + !a.relativePath.toLowerCase().startsWith(queryLower)) return 1; + + // Shorter paths first + return a.relativePath.length - b.relativePath.length; + }).slice(0, 50); + } + + /** + * Build file cache by recursively scanning directories + */ + private buildFileCache(): void { + const files: FileSuggestion[] = []; + const visited = new Set(); + + const shouldIgnore = (filePath: string): boolean => { + const basename = path.basename(filePath); + const ignoredPatterns = [ + // Hidden files (optional) + ...(this.includeDotfiles ? [] : [/^\./]), + /node_modules/, + /\.git$/, + /dist$/, + /build$/, + /coverage$/, + /\.next$/, + /\.nuxt$/, + /\.output$/, + /target$/, // Rust + /__pycache__$/, + /\.pyc$/, + /\.DS_Store$/, + /Thumbs\.db$/, + ]; + + return ignoredPatterns.some( + (pattern) => pattern.test(basename) || pattern.test(filePath) + ); + }; + + const scanDirectory = (dir: string, depth: number = 0): void => { + if (depth > this.maxDepth || files.length > this.maxFiles) return; + + try { + const realPath = fs.realpathSync(dir); + if (visited.has(realPath)) return; // Avoid circular references + visited.add(realPath); + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.relative(this.workingDirectory, fullPath); + + if (shouldIgnore(fullPath)) continue; + + const suggestion: FileSuggestion = { + file: entry.name, + relativePath: relativePath || entry.name, + isDirectory: entry.isDirectory(), + }; + + files.push(suggestion); + + // Recursively scan subdirectories + if (entry.isDirectory() && depth < this.maxDepth) { + scanDirectory(fullPath, depth + 1); + } + } + } catch (error) { + // Silently ignore errors (permission issues, etc.) + } + }; + + scanDirectory(this.workingDirectory); + this.fileCache.set(this.workingDirectory, files); + } + + /** + * Clear the file cache (useful when files change) + */ + clearCache(): void { + this.fileCache.clear(); + } + + /** + * Read file content for attaching to prompt + */ + async readFileContent(relativePath: string): Promise { + try { + const fullPath = path.resolve(this.workingDirectory, relativePath); + + // Security check - ensure file is within working directory + const resolvedPath = path.resolve(fullPath); + const resolvedWorkDir = path.resolve(this.workingDirectory); + if (!resolvedPath.startsWith(resolvedWorkDir)) { + throw new Error("File access outside working directory not allowed"); + } + + const stats = fs.statSync(fullPath); + if (!stats.isFile()) { + return null; // Not a file + } + + // Don't read very large files (>1MB) + if (stats.size > 1024 * 1024) { + return `[File too large: ${relativePath} (${Math.round(stats.size / 1024)}KB)]`; + } + + // Don't read binary files + if (this.isBinaryFile(fullPath)) { + return `[Binary file: ${relativePath}]`; + } + + const content = fs.readFileSync(fullPath, "utf8"); + return content; + } catch (error) { + return `[Error reading file: ${relativePath}]`; + } + } + + /** + * Simple binary file detection + */ + private isBinaryFile(filePath: string): boolean { + const binaryExtensions = [ + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".ico", ".svg", + ".mp3", ".mp4", ".avi", ".mov", ".wmv", ".flv", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".zip", ".rar", ".7z", ".tar", ".gz", + ".exe", ".dll", ".so", ".dylib", + ".woff", ".woff2", ".ttf", ".otf", + ]; + + const ext = path.extname(filePath).toLowerCase(); + return binaryExtensions.includes(ext); + } +}