{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tool-call","type":"registry:ui","title":"ToolCall","description":"One tool invocation in a disclosure card: status dot, tool name, duration, and the request and result payloads pretty-printed on expand","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-agents"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/tool-call/ToolCall.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { formatElapsed } from \"../../internals/use-elapsed\";\nimport { useInertAttribute } from \"../../internals/dom/use-inert-attribute\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport type { ToolCallData } from \"../../internals/ai-types\";\nimport \"./tool-call.css\";\n\n/**\n * Props for ToolCall\n */\nexport interface ToolCallProps {\n\t/** The invocation to render: name, status, and whatever payloads exist so far. */\n\tcall: ToolCallData;\n\t/**\n\t * Whether the payloads are expanded. Controlled when supplied — pair it with\n\t * `onToggle`, the React counterpart of the Svelte source's `bind:open`. Left\n\t * out until either the reader or a failure decides — see the open-behaviour\n\t * contract in the README.\n\t */\n\topen?: boolean;\n\t/** Replaces the default request rendering. Receives `call.input`. */\n\tinput?: (value: unknown) => ReactNode;\n\t/** Replaces the default result rendering. Receives `call.output`. */\n\toutput?: (value: unknown) => ReactNode;\n\t/** Leading icon, replacing the default wrench. */\n\ticon?: ReactNode;\n\t/** Called whenever the card opens or closes, by click or on its own. */\n\tonToggle?: (open: boolean) => void;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n/** Spoken alongside the tool name, since the dot's colour says nothing out loud. */\nconst STATUS_LABELS = {\n\tpending: \"Pending\",\n\trunning: \"Running\",\n\tdone: \"Completed\",\n\terror: \"Failed\",\n\tcancelled: \"Cancelled\",\n} as const;\n\n/** Shown when a call reports failure without saying why. */\nconst FALLBACK_ERROR = \"The tool call failed.\";\n\n/**\n * The shared formatter floors to the second, which reads as \"0s\" for anything\n * quicker than that — and a tool call more often than not is. Sub-second work\n * is reported in milliseconds here; everything else defers to the shared\n * formatter, whose contract other callers depend on.\n */\nfunction formatDuration(ms: number): string {\n\tif (Number.isFinite(ms) && ms >= 0 && ms < 1000) return `${Math.round(ms)}ms`;\n\treturn formatElapsed(ms);\n}\n\n/**\n * Objects and arrays get pretty-printed JSON; primitives are rendered bare so\n * a string result reads as prose rather than as a quoted literal.\n *\n * The two things `JSON.stringify` refuses outright — a cycle and a bigint —\n * are what tool payloads are actually made of, and `String()` turns both into\n * `[object Object]`, which tells the reader nothing. So a refusal earns a\n * second attempt through a replacer that names them instead; `String` is left\n * for the payload that defeats even that, such as a `toJSON` that throws.\n */\nfunction formatPayload(value: unknown): string {\n\tif (value === undefined) return \"\";\n\tif (value === null || typeof value !== \"object\") return String(value);\n\ttry {\n\t\treturn JSON.stringify(value, null, 2) ?? String(value);\n\t} catch {\n\t\ttry {\n\t\t\t// Tracking every object ever seen would call the second of two\n\t\t\t// siblings pointing at one value a cycle, which it is not. Only the\n\t\t\t// chain currently being descended counts. `this` is whatever object\n\t\t\t// owns the key being visited, so popping back to it before the check\n\t\t\t// unwinds the stack on the way out of a branch.\n\t\t\tconst ancestors: unknown[] = [];\n\t\t\tconst json = JSON.stringify(\n\t\t\t\tvalue,\n\t\t\t\tfunction (this: unknown, _key, entry: unknown) {\n\t\t\t\t\tif (typeof entry === \"bigint\") return `${entry}n`;\n\t\t\t\t\tif (entry !== null && typeof entry === \"object\") {\n\t\t\t\t\t\twhile (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {\n\t\t\t\t\t\t\tancestors.pop();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (ancestors.includes(entry)) return \"[Circular]\";\n\t\t\t\t\t\tancestors.push(entry);\n\t\t\t\t\t}\n\t\t\t\t\treturn entry;\n\t\t\t\t},\n\t\t\t\t2\n\t\t\t);\n\t\t\treturn json ?? String(value);\n\t\t} catch {\n\t\t\treturn String(value);\n\t\t}\n\t}\n}\n\n/**\n * One tool invocation as a collapsible card: name, status dot, duration in the\n * header; request and result payloads behind a click.\n *\n * The root element arrives through the ref channel rather than a `ref` prop,\n * per PORTING.md — the Svelte source declares `ref = $bindable(null)`.\n */\nexport const ToolCall = forwardRef<HTMLDivElement, ToolCallProps>(function ToolCall(\n\t{ call, open, input, output, icon, onToggle, className, sound = false },\n\tref\n) {\n\tconst uid = useFancyId();\n\tconst headerId = `${uid}-header`;\n\tconst bodyId = `${uid}-body`;\n\tconst requestId = `${uid}-request`;\n\tconst resultId = `${uid}-result`;\n\tconst playCue = useSoundCue(sound);\n\n\t// `open` stays undefined until something actually decides: `autoOpen` carries\n\t// the answer in the meantime, so a consumer that never touches the prop still\n\t// gets the automatic behaviour and one that controls it still owns the value.\n\t// Seeded from the initial status as well, because the effect that opens a\n\t// failed call never runs on the server: a call that already reports failure\n\t// would otherwise be rendered folded and only spring open at hydration.\n\tconst [autoOpen, setAutoOpen] = useState(() => call.status === \"error\");\n\tconst userToggledRef = useRef(false);\n\n\tconst isOpen = open ?? autoOpen;\n\tconst inertRef = useInertAttribute<HTMLDivElement>(!isOpen);\n\tconst status = call.status;\n\tconst isError = status === \"error\";\n\tconst errorText = isError ? (call.error ?? FALLBACK_ERROR) : \"\";\n\tconst duration = call.durationMs === undefined ? \"\" : formatDuration(call.durationMs);\n\n\t// A render prop counts as content on its own: a caller who renders the\n\t// payload themselves may well have nothing on `call` for us to look at.\n\tconst hasRequest = input !== undefined || call.input !== undefined;\n\tconst hasOutput = output !== undefined || call.output !== undefined;\n\tconst hasResult = hasOutput || errorText !== \"\";\n\n\tconst requestText = hasRequest ? formatPayload(call.input) : \"\";\n\tconst resultText = hasOutput ? formatPayload(call.output) : \"\";\n\n\tfunction commit(next: boolean) {\n\t\tif ((open ?? autoOpen) === next) return;\n\t\tsetAutoOpen(next);\n\t\tonToggle?.(next);\n\t}\n\n\tfunction toggle() {\n\t\t// From here on the reader owns the card: a later failure will not pop it\n\t\t// open again behind their back.\n\t\tuserToggledRef.current = true;\n\t\tconst next = !isOpen;\n\t\t// Only this click plays a cue — the error auto-open effect and the\n\t\t// initial `autoOpen` seed both bypass `commit()` (or fold through it\n\t\t// silently) and must stay silent, so the play sits here rather than in\n\t\t// `commit()`.\n\t\tplayCue(next ? \"open\" : \"close\");\n\t\tcommit(next);\n\t}\n\n\t// A failure is the one thing worth reading without being asked, so it expands\n\t// itself — unless the reader has already taken over. Everything but the\n\t// status is read through refs so that taking over, or the card opening some\n\t// other way, does not itself re-run this.\n\tconst isOpenRef = useRef(isOpen);\n\tisOpenRef.current = isOpen;\n\tconst onToggleRef = useRef(onToggle);\n\tonToggleRef.current = onToggle;\n\n\tuseEffect(() => {\n\t\tif (call.status !== \"error\" || userToggledRef.current) return;\n\t\tif (isOpenRef.current) return;\n\t\tsetAutoOpen(true);\n\t\tonToggleRef.current?.(true);\n\t}, [call.status]);\n\n\treturn (\n\t\t<div\n\t\t\tref={ref}\n\t\t\tclassName={cn(\"ft-toolcall border-border bg-card/50 w-full rounded-lg border text-sm\", className)}\n\t\t\tdata-status={status}\n\t\t>\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tid={headerId}\n\t\t\t\tclassName=\"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors\"\n\t\t\t\taria-expanded={isOpen}\n\t\t\t\taria-controls={bodyId}\n\t\t\t\tonClick={toggle}\n\t\t\t>\n\t\t\t\t<span className=\"ft-toolcall-icon flex-none\" aria-hidden=\"true\">\n\t\t\t\t\t{icon ?? (\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\tclassName=\"size-3.5\"\n\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\" />\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t)}\n\t\t\t\t</span>\n\n\t\t\t\t{/*\n\t\t\t\t\tFilled versus hollow, not just hue: the four states stay apart for anyone\n\t\t\t\t\twho cannot tell the colours apart, and the label below says it in words.\n\t\t\t\t*/}\n\t\t\t\t<span\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"ft-toolcall-dot flex-none\",\n\t\t\t\t\t\tstatus === \"pending\" && \"ft-status-pending\",\n\t\t\t\t\t\tstatus === \"running\" && \"ft-status-running\",\n\t\t\t\t\t\tstatus === \"done\" && \"ft-status-done\",\n\t\t\t\t\t\tisError && \"ft-status-error\",\n\t\t\t\t\t\tstatus === \"cancelled\" && \"ft-status-cancelled\"\n\t\t\t\t\t)}\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t></span>\n\n\t\t\t\t<span\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"ft-toolcall-name text-foreground min-w-0 truncate font-mono text-xs\",\n\t\t\t\t\t\tstatus === \"cancelled\" && \"ft-struck\"\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{call.name}\n\t\t\t\t</span>\n\t\t\t\t<span className=\"sr-only\">{STATUS_LABELS[status]}</span>\n\n\t\t\t\t<span className=\"ml-auto flex flex-none items-center gap-2\">\n\t\t\t\t\t{duration ? <span className=\"text-xs tabular-nums\">{duration}</span> : null}\n\t\t\t\t\t<svg\n\t\t\t\t\t\tclassName={cn(\"ft-toolcall-chevron size-3.5\", isOpen && \"ft-open\")}\n\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\tstrokeWidth=\"2.5\"\n\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<path d=\"m9 6 6 6-6 6\" />\n\t\t\t\t\t</svg>\n\t\t\t\t</span>\n\t\t\t</button>\n\n\t\t\t<div className={cn(\"ft-toolcall-body\", isOpen && \"ft-open\")}>\n\t\t\t\t<div className=\"overflow-hidden\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={inertRef}\n\t\t\t\t\t\tid={bodyId}\n\t\t\t\t\t\trole=\"group\"\n\t\t\t\t\t\taria-labelledby={headerId}\n\t\t\t\t\t\tclassName=\"flex flex-col gap-3 px-3 pb-3\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{hasRequest ? (\n\t\t\t\t\t\t\t<section aria-labelledby={requestId}>\n\t\t\t\t\t\t\t\t<div id={requestId} className=\"text-muted-foreground mb-1 text-xs font-medium\">\n\t\t\t\t\t\t\t\t\tRequest\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{input ? input(call.input) : <pre className=\"ft-toolcall-payload\">{requestText}</pre>}\n\t\t\t\t\t\t\t</section>\n\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t{hasResult ? (\n\t\t\t\t\t\t\t<section aria-labelledby={resultId}>\n\t\t\t\t\t\t\t\t<div id={resultId} className=\"text-muted-foreground mb-1 text-xs font-medium\">\n\t\t\t\t\t\t\t\t\tResult\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{errorText ? <p className=\"ft-toolcall-error-text\">{errorText}</p> : null}\n\t\t\t\t\t\t\t\t{output ? (\n\t\t\t\t\t\t\t\t\toutput(call.output)\n\t\t\t\t\t\t\t\t) : hasOutput ? (\n\t\t\t\t\t\t\t\t\t<pre className={cn(\"ft-toolcall-payload\", errorText && \"mt-2\")}>{resultText}</pre>\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t</section>\n\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t{!hasRequest && !hasResult ? (\n\t\t\t\t\t\t\t<p className=\"text-muted-foreground text-xs italic\">Nothing recorded yet.</p>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/tool-call/ToolCall.tsx"},{"path":"react/src/components/tool-call/index.ts","content":"\"use client\";\n\nexport { ToolCall } from \"./ToolCall\";\nexport type { ToolCallProps } from \"./ToolCall\";\n","type":"registry:ui","target":"@ui/fancy/components/tool-call/index.ts"},{"path":"react/src/components/tool-call/tool-call.css","content":"/*\n * Every status colour is read at the point of use through two hooks: the\n * component's own `--ft-toolcall-*`, then the `--ft-status-*` vocabulary every\n * component in this family shares, then the literal. Setting either anywhere up\n * the tree retints the state without having to win a specificity fight against\n * these scoped rules — the shared one recolours the whole family at once.\n */\n.ft-toolcall-dot {\n\tposition: relative;\n\tdisplay: inline-block;\n\twidth: 0.5rem;\n\theight: 0.5rem;\n\tborder-radius: 9999px;\n}\n\n/* Hollow: nothing has happened yet, or nothing ever will. The two share a hue\n   and stay apart on the ring's weight, plus the struck-through name below. */\n.ft-toolcall-dot.ft-status-pending {\n\tbox-shadow: inset 0 0 0 1.5px\n\t\tvar(\n\t\t\t--ft-toolcall-pending,\n\t\t\tcolor-mix(\n\t\t\t\tin oklab,\n\t\t\t\tvar(--ft-status-pending, light-dark(oklch(0.5 0.02 260), oklch(0.72 0.02 260))) 55%,\n\t\t\t\ttransparent\n\t\t\t)\n\t\t);\n}\n\n.ft-toolcall-dot.ft-status-cancelled {\n\tbox-shadow: inset 0 0 0 1.5px\n\t\tvar(\n\t\t\t--ft-toolcall-cancelled,\n\t\t\tcolor-mix(\n\t\t\t\tin oklab,\n\t\t\t\tvar(--ft-status-cancelled, light-dark(oklch(0.5 0.02 260), oklch(0.72 0.02 260))) 40%,\n\t\t\t\ttransparent\n\t\t\t)\n\t\t);\n}\n\n.ft-toolcall-dot.ft-status-running {\n\tbackground: var(\n\t\t--ft-toolcall-running,\n\t\tvar(--ft-status-running, light-dark(oklch(0.5 0.18 265), oklch(0.72 0.15 265)))\n\t);\n}\n\n.ft-toolcall-dot.ft-status-done {\n\tbackground: var(\n\t\t--ft-toolcall-done,\n\t\tvar(--ft-status-done, light-dark(oklch(0.5 0.14 145), oklch(0.72 0.15 145)))\n\t);\n}\n\n.ft-toolcall-dot.ft-status-error {\n\tbackground: var(\n\t\t--ft-toolcall-error,\n\t\tvar(--ft-status-error, light-dark(oklch(0.5 0.19 25), oklch(0.7 0.18 25)))\n\t);\n}\n\n.ft-toolcall-name.ft-struck {\n\ttext-decoration: line-through;\n\topacity: 0.7;\n}\n\n.ft-toolcall-error-text {\n\tcolor: var(\n\t\t--ft-toolcall-error,\n\t\tvar(--ft-status-error, light-dark(oklch(0.5 0.19 25), oklch(0.7 0.18 25)))\n\t);\n\tfont-size: 0.75rem;\n\tline-height: 1.5;\n}\n\n.ft-toolcall-payload {\n\tmax-height: var(--ft-toolcall-max-height, 16rem);\n\toverflow-y: auto;\n\tborder-radius: 0.375rem;\n\tbackground: var(--ft-toolcall-payload-bg, color-mix(in oklab, currentColor 6%, transparent));\n\tpadding: 0.5rem 0.625rem;\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\tfont-size: 0.75rem;\n\tline-height: 1.5;\n\t/* Wrapping rather than scrolling sideways: a long URL in a payload should\n\t   not put the whole card on a horizontal rail. */\n\twhite-space: pre-wrap;\n\toverflow-wrap: anywhere;\n}\n\n/*\n * 0fr → 1fr on a one-row grid is the only way to transition \"auto\" height\n * without measuring anything. The inner wrapper does the clipping.\n */\n.ft-toolcall-body {\n\tdisplay: grid;\n\tgrid-template-rows: 0fr;\n}\n\n.ft-toolcall-body.ft-open {\n\tgrid-template-rows: 1fr;\n}\n\n.ft-toolcall-chevron.ft-open {\n\ttransform: rotate(90deg);\n}\n\n/*\n * Everything that moves lives behind the query, so reduced motion gets the\n * same states with nothing to shorten or fall back from — a running call is\n * still a filled accent dot, it just stops breathing.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-toolcall-body {\n\t\ttransition: grid-template-rows 260ms cubic-bezier(0.4, 0, 0.2, 1);\n\t}\n\n\t.ft-toolcall-chevron {\n\t\ttransition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1);\n\t}\n\n\t.ft-toolcall-dot.ft-status-running::after {\n\t\tcontent: \"\";\n\t\tposition: absolute;\n\t\tinset: 0;\n\t\tborder-radius: inherit;\n\t\tbackground: inherit;\n\t\tanimation: ft-toolcall-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite;\n\t}\n}\n\n@keyframes ft-toolcall-ping {\n\tfrom {\n\t\topacity: 0.55;\n\t\ttransform: scale(1);\n\t}\n\tto {\n\t\topacity: 0;\n\t\ttransform: scale(2.4);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/tool-call/tool-call.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/tool-call?framework=react"}}