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();
+}