| 1 | import { memo, useRef, useState } from "react"; |
| 2 | import { ChevronRight } from "lucide-react"; |
| 3 | import { useT } from "../lib/i18n"; |
| 4 | import { useGSAPCollapse } from "../lib/useGSAPCollapse"; |
| 5 | import type { Item } from "../lib/useController"; |
| 6 | import { ToolCard } from "./ToolCard"; |
| 7 | |
| 8 | type ToolItem = Extract<Item, { kind: "tool" }>; |
| 9 | |
| 10 | export type ToolGroupKind = "explore" | "modify" | "delegate" | "shell"; |
| 11 | |
| 12 | const SHELL_TOOLS = new Set(["bash", "bash_output", "wait", "waitJob", "kill_shell"]); |
| 13 | const EXPLORE_TOOLS = new Set(["read_file", "ls", "grep", "glob", "web_fetch", "code_index", "read_skill", "connect_tool_source"]); |
| 14 | const MODIFY_TOOLS = new Set(["write_file", "edit_file", "multi_edit", "move_file", "delete_range", "delete_symbol", "notebook_edit"]); |
| 15 | const DELEGATE_TOOLS = new Set(["task", "run_skill", "explore", "research", "review", "security_review"]); |
| 16 | |
| 17 | export function toolGroupKind(item: ToolItem): ToolGroupKind | null { |
| 18 | if (item.parentId || item.name === "todo_write" || item.name === "exit_plan_mode") return null; |
| 19 | if (SHELL_TOOLS.has(item.name)) return "shell"; |
| 20 | if (EXPLORE_TOOLS.has(item.name)) return "explore"; |
| 21 | if (MODIFY_TOOLS.has(item.name)) return "modify"; |
| 22 | if (DELEGATE_TOOLS.has(item.name)) return "delegate"; |
| 23 | return item.readOnly ? "explore" : "modify"; |
| 24 | } |
| 25 | |
| 26 | export function isCreationGroupableTool(item: ToolItem): boolean { |
| 27 | return item.status !== "running" && toolGroupKind(item) !== null; |
| 28 | } |
| 29 | |
| 30 | function count(items: ToolItem[], names: readonly string[]): number { |
| 31 | return items.filter((item) => names.includes(item.name)).length; |
| 32 | } |
| 33 | |
| 34 | function titleFor(kind: ToolGroupKind, t: ReturnType<typeof useT>): string { |
| 35 | switch (kind) { |
| 36 | case "explore": return t("creation.toolGroup.explore"); |
| 37 | case "modify": return t("creation.toolGroup.modify"); |
| 38 | case "delegate": return t("creation.toolGroup.delegate"); |
| 39 | case "shell": return t("creation.toolGroup.shell"); |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | function groupSummary(kind: ToolGroupKind, items: ToolItem[], t: ReturnType<typeof useT>): string { |
| 44 | const parts: string[] = []; |
| 45 | if (kind === "explore") { |
| 46 | const readCount = count(items, ["read_file", "ls", "web_fetch", "read_skill"]); |
| 47 | const searchCount = count(items, ["grep", "glob", "code_index"]); |
| 48 | const otherCount = items.length - readCount - searchCount; |
| 49 | if (readCount > 0) parts.push(t("creation.toolStat.read", { n: readCount })); |
| 50 | if (searchCount > 0) parts.push(t("creation.toolStat.search", { n: searchCount })); |
| 51 | if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount })); |
| 52 | } else if (kind === "modify") { |
| 53 | const writeCount = count(items, ["write_file"]); |
| 54 | const editCount = count(items, ["edit_file", "multi_edit", "notebook_edit"]); |
| 55 | const moveCount = count(items, ["move_file"]); |
| 56 | const deleteCount = count(items, ["delete_range", "delete_symbol"]); |
| 57 | const otherCount = items.length - writeCount - editCount - moveCount - deleteCount; |
| 58 | if (writeCount > 0) parts.push(t("creation.toolStat.write", { n: writeCount })); |
| 59 | if (editCount > 0) parts.push(t("creation.toolStat.edit", { n: editCount })); |
| 60 | if (moveCount > 0) parts.push(t("creation.toolStat.move", { n: moveCount })); |
| 61 | if (deleteCount > 0) parts.push(t("creation.toolStat.delete", { n: deleteCount })); |
| 62 | if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount })); |
| 63 | } else if (kind === "delegate") { |
| 64 | const taskCount = count(items, ["task", "run_skill", "explore", "research", "review", "security_review"]); |
| 65 | const otherCount = items.length - taskCount; |
| 66 | if (taskCount > 0) parts.push(t("creation.toolStat.task", { n: taskCount })); |
| 67 | if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount })); |
| 68 | } else { |
| 69 | const commandCount = count(items, ["bash"]); |
| 70 | const checkCount = count(items, ["bash_output", "wait", "waitJob"]); |
| 71 | const stopCount = count(items, ["kill_shell"]); |
| 72 | const otherCount = items.length - commandCount - checkCount - stopCount; |
| 73 | if (commandCount > 0) parts.push(t("creation.toolStat.command", { n: commandCount })); |
| 74 | if (checkCount > 0) parts.push(t("creation.toolStat.check", { n: checkCount })); |
| 75 | if (stopCount > 0) parts.push(t("creation.toolStat.stop", { n: stopCount })); |
| 76 | if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount })); |
| 77 | } |
| 78 | return parts.join(", "); |
| 79 | } |
| 80 | |
| 81 | function titleCaseName(name: string): string { |
| 82 | return name |
| 83 | .replace(/[-_]+/g, " ") |
| 84 | .split(" ") |
| 85 | .filter(Boolean) |
| 86 | .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) |
| 87 | .join(" "); |
| 88 | } |
| 89 | |
| 90 | function toolDisplayName(name: string): string { |
| 91 | switch (name) { |
| 92 | case "read_file": return "Read"; |
| 93 | case "ls": return "List"; |
| 94 | case "web_fetch": return "Web Fetch"; |
| 95 | case "code_index": return "Code Index"; |
| 96 | case "write_file": return "Write"; |
| 97 | case "edit_file": return "Edit"; |
| 98 | case "multi_edit": return "Multi Edit"; |
| 99 | case "move_file": return "Move"; |
| 100 | case "bash": return "Shell"; |
| 101 | case "bash_output": return "Shell Output"; |
| 102 | case "kill_shell": return "Kill Shell"; |
| 103 | case "wait": |
| 104 | case "waitJob": return "Wait"; |
| 105 | case "use_capability": return "MCP"; |
| 106 | default: return titleCaseName(name); |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | export const ToolGroup = memo(function ToolGroup({ |
| 111 | kind, |
| 112 | items, |
| 113 | subcalls, |
| 114 | tabId, |
| 115 | }: { |
| 116 | kind: ToolGroupKind; |
| 117 | items: ToolItem[]; |
| 118 | subcalls: ReadonlyMap<string, ToolItem[]>; |
| 119 | tabId?: string; |
| 120 | }) { |
| 121 | const t = useT(); |
| 122 | const [open, setOpen] = useState(false); |
| 123 | const bodyRef = useRef<HTMLDivElement>(null); |
| 124 | useGSAPCollapse(bodyRef, open); |
| 125 | |
| 126 | if (items.length === 0) return null; |
| 127 | |
| 128 | return ( |
| 129 | <div className={`tool-group tool-group--${kind}${open ? " tool-group--open" : ""}`} data-kind={kind} data-entrance={items[0]?.id}> |
| 130 | <button type="button" className="tool-group__head" onClick={() => setOpen((value) => !value)} aria-expanded={open}> |
| 131 | <span className="tool-group__title">{titleFor(kind, t)}</span> |
| 132 | <span className="tool-group__summary">{groupSummary(kind, items, t)}</span> |
| 133 | <ChevronRight className={`tool-group__chevron${open ? " tool-group__chevron--open" : ""}`} size={12} /> |
| 134 | </button> |
| 135 | <div ref={bodyRef} className="tool-group__body"> |
| 136 | {items.map((item) => ( |
| 137 | <ToolCard key={item.id} item={item} subcalls={subcalls.get(item.id)} tabId={tabId} displayName={toolDisplayName(item.name)} /> |
| 138 | ))} |
| 139 | </div> |
| 140 | </div> |
| 141 | ); |
| 142 | }); |
| 143 |