Tool Calls

Mostre o que um agente faz, uma linha por etapa. Leituras e buscas se dobram em um resumo curto, enquanto edições, comandos, aprovações e erros permanecem à vista. Cada etapa se abre em uma visão real: o arquivo, o diff, o terminal ou os resultados. Todos os estados de ferramenta do AI SDK são cobertos, incluindo aprovações com a opção de dar um motivo.

Agentes podem dar dezenas de pequenos passos antes de responder. Mostrar todos eles enterra a resposta, e escondê-los faz o agente parecer uma caixa-preta. O Tool Calls dá a cada etapa uma linha discreta que se lê como uma frase, como "Leu components/search.tsx" ou "Buscou por useResults, 3 resultados".

Leituras, buscas e consultas que ocorrem em sequência se dobram em um único resumo como "Explorou 6 arquivos". Tudo o que altera algo, exige uma decisão ou falha permanece em sua própria linha, com um cronômetro ao vivo enquanto roda. Abra qualquer etapa para ver o trabalho real: o arquivo, o diff, o terminal ou os resultados.

Todo estado de ferramenta do AI SDK tem sua própria aparência e redação, inclusive as aprovações. Aprovar oferece Allow, Deny, Always allow e "Tell it what to do instead", que envia suas palavras de volta como o motivo, com ⌘↵ e ⌘⌫ pelo teclado. getToolPartStatus mapeia as partes de ferramenta para você, e visões prontas cobrem resultados de busca, listas de arquivos, perguntas de múltipla escolha e JSON bruto.

  1. Adicione o registro Pro ao components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Adicione seu token

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  3. Adicione o bloco

    pnpm dlx shadcn@latest add @hextaui-pro/tool-calls

Com o AI SDK

Transforme as partes de ferramenta em passos com getToolPartStatus e escolha um kind e uma view para cada ferramenta. Passe stopped quando o chat parar de transmitir, para que uma chamada interrompida no meio exiba Cancelled em vez de girar para sempre.

"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
  )
}

Aprovando comandos

Marque uma ferramenta com needsApproval no servidor e responda com addToolApprovalResponse. Always allow memoriza o programa, e uma negação pode levar o que fazer em seu lugar como motivo. ⌘↵ e ⌘⌫ funcionam enquanto a etapa tem o foco.

"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"}
            />
          }
        />
      )
    })
  )
}

Perguntando ao usuário

Uma ferramenta do cliente sem função execute aguarda uma resposta. Mostre as opções com AskUser e envie a escolha de volta com 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,
                })
              }
            />
          }
        />
      )
    })
  )
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
ToolCallsA lista. Agrupa execuções silenciosas e compartilha o tempo entre os passos.
ToolCallUma etapa: ícone, frase, meta, cronômetro e seu conteúdo quando aberta.
ToolGroupUma sequência dobrada de etapas discretas com uma linha de resumo.
ToolApprovalO card de aprovar, negar e redirecionar, exibido dentro de um passo que aguarda aprovação.
SearchResults, FileList, AskUser, ToolJsonViews para passar como conteúdo de um passo.

ToolCalls

Também aceita todas as props de ol, exceto children.

PropTipoPadrão
callsOs passos na ordem em que foram executados.
ToolCallProps[]–
groupDobra sequências de etapas discretas. Defina false para mostrar todas as etapas.
booleantrue
PropTipoPadrão
idId estável, geralmente o toolCallId. O tempo é mantido por id.
string–
statusstreaming, running, waiting, approval, done, error, denied ou cancelled.
ToolStatus–
kindread, search, list, edit, write, run, web, fetch, ask ou other. Define o ícone e o verbo.
ToolKind"other"
subjectSobre o que atuou: um caminho, consulta, URL ou comando.
string–
nameNome da ferramenta, usado na frase quando kind é other.
string–
titleSubstitui totalmente a frase gerada.
string–
metaResultado curto após a frase, como "3 resultados" ou "+12 −3".
ReactNode–
contentExibido quando a etapa é aberta.
ReactNode–
exitCodePara etapas de execução. Um valor diferente de zero marca a etapa como falha e a abre.
number–
errorMensagem exibida para o status de erro.
string–
durationDuração armazenada em segundos, para o histórico.
number–
approvalMotivo, resultado e rótulo de always-allow para as aprovações.
ToolCallApproval–
defaultOpenSubstitui se a etapa começa aberta.
boolean–
onApproveChamado por Allow ou Always allow.
(options: { always: boolean }) => void–
onDenyChamado por Deny, ou com o texto de "Tell it what to do instead".
(reason?: string) => void–
PropTipoPadrão
reasonPor que a aprovação é necessária, exibido acima dos botões.
string–
approvedA resposta, depois de dada.
boolean–
automaticAprovado por uma regra, então nenhum card é exibido.
boolean–
denialReasonO que a pessoa pediu em vez disso, exibido nos passos negados.
string–
alwaysLabelMostra "Always allow …" com este rótulo, por exemplo o nome do programa.
string–

getToolPartStatus(part, options)

Mapeia uma parte de ferramenta do AI SDK para um ToolStatus.

PropTipoPadrão
partA parte vinda de message.parts.
ToolUIPart | DynamicToolUIPart–
options.stoppedO chat foi interrompido, então chamadas inacabadas exibem Cancelled em vez de ficarem girando.
booleanfalse
options.waitingUma ferramenta do cliente está aguardando a pessoa, por exemplo AskUser.
booleanfalse
PropTipoPadrão
questionA pergunta.
string–
optionsAs opções.
{ value, label, description? }[]–
answerO valor escolhido, depois de respondido.
string–
onAnswerEnvie de volta com addToolOutput.
(value: string) => void–
PropTipoPadrão
queryDestacado dentro de cada correspondência.
string–
matchesOs resultados.
{ path, line, text }[]–
limitExibido antes de "Show all".
number6
TeclaAção
EnterSpaceAbre ou fecha uma etapa ou um grupo dobrado.
⌘↵Permite uma etapa que aguarda aprovação enquanto o foco está dentro dela.
⌘⌫Nega uma etapa que aguarda aprovação enquanto o foco está dentro dela.
EscSai de "Tell it what to do instead" sem enviar.
  • Os passos formam uma lista ordenada, então os leitores de tela anunciam quantos passos existem e em qual posição cada um está.
  • Passos que precisam da pessoa são anunciados por uma região live educada (polite), como “Aprovação necessária: Run pnpm test”, junto com perguntas e erros. Passos de rotina ficam em silêncio para não sobrecarregar os leitores de tela.
  • O status nunca é só cor: cada estado tem seu próprio ícone e redação.
  • Os atalhos de aprovação são ignorados enquanto você digita em um campo, e o campo de redirecionamento se foca sozinho ao abrir e devolve o foco ao fechar.

Construído com

Os componentes gratuitos do HextaUI de que Tool Calls é feito. Cada um é instalado separadamente.

Código

8 arquivos, adicionados a components/blocks/tool-calls.