/** * File reference expansion for TUI chat input. * * Detects @path/to/file patterns in user messages, reads the file contents, * and inlines them as fenced code blocks in the message. * * Supports: * - @relative/path.ts * - @./relative/path.ts * - @/absolute/path.ts * - @~/home-relative/path.ts * * Also provides an /attach command handler. */ import { readFile, stat } from 'node:fs/promises'; import { resolve, extname, basename } from 'node:path'; import { homedir } from 'node:os'; const MAX_FILE_SIZE = 256 * 1024; // 256 KB const MAX_FILES_PER_MESSAGE = 10; /** * Regex to detect @file references in user input. * Matches @ where path starts with /, ./, ~/, or a word char, * and continues until whitespace or end of string. * Excludes @mentions that look like usernames (no dots/slashes). */ const FILE_REF_PATTERN = /(?:^|\s)@((?:\.{0,2}\/|~\/|[a-zA-Z0-9_])[^\s]+)/g; interface FileRefResult { /** The expanded message text with file contents inlined */ expandedMessage: string; /** Files that were successfully read */ filesAttached: string[]; /** Errors encountered while reading files */ errors: string[]; } function resolveFilePath(ref: string): string { if (ref.startsWith('~/')) { return resolve(homedir(), ref.slice(2)); } return resolve(process.cwd(), ref); } function getLanguageHint(filePath: string): string { const ext = extname(filePath).toLowerCase(); const map: Record = { '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.py': 'python', '.rb': 'ruby', '.rs': 'rust', '.go': 'go', '.java': 'java', '.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp', '.cs': 'csharp', '.sh': 'bash', '.bash': 'bash', '.zsh': 'zsh', '.fish': 'fish', '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.toml': 'toml', '.xml': 'xml', '.html': 'html', '.css': 'css', '.scss': 'scss', '.md': 'markdown', '.sql': 'sql', '.graphql': 'graphql', '.dockerfile': 'dockerfile', '.tf': 'terraform', '.vue': 'vue', '.svelte': 'svelte', }; return map[ext] ?? ''; } /** * Check if the input contains any @file references. */ export function hasFileRefs(input: string): boolean { FILE_REF_PATTERN.lastIndex = 0; return FILE_REF_PATTERN.test(input); } /** * Expand @file references in a message by reading file contents * and appending them as fenced code blocks. */ export async function expandFileRefs(input: string): Promise { const refs: string[] = []; FILE_REF_PATTERN.lastIndex = 0; let match; while ((match = FILE_REF_PATTERN.exec(input)) !== null) { const ref = match[1]!; if (!refs.includes(ref)) { refs.push(ref); } } if (refs.length === 0) { return { expandedMessage: input, filesAttached: [], errors: [] }; } if (refs.length > MAX_FILES_PER_MESSAGE) { return { expandedMessage: input, filesAttached: [], errors: [`Too many file references (${refs.length}). Maximum is ${MAX_FILES_PER_MESSAGE}.`], }; } const filesAttached: string[] = []; const errors: string[] = []; const attachments: string[] = []; for (const ref of refs) { const filePath = resolveFilePath(ref); try { const info = await stat(filePath); if (!info.isFile()) { errors.push(`@${ref}: not a file`); continue; } if (info.size > MAX_FILE_SIZE) { errors.push( `@${ref}: file too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`, ); continue; } const content = await readFile(filePath, 'utf8'); const lang = getLanguageHint(filePath); const name = basename(filePath); attachments.push(`\nšŸ“Ž ${ref} (${name}):\n\`\`\`${lang}\n${content}\n\`\`\``); filesAttached.push(ref); } catch (err) { const msg = err instanceof Error ? err.message : String(err); // Only report meaningful errors — ENOENT is common for false @mention matches if (msg.includes('ENOENT')) { // Check if this looks like a file path (has extension or slash) if (ref.includes('/') || ref.includes('.')) { errors.push(`@${ref}: file not found`); } // Otherwise silently skip — likely an @mention, not a file ref } else { errors.push(`@${ref}: ${msg}`); } } } if (attachments.length === 0) { return { expandedMessage: input, filesAttached, errors }; } const expandedMessage = input + '\n' + attachments.join('\n'); return { expandedMessage, filesAttached, errors }; } /** * Handle the /attach command. * Reads a file and returns the content formatted for inclusion in the chat. */ export async function handleAttachCommand( args: string, ): Promise<{ content: string; error?: string }> { const filePath = args.trim(); if (!filePath) { return { content: '', error: 'Usage: /attach ' }; } const resolved = resolveFilePath(filePath); try { const info = await stat(resolved); if (!info.isFile()) { return { content: '', error: `Not a file: ${filePath}` }; } if (info.size > MAX_FILE_SIZE) { return { content: '', error: `File too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`, }; } const content = await readFile(resolved, 'utf8'); const lang = getLanguageHint(resolved); const name = basename(resolved); return { content: `šŸ“Ž Attached file: ${name} (${filePath})\n\`\`\`${lang}\n${content}\n\`\`\``, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { content: '', error: `Failed to read file: ${msg}` }; } }