Chat and Terminal as plugins
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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 text-red-700' : '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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user