Show what an agent does, one line per step. Reads and searches fold into a short summary, while edits, commands, approvals and errors stay in view. Each step opens into a real view: the file, the diff, the terminal or the results. Every AI SDK tool state is covered, including approvals with a reason to give instead.
- 1
Add the Pro registry to components.json
components.json { "registries": { "@hextaui-pro": { "url": "https://hextaui.com/r/pro/{name}.json", "headers": { "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}" } } } } - 2
Add your token
Create a token on your account page and put it in
.env.localasHEXTAUI_PRO_TOKEN. - 3
Add the block
pnpm dlx shadcn@latest add @hextaui-pro/tool-calls
With the AI SDK#
Turn tool parts into steps with getToolPartStatus and pick a kind and view per tool. Pass stopped once the chat stops streaming, so a call cut off midway reads Cancelled instead of spinning forever.
"use client"
import type * as React from "react"
import { useChat } from "@ai-sdk/react"
import {
getToolName,
isToolUIPart,
lastAssistantMessageIsCompleteWithApprovalResponses,
type DynamicToolUIPart,
type ToolUIPart,
type UIMessage,
} from "ai"
import { CodeBlock } from "../code-block/code-block"
import { CodeTerminal } from "../code-block/code-terminal"
import type { ToolCallProps } from "@/components/blocks/tool-calls/tool-call"
import { ToolCalls } from "@/components/blocks/tool-calls/tool-group"
import {
getToolPartApproval,
getToolPartStatus,
} from "@/components/blocks/tool-calls/tool-part"
import { ToolJson } from "@/components/blocks/tool-calls/tool-views"
type ToolPart = ToolUIPart | DynamicToolUIPart
type Fields = Record<string, string | number | undefined>
function describe(part: ToolPart): Partial<ToolCallProps> {
const input = (part.input ?? {}) as Fields
const output = (part.state === "output-available" ? part.output : {}) as Fields
const done = part.state === "output-available"
switch (getToolName(part)) {
case "readFile":
return {
kind: "read",
subject: String(input.path ?? ""),
content: done ? (
<CodeBlock
code={String(output.content)}
filename={String(input.path)}
lineNumbers
/>
) : undefined,
}
case "editFile":
return {
kind: "edit",
subject: String(input.path ?? ""),
content: input.diff ? (
<CodeBlock
code={String(input.diff)}
filename={String(input.path)}
streaming={part.state === "input-streaming"}
diff
/>
) : undefined,
}
case "runCommand":
return {
kind: "run",
subject: String(input.command ?? ""),
exitCode: done ? Number(output.exitCode) : undefined,
content: (
<CodeTerminal
command={String(input.command ?? "")}
output={String(output.stdout ?? "")}
running={!done && part.state !== "approval-requested"}
exitCode={done ? Number(output.exitCode) : undefined}
/>
),
}
default:
return {
kind: "other",
name: getToolName(part),
content: (
<ToolJson
input={part.input}
output={done ? part.output : undefined}
streaming={part.state === "input-streaming"}
/>
),
}
}
}
function AssistantMessage({
message,
streaming,
onApproval,
}: {
message: UIMessage
streaming: boolean
onApproval: (id: string, approved: boolean, reason?: string) => void
}) {
const blocks: (React.ReactNode | ToolCallProps[])[] = []
message.parts.forEach((part, index) => {
if (isToolUIPart(part)) {
const call: ToolCallProps = {
id: part.toolCallId,
title: part.title,
status: getToolPartStatus(part, { stopped: !streaming }),
approval: getToolPartApproval(part),
error: part.state === "output-error" ? part.errorText : undefined,
onApprove: () => part.approval && onApproval(part.approval.id, true),
onDeny: (reason) =>
part.approval && onApproval(part.approval.id, false, reason),
...describe(part),
}
const last = blocks.at(-1)
if (Array.isArray(last)) last.push(call)
else blocks.push([call])
} else if (part.type === "text") {
blocks.push(<p key={index}>{part.text}</p>)
}
})
return (
<div className="flex flex-col gap-4">
{blocks.map((block, index) =>
Array.isArray(block) ? (
<ToolCalls key={block[0].id} calls={block} />
) : (
<div key={index}>{block}</div>
)
)}
</div>
)
}
export function Chat() {
const { messages, status, addToolApprovalResponse } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
})
const busy = status === "submitted" || status === "streaming"
return messages.map((message, index) =>
message.role === "assistant" ? (
<AssistantMessage
key={message.id}
message={message}
streaming={busy && index === messages.length - 1}
onApproval={(id, approved, reason) =>
addToolApprovalResponse({ id, approved, reason })
}
/>
) : null
)
}Approving commands#
Mark a tool with needsApproval on the server and answer with addToolApprovalResponse. Always allow remembers the program, and a denial can carry what to do instead as its reason. ⌘↵ and ⌘⌫ work while the step has focus.
"use client"
import * as React from "react"
import { useChat } from "@ai-sdk/react"
import {
isToolUIPart,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from "ai"
import { CodeTerminal } from "../code-block/code-terminal"
import { ToolCall } from "@/components/blocks/tool-calls/tool-call"
import { getToolPartApproval, getToolPartStatus } from "@/components/blocks/tool-calls/tool-part"
function program(command: string) {
return command.trim().split(/\s+/)[0] ?? ""
}
export function Chat() {
const [allowed, setAllowed] = React.useState<string[]>([])
const { messages, status, addToolApprovalResponse } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
})
const streaming = status === "submitted" || status === "streaming"
React.useEffect(() => {
for (const part of messages.at(-1)?.parts ?? []) {
if (part.type !== "tool-runCommand") continue
if (part.state !== "approval-requested") continue
const command = String((part.input as { command?: string }).command)
if (allowed.includes(program(command))) {
addToolApprovalResponse({ id: part.approval.id, approved: true })
}
}
}, [messages, allowed, addToolApprovalResponse])
return messages.flatMap((message) =>
message.parts.map((part) => {
if (!isToolUIPart(part) || part.type !== "tool-runCommand") return null
const command = String((part.input as { command?: string })?.command ?? "")
const output = part.state === "output-available"
? (part.output as { stdout: string; exitCode: number })
: undefined
return (
<ToolCall
key={part.toolCallId}
id={part.toolCallId}
kind="run"
subject={command}
status={getToolPartStatus(part, { stopped: !streaming })}
exitCode={output?.exitCode}
approval={{
...getToolPartApproval(part),
alwaysLabel: `Always allow ${program(command)}`,
}}
onApprove={({ always }) => {
if (!part.approval) return
if (always) setAllowed((list) => [...list, program(command)])
addToolApprovalResponse({ id: part.approval.id, approved: true })
}}
onDeny={(reason) => {
if (!part.approval) return
addToolApprovalResponse({
id: part.approval.id,
approved: false,
reason,
})
}}
content={
<CodeTerminal
command={command}
output={output?.stdout}
exitCode={output?.exitCode}
running={part.state === "approval-responded"}
/>
}
/>
)
})
)
}Asking the user#
A client tool with no execute function waits for an answer. Show the choices with AskUser and send the pick back with addToolOutput.
"use client"
import { useChat } from "@ai-sdk/react"
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"
import { ToolCall } from "@/components/blocks/tool-calls/tool-call"
import { getToolPartStatus } from "@/components/blocks/tool-calls/tool-part"
import { AskUser, type AskOption } from "@/components/blocks/tool-calls/tool-views"
export function Chat() {
const { messages, addToolOutput } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
})
return messages.flatMap((message) =>
message.parts.map((part) => {
if (part.type !== "tool-askUser") return null
const input = (part.input ?? {}) as {
question?: string
options?: AskOption[]
}
return (
<ToolCall
key={part.toolCallId}
id={part.toolCallId}
kind="ask"
subject={input.question}
status={getToolPartStatus(part, { waiting: true })}
content={
<AskUser
question={input.question ?? ""}
options={input.options ?? []}
answer={
part.state === "output-available"
? String(part.output)
: undefined
}
onAnswer={(value) =>
addToolOutput({
tool: "askUser",
toolCallId: part.toolCallId,
output: value,
})
}
/>
}
/>
)
})
)
}Code#
8 files, added to components/blocks/tool-calls.