feat: add web search, file edit, MCP management, file refs, and /stop to CLI/TUI (#348)
This commit was merged in pull request #348.
This commit is contained in:
202
packages/cli/src/tui/file-ref.ts
Normal file
202
packages/cli/src/tui/file-ref.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 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 <path> 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 @<path> 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<string, string> = {
|
||||
'.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<FileRefResult> {
|
||||
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 <path> 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 <file-path>' };
|
||||
}
|
||||
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user