---
/**
* ToolChips: an agent run as compact tool-call rows, then file-diff chips.
*
* Rows arrive one at a time (STEP_MS apart) and expand to show what the tool
* did. Hover, focus or click a diff chip to preview its diff in a popover
* that stays in the viewport (Esc closes it).
*
* Events (bubbling, on the root):
* vs-tool-chips:toggle-row detail { label, open }
* vs-tool-chips:open-change detail { open }
*
* Adapted from Beautiful UI ToolChips (MIT, © 2026 Shane Levine).
*
* @example
* <ToolChips steps={steps} diffs={diffs} header="3 tool calls" />
*/
import type { HTMLAttributes } from "astro/types";
export type ToolIcon = "think" | "write" | "run" | "read" | "search";
export interface ToolDetailLine {
text: string;
tone?: "add";
}
export interface ToolStep {
icon: ToolIcon;
label: string;
/** The inline chip: a file, command or one-line summary. */
chip: string;
/** Set the chip in mono. */
mono?: boolean;
/** Set the expanded detail in mono. */
detailMono?: boolean;
detail: ToolDetailLine[];
}
export interface ToolDiffLine {
text: string;
tone: "add" | "del" | "ctx";
}
export interface ToolDiff {
file: string;
add: number;
del: number;
/** Lines shown in the hover preview. */
lines?: ToolDiffLine[];
}
type Props = HTMLAttributes<"div"> & {
steps: ToolStep[];
diffs?: ToolDiff[];
/** Run header. Defaults to "N tool calls". */
header?: string;
/** Trailing note after the diff chips, e.g. "+2 more". */
more?: string;
/** Start with the run expanded. */
open?: boolean;
/** Play when scrolled into view. */
autoplay?: boolean;
/** Replay after a pause, never while someone is using it. */
loop?: boolean;
};
const {
steps = [],
diffs = [],
header = `${steps.length} tool call${steps.length === 1 ? "" : "s"}`,
more,
open = true,
autoplay = true,
loop = false,
class: className,
...rest
} = Astro.props;
const ICONS: Record<ToolIcon, string> = {
think: '<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" fill="currentColor" stroke="none"/>',
write: '<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/>',
run: '<path d="M4 17l6-5-6-5M12 19h8"/>',
read: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
};
const uid = `vs-tool-${Math.random().toString(36).slice(2, 8)}`;
---
<div
class:list={["vs-tool-chips", className]}
data-autoplay={autoplay ? "" : undefined}
data-loop={loop ? "" : undefined}
data-open={open ? "" : undefined}
{...rest}
>
<button type="button" class="run-header" aria-expanded={open ? "true" : "false"} aria-controls={`${uid}-run`} data-run-toggle>
<svg class="run-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 9l6 6 6-6"></path>
</svg>
<span>{header}</span>
</button>
<div class="run" id={`${uid}-run`}>
<div class="run-clip" data-run-clip>
<div class="steps">
{steps.map((step, i) => (
<div class="step" data-step>
<button type="button" class="step-row" aria-expanded="false" aria-controls={`${uid}-d${i}`} data-step-toggle={step.label}>
<span class="step-icon" aria-hidden="true">
<svg class="glyph" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={ICONS[step.icon] ?? ICONS.run} />
<svg class="step-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"></path>
</svg>
</span>
<span class="step-label">{step.label}</span>
<span class:list={["step-chip", step.mono && "is-mono"]}>{step.chip}</span>
</button>
<div class="detail" id={`${uid}-d${i}`}>
<div class="detail-clip" data-detail-clip>
<div class:list={["detail-lines", step.detailMono && "is-mono"]}>
{step.detail.map((line) => <span class:list={["detail-line", line.tone === "add" && "is-add"]}>{line.text}</span>)}
</div>
</div>
</div>
</div>
))}
</div>
{(diffs.length > 0 || more) && (
<div class="diffs" data-diffs>
{diffs.map((d, i) => (
<button
type="button"
class="diff-chip"
style={`--vs-tc-i: ${i}`}
aria-label={`Show diff for ${d.file}, ${d.add} added${d.del > 0 ? `, ${d.del} removed` : ""}`}
aria-expanded="false"
aria-controls={d.lines?.length ? `${uid}-p${i}` : undefined}
data-diff-chip={d.lines?.length ? `${uid}-p${i}` : undefined}
>
<span class="diff-file">{d.file}</span>
<span class="diff-add">+{d.add}</span>
{d.del > 0 && <span class="diff-del">−{d.del}</span>}
</button>
))}
{more && <span class="diff-more" style={`--vs-tc-i: ${diffs.length}`}>{more}</span>}
</div>
)}
</div>
</div>
{diffs.map((d, i) =>
d.lines?.length ? (
<div class="preview" id={`${uid}-p${i}`} popover="manual" hidden data-preview>
<div class="preview-head">
<span class="preview-file">{d.file}</span>
<span class="preview-counts">
<span class="diff-add">+{d.add}</span>
{d.del > 0 && <span class="diff-del"> −{d.del}</span>}
</span>
</div>
<div class="preview-body">
{d.lines.map((line) => (
<div class:list={["preview-line", `is-${line.tone}`]}>
<span class="preview-sign" aria-hidden="true">{line.tone === "add" ? "+" : line.tone === "del" ? "−" : " "}</span>
<span class="preview-text">{line.text}</span>
</div>
))}
</div>
</div>
) : null,
)}
</div>
<style>
.vs-tool-chips {
width: 100%;
max-width: 320px;
min-height: 220px;
padding-bottom: 4px;
}
button {
font: inherit;
color: inherit;
}
button:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
/* ── run header ── */
.run-header {
display: flex;
width: fit-content;
align-items: center;
gap: 6px;
margin: 0 -6px;
padding: 4px 6px;
border: none;
border-radius: var(--vs-radius-control);
background: transparent;
color: var(--vs-ink-2);
font-size: 12.5px;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color 100ms ease-out;
}
.run-header:hover {
background: var(--vs-hover-2);
}
.run-chevron {
transform: rotate(-90deg);
transition: transform 200ms ease-out;
}
[data-open] .run-chevron {
transform: rotate(0deg);
}
.run {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows 300ms ease-out,
opacity 300ms ease-out;
}
[data-open] .run {
grid-template-rows: 1fr;
opacity: 1;
}
/* the negative margin + padding keeps content aligned while giving row hover pills room */
.run-clip {
min-height: 0;
margin: 0 -4px;
padding: 0 6px 4px;
overflow: hidden;
}
/* ── tool call rows ── */
.steps {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.step {
animation: vs-fade-up 300ms var(--vs-ease-out) both;
}
.step-row {
display: flex;
width: calc(100% + 6px);
min-width: 0;
height: 28px;
align-items: center;
gap: 8px;
margin: 0 -3px;
padding: 0 3px;
border: none;
border-radius: var(--vs-radius-control);
background: transparent;
text-align: left;
cursor: pointer;
transition: background-color 100ms ease-out;
}
.step-row:hover {
background: var(--vs-hover-2);
}
.step-icon {
position: relative;
display: flex;
width: 16px;
height: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--vs-ink-3);
}
.glyph {
transition: opacity 100ms ease-out;
}
.step-chevron {
position: absolute;
opacity: 0;
transform: rotate(-90deg);
transition:
opacity 150ms ease-out,
transform 150ms ease-out;
}
.step-row:hover .glyph,
.step-row:focus-visible .glyph,
.step-row[aria-expanded="true"] .glyph {
opacity: 0;
}
.step-row:hover .step-chevron,
.step-row:focus-visible .step-chevron,
.step-row[aria-expanded="true"] .step-chevron {
opacity: 1;
}
.step-row[aria-expanded="true"] .step-chevron {
transform: rotate(0deg);
}
.step-label {
flex-shrink: 0;
font-size: 12.5px;
font-weight: 500;
color: var(--vs-ink);
}
.step-chip {
display: block;
min-width: 0;
height: 22px;
flex: 1;
box-sizing: border-box;
padding: 0 6px;
overflow: hidden;
border-radius: var(--vs-radius-chip);
background: var(--vs-field);
box-shadow: var(--vs-shadow-hairline);
color: var(--vs-ink-2);
font-size: 11.5px;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
transition: background-color 100ms ease-out;
}
.step-chip:hover {
background: var(--vs-hover-2);
}
.is-mono {
font-family: var(--vs-font-mono);
}
.detail {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows 300ms var(--vs-ease-out),
opacity 300ms var(--vs-ease-out);
}
.step[data-row-open] .detail {
grid-template-rows: 1fr;
opacity: 1;
}
.detail-clip {
min-height: 0;
overflow: hidden;
}
.detail-lines {
display: flex;
flex-direction: column;
gap: 2px;
margin: 2px 0 4px 8px;
padding: 2px 0 2px 14px;
border-left: 1px solid var(--vs-line);
}
.detail-line {
overflow: hidden;
color: var(--vs-ink-2);
font-size: 11.5px;
line-height: 1.6;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-line.is-add {
color: var(--vs-green);
}
/* ── file-diff chips ── */
.diffs {
display: flex;
max-width: 100%;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--vs-line);
}
.diffs[hidden] {
display: none;
}
.diff-chip {
display: inline-flex;
max-width: 100%;
height: 28px;
align-items: center;
gap: 8px;
padding: 0 8px;
border: none;
border-radius: var(--vs-radius-chip);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-btn);
color: var(--vs-ink);
font-family: var(--vs-font-mono);
font-size: 11.5px;
cursor: default;
transition: background-color 100ms ease-out;
animation: vs-pop-in 250ms var(--vs-ease-out) calc(var(--vs-tc-i) * 80ms) both;
}
.diff-chip[data-diff-chip] {
cursor: pointer;
}
.diff-chip:hover,
.diff-chip[aria-expanded="true"] {
background: var(--vs-hover);
}
.diff-file {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff-add,
.diff-del {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.diff-add {
color: var(--vs-green);
}
.diff-del {
color: var(--vs-red);
}
.diff-more {
display: inline-flex;
height: 28px;
align-items: center;
padding: 0 6px;
color: var(--vs-ink-3);
font-family: var(--vs-font-sans);
font-variant-numeric: tabular-nums;
font-size: 11.5px;
animation: vs-fade-in 300ms ease-out calc(var(--vs-tc-i) * 80ms) both;
}
/* ── diff preview (top layer via the Popover API; fixed fallback) ── */
.preview {
position: fixed;
inset: auto;
z-index: 50;
width: min(288px, calc(100vw - 24px));
box-sizing: border-box;
margin: 0;
padding: 0;
overflow: hidden;
border: none;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-overlay);
color: var(--vs-ink);
animation: vs-pop-in 160ms var(--vs-ease-out) both;
}
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--vs-line);
font-family: var(--vs-font-mono);
font-size: 11px;
}
.preview-file {
min-width: 0;
overflow: hidden;
color: var(--vs-ink-2);
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-counts {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.preview-body {
padding: 4px 0;
font-family: var(--vs-font-mono);
font-size: 11px;
line-height: 1.8;
}
.preview-line {
display: flex;
gap: 8px;
padding: 0 10px;
color: var(--vs-ink-2);
white-space: pre;
}
.preview-line.is-add {
background: var(--vs-green-tint);
color: var(--vs-green);
}
.preview-line.is-del {
background: var(--vs-red-tint);
color: var(--vs-red);
}
.preview-sign {
width: 12px;
flex-shrink: 0;
user-select: none;
}
.preview-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* hide the finished run for the instant before the script rewinds it */
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
.vs-tool-chips[data-autoplay]:not([data-vs-ready]) {
visibility: hidden;
}
}
/* without script the rows can't toggle, so show what each tool did */
@media (scripting: none) {
.run {
grid-template-rows: 1fr;
opacity: 1;
}
.detail {
grid-template-rows: 1fr;
opacity: 1;
}
.run-chevron,
.step-chevron {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.step,
.diff-chip,
.diff-more,
.preview {
animation: none;
}
}
</style>
<script>
const STEP_MS = 700;
const LOOP_HOLD = 4000;
const GAP = 6;
const MARGIN = 12;
/** One pending timeout that can pause and resume where it left off. */
function createTimer() {
let id: number | undefined;
let fn: (() => void) | undefined;
let left = 0;
let due = 0;
let paused = true;
const arm = () => {
due = performance.now() + left;
id = window.setTimeout(() => {
const run = fn;
id = fn = undefined;
run?.();
}, left);
};
return {
set(ms: number, next: () => void) {
window.clearTimeout(id);
id = undefined;
fn = next;
left = ms;
if (!paused) arm();
},
pause() {
if (paused) return;
paused = true;
if (id === undefined) return;
window.clearTimeout(id);
id = undefined;
left = Math.max(0, due - performance.now());
},
resume() {
if (!paused) return;
paused = false;
if (fn) arm();
},
clear() {
window.clearTimeout(id);
id = fn = undefined;
},
};
}
const supportsPopover = typeof HTMLElement !== "undefined" && "showPopover" in HTMLElement.prototype;
function init(root: HTMLElement) {
const runToggle = root.querySelector<HTMLButtonElement>("[data-run-toggle]")!;
const runClip = root.querySelector<HTMLElement>("[data-run-clip]")!;
const stepEls = [...root.querySelectorAll<HTMLElement>("[data-step]")];
const diffs = root.querySelector<HTMLElement>("[data-diffs]");
const chips = [...root.querySelectorAll<HTMLButtonElement>("[data-diff-chip]")];
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const loop = root.hasAttribute("data-loop");
const timer = createTimer();
const total = stepEls.length + 1; // rows, then the diff chips
let step = total;
let touched = false;
const emit = (name: string, detail: unknown) =>
root.dispatchEvent(new CustomEvent(`vs-tool-chips:${name}`, { bubbles: true, detail }));
/* ── run + rows ── */
const setRunOpen = (open: boolean) => {
root.toggleAttribute("data-open", open);
runToggle.setAttribute("aria-expanded", String(open));
runClip.inert = !open;
};
const setRowOpen = (stepEl: HTMLElement, open: boolean) => {
stepEl.toggleAttribute("data-row-open", open);
stepEl.querySelector("[data-step-toggle]")!.setAttribute("aria-expanded", String(open));
stepEl.querySelector<HTMLElement>("[data-detail-clip]")!.inert = !open;
};
runToggle.addEventListener("click", () => {
const open = !root.hasAttribute("data-open");
touched = true;
setRunOpen(open);
if (!open) closePreview();
emit("open-change", { open });
});
stepEls.forEach((stepEl) => {
const button = stepEl.querySelector<HTMLButtonElement>("[data-step-toggle]")!;
setRowOpen(stepEl, false);
button.addEventListener("click", () => {
const open = button.getAttribute("aria-expanded") !== "true";
touched = true;
setRowOpen(stepEl, open);
emit("toggle-row", { label: button.dataset.stepToggle, open });
});
});
setRunOpen(root.hasAttribute("data-open"));
/* ── diff previews ── */
let current: { chip: HTMLButtonElement; preview: HTMLElement } | null = null;
let pinned = false; // opened by click/tap: stays until clicked again, Esc, or an outside press
let closeTimer: number | undefined;
const place = (chip: HTMLElement, preview: HTMLElement) => {
const rect = chip.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const width = preview.offsetWidth;
const height = preview.offsetHeight;
const left = Math.max(MARGIN, Math.min(rect.left, vw - width - MARGIN));
const fitsBelow = rect.bottom + GAP + height <= vh - MARGIN;
const fitsAbove = rect.top - GAP - height >= MARGIN;
const below = fitsBelow || !fitsAbove;
let top = below ? rect.bottom + GAP : rect.top - GAP - height;
top = Math.max(MARGIN, Math.min(top, vh - height - MARGIN));
preview.style.left = `${left}px`;
preview.style.top = `${top}px`;
preview.style.transformOrigin = below ? "top left" : "bottom left";
/* without the top layer, a transformed ancestor can shift `fixed`; correct for it */
if (!supportsPopover) {
const actual = preview.getBoundingClientRect();
preview.style.left = `${left - (actual.left - left)}px`;
preview.style.top = `${top - (actual.top - top)}px`;
}
};
const openPreview = (chip: HTMLButtonElement) => {
window.clearTimeout(closeTimer);
if (current?.chip === chip) return;
closePreview();
const preview = root.querySelector<HTMLElement>(`#${CSS.escape(chip.dataset.diffChip!)}`);
if (!preview) return;
preview.hidden = false;
if (supportsPopover) preview.showPopover();
place(chip, preview);
chip.setAttribute("aria-expanded", "true");
current = { chip, preview };
};
function closePreview() {
window.clearTimeout(closeTimer);
if (!current) return;
const { chip, preview } = current;
if (supportsPopover && preview.matches(":popover-open")) preview.hidePopover();
preview.hidden = true;
chip.setAttribute("aria-expanded", "false");
current = null;
pinned = false;
}
/* a short grace period lets the pointer travel from chip to preview */
const scheduleClose = () => {
if (pinned) return;
window.clearTimeout(closeTimer);
closeTimer = window.setTimeout(closePreview, 120);
};
chips.forEach((chip) => {
chip.addEventListener("pointerenter", (e) => e.pointerType === "mouse" && openPreview(chip));
chip.addEventListener("pointerleave", (e) => e.pointerType === "mouse" && scheduleClose());
chip.addEventListener("focus", () => chip.matches(":focus-visible") && openPreview(chip));
chip.addEventListener("blur", scheduleClose);
chip.addEventListener("click", () => {
if (current?.chip === chip && pinned) return closePreview();
openPreview(chip);
pinned = true;
});
});
root.querySelectorAll<HTMLElement>("[data-preview]").forEach((preview) => {
preview.addEventListener("pointerenter", () => window.clearTimeout(closeTimer));
preview.addEventListener("pointerleave", scheduleClose);
});
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && current) {
e.preventDefault();
closePreview();
}
};
const onViewport = () => current && place(current.chip, current.preview);
const onOutside = (e: PointerEvent) => {
if (current && !current.chip.contains(e.target as Node) && !current.preview.contains(e.target as Node)) closePreview();
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onOutside);
window.addEventListener("scroll", onViewport, { capture: true, passive: true });
window.addEventListener("resize", onViewport, { passive: true });
/* ── sequence ── */
const render = () => {
stepEls.forEach((el, i) => (el.hidden = i >= step));
if (diffs) diffs.hidden = step < total;
};
const tick = () => {
step += 1;
render();
if (step < total) timer.set(STEP_MS, tick);
else if (loop) timer.set(LOOP_HOLD, restart);
};
function restart() {
/* never rewind while someone is reading or using it */
if (touched || current || root.matches(":hover") || root.contains(document.activeElement)) {
if (!touched) timer.set(LOOP_HOLD, restart);
return;
}
step = 0;
stepEls.forEach((el) => setRowOpen(el, false));
render();
timer.set(STEP_MS, tick);
}
let io: IntersectionObserver | undefined;
function teardown() {
timer.clear();
io?.disconnect();
closePreview();
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onOutside);
window.removeEventListener("scroll", onViewport, { capture: true });
window.removeEventListener("resize", onViewport);
}
document.addEventListener("astro:before-swap", teardown, { once: true });
if (reduced || !root.hasAttribute("data-autoplay")) {
render();
return;
}
step = 0;
render();
timer.set(STEP_MS, tick);
io = new IntersectionObserver(([entry]) => {
if (!root.isConnected) return teardown();
if (entry?.isIntersecting) timer.resume();
else timer.pause();
}, { threshold: 0.2 });
io.observe(root);
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-tool-chips:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>