Tool Calls

Muestra lo que hace un agente, una línea por paso. Las lecturas y búsquedas se pliegan en un resumen breve, mientras que las ediciones, comandos, aprobaciones y errores permanecen a la vista. Cada paso se abre en una vista real: el archivo, el diff, la terminal o los resultados. Cubre todos los estados de herramienta de AI SDK, incluidas las aprobaciones con un motivo para dar otra indicación.

Los agentes pueden dar docenas de pequeños pasos antes de responder. Mostrarlos todos entierra la respuesta, y ocultarlos hace que el agente parezca una caja negra. Tool Calls da a cada paso una línea discreta que se lee como una frase, como «Read components/search.tsx» o «Searched for useResults, 3 results».

Las lecturas, búsquedas y consultas que se ejecutan una tras otra se pliegan en un único resumen como «Explored 6 files». Todo lo que cambia algo, necesita una decisión o falla permanece en su propia línea, con un temporizador en vivo mientras se ejecuta. Abre cualquier paso para ver el trabajo real: el archivo, el diff, la terminal o los resultados.

Cada estado de herramienta de AI SDK tiene su propio aspecto y redacción, incluidas las aprobaciones. Aprobar ofrece Allow, Deny, Always allow y «Tell it what to do instead», que devuelve tus palabras como motivo, con ⌘↵ y ⌘⌫ desde el teclado. getToolPartStatus asigna las partes de herramienta por ti, y hay vistas listas para resultados de búsqueda, listas de archivos, preguntas de opción múltiple y JSON sin procesar.

  1. Añade el registro Pro a components.json

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

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  3. Añade el bloque

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

Con AI SDK

Convierte las partes de herramienta en pasos con getToolPartStatus y elige un kind y una vista por herramienta. Pasa stopped cuando el chat deja de transmitir, para que una llamada cortada a medias muestre Cancelled en lugar de girar para siempre.

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

Aprobación de comandos

Marca una herramienta con needsApproval en el servidor y responde con addToolApprovalResponse. Always allow recuerda el programa, y una denegación puede llevar como motivo qué hacer en su lugar. ⌘↵ y ⌘⌫ funcionan mientras el paso tiene el 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"}
            />
          }
        />
      )
    })
  )
}

Preguntar al usuario

Una herramienta de cliente sin función execute espera una respuesta. Muestra las opciones con AskUser y envía la elegida de vuelta con 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,
                })
              }
            />
          }
        />
      )
    })
  )
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
ToolCallsLa lista. Agrupa las ejecuciones silenciosas y comparte los tiempos entre pasos.
ToolCallUn paso: icono, frase, meta, temporizador y su contenido al abrirse.
ToolGroupUna tanda plegada de pasos discretos con una línea de resumen.
ToolApprovalLa tarjeta de aprobar, denegar y redirigir, que se muestra dentro de un paso a la espera de aprobación.
SearchResults, FileList, AskUser, ToolJsonVistas para pasar como contenido de un paso.

ToolCalls

También acepta todas las props de ol excepto children.

PropTipoPredeterminado
callsLos pasos en el orden en que se ejecutaron.
ToolCallProps[]–
groupPliega las tandas de pasos discretos. Establécelo en false para mostrar todos los pasos.
booleantrue
PropTipoPredeterminado
idId estable, normalmente el toolCallId. El tiempo se guarda por id.
string–
statusstreaming, running, waiting, approval, done, error, denied o cancelled.
ToolStatus–
kindread, search, list, edit, write, run, web, fetch, ask u other. Elige el icono y el verbo.
ToolKind"other"
subjectSobre qué actuó: una ruta, consulta, URL o comando.
string–
nameNombre de la herramienta, usado en la frase cuando kind es other.
string–
titleSustituye por completo la frase generada.
string–
metaResultado breve tras la frase, como «3 results» o «+12 −3».
ReactNode–
contentSe muestra cuando se abre el paso.
ReactNode–
exitCodePara pasos de ejecución. Un valor distinto de cero marca el paso como fallido y lo abre.
number–
errorMensaje mostrado para el estado de error.
string–
durationDuración almacenada en segundos, para el historial.
number–
approvalMotivo, resultado y etiqueta de siempre permitir para las aprobaciones.
ToolCallApproval–
defaultOpenSustituye si el paso empieza abierto.
boolean–
onApproveSe llama desde Allow o Always allow.
(options: { always: boolean }) => void–
onDenySe llama desde Deny, o con el texto de «Tell it what to do instead».
(reason?: string) => void–
PropTipoPredeterminado
reasonPor qué se necesita aprobación, que se muestra sobre los botones.
string–
approvedLa respuesta, una vez dada.
boolean–
automaticAprobado por una regla, así que no se muestra ninguna tarjeta.
boolean–
denialReasonLo que la persona pidió en su lugar, que se muestra en los pasos denegados.
string–
alwaysLabelMuestra «Always allow …» con esta etiqueta, por ejemplo el nombre del programa.
string–

getToolPartStatus(part, options)

Asigna una parte de herramienta de AI SDK a un ToolStatus.

PropTipoPredeterminado
partLa parte de message.parts.
ToolUIPart | DynamicToolUIPart–
options.stoppedEl chat se detuvo, así que las llamadas sin terminar muestran Cancelled en lugar de seguir girando.
booleanfalse
options.waitingUna herramienta de cliente está esperando a la persona, por ejemplo AskUser.
booleanfalse
PropTipoPredeterminado
questionLa pregunta.
string–
optionsLas opciones.
{ value, label, description? }[]–
answerEl valor elegido, una vez respondido.
string–
onAnswerDevuélvelo con addToolOutput.
(value: string) => void–
PropTipoPredeterminado
queryResaltado dentro de cada coincidencia.
string–
matchesLas coincidencias.
{ path, line, text }[]–
limitSe muestra antes de «Show all».
number6
KeyAcción
EnterSpaceAbre o cierra un paso o un grupo plegado.
⌘↵Permite un paso que espera aprobación mientras el foco está dentro de él.
⌘⌫Deniega un paso que espera aprobación mientras el foco está dentro de él.
EscSale de «Tell it what to do instead» sin enviar.
  • Los pasos son una lista ordenada, así que los lectores de pantalla anuncian cuántos pasos hay y en cuál estás.
  • Los pasos que requieren a la persona se anuncian mediante una región activa polite, como “Approval needed: Run pnpm test”, igual que las preguntas y los errores. Los pasos rutinarios permanecen en silencio para no saturar a los lectores de pantalla.
  • El estado nunca depende solo del color: cada estado tiene su propio icono y redacción.
  • Los atajos de aprobación se ignoran mientras se escribe en un campo, y el campo de redirección se enfoca solo al abrirse y devuelve el foco al cerrarse.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Tool Calls. Cada uno se instala por separado.

Código

8 archivos, añadidos a components/blocks/tool-calls.