/** * Tool Call Renderers - Specialized rendering per tool type * Inspired by CodeNomad's tool rendering system * https://github.com/NeuralNomadsAI/CodeNomad * * Each tool type gets specialized rendering to show the most relevant information * in a user-friendly, context-specific way. */ 'use strict'; // ============================================================ // Tool Renderer Registry // ============================================================ class ToolRendererRegistry { constructor() { this.renderers = new Map(); this.registerDefaultRenderers(); } /** * Register a tool renderer * @param {string} toolName - Name of the tool * @param {Object} renderer - Renderer object with getAction, getTitle, renderBody */ register(toolName, renderer) { this.renderers.set(toolName, renderer); } /** * Get renderer for a tool * @param {string} toolName - Name of the tool * @returns {Object} - Renderer object */ get(toolName) { return this.renderers.get(toolName) || this.renderers.get('default'); } /** * Register all default tool renderers */ registerDefaultRenderers() { // Bash/Shell Commands this.register('bash', { icon: 'โšก', getAction: (input) => 'Writing command...', getTitle: (input, output, metadata) => { const desc = input.description || input.command || ''; const timeout = input.timeout ? `ยท ${input.timeout}ms` : ''; return desc ? `Shell ${desc} ${timeout}`.trim() : 'Shell'; }, renderBody: (input, output, metadata) => { const command = input.command ? `$ ${escapeHtml(input.command)}` : ''; const out = metadata.output || output || ''; const exitCode = metadata.exitCode !== undefined ? `Exit: ${metadata.exitCode}` : ''; return `
${command ? `
${command}
` : ''} ${out ? `
${formatAnsiOutput(escapeHtml(out))}
` : ''} ${exitCode ? `
${exitCode}
` : ''}
`; } }); // File Edit this.register('edit', { icon: 'โœ๏ธ', getAction: () => 'Preparing edit...', getTitle: (input) => { const file = input.filePath?.split('/').pop() || 'file'; return `Edit ${file}`; }, renderBody: (input, output, metadata) => { const diff = metadata.diff || output; const filePath = input.filePath || 'unknown'; if (diff) { return `
Modified: ${escapeHtml(filePath)}
${formatDiff(diff)}
`; } const stats = metadata.stats || {}; const statsText = stats.added || stats.removed ? `${stats.added || 0} additions, ${stats.removed || 0} deletions` : ''; return `
Modified: ${escapeHtml(filePath)}
${statsText ? `
${statsText}
` : ''}
`; } }); // File Write this.register('write', { icon: '๐Ÿ“', getAction: () => 'Writing file...', getTitle: (input) => { const file = input.filePath?.split('/').pop() || 'file'; return `Write ${file}`; }, renderBody: (input, output) => { const filePath = input.filePath || 'unknown'; const content = output || ''; const lines = content.split('\n').length; const size = new Blob([content]).size; return `
Created: ${escapeHtml(filePath)}
${lines} lines (${formatBytes(size)})
${content ? `
${escapeHtml(content.split('\n').slice(0, 6).join('\n'))}${lines > 6 ? '\n...' : ''}
` : ''}
`; } }); // File Read this.register('read', { icon: '๐Ÿ“–', getAction: () => 'Reading file...', getTitle: (input) => { const file = input.filePath?.split('/').pop() || 'file'; return `Read ${file}`; }, renderBody: (input, output, metadata) => { const preview = metadata.preview || output || ''; const filePath = input.filePath || 'unknown'; if (preview) { const lines = preview.split('\n').slice(0, 6).join('\n'); const totalLines = preview.split('\n').length; return `
Read: ${escapeHtml(filePath)}
${escapeHtml(lines)}${totalLines > 6 ? '\n...' : ''}
${totalLines} lines total
`; } return `
Read: ${escapeHtml(filePath)}
`; } }); // Web Search this.register('websearch', { icon: '๐Ÿ”', getAction: () => 'Searching...', getTitle: (input) => { const query = input.query?.substring(0, 50) || ''; return query ? `Search: ${query}...` : 'Search'; }, renderBody: (input, output) => { if (Array.isArray(output)) { return `
${output.map(result => `
${escapeHtml(result.title || '')}
${escapeHtml(result.snippet || result.description || '')}
`).join('')}
`; } // Fallback for string output if (typeof output === 'string') { return `
${escapeHtml(output.substring(0, 500))}
`; } return ''; } }); // WebFetch this.register('webfetch', { icon: '๐ŸŒ', getAction: () => 'Fetching...', getTitle: (input) => { const url = input.url?.substring(0, 50) || ''; return url ? `Fetch ${url}...` : 'Fetch'; }, renderBody: (input, output, metadata) => { const url = input.url || ''; const content = output || ''; const preview = content.substring(0, 300); return `
${escapeHtml(url)}
${escapeHtml(preview)}${content.length > 300 ? '...' : ''}
${content.length > 300 ? `
${content.length} characters
` : ''}
`; } }); // Task/Agent Delegation this.register('task', { icon: '๐Ÿค–', getAction: () => 'Delegating...', getTitle: (input) => { const type = input.subagent_type || 'agent'; const desc = input.description?.substring(0, 40) || ''; return desc ? `Task[${type}] ${desc}...` : `Task[${type}]`; }, renderBody: (input, output, metadata) => { const summary = metadata.summary || []; const result = output; if (summary.length > 0) { return `
${summary.map(item => `
${getToolIcon(item.tool)} ${escapeHtml(item.description || item.tool)}
`).join('')}
`; } if (result && typeof result === 'string') { return `
${escapeHtml(result.substring(0, 500))}
`; } return ''; } }); // Todo/Plan Management this.register('todowrite', { icon: '๐Ÿ“‹', getAction: (input) => { const todos = input.todos || []; const allPending = todos.every(t => t.status === 'pending'); const allCompleted = todos.every(t => t.status === 'completed'); if (allPending) return 'Creating plan...'; if (allCompleted) return 'Completing plan...'; return 'Updating plan...'; }, getTitle: (input) => { const todos = input.todos || []; const completed = todos.filter(t => t.status === 'completed').length; const total = todos.length; return `Plan (${completed}/${total})`; }, renderBody: (input) => { const todos = input.todos || []; return `
${todos.map(todo => `
${todo.status === 'completed' ? 'โ˜‘' : todo.status === 'in_progress' ? 'โ˜' : 'โ˜'} ${escapeHtml(todo.content)}
`).join('')}
`; } }); // Grep - Content Search this.register('grep', { icon: '๐Ÿ”Ž', getAction: () => 'Searching...', getTitle: (input) => { const pattern = input.pattern?.substring(0, 40) || ''; return pattern ? `Grep "${pattern}"` : 'Grep'; }, renderBody: (input, output) => { if (Array.isArray(output)) { return `
${output.slice(0, 20).map(match => `
${escapeHtml(match.path || match.file || '')}
Line ${match.line || '?'}: ${escapeHtml(match.text || match.content || '').substring(0, 100)}
`).join('')} ${output.length > 20 ? `
+${output.length - 20} more matches
` : ''}
`; } return ''; } }); // Glob - File Pattern Matching this.register('glob', { icon: '๐Ÿ“', getAction: () => 'Searching files...', getTitle: (input) => { const pattern = input.pattern?.substring(0, 50) || ''; return pattern ? `Glob ${pattern}` : 'Glob'; }, renderBody: (input, output) => { const files = output || []; const count = files.length; if (count > 0) { return `
${count} file${count !== 1 ? 's' : ''} found
${files.slice(0, 15).map(f => `
${escapeHtml(f)}
`).join('')}
${count > 15 ? `
+${count - 15} more files
` : ''}
`; } return '
No files found
'; } }); // List - Directory Listing this.register('list', { icon: '๐Ÿ“‚', getAction: () => 'Listing directory...', getTitle: (input) => { const path = input.path || ''; return `List ${path.split('/').pop() || 'directory'}`; }, renderBody: (input, output) => { const entries = output?.entries || []; return `
${entries.slice(0, 20).map(entry => `
${entry.type === 'directory' ? '๐Ÿ“' : '๐Ÿ“„'} ${escapeHtml(entry.name)}
`).join('')} ${entries.length > 20 ? `
+${entries.length - 20} more entries
` : ''}
`; } }); // Patch - Apply Patch this.register('patch', { icon: '๐Ÿ”ง', getAction: () => 'Applying patch...', getTitle: () => 'Patch', renderBody: (input, output) => { return `
Patch applied successfully
`; } }); // Default fallback for unknown tools this.register('default', { icon: '๐Ÿ”ง', getAction: () => 'Processing...', getTitle: (input, toolName) => { return capitalize(toolName || 'tool'); }, renderBody: (input, output) => { if (typeof output === 'string' && output) { const preview = output.split('\n').slice(0, 10).join('\n'); return `
${escapeHtml(preview)}
`; } if (output && typeof output === 'object') { return `
${escapeHtml(JSON.stringify(output, null, 2).substring(0, 500))}
`; } return ''; } }); } } // ============================================================ // Helper Functions // ============================================================ /** * Escape HTML to prevent XSS */ function escapeHtml(text) { if (text === null || text === undefined) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Capitalize first letter */ function capitalize(str) { if (!str) return ''; return str.charAt(0).toUpperCase() + str.slice(1); } /** * Format bytes to human readable size */ function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } /** * Format ANSI escape sequences for terminal output */ function formatAnsiOutput(text) { // Simple ANSI to HTML conversion // Remove ANSI codes for now (can be enhanced with proper library) return text .replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI color codes .replace(/\x1b\[[0-9;]*K/g, ''); // Remove ANSI erase codes } /** * Format unified diff for display */ function formatDiff(diff) { if (!diff) return ''; const lines = diff.split('\n'); let html = ''; for (const line of lines) { let className = 'diff-line'; if (line.startsWith('+') && !line.startsWith('+++')) { className += ' diff-add'; } else if (line.startsWith('-') && !line.startsWith('---')) { className += ' diff-remove'; } else if (line.startsWith('@@')) { className += ' diff-hunk'; } html += `
${escapeHtml(line)}
`; } return html; } /** * Get icon for tool type */ function getToolIcon(toolName) { const icons = { 'bash': 'โšก', 'edit': 'โœ๏ธ', 'write': '๐Ÿ“', 'read': '๐Ÿ“–', 'websearch': '๐Ÿ”', 'webfetch': '๐ŸŒ', 'task': '๐Ÿค–', 'todowrite': '๐Ÿ“‹', 'grep': '๐Ÿ”Ž', 'glob': '๐Ÿ“', 'list': '๐Ÿ“‚' }; return icons[toolName] || '๐Ÿ”ง'; } /** * Get status icon for tool state */ function getStatusIcon(status) { const icons = { 'pending': 'โณ', 'running': 'โšก', 'completed': 'โœ“', 'error': 'โœ—' }; return icons[status] || 'โณ'; } // ============================================================ // Public API // ============================================================ /** * Render a tool call with specialized renderer * @param {Object} toolCall - Tool call object * @param {string} status - Tool status (pending, running, completed, error) * @returns {string} - HTML string */ function renderToolCall(toolCall, status = 'pending') { const registry = window.toolRendererRegistry; if (!registry) return ''; const renderer = registry.get(toolCall.name); const input = toolCall.input || {}; const output = toolCall.output; const metadata = toolCall.metadata || {}; const toolId = toolCall.id || `tool-${Date.now()}`; const icon = renderer.icon; const action = status === 'pending' ? renderer.getAction(input) : renderer.getTitle(input, output, metadata); const body = status !== 'pending' ? renderer.renderBody(input, output, metadata) : ''; return `
${body}
`; } /** * Toggle tool call expand/collapse */ function toggleToolCall(toolId) { const body = document.getElementById(`tool-body-${toolId}`); const header = body?.previousElementSibling; const expand = header?.querySelector('.tool-expand'); if (body && header && expand) { const isCollapsed = body.style.display === 'none'; body.style.display = isCollapsed ? 'block' : 'none'; expand.textContent = isCollapsed ? 'โ–ผ' : 'โ–ถ'; } } // ============================================================ // Initialize // ============================================================ // Create global registry window.toolRendererRegistry = new ToolRendererRegistry(); // Export functions window.renderToolCall = renderToolCall; window.toggleToolCall = toggleToolCall; console.log('[ToolRenderers] Tool renderer registry initialized with ' + window.toolRendererRegistry.renderers.size + ' renderers');