# Tool Chips

> List the tool calls in an agent run, what each one returned, and the files the run changed.

Source: https://ai.honestui.com/docs/components/tool-chips

```tsx
import {
  FileText as FileTextIcon,
  Pencil as PencilIcon,
  Terminal as TerminalIcon,
} from "honestui/icons"

import { ToolChips, type FileChange, type ToolCall } from "@/components/ai/tool-chips"

// Sample run.
const sampleCalls: ToolCall[] = [
  {
    id: "read",
    label: "Read",
    detail: "export.ts",
    detailIsCode: true,
    icon: <FileTextIcon />,
    output: [
      { text: "148 lines. Totals are rounded per row, before they are summed." },
    ],
  },
  {
    id: "edit",
    label: "Edit",
    detail: "export.ts",
    detailIsCode: true,
    icon: <PencilIcon />,
    outputIsCode: true,
    output: [
      { text: "const total = rows.reduce(sum, 0)", kind: "context" },
      { text: "return rows.map(roundRow)", kind: "removed" },
      { text: "return roundTotal(total)", kind: "added" },
    ],
  },
  {
    id: "test",
    label: "Run",
    detail: "npm test -- export",
    detailIsCode: true,
    icon: <TerminalIcon />,
    outputIsCode: true,
    output: [{ text: "14 passed, 0 failed" }],
  },
]

const sampleChanges: FileChange[] = [
  {
    path: "export.ts",
    added: 6,
    removed: 4,
    lines: [
      { text: "const total = rows.reduce(sum, 0)", kind: "context" },
      { text: "return rows.map(roundRow)", kind: "removed" },
      { text: "return roundTotal(total)", kind: "added" },
    ],
  },
  {
    path: "export.test.ts",
    added: 22,
    removed: 0,
    lines: [
      { text: "it(\"rounds the total once\", () => {", kind: "added" },
      { text: "  expect(exportTotal(rows)).toBe(10.01)", kind: "added" },
      { text: "})", kind: "added" },
    ],
  },
  { path: "CHANGELOG.md", added: 1, removed: 0 },
  { path: "rounding.ts", added: 9, removed: 0 },
]

export function ToolChipsDemo() {
  return (
    <div className="w-full max-w-sm min-w-0">
      <ToolChips summary="3 tool calls" calls={sampleCalls} changes={sampleChanges} />
    </div>
  )
}

```

## Overview [#overview]

Tool Chips is a compact record of a run. A summary line opens a list of calls. Each call shows what the tool did and what it acted on, and a call with output opens to show it. Under the calls, one chip for each changed file shows how many lines were added and removed, with the diff a hover or a press away.

Tool Chips shows whatever is in `calls` and `changes`. Append a call when your agent starts one, and update its `status` when it settles. Nothing is revealed on a timer.

Use [Thinking State](/docs/components/thinking-state) instead when the run is reasoning or search with little tool output.

## Anatomy [#anatomy]

<Anatomy title="ToolChips">
  <AnatomyItem name="Summary" description="A button that opens and closes the run" />

  <AnatomyItem name="Calls" description="A list with one row for each tool call">
    <AnatomyItem name="Label" description="What the tool did" />

    <AnatomyItem name="Detail" description="The file, command, or note it acted on" />

    <AnatomyItem name="Output" description="What the call returned, behind a disclosure" />
  </AnatomyItem>

  <AnatomyItem name="Changes" description="One chip for each changed file, with its diff in a popover" />
</Anatomy>

