diff --git a/apps/cli/package.json b/apps/cli/package.json index 036eaf7e5..d867dcf7e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -8,7 +8,7 @@ }, "scripts": { "prebuild": "[ \"$SKIP_ENV_CHECK\" = \"true\" ] || tsx scripts/validate-env.ts", - "build": "bun build src/index.tsx --outdir dist --target node", + "build": "bun build src/index.tsx --outdir dist --target bun", "build:dry-run": "tsc --noEmit", "build:standalone": "bun build src/index.tsx --compile --outfile dist/buster", "start": "bun run dist/index.js", diff --git a/apps/cli/src/components/chat-layout.tsx b/apps/cli/src/components/chat-layout.tsx index 6ed9987ab..560b9c63d 100644 --- a/apps/cli/src/components/chat-layout.tsx +++ b/apps/cli/src/components/chat-layout.tsx @@ -1,6 +1,8 @@ import { Box, Text } from 'ink'; -import { useMemo } from 'react'; -import { MultiLineTextInput } from './multi-line-text-input'; +import { useEffect, useMemo, useState } from 'react'; +import { type FileSearchResult, searchFiles } from '../utils/file-search'; +import { FileAutocompleteDisplay } from './file-autocomplete-display'; +import { MultiLineTextInput, replaceMention } from './multi-line-text-input'; import { SimpleBigText } from './simple-big-text'; export function ChatTitle() { @@ -66,26 +68,112 @@ interface ChatInputProps { } export function ChatInput({ value, placeholder, onChange, onSubmit }: ChatInputProps) { + const [mentionQuery, setMentionQuery] = useState(null); + const [mentionStart, setMentionStart] = useState(-1); + const [searchResults, setSearchResults] = useState([]); + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + // Handle mention changes from the input + const handleMentionChange = (query: string | null, position: number) => { + // Only reset selection if the query actually changed + if (query !== mentionQuery) { + setSelectedIndex(0); + } + setMentionQuery(query); + setMentionStart(position); + setShowAutocomplete(query !== null); + }; + + // Search for files when mention query changes + useEffect(() => { + if (mentionQuery !== null) { + searchFiles(mentionQuery, { maxResults: 20 }) + .then((results) => { + setSearchResults(results); + // Adjust selection if it's out of bounds + setSelectedIndex((currentIndex) => { + if (currentIndex >= results.length && results.length > 0) { + return results.length - 1; + } + return currentIndex; + }); + }) + .catch((error) => { + console.error('File search failed:', error); + setSearchResults([]); + }); + } else { + setSearchResults([]); + } + }, [mentionQuery]); + + // Handle autocomplete navigation + const handleAutocompleteNavigate = (direction: 'up' | 'down' | 'select' | 'close') => { + const displayItems = searchResults.slice(0, 10); + + switch (direction) { + case 'up': + setSelectedIndex((prev) => Math.max(0, prev - 1)); + break; + case 'down': + setSelectedIndex((prev) => Math.min(displayItems.length - 1, prev + 1)); + break; + case 'select': + if (displayItems[selectedIndex]) { + const file = displayItems[selectedIndex]; + if (mentionStart !== -1 && mentionQuery !== null) { + const mentionEnd = mentionStart + mentionQuery.length + 1; // +1 for the @ symbol + const replacement = `@${file.relativePath} `; // Always add space + const newValue = replaceMention(value, mentionStart, mentionEnd, replacement); + onChange(newValue); + setShowAutocomplete(false); + setMentionQuery(null); + setMentionStart(-1); + } + } + break; + case 'close': + setShowAutocomplete(false); + setMentionQuery(null); + setMentionStart(-1); + break; + } + }; + return ( - - - ❯{' '} - - - + + + + ❯{' '} + + + + + {showAutocomplete && ( + + + + )} ); } diff --git a/apps/cli/src/components/file-autocomplete-display.tsx b/apps/cli/src/components/file-autocomplete-display.tsx new file mode 100644 index 000000000..1d852e8c4 --- /dev/null +++ b/apps/cli/src/components/file-autocomplete-display.tsx @@ -0,0 +1,38 @@ +import { Box, Text } from 'ink'; +import type { FileSearchResult } from '../utils/file-search'; + +interface FileAutocompleteDisplayProps { + items: FileSearchResult[]; + selectedIndex: number; + maxDisplay?: number; +} + +export function FileAutocompleteDisplay({ + items, + selectedIndex, + maxDisplay = 10, +}: FileAutocompleteDisplayProps) { + const displayItems = items.slice(0, maxDisplay); + + if (items.length === 0) { + return null; // Don't show anything when no matches + } + + return ( + + {displayItems.map((item, index) => { + const isSelected = index === selectedIndex; + const { relativePath } = item; + + return ( + + + {isSelected ? '> ' : ' '} + {relativePath} + + + ); + })} + + ); +} \ No newline at end of file diff --git a/apps/cli/src/components/file-autocomplete.tsx b/apps/cli/src/components/file-autocomplete.tsx new file mode 100644 index 000000000..6200587ed --- /dev/null +++ b/apps/cli/src/components/file-autocomplete.tsx @@ -0,0 +1,85 @@ +import { Box, Text, useInput } from 'ink'; +import { useEffect, useState } from 'react'; +import type { FileSearchResult } from '../utils/file-search'; + +interface FileAutocompleteProps { + items: FileSearchResult[]; + onSelect: (item: FileSearchResult, addSpace: boolean) => void; + onClose: () => void; + isActive?: boolean; + maxDisplay?: number; +} + +export function FileAutocomplete({ + items, + onSelect, + onClose, + isActive = false, // Changed default to false - parent controls this + maxDisplay = 10, +}: FileAutocompleteProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const displayItems = items.slice(0, maxDisplay); + + // Reset selection when items change + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + useInput( + (_input, key) => { + if (key.escape) { + onClose(); + return; + } + + if (key.return) { + if (displayItems[selectedIndex]) { + // Enter adds the file with a space + onSelect(displayItems[selectedIndex], true); + } + return; + } + + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + return; + } + + if (key.downArrow) { + setSelectedIndex((prev) => Math.min(displayItems.length - 1, prev + 1)); + return; + } + + if (key.tab) { + // Tab also selects the current item with a space + if (displayItems[selectedIndex]) { + onSelect(displayItems[selectedIndex], true); + } + return; + } + }, + { isActive } + ); + + if (items.length === 0) { + return null; // Don't show anything when no matches + } + + return ( + + {displayItems.map((item, index) => { + const isSelected = index === selectedIndex; + const { relativePath } = item; + + return ( + + + {isSelected ? '> ' : ' '} + {relativePath} + + + ); + })} + + ); +} diff --git a/apps/cli/src/components/multi-line-text-input.tsx b/apps/cli/src/components/multi-line-text-input.tsx index 246d7aac3..fcb6f64b3 100644 --- a/apps/cli/src/components/multi-line-text-input.tsx +++ b/apps/cli/src/components/multi-line-text-input.tsx @@ -5,16 +5,22 @@ interface MultiLineTextInputProps { value: string; onChange: (value: string) => void; onSubmit: () => void; + onMentionChange?: (mention: string | null, cursorPosition: number) => void; + onAutocompleteNavigate?: (direction: 'up' | 'down' | 'select' | 'close') => void; placeholder?: string; focus?: boolean; + isAutocompleteOpen?: boolean; } export function MultiLineTextInput({ value, onChange, onSubmit, + onMentionChange, + onAutocompleteNavigate, placeholder = '', focus = true, + isAutocompleteOpen = false, }: MultiLineTextInputProps) { const [cursorPosition, setCursorPosition] = useState(value.length); const [showCursor, setShowCursor] = useState(true); @@ -43,6 +49,43 @@ export function MultiLineTextInput({ setCursorPosition(value.length); }, [value]); + // Detect @ mentions + useEffect(() => { + if (!onMentionChange) return; + + // Find the last @ before cursor position + let mentionStart = -1; + for (let i = cursorPosition - 1; i >= 0; i--) { + if (value[i] === '@') { + mentionStart = i; + break; + } + // Stop if we hit whitespace or newline (mention ended) + if (value[i] === ' ' || value[i] === '\n' || value[i] === '\t') { + break; + } + } + + if (mentionStart !== -1) { + // Check if there's a space or newline before @ (or it's at the start) + const charBefore = mentionStart > 0 ? value[mentionStart - 1] : ' '; + if (charBefore === ' ' || charBefore === '\n' || charBefore === '\t' || mentionStart === 0) { + // Extract the mention query (text after @) + const mentionEnd = cursorPosition; + const mentionQuery = value.substring(mentionStart + 1, mentionEnd); + + // Only trigger if we're still in the mention (no spaces) + if (!mentionQuery.includes(' ') && !mentionQuery.includes('\n')) { + onMentionChange(mentionQuery, mentionStart); + return; + } + } + } + + // No active mention + onMentionChange(null, -1); + }, [value, cursorPosition, onMentionChange]); + useInput( (input, key) => { if (!focus) return; @@ -50,6 +93,26 @@ export function MultiLineTextInput({ // Debug: Log what we're receiving // console.log('Input:', input, 'Key:', key); + // Handle autocomplete navigation when it's open + if (isAutocompleteOpen && onAutocompleteNavigate) { + if (key.upArrow) { + onAutocompleteNavigate('up'); + return; + } + if (key.downArrow) { + onAutocompleteNavigate('down'); + return; + } + if (key.escape) { + onAutocompleteNavigate('close'); + return; + } + if (key.return || key.tab) { + onAutocompleteNavigate('select'); + return; + } + } + // Handle backslash + n sequence for newline if (expectingNewline) { setExpectingNewline(false); @@ -120,8 +183,9 @@ export function MultiLineTextInput({ return; } - // Handle Enter - submit (only if not modified) - if (key.return && !key.meta && !key.shift && !key.ctrl) { + // Handle Enter - submit (only if not modified and autocomplete is not open) + // This is now handled above in autocomplete navigation section + if (key.return && !key.meta && !key.shift && !key.ctrl && !isAutocompleteOpen) { onSubmit(); return; } @@ -145,8 +209,8 @@ export function MultiLineTextInput({ return; } - if (key.upArrow) { - // Move cursor to previous line + if (key.upArrow && !isAutocompleteOpen) { + // Move cursor to previous line (only when autocomplete is closed) const lines = value.split('\n'); let currentLineStart = 0; let currentLineIndex = 0; @@ -173,8 +237,8 @@ export function MultiLineTextInput({ return; } - if (key.downArrow) { - // Move cursor to next line + if (key.downArrow && !isAutocompleteOpen) { + // Move cursor to next line (only when autocomplete is closed) const lines = value.split('\n'); let currentLineStart = 0; let currentLineIndex = 0; @@ -303,3 +367,13 @@ export function MultiLineTextInput({ return renderTextWithCursor(); } + +// Helper function to replace a mention with a file path +export function replaceMention( + text: string, + mentionStart: number, + mentionEnd: number, + replacement: string +): string { + return text.slice(0, mentionStart) + replacement + text.slice(mentionEnd); +} diff --git a/apps/cli/src/utils/file-search.ts b/apps/cli/src/utils/file-search.ts new file mode 100644 index 000000000..5d8378c0a --- /dev/null +++ b/apps/cli/src/utils/file-search.ts @@ -0,0 +1,271 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { Glob } from 'bun'; +import micromatch from 'micromatch'; + +interface FileSearchOptions { + cwd?: string; + maxResults?: number; + includeHidden?: boolean; +} + +interface FileSearchResult { + path: string; + relativePath: string; + name: string; +} + +class FileSearcher { + private cwd: string; + private fileCache: string[] | null = null; + private gitignorePatterns: string[] | null = null; + private lastCacheTime: number = 0; + private readonly CACHE_TTL = 5000; // 5 seconds cache + + constructor(cwd: string = process.cwd()) { + this.cwd = resolve(cwd); + } + + private parseGitignore(content: string): string[] { + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((pattern) => { + // Convert gitignore patterns to glob patterns + if (pattern.startsWith('!')) { + return pattern; // Keep negation patterns as-is + } + if (pattern.startsWith('/')) { + return pattern.slice(1); // Remove leading slash for relative patterns + } + if (!pattern.includes('/')) { + // Match file/folder anywhere in tree + return `**/${pattern}`; + } + return pattern; + }); + } + + private loadGitignorePatterns(): string[] { + if (this.gitignorePatterns !== null) { + return this.gitignorePatterns; + } + + const patterns: string[] = [ + // Default exclusions + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + '**/.turbo/**', + '**/.next/**', + '**/coverage/**', + '**/.cache/**', + ]; + + // Load .gitignore if it exists + const gitignorePath = join(this.cwd, '.gitignore'); + if (existsSync(gitignorePath)) { + try { + const content = readFileSync(gitignorePath, 'utf-8'); + const gitignorePatterns = this.parseGitignore(content); + patterns.push(...gitignorePatterns); + } catch (error) { + console.error('Failed to read .gitignore:', error); + } + } + + // Load .gitignore from parent directories up to root + let currentDir = resolve(this.cwd, '..'); + const root = resolve('/'); + while (currentDir !== root) { + const parentGitignorePath = join(currentDir, '.gitignore'); + if (existsSync(parentGitignorePath)) { + try { + const content = readFileSync(parentGitignorePath, 'utf-8'); + const gitignorePatterns = this.parseGitignore(content); + // Adjust patterns relative to our cwd + const adjustedPatterns = gitignorePatterns.map((pattern) => { + if (pattern.startsWith('!') || pattern.startsWith('**/')) { + return pattern; + } + return `**/${pattern}`; + }); + patterns.push(...adjustedPatterns); + } catch (error) { + // Silently ignore + } + } + const parentDir = resolve(currentDir, '..'); + if (parentDir === currentDir) break; // Reached root + currentDir = parentDir; + } + + this.gitignorePatterns = patterns; + return patterns; + } + + private async getAllFiles(includeHidden: boolean = false): Promise { + const now = Date.now(); + + // Return cached files if still valid + if (this.fileCache && now - this.lastCacheTime < this.CACHE_TTL) { + return this.fileCache; + } + + const ignorePatterns = this.loadGitignorePatterns(); + + // Use Bun's Glob to find all files + const glob = new Glob('**/*', { + cwd: this.cwd, + onlyFiles: true, + dot: includeHidden, // Include hidden files if requested + }); + + const allFiles: string[] = []; + + for await (const file of glob.scan()) { + // Check if file should be ignored + const shouldIgnore = micromatch.isMatch(file, ignorePatterns, { + dot: true, + contains: true, + }); + + if (!shouldIgnore) { + allFiles.push(file); + } + } + + // Update cache + this.fileCache = allFiles.sort(); + this.lastCacheTime = now; + + return this.fileCache; + } + + async searchFiles(query: string, options: FileSearchOptions = {}): Promise { + const { cwd = this.cwd, maxResults = 10, includeHidden = false } = options; + + // Update cwd if different + if (cwd !== this.cwd) { + this.cwd = resolve(cwd); + this.fileCache = null; // Clear cache when directory changes + this.gitignorePatterns = null; + } + + const files = await this.getAllFiles(includeHidden); + + if (!query) { + // Return first N files if no query + return files.slice(0, maxResults).map((file) => ({ + path: join(this.cwd, file), + relativePath: file, + name: file.split('/').pop() || file, + })); + } + + // Fuzzy search - split query into parts and check if all parts are in the path + const queryParts = query.toLowerCase().split('').filter(Boolean); + + const scoredFiles = files + .map((file) => { + const lowerFile = file.toLowerCase(); + const fileName = file.split('/').pop() || ''; + const lowerFileName = fileName.toLowerCase(); + + // Check if all query parts appear in order in the file path + let score = 0; + let lastIndex = -1; + let allPartsFound = true; + + for (const part of queryParts) { + const indexInPath = lowerFile.indexOf(part, lastIndex + 1); + const indexInName = lowerFileName.indexOf(part, lastIndex + 1); + + if (indexInPath === -1) { + allPartsFound = false; + break; + } + + // Prefer matches in filename over path + if (indexInName !== -1) { + score += 2; + lastIndex = indexInName; + } else { + score += 1; + lastIndex = indexInPath; + } + + // Bonus for consecutive matches + if (lastIndex === indexInPath - 1) { + score += 0.5; + } + } + + if (!allPartsFound) { + return null; + } + + // Bonus for exact substring match + if (lowerFile.includes(query.toLowerCase())) { + score += 5; + } + + // Bonus for matching at start of filename + if (lowerFileName.startsWith(query.toLowerCase())) { + score += 10; + } + + // Penalty for depth + const depth = file.split('/').length; + score -= depth * 0.1; + + return { + file, + score, + }; + }) + .filter((item): item is { file: string; score: number } => item !== null) + .sort((a, b) => b.score - a.score) + .slice(0, maxResults); + + return scoredFiles.map(({ file }) => ({ + path: join(this.cwd, file), + relativePath: file, + name: file.split('/').pop() || file, + })); + } + + clearCache(): void { + this.fileCache = null; + this.gitignorePatterns = null; + this.lastCacheTime = 0; + } +} + +// Export singleton instance +let searcherInstance: FileSearcher | null = null; + +export function getFileSearcher(cwd?: string): FileSearcher { + if (!searcherInstance || (cwd && resolve(cwd) !== searcherInstance.cwd)) { + searcherInstance = new FileSearcher(cwd); + } + return searcherInstance; +} + +export async function searchFiles( + query: string, + options?: FileSearchOptions +): Promise { + const searcher = getFileSearcher(options?.cwd); + return searcher.searchFiles(query, options); +} + +export function clearFileSearchCache(): void { + if (searcherInstance) { + searcherInstance.clearCache(); + } +} + +export type { FileSearchResult, FileSearchOptions };