diff --git a/components/VercelChat/MessageParts.tsx b/components/VercelChat/MessageParts.tsx index 1e522c846..e4318fa6d 100644 --- a/components/VercelChat/MessageParts.tsx +++ b/components/VercelChat/MessageParts.tsx @@ -2,6 +2,7 @@ import { ToolUIPart, UIMessage, isToolOrDynamicToolUIPart, + getToolOrDynamicToolName, UIMessagePart, UIDataTypes, UITools, @@ -11,6 +12,8 @@ import { cn } from "@/lib/utils"; import ViewingMessage from "./ViewingMessage"; import EditingMessage from "./EditingMessage"; import { getToolCallComponent, getToolResultComponent } from "./ToolComponents"; +import { ToolCall } from "./tools/agent/ToolCall"; +import { isAgentToolName } from "./tools/agent/renderTool"; import MessageFileViewer from "./message-file-viewer"; import { EnhancedReasoning } from "@/components/reasoning/EnhancedReasoning"; import { Actions, Action } from "@/components/actions"; @@ -71,7 +74,7 @@ export function MessageParts({ message, mode, setMode }: MessagePartsProps) { { "justify-start": message.role === "assistant", "justify-end": message.role === "user", - } + }, )} > {message.role === "user" && ( @@ -106,14 +109,26 @@ export function MessageParts({ message, mode, setMode }: MessagePartsProps) { } if (isToolOrDynamicToolUIPart(part)) { - const { state } = part as ToolUIPart; - if (state !== "output-available") { - return getToolCallComponent(part as ToolUIPart); + const toolPart = part as ToolUIPart; + if (isAgentToolName(getToolOrDynamicToolName(toolPart))) { + return ( + + ); + } + if (toolPart.state !== "output-available") { + return getToolCallComponent(toolPart); } else { - return getToolResultComponent(part as ToolUIPart); + return getToolResultComponent(toolPart); } } - } + }, )} ); diff --git a/components/VercelChat/tools/agent/ApprovalButtons.tsx b/components/VercelChat/tools/agent/ApprovalButtons.tsx new file mode 100644 index 000000000..908bfc523 --- /dev/null +++ b/components/VercelChat/tools/agent/ApprovalButtons.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Button } from "@/components/ui/button"; + +export type ApprovalButtonsProps = { + approvalId: string; + onApprove?: (id: string) => void; + onDeny?: (id: string, reason?: string) => void; +}; + +export function ApprovalButtons({ + approvalId, + onApprove, + onDeny, +}: ApprovalButtonsProps) { + return ( +
+ + +
+ ); +} diff --git a/components/VercelChat/tools/agent/AskUserQuestionRenderer.tsx b/components/VercelChat/tools/agent/AskUserQuestionRenderer.tsx new file mode 100644 index 000000000..8c4075433 --- /dev/null +++ b/components/VercelChat/tools/agent/AskUserQuestionRenderer.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { MessageCircleQuestion } from "lucide-react"; +import { ToolLayout } from "./ToolLayout"; +import type { ToolRendererProps } from "./renderTool"; + +type Question = { question?: string }; +type AskInput = { questions?: Question[] }; +type AskOutput = { + declined?: boolean; + answers?: Record | null; +}; + +export function AskUserQuestionRenderer({ part, state }: ToolRendererProps) { + const input = part.input as AskInput | undefined; + const output = + part.state === "output-available" + ? (part.output as AskOutput | undefined) + : undefined; + const questions = input?.questions ?? []; + + const isWaitingForInput = part.state === "input-available"; + const isStreaming = part.state === "input-streaming"; + const hasOutput = part.state === "output-available"; + const isDeclined = hasOutput && output?.declined === true; + const hasAnswers = hasOutput && output?.answers != null; + + const summary = isStreaming + ? "Generating questions" + : isWaitingForInput + ? "Waiting for user input" + : isDeclined + ? "User declined to answer" + : hasAnswers + ? "Answered" + : state.denied + ? "Cancelled" + : "Questions"; + + const questionCount = questions.length; + const meta = + questionCount > 0 + ? `${questionCount} question${questionCount === 1 ? "" : "s"}` + : undefined; + + const answers = output?.answers; + const expandedContent = + hasAnswers && answers ? ( +
+ {questions.map((q) => { + if (!q?.question) return null; + const answer = answers[q.question]; + const answerStr = Array.isArray(answer) + ? answer.join(", ") + : (answer ?? "(not answered)"); + return ( +
+

{q.question}

+

+ {answerStr} +

+
+ ); + })} +
+ ) : undefined; + + const displayState = isWaitingForInput + ? { ...state, interrupted: false } + : state; + + return ( + } + nameClassName={state.denied || isDeclined ? "text-red-500" : undefined} + expandedContent={expandedContent} + defaultExpanded={false} + /> + ); +} diff --git a/components/VercelChat/tools/agent/BashRenderer.tsx b/components/VercelChat/tools/agent/BashRenderer.tsx new file mode 100644 index 000000000..0e94292b1 --- /dev/null +++ b/components/VercelChat/tools/agent/BashRenderer.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Terminal } from "lucide-react"; +import { ToolLayout } from "./ToolLayout"; +import type { ToolRendererProps } from "./renderTool"; + +type BashInput = { command?: string; cwd?: string; detached?: boolean }; +type BashOutput = { + success?: boolean; + exitCode?: number | null; + stdout?: string; + stderr?: string; +}; + +export function BashRenderer({ + part, + state, + onApprove, + onDeny, +}: ToolRendererProps) { + const input = part.input as BashInput | undefined; + const command = String(input?.command ?? ""); + const isDetached = input?.detached === true; + + const output = + part.state === "output-available" + ? (part.output as BashOutput | undefined) + : undefined; + const exitCode = output?.exitCode; + const stdout = output?.stdout; + const stderr = output?.stderr; + const hasOutput = Boolean(stdout || stderr); + const toolFailed = output?.success === false; + const isError = + toolFailed || (typeof exitCode === "number" && exitCode !== 0); + + const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim(); + const hasExpandableContent = part.state === "output-available" || isDetached; + + // When bash errors, route through ToolLayout's standard error UI. + const mergedState = + isError && !state.error + ? { ...state, error: `Exit code ${exitCode ?? "unknown"}` } + : state; + + const meta = isDetached ? ( + + detached + + ) : undefined; + + const errorMetaContent = + isError && exitCode !== undefined && exitCode !== null + ? `exit ${exitCode}` + : undefined; + + const expandedContent = hasExpandableContent ? ( + isError ? ( + hasOutput ? ( +
+          {combinedOutput}
+        
+ ) : undefined + ) : ( +
+        {hasOutput ? combinedOutput : "(No output)"}
+      
+ ) + ) : undefined; + + return ( + } + expandedContent={expandedContent} + onApprove={onApprove} + onDeny={onDeny} + /> + ); +} diff --git a/components/VercelChat/tools/agent/EditRenderer.tsx b/components/VercelChat/tools/agent/EditRenderer.tsx new file mode 100644 index 000000000..48ad658d0 --- /dev/null +++ b/components/VercelChat/tools/agent/EditRenderer.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { Pencil } from "lucide-react"; +import { useMemo } from "react"; +import { ToolLayout } from "./ToolLayout"; +import { FileNamePill } from "./FileNamePill"; +import type { ToolRendererProps } from "./renderTool"; + +type EditInput = { filePath?: string; oldString?: string; newString?: string }; +type EditOutput = { success?: boolean; error?: string }; + +export function EditRenderer({ + part, + state, + onApprove, + onDeny, +}: ToolRendererProps) { + const input = part.input as EditInput | undefined; + const filePath = input?.filePath ?? "..."; + const oldString = input?.oldString ?? ""; + const newString = input?.newString ?? ""; + + const { additions, removals } = useMemo(() => { + const oldLines = oldString.split("\n"); + const newLines = newString.split("\n"); + + const oldCounts = new Map(); + for (const line of oldLines) { + oldCounts.set(line, (oldCounts.get(line) ?? 0) + 1); + } + const newCounts = new Map(); + for (const line of newLines) { + newCounts.set(line, (newCounts.get(line) ?? 0) + 1); + } + + let add = 0; + for (const [line, count] of newCounts) { + add += Math.max(0, count - (oldCounts.get(line) ?? 0)); + } + let remove = 0; + for (const [line, count] of oldCounts) { + remove += Math.max(0, count - (newCounts.get(line) ?? 0)); + } + return { additions: add, removals: remove }; + }, [oldString, newString]); + + const output = + part.state === "output-available" + ? (part.output as EditOutput | undefined) + : undefined; + const outputError = + output?.success === false ? (output?.error ?? "Edit failed") : undefined; + + const mergedState = outputError + ? { ...state, error: state.error ?? outputError } + : state; + + const showDiff = + mergedState.approvalRequested || + (!mergedState.running && !mergedState.error && !mergedState.denied); + + const expandedContent = + showDiff && !mergedState.denied && (oldString || newString) ? ( +
+ {oldString + .split("\n") + .filter((_, i, arr) => !(arr.length === 1 && arr[0] === "")) + .map((line, i) => ( +
+ - + {line} +
+ ))} + {newString + .split("\n") + .filter((_, i, arr) => !(arr.length === 1 && arr[0] === "")) + .map((line, i) => ( +
+ + + {line} +
+ ))} +
+ ) : undefined; + + const meta = + showDiff && !mergedState.denied ? ( + + +{additions} + -{removals} + + ) : undefined; + + return ( + } + summary={ + filePath === "..." ? ( + filePath + ) : ( + + ) + } + meta={meta} + errorMeta={mergedState.error ? "failed" : undefined} + state={mergedState} + expandedContent={expandedContent} + onApprove={onApprove} + onDeny={onDeny} + /> + ); +} diff --git a/components/VercelChat/tools/agent/FetchRenderer.tsx b/components/VercelChat/tools/agent/FetchRenderer.tsx new file mode 100644 index 000000000..0d0f9161c --- /dev/null +++ b/components/VercelChat/tools/agent/FetchRenderer.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Globe } from "lucide-react"; +import { ToolLayout } from "./ToolLayout"; +import type { ToolRendererProps } from "./renderTool"; + +type FetchInput = { url?: string; method?: string }; +type FetchOutput = { + success?: boolean; + status?: number | null; + error?: string; +}; + +export function FetchRenderer({ + part, + state, + onApprove, + onDeny, +}: ToolRendererProps) { + const input = part.input as FetchInput | undefined; + const url = input?.url ?? "..."; + const method = input?.method ?? "GET"; + + const output = + part.state === "output-available" + ? (part.output as FetchOutput | undefined) + : undefined; + const status = + output?.success === true && output.status != null + ? output.status + : undefined; + const outputError = + output?.success === false ? (output.error ?? "Fetch failed") : undefined; + + const mergedState = outputError + ? { ...state, error: state.error ?? outputError } + : state; + + const displayUrl = url.length > 60 ? `${url.slice(0, 57)}...` : url; + const summary = method === "GET" ? displayUrl : `${method} ${displayUrl}`; + + return ( + } + summary={summary} + summaryClassName="font-mono" + meta={status !== undefined ? `${status}` : undefined} + state={mergedState} + onApprove={onApprove} + onDeny={onDeny} + /> + ); +} diff --git a/components/VercelChat/tools/agent/FileNamePill.tsx b/components/VercelChat/tools/agent/FileNamePill.tsx new file mode 100644 index 000000000..26f7edc62 --- /dev/null +++ b/components/VercelChat/tools/agent/FileNamePill.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { FileText } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +function getFileName(filePath: string): string { + const parts = filePath.split("/"); + return parts[parts.length - 1] || filePath; +} + +export function FileNamePill({ + filePath, + fullPath, + error = false, +}: { + filePath: string; + fullPath?: string; + error?: boolean; +}) { + const fileName = getFileName(filePath); + const tooltipPath = fullPath ?? filePath; + const showTooltip = tooltipPath !== fileName; + + const pill = ( + + + {fileName} + + ); + + if (!showTooltip) return pill; + + return ( + + + {pill} + + {tooltipPath} + + + + ); +} diff --git a/components/VercelChat/tools/agent/GlobRenderer.tsx b/components/VercelChat/tools/agent/GlobRenderer.tsx new file mode 100644 index 000000000..e557fcbe2 --- /dev/null +++ b/components/VercelChat/tools/agent/GlobRenderer.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { FolderSearch } from "lucide-react"; +import { ToolLayout } from "./ToolLayout"; +import type { ToolRendererProps } from "./renderTool"; + +type GlobInput = { pattern?: string; path?: string }; +type GlobFile = { path: string }; + +function getGlobFiles(output: unknown): GlobFile[] { + if (typeof output !== "object" || output === null) return []; + if (!("files" in output) || !Array.isArray(output.files)) return []; + return output.files.filter( + (file): file is GlobFile => + typeof file === "object" && + file !== null && + "path" in file && + typeof file.path === "string", + ); +} + +/** Show at most the last 2 path segments. */ +function truncatePath(path: string): string { + const parts = path.split("/").filter(Boolean); + if (parts.length <= 2) return path; + return `…/${parts.slice(-2).join("/")}`; +} + +export function GlobRenderer({ + part, + state, + onApprove, + onDeny, +}: ToolRendererProps) { + const input = part.input as GlobInput | undefined; + const pattern = input?.pattern ?? "..."; + const path = input?.path; + + const output = part.state === "output-available" ? part.output : undefined; + const files = getGlobFiles(output); + + const summary = path ? `in ${truncatePath(path)}` : ""; + + const expandedContent = + files.length > 0 ? ( +
+        Found {files.length} file{files.length !== 1 ? "s" : ""}
+        {"\n"}
+        {files.map((f) => f.path).join("\n")}
+      
+ ) : undefined; + + return ( + } + summary={ + <> + '{pattern}' + {summary && ( + {summary} + )} + + } + meta={files.length > 0 ? `${files.length} files` : undefined} + state={state} + expandedContent={expandedContent} + onApprove={onApprove} + onDeny={onDeny} + /> + ); +} diff --git a/components/VercelChat/tools/agent/GrepRenderer.tsx b/components/VercelChat/tools/agent/GrepRenderer.tsx new file mode 100644 index 000000000..6fd0a4853 --- /dev/null +++ b/components/VercelChat/tools/agent/GrepRenderer.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Search } from "lucide-react"; +import { ToolLayout } from "./ToolLayout"; +import type { ToolRendererProps } from "./renderTool"; + +type GrepInput = { pattern?: string; path?: string }; +type GrepMatch = { file: string; line: number; content?: string }; + +function getGrepMatches(output: unknown): GrepMatch[] { + if (typeof output !== "object" || output === null) return []; + if (!("matches" in output) || !Array.isArray(output.matches)) return []; + return output.matches.filter( + (match): match is GrepMatch => + typeof match === "object" && + match !== null && + "file" in match && + typeof match.file === "string" && + "line" in match && + typeof match.line === "number", + ); +} + +/** Show at most the last 2 path segments. */ +function truncatePath(path: string): string { + const parts = path.split("/").filter(Boolean); + if (parts.length <= 2) return path; + return `…/${parts.slice(-2).join("/")}`; +} + +function getUniqueFiles(matches: GrepMatch[]): string[] { + const seen = new Set(); + for (const m of matches) seen.add(m.file); + return Array.from(seen); +} + +export function GrepRenderer({ + part, + state, + onApprove, + onDeny, +}: ToolRendererProps) { + const input = part.input as GrepInput | undefined; + const pattern = input?.pattern ?? "..."; + const path = input?.path; + + const output = part.state === "output-available" ? part.output : undefined; + const matches = getGrepMatches(output); + const uniqueFiles = getUniqueFiles(matches); + + const summary = path ? `in ${truncatePath(path)}` : ""; + + const expandedContent = + output !== undefined ? ( +
+        {matches.length > 0 ? (
+          <>
+            Found {uniqueFiles.length} file{uniqueFiles.length !== 1 ? "s" : ""}
+            {"\n"}
+            {uniqueFiles.join("\n")}
+          
+        ) : (
+          "No matches"
+        )}
+      
+ ) : undefined; + + return ( + } + summary={ + <> + '{pattern}' + {summary && ( + {summary} + )} + + } + meta={output ? `${matches.length} matches` : undefined} + state={state} + expandedContent={expandedContent} + onApprove={onApprove} + onDeny={onDeny} + /> + ); +} diff --git a/components/VercelChat/tools/agent/ReadRenderer.tsx b/components/VercelChat/tools/agent/ReadRenderer.tsx new file mode 100644 index 000000000..03bcce7d3 --- /dev/null +++ b/components/VercelChat/tools/agent/ReadRenderer.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { FileText } from "lucide-react"; +import { ToolLayout } from "./ToolLayout"; +import { FileNamePill } from "./FileNamePill"; +import type { ToolRendererProps } from "./renderTool"; + +type ReadInput = { filePath?: string }; +type ReadOutput = { + success?: boolean; + error?: string; + totalLines?: number; + startLine?: number; + endLine?: number; + content?: string; +}; + +export function ReadRenderer({ + part, + state, + onApprove, + onDeny, +}: ToolRendererProps) { + const input = part.input as ReadInput | undefined; + const filePath = input?.filePath ?? "..."; + + const output = + part.state === "output-available" + ? (part.output as ReadOutput | undefined) + : undefined; + const totalLines = output?.totalLines; + const startLine = output?.startLine; + const endLine = output?.endLine; + const fileContent = output?.content; + const isPartialRead = + startLine !== undefined && + endLine !== undefined && + totalLines !== undefined && + (startLine > 1 || endLine < totalLines); + const outputError = + output?.success === false ? (output?.error ?? "Read failed") : undefined; + + const mergedState = outputError + ? { ...state, error: state.error ?? outputError } + : state; + + // Content arrives with "N: " line-number prefixes; strip for display. + const cleanContent = fileContent + ? fileContent + .split("\n") + .map((line) => line.replace(/^\d+: /, "")) + .join("\n") + : undefined; + + const expandedContent = cleanContent ? ( +
+      {cleanContent}
+    
+ ) : undefined; + + const meta = isPartialRead + ? `[${startLine}–${endLine}]` + : totalLines !== undefined + ? `${totalLines} lines` + : undefined; + + return ( + } + summary={ + filePath === "..." ? ( + filePath + ) : ( + + ) + } + meta={meta} + errorMeta={mergedState.error ? "failed" : undefined} + state={mergedState} + expandedContent={expandedContent} + onApprove={onApprove} + onDeny={onDeny} + /> + ); +} diff --git a/components/VercelChat/tools/agent/SkillRenderer.tsx b/components/VercelChat/tools/agent/SkillRenderer.tsx new file mode 100644 index 000000000..baa53608f --- /dev/null +++ b/components/VercelChat/tools/agent/SkillRenderer.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Zap } from "lucide-react"; +import { ToolLayout } from "./ToolLayout"; +import type { ToolRendererProps } from "./renderTool"; + +type SkillInput = { skill?: string; args?: string }; +type SkillOutput = { success?: boolean; error?: string }; + +function getDisplayString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function SkillRenderer({ + part, + state, + onApprove, + onDeny, +}: ToolRendererProps) { + const input = part.input as SkillInput | undefined; + const skillName = getDisplayString(input?.skill); + const rawArgs = getDisplayString(input?.args); + + const output = + part.state === "output-available" + ? (part.output as SkillOutput | undefined) + : undefined; + const outputError = + output?.success === false ? (output?.error ?? "Skill failed") : undefined; + + const mergedState = outputError + ? { ...state, error: state.error ?? outputError } + : state; + + const expandedContent = rawArgs ? ( +
+      {rawArgs}
+    
+ ) : undefined; + + return ( + } + summary={skillName ? `/${skillName}` : "..."} + summaryClassName="font-mono" + state={mergedState} + nameClassName={mergedState.error ? "text-red-500" : undefined} + expandedContent={expandedContent} + onApprove={onApprove} + onDeny={onDeny} + /> + ); +} diff --git a/components/VercelChat/tools/agent/TaskRenderer.tsx b/components/VercelChat/tools/agent/TaskRenderer.tsx new file mode 100644 index 000000000..81053144d --- /dev/null +++ b/components/VercelChat/tools/agent/TaskRenderer.tsx @@ -0,0 +1,360 @@ +"use client"; + +import type { ToolUIPart } from "ai"; +import { + Bot, + FilePlus, + FileText, + FolderSearch, + Hammer, + Paintbrush, + Pencil, + Search, + Telescope, + Terminal, + Zap, +} from "lucide-react"; +import type { ReactNode } from "react"; +import { ToolLayout } from "./ToolLayout"; +import { BashRenderer } from "./BashRenderer"; +import { ReadRenderer } from "./ReadRenderer"; +import { WriteRenderer } from "./WriteRenderer"; +import { EditRenderer } from "./EditRenderer"; +import { GlobRenderer } from "./GlobRenderer"; +import { GrepRenderer } from "./GrepRenderer"; +import { TodoRenderer } from "./TodoRenderer"; +import { FetchRenderer } from "./FetchRenderer"; +import { SkillRenderer } from "./SkillRenderer"; +import { extractRenderState, formatTokens } from "./toolState"; +import type { ToolRendererProps } from "./renderTool"; + +const TOOL_ICON_CLASS = "h-3.5 w-3.5"; + +type PendingToolCall = { name: string; input?: unknown }; +type TaskOutput = { + pending?: PendingToolCall | null; + toolCallCount?: number; + usage?: { inputTokens?: number }; + final?: unknown; +}; +type TaskInput = { task?: string; subagentType?: string }; + +function getToolMeta(toolName: string): { + displayName: string; + icon: ReactNode; +} { + switch (toolName) { + case "bash": + return { + displayName: "Bash", + icon: , + }; + case "read": + return { + displayName: "Read", + icon: , + }; + case "write": + return { + displayName: "Create", + icon: , + }; + case "edit": + return { + displayName: "Update", + icon: , + }; + case "grep": + return { + displayName: "Grep", + icon: , + }; + case "glob": + return { + displayName: "Glob", + icon: , + }; + case "skill": + return { + displayName: "Skill", + icon: , + }; + default: { + const name = toolName.charAt(0).toUpperCase() + toolName.slice(1); + return { displayName: name, icon: }; + } + } +} + +function getToolSummary(name: string, input: unknown): string { + const inp = input as Record | undefined; + if (!inp) return ""; + switch (name) { + case "read": + case "write": + case "edit": + return inp.filePath ? String(inp.filePath) : ""; + case "grep": + case "glob": + return inp.pattern ? `'${inp.pattern}'` : ""; + case "bash": + return inp.command ? String(inp.command) : ""; + default: + return ""; + } +} + +/** Unwrap AI SDK tool output envelope: { type: "json", value: { ... } } */ +function unwrapToolOutput(output: unknown): unknown { + if (!output || typeof output !== "object") return output; + const o = output as Record; + if (o.type === "json" && o.value && typeof o.value === "object") { + return o.value; + } + return output; +} + +type SynthPart = { + type: string; + toolCallId: string; + input: unknown; + output: unknown; +}; + +/** Pull completed tool calls out of a subagent's final message list. */ +function extractToolParts(messages: unknown): SynthPart[] { + if (!Array.isArray(messages)) return []; + + const calls: { id: string; name: string; input: unknown }[] = []; + for (const msg of messages) { + if ( + typeof msg !== "object" || + msg === null || + (msg as { role?: string }).role !== "assistant" + ) + continue; + const content = (msg as { content?: unknown }).content; + if (!Array.isArray(content)) continue; + for (const p of content) { + if ( + typeof p === "object" && + p !== null && + (p as { type?: string }).type === "tool-call" + ) { + const tc = p as { + toolCallId?: string; + toolName?: string; + input?: unknown; + }; + if (tc.toolName && tc.toolCallId) { + calls.push({ id: tc.toolCallId, name: tc.toolName, input: tc.input }); + } + } + } + } + + const resultMap = new Map(); + for (const msg of messages) { + if ( + typeof msg !== "object" || + msg === null || + (msg as { role?: string }).role !== "tool" + ) + continue; + const content = (msg as { content?: unknown }).content; + if (!Array.isArray(content)) continue; + for (const p of content) { + if ( + typeof p === "object" && + p !== null && + (p as { type?: string }).type === "tool-result" + ) { + const tr = p as { toolCallId?: string; output?: unknown }; + if (tr.toolCallId) resultMap.set(tr.toolCallId, tr.output); + } + } + } + + return calls.map((call) => ({ + type: `tool-${call.name}`, + toolCallId: call.id, + input: call.input, + output: unwrapToolOutput(resultMap.get(call.id)), + })); +} + +function countToolCalls(messages: unknown): number { + if (!Array.isArray(messages)) return 0; + return messages.filter( + (m) => + typeof m === "object" && + m !== null && + (m as { role?: string }).role === "tool", + ).length; +} + +function getSubagentIcon(subagentType: string | undefined, className: string) { + switch (subagentType) { + case "executor": + return ; + case "design": + return ; + case "explorer": + return ; + default: + return ; + } +} + +function getSubagentLabel(subagentType: string | undefined): string { + switch (subagentType) { + case "executor": + return "Executor Subagent"; + case "design": + return "Design Subagent"; + case "explorer": + return "Explorer Subagent"; + default: + return subagentType + ? `${subagentType.charAt(0).toUpperCase() + subagentType.slice(1)} Subagent` + : "Subagent"; + } +} + +/** Render one completed nested tool call using the real renderers. */ +function SubagentToolCall({ part }: { part: SynthPart }) { + const uiPart = { + ...part, + state: "output-available", + } as unknown as ToolUIPart; + const state = extractRenderState(uiPart, null, false); + + switch (part.type) { + case "tool-bash": + return ; + case "tool-read": + return ; + case "tool-write": + return ; + case "tool-edit": + return ; + case "tool-glob": + return ; + case "tool-grep": + return ; + case "tool-todo_write": + return ; + case "tool-web_fetch": + return ; + case "tool-skill": + return ; + default: { + const name = part.type.slice(5); + const meta = getToolMeta(name); + return ( + + ); + } + } +} + +function PendingMiniToolCall({ name, input }: PendingToolCall) { + const meta = getToolMeta(name); + return ( + + ); +} + +export function TaskRenderer({ + part, + state, + onApprove, + onDeny, +}: ToolRendererProps) { + const input = part.input as TaskInput | undefined; + const desc = input?.task ?? "Spawning subagent"; + const subagentType = input?.subagentType; + const taskDenied = part.state === "output-denied"; + + const hasOutput = part.state === "output-available"; + const isPreliminary = + hasOutput && (part as { preliminary?: boolean }).preliminary === true; + const isComplete = hasOutput && !isPreliminary; + const output = hasOutput + ? (part.output as TaskOutput | undefined) + : undefined; + + const pendingToolCall = output?.pending ?? null; + const toolCount = + output?.toolCallCount ?? (isComplete ? countToolCalls(output?.final) : 0); + const tokenCount = output?.usage?.inputTokens ?? null; + + const statParts: string[] = []; + if (toolCount > 0) { + statParts.push(`${toolCount} tool${toolCount !== 1 ? "s" : ""}`); + } + if (tokenCount !== null) { + statParts.push(`${formatTokens(tokenCount)} tokens`); + } + + const meta = + statParts.length > 0 ? ( + + {statParts.join(" · ")} + + ) : null; + + const completedParts = isComplete ? extractToolParts(output?.final) : []; + const hasExpandableContent = + pendingToolCall !== null || completedParts.length > 0; + + const expandedContent = hasExpandableContent ? ( +
+ {pendingToolCall && !isComplete && ( + + )} + {isComplete && + completedParts.map((toolPart) => ( + + ))} +
+ ) : undefined; + + return ( + + ); +} diff --git a/components/VercelChat/tools/agent/TodoRenderer.tsx b/components/VercelChat/tools/agent/TodoRenderer.tsx new file mode 100644 index 000000000..262e64e4f --- /dev/null +++ b/components/VercelChat/tools/agent/TodoRenderer.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { ArrowRight, LayoutList, ListChecks, ListTodo } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { ToolLayout } from "./ToolLayout"; +import type { ToolRendererProps } from "./renderTool"; + +type Todo = { id?: string; content?: string; status?: string }; + +function CompletedIcon({ className }: { className?: string }) { + return ( + + ); +} + +function InProgressIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function PendingIcon({ className }: { className?: string }) { + return ( + + ); +} + +function TodoItem({ todo }: { todo: Todo }) { + const status = todo.status ?? "pending"; + const content = todo.content ?? ""; + + return ( +
+ + {status === "completed" ? ( + + ) : status === "in_progress" ? ( + + ) : ( + + )} + + + {content} + +
+ ); +} + +export function TodoRenderer({ part, state }: ToolRendererProps) { + const input = part.input as { todos?: Todo[] } | undefined; + const todos: Todo[] = (input?.todos ?? []).filter( + (t): t is Todo => t !== undefined, + ); + + const activeTodo = todos.find((todo) => todo?.status === "in_progress"); + const completedCount = todos.filter( + (todo) => todo?.status === "completed", + ).length; + const allDone = completedCount === todos.length && todos.length > 0; + const noneStarted = completedCount === 0 && !activeTodo; + + let name: string; + let icon: ReactNode; + let statusMeta: string | undefined; + + if (allDone) { + name = "All tasks completed"; + icon = ; + } else if (activeTodo?.content) { + name = activeTodo.content; + statusMeta = "→ in progress"; + icon = ; + } else if (noneStarted) { + name = `${todos.length} task${todos.length !== 1 ? "s" : ""} created`; + icon = ; + } else { + name = `${todos.length} task${todos.length !== 1 ? "s" : ""} updated`; + statusMeta = `${completedCount}/${todos.length} done`; + icon = ; + } + + const expandedContent = + todos.length > 0 ? ( +
+ {todos.map((todo, i) => ( + + ))} +
+ ) : undefined; + + return ( + + ); +} diff --git a/components/VercelChat/tools/agent/ToolCall.tsx b/components/VercelChat/tools/agent/ToolCall.tsx new file mode 100644 index 000000000..705749a11 --- /dev/null +++ b/components/VercelChat/tools/agent/ToolCall.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { type ToolUIPart, getToolOrDynamicToolName } from "ai"; +import { ToolLayout } from "./ToolLayout"; +import { BashRenderer } from "./BashRenderer"; +import { ReadRenderer } from "./ReadRenderer"; +import { WriteRenderer } from "./WriteRenderer"; +import { EditRenderer } from "./EditRenderer"; +import { GlobRenderer } from "./GlobRenderer"; +import { GrepRenderer } from "./GrepRenderer"; +import { TodoRenderer } from "./TodoRenderer"; +import { FetchRenderer } from "./FetchRenderer"; +import { SkillRenderer } from "./SkillRenderer"; +import { AskUserQuestionRenderer } from "./AskUserQuestionRenderer"; +import { TaskRenderer } from "./TaskRenderer"; +import { extractRenderState, type GenericToolPart } from "./toolState"; + +export type ToolCallProps = { + part: ToolUIPart; + isStreaming?: boolean; + onApprove?: (id: string) => void; + onDeny?: (id: string, reason?: string) => void; +}; + +/** + * Renders a coding-agent tool call (bash, read, edit, …) with a state-aware, + * collapsible interactive shell. Dispatches on the tool name; unknown agent + * tools fall back to a generic ToolLayout. + */ +export function ToolCall({ + part, + isStreaming = false, + onApprove, + onDeny, +}: ToolCallProps) { + const state = extractRenderState( + part as unknown as GenericToolPart, + null, + isStreaming, + ); + const toolName = getToolOrDynamicToolName(part); + const rendererProps = { part, state, onApprove, onDeny }; + + switch (toolName) { + case "bash": + return ; + case "read": + return ; + case "write": + return ; + case "edit": + return ; + case "glob": + return ; + case "grep": + return ; + case "todo_write": + return ; + case "web_fetch": + return ; + case "skill": + return ; + case "ask_user_question": + return ; + case "task": + return ; + default: { + const name = toolName.charAt(0).toUpperCase() + toolName.slice(1); + const input = part.input as Record | undefined; + const summary = input ? JSON.stringify(input).slice(0, 40) : "..."; + return ( + + ); + } + } +} diff --git a/components/VercelChat/tools/agent/ToolLayout.tsx b/components/VercelChat/tools/agent/ToolLayout.tsx new file mode 100644 index 000000000..3a098cdc0 --- /dev/null +++ b/components/VercelChat/tools/agent/ToolLayout.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { CircleX, Loader2, Minus, OctagonPause, Plus } from "lucide-react"; +import type React from "react"; +import { type ReactNode, useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { ApprovalButtons } from "./ApprovalButtons"; +import type { ToolRenderState } from "./toolState"; + +export type ToolLayoutProps = { + name: string; + summary: ReactNode; + summaryClassName?: string; + meta?: ReactNode; + /** When true, push meta to the far right of the header row. */ + rightAlignMeta?: boolean; + /** Short label shown right-aligned in the error header (e.g. "exit 1"). */ + errorMeta?: ReactNode; + state: ToolRenderState; + output?: ReactNode; + children?: ReactNode; + expandedContent?: ReactNode; + onApprove?: (id: string) => void; + onDeny?: (id: string, reason?: string) => void; + defaultExpanded?: boolean; + /** Tool-specific icon (Lucide element). */ + icon?: ReactNode; + nameClassName?: string; +}; + +function StatusIndicator({ state }: { state: ToolRenderState }) { + if (state.interrupted) { + return ( + + ); + } + + if (state.running) { + return ; + } + + const color = state.denied + ? "bg-red-500" + : state.approvalRequested + ? "bg-yellow-500" + : state.error + ? "bg-red-500" + : "bg-green-500"; + + return ; +} + +function hasRenderableContent(value: ReactNode) { + return ( + value !== null && value !== undefined && value !== false && value !== "" + ); +} + +const EXPANDED_CONTENT_TRANSITION_MS = 200; + +function trimErrorPrefix(message: string) { + return message.replace(/^Error:\s*/i, "").trim(); +} + +export function ToolLayout({ + name, + summary, + summaryClassName, + meta, + rightAlignMeta = false, + errorMeta, + state, + output, + children, + expandedContent, + onApprove, + onDeny, + defaultExpanded = false, + icon, + nameClassName, +}: ToolLayoutProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const showApprovalButtons = Boolean( + state.approvalRequested && !state.isActiveApproval && state.approvalId, + ); + const errorMessage = + state.error && !state.denied ? trimErrorPrefix(state.error) : undefined; + const hasError = Boolean(errorMessage); + const isInterrupted = Boolean(state.interrupted); + const hasExpandedDetails = + hasRenderableContent(expandedContent) || hasError || isInterrupted; + const hasOutput = hasRenderableContent(output); + const hasMeta = hasRenderableContent(meta); + const hasSummary = + typeof summary === "string" ? summary.trim().length > 0 : summary != null; + const showRunningNotice = + state.approvalRequested && !showApprovalButtons && !state.interrupted; + const isExpandedPanelVisible = isExpanded && hasExpandedDetails; + const [shouldRenderExpandedContent, setShouldRenderExpandedContent] = + useState(defaultExpanded && hasExpandedDetails); + + const showErrorHeader = hasError; + const showInterruptedHeader = isInterrupted && !hasError; + const showErrorExpanded = hasError && isExpandedPanelVisible; + const showInterruptedExpanded = + isInterrupted && !hasError && isExpandedPanelVisible; + const hasErrorMeta = hasRenderableContent(errorMeta); + const hasTrailingMeta = !showErrorHeader && !showInterruptedHeader && hasMeta; + + useEffect(() => { + if (!hasExpandedDetails) { + setShouldRenderExpandedContent(false); + return; + } + + if (isExpandedPanelVisible) { + setShouldRenderExpandedContent(true); + return; + } + + if (!shouldRenderExpandedContent) { + return; + } + + const timeoutId = window.setTimeout(() => { + setShouldRenderExpandedContent(false); + }, EXPANDED_CONTENT_TRANSITION_MS); + + return () => window.clearTimeout(timeoutId); + }, [hasExpandedDetails, isExpandedPanelVisible, shouldRenderExpandedContent]); + + const handleToggle = () => { + if (!hasExpandedDetails) { + return; + } + + const nextExpanded = !isExpanded; + + if (nextExpanded) { + setShouldRenderExpandedContent(true); + } + + setIsExpanded(nextExpanded); + }; + + const isRunning = state.running; + const resolvedIcon = isRunning ? ( + + ) : ( + (icon ?? ) + ); + + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleToggle(); + } + }, + role: "button", + tabIndex: 0, + "aria-expanded": isExpanded, + })} + > + {/* Icon area */} + + {showErrorHeader ? ( + <> + + {isExpandedPanelVisible ? ( + + ) : ( + + )} + + ) : showInterruptedHeader ? ( + <> + + {isExpandedPanelVisible ? ( + + ) : ( + + )} + + ) : hasExpandedDetails && !isRunning ? ( + <> + {resolvedIcon} + {isExpandedPanelVisible ? ( + + ) : ( + + )} + + ) : ( + resolvedIcon + )} + + + {/* Name + summary */} + + {name} + + +
+ {hasSummary && ( + + {summary} + + )} + + {(rightAlignMeta || showErrorHeader || showInterruptedHeader) && ( + + )} + + {showErrorHeader && hasErrorMeta && ( + + {errorMeta} + + )} + + {hasTrailingMeta && ( + + {meta} + + )} +
+
+ + {children} + + {showRunningNotice && ( +
Running...
+ )} + + {showApprovalButtons && ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + +
+ )} + + {hasOutput && + !state.approvalRequested && + !state.denied && + !state.interrupted && ( +
{output}
+ )} + + {state.denied && ( +
+ Denied{state.denialReason ? `: ${state.denialReason}` : ""} +
+ )} + + {hasExpandedDetails && ( +
+
+ {shouldRenderExpandedContent && ( +
+ {showErrorExpanded && + !hasRenderableContent(expandedContent) && ( +
+                      {errorMessage}
+                    
+ )} + {showInterruptedExpanded && ( +
+                    interrupted
+                  
+ )} + {expandedContent} +
+ )} +
+
+ )} +
+ ); +} diff --git a/components/VercelChat/tools/agent/WriteRenderer.tsx b/components/VercelChat/tools/agent/WriteRenderer.tsx new file mode 100644 index 000000000..1d0059e58 --- /dev/null +++ b/components/VercelChat/tools/agent/WriteRenderer.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { FilePlus } from "lucide-react"; +import { ToolLayout } from "./ToolLayout"; +import { FileNamePill } from "./FileNamePill"; +import type { ToolRendererProps } from "./renderTool"; + +type WriteInput = { filePath?: string; content?: string }; +type WriteOutput = { success?: boolean; error?: string }; + +export function WriteRenderer({ + part, + state, + onApprove, + onDeny, +}: ToolRendererProps) { + const input = part.input as WriteInput | undefined; + const filePath = input?.filePath ?? "..."; + const content = input?.content ?? ""; + + const totalLines = content.length === 0 ? 0 : content.split("\n").length; + + const output = + part.state === "output-available" + ? (part.output as WriteOutput | undefined) + : undefined; + const outputError = + output?.success === false ? (output?.error ?? "Write failed") : undefined; + + const mergedState = outputError + ? { ...state, error: state.error ?? outputError } + : state; + + const showCode = + mergedState.approvalRequested || + (!mergedState.running && !mergedState.error && !mergedState.denied); + + const expandedContent = + showCode && !mergedState.denied && content ? ( +
+        {content}
+      
+ ) : undefined; + + const meta = + showCode && !mergedState.denied ? ( + + +{totalLines} + -0 + + ) : undefined; + + return ( + } + summary={ + filePath === "..." ? ( + filePath + ) : ( + + ) + } + meta={meta} + errorMeta={mergedState.error ? "failed" : undefined} + state={mergedState} + expandedContent={expandedContent} + onApprove={onApprove} + onDeny={onDeny} + /> + ); +} diff --git a/components/VercelChat/tools/agent/renderTool.ts b/components/VercelChat/tools/agent/renderTool.ts new file mode 100644 index 000000000..aba494a8b --- /dev/null +++ b/components/VercelChat/tools/agent/renderTool.ts @@ -0,0 +1,32 @@ +import type { ToolUIPart } from "ai"; +import type { ToolRenderState } from "./toolState"; + +/** Props shared by every agent tool renderer. */ +export type ToolRendererProps = { + part: ToolUIPart; + state: ToolRenderState; + onApprove?: (id: string) => void; + onDeny?: (id: string, reason?: string) => void; +}; + +/** + * Coding-agent tools that get a dedicated interactive renderer. Any tool not in + * this set falls through to the legacy / generic rendering path. + */ +export const AGENT_TOOL_NAMES = new Set([ + "bash", + "read", + "write", + "edit", + "grep", + "glob", + "todo_write", + "web_fetch", + "task", + "skill", + "ask_user_question", +]); + +export function isAgentToolName(name: string): boolean { + return AGENT_TOOL_NAMES.has(name); +} diff --git a/components/VercelChat/tools/agent/toolState.ts b/components/VercelChat/tools/agent/toolState.ts new file mode 100644 index 000000000..8b4582906 --- /dev/null +++ b/components/VercelChat/tools/agent/toolState.ts @@ -0,0 +1,75 @@ +/** + * Derives a common render state from a Vercel AI SDK tool part so every agent + * tool renderer shares one notion of running / error / interrupted / approval. + */ + +export type ToolRenderState = { + /** Whether the tool is currently running */ + running: boolean; + /** Whether the tool was interrupted (running when the stream stopped) */ + interrupted: boolean; + /** Error message if the tool failed */ + error?: string; + /** Whether the tool was denied by the user */ + denied: boolean; + /** Reason for denial if provided */ + denialReason?: string; + /** Whether approval is being requested */ + approvalRequested: boolean; + /** Approval ID if approval is requested */ + approvalId?: string; + /** Whether this is the currently active approval */ + isActiveApproval: boolean; +}; + +/** Loose tool-part shape so this works with any AI SDK tool/dynamic-tool part. */ +export type GenericToolPart = { + state: string; + approval?: { id?: string; approved?: boolean; reason?: string }; + errorText?: string; + input?: unknown; + output?: unknown; +}; + +export function extractRenderState( + part: GenericToolPart, + activeApprovalId: string | null, + isStreaming: boolean, +): ToolRenderState { + const isRunningState = + part.state === "input-streaming" || part.state === "input-available"; + const approval = part.approval; + const denied = part.state === "output-denied" || approval?.approved === false; + const denialReason = denied ? approval?.reason : undefined; + const approvalRequested = part.state === "approval-requested" && !denied; + const error = part.state === "output-error" ? part.errorText : undefined; + const approvalId = approvalRequested ? approval?.id : undefined; + const isActiveApproval = + approvalId != null && approvalId === activeApprovalId; + + // Tool was running but the stream stopped → it was interrupted. + const interrupted = isRunningState && !isStreaming; + const running = isRunningState && isStreaming; + + return { + running, + interrupted, + error, + denied, + denialReason, + approvalRequested, + approvalId, + isActiveApproval, + }; +} + +/** + * Format a token count for compact display. + * 500 → "500", 1200 → "1.2k", 999950 → "1.0m". + */ +export function formatTokens(tokens: number): string { + if (tokens >= 999_950_000) return `${(tokens / 1_000_000_000).toFixed(1)}b`; + if (tokens >= 999_950) return `${(tokens / 1_000_000).toFixed(1)}m`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; + return tokens.toLocaleString(); +}