LLM index: /llms.txt
Status-aware UI for rendering tool calls (input and output JSON) in agent/chat interfaces. Tool is provider-agnostic and works well with Vercel AI SDK tool parts by mapping runtime tool states to the component status model.
{
"origin": "LHR",
"destination": "SFO",
"date": "2026-06-14",
"cabin": "economy"
}{
"route": "LHR -> SFO",
"bestOption": {
"airline": "BA",
"priceUsd": 742,
"duration": "10h 45m",
"stops": 0
}
}import {
Tool,
ToolContent,
ToolInput,
ToolOutput,
ToolTrigger,
} from "@/components/nexus-ui/tool";
function ToolDefault() {
const input = {
origin: "LHR",
destination: "SFO",
date: "2026-06-14",
cabin: "economy",
};
const output = {
route: "LHR -> SFO",
bestOption: {
airline: "BA",
priceUsd: 742,
duration: "10h 45m",
stops: 0,
},
};
return (
<Tool status="completed" defaultOpen>
<ToolTrigger name="search_flights" />
<ToolContent>
<ToolInput payload={input} />
<ToolOutput payload={output} />
</ToolContent>
</Tool>
);
}
export default ToolDefault;
Installation
npx shadcn@latest add @nexus-ui/toolpnpm dlx shadcn@latest add @nexus-ui/toolyarn dlx shadcn@latest add @nexus-ui/toolbunx shadcn@latest add @nexus-ui/toolInstall the following dependencies:
npx shadcn@latest add badge collapsible && npm install @hugeicons/react @hugeicons/core-free-icons @react-symbols/icons shikipnpm dlx shadcn@latest add badge collapsible && pnpm add @hugeicons/react @hugeicons/core-free-icons @react-symbols/icons shikiyarn dlx shadcn@latest add badge collapsible && yarn add @hugeicons/react @hugeicons/core-free-icons @react-symbols/icons shikibunx shadcn@latest add badge collapsible && bun add @hugeicons/react @hugeicons/core-free-icons @react-symbols/icons shikiCopy the following files into your project.
"use client";
import {
createContext,
useContext,
type ComponentProps,
type CSSProperties,
} from "react";
import {
ArrowDown01Icon,
CancelCircleIcon,
CheckmarkCircle01Icon,
Clock01Icon,
Loading03Icon,
ToolsIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
CodeBlock,
CodeBlockContent,
CodeblockShiki,
} from "@/components/nexus-ui/codeblock-new";
type ToolStatus = "pending" | "ready" | "running" | "completed" | "error";
type ToolMeta = {
label: string;
icon: IconSvgElement;
color: { bg: string; fg: string };
iconClassName?: string;
};
const TOOL_META: Record<ToolStatus, ToolMeta> = {
pending: {
label: "Pending",
icon: ToolsIcon,
color: { bg: "var(--color-gray-100)", fg: "var(--color-gray-500)" },
},
ready: {
label: "Ready",
icon: Clock01Icon,
color: { bg: "var(--color-orange-100)", fg: "var(--color-orange-600)" },
},
running: {
label: "Running",
icon: Loading03Icon,
color: { bg: "var(--color-blue-100)", fg: "var(--color-blue-600)" },
iconClassName: "animate-spin",
},
completed: {
label: "Completed",
icon: CheckmarkCircle01Icon,
color: { bg: "var(--color-green-100)", fg: "var(--color-green-600)" },
},
error: {
label: "Error",
icon: CancelCircleIcon,
color: { bg: "var(--color-red-100)", fg: "var(--color-red-600)" },
},
};
type ToolContextValue = {
status: ToolStatus;
meta: ToolMeta;
};
const ToolContext = createContext<ToolContextValue | null>(null);
function isToolStatus(value: unknown): value is ToolStatus {
return (
typeof value === "string" &&
Object.prototype.hasOwnProperty.call(TOOL_META, value)
);
}
function useToolContext(component: string): ToolContextValue {
const context = useContext(ToolContext);
if (!context) {
throw new Error(`${component} must be used within <Tool>`);
}
return context;
}
function stringifyToolPayload(payload: unknown): string {
if (typeof payload === "string") return payload;
if (payload === undefined) return "";
try {
return JSON.stringify(payload, null, 2);
} catch {
return String(payload);
}
}
type ToolProps = ComponentProps<typeof Collapsible> & {
status: ToolStatus;
};
function Tool({ status, className, style, ...props }: ToolProps) {
const resolvedStatus = isToolStatus(status) ? status : "pending";
const meta = TOOL_META[resolvedStatus];
return (
<ToolContext.Provider value={{ status: resolvedStatus, meta }}>
<Collapsible
data-slot="tool"
className={cn(
"not-prose w-full max-w-100 border dark:border-accent bg-card",
"data-[state=closed]:rounded-xl data-[state=open]:rounded-xl",
className,
)}
style={
{
"--tool-color": meta.color.fg,
"--tool-bg": meta.color.bg,
...style,
} as CSSProperties
}
{...props}
/>
</ToolContext.Provider>
);
}
type ToolTriggerProps = Omit<
ComponentProps<typeof CollapsibleTrigger>,
"children"
> & {
name: string;
};
function ToolTrigger({ name, className, ...props }: ToolTriggerProps) {
const { meta } = useToolContext("ToolTrigger");
return (
<CollapsibleTrigger
data-slot="tool-trigger"
className={cn(
"group flex h-10 w-full cursor-pointer items-center justify-between px-3 py-2",
className,
)}
{...props}
>
<div className="flex items-center gap-2">
<HugeiconsIcon
data-slot="tool-trigger-icon"
icon={meta.icon}
strokeWidth={2}
className={cn("size-4 text-(--tool-color)", meta.iconClassName)}
/>
<span
data-slot="tool-trigger-name"
className="text-sm leading-6 font-[450] text-primary"
>
{name}
</span>
<Badge
data-slot="tool-trigger-badge"
className="h-6 bg-(--tool-bg)/60 font-[450] text-(--tool-color) dark:bg-(--tool-color)/10 dark:text-(--tool-color)"
>
{meta.label}
</Badge>
</div>
<HugeiconsIcon
data-slot="tool-trigger-chevron"
icon={ArrowDown01Icon}
strokeWidth={1.75}
className="size-4 transition-transform duration-200 group-data-[state=open]:rotate-180"
/>
</CollapsibleTrigger>
);
}
type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
function ToolContent({ className, ...props }: ToolContentProps) {
return (
<CollapsibleContent
data-slot="tool-content"
className={cn(
"flex flex-col gap-6 p-3 pt-4",
"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className,
)}
{...props}
/>
);
}
type ToolPartProps = {
kind: "input" | "output";
payload: unknown;
errorText?: string;
};
function ToolPart({ kind, payload, errorText }: ToolPartProps) {
const { status } = useToolContext("ToolPart");
const code = stringifyToolPayload(payload);
const isOutputError = kind === "output" && status === "error";
const hasPayload = payload !== undefined && payload !== null;
const shouldShowCodeblock = !isOutputError || hasPayload;
const title = kind === "input" ? "Input" : isOutputError ? "Error" : "Output";
return (
<div data-slot={`tool-${kind}`} className="flex flex-col gap-3">
<span
data-slot={`tool-${kind}-title`}
className={cn(
"text-xs leading-4 font-[450] text-muted-foreground uppercase",
isOutputError && "text-destructive",
)}
>
{title}
</span>
{isOutputError ? (
<div
data-slot="tool-output-error"
className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm leading-6 text-destructive dark:bg-destructive/10"
>
{errorText ?? "Tool execution failed"}
</div>
) : null}
{shouldShowCodeblock ? (
<CodeBlock
data-slot="tool-output-error-codeblock"
className="rounded-lg"
keepBackground
>
<CodeBlockContent>
<CodeblockShiki language="json">{code}</CodeblockShiki>
</CodeBlockContent>
</CodeBlock>
) : null}
</div>
);
}
type ToolPayloadProps = {
payload: unknown;
};
function ToolInput({ payload }: ToolPayloadProps) {
return <ToolPart kind="input" payload={payload} />;
}
type ToolOutputProps = ToolPayloadProps & {
showWhen?: ToolStatus[];
errorText?: string;
};
function ToolOutput({
payload,
showWhen = ["completed"],
errorText,
}: ToolOutputProps) {
const { status } = useToolContext("ToolOutput");
if (!showWhen.includes(status)) return null;
return <ToolPart kind="output" payload={payload} errorText={errorText} />;
}
export type { ToolStatus };
export { Tool, ToolTrigger, ToolContent, ToolInput, ToolOutput };
"use client";
import { FileIcon } from "@react-symbols/icons/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
createContext,
useEffect,
useContext,
useState,
type CSSProperties,
type ComponentProps,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { highlight, Themes } from "@/lib/shiki/highlighter";
import type { BundledLanguage } from "shiki/bundle/web";
const highlighterPromise = highlight();
type DivProps = ComponentProps<"div">;
type CodeBlockProps = DivProps & {
keepBackground?: boolean;
};
type CodeBlockCopyContextValue = {
content: string;
setContent: (value: string) => void;
};
const CodeBlockCopyContext = createContext<CodeBlockCopyContextValue | null>(
null,
);
interface CodeblockClientShikiProps extends DivProps {
code?: string;
language?: string;
lineNumbers?: boolean;
children?: ReactNode;
}
type ShikiToken = {
content: string;
htmlStyle?: Record<string, string>;
};
const buildRawTokenRows = (input: string): ShikiToken[][] =>
input.split(/\r?\n/).map((line) => [{ content: line || "\u00A0" }]);
const EMPTY_TOKEN_ROW: ShikiToken[] = [{ content: "\u00A0" }];
const resolveCodeToHighlight = (children: ReactNode, code?: string): string =>
typeof children === "string"
? children
: Array.isArray(children) &&
children.length === 1 &&
typeof children[0] === "string"
? children[0]
: (code ?? "");
const CodeBlock = ({
children,
className,
keepBackground = false,
...props
}: CodeBlockProps) => {
const [copyContent, setCopyContent] = useState("");
return (
<CodeBlockCopyContext.Provider
value={{ content: copyContent, setContent: setCopyContent }}
>
<div
className={cn(
"not-prose",
"my-0 flex w-full flex-col overflow-hidden rounded-xl",
keepBackground
? "border-none bg-secondary dark:bg-background"
: "border bg-card dark:border-accent",
"text-[13px] font-[450]",
className,
)}
{...props}
>
{children}
</div>
</CodeBlockCopyContext.Provider>
);
};
type CodeBlockHeaderProps = DivProps;
const CodeBlockHeader = ({
children,
className,
...props
}: CodeBlockHeaderProps) => {
return (
<div
className={cn(
"not-prose", // Disable Markdown Styles
"flex h-9.5 items-center justify-between gap-2 px-4",
"text-fd-muted-foreground",
className,
)}
{...props}
>
{children}
</div>
);
};
interface CodeBlockIconProps extends DivProps {
language?: string;
}
const CodeBlockIcon = ({ language, className }: CodeBlockIconProps) => {
return (
<FileIcon
width={16}
height={16}
fileName={`.${language ?? ""}`}
autoAssign={true}
className={cn(className)}
/>
);
};
type CodeBlockGroupProps = DivProps;
const CodeBlockGroup = ({
children,
className,
...props
}: CodeBlockGroupProps) => {
return (
<div
className={cn(
"flex items-center gap-2",
"text-fd-muted-foreground",
className,
)}
{...props}
>
{children}
</div>
);
};
const CodeBlockContent = ({ className, children, ...props }: DivProps) => {
return (
<div
className={cn(
"no-scrollbar max-h-96 overflow-auto overscroll-x-none",
"rounded-xl px-4 text-sm leading-6",
"font-mono whitespace-pre",
className,
)}
{...props}
>
{children}
</div>
);
};
const CodeblockShiki = ({
code,
language = "tsx",
lineNumbers = false,
className,
children,
...props
}: CodeblockClientShikiProps) => {
const setCopyContent = useContext(CodeBlockCopyContext)?.setContent;
const codeToHighlight = resolveCodeToHighlight(children, code);
const [tokenRows, setTokenRows] = useState<ShikiToken[][]>(() =>
buildRawTokenRows(codeToHighlight),
);
useEffect(() => {
setCopyContent?.(codeToHighlight);
}, [codeToHighlight, setCopyContent]);
useEffect(() => {
let cancelled = false;
async function clientHighlight() {
const rawRows = buildRawTokenRows(codeToHighlight);
if (!cancelled) {
setTokenRows(rawRows);
}
if (!codeToHighlight) {
return;
}
try {
const highlighter = await highlighterPromise;
const result = await highlighter.codeToTokens(codeToHighlight, {
lang: language as BundledLanguage,
themes: {
light: Themes.light,
dark: Themes.dark,
},
});
if (!cancelled) {
setTokenRows((result.tokens ?? rawRows) as ShikiToken[][]);
}
} catch {
if (!cancelled) {
setTokenRows(rawRows);
}
}
}
void clientHighlight();
return () => {
cancelled = true;
};
}, [codeToHighlight, language]);
return (
<div
className={cn(
"no-scrollbar w-full overflow-auto overscroll-x-none py-0",
className,
)}
{...props}
>
<pre
className={cn(
"shiki",
lineNumbers ? "shiki-line-numbers" : "nd-no-line-numbers",
)}
>
<code>
{tokenRows.map((row, rowIndex) => (
<span key={`row-${rowIndex}`} className="line">
{(row.length ? row : EMPTY_TOKEN_ROW).map((token, tokenIndex) => (
<span
key={`token-${rowIndex}-${tokenIndex}`}
style={token.htmlStyle as CSSProperties | undefined}
>
{token.content || "\u00A0"}
</span>
))}
{rowIndex < tokenRows.length - 1 && "\n"}
</span>
))}
</code>
</pre>
</div>
);
};
type CodeBlockCopyButtonProps = ComponentProps<"button">;
const CodeBlockCopyButton = ({
className,
...props
}: CodeBlockCopyButtonProps) => {
const content = useContext(CodeBlockCopyContext)?.content ?? "";
const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error("Failed to copy text: ", err);
return false;
}
};
useEffect(() => {
if (!isCopied) return;
const timeout = setTimeout(() => {
setIsCopied(false);
}, 2000);
return () => clearTimeout(timeout);
}, [isCopied]);
const handleCopy = async () => {
if (!content) return;
await copyToClipboard(content);
setIsCopied(true);
};
return (
<button
title="Copy to clipboard"
className={cn(
"relative flex size-7 cursor-pointer items-center justify-center rounded-full text-ring hover:text-primary",
"",
className,
)}
onClick={handleCopy}
{...props}
>
{isCopied ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="size-3.5 animate-in text-green-900 duration-200 zoom-in-50 dark:text-green-400"
/>
) : (
<HugeiconsIcon
icon={Copy01Icon}
strokeWidth={2}
className="size-3.5 animate-in duration-200 zoom-in-50"
/>
)}
</button>
);
};
export {
CodeBlock,
CodeBlockHeader,
CodeBlockIcon,
CodeBlockGroup,
CodeBlockContent,
CodeblockShiki,
CodeBlockCopyButton,
};
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import {
bundledLanguages,
type Highlighter,
type RegexEngine,
createHighlighter,
} from "shiki/bundle/web";
let jsEngine: RegexEngine | null = null;
let highlighter: Promise<Highlighter> | null = null;
// Settings for UI components
const Themes = {
light: "github-light",
dark: "github-dark",
};
const allBundledLanguageIds = Object.keys(bundledLanguages);
const getJsEngine = (): RegexEngine => {
jsEngine ??= createJavaScriptRegexEngine();
return jsEngine;
};
const highlight = async (): Promise<Highlighter> => {
highlighter ??= createHighlighter({
langs: allBundledLanguageIds,
themes: ["github-light", "github-dark"],
engine: getJsEngine(),
});
return highlighter;
};
export { highlight, Themes };
/* Shiki Light/Dark Mode */
html.light .shiki,
html.light .shiki span {
font-family: var(--font-mono);
background-color: transparent !important;
}
html.dark .shiki,
html.dark .shiki span {
font-family: var(--font-mono);
color: var(--shiki-dark) !important;
background-color: transparent !important;
}
/* Base Shiki Pre & Span Styles */
pre.shiki {
@apply py-3;
}
pre.shiki span.line {
@apply px-4 py-0.5;
}
/* Shiki Word Wrap */
pre.shiki-word-wrap {
white-space: pre-wrap;
word-break: break-word;
}
pre.shiki-word-wrap span.line {
display: inline-block;
width: 100%;
box-sizing: border-box;
padding-top: 0.2px;
padding-bottom: 0.2px;
}
/* Shiki Line Numbers */
pre.shiki-line-numbers code {
counter-reset: step;
counter-increment: step 0;
.line {
&::before {
counter-increment: step;
@apply mr-6 inline-block border-transparent text-right text-sm whitespace-nowrap text-muted-foreground content-[counter(step)];
}
}
}
.nd-no-line-numbers code .line::before {
content: none;
display: none;
}
/* Shiki Highlight */
pre span.shiki-line-highlight {
@apply relative z-0 inline-block w-full;
&::after {
content: "";
@apply absolute top-0 left-0 -z-10 h-full w-full border-l-2 border-neutral-400 bg-neutral-500/20! opacity-40;
}
}
/* Shiki Notation Diff */
pre.has-diff span.line.diff {
@apply relative inline-block w-full;
}
pre.has-diff span.line.diff.add {
@apply bg-emerald-300/20! dark:bg-emerald-700/20!;
&::before {
content: "+";
@apply absolute left-2 text-green-600 dark:text-green-400;
}
}
pre.has-diff span.line.diff.remove {
@apply bg-red-300/20! opacity-70 dark:bg-red-600/20!;
&::before {
content: "-";
@apply absolute left-2 text-red-600 dark:text-red-400;
}
}
/* Shiki Notation Focus */
pre.shiki-has-focused .line:not(.focused) {
@apply opacity-50 blur-[0.8px] transition-opacity duration-200 ease-in-out;
}
pre.shiki-has-focused:hover .line:not(.focused) {
@apply opacity-100 blur-none;
}
/* Shiki Line Anchors */
pre.shiki-line-anchors .line:target {
@apply scroll-mt-14 bg-blue-400/15! dark:bg-blue-600/15!;
}
pre.shiki-line-numbers.shiki-line-anchors code .line::before {
@apply cursor-pointer transition-colors select-none;
}
pre.shiki-line-numbers.shiki-line-anchors code .line::before:hover {
@apply text-blue-500 underline dark:text-blue-400;
}
/* Shiki Highlighted Word */
pre span.shiki-word-highlight {
@apply relative z-0 inline-block rounded-sm px-0.5;
&::after {
content: "";
@apply absolute inset-0 -z-10 rounded-sm bg-neutral-500/25!;
}
}
Ensure your Shiki CSS is loaded.
CodeblockShiki expects .shiki styles. Import app/shiki.css once at your app root (for example in app/layout.tsx):
import "./global.css";
import "./shiki.css";Update import paths to match your project setup.
Usage
import {
Tool,
ToolTrigger,
ToolContent,
ToolInput,
ToolOutput,
type ToolStatus,
} from "@/components/nexus-ui/tool";<Tool status="completed" defaultOpen>
<ToolTrigger name="get_weather" />
<ToolContent>
<ToolInput payload={{ city: "Paris", unit: "celsius" }} />
<ToolOutput
payload={{ city: "Paris", temperature: 22, condition: "sunny" }}
/>
</ToolContent>
</Tool>Examples
Pending
Set status="pending" and render only ToolTrigger + ToolContent while arguments are still streaming.
import { Tool, ToolContent, ToolTrigger } from "@/components/nexus-ui/tool";
import { TextShimmer } from "@/components/nexus-ui/text-shimmer";
function ToolPending() {
return (
<Tool status="pending" defaultOpen>
<ToolTrigger name="extract_receipt_fields" />
<ToolContent>
<div className="text-sm text-muted-foreground">
<TextShimmer invertLight>
Model is still streaming tool arguments...
</TextShimmer>
</div>
</ToolContent>
</Tool>
);
}
export default ToolPending;
Ready
Set status="ready", render ToolInput with parsed arguments, and omit ToolOutput until execution starts.
{
"channel": "#support",
"text": "Escalate ticket #4821 to on-call engineer",
"mention": "@ops-oncall"
}import {
Tool,
ToolContent,
ToolInput,
ToolTrigger,
} from "@/components/nexus-ui/tool";
function ToolReady() {
const input = {
channel: "#support",
text: "Escalate ticket #4821 to on-call engineer",
mention: "@ops-oncall",
};
return (
<Tool status="ready" defaultOpen>
<ToolTrigger name="send_slack_message" />
<ToolContent>
<ToolInput payload={input} />
</ToolContent>
</Tool>
);
}
export default ToolReady;
Running
Set status="running" and keep only ToolInput visible while the tool call is in progress.
{
"userId": "usr_9f23",
"includeInvoices": true,
"includeUsage": true,
"lookbackDays": 90
}import {
Tool,
ToolContent,
ToolInput,
ToolTrigger,
} from "@/components/nexus-ui/tool";
import { TextShimmer } from "@/components/nexus-ui/text-shimmer";
function ToolRunning() {
const input = {
userId: "usr_9f23",
includeInvoices: true,
includeUsage: true,
lookbackDays: 90,
};
return (
<Tool status="running" defaultOpen>
<ToolTrigger name="fetch_billing_summary" />
<ToolContent>
<ToolInput payload={input} />
<div className="text-sm text-muted-foreground">
<TextShimmer invertLight>Fetching billing summary...</TextShimmer>
</div>
</ToolContent>
</Tool>
);
}
export default ToolRunning;
Error State
Set status="error", keep ToolInput visible, and render ToolOutput with errorText (and showWhen={["error"]}) to show a destructive error message.
{
"to": "ceo@acme.com",
"subject": "Weekly KPI Summary",
"templateId": "kpi-weekly-v2"
}import {
Tool,
ToolContent,
ToolInput,
ToolOutput,
ToolTrigger,
} from "@/components/nexus-ui/tool";
function ToolErrorState() {
const input = {
to: "ceo@acme.com",
subject: "Weekly KPI Summary",
templateId: "kpi-weekly-v2",
};
const error = {
code: "smtp_auth_failed",
message: "SMTP authentication failed for configured sender",
};
return (
<Tool status="error" defaultOpen>
<ToolTrigger name="send_email" />
<ToolContent>
<ToolInput payload={input} />
<ToolOutput payload={null} errorText={error.message} showWhen={["error"]} />
</ToolContent>
</Tool>
);
}
export default ToolErrorState;
Vercel AI SDK Integration
Tool maps cleanly to AI SDK tool parts by converting streamed tool part state into ToolStatus and passing input / output payloads directly.
Use it with Vercel AI SDK by:
- mapping AI SDK tool part states (
input-streaming,input-available,output-available,output-error) into your UI states (pending,ready,running,completed,error) - rendering
part.inputinToolInput - rendering
part.outputinToolOutputwhen available - passing
errorTexttoToolOutputforerrorstate
ready is an app-level state in this mapping (for example, waiting on user approval before execution). AI SDK does not emit ready as a native tool part state.
pending via input-streaming is only available in streaming flows (for example, streamText / streamed UI messages).
Install the AI SDK
npm install ai @ai-sdk/reactStream messages from your chat API route
import { streamText, convertToModelMessages, tool, type UIMessage } from "ai";
import { z } from "zod";
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: "anthropic/claude-sonnet-4.5",
messages: await convertToModelMessages(messages),
tools: {
displayWeather: tool({
description: "Get weather for a city",
inputSchema: z.object({
city: z.string(),
unit: z.enum(["celsius", "fahrenheit"]),
}),
execute: async ({ city, unit }) => ({
city,
unit,
temperature: 22,
condition: "sunny",
}),
}),
},
});
return result.toUIMessageStreamResponse();
}Map assistant tool parts into Tool
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import {
Tool,
ToolTrigger,
ToolContent,
ToolInput,
ToolOutput,
type ToolStatus,
} from "@/components/nexus-ui/tool";
type ToolPartLike = {
type: string;
state?: string;
input?: unknown;
output?: unknown;
errorText?: string;
error?: unknown;
};
function mapToolStatus(
part: ToolPartLike,
isAwaitingApproval: boolean,
): ToolStatus | null {
switch (part.state) {
case "input-streaming":
// Streaming-only: arguments are still being generated.
return "pending";
case "input-available":
// App-level mapping: use "ready" while waiting for approval/user action.
return isAwaitingApproval ? "ready" : "running";
case "output-available":
return "completed";
case "output-error":
return "error";
default:
return null;
}
}
function toolNameFromPartType(type: string): string {
return type.startsWith("tool-") ? type.slice(5) : type;
}
export default function ToolCallsFromUseChat() {
const { messages } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
const assistant = [...messages].reverse().find((m) => m.role === "assistant");
if (!assistant) return null;
const toolParts = assistant.parts.filter(
(part): part is ToolPartLike => part.type.startsWith("tool-"),
);
if (toolParts.length === 0) return null;
return (
<div className="space-y-3">
{toolParts.map((part, index) => {
const status = mapToolStatus(part, false);
if (!status) return null;
return (
<Tool key={`${part.type}-${index}`} status={status} defaultOpen>
<ToolTrigger name={toolNameFromPartType(part.type)} />
<ToolContent>
<ToolInput payload={part.input} />
<ToolOutput
payload={part.output}
errorText={part.errorText}
showWhen={["completed", "error"]}
/>
</ToolContent>
</Tool>
);
})}
</div>
);
}API Reference
Tool
Root wrapper for a single tool call. Provides status context and status color tokens for child components. Wraps Collapsible Root.
Prop
Type
ToolTrigger
Header row for tool name, status icon, status badge, and expand/collapse chevron. Wraps Collapsible Trigger.
Prop
Type
ToolContent
Body wrapper for input/output/error sections. Wraps Collapsible Content.
Prop
Type
ToolInput
Renders the tool input payload in a JSON codeblock.
Prop
Type
ToolOutput
Renders tool output payload in a JSON codeblock when the current tool state matches showWhen.
Prop
Type