270 lines
7.5 KiB
JavaScript
270 lines
7.5 KiB
JavaScript
/**
|
|
* Tool Registry - Renders tool-specific UI
|
|
*
|
|
* Based on sst/opencode ToolRegistry pattern
|
|
* Credit: https://github.com/sst/opencode
|
|
*
|
|
* Each tool has a dedicated renderer for consistent output
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { Box, Text } from 'ink';
|
|
import Spinner from 'ink-spinner';
|
|
import { colors } from '../../tui-theme.mjs';
|
|
import { icon } from '../../icons.mjs';
|
|
import { getCapabilities } from '../../terminal-profile.mjs';
|
|
|
|
const h = React.createElement;
|
|
|
|
// Registry of tool renderers
|
|
const toolRenderers = new Map();
|
|
|
|
/**
|
|
* Register a tool renderer
|
|
* @param {string} toolName - Tool identifier
|
|
* @param {object} renderer - { icon, title, renderSummary, renderDetails }
|
|
*/
|
|
export function registerTool(toolName, renderer) {
|
|
toolRenderers.set(toolName, {
|
|
icon: renderer.icon || '⚙',
|
|
iconAscii: renderer.iconAscii || '*',
|
|
title: renderer.title || toolName,
|
|
color: renderer.color || colors.accent,
|
|
renderSummary: renderer.renderSummary || defaultRenderSummary,
|
|
renderDetails: renderer.renderDetails || defaultRenderDetails
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get renderer for a tool
|
|
*/
|
|
export function getToolRenderer(toolName) {
|
|
return toolRenderers.get(toolName) || {
|
|
icon: '⚙',
|
|
iconAscii: '*',
|
|
title: toolName,
|
|
color: colors.muted,
|
|
renderSummary: defaultRenderSummary,
|
|
renderDetails: defaultRenderDetails
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Default summary renderer
|
|
*/
|
|
function defaultRenderSummary(args, status) {
|
|
const summary = Object.entries(args || {})
|
|
.slice(0, 2)
|
|
.map(([k, v]) => `${k}=${String(v).slice(0, 20)}`)
|
|
.join(', ');
|
|
return summary || 'Running...';
|
|
}
|
|
|
|
/**
|
|
* Default details renderer
|
|
*/
|
|
function defaultRenderDetails(args, result) {
|
|
if (result?.output) {
|
|
return result.output.slice(0, 500);
|
|
}
|
|
return JSON.stringify(args, null, 2).slice(0, 500);
|
|
}
|
|
|
|
// ============================================
|
|
// BUILT-IN TOOL RENDERERS
|
|
// ============================================
|
|
|
|
// File read tool
|
|
registerTool('read_file', {
|
|
icon: '📄',
|
|
iconAscii: '[R]',
|
|
title: 'Read File',
|
|
color: colors.accent,
|
|
renderSummary: (args) => args?.path || 'reading...',
|
|
renderDetails: (args, result) => result?.content?.slice(0, 500) || ''
|
|
});
|
|
|
|
// File write tool
|
|
registerTool('write_file', {
|
|
icon: '✏️',
|
|
iconAscii: '[W]',
|
|
title: 'Write File',
|
|
color: 'green',
|
|
renderSummary: (args) => args?.path || 'writing...',
|
|
renderDetails: (args) => `${args?.content?.split('\n').length || 0} lines`
|
|
});
|
|
|
|
// Edit file tool
|
|
registerTool('edit_file', {
|
|
icon: '📝',
|
|
iconAscii: '[E]',
|
|
title: 'Edit File',
|
|
color: 'yellow',
|
|
renderSummary: (args) => args?.path || 'editing...',
|
|
renderDetails: (args) => args?.description || ''
|
|
});
|
|
|
|
// Delete file tool
|
|
registerTool('delete_file', {
|
|
icon: '🗑️',
|
|
iconAscii: '[D]',
|
|
title: 'Delete File',
|
|
color: 'red',
|
|
renderSummary: (args) => args?.path || 'deleting...',
|
|
renderDetails: () => ''
|
|
});
|
|
|
|
// Shell/command tool
|
|
registerTool('shell', {
|
|
icon: '💻',
|
|
iconAscii: '>',
|
|
title: 'Shell',
|
|
color: 'magenta',
|
|
renderSummary: (args) => {
|
|
const cmd = args?.command || args?.cmd || '';
|
|
return cmd.length > 40 ? cmd.slice(0, 37) + '...' : cmd;
|
|
},
|
|
renderDetails: (args, result) => result?.output?.slice(0, 1000) || ''
|
|
});
|
|
|
|
registerTool('run_command', {
|
|
icon: '💻',
|
|
iconAscii: '>',
|
|
title: 'Command',
|
|
color: 'magenta',
|
|
renderSummary: (args) => {
|
|
const cmd = args?.command || args?.CommandLine || '';
|
|
return cmd.length > 40 ? cmd.slice(0, 37) + '...' : cmd;
|
|
},
|
|
renderDetails: (args, result) => result?.output?.slice(0, 1000) || ''
|
|
});
|
|
|
|
// Search tool
|
|
registerTool('search', {
|
|
icon: '🔍',
|
|
iconAscii: '?',
|
|
title: 'Search',
|
|
color: colors.accent,
|
|
renderSummary: (args) => args?.query || args?.pattern || 'searching...',
|
|
renderDetails: (args, result) => `${result?.matches?.length || 0} matches`
|
|
});
|
|
|
|
registerTool('grep_search', {
|
|
icon: '🔍',
|
|
iconAscii: '?',
|
|
title: 'Grep',
|
|
color: colors.accent,
|
|
renderSummary: (args) => args?.Query || 'searching...',
|
|
renderDetails: (args, result) => `${result?.matches?.length || 0} matches`
|
|
});
|
|
|
|
// List files tool
|
|
registerTool('list_files', {
|
|
icon: '📁',
|
|
iconAscii: '[L]',
|
|
title: 'List Files',
|
|
color: colors.muted,
|
|
renderSummary: (args) => args?.path || args?.directory || '.',
|
|
renderDetails: (args, result) => `${result?.files?.length || 0} items`
|
|
});
|
|
|
|
registerTool('list_dir', {
|
|
icon: '📁',
|
|
iconAscii: '[L]',
|
|
title: 'List Dir',
|
|
color: colors.muted,
|
|
renderSummary: (args) => args?.DirectoryPath || '.',
|
|
renderDetails: (args, result) => `${result?.children?.length || 0} items`
|
|
});
|
|
|
|
// TODO/task tool
|
|
registerTool('todowrite', {
|
|
icon: '✅',
|
|
iconAscii: '[T]',
|
|
title: 'Tasks',
|
|
color: 'green',
|
|
renderSummary: (args) => {
|
|
const todos = args?.todos || [];
|
|
const done = todos.filter(t => t.status === 'done').length;
|
|
return `${done}/${todos.length} done`;
|
|
},
|
|
renderDetails: (args) => {
|
|
const todos = args?.todos || [];
|
|
return todos.map(t =>
|
|
`[${t.status === 'done' ? 'x' : t.status === 'in_progress' ? '/' : ' '}] ${t.text}`
|
|
).join('\n');
|
|
}
|
|
});
|
|
|
|
// Web search tool
|
|
registerTool('web_search', {
|
|
icon: '🌐',
|
|
iconAscii: '[W]',
|
|
title: 'Web Search',
|
|
color: colors.accent,
|
|
renderSummary: (args) => args?.query || 'searching...',
|
|
renderDetails: (args, result) => result?.summary?.slice(0, 300) || ''
|
|
});
|
|
|
|
// Browser tool
|
|
registerTool('browser', {
|
|
icon: '🌐',
|
|
iconAscii: '[B]',
|
|
title: 'Browser',
|
|
color: colors.accent,
|
|
renderSummary: (args) => args?.url || 'browsing...',
|
|
renderDetails: () => ''
|
|
});
|
|
|
|
/**
|
|
* ToolBlock Component - Renders a tool invocation
|
|
*/
|
|
export const ToolBlock = ({
|
|
toolName,
|
|
args = {},
|
|
status = 'running', // running | done | failed
|
|
result = null,
|
|
isExpanded = false,
|
|
width = 80
|
|
}) => {
|
|
const caps = getCapabilities();
|
|
const renderer = getToolRenderer(toolName);
|
|
const railChar = caps.unicodeOK ? '│' : '|';
|
|
const toolIcon = caps.unicodeOK ? renderer.icon : renderer.iconAscii;
|
|
|
|
const statusConfig = {
|
|
running: { color: renderer.color, showSpinner: true },
|
|
done: { color: colors.success, showSpinner: false },
|
|
failed: { color: colors.error, showSpinner: false }
|
|
};
|
|
const config = statusConfig[status] || statusConfig.running;
|
|
|
|
// Summary line
|
|
const summary = renderer.renderSummary(args, status);
|
|
|
|
return h(Box, { flexDirection: 'column' },
|
|
// Header line
|
|
h(Box, { flexDirection: 'row' },
|
|
h(Text, { color: 'magenta' }, railChar + ' '),
|
|
config.showSpinner
|
|
? h(Spinner, { type: 'dots' })
|
|
: h(Text, { color: config.color }, toolIcon),
|
|
h(Text, {}, ' '),
|
|
h(Text, { color: config.color, bold: true }, renderer.title),
|
|
h(Text, { color: colors.muted }, ': '),
|
|
h(Text, { color: colors.muted, wrap: 'truncate' },
|
|
summary.length > width - 25 ? summary.slice(0, width - 28) + '...' : summary
|
|
)
|
|
),
|
|
|
|
// Details (if expanded)
|
|
isExpanded && result ? h(Box, { paddingLeft: 4 },
|
|
h(Text, { color: colors.muted, dimColor: true, wrap: 'wrap' },
|
|
renderer.renderDetails(args, result).slice(0, 500)
|
|
)
|
|
) : null
|
|
);
|
|
};
|
|
|
|
export default { registerTool, getToolRenderer, ToolBlock };
|