import { readdirSync, statSync } from "fs"; import mimeTypes from "mime-types"; import { homedir } from "os"; import { basename, dirname, extname, join } from "path"; import { logger } from "./logger.js"; 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; 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 { logger.debug("CombinedAutocompleteProvider", "getSuggestions called", { lines, cursorLine, cursorCol, }); const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); // 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); logger.debug("CombinedAutocompleteProvider", "Path match check", { textBeforeCursor, pathMatch, }); 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 } // Match paths - including those ending with /, ~/, or any word at end for forced extraction // This regex captures: // - Paths starting from beginning of line or after space/quote/equals // - Optional ./ or ../ or ~/ prefix (including the trailing slash for ~/) // - The path itself (can include / in the middle) // - For forced extraction, capture any word at the end const matches = text.match(/(?:^|[\s"'=])((?:~\/|\.{0,2}\/?)?(?:[^\s"'=]*\/?)*[^\s"'=]*)$/); if (!matches) { // If forced extraction and no matches, return empty string to trigger from current dir return forceExtract ? "" : null; } const pathPrefix = matches[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[] { logger.debug("CombinedAutocompleteProvider", "getFileSuggestions called", { prefix, basePath: this.basePath, }); 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 === "~/" || prefix === "@" ) { // Complete from specified position if (prefix.startsWith("~")) { 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("~") || (isAtPrefix && 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("~") || (isAtPrefix && expandedPrefix.startsWith("/"))) { searchDir = dir; } else { searchDir = join(this.basePath, dir); } searchPrefix = file; } logger.debug("CombinedAutocompleteProvider", "Searching directory", { searchDir, searchPrefix, }); const entries = readdirSync(searchDir); const suggestions: AutocompleteItem[] = []; for (const entry of entries) { if (!entry.toLowerCase().startsWith(searchPrefix.toLowerCase())) { continue; } const fullPath = join(searchDir, entry); const isDirectory = statSync(fullPath).isDirectory(); // 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 { 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); }); logger.debug("CombinedAutocompleteProvider", "Returning suggestions", { count: suggestions.length, firstFew: suggestions.slice(0, 3).map((s) => s.label), }); return suggestions.slice(0, 10); // Limit to 10 suggestions } catch (e) { // Directory doesn't exist or not accessible logger.error("CombinedAutocompleteProvider", "Error reading directory", { error: e instanceof Error ? e.message : String(e), }); return []; } } // Force file completion (called on Tab key) - always returns suggestions getForceFileSuggestions( lines: string[], cursorLine: number, cursorCol: number, ): { items: AutocompleteItem[]; prefix: string } | null { logger.debug("CombinedAutocompleteProvider", "getForceFileSuggestions called", { lines, cursorLine, cursorCol, }); const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); // Don't trigger if we're in a slash command if (textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) { return null; } // Force extract path prefix - this will always return something const pathMatch = this.extractPathPrefix(textBeforeCursor, true); logger.debug("CombinedAutocompleteProvider", "Forced path match", { textBeforeCursor, pathMatch, }); 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 in a slash command if (textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) { return false; } return true; } }