`ToolChips` is one component that takes data. Installing `tool-chips` brings [Collapsible](https://www.honestui.com/docs/components/collapsible) and [Popover](https://www.honestui.com/docs/components/popover) along as registry dependencies.

## Behavior [#behavior]

### Calls [#calls]

A call's `status` is `running`, `done`, or `error`, and defaults to done. A running call shows a spinner in place of its icon. A failed call shows an alert icon in the danger color. A call with `output` becomes a button that opens the output. A call without output is plain text.

### Output and diff lines [#output-and-diff-lines]

Each line has a `kind`: `context`, `added`, or `removed`. When any line in a block is added or removed, every line in the block starts with a sign, and changed lines sit on a tint. A change is marked by a character as well as by color.

### File changes [#file-changes]

`maxVisibleChanges` chips are shown, three by default. The rest wait behind a "Show 2 more" button, which reveals them all. A change with `lines` is a button that opens its diff in a popover, on hover or on a press. A change without lines is a plain chip.

## Accessibility [#accessibility]

The summary and every call with output are buttons that report whether they are expanded. The calls are a list. The file chips are a group named "Files changed".

A running or failed call has its status in words after its label, read only by screen readers. A file chip reads its counts as "6 added" and "4 removed" in place of the bare signs. All of these strings come from `labels`.

The diff popover can be opened and closed from the keyboard, and it takes its name from the file. Icons are decorative. With reduced motion, the spinner does not rotate and new rows appear without fading.

Tool Chips does not announce new calls as they arrive. If the person needs to hear progress, announce it from your own status region, at a pace that is not one message per call.

| Key                                  | Action                                                             |
| ------------------------------------ | ------------------------------------------------------------------ |
| <kbd>Tab</kbd>                       | Move across the summary, the calls with output, and the file chips |
| <kbd>Enter</kbd> or <kbd>Space</kbd> | Open or close the focused summary, call, or diff                   |
| <kbd>Escape</kbd>                    | Close an open diff                                                 |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/tool-chips&#x22;]" />
  

  
    
      
        Add the components it is built from.
      

      <CliBlock commands="[&#x22;collapsible&#x22;, &#x22;popover&#x22;]" />

      
        Copy and paste this file into your project.
      

      ### components/ai/tool-chips.tsx

```tsx
"use client"

import * as React from "react"
import {
  ChevronDown as ChevronDownIcon,
  CircleAlert as CircleAlertIcon,
  LoaderCircle as LoaderCircleIcon,
  Wrench as WrenchIcon,
} from "honestui/icons"

import { cn } from "@/lib/utils"
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"

export type ToolLine = {
  text: string
  /** Defaults to "context". */
  kind?: "context" | "added" | "removed"
}

export type ToolCall = {
  id: string
  /** What the tool did, such as "Read image" or "Write 204 lines". */
  label: string
  /** The object of the call: a file name, a command, or a short note. */
  detail?: string
  /** Sets `detail` in the monospace face. */
  detailIsCode?: boolean
  /** Defaults to "done". */
  status?: "running" | "done" | "error"
  icon?: React.ReactNode
  /** What the call returned. A call with output can be expanded. */
  output?: ToolLine[]
  /** Sets `output` in the monospace face. */
  outputIsCode?: boolean
}

export type FileChange = {
  path: string
  added: number
  removed: number
  /** The diff. A change with lines shows them on hover, focus, or press. */
  lines?: ToolLine[]
}

export type ToolChipsLabels = {
  running: string
  failed: string
  changes: string
  showMoreChanges: (hiddenCount: number) => string
  diffFor: (path: string) => string
  added: (count: number) => string
  removed: (count: number) => string
}

export const defaultToolChipsLabels: ToolChipsLabels = {
  running: "Running",
  failed: "Failed",
  changes: "Files changed",
  showMoreChanges: (hiddenCount) => `Show ${hiddenCount} more`,
  diffFor: (path) => `Changes to ${path}`,
  added: (count) => `${count} added`,
  removed: (count) => `${count} removed`,
}

const rowText =
  "[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

const codeText = "font-mono [font-size:var(--hui-font-size-mono-mini)]"

const interactiveRow =
  "border-0 bg-transparent outline-none motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)]"

const rowEnter =
  "motion-safe:transition-[opacity,translate] motion-safe:duration-300 motion-safe:starting:translate-y-1 motion-safe:starting:opacity-0"

// Changed lines keep the primary text color on a tint. The success and danger
// foregrounds are too light for small text on their own tints.
const lineTone: Record<NonNullable<ToolLine["kind"]>, string> = {
  context: "text-[var(--hui-color-foreground-base-secondary)]",
  added: "bg-[var(--hui-color-background-success-primary)] text-[var(--hui-color-foreground-base-primary)]",
  removed: "bg-[var(--hui-color-background-danger-primary)] text-[var(--hui-color-foreground-base-primary)]",
}

// The `-hover` foregrounds are the theme's darker text step, which small counts need.
const addedCountTone = "text-[var(--hui-color-foreground-success-primary-hover)]"
const removedCountTone = "text-[var(--hui-color-foreground-danger-primary-hover)]"

const lineSign: Record<NonNullable<ToolLine["kind"]>, string> = {
  context: " ",
  added: "+",
  removed: "−",
}

export type ToolChipsProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** The header text, such as "4 tool calls". */
  summary: string
  calls: ToolCall[]
  /** Files the run edited, shown as chips under the calls. */
  changes?: FileChange[]
  /** How many file chips to show before the rest move behind a button. */
  maxVisibleChanges?: number
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  labels?: Partial<ToolChipsLabels>
}

/**
 * An agent run as a list of tool calls, followed by the files it changed.
 * The list shows whatever `calls` holds, so append to the array as calls
 * arrive and update each call's `status` as it settles.
 */
function ToolChips({
  summary,
  calls,
  changes = [],
  maxVisibleChanges = 3,
  open,
  defaultOpen = true,
  onOpenChange,
  labels: labelsProp,
  className,
  ...props
}: ToolChipsProps) {
  const labels = React.useMemo(
    () => ({ ...defaultToolChipsLabels, ...labelsProp }),
    [labelsProp]
  )
  const [showAllChanges, setShowAllChanges] = React.useState(false)
  const visibleChanges = showAllChanges ? changes : changes.slice(0, maxVisibleChanges)
  const hiddenCount = changes.length - visibleChanges.length

  return (
    <div
      data-slot="tool-chips"
      className={cn(
        "flex w-full min-w-0 flex-col text-[var(--hui-color-foreground-base-primary)]",
        rowText,
        className
      )}
      {...props}
    >
      <Collapsible open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
        <CollapsibleTrigger
          data-slot="tool-chips-trigger"
          className={cn(
            "group/summary -mx-[var(--hui-space-2)] flex w-fit max-w-full items-center gap-[var(--hui-space-2)] rounded-[var(--hui-radius-2)] px-[var(--hui-space-2)] py-[var(--hui-space-2)] text-left text-[var(--hui-color-foreground-base-secondary)]",
            interactiveRow
          )}
        >
          <ChevronDownIcon
            aria-hidden="true"
            className="size-3.5 shrink-0 -rotate-90 motion-safe:transition-transform motion-safe:duration-200 group-data-panel-open/summary:rotate-0"
          />
          <span className="min-w-0 truncate">{summary}</span>
        </CollapsibleTrigger>
        <CollapsiblePanel>
          <ul
            role="list"
            data-slot="tool-chips-calls"
            className="m-0 flex list-none flex-col gap-[var(--hui-space-1)] p-0 pt-[var(--hui-space-2)]"
          >
            {calls.map((call) => (
              <ToolCallRow key={call.id} call={call} labels={labels} />
            ))}
          </ul>
          {changes.length > 0 && (
            <div
              role="group"
              aria-label={labels.changes}
              data-slot="tool-chips-changes"
              className="mt-[var(--hui-space-3)] flex min-w-0 flex-wrap items-center gap-[var(--hui-space-2)] border-t border-[var(--hui-color-border-base-primary)] pt-[var(--hui-space-3)]"
            >
              {visibleChanges.map((change) => (
                <FileChangeChip key={change.path} change={change} labels={labels} />
              ))}
              {hiddenCount > 0 && (
                <button
                  type="button"
                  onClick={() => setShowAllChanges(true)}
                  className={cn(
                    "rounded-[var(--hui-radius-2)] px-[var(--hui-space-2)] py-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-secondary)] underline underline-offset-2",
                    interactiveRow
                  )}
                >
                  {labels.showMoreChanges(hiddenCount)}
                </button>
              )}
            </div>
          )}
        </CollapsiblePanel>
      </Collapsible>
    </div>
  )
}

function ToolCallRow({ call, labels }: { call: ToolCall; labels: ToolChipsLabels }) {
  const status = call.status ?? "done"
  const hasOutput = Boolean(call.output?.length)

  const summary = (
    <>
      <span
        aria-hidden="true"
        className={cn(
          "flex size-4 shrink-0 items-center justify-center [&_svg]:size-3.5",
          status === "error"
            ? "text-[var(--hui-color-foreground-danger-primary)]"
            : "text-[var(--hui-color-foreground-base-tertiary)]"
        )}
      >
        {status === "running" ? (
          <LoaderCircleIcon className="motion-safe:animate-spin" />
        ) : status === "error" ? (
          <CircleAlertIcon strokeWidth={1.5} strokeLinecap="round" />
        ) : (
          (call.icon ?? <WrenchIcon />)
        )}
      </span>
      <span className="shrink-0 [font-weight:var(--hui-font-weight-medium)]">
        {call.label}
        {status !== "done" && (
          <span className="sr-only"> ({status === "running" ? labels.running : labels.failed})</span>
        )}
      </span>
      {call.detail && (
        <span
          className={cn(
            "min-w-0 truncate rounded-[var(--hui-radius-2)] bg-[var(--hui-color-background-base-secondary)] px-[var(--hui-space-2)] py-[var(--hui-space-1)] text-[var(--hui-color-foreground-base-secondary)]",
            call.detailIsCode && codeText
          )}
        >
          {call.detail}
        </span>
      )}
    </>
  )

  const rowLayout =
    "flex w-full min-w-0 items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-2)] px-[var(--hui-space-2)] py-[var(--hui-space-2)] text-left"

  if (!hasOutput) {
    return (
      <li data-slot="tool-call" data-status={status} className={cn(rowLayout, rowEnter)}>
        {summary}
      </li>
    )
  }

  return (
    <li data-slot="tool-call" data-status={status} className={cn("min-w-0", rowEnter)}>
      <Collapsible>
        <CollapsibleTrigger className={cn("group/call", rowLayout, interactiveRow)}>
          {summary}
          <ChevronDownIcon
            aria-hidden="true"
            className="ms-auto size-3.5 shrink-0 -rotate-90 text-[var(--hui-color-foreground-base-tertiary)] motion-safe:transition-transform motion-safe:duration-200 group-data-panel-open/call:rotate-0"
          />
        </CollapsibleTrigger>
        <CollapsiblePanel>
          <ToolLines
            lines={call.output ?? []}
            isCode={call.outputIsCode}
            className="ms-[var(--hui-space-4)] mt-[var(--hui-space-1)] mb-[var(--hui-space-2)] border-s border-[var(--hui-color-border-base-primary)] ps-[var(--hui-space-3)]"
          />
        </CollapsiblePanel>
      </Collapsible>
    </li>
  )
}

function ToolLines({
  lines,
  isCode = false,
  className,
}: {
  lines: ToolLine[]
  isCode?: boolean
  className?: string
}) {
  const hasChanges = lines.some((line) => line.kind === "added" || line.kind === "removed")

  return (
    <div className={cn("flex min-w-0 flex-col overflow-x-auto", isCode && codeText, className)}>
      {lines.map((line, index) => {
        const kind = line.kind ?? "context"
        return (
          <div
            key={index}
            className={cn(
              "flex gap-[var(--hui-space-3)] px-[var(--hui-space-2)] [line-height:var(--hui-line-height-regular)]",
              isCode ? "whitespace-pre" : "break-words",
              lineTone[kind]
            )}
          >
            {/* The sign carries the meaning, so the tint is never the only cue. */}
            {hasChanges && <span className="w-[1ch] shrink-0 select-none">{lineSign[kind]}</span>}
            <span className="min-w-0">{line.text}</span>
          </div>
        )
      })}
    </div>
  )
}

function FileChangeChip({ change, labels }: { change: FileChange; labels: ToolChipsLabels }) {
  const chipLayout = cn(
    "inline-flex max-w-full items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-secondary)] bg-[var(--hui-color-background-base-primary)] px-[var(--hui-space-3)] py-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)]",
    codeText
  )
  const counts = (
    <>
      <span className="min-w-0 truncate">{change.path}</span>
      <span className={cn("shrink-0 tabular-nums", addedCountTone)}>
        <span aria-hidden="true">+{change.added}</span>
        <span className="sr-only">{labels.added(change.added)}</span>
      </span>
      {change.removed > 0 && (
        <span className={cn("shrink-0 tabular-nums", removedCountTone)}>
          <span aria-hidden="true">−{change.removed}</span>
          <span className="sr-only">{labels.removed(change.removed)}</span>
        </span>
      )}
    </>
  )

  if (!change.lines?.length) {
    return (
      <span data-slot="tool-chips-change" className={chipLayout}>
        {counts}
      </span>
    )
  }

  return (
    <Popover>
      <PopoverTrigger
        openOnHover
        delay={150}
        data-slot="tool-chips-change"
        className={cn(
          chipLayout,
          "cursor-pointer outline-none motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)] data-popup-open:bg-[var(--hui-color-background-base-primary-hover)]"
        )}
      >
        {counts}
      </PopoverTrigger>
      <PopoverPopup
        align="start"
        aria-label={labels.diffFor(change.path)}
        className="w-80 max-w-[calc(100vw-2rem)] p-0"
      >
        <ToolLines lines={change.lines} isCode className="py-[var(--hui-space-2)]" />
      </PopoverPopup>
    </Popover>
  )
}

export { ToolChips }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

Map your agent's tool events onto `calls`, and its edits onto `changes`:

```tsx
import { ToolChips, type ToolCall } from "@/components/ai/tool-chips"

const calls: ToolCall[] = run.toolCalls.map((call) => ({
  id: call.id,
  label: call.title,
  detail: call.target,
  detailIsCode: true,
  status: call.state,
  output: call.result?.lines,
}))

<ToolChips summary={`${calls.length} tool calls`} calls={calls} changes={run.fileChanges} />
```

Write the summary yourself, so it can say what matters in your product: "3 tool calls, 1 failed" is more useful than a count.

## Examples [#examples]

### A run in progress [#a-run-in-progress]

This preview adds one call each second and marks the last one failed. The failed call opens to show the error it returned.

```tsx
"use client"

import * as React from "react"

import { ToolChips, type ToolCall } from "@/components/ai/tool-chips"
import { Button } from "@/components/ui/button"

// Sample run. The last call fails on purpose.
const sampleCalls: ToolCall[] = [
  { id: "read", label: "Read", detail: "schema.sql", detailIsCode: true },
  { id: "write", label: "Write", detail: "0042_add_region.sql", detailIsCode: true },
  {
    id: "migrate",
    label: "Run",
    detail: "npm run migrate",
    detailIsCode: true,
    outputIsCode: true,
    output: [{ text: "error: column \"region\" already exists" }],
  },
]

const CALL_MILLISECONDS = 1000

/**
 * No tools run behind this preview. A timer adds one call at a time, and the
 * last one is marked failed, to show how the list reads while a run is live.
 */
export function ToolChipsRunningExample() {
  const [settled, setSettled] = React.useState(0)
  const isRunning = settled < sampleCalls.length

  React.useEffect(() => {
    if (!isRunning) return
    const timer = window.setTimeout(() => setSettled((count) => count + 1), CALL_MILLISECONDS)
    return () => window.clearTimeout(timer)
  }, [settled, isRunning])

  const calls = sampleCalls.slice(0, settled + 1).map((call, index): ToolCall => {
    if (index >= settled) return { ...call, status: "running", output: undefined }
    return { ...call, status: call.id === "migrate" ? "error" : "done" }
  })

  return (
    <div className="flex w-full max-w-sm min-w-0 flex-col items-start gap-[var(--hui-space-5)]">
      <ToolChips
        summary={isRunning ? `Running call ${calls.length} of ${sampleCalls.length}` : "3 tool calls, 1 failed"}
        calls={calls}
      />
      <Button variant="secondary" size="sm" disabled={isRunning} onClick={() => setSettled(0)}>
        Run the sample again
      </Button>
    </div>
  )
}

```

## API reference [#api-reference]

### ToolChips [#toolchips]

Accepts native div props beyond those listed.

| Prop                | Type                       | Default  | Description                                                                |
| ------------------- | -------------------------- | -------- | -------------------------------------------------------------------------- |
| `summary`           | string                     | Required | The header text.                                                           |
| `calls`             | `ToolCall[]`               | Required | The tool calls, in order.                                                  |
| `changes`           | `FileChange[]`             | `[]`     | The files the run edited.                                                  |
| `maxVisibleChanges` | number                     | `3`      | How many file chips to show before the rest move behind a button.          |
| `open`              | boolean                    | None     | Controls whether the run is shown.                                         |
| `defaultOpen`       | boolean                    | `true`   | The initial open state.                                                    |
| `onOpenChange`      | `(open: boolean) => void`  | None     | Runs when the person opens or closes the run.                              |
| `labels`            | `Partial<ToolChipsLabels>` | English  | The status words, the group name, and the names read for counts and diffs. |

### Types [#types]

| Export            | Shape                                                                                            |
| ----------------- | ------------------------------------------------------------------------------------------------ |
| `ToolCall`        | `id`, `label`, and optional `detail`, `detailIsCode`, `status`, `icon`, `output`, `outputIsCode` |
| `FileChange`      | `path`, `added`, `removed`, and optional `lines`                                                 |
| `ToolLine`        | `text`, and optional `kind` of `"context"`, `"added"`, or `"removed"`                            |
| `ToolChipsLabels` | `running`, `failed`, `changes`, `showMoreChanges`, `diffFor`, `added`, `removed`                 |
