feat: add web search, file edit, MCP management, file refs, and /stop to CLI/TUI
1. Web search tools (multi-provider): web_search, web_search_news, web_search_providers - Brave, Tavily, SearXNG (env-var activated), DuckDuckGo (always available) - Auto mode tries providers in priority order 2. File edit tool (fs_edit_file): targeted text replacement with validation - Unique oldText matching, overlap detection, atomic application - Sandboxed with size limits 3. MCP management commands: /mcp status, /mcp servers, /mcp reconnect <name> - Shows connection status, tool counts, errors for all MCP servers - Reconnect disconnected servers from the TUI 4. File reference in chat: @path/to/file inline expansion + /attach command - Auto-detects @file patterns and inlines contents as fenced code blocks - 256KB per file limit, max 10 files per message - Language-aware syntax highlighting hints 5. /stop command: abort streaming via Pi SDK abort() - New AbortPayload type in @mosaic/types - Gateway abort handler calls piSession.abort() - TUI emits abort event on /stop
This commit is contained in:
@@ -15,6 +15,7 @@ import { useConversations } from './hooks/use-conversations.js';
|
||||
import { useSearch } from './hooks/use-search.js';
|
||||
import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js';
|
||||
import { fetchConversationMessages } from './gateway-api.js';
|
||||
import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js';
|
||||
|
||||
export interface TuiAppProps {
|
||||
gatewayUrl: string;
|
||||
@@ -85,6 +86,36 @@ export function TuiApp({
|
||||
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
|
||||
const ctrlJustFired = useRef(false);
|
||||
|
||||
// Wrap sendMessage to expand @file references before sending
|
||||
const sendMessageWithFileRefs = useCallback(
|
||||
(content: string) => {
|
||||
if (!hasFileRefs(content)) {
|
||||
socket.sendMessage(content);
|
||||
return;
|
||||
}
|
||||
void expandFileRefs(content)
|
||||
.then(({ expandedMessage, filesAttached, errors }) => {
|
||||
for (const err of errors) {
|
||||
socket.addSystemMessage(err);
|
||||
}
|
||||
if (filesAttached.length > 0) {
|
||||
socket.addSystemMessage(
|
||||
`📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`,
|
||||
);
|
||||
}
|
||||
socket.sendMessage(expandedMessage);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
socket.addSystemMessage(
|
||||
`File expansion failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// Send original message without expansion
|
||||
socket.sendMessage(content);
|
||||
});
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleLocalCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
switch (parsed.command) {
|
||||
@@ -123,9 +154,36 @@ export function TuiApp({
|
||||
socket.addSystemMessage('Failed to create new conversation.');
|
||||
});
|
||||
break;
|
||||
case 'attach': {
|
||||
if (!parsed.args) {
|
||||
socket.addSystemMessage('Usage: /attach <file-path>');
|
||||
break;
|
||||
}
|
||||
void handleAttachCommand(parsed.args)
|
||||
.then(({ content, error }) => {
|
||||
if (error) {
|
||||
socket.addSystemMessage(`Attach error: ${error}`);
|
||||
} else if (content) {
|
||||
// Send the file content as a user message
|
||||
socket.sendMessage(content);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
socket.addSystemMessage(
|
||||
`Attach failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'stop':
|
||||
// Currently no stop mechanism exposed — show feedback
|
||||
socket.addSystemMessage('Stop is not available for the current session.');
|
||||
if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) {
|
||||
socket.socketRef.current.emit('abort', {
|
||||
conversationId: socket.conversationId,
|
||||
});
|
||||
socket.addSystemMessage('Abort signal sent.');
|
||||
} else {
|
||||
socket.addSystemMessage('No active stream to stop.');
|
||||
}
|
||||
break;
|
||||
case 'cost': {
|
||||
const u = socket.tokenUsage;
|
||||
@@ -348,7 +406,7 @@ export function TuiApp({
|
||||
}
|
||||
setTuiInput(val);
|
||||
}}
|
||||
onSubmit={socket.sendMessage}
|
||||
onSubmit={sendMessageWithFileRefs}
|
||||
onSystemMessage={socket.addSystemMessage}
|
||||
onLocalCommand={handleLocalCommand}
|
||||
onGatewayCommand={handleGatewayCommand}
|
||||
|
||||
@@ -56,6 +56,22 @@ const LOCAL_COMMANDS: CommandDef[] = [
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'attach',
|
||||
description: 'Attach a file to the next message (@file syntax also works inline)',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'path',
|
||||
type: 'string' as const,
|
||||
optional: false,
|
||||
description: 'File path to attach',
|
||||
},
|
||||
],
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
|
||||
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