feat(ui): add ToolRenderer system for Omega chat

Add abstract tool rendering infrastructure to @tpmjs/ui:
- ToolRenderer component with registry-based renderer lookup
- DefaultJsonRenderer as fallback with collapsible JSON display
- RegistrySearchRenderer for registrySearchTool results
- RegistryExecuteRenderer for registryExecuteTool results
- registerBuiltInRenderers() for idempotent initialization

Update Omega chat page to use new ToolRenderer:
- Replace inline ToolCallCard with ToolRenderer component
- Add helper functions to convert between ToolCall and ToolPart
- Remove unused expandedToolCalls state (managed internally)

Also add video file extensions to .gitignore
This commit is contained in:
Ajax Davis 2026-01-23 22:35:17 +10:00
parent ca6f908ebd
commit 0489646e55
12 changed files with 967 additions and 136 deletions

6
.gitignore vendored
View file

@ -16,6 +16,12 @@ dist
# misc # misc
.DS_Store .DS_Store
# video files
*.mp4
*.webm
*.mov
*.avi
# debug # debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*

View file

@ -4,6 +4,9 @@ import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button'; import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon'; import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Textarea } from '@tpmjs/ui/Textarea/Textarea'; import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
import { registerBuiltInRenderers } from '@tpmjs/ui/ToolRenderer/registerBuiltInRenderers';
import { ToolRenderer } from '@tpmjs/ui/ToolRenderer/ToolRenderer';
import type { ToolPart, ToolState } from '@tpmjs/ui/ToolRenderer/types';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
@ -11,6 +14,9 @@ import { Streamdown } from 'streamdown';
import { AppHeader } from '~/components/AppHeader'; import { AppHeader } from '~/components/AppHeader';
import { EnvVarWarningBanner } from '~/components/omega/EnvVarWarningBanner'; import { EnvVarWarningBanner } from '~/components/omega/EnvVarWarningBanner';
// Initialize built-in tool renderers (idempotent)
registerBuiltInRenderers();
interface ToolDiscoveryInfo { interface ToolDiscoveryInfo {
staticTools?: string[]; staticTools?: string[];
dynamicToolsLoaded?: string[]; dynamicToolsLoaded?: string[];
@ -29,7 +35,8 @@ interface Message {
toolCalls?: Array<{ toolCalls?: Array<{
toolCallId: string; toolCallId: string;
toolName: string; toolName: string;
args: unknown; args?: unknown;
output?: unknown;
}>; }>;
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
@ -69,115 +76,32 @@ interface Conversation {
} }
/** /**
* Tool call debug card component * Convert ToolCall status to ToolState
*/ */
function ToolCallCard({ function statusToToolState(status: ToolCall['status']): ToolState {
toolCall, switch (status) {
isExpanded, case 'pending':
onToggle, return 'partial-call';
}: { case 'running':
toolCall: ToolCall; return 'call';
isExpanded: boolean; case 'success':
onToggle: () => void; case 'error':
}) { return 'result';
const statusColors = { }
pending: 'bg-warning/10 text-warning border-warning/30', }
running: 'bg-info/10 text-info border-info/30',
success: 'bg-success/10 text-success border-success/30', /**
error: 'bg-error/10 text-error border-error/30', * Convert ToolCall to ToolPart for rendering
*/
function toolCallToToolPart(tc: ToolCall): ToolPart {
return {
type: tc.status === 'success' || tc.status === 'error' ? 'tool-result' : 'tool-call',
toolCallId: tc.toolCallId,
toolName: tc.toolName,
args: tc.input,
result: tc.isError ? { error: tc.output } : tc.output,
state: statusToToolState(tc.status),
}; };
const statusIcons: Record<ToolCall['status'], 'loader' | 'check' | 'alertCircle' | 'info'> = {
pending: 'info',
running: 'loader',
success: 'check',
error: 'alertCircle',
};
const formatJson = (data: unknown): React.ReactNode => {
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
};
return (
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden font-mono text-xs">
{/* Header */}
<Button
variant="ghost"
onClick={onToggle}
className="w-full flex items-center gap-3 p-3 h-auto justify-start rounded-none hover:bg-surface-secondary/80"
>
<div className={`p-1.5 rounded ${statusColors[toolCall.status]}`}>
<Icon
icon={statusIcons[toolCall.status]}
size="xs"
className={toolCall.status === 'running' ? 'animate-spin' : ''}
/>
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className="text-foreground font-semibold">{toolCall.toolName}</span>
<span className="text-foreground-tertiary text-[10px]">
{toolCall.toolCallId.slice(0, 8)}...
</span>
</div>
</div>
<Icon
icon="chevronRight"
size="xs"
className={`text-foreground-tertiary transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
</Button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border">
{/* Input Section */}
{toolCall.input !== undefined && toolCall.input !== null ? (
<div className="p-3 border-b border-border/50">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
Input
</span>
<div className="flex-1 h-px bg-border/50" />
</div>
<pre className="text-[11px] text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto">
{formatJson(toolCall.input)}
</pre>
</div>
) : null}
{/* Output Section */}
{toolCall.output !== undefined && toolCall.output !== null ? (
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
Output
</span>
<div className="flex-1 h-px bg-border/50" />
</div>
<pre
className={`text-[11px] overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto ${toolCall.isError ? 'text-error' : 'text-success'}`}
>
{formatJson(toolCall.output)}
</pre>
</div>
) : null}
{/* Status indicator for running */}
{toolCall.status === 'running' && !toolCall.output && (
<div className="p-3 flex items-center gap-2 text-foreground-tertiary">
<Icon icon="loader" size="xs" className="animate-spin" />
<span>Executing...</span>
</div>
)}
</div>
)}
</div>
);
} }
/** /**
@ -197,7 +121,6 @@ export default function OmegaChatPage(): React.ReactElement {
const [streamingContent, setStreamingContent] = useState(''); const [streamingContent, setStreamingContent] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]); const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(new Set());
const [viewMode, setViewMode] = useState<'chat' | 'debug'>('chat'); const [viewMode, setViewMode] = useState<'chat' | 'debug'>('chat');
// Map of message IDs to their tool discovery info (persisted in frontend state) // Map of message IDs to their tool discovery info (persisted in frontend state)
const [messageToolDiscovery, setMessageToolDiscovery] = useState<Map<string, ToolDiscoveryInfo>>( const [messageToolDiscovery, setMessageToolDiscovery] = useState<Map<string, ToolDiscoveryInfo>>(
@ -209,18 +132,6 @@ export default function OmegaChatPage(): React.ReactElement {
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const toggleToolCall = (toolCallId: string) => {
setExpandedToolCalls((prev) => {
const next = new Set(prev);
if (next.has(toolCallId)) {
next.delete(toolCallId);
} else {
next.add(toolCallId);
}
return next;
});
};
// Fetch conversation details // Fetch conversation details
const fetchConversation = useCallback(async () => { const fetchConversation = useCallback(async () => {
try { try {
@ -353,8 +264,6 @@ export default function OmegaChatPage(): React.ReactElement {
status: 'running', status: 'running',
}, },
]); ]);
// Auto-expand new tool calls
setExpandedToolCalls((prev) => new Set([...prev, data.toolCallId]));
break; break;
case 'run.step.tool.completed': case 'run.step.tool.completed':
// Update tool call with result // Update tool call with result
@ -580,6 +489,7 @@ export default function OmegaChatPage(): React.ReactElement {
</div> </div>
{/* Per-Message View */} {/* Per-Message View */}
{/* biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Debug view with multiple conditional sections */}
{messages.map((message, index) => { {messages.map((message, index) => {
const discovery = messageToolDiscovery.get(message.id); const discovery = messageToolDiscovery.get(message.id);
return ( return (
@ -789,16 +699,24 @@ export default function OmegaChatPage(): React.ReactElement {
</div> </div>
)} )}
{/* TOOL message - show as collapsed tool result */} {/* TOOL message - show tool results from toolCalls field */}
{message.role === 'TOOL' && ( {message.role === 'TOOL' && message.toolCalls && (
<div className="flex justify-start"> <div className="flex justify-start">
<div className="max-w-[80%]"> <div className="max-w-[80%] space-y-2">
<div className="text-xs text-foreground-tertiary mb-1"> {message.toolCalls.map((tc) => (
Tool Results <ToolRenderer
</div> key={tc.toolCallId}
<pre className="text-xs font-mono bg-surface-secondary border border-border rounded p-2 overflow-x-auto max-h-32 overflow-y-auto"> part={{
{message.content} type: 'tool-result',
</pre> toolCallId: tc.toolCallId,
toolName: tc.toolName,
args: tc.args,
result: tc.output,
state: 'result',
}}
isStreaming={false}
/>
))}
</div> </div>
</div> </div>
)} )}
@ -811,10 +729,9 @@ export default function OmegaChatPage(): React.ReactElement {
{toolCalls.map((tc) => ( {toolCalls.map((tc) => (
<div key={tc.toolCallId} className="flex justify-start"> <div key={tc.toolCallId} className="flex justify-start">
<div className="max-w-[80%]"> <div className="max-w-[80%]">
<ToolCallCard <ToolRenderer
toolCall={tc} part={toolCallToToolPart(tc)}
isExpanded={expandedToolCalls.has(tc.toolCallId)} isStreaming={tc.status === 'running'}
onToggle={() => toggleToolCall(tc.toolCallId)}
/> />
</div> </div>
</div> </div>

View file

@ -234,6 +234,34 @@
"./PageHeader/PageHeader": { "./PageHeader/PageHeader": {
"types": "./dist/PageHeader/PageHeader.d.ts", "types": "./dist/PageHeader/PageHeader.d.ts",
"default": "./dist/PageHeader/PageHeader.js" "default": "./dist/PageHeader/PageHeader.js"
},
"./ToolRenderer/ToolRenderer": {
"types": "./dist/ToolRenderer/ToolRenderer.d.ts",
"default": "./dist/ToolRenderer/ToolRenderer.js"
},
"./ToolRenderer/types": {
"types": "./dist/ToolRenderer/types.d.ts",
"default": "./dist/ToolRenderer/types.js"
},
"./ToolRenderer/registry": {
"types": "./dist/ToolRenderer/registry.d.ts",
"default": "./dist/ToolRenderer/registry.js"
},
"./ToolRenderer/DefaultJsonRenderer": {
"types": "./dist/ToolRenderer/DefaultJsonRenderer.d.ts",
"default": "./dist/ToolRenderer/DefaultJsonRenderer.js"
},
"./ToolRenderer/registerBuiltInRenderers": {
"types": "./dist/ToolRenderer/registerBuiltInRenderers.d.ts",
"default": "./dist/ToolRenderer/registerBuiltInRenderers.js"
},
"./ToolRenderer/renderers/RegistrySearchRenderer": {
"types": "./dist/ToolRenderer/renderers/RegistrySearchRenderer.d.ts",
"default": "./dist/ToolRenderer/renderers/RegistrySearchRenderer.js"
},
"./ToolRenderer/renderers/RegistryExecuteRenderer": {
"types": "./dist/ToolRenderer/renderers/RegistryExecuteRenderer.d.ts",
"default": "./dist/ToolRenderer/renderers/RegistryExecuteRenderer.js"
} }
}, },
"files": [ "files": [

View file

@ -0,0 +1,165 @@
/**
* Default JSON Renderer
*
* Fallback renderer that displays tool input/output as formatted JSON.
* Used when no specific renderer is registered for a tool.
*/
import { cn } from '@tpmjs/utils/cn';
import { useState } from 'react';
import { Button } from '../Button/Button';
import { Icon } from '../Icon/Icon';
import type { ToolRendererProps } from './types';
/**
* Format data as JSON string for display
*/
function formatJson(data: unknown): string {
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
}
/**
* DefaultJsonRenderer - Fallback renderer for any tool
*
* Displays:
* - Collapsible header with tool name and status
* - Input parameters as formatted JSON
* - Output results as formatted JSON
* - Loading state while executing
* - Error state styling
*/
export function DefaultJsonRenderer({
toolName,
input,
output,
state,
isStreaming,
error,
}: ToolRendererProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
// Determine status based on state
const status = error
? 'error'
: state === 'result'
? 'success'
: isStreaming
? 'running'
: 'pending';
const statusConfig = {
pending: {
icon: 'info' as const,
colorClass: 'bg-warning/10 text-warning border-warning/30',
label: 'Pending',
},
running: {
icon: 'loader' as const,
colorClass: 'bg-info/10 text-info border-info/30',
label: 'Running',
},
success: {
icon: 'check' as const,
colorClass: 'bg-success/10 text-success border-success/30',
label: 'Success',
},
error: {
icon: 'alertCircle' as const,
colorClass: 'bg-error/10 text-error border-error/30',
label: 'Error',
},
};
const { icon, colorClass, label } = statusConfig[status];
return (
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden font-mono text-xs">
{/* Header */}
<Button
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center gap-3 p-3 h-auto justify-start rounded-none hover:bg-surface-secondary/80"
>
<div className={cn('p-1.5 rounded border', colorClass)}>
<Icon icon={icon} size="xs" className={status === 'running' ? 'animate-spin' : ''} />
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className="text-foreground font-semibold">{toolName}</span>
<span className="text-foreground-tertiary text-[10px]">{label}</span>
</div>
</div>
<Icon
icon="chevronRight"
size="xs"
className={cn('text-foreground-tertiary transition-transform', isExpanded && 'rotate-90')}
/>
</Button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border">
{/* Input Section */}
{input !== undefined && input !== null && (
<div className="p-3 border-b border-border/50">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
Input
</span>
<div className="flex-1 h-px bg-border/50" />
</div>
<pre className="text-[11px] text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto">
{formatJson(input)}
</pre>
</div>
)}
{/* Output Section */}
{output !== undefined && output !== null && (
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
Output
</span>
<div className="flex-1 h-px bg-border/50" />
</div>
<pre
className={cn(
'text-[11px] overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto',
error ? 'text-error' : 'text-success'
)}
>
{formatJson(output)}
</pre>
</div>
)}
{/* Error Section */}
{error && !output && (
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] uppercase tracking-wider text-error">Error</span>
<div className="flex-1 h-px bg-error/30" />
</div>
<pre className="text-[11px] text-error overflow-x-auto whitespace-pre-wrap break-all">
{error}
</pre>
</div>
)}
{/* Loading state */}
{status === 'running' && !output && !error && (
<div className="p-3 flex items-center gap-2 text-foreground-tertiary">
<Icon icon="loader" size="xs" className="animate-spin" />
<span>Executing...</span>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,117 @@
/**
* ToolRenderer Component
*
* Main component for rendering tool calls in chat interfaces.
* Uses the registry to find the appropriate renderer for each tool.
*/
import { DefaultJsonRenderer } from './DefaultJsonRenderer';
import { toolRendererRegistry } from './registry';
import type { ToolPart, ToolRendererProps, ToolState } from './types';
export interface ToolRendererComponentProps {
/** The tool part from AI SDK message.parts */
part: ToolPart;
/** Whether this tool is currently streaming/executing */
isStreaming?: boolean;
}
/**
* Extract input from various tool part formats
*/
function extractInput(part: ToolPart): unknown {
// AI SDK uses 'args' for tool-call and tool-invocation
if ('args' in part) {
return part.args;
}
return undefined;
}
/**
* Extract output from various tool part formats
*/
function extractOutput(part: ToolPart): unknown {
// AI SDK uses 'result' for tool-result
if ('result' in part) {
return part.result;
}
return undefined;
}
/**
* Determine the tool state from the part type
*/
function determineState(part: ToolPart): ToolState {
// If state is explicitly set, use it
if (part.state) {
return part.state;
}
// Otherwise infer from type
if (part.type === 'tool-result') {
return 'result';
}
if (part.type === 'tool-call') {
return 'call';
}
return 'partial-call';
}
/**
* Check if output indicates an error
*/
function extractError(output: unknown): string | undefined {
if (output && typeof output === 'object' && 'error' in output) {
const err = (output as { error: unknown }).error;
return typeof err === 'string' ? err : JSON.stringify(err);
}
return undefined;
}
/**
* ToolRenderer - Renders a tool call using the appropriate registered renderer
*
* @example
* ```tsx
* import { ToolRenderer } from '@tpmjs/ui/ToolRenderer/ToolRenderer';
*
* // In a chat message component
* {message.parts?.map((part, idx) => {
* if (part.type.startsWith('tool-')) {
* return (
* <ToolRenderer
* key={part.toolCallId || idx}
* part={part}
* isStreaming={isStreaming && part.state !== 'result'}
* />
* );
* }
* return null;
* })}
* ```
*/
export function ToolRenderer({
part,
isStreaming = false,
}: ToolRendererComponentProps): React.ReactElement {
const toolName = part.toolName;
const input = extractInput(part);
const output = extractOutput(part);
const state = determineState(part);
const error = extractError(output);
// Find a registered renderer or use default
const RegisteredRenderer = toolRendererRegistry.getRenderer(toolName);
const Renderer = RegisteredRenderer ?? DefaultJsonRenderer;
const props: ToolRendererProps = {
part,
toolName,
input,
output,
state,
isStreaming,
error,
};
return <Renderer {...props} />;
}

View file

@ -0,0 +1,54 @@
/**
* Register Built-in Tool Renderers
*
* Call this function once at app initialization to register
* all built-in tool renderers with the registry.
*/
import { DefaultJsonRenderer } from './DefaultJsonRenderer';
import { toolRendererRegistry } from './registry';
import { RegistryExecuteRenderer } from './renderers/RegistryExecuteRenderer';
import { RegistrySearchRenderer } from './renderers/RegistrySearchRenderer';
let isRegistered = false;
/**
* Register all built-in tool renderers
*
* This function is idempotent - calling it multiple times has no effect.
*
* @example
* ```tsx
* // In your app's root or layout
* import { registerBuiltInRenderers } from '@tpmjs/ui/ToolRenderer/registerBuiltInRenderers';
*
* registerBuiltInRenderers();
* ```
*/
export function registerBuiltInRenderers(): void {
if (isRegistered) {
return;
}
// Register specific renderers first (higher priority)
toolRendererRegistry.register({
match: (name) => name === 'registrySearchTool',
priority: 100,
component: RegistrySearchRenderer,
});
toolRendererRegistry.register({
match: (name) => name === 'registryExecuteTool',
priority: 100,
component: RegistryExecuteRenderer,
});
// Register default fallback renderer (lowest priority)
toolRendererRegistry.register({
match: () => true,
priority: -1000,
component: DefaultJsonRenderer,
});
isRegistered = true;
}

View file

@ -0,0 +1,51 @@
/**
* Tool Renderer Registry
*
* Singleton registry for registering and looking up tool renderers.
* Renderers are matched by priority (higher = more specific).
*/
import type { ToolRendererConfig, ToolRendererProps } from './types';
class ToolRendererRegistry {
private renderers: ToolRendererConfig[] = [];
/**
* Register a tool renderer
* @param config - The renderer configuration
*/
register<TInput = unknown, TOutput = unknown>(config: ToolRendererConfig<TInput, TOutput>): void {
// Add renderer and sort by priority (descending)
this.renderers.push(config as ToolRendererConfig);
this.renderers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
/**
* Get the appropriate renderer for a tool
* @param toolName - The name of the tool
* @returns The matching renderer component, or null if none found
*/
getRenderer(toolName: string): React.ComponentType<ToolRendererProps> | null {
const config = this.renderers.find((r) => r.match(toolName));
return config?.component ?? null;
}
/**
* Get all registered renderers (for debugging)
*/
getRegisteredRenderers(): ToolRendererConfig[] {
return [...this.renderers];
}
/**
* Clear all renderers (useful for testing)
*/
clear(): void {
this.renderers = [];
}
}
/**
* Global tool renderer registry
*/
export const toolRendererRegistry = new ToolRendererRegistry();

View file

@ -0,0 +1,219 @@
/**
* Registry Execute Tool Renderer
*
* Specialized renderer for the registryExecuteTool that displays
* the tool being executed with its input and output.
*/
import { cn } from '@tpmjs/utils/cn';
import { useState } from 'react';
import { Badge } from '../../Badge/Badge';
import { Button } from '../../Button/Button';
import { Icon } from '../../Icon/Icon';
import type { ToolRendererProps } from '../types';
/**
* Input for registryExecuteTool
*/
interface ExecuteInput {
toolId?: string;
params?: Record<string, unknown>;
}
/**
* Output from registryExecuteTool
*/
interface ExecuteOutput {
result?: unknown;
error?: string;
executionTimeMs?: number;
}
/**
* Parse toolId into package and tool name
*/
function parseToolId(toolId: string): { packageName: string; toolName: string } {
const parts = toolId.split('::');
if (parts.length >= 2) {
return {
packageName: parts[0] || 'unknown',
toolName: parts.slice(1).join('::') || 'unknown',
};
}
return {
packageName: toolId,
toolName: 'unknown',
};
}
/**
* Format data as JSON string for display
*/
function formatJson(data: unknown): string {
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
}
/**
* RegistryExecuteRenderer - Displays tool execution with input/output
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex renderer with multiple UI states
export function RegistryExecuteRenderer({
input,
output,
state,
isStreaming,
error,
}: ToolRendererProps<ExecuteInput, ExecuteOutput>): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
const [showInput, setShowInput] = useState(false);
const execInput = input as ExecuteInput | undefined;
const execOutput = output as ExecuteOutput | undefined;
const hasError = error || execOutput?.error;
const { packageName, toolName } = parseToolId(execInput?.toolId ?? '');
// Determine status
const status = hasError
? 'error'
: state === 'result'
? 'success'
: isStreaming
? 'running'
: 'pending';
const statusConfig = {
pending: {
icon: 'info' as const,
colorClass: 'bg-warning/10 text-warning border-warning/30',
label: 'Pending',
},
running: {
icon: 'loader' as const,
colorClass: 'bg-info/10 text-info border-info/30',
label: 'Executing',
},
success: {
icon: 'check' as const,
colorClass: 'bg-success/10 text-success border-success/30',
label: 'Success',
},
error: {
icon: 'alertCircle' as const,
colorClass: 'bg-error/10 text-error border-error/30',
label: 'Error',
},
};
const { icon, colorClass, label } = statusConfig[status];
return (
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden">
{/* Header */}
<Button
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center gap-3 p-3 h-auto justify-start rounded-none hover:bg-surface-secondary/80"
>
<div className={cn('p-1.5 rounded border', colorClass)}>
<Icon icon={icon} size="xs" className={status === 'running' ? 'animate-spin' : ''} />
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" size="sm" className="font-mono">
{packageName}
</Badge>
<span className="text-sm font-medium text-foreground">{toolName}</span>
<span className="text-xs text-foreground-tertiary">{label}</span>
</div>
{execOutput?.executionTimeMs && (
<span className="text-[10px] text-foreground-tertiary">
{execOutput.executionTimeMs}ms
</span>
)}
</div>
<Icon
icon="chevronRight"
size="xs"
className={cn('text-foreground-tertiary transition-transform', isExpanded && 'rotate-90')}
/>
</Button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border">
{/* Input toggle */}
{execInput?.params && Object.keys(execInput.params).length > 0 && (
<div className="border-b border-border/50">
<Button
variant="ghost"
size="sm"
onClick={() => setShowInput(!showInput)}
className="w-full rounded-none text-xs text-foreground-tertiary flex items-center gap-2 justify-start"
>
<Icon
icon="chevronRight"
size="xs"
className={cn('transition-transform', showInput && 'rotate-90')}
/>
<span>Input Parameters ({Object.keys(execInput.params).length})</span>
</Button>
{showInput && (
<pre className="p-3 text-[11px] font-mono text-foreground-secondary bg-background/50 overflow-x-auto whitespace-pre-wrap break-all max-h-40 overflow-y-auto border-t border-border/30">
{formatJson(execInput.params)}
</pre>
)}
</div>
)}
{/* Loading state */}
{status === 'running' && (
<div className="p-4 flex items-center gap-2 text-foreground-tertiary">
<Icon icon="loader" size="sm" className="animate-spin" />
<span className="text-sm">Executing {toolName}...</span>
</div>
)}
{/* Error state */}
{hasError && (
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<Icon icon="alertCircle" size="xs" className="text-error" />
<span className="text-[10px] uppercase tracking-wider text-error">Error</span>
</div>
<pre className="text-[11px] font-mono text-error overflow-x-auto whitespace-pre-wrap break-all">
{error || execOutput?.error}
</pre>
</div>
)}
{/* Success output */}
{state === 'result' && !hasError && execOutput?.result !== undefined && (
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
Output
</span>
<div className="flex-1 h-px bg-border/50" />
</div>
<pre className="text-[11px] font-mono text-success overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
{formatJson(execOutput.result)}
</pre>
</div>
)}
{/* No output state */}
{state === 'result' && !hasError && execOutput?.result === undefined && (
<div className="p-3 text-sm text-foreground-tertiary text-center">
No output returned
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,204 @@
/**
* Registry Search Tool Renderer
*
* Specialized renderer for the registrySearchTool that displays
* search results as a grid of tool cards.
*/
import { cn } from '@tpmjs/utils/cn';
import { useState } from 'react';
import { Badge } from '../../Badge/Badge';
import { Button } from '../../Button/Button';
import { Icon } from '../../Icon/Icon';
import type { ToolRendererProps } from '../types';
/**
* Input for registrySearchTool
*/
interface SearchInput {
query?: string;
limit?: number;
}
/**
* A tool result from the search
*/
interface SearchResultTool {
toolId: string;
package: string;
name: string;
description: string;
}
/**
* Output from registrySearchTool
*/
interface SearchOutput {
tools?: SearchResultTool[];
total?: number;
error?: string;
}
/**
* RegistrySearchRenderer - Displays search results as tool cards
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex renderer with multiple UI states
export function RegistrySearchRenderer({
input,
output,
state,
isStreaming,
error,
}: ToolRendererProps<SearchInput, SearchOutput>): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
const [showRawJson, setShowRawJson] = useState(false);
const searchInput = input as SearchInput | undefined;
const searchOutput = output as SearchOutput | undefined;
const tools = searchOutput?.tools ?? [];
const hasError = error || searchOutput?.error;
// Determine status
const status = hasError
? 'error'
: state === 'result'
? 'success'
: isStreaming
? 'running'
: 'pending';
const statusConfig = {
pending: { icon: 'info' as const, colorClass: 'text-warning' },
running: { icon: 'loader' as const, colorClass: 'text-info' },
success: { icon: 'check' as const, colorClass: 'text-success' },
error: { icon: 'alertCircle' as const, colorClass: 'text-error' },
};
const { icon, colorClass } = statusConfig[status];
const handleCopyToolId = (toolId: string) => {
navigator.clipboard.writeText(toolId);
};
return (
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden">
{/* Header */}
<Button
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center gap-3 p-3 h-auto justify-start rounded-none hover:bg-surface-secondary/80"
>
<Icon icon="search" size="sm" className="text-primary" />
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">Registry Search</span>
{searchInput?.query && (
<Badge variant="secondary" size="sm">
"{searchInput.query}"
</Badge>
)}
</div>
{state === 'result' && !hasError && (
<span className="text-xs text-foreground-tertiary">
Found {tools.length} tool{tools.length !== 1 ? 's' : ''}
</span>
)}
</div>
<Icon
icon={icon}
size="xs"
className={cn(colorClass, status === 'running' && 'animate-spin')}
/>
<Icon
icon="chevronRight"
size="xs"
className={cn('text-foreground-tertiary transition-transform', isExpanded && 'rotate-90')}
/>
</Button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border">
{/* Loading state */}
{status === 'running' && (
<div className="p-4 flex items-center justify-center gap-2 text-foreground-tertiary">
<Icon icon="loader" size="sm" className="animate-spin" />
<span className="text-sm">Searching registry...</span>
</div>
)}
{/* Error state */}
{hasError && <div className="p-4 text-sm text-error">{error || searchOutput?.error}</div>}
{/* Results grid */}
{state === 'result' && !hasError && tools.length > 0 && (
<div className="p-3 space-y-2">
{tools.map((tool) => (
<div
key={tool.toolId}
className="p-3 rounded-md border border-border/50 bg-background/50 hover:bg-background transition-colors group"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-primary font-mono">{tool.package}</span>
<span className="text-foreground-tertiary">::</span>
<span className="text-sm font-medium text-foreground">{tool.name}</span>
</div>
<p className="text-xs text-foreground-secondary line-clamp-2">
{tool.description}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleCopyToolId(tool.toolId);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy toolId"
>
<Icon icon="copy" size="xs" />
</Button>
</div>
<div className="mt-2 pt-2 border-t border-border/30">
<code className="text-[10px] text-foreground-tertiary font-mono">
{tool.toolId}
</code>
</div>
</div>
))}
</div>
)}
{/* Empty results */}
{state === 'result' && !hasError && tools.length === 0 && (
<div className="p-4 text-center text-sm text-foreground-tertiary">
No tools found for this search
</div>
)}
{/* Raw JSON toggle */}
{state === 'result' && (
<div className="border-t border-border/50">
<Button
variant="ghost"
size="sm"
onClick={() => setShowRawJson(!showRawJson)}
className="w-full rounded-none text-xs text-foreground-tertiary"
>
{showRawJson ? 'Hide' : 'Show'} Raw JSON
</Button>
{showRawJson && (
<pre className="p-3 text-[10px] font-mono text-foreground-tertiary bg-background/50 overflow-x-auto max-h-40 overflow-y-auto">
{JSON.stringify(output, null, 2)}
</pre>
)}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,8 @@
/**
* Built-in Tool Renderers
*
* Export all built-in renderers for registration.
*/
export { RegistryExecuteRenderer } from './RegistryExecuteRenderer';
export { RegistrySearchRenderer } from './RegistrySearchRenderer';

View file

@ -0,0 +1,55 @@
/**
* Tool Renderer Types
*
* Interfaces for the abstract tool rendering system.
* Used to render tool calls in AI chat interfaces.
*/
/**
* Tool state during streaming
*/
export type ToolState = 'partial-call' | 'call' | 'result';
/**
* A part representing a tool call or result from AI SDK
*/
export interface ToolPart {
type: 'tool-invocation' | 'tool-call' | 'tool-result';
toolCallId: string;
toolName: string;
args?: unknown;
result?: unknown;
state?: ToolState;
}
/**
* Props passed to every tool renderer
*/
export interface ToolRendererProps<TInput = unknown, TOutput = unknown> {
/** The full tool part from AI SDK */
part: ToolPart;
/** Name of the tool being rendered */
toolName: string;
/** Input parameters for the tool */
input: TInput;
/** Output from the tool (undefined while running) */
output: TOutput | undefined;
/** Current state of the tool call */
state: ToolState;
/** Whether the tool is currently streaming/executing */
isStreaming: boolean;
/** Error message if the tool failed */
error?: string;
}
/**
* Configuration for registering a tool renderer
*/
export interface ToolRendererConfig<TInput = unknown, TOutput = unknown> {
/** Function to determine if this renderer handles a given tool */
match: (toolName: string) => boolean;
/** Priority for matching (higher = more specific, checked first) */
priority?: number;
/** The React component to render the tool */
component: React.ComponentType<ToolRendererProps<TInput, TOutput>>;
}

View file

@ -44,6 +44,13 @@ entries.push('src/system/hooks/useParallax.ts');
entries.push('src/system/hooks/useReducedMotion.ts'); entries.push('src/system/hooks/useReducedMotion.ts');
entries.push('src/system/hooks/useDitherAnimation.ts'); entries.push('src/system/hooks/useDitherAnimation.ts');
// Manually add ToolRenderer files that don't match naming conventions
entries.push('src/ToolRenderer/types.ts');
entries.push('src/ToolRenderer/registry.ts');
entries.push('src/ToolRenderer/registerBuiltInRenderers.ts');
entries.push('src/ToolRenderer/renderers/RegistrySearchRenderer.tsx');
entries.push('src/ToolRenderer/renderers/RegistryExecuteRenderer.tsx');
export default defineConfig({ export default defineConfig({
entry: entries, entry: entries,
format: ['esm'], format: ['esm'],