claw-web-v2 / client /src /components /ToolCallCard.tsx
Claw Web
Claw Web v1.0 — AI Agent Web Interface with MiMo-V2-Flash
7540aea
import { useState, useMemo } from "react";
import { cn } from "@/lib/utils";
import {
Terminal,
FileText,
FileEdit,
Search,
FolderSearch,
Globe,
Link,
ListTodo,
Bot,
ChevronDown,
ChevronRight,
Loader2,
Check,
X,
Clock,
Copy,
GitBranch,
MessageCircle,
Wrench,
Settings,
Send,
Server,
KeyRound,
Timer,
Zap,
Code2,
BookOpen,
MonitorPlay,
Moon,
Play,
Braces,
ListChecks,
CalendarClock,
Trash2,
List,
Eye,
StopCircle,
RefreshCw,
Languages,
Map,
MapPin,
Users,
UserMinus,
Webhook,
FlaskConical,
GitFork,
} from "lucide-react";
import type { ToolCallInfo } from "@/hooks/useChat";
/**
* Tool icon map — EXACT parity with original claw-code tool names.
* Supports both original names and legacy aliases.
*/
const TOOL_ICONS: Record<string, React.ElementType> = {
// ── Core 19 tools (original names) ──
bash: Terminal,
PowerShell: MonitorPlay,
read_file: FileText,
write_file: FileEdit,
edit_file: FileEdit,
glob_search: FolderSearch,
grep_search: Search,
NotebookEdit: BookOpen,
WebSearch: Globe,
WebFetch: Link,
TodoWrite: ListTodo,
Agent: Bot,
SendUserMessage: MessageCircle,
Brief: MessageCircle,
TestingPermission: FlaskConical,
ToolSearch: Wrench,
Config: Settings,
Skill: Zap,
Sleep: Moon,
REPL: Play,
StructuredOutput: Braces,
// ── Extended tools (full parity) ──
TaskCreate: ListChecks,
TaskGet: Eye,
TaskList: List,
TaskOutput: Terminal,
TaskStop: StopCircle,
TaskUpdate: RefreshCw,
CronCreate: CalendarClock,
CronDelete: Trash2,
CronList: CalendarClock,
LSP: Languages,
EnterPlanMode: Map,
ExitPlanMode: MapPin,
EnterWorktree: GitFork,
ExitWorktree: GitBranch,
TeamCreate: Users,
TeamDelete: UserMinus,
RemoteTrigger: Webhook,
SyntheticOutput: FlaskConical,
// ── MCP ──
mcp_tool: Server,
list_mcp_resources: Server,
read_mcp_resource: Server,
mcp_auth: KeyRound,
// ── Legacy aliases ──
powershell: MonitorPlay,
grep: Search,
glob: FolderSearch,
web_search: Globe,
web_fetch: Link,
todo_read: ListTodo,
todo_write: ListTodo,
sub_agent: Bot,
send_message: Send,
ask_user: MessageCircle,
tool_search: Wrench,
config_read: Settings,
config_write: Settings,
notebook_edit: BookOpen,
skill: Zap,
};
/**
* Tool color map — EXACT parity with original claw-code tool names.
*/
const TOOL_COLORS: Record<string, string> = {
// ── Core 19 tools ──
bash: "text-green-400",
PowerShell: "text-blue-500",
read_file: "text-blue-400",
write_file: "text-yellow-400",
edit_file: "text-orange-400",
glob_search: "text-cyan-400",
grep_search: "text-purple-400",
NotebookEdit: "text-emerald-400",
WebSearch: "text-pink-400",
WebFetch: "text-indigo-400",
TodoWrite: "text-teal-400",
Agent: "text-amber-400",
SendUserMessage: "text-sky-400",
Brief: "text-sky-400",
TestingPermission: "text-gray-400",
ToolSearch: "text-violet-400",
Config: "text-slate-400",
Skill: "text-yellow-300",
Sleep: "text-indigo-300",
REPL: "text-lime-400",
StructuredOutput: "text-cyan-300",
// ── Extended tools (full parity) ──
TaskCreate: "text-emerald-500",
TaskGet: "text-emerald-400",
TaskList: "text-emerald-300",
TaskOutput: "text-emerald-400",
TaskStop: "text-red-400",
TaskUpdate: "text-emerald-300",
CronCreate: "text-amber-500",
CronDelete: "text-red-400",
CronList: "text-amber-400",
LSP: "text-blue-500",
EnterPlanMode: "text-violet-500",
ExitPlanMode: "text-violet-400",
EnterWorktree: "text-orange-500",
ExitWorktree: "text-orange-400",
TeamCreate: "text-cyan-500",
TeamDelete: "text-red-400",
RemoteTrigger: "text-pink-500",
SyntheticOutput: "text-lime-500",
// ── MCP ──
mcp_tool: "text-rose-400",
list_mcp_resources: "text-rose-300",
read_mcp_resource: "text-rose-300",
mcp_auth: "text-rose-500",
// ── Legacy aliases ──
powershell: "text-blue-500",
grep: "text-purple-400",
glob: "text-cyan-400",
web_search: "text-pink-400",
web_fetch: "text-indigo-400",
todo_read: "text-teal-400",
todo_write: "text-teal-400",
sub_agent: "text-amber-400",
send_message: "text-sky-400",
ask_user: "text-sky-400",
tool_search: "text-violet-400",
config_read: "text-slate-400",
config_write: "text-slate-400",
notebook_edit: "text-emerald-400",
skill: "text-yellow-300",
};
/** Simple diff line renderer for edit_file results */
function DiffView({ content }: { content: string }) {
const lines = content.split("\n");
return (
<div className="text-xs font-mono">
{lines.map((line, i) => {
let cls = "text-foreground/70";
if (line.startsWith("+ ") || line.startsWith("+\t") || line === "+") {
cls = "text-green-400 bg-green-400/10";
} else if (
line.startsWith("- ") ||
line.startsWith("-\t") ||
line === "-"
) {
cls = "text-red-400 bg-red-400/10";
} else if (line.startsWith("@@ ")) {
cls = "text-cyan-400/60";
} else if (line.startsWith("--- ") || line.startsWith("+++ ")) {
cls = "text-muted-foreground";
}
return (
<div key={i} className={cn("px-2 py-px whitespace-pre-wrap", cls)}>
{line}
</div>
);
})}
</div>
);
}
/** Detect if output looks like a diff */
function isDiffLike(content: string): boolean {
if (!content) return false;
const lines = content.split("\n").slice(0, 20);
let diffIndicators = 0;
for (const l of lines) {
if (
l.startsWith("+ ") ||
l.startsWith("- ") ||
l.startsWith("@@ ") ||
l.startsWith("--- ") ||
l.startsWith("+++ ")
)
diffIndicators++;
}
return diffIndicators >= 2;
}
export function ToolCallCard({ tool }: { tool: ToolCallInfo }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const Icon = TOOL_ICONS[tool.name] || Terminal;
const colorClass = TOOL_COLORS[tool.name] || "text-muted-foreground";
let parsedArgs: Record<string, unknown> = {};
try {
parsedArgs = JSON.parse(tool.arguments || "{}");
} catch {
parsedArgs = { raw: tool.arguments };
}
const showDiff = useMemo(
() =>
(tool.name === "edit_file" || tool.name === "bash") &&
tool.result &&
isDiffLike(tool.result),
[tool.name, tool.result]
);
// Format the tool call summary — supports both original and legacy names
const getSummary = () => {
switch (tool.name) {
case "bash":
case "PowerShell":
case "powershell":
return String(parsedArgs.command || "").substring(0, 80);
case "read_file":
case "write_file":
case "edit_file":
case "NotebookEdit":
case "notebook_edit":
return String(parsedArgs.path || parsedArgs.notebook_path || "");
case "grep_search":
case "grep":
return `"${parsedArgs.pattern}" in ${parsedArgs.path || "."}`;
case "glob_search":
case "glob":
return String(parsedArgs.pattern || "");
case "WebSearch":
case "web_search":
return String(parsedArgs.query || "");
case "WebFetch":
case "web_fetch":
return String(parsedArgs.url || "").substring(0, 60);
case "Agent":
case "sub_agent":
return String(parsedArgs.description || parsedArgs.task || "").substring(0, 60);
case "SendUserMessage":
case "Brief":
case "ask_user":
return String(parsedArgs.message || parsedArgs.question || "").substring(0, 60);
case "TestingPermission":
return `check: ${parsedArgs.tool || "unknown"}`;
case "ToolSearch":
case "tool_search":
return String(parsedArgs.query || "");
case "Config":
return parsedArgs.value !== undefined
? `${parsedArgs.setting} = ${parsedArgs.value}`
: String(parsedArgs.setting || "all");
case "config_read":
return String(parsedArgs.key || "all");
case "config_write":
return `${parsedArgs.key} = ${parsedArgs.value}`;
case "Skill":
case "skill":
return String(parsedArgs.skill || parsedArgs.name || "");
case "Sleep":
return `${parsedArgs.duration_ms || 0}ms`;
case "REPL":
return `${parsedArgs.language || "python"}: ${String(parsedArgs.code || "").substring(0, 50)}`;
case "TodoWrite":
return "Update task list";
// Extended tools
case "TaskCreate":
return String(parsedArgs.description || "").substring(0, 60);
case "TaskGet":
case "TaskOutput":
case "TaskStop":
case "TaskUpdate":
return String(parsedArgs.id || "");
case "TaskList":
return "List background tasks";
case "CronCreate":
return `${parsedArgs.schedule}${String(parsedArgs.command || "").substring(0, 40)}`;
case "CronDelete":
return String(parsedArgs.id || "");
case "CronList":
return "List cron jobs";
case "LSP":
return `${parsedArgs.action || ""} ${parsedArgs.path || ""}`;
case "EnterPlanMode":
return "Enter plan mode";
case "ExitPlanMode":
return "Exit plan mode";
case "EnterWorktree":
return String(parsedArgs.branch || "");
case "ExitWorktree":
return "Exit worktree";
case "TeamCreate":
return String(parsedArgs.name || "");
case "TeamDelete":
return String(parsedArgs.id || "");
case "RemoteTrigger":
return `${parsedArgs.method || "POST"} ${String(parsedArgs.url || "").substring(0, 50)}`;
case "SyntheticOutput":
return String(parsedArgs.format || "json");
default:
return tool.name;
}
};
const copyResult = () => {
if (tool.result) {
navigator.clipboard.writeText(tool.result);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="tool-card rounded-lg border border-border bg-secondary/30 my-2 overflow-hidden">
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-accent/30 transition-colors"
>
<Icon className={cn("size-4 shrink-0", colorClass)} />
<span className="font-mono text-xs font-medium text-foreground/80">
{tool.name}
</span>
<span className="text-xs text-muted-foreground truncate flex-1 font-mono">
{getSummary()}
</span>
{/* Status indicator */}
<div className="flex items-center gap-1.5 shrink-0">
{tool.isExecuting && (
<Loader2 className="size-3.5 animate-spin text-primary" />
)}
{!tool.isExecuting && tool.result !== undefined && !tool.isError && (
<Check className="size-3.5 text-green-400" />
)}
{!tool.isExecuting && tool.isError && (
<X className="size-3.5 text-destructive" />
)}
{tool.durationMs !== undefined && tool.durationMs > 0 && (
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
<Clock className="size-2.5" />
{tool.durationMs < 1000
? `${tool.durationMs}ms`
: `${(tool.durationMs / 1000).toFixed(1)}s`}
</span>
)}
{showDiff && (
<GitBranch className="size-3 text-orange-400" />
)}
{expanded ? (
<ChevronDown className="size-3.5 text-muted-foreground" />
) : (
<ChevronRight className="size-3.5 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-border">
{/* Arguments */}
<div className="px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">
Input
</div>
<pre className="text-xs font-mono text-foreground/80 whitespace-pre-wrap break-all bg-background/50 rounded p-2 max-h-48 overflow-auto">
{tool.name === "bash" || tool.name === "PowerShell" || tool.name === "powershell"
? String(parsedArgs.command || "")
: JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
{/* Result */}
{tool.result !== undefined && (
<div className="px-3 py-2 border-t border-border">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
Output
{tool.isError && (
<span className="text-destructive ml-1">(error)</span>
)}
{showDiff && (
<span className="text-orange-400 ml-1">(diff)</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
copyResult();
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<Check className="size-3" />
) : (
<Copy className="size-3" />
)}
</button>
</div>
{showDiff ? (
<div className="bg-background/50 rounded max-h-64 overflow-auto">
<DiffView content={tool.result} />
</div>
) : (
<pre
className={cn(
"text-xs font-mono whitespace-pre-wrap break-all rounded p-2 max-h-64 overflow-auto",
tool.isError
? "bg-destructive/10 text-destructive"
: "bg-background/50 text-foreground/80"
)}
>
{tool.result}
</pre>
)}
</div>
)}
</div>
)}
</div>
);
}