139 lines
4.7 KiB
TypeScript
139 lines
4.7 KiB
TypeScript
import { useState } from 'react';
|
|
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
|
|
import type { ChatMessage } from './types';
|
|
|
|
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
|
|
|
type ToolActivityProps = {
|
|
message: ToolMessage;
|
|
};
|
|
|
|
const toolIcons: Record<string, typeof FileText> = {
|
|
Read: FileText,
|
|
Edit: Pencil,
|
|
Write: Pencil,
|
|
Bash: Terminal,
|
|
Grep: Search,
|
|
Glob: Search,
|
|
WebFetch: Globe,
|
|
WebSearch: Globe,
|
|
};
|
|
|
|
function getToolSummary(toolName: string, toolInput: Record<string, unknown>): string {
|
|
switch (toolName) {
|
|
case 'Read':
|
|
case 'Edit':
|
|
case 'Write':
|
|
return (toolInput.file_path as string) ?? '';
|
|
case 'Bash':
|
|
return truncate((toolInput.command as string) ?? '', 80);
|
|
case 'Grep':
|
|
case 'Glob':
|
|
return (toolInput.pattern as string) ?? '';
|
|
case 'WebFetch':
|
|
return (toolInput.url as string) ?? '';
|
|
case 'WebSearch':
|
|
return (toolInput.query as string) ?? '';
|
|
default:
|
|
return (
|
|
Object.values(toolInput)
|
|
.find((v) => typeof v === 'string')
|
|
?.toString() ?? ''
|
|
);
|
|
}
|
|
}
|
|
|
|
function truncate(str: string, max: number): string {
|
|
return str.length > max ? str.slice(0, max) + '...' : str;
|
|
}
|
|
|
|
export const ToolActivity = ({ message }: ToolActivityProps) => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const Icon = toolIcons[message.toolName] ?? Wrench;
|
|
const summary = getToolSummary(message.toolName, message.toolInput);
|
|
const pending = message.output === undefined;
|
|
const isError = message.isError === true;
|
|
|
|
return (
|
|
<div className="my-1">
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className="flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-md hover:bg-duck-dark/5 transition-colors cursor-pointer text-sm"
|
|
>
|
|
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
|
|
<span className="font-medium text-duck-dark/80">{message.toolName}</span>
|
|
<span className="text-duck-dark/50 truncate flex-1 font-mono text-xs">{summary}</span>
|
|
<span className="shrink-0">
|
|
{pending && <span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
|
|
{!pending && !isError && <span className="text-green-600 text-xs">done</span>}
|
|
{!pending && isError && <span className="text-red-600 text-xs">error</span>}
|
|
</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="ml-7 mt-1 space-y-2 text-xs">
|
|
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
|
|
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Input</div>
|
|
{message.toolName === 'Bash' ? (
|
|
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-all">
|
|
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
|
|
</pre>
|
|
) : (
|
|
<pre className="font-mono whitespace-pre-wrap break-all text-duck-dark/70">
|
|
{Object.entries(message.toolInput)
|
|
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
.join('\n')}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
|
|
{message.output !== undefined && (
|
|
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
|
|
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Output</div>
|
|
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type ToolOutputProps = {
|
|
toolName: string;
|
|
output: string;
|
|
isError: boolean;
|
|
};
|
|
|
|
const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const maxLines = 20;
|
|
const lines = output.split('\n');
|
|
const needsTruncation = lines.length > maxLines;
|
|
const displayText = expanded ? output : lines.slice(0, maxLines).join('\n');
|
|
|
|
const isBash = toolName === 'Bash';
|
|
|
|
return (
|
|
<>
|
|
<pre
|
|
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
|
|
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' : 'text-duck-dark/70'
|
|
}`}
|
|
>
|
|
{displayText}
|
|
</pre>
|
|
{needsTruncation && (
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="text-duck-teal hover:underline text-[11px] mt-1 cursor-pointer"
|
|
>
|
|
{expanded ? 'Show less' : `Show more (${lines.length - maxLines} more lines)`}
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
};
|