Tool Calls

Montrez ce que fait un agent, une ligne par étape. Les lectures et recherches se replient en un court résumé, tandis que les modifications, commandes, approbations et erreurs restent visibles. Chaque étape s’ouvre sur une vraie vue : le fichier, le diff, le terminal ou les résultats. Tous les états d’outil d’AI SDK sont couverts, y compris les approbations avec un motif à donner en échange.

Les agents peuvent faire des dizaines de petites étapes avant de répondre. Les montrer toutes noie la réponse, et les cacher fait de l’agent une boîte noire. Tool Calls donne à chaque étape une ligne discrète qui se lit comme une phrase, comme « Read components/search.tsx » ou « Searched for useResults, 3 results ».

Les lectures, recherches et consultations enchaînées se replient en un seul résumé comme « Explored 6 files ». Tout ce qui modifie quelque chose, exige une décision ou échoue reste sur sa propre ligne, avec un minuteur en direct pendant l’exécution. Ouvrez n’importe quelle étape pour voir le vrai travail : le fichier, le diff, le terminal ou les résultats.

Chaque état d’outil d’AI SDK a son propre aspect et sa formulation, approbations comprises. L’approbation propose Allow, Deny, Always allow et « Tell it what to do instead », qui renvoie vos mots comme motif, avec ⌘↵ et ⌘⌫ au clavier. getToolPartStatus associe les parts d’outil pour vous, et des vues prêtes à l’emploi couvrent les résultats de recherche, les listes de fichiers, les questions à choix multiples et le JSON brut.

  1. Ajouter le registre Pro à components.json

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

    Créez un token sur votre page de compte et placez-le dans .env.local sous le nom HEXTAUI_PRO_TOKEN.

  3. Ajouter le block

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

Avec AI SDK

Transformez les parties d’outil en étapes avec getToolPartStatus et choisissez un kind et une vue pour chaque outil. Passez stopped dès que la conversation cesse de diffuser, afin qu’un appel interrompu en cours de route affiche Cancelled au lieu de tourner indéfiniment.

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

Approuver des commandes

Marquez un outil avec needsApproval côté serveur et répondez avec addToolApprovalResponse. Always allow mémorise le programme, et un refus peut porter ce qu’il faut faire à la place comme motif. ⌘↵ et ⌘⌫ fonctionnent tant que l’étape a le 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"}
            />
          }
        />
      )
    })
  )
}

Interroger l’utilisateur

Un outil côté client sans fonction execute attend une réponse. Affichez les choix avec AskUser et renvoyez le choix avec 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,
                })
              }
            />
          }
        />
      )
    })
  )
}

Anatomie

Les parties à composer, de l’extérieur vers l’intérieur.

PartieDescription
ToolCallsLa liste. Regroupe les exécutions discrètes et partage le temps entre les étapes.
ToolCallUne étape : icône, phrase, méta, minuteur, et son contenu à l’ouverture.
ToolGroupUne série repliée d’étapes discrètes avec une ligne de résumé.
ToolApprovalLa carte d’approbation, de refus et de redirection, affichée dans une étape en attente d’approbation.
SearchResults, FileList, AskUser, ToolJsonVues à passer comme contenu d’une étape.

ToolCalls

Accepte aussi toutes les props de ol sauf children.

PropTypePar défaut
callsLes étapes dans l’ordre où elles se sont exécutées.
ToolCallProps[]–
groupReplie les séries d’étapes discrètes. Mettez false pour afficher chaque étape.
booleantrue
PropTypePar défaut
idId stable, généralement le toolCallId. Le chronométrage est conservé par id.
string–
statusstreaming, running, waiting, approval, done, error, denied ou cancelled.
ToolStatus–
kindread, search, list, edit, write, run, web, fetch, ask ou other. Détermine l’icône et le verbe.
ToolKind"other"
subjectCe sur quoi elle a agi : un chemin, une requête, une URL ou une commande.
string–
nameNom de l’outil, utilisé pour la phrase lorsque kind vaut other.
string–
titleRemplace entièrement la phrase générée.
string–
metaCourt résultat après la phrase, comme « 3 results » ou « +12 −3 ».
ReactNode–
contentAffiché quand l’étape est ouverte.
ReactNode–
exitCodePour les étapes d’exécution. Une valeur non nulle marque l’étape comme échouée et l’ouvre.
number–
errorMessage affiché pour le statut d’erreur.
string–
durationDurée enregistrée en secondes, pour l’historique.
number–
approvalMotif, résultat et libellé de Always allow pour les approbations.
ToolCallApproval–
defaultOpenRemplace le fait que l’étape démarre ouverte ou non.
boolean–
onApproveAppelé depuis Allow ou Always allow.
(options: { always: boolean }) => void–
onDenyAppelé depuis Deny, ou avec le texte de « Tell it what to do instead ».
(reason?: string) => void–
PropTypePar défaut
reasonLa raison pour laquelle une approbation est requise, affichée au-dessus des boutons.
string–
approvedLa réponse, une fois donnée.
boolean–
automaticApprouvé par une règle, donc aucune carte n’est affichée.
boolean–
denialReasonCe que la personne a demandé à la place, affiché sur les étapes refusées.
string–
alwaysLabelAffiche « Always allow … » avec ce libellé, par exemple le nom du programme.
string–

getToolPartStatus(part, options)

Associe une part d’outil AI SDK à un ToolStatus.

PropTypePar défaut
partLa partie issue de message.parts.
ToolUIPart | DynamicToolUIPart–
options.stoppedLa conversation s’est arrêtée : les appels inachevés affichent Cancelled au lieu de tourner indéfiniment.
booleanfalse
options.waitingUn outil côté client attend la personne, par exemple AskUser.
booleanfalse
PropTypePar défaut
questionLa question.
string–
optionsLes choix.
{ value, label, description? }[]–
answerLa valeur choisie, une fois la réponse donnée.
string–
onAnswerRenvoyez-le avec addToolOutput.
(value: string) => void–
PropTypePar défaut
queryMis en évidence à l’intérieur de chaque correspondance.
string–
matchesLes correspondances.
{ path, line, text }[]–
limitAffiché avant « Show all ».
number6
ToucheAction
EnterSpaceOuvre ou ferme une étape ou un groupe replié.
⌘↵Autorise une étape en attente d’approbation tant que le focus est à l’intérieur.
⌘⌫Refuse une étape en attente d’approbation tant que le focus est à l’intérieur.
EscQuitte « Tell it what to do instead » sans envoyer.
  • Les étapes forment une liste ordonnée : les lecteurs d’écran annoncent leur nombre et la position de chacune.
  • Les étapes qui nécessitent une personne sont annoncées via une région live polie, par exemple « Approbation requise : Run pnpm test », de même que les questions et les erreurs. Les étapes courantes restent silencieuses pour ne pas submerger les lecteurs d’écran.
  • Le statut ne repose jamais sur la couleur seule : chaque état a sa propre icône et sa formulation.
  • Les raccourcis d’approbation sont ignorés pendant la saisie dans un champ, et le champ de redirection prend le focus à son ouverture et le rend à sa fermeture.

Construit avec

Les composants HextaUI gratuits dont Tool Calls est constitué. Chacun s’installe séparément.

Code

8 fichiers, ajoutés à components/blocks/tool-calls.