/** * Simplified File Tagging System for Context Inclusion * Handles @ symbol detection without external dependencies */ import fs from 'node:fs/promises'; import path from 'node:path'; export interface SimpleFileItem { name: string; path: string; type: 'file' | 'directory'; } export class SimpleFileTagging { private workingDirectory: string; private fileCache: SimpleFileItem[] | null = null; constructor(workingDirectory?: string) { this.workingDirectory = workingDirectory || process.cwd(); } /** * Discover files in the working directory (simple version) */ async discoverFiles(): Promise { if (this.fileCache) { return this.fileCache; } try { const files: SimpleFileItem[] = []; // Read current directory await this.scanDirectory('.', files, 2); // Max depth of 2 // Sort files: directories first, then files files.sort((a, b) => { if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }); this.fileCache = files; return files; } catch (error) { console.error('Error discovering files:', error); return []; } } /** * Recursively scan directory */ private async scanDirectory(dirPath: string, files: SimpleFileItem[], maxDepth: number): Promise { if (maxDepth <= 0) return; try { const fullDirPath = path.join(this.workingDirectory, dirPath); const items = await fs.readdir(fullDirPath, { withFileTypes: true }); for (const item of items) { // Skip hidden files and common ignore patterns if (item.name.startsWith('.') || item.name === 'node_modules' || item.name === 'dist' || item.name === 'build') { continue; } const itemPath = dirPath === '.' ? item.name : path.join(dirPath, item.name); if (item.isDirectory()) { files.push({ name: item.name, path: itemPath, type: 'directory' }); // Recursively scan subdirectory await this.scanDirectory(itemPath, files, maxDepth - 1); } else if (item.isFile()) { files.push({ name: item.name, path: itemPath, type: 'file' }); } } } catch (error) { // Skip directories we can't access return; } } /** * Filter files based on user input */ async filterFiles(query: string): Promise { const allFiles = await this.discoverFiles(); if (!query || query.length === 0) { return allFiles.slice(0, 15); // Return first 15 items } const lowerQuery = query.toLowerCase(); return allFiles .filter(file => file.name.toLowerCase().includes(lowerQuery) || file.path.toLowerCase().includes(lowerQuery) ) .slice(0, 15); // Limit results } /** * Read file content for context inclusion */ async readFileContent(filePath: string): Promise { const fullPath = path.resolve(this.workingDirectory, filePath); try { const stats = await fs.stat(fullPath); if (stats.isDirectory()) { // For directories, return a listing const dirContents = await this.getDirectoryListing(fullPath); return dirContents; } if (!stats.isFile()) { return null; } // Check file size (limit to 100KB) if (stats.size > 100 * 1024) { return `File too large (${this.formatFileSize(stats.size)}). Please specify a smaller file.`; } // Check if it's likely a binary file if (this.isBinaryFile(filePath)) { return `Binary file: ${filePath} (${this.formatFileSize(stats.size)})`; } const content = await fs.readFile(fullPath, 'utf-8'); return content; } catch (error) { return `Error reading file: ${(error as Error).message}`; } } /** * Get directory listing */ private async getDirectoryListing(dirPath: string): Promise { try { const items = await fs.readdir(dirPath, { withFileTypes: true }); const listing = items .filter(item => !item.name.startsWith('.')) .slice(0, 20) // Limit to 20 items .map(item => { const type = item.isDirectory() ? '📁' : '📄'; return `${type} ${item.name}`; }) .join('\n'); return `Directory listing for: ${path.relative(this.workingDirectory, dirPath)}\n\n${listing}`; } catch (error) { return `Error reading directory: ${(error as Error).message}`; } } /** * Check if file is likely binary */ private isBinaryFile(filePath: string): boolean { const binaryExtensions = [ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico', '.mp3', '.mp4', '.avi', '.mov', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.zip', '.tar', '.gz', '.rar', '.exe', '.dll', '.so' ]; const ext = path.extname(filePath).toLowerCase(); return binaryExtensions.includes(ext); } /** * Format file size */ private formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** * Parse @ tags from user input */ parseFileTags(input: string): { cleanInput: string; fileTags: string[] } { const fileTagRegex = /@([\w\-./\\]+(?:\.[a-zA-Z0-9]+)?)/g; const fileTags: string[] = []; let match; while ((match = fileTagRegex.exec(input)) !== null) { fileTags.push(match[1]); } // Remove file tags from input const cleanInput = input.replace(fileTagRegex, '').trim(); return { cleanInput, fileTags }; } /** * Build context from tagged files */ async buildFileContext(fileTags: string[]): Promise { if (fileTags.length === 0) { return ''; } const contextParts: string[] = []; contextParts.push('📁 **FILE CONTEXT:**\n'); for (const fileTag of fileTags) { const content = await this.readFileContent(fileTag); if (content !== null) { contextParts.push(`\n## 📄 ${fileTag}\n\`\`\`\n${content}\n\`\`\`\n`); } else { contextParts.push(`\n## ❌ ${fileTag}\nFile not found or could not be read.\n`); } } return contextParts.join(''); } /** * Clear cache */ clearCache(): void { this.fileCache = null; } /** * Set working directory */ setWorkingDirectory(directory: string): void { this.workingDirectory = path.resolve(directory); this.clearCache(); } } /** * Helper functions */ export function detectFileTagging(input: string): boolean { return input.includes('@') && /@[\w\-./\\]*/.test(input); } export function getCurrentTagQuery(input: string, cursorPosition: number): string | null { const beforeCursor = input.slice(0, cursorPosition); const match = beforeCursor.match(/@([\w\-./\\]*)$/); return match ? match[1] : null; } /** * Simple autocomplete manager */ export class SimpleAutocompleteManager { private fileTagging: SimpleFileTagging; private isActive: boolean = false; private results: SimpleFileItem[] = []; private selectedIndex: number = 0; constructor(workingDirectory?: string) { this.fileTagging = new SimpleFileTagging(workingDirectory); } async handleInput(input: string, cursorPosition: number) { const currentTagQuery = getCurrentTagQuery(input, cursorPosition); if (currentTagQuery !== null) { this.isActive = true; this.results = await this.fileTagging.filterFiles(currentTagQuery); this.selectedIndex = 0; return { shouldShowAutocomplete: true, results: this.results, selectedIndex: this.selectedIndex }; } else { this.isActive = false; this.results = []; this.selectedIndex = 0; return { shouldShowAutocomplete: false, results: [], selectedIndex: 0 }; } } navigateResults(direction: 'up' | 'down'): void { if (!this.isActive || this.results.length === 0) return; if (direction === 'up') { this.selectedIndex = Math.max(0, this.selectedIndex - 1); } else { this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1); } } selectResult(input: string, cursorPosition: number) { if (!this.isActive || this.results.length === 0 || this.selectedIndex >= this.results.length) { return null; } const selectedFile = this.results[this.selectedIndex]; const beforeCursor = input.slice(0, cursorPosition); const afterCursor = input.slice(cursorPosition); const match = beforeCursor.match(/@([\w\-./\\]*)$/); if (!match) return null; const startPos = beforeCursor.lastIndexOf('@'); const newText = input.slice(0, startPos) + '@' + selectedFile.path + ' ' + afterCursor; const newCursorPosition = startPos + selectedFile.path.length + 2; this.isActive = false; return { text: newText, cursorPosition: newCursorPosition }; } getState() { return { isActive: this.isActive, results: this.results, selectedIndex: this.selectedIndex }; } } /** * Render autocomplete popup */ export function renderAutocompletePopup(results: SimpleFileItem[], selectedIndex: number): string { if (results.length === 0) { return '📁 No files found...'; } const formattedResults = results.slice(0, 10).map((item, index) => { const isSelected = index === selectedIndex; const prefix = isSelected ? '▶ ' : ' '; const icon = item.type === 'directory' ? '📁' : '📄'; let display = item.path; if (display.length > 50) { display = '...' + display.slice(-47); } return `${prefix}${icon} ${display}`; }); return [ '┌─ File Suggestions ─────────────────────────', ...formattedResults, '└─ ↑↓ Navigate, Enter to select, Esc to cancel' ].join('\n'); } // Global instances export const simpleFileTagging = new SimpleFileTagging(); export const simpleAutocompleteManager = new SimpleAutocompleteManager();