"use client"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; import { ArrowRight01Icon, CheckListIcon, Edit02Icon, EyeIcon, File01Icon, FileEditIcon, FilePlusIcon, Folder01Icon, FolderAddIcon, FolderOpenIcon, GlobalSearchIcon, RobotIcon, SparklesIcon, TerminalIcon, ToolsIcon, } from "@hugeicons/core-free-icons"; import { useChatStore } from "@/modules/ai/store/chatStore"; import { HugeiconsIcon } from "@hugeicons/react"; import type { DynamicToolUIPart, ToolUIPart } from "ai"; import type { ComponentProps, ReactNode } from "react"; import { isValidElement, memo, useState } from "react"; import type { BundledLanguage } from "shiki"; import { CodeBlockContent } from "./code-block"; export type ToolPart = ToolUIPart | DynamicToolUIPart; const TOOL_META: Record = { read_file: { label: "Read", icon: File01Icon }, list_directory: { label: "List", icon: FolderOpenIcon }, write_file: { label: "Write", icon: FilePlusIcon }, create_directory: { label: "Create dir", icon: FolderAddIcon }, edit: { label: "Edit", icon: FileEditIcon }, multi_edit: { label: "Edit", icon: Edit02Icon }, bash_run: { label: "Run", icon: TerminalIcon }, bash_background: { label: "Spawn", icon: TerminalIcon }, bash_logs: { label: "Logs", icon: TerminalIcon }, bash_list: { label: "Jobs", icon: TerminalIcon }, bash_kill: { label: "Kill", icon: TerminalIcon }, grep: { label: "Search", icon: GlobalSearchIcon }, glob: { label: "Glob", icon: Folder01Icon }, suggest_command: { label: "Suggest", icon: SparklesIcon }, open_preview: { label: "Preview", icon: EyeIcon }, run_subagent: { label: "Subagent", icon: RobotIcon }, todo_write: { label: "Todos", icon: CheckListIcon }, }; const STATUS_DOT: Record = { "approval-requested": "bg-amber-500", "approval-responded": "bg-sky-500", "input-streaming": "bg-muted-foreground/40", "input-available": "bg-amber-500", "output-available": "bg-transparent border border-muted-foreground/40", "output-denied": "bg-orange-500", "output-error": "bg-destructive", }; const STATUS_LABEL: Record = { "approval-requested": "awaiting approval", "approval-responded": "responded", "input-streaming": "preparing", "input-available": "running", "output-available": "done", "output-denied": "denied", "output-error": "error", }; function deriveSummary(toolName: string, input: unknown): string | null { if (!input || typeof input !== "object") return null; const i = input as Record; const str = (k: string) => typeof i[k] === "string" ? (i[k] as string) : null; switch (toolName) { case "read_file": case "write_file": case "edit": case "multi_edit": case "create_directory": case "list_directory": return str("path"); case "bash_run": case "bash_background": return str("command"); case "bash_logs": case "bash_kill": return str("id"); case "grep": return str("pattern") ?? str("query"); case "glob": return str("pattern"); case "suggest_command": return str("intent") ?? str("description"); case "open_preview": return str("path") ?? str("url"); case "run_subagent": return str("agent") ?? str("task"); case "todo_write": { const items = Array.isArray(i.todos) ? i.todos : null; return items ? `${items.length} item${items.length === 1 ? "" : "s"}` : null; } default: return null; } } export type ToolProps = ComponentProps & { toolName: string; state: ToolPart["state"]; input?: unknown; output?: unknown; errorText?: string; }; // Tools whose `input` carries large/streaming content (file bodies, sub- // agent prompts, todo lists). The AI diff tab is the canonical place to // view file changes; for the rest, the header summary + final output is // enough. Re-rendering streamed input on every token both stalls the UI // and duplicates information. const HEAVY_CONTENT_TOOLS = new Set([ "write_file", "edit", "multi_edit", "run_subagent", "todo_write", ]); const ToolImpl = ({ className, toolName, state, input, output, errorText, defaultOpen, ...props }: ToolProps) => { const meta = TOOL_META[toolName]; const Icon = meta?.icon ?? ToolsIcon; const label = meta?.label ?? toolName; const summary = deriveSummary(toolName, input); const isError = state === "output-error"; const open = defaultOpen ?? isError; const isHeavy = HEAVY_CONTENT_TOOLS.has(toolName); // For heavy tools, only show details on error — never the streamed input // body, which is huge and re-renders per token. const showInputBody = !isHeavy && Boolean(input); const showOutputBody = !isHeavy && output !== undefined; const hasDetails = showInputBody || showOutputBody || Boolean(errorText); return ( {label} {summary ? ( {summary} ) : ( )} {isError && ( failed )} {hasDetails && (
{showInputBody ? ( ) : null} {showOutputBody || errorText ? ( ) : null}
)}
); }; // For heavy tools, the only thing that should trigger a re-render is a // state transition or the path summary changing — NOT every input-content // token. We compare the cheap derived summary instead of the input ref. export const Tool = memo(ToolImpl, (a, b) => { if (a.toolName !== b.toolName || a.state !== b.state) return false; if (a.errorText !== b.errorText) return false; if (a.output !== b.output) return false; if (a.className !== b.className) return false; if (HEAVY_CONTENT_TOOLS.has(a.toolName)) { return deriveSummary(a.toolName, a.input) === deriveSummary(b.toolName, b.input); } return a.input === b.input; }); function ToolInput({ toolName, input }: { toolName: string; input: unknown }) { if (input == null) return null; const preview = renderInputPreview(toolName, input); if (preview) { return (
Input
{preview}
); } return (
Input
); } function renderInputPreview( toolName: string, input: unknown, ): ReactNode | null { if (!input || typeof input !== "object") return null; const i = input as Record; const str = (k: string) => typeof i[k] === "string" ? (i[k] as string) : null; if (toolName === "bash_run" || toolName === "bash_background") { const cmd = str("command"); const cwd = str("cwd"); if (!cmd) return null; return (
{cwd ? (
{cwd}
) : null}
          {cmd}
        
); } if ( toolName === "read_file" || toolName === "list_directory" || toolName === "create_directory" || toolName === "open_preview" ) { const path = str("path") ?? str("url"); if (!path) return null; return (
{path}
); } if (toolName === "grep") { const pat = str("pattern") ?? str("query"); const path = str("path") ?? str("root"); if (!pat) return null; return (
{pat}
{path ?
{path}
: null}
); } return null; } function ToolOutput({ toolName, output, errorText, }: { toolName: string; output: unknown; errorText?: string; }) { if (errorText) { return (
Error
{errorText}
); } if (output === undefined || output === null) return null; const custom = renderToolOutput(toolName, output); if (custom) return custom; let body: ReactNode; if (typeof output === "string") { body = ; } else if (typeof output === "object" && !isValidElement(output)) { body = ( ); } else { body =
{output as ReactNode}
; } return (
Output
{body}
); } function renderToolOutput(toolName: string, output: unknown): ReactNode | null { if (!output || typeof output !== "object") return null; const o = output as Record; if (toolName === "read_file") { const path = typeof o.path === "string" ? o.path : ""; const size = typeof o.size === "number" ? o.size : null; const content = typeof o.content === "string" ? o.content : ""; const lines = content ? content.split("\n").length : null; return (
read {path ? · {path} : null} {lines != null ? ( ({lines} line{lines === 1 ? "" : "s"} {size != null ? `, ${formatBytes(size)}` : ""}) ) : null}
); } if (toolName === "list_directory") { const entries = Array.isArray(o.entries) ? (o.entries as Array<{ name: string; kind: string }>) : []; if (entries.length === 0) { return (
empty
); } const dirs = entries.filter( (e) => e.kind === "directory" || e.kind === "dir", ); const files = entries.filter( (e) => !(e.kind === "directory" || e.kind === "dir"), ); return (
{dirs.map((e) => (
{e.name}/
))} {files.map((e) => (
{e.name}
))}
); } if (toolName === "bash_run") { return ; } if (toolName === "suggest_command") { const cmd = typeof o.command === "string" ? o.command : null; const explanation = typeof o.explanation === "string" ? o.explanation : null; if (!cmd) return null; return ; } if (toolName === "grep") { const hits = Array.isArray(o.hits) ? (o.hits as Array<{ rel?: string; path?: string; line: number; text: string; }>) : []; const pattern = typeof o.pattern === "string" ? o.pattern : null; const truncated = Boolean(o.truncated); const filesScanned = typeof o.files_scanned === "number" ? o.files_scanned : null; if (hits.length === 0) { return (
no matches {filesScanned != null ? ` · ${filesScanned} files scanned` : ""}
); } return (
{hits.slice(0, 200).map((h, idx) => (
{h.rel ?? h.path}:{h.line} {pattern ? highlightMatch(h.text, pattern) : h.text}
))}
{hits.length} hit{hits.length === 1 ? "" : "s"} {filesScanned != null ? ` · ${filesScanned} files` : ""} {truncated ? ( truncated ) : null}
); } if (toolName === "glob") { const matches = Array.isArray(o.matches) ? (o.matches as string[]) : Array.isArray(o.paths) ? (o.paths as string[]) : []; if (matches.length === 0) { return (
no matches
); } return (
{matches.slice(0, 300).map((p) => (
{p}
))}
); } if (toolName === "edit" || toolName === "multi_edit") { const ok = o.ok === true || typeof o.replacements === "number"; if (ok) { const reps = typeof o.replacements === "number" ? o.replacements : null; const path = typeof o.path === "string" ? o.path : ""; return (
{reps != null ? ( {reps} replacement{reps === 1 ? "" : "s"} ) : null} {path ? ( · {path} ) : null}
); } } if (toolName === "write_file" || toolName === "create_directory") { const path = typeof o.path === "string" ? o.path : ""; const bytes = typeof o.bytesWritten === "number" ? o.bytesWritten : null; return (
{toolName === "create_directory" ? "created" : "wrote"} {path ? · {path} : null} {bytes != null ? ( ({formatBytes(bytes)}) ) : null}
); } if (toolName === "bash_background") { const handle = typeof o.handle === "string" ? o.handle : null; const cmd = typeof o.command === "string" ? o.command : ""; return (
{handle ? {handle} : null} running
{cmd ? (
{cmd}
) : null}
); } return null; } function BashRunOutput({ data }: { data: Record }) { const stdout = typeof data.stdout === "string" ? data.stdout : ""; const stderr = typeof data.stderr === "string" ? data.stderr : ""; const exit = typeof data.exit_code === "number" ? data.exit_code : null; const cwdAfter = typeof data.cwd_after === "string" ? data.cwd_after : null; const truncated = Boolean(data.truncated); const timedOut = Boolean(data.timed_out); const hasStdout = stdout.length > 0; const hasStderr = stderr.length > 0; const initial = hasStdout ? "stdout" : hasStderr ? "stderr" : "stdout"; const [tab, setTab] = useState<"stdout" | "stderr">(initial); const tabs: Array<{ key: "stdout" | "stderr"; label: string; count: number; }> = [ { key: "stdout", label: "stdout", count: stdout.length }, { key: "stderr", label: "stderr", count: stderr.length }, ]; return (
{tabs.map((t) => ( ))} {exit != null ? ( exit {exit} ) : null} {timedOut ? ( timed out ) : null} {truncated ? ( truncated ) : null}
        {tab === "stdout" ? stdout || " " : stderr || " "}
      
{cwdAfter ? (
cwd → {cwdAfter}
) : null}
); } function highlightMatch(text: string, pattern: string): ReactNode { if (!pattern) return text; let re: RegExp; try { re = new RegExp( `(${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi", ); } catch { return text; } const parts = text.split(re); return parts.map((p, i) => i % 2 === 1 ? ( {p} ) : ( {p} ), ); } function formatBytes(n: number): string { if (n < 1024) return `${n}B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; return `${(n / (1024 * 1024)).toFixed(1)}MB`; } function CodeBlockMini({ code, language }: { code: string; language: string }) { return (
); } function SuggestCommandCard({ command, explanation, }: { command: string; explanation: string | null; }) { const [inserted, setInserted] = useState(false); const onInsert = () => { const ok = useChatStore .getState() .live.injectIntoActivePty(command); if (ok) setInserted(true); }; return (
{explanation ? (
{explanation}
) : null}
          {command}
        
); } // Compatibility re-exports — the previous API exposed these subcomponents, // but the new compact takes everything via props. Kept as no-ops // to avoid breaking accidental imports. export const ToolHeader = () => null; export const ToolContent = ({ children }: { children?: ReactNode }) => ( <>{children} ); export { ToolInput, ToolOutput };