import { execSync } from "child_process"; import { readdirSync, statSync } from "fs"; import mimeTypes from "mime-types"; import { homedir } from "os"; import { basename, dirname, extname, join } from "path"; function isAttachableFile(filePath: string): boolean { const mimeType = mimeTypes.lookup(filePath); // Check file extension for common text files that might be misidentified const textExtensions = [ ".txt", ".md", ".markdown", ".js", ".ts", ".tsx", ".jsx", ".py", ".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".php", ".rb", ".go", ".rs", ".swift", ".kt", ".scala", ".sh", ".bash", ".zsh", ".fish", ".html", ".htm", ".css", ".scss", ".sass", ".less", ".xml", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".log", ".sql", ".r", ".R", ".m", ".pl", ".lua", ".vim", ".dockerfile", ".makefile", ".cmake", ".gradle", ".maven", ".properties", ".env", ]; const ext = extname(filePath).toLowerCase(); if (textExtensions.includes(ext)) return true; if (!mimeType) return false; if (mimeType.startsWith("image/")) return true; if (mimeType.startsWith("text/")) return true; // Special cases for common text files that might not be detected as text/ const commonTextTypes = [ "application/json", "application/javascript", "application/typescript", "application/xml", "application/yaml", "application/x-yaml", ]; return commonTextTypes.includes(mimeType); } export interface AutocompleteItem { value: string; label: string; description?: string; } export interface SlashCommand { name: string; description?: string; // Function to get argument completions for this command // Returns null if no argument completion is available getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null; } export interface AutocompleteProvider { // Get autocomplete suggestions for current text/cursor position // Returns null if no suggestions available getSuggestions( lines: string[], cursorLine: number, cursorCol: number, ): { items: AutocompleteItem[]; prefix: string; // What we're matching against (e.g., "/" or "src/") } | null; // Apply the selected item // Returns the new text and cursor position applyCompletion( lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number; }; } // Combined provider that handles both slash commands and file paths export class CombinedAutocompleteProvider implements AutocompleteProvider { private commands: (SlashCommand | AutocompleteItem)[]; private basePath: string; private fdCommand: string | null | undefined = undefined; // undefined = not checked yet constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) { this.commands = commands; this.basePath = basePath; } getSuggestions( lines: string[], cursorLine: number, cursorCol: number, ): { items: AutocompleteItem[]; prefix: string } | null { const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); // Check for @ file reference (fuzzy search) - must be after a space or at start const atMatch = textBeforeCursor.match(/(?:^|[\s])(@[^\s]*)$/); if (atMatch) { const prefix = atMatch[1] ?? "@"; // The @... part const query = prefix.slice(1); // Remove the @ const suggestions = this.getFuzzyFileSuggestions(query); if (suggestions.length === 0) return null; return { items: suggestions, prefix: prefix, }; } // Check for slash commands if (textBeforeCursor.startsWith("/")) { const spaceIndex = textBeforeCursor.indexOf(" "); if (spaceIndex === -1) { // No space yet - complete command names const prefix = textBeforeCursor.slice(1); // Remove the "/" const filtered = this.commands .filter((cmd) => { const name = "name" in cmd ? cmd.name : cmd.value; // Check if SlashCommand or AutocompleteItem return name?.toLowerCase().startsWith(prefix.toLowerCase()); }) .map((cmd) => ({ value: "name" in cmd ? cmd.name : cmd.value, label: "name" in cmd ? cmd.name : cmd.label, ...(cmd.description && { description: cmd.description }), })); if (filtered.length === 0) return null; return { items: filtered, prefix: textBeforeCursor, }; } else { // Space found - complete command arguments const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/" const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space const command = this.commands.find((cmd) => { const name = "name" in cmd ? cmd.name : cmd.value; return name === commandName; }); if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) { return null; // No argument completion for this command } const argumentSuggestions = command.getArgumentCompletions(argumentText); if (!argumentSuggestions || argumentSuggestions.length === 0) { return null; } return { items: argumentSuggestions, prefix: argumentText, }; } } // Check for file paths - triggered by Tab or if we detect a path pattern const pathMatch = this.extractPathPrefix(textBeforeCursor, false); if (pathMatch !== null) { const suggestions = this.getFileSuggestions(pathMatch); if (suggestions.length === 0) return null; return { items: suggestions, prefix: pathMatch, }; } return null; } applyCompletion( lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { const currentLine = lines[cursorLine] || ""; const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); const afterCursor = currentLine.slice(cursorCol); // Check if we're completing a slash command (prefix starts with "/") if (prefix.startsWith("/")) { // This is a command name completion const newLine = beforePrefix + "/" + item.value + " " + afterCursor; const newLines = [...lines]; newLines[cursorLine] = newLine; return { lines: newLines, cursorLine, cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space }; } // Check if we're completing a file attachment (prefix starts with "@") if (prefix.startsWith("@")) { // This is a file attachment completion const newLine = beforePrefix + item.value + " " + afterCursor; const newLines = [...lines]; newLines[cursorLine] = newLine; return { lines: newLines, cursorLine, cursorCol: beforePrefix.length + item.value.length + 1, // +1 for space }; } // Check if we're in a slash command context (beforePrefix contains "/command ") const textBeforeCursor = currentLine.slice(0, cursorCol); if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) { // This is likely a command argument completion const newLine = beforePrefix + item.value + afterCursor; const newLines = [...lines]; newLines[cursorLine] = newLine; return { lines: newLines, cursorLine, cursorCol: beforePrefix.length + item.value.length, }; } // For file paths, complete the path const newLine = beforePrefix + item.value + afterCursor; const newLines = [...lines]; newLines[cursorLine] = newLine; return { lines: newLines, cursorLine, cursorCol: beforePrefix.length + item.value.length, }; } // Extract a path-like prefix from the text before cursor private extractPathPrefix(text: string, forceExtract: boolean = false): string | null { // Check for @ file attachment syntax first const atMatch = text.match(/@([^\s]*)$/); if (atMatch) { return atMatch[0]; // Return the full @path pattern } // Simple approach: find the last whitespace/delimiter and extract the word after it // This avoids catastrophic backtracking from nested quantifiers const lastDelimiterIndex = Math.max( text.lastIndexOf(" "), text.lastIndexOf("\t"), text.lastIndexOf('"'), text.lastIndexOf("'"), text.lastIndexOf("="), ); const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1); // For forced extraction (Tab key), always return something if (forceExtract) { return pathPrefix; } // For natural triggers, return if it looks like a path, ends with /, starts with ~/, . // Only return empty string if the text looks like it's starting a path context if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) { return pathPrefix; } // Return empty string only if we're at the beginning of the line or after a space // (not after quotes or other delimiters that don't suggest file paths) if (pathPrefix === "" && (text === "" || text.endsWith(" "))) { return pathPrefix; } return null; } // Expand home directory (~/) to actual home path private expandHomePath(path: string): string { if (path.startsWith("~/")) { const expandedPath = join(homedir(), path.slice(2)); // Preserve trailing slash if original path had one return path.endsWith("/") && !expandedPath.endsWith("/") ? expandedPath + "/" : expandedPath; } else if (path === "~") { return homedir(); } return path; } // Get file/directory suggestions for a given path prefix private getFileSuggestions(prefix: string): AutocompleteItem[] { try { let searchDir: string; let searchPrefix: string; let expandedPrefix = prefix; let isAtPrefix = false; // Handle @ file attachment prefix if (prefix.startsWith("@")) { isAtPrefix = true; expandedPrefix = prefix.slice(1); // Remove the @ } // Handle home directory expansion if (expandedPrefix.startsWith("~")) { expandedPrefix = this.expandHomePath(expandedPrefix); } if ( expandedPrefix === "" || expandedPrefix === "./" || expandedPrefix === "../" || expandedPrefix === "~" || expandedPrefix === "~/" || expandedPrefix === "/" || prefix === "@" ) { // Complete from specified position if (prefix.startsWith("~") || expandedPrefix === "/") { searchDir = expandedPrefix; } else { searchDir = join(this.basePath, expandedPrefix); } searchPrefix = ""; } else if (expandedPrefix.endsWith("/")) { // If prefix ends with /, show contents of that directory if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = expandedPrefix; } else { searchDir = join(this.basePath, expandedPrefix); } searchPrefix = ""; } else { // Split into directory and file prefix const dir = dirname(expandedPrefix); const file = basename(expandedPrefix); if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = dir; } else { searchDir = join(this.basePath, dir); } searchPrefix = file; } const entries = readdirSync(searchDir); const suggestions: AutocompleteItem[] = []; for (const entry of entries) { if (!entry.toLowerCase().startsWith(searchPrefix.toLowerCase())) { continue; } const fullPath = join(searchDir, entry); let isDirectory: boolean; try { isDirectory = statSync(fullPath).isDirectory(); } catch (e) { // Skip files we can't stat (permission issues, broken symlinks, etc.) continue; } // For @ prefix, filter to only show directories and attachable files if (isAtPrefix && !isDirectory && !isAttachableFile(fullPath)) { continue; } let relativePath: string; // Handle @ prefix path construction if (isAtPrefix) { const pathWithoutAt = expandedPrefix; if (pathWithoutAt.endsWith("/")) { relativePath = "@" + pathWithoutAt + entry; } else if (pathWithoutAt.includes("/")) { if (pathWithoutAt.startsWith("~/")) { const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/ const dir = dirname(homeRelativeDir); relativePath = "@~/" + (dir === "." ? entry : join(dir, entry)); } else { relativePath = "@" + join(dirname(pathWithoutAt), entry); } } else { if (pathWithoutAt.startsWith("~")) { relativePath = "@~/" + entry; } else { relativePath = "@" + entry; } } } else if (prefix.endsWith("/")) { // If prefix ends with /, append entry to the prefix relativePath = prefix + entry; } else if (prefix.includes("/")) { // Preserve ~/ format for home directory paths if (prefix.startsWith("~/")) { const homeRelativeDir = prefix.slice(2); // Remove ~/ const dir = dirname(homeRelativeDir); relativePath = "~/" + (dir === "." ? entry : join(dir, entry)); } else if (prefix.startsWith("/")) { // Absolute path - construct properly const dir = dirname(prefix); if (dir === "/") { relativePath = "/" + entry; } else { relativePath = dir + "/" + entry; } } else { relativePath = join(dirname(prefix), entry); } } else { // For standalone entries, preserve ~/ if original prefix was ~/ if (prefix.startsWith("~")) { relativePath = "~/" + entry; } else { relativePath = entry; } } suggestions.push({ value: isDirectory ? relativePath + "/" : relativePath, label: entry, description: isDirectory ? "directory" : "file", }); } // Sort directories first, then alphabetically suggestions.sort((a, b) => { const aIsDir = a.description === "directory"; const bIsDir = b.description === "directory"; if (aIsDir && !bIsDir) return -1; if (!aIsDir && bIsDir) return 1; return a.label.localeCompare(b.label); }); return suggestions; } catch (e) { // Directory doesn't exist or not accessible return []; } } // Score a file against the query (higher = better match) private scoreFile(filePath: string, query: string): number { const fileName = basename(filePath); const lowerFileName = fileName.toLowerCase(); const lowerQuery = query.toLowerCase(); // Exact filename match (highest) if (lowerFileName === lowerQuery) return 100; // Filename starts with query if (lowerFileName.startsWith(lowerQuery)) return 80; // Substring match in filename if (lowerFileName.includes(lowerQuery)) return 50; // Substring match in full path if (filePath.toLowerCase().includes(lowerQuery)) return 30; return 0; } // Fuzzy file search using fdfind, fd, or find (fallback) private getFuzzyFileSuggestions(query: string): AutocompleteItem[] { try { let result: string; const fdCommand = this.getFdCommand(); if (fdCommand) { const args = ["-t", "f", "--max-results", "100"]; if (query) { args.push(query); } result = execSync(`${fdCommand} ${args.join(" ")}`, { cwd: this.basePath, encoding: "utf-8", timeout: 2000, maxBuffer: 1024 * 1024, }); } else { // Fallback to find const pattern = query ? `*${query}*` : "*"; const cmd = [ "find", ".", "-type", "f", "-iname", `'${pattern}'`, "!", "-path", "'*/.git/*'", "!", "-path", "'*/node_modules/*'", "!", "-path", "'*/__pycache__/*'", "!", "-path", "'*/.venv/*'", "!", "-path", "'*/dist/*'", "!", "-path", "'*/build/*'", "2>/dev/null", "|", "head", "-100", ].join(" "); result = execSync(cmd, { cwd: this.basePath, encoding: "utf-8", timeout: 3000, maxBuffer: 1024 * 1024, shell: "/bin/bash", }); } const files = result .trim() .split("\n") .filter((f) => f.length > 0) .map((f) => (f.startsWith("./") ? f.slice(2) : f)); // Score and filter files const scoredFiles: { path: string; score: number }[] = []; for (const filePath of files) { const fullPath = join(this.basePath, filePath); if (!isAttachableFile(fullPath)) { continue; } const score = query ? this.scoreFile(filePath, query) : 1; if (score > 0) { scoredFiles.push({ path: filePath, score }); } } // Sort by score (descending) and take top 20 scoredFiles.sort((a, b) => b.score - a.score); const topFiles = scoredFiles.slice(0, 20); // Build suggestions const suggestions: AutocompleteItem[] = []; for (const { path: filePath } of topFiles) { const fileName = basename(filePath); const dirPath = dirname(filePath); suggestions.push({ value: "@" + filePath, label: fileName, description: dirPath === "." ? "" : dirPath, }); } return suggestions; } catch (e) { return []; } } // Check which fd command is available (fdfind on Debian/Ubuntu, fd elsewhere) // Result is cached after first check private getFdCommand(): string | null { if (this.fdCommand !== undefined) { return this.fdCommand; } try { execSync("fdfind --version", { encoding: "utf-8", timeout: 1000 }); this.fdCommand = "fdfind"; return this.fdCommand; } catch { try { execSync("fd --version", { encoding: "utf-8", timeout: 1000 }); this.fdCommand = "fd"; return this.fdCommand; } catch { this.fdCommand = null; return null; } } } // Force file completion (called on Tab key) - always returns suggestions getForceFileSuggestions( lines: string[], cursorLine: number, cursorCol: number, ): { items: AutocompleteItem[]; prefix: string } | null { const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); // Don't trigger if we're typing a slash command at the start of the line if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) { return null; } // Force extract path prefix - this will always return something const pathMatch = this.extractPathPrefix(textBeforeCursor, true); if (pathMatch !== null) { const suggestions = this.getFileSuggestions(pathMatch); if (suggestions.length === 0) return null; return { items: suggestions, prefix: pathMatch, }; } return null; } // Check if we should trigger file completion (called on Tab key) shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); // Don't trigger if we're typing a slash command at the start of the line if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) { return false; } return true; } }