Tool chips
NewAn agent’s turn folded into compact rows of tool calls, and the files it changed as chips that open their diff.
tool-chips/tool-chips.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Showing what an agent did in one turn (thoughts, edits, commands, reads) without burying its answer.
- A chat or a side panel where the steps matter to some readers and not to others: folded by default, one click away.
- Turns that change files, where a chip per file with its
+added −removedcounts says how big the change was. - A quick look at a change before opening it: a file chip shows a short excerpt of its diff in a popover.
When not to use
- A run still in progress, step by step. Use a live progress list that shows the current step.
- Reviewing a whole change. The popover is an excerpt; link to a real diff view instead.
- One or two steps that are the answer itself. Say them in the message rather than folding them away.
- A log of many turns or hundreds of calls. Use a searchable log or a timeline.
You can add this component with the shadcn CLI or by hand:
Into components/tool-chips of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/tool-chips.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { ToolChips } from './tool-chips';export function Turn({ turn }: { turn: AgentTurn }) { return ( <ToolChips calls={turn.calls} files={turn.files} messages={turn.messages.length} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
What an agent did in one turn, in a few quiet lines. A header counts the tool calls and opens onto a row for each: a glyph, a few words and a chip with the file, the command or the first words of a thought. Open a row for the lines behind it. Under a hairline, the files the turn changed count their lines added and removed, and open an excerpt of their diff. On screen the turn plays in a row at a time, then the file chips pop in.
- The group and every row fold by animating grid-template-rows between 0fr and 1fr, the content fading along: 300 ms, with no height ever measured. Folded content is inert, so focus never lands in it.
- On a row, the glyph fades out for a chevron on hover (100 and 150 ms); once the row is open the chevron stays, turned down.
- A file chip opens its diff in a popover, through the Popover API: it lives in the top layer, so no card or scroller clips it, and a click outside or Escape closes it with no script of its own. It is placed under the chip as it opens (or above it when the window has no room below), kept inside the window, and follows the chip while the page scrolls.
- The popover pops in from the chip’s corner in 160 ms, a 0.95 scale and a fade, and closes at once.
- While on screen, the turn plays in once: a row every 700 ms, each rising 8 px as it fades in, then the file chips pop in 80 ms apart and the “+2 more” link fades in after them.
- After that a demo pointer opens two rows, a diff, and folds the group and opens it again, hovers included; any input of the reader’s hands it over and brings the whole turn in at once.
- Every colour is a light-dark() token on the root. Shadows that differ between the schemes are written once with all their layers, the layers a scheme does not use being transparent: a long soft shadow by day, a single deep one at night.
| Prop | Type | Default | Description |
|---|---|---|---|
calls | ToolCall[] | a sample turn | The turn’s tool calls, a row each: { id, icon: thinking | write | command | read, label, chip, code?, details?, tone?: text | code | added }. |
files | FileChange[] | a sample turn | The files it changed, a chip each: { path, added, removed?, lines }, the lines being the excerpt of the diff its popover shows. |
summary | string | — | The group’s header. By default it counts the tool calls and the messages. |
messages | number | 2 | How many messages the header counts; 0 leaves them out. |
visibleFiles | number | 3 | How many file chips show before a “+N more” link brings in the rest. |
defaultOpen | boolean | true | Whether the group starts open. |
defaultOpenRows | string[] | — | The rows (by call id) that start open. Setting it turns the play-in and the demo off. |
defaultAllFiles | boolean | — | Whether every file chip starts shown, past “+N more”. Setting it turns the play-in and the demo off. |
onOpenFile | (path: string | null) => void | — | Called when a file’s diff opens (with its path) or closes (null). |
autoplay | boolean | true | While on screen and untouched, the turn plays in a row at a time, then a demo pointer opens rows and a diff. Without it, the turn is all there from the start. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties, type ToggleEvent,} from 'react';import { CALLS, FILES, type FileChange, type ToolCall, type ToolIcon } from './data';import { DemoPointer, runDemo, type Demo } from './demo-pointer';import { useAutoplay } from './use-autoplay';import styles from './tool-chips.module.css';export type { DiffLine, FileChange, ToolCall, ToolIcon } from './data';/* * Tool chips: what an agent did in one turn, folded into a few compact lines. A group header * ("4 tool calls, 2 messages") opens onto one row per tool call, each a glyph, a few words and a * chip (the file, the command, or the first words of a thought), and under them the files the turn/* Tool chips (tool-chips.tsx): a soft grey card, and in its middle a column 320 px wide at most: the group's header, its rows, and the file chips under a hairline. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Shadows that differ between the schemes are written once with every layer, the layers a scheme does not use being transparent. Motion. The folds animate grid-template-rows between 0fr and 1fr, so nothing is measured; rows rise in by a transform and the chips pop in by a scale. Under reduced motion all of it is instant. The diffs' popovers live in the top layer: they inherit these tokens from the root, their parent in the document, whatever is drawn around the card. */.root { /* The card, and the ink in three strengths. */ --canvas: oklch(96.1% 0.002 247.84); --ink: oklch(24.7% 0.006 258.361); --ink-2: oklch(50.6% 0.01 264.477); --ink-3: oklch(69.5% 0.009 264.505); /* Hairlines: the card's edge, the row chips' and the rule above the files. */ --line: oklch(94.6% 0.003 264.542); /* A row's chip; the file chips and the popovers; their hovers. */ --field: oklch(96.1% 0.001 286.375); --surface: oklch(100% 0 0);// Copyright (c) 2026 Bruno Delorence. MIT License./* * What the chips show: the steps of an agent's run (tool calls) and the files it changed. The * sample is an ice-cream shop's code, planning which flavours to churn and when. *//** The glyph at the start of a row. */export type ToolIcon = 'thinking' | 'write' | 'command' | 'read';export type ToolCall = { id: string; icon: ToolIcon; /** What the step did, in a few words: "Write 204 lines". */ label: string; /** Its chip: a file, a command, or the first words of a thought. */ chip: string; /** Sets the chip in monospace, as a file name or a command. */ code?: boolean; /** The lines the row opens onto. */ details?: string[]; /** How they read: prose, output in monospace, or lines added to a file. */ tone?: 'text' | 'code' | 'added';};'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide * the pointer onto a control, press it and call the component's own handler, as a click would. * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once: * its promise rejects, which ends the script right there, and the pointer fades out. * * The pointer is an HTML box around a still drawing: it glides by an animation the compositor * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a * layout. */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>; /** Presses and lets go; `action` runs as the pointer goes down. */ press: (action: () => void) => Promise<void>;'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The header and each row are buttons with aria-expanded and aria-controls; folded content is inert, so it is neither focused nor read.
- Each file chip is a button named “Show diff for …” that opens its popover (role dialog, named after the file); Escape or a click outside closes it and focus stays on the chip.
- In a diff, added and removed lines say so to screen readers; the + and − signs are hidden from them.
- Glyphs and chevrons are decorative (aria-hidden). Focus shows a ring on every control.
- Under reduced motion the turn is all there at once, folds and popovers open instantly and the demo never plays.