HextaUI

Tool Calls

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.local as HEXTAUI_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.