@mosaic/mosaic is now the single package providing both: - 'mosaic' binary (CLI: yolo, coord, prdy, tui, gateway, etc.) - 'mosaic-wizard' binary (installation wizard) Changes: - Move packages/cli/src/* into packages/mosaic/src/ - Convert dynamic @mosaic/mosaic imports to static relative imports - Add CLI deps (ink, react, socket.io-client, @mosaic/config) to mosaic - Add jsx: react-jsx to mosaic's tsconfig - Exclude packages/cli from workspace (pnpm-workspace.yaml) - Update install.sh to install @mosaic/mosaic instead of @mosaic/cli - Bump version to 0.0.17 This eliminates the circular dependency between @mosaic/cli and @mosaic/mosaic that was blocking the build graph.
203 lines
5.7 KiB
TypeScript
203 lines
5.7 KiB
TypeScript
/**
|
|
* 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}` };
|
|
}
|
|
}
|