Skip to content
edmi-ui

Tool

Tool call card with a state badge, collapsible input parameters and output or error.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}
Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Error
Rate limit: retry in 20 s
Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}
Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Error
Rate limit: retry in 20 s
Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}
Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Error
Rate limit: retry in 20 s

Built on badge and collapsible.

Terminal window
npx shadcn@latest add @edmi-ui/ai-tool
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-tool
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-tool
Terminal window
bunx shadcn@latest add @edmi-ui/ai-tool

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-tool
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-tool
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-tool
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-tool

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-tool.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-tool.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-tool.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-tool.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@/components/ai/tool";
<div className="flex w-full max-w-md flex-col gap-3">
<Tool defaultOpen>
<ToolHeader type="tool-get_prices" state="output-available" />
<ToolContent>
<ToolInput />
<ToolOutput
output={{ price: 188.2, change: 0.024 }}
errorText={undefined}
/>
</ToolContent>
</Tool>
<Tool defaultOpen>
<ToolHeader type="tool-get_prices" state="output-error" />
<ToolContent>
<ToolInput />
<ToolOutput
output={undefined}
errorText="Rate limit: retry in 20 s"
/>
</ToolContent>
</Tool>
</div>
<script setup lang="ts">
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@/components/ai/tool";
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-3">
<Tool default-open>
<ToolHeader type="tool-get_prices" state="output-available" />
<ToolContent>
<ToolInput />
<ToolOutput :output="{ price: 188.2, change: 0.024 }" :error-text="undefined" />
</ToolContent>
</Tool>
<Tool default-open>
<ToolHeader type="tool-get_prices" state="output-error" />
<ToolContent>
<ToolInput />
<ToolOutput :output="undefined" error-text="Rate limit: retry in 20 s" />
</ToolContent>
</Tool>
<Tool :key="state">
<ToolHeader type="tool-get_prices" :state="state" />
<ToolContent>
<ToolInput />
</ToolContent>
</Tool>
</div>
</template>
<script lang="ts">
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "$lib/components/ai/tool";
</script>
<div class="flex w-full max-w-md flex-col gap-3">
<Tool open>
<ToolHeader type="tool-get_prices" state="output-available" />
<ToolContent>
<ToolInput />
<ToolOutput output={{ price: 188.2, change: 0.024 }} />
</ToolContent>
</Tool>
<Tool open>
<ToolHeader type="tool-get_prices" state="output-error" />
<ToolContent>
<ToolInput />
<ToolOutput errorText="Rate limit: retry in 20 s" />
</ToolContent>
</Tool>
{#each states as state (state)}
<Tool>
<ToolHeader type="tool-get_prices" {state} />
<ToolContent>
<ToolInput />
</ToolContent>
</Tool>
{/each}
</div>
registry/components/ai/tool.tsx
"use client";
import type { DynamicToolUIPart, ToolUIPart } from "ai";
import { cn } from "cn";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { ShieldIcon, CheckIcon, ClockIcon, CircleIcon, CheckCircleIcon, XIcon, XCircleIcon, WrenchIcon, CaretDownIcon } from "@phosphor-icons/react";
import { Badge } from "@/registry/edmi/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/edmi/ui/collapsible";
import {
type Elevation,
SurfaceProvider,
useElevation,
} from "@/registry/edmi/ui/elevation";
export type ToolProps = ComponentProps<typeof Collapsible> & {
/** ✦ depth of the card (sunken -1, flat 0, raised +1, floating +2). */
elevation?: Elevation;
};
// surface role: same faces as the ui card
const toolElevation = {
sunken: "border-sk-bd bg-sk-bg shadow-sunken",
flat: "",
raised: "border-transparent shadow-raised",
floating: "border-transparent shadow-floating",
};
export const Tool = ({ className, elevation, ...props }: ToolProps) => {
const level = useElevation(elevation, "surface");
return (
<SurfaceProvider level={level}>
<Collapsible
data-slot="ai-tool"
className={cn(
"group/tool not-prose w-full overflow-hidden rounded-xl border border-border bg-card text-card-foreground",
toolElevation[level],
className,
)}
{...props}
/>
</SurfaceProvider>
);
};
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
type: DynamicToolUIPart["type"];
state: DynamicToolUIPart["state"];
toolName: string;
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const statusVariants: Record<
ToolPart["state"],
"secondary" | "info" | "warning" | "success" | "outline" | "destructive"
> = {
"approval-requested": "warning",
"approval-responded": "secondary",
"input-available": "info",
"input-streaming": "secondary",
"output-available": "success",
"output-denied": "outline",
"output-error": "destructive",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": (
<ShieldIcon
/>
),
"approval-responded": (
<CheckIcon
/>
),
"input-available": (
<ClockIcon
className="animate-pulse"
/>
),
"input-streaming": (
<CircleIcon
/>
),
"output-available": (
<CheckCircleIcon
/>
),
"output-denied": (
<XIcon
/>
),
"output-error": (
<XCircleIcon
/>
),
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge
className="gap-1"
data-slot="ai-tool-status"
shape="pill"
variant={statusVariants[status]}
>
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
return (
<CollapsibleTrigger
data-slot="ai-tool-header"
className={cn(
"group/tool-trigger flex w-full items-center gap-2.5 px-3.5 py-2.5 text-left text-[13.5px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
className,
)}
{...props}
>
<WrenchIcon
className="size-[15px] shrink-0 text-muted-foreground"
/>
<span className="font-mono text-[12.5px]">{title ?? derivedName}</span>
{getStatusBadge(state)}
<CaretDownIcon
className="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/tool-trigger:rotate-180"
/>
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({
className,
children,
...props
}: ToolContentProps) => (
<CollapsibleContent
data-slot="ai-tool-content"
className={cn(
"h-(--collapsible-panel-height) overflow-hidden outline-none transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0",
className,
)}
{...props}
>
<div className="space-y-3 border-t border-border px-3.5 py-3 text-[13px]">
{children}
</div>
</CollapsibleContent>
);
const labelClass =
"mb-1.5 font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase";
const preClass =
"m-0 overflow-x-auto rounded-lg bg-muted px-3 py-2.5 font-mono text-xs leading-[1.6] whitespace-pre text-foreground";
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div
data-slot="ai-tool-input"
className={cn("overflow-hidden", className)}
{...props}
>
<div className={labelClass}>Parameters</div>
<pre className={preClass}>{JSON.stringify(input, null, 2)}</pre>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let body: ReactNode;
if (errorText) {
body = (
<pre
className={cn(preClass, "bg-destructive-soft text-destructive-text")}
>
{errorText}
</pre>
);
} else if (isValidElement(output)) {
body = <div className="overflow-x-auto">{output}</div>;
} else if (typeof output === "string") {
body = <pre className={preClass}>{output}</pre>;
} else {
body = <pre className={preClass}>{JSON.stringify(output, null, 2)}</pre>;
}
return (
<div data-slot="ai-tool-output" className={className} {...props}>
<div className={labelClass}>{errorText ? "Error" : "Result"}</div>
{body}
</div>
);
};
registry/components/ai/tool/Tool.vue
<script setup lang="ts">
import type { CollapsibleRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Collapsible } from "@/registry/edmi/ui/collapsible"
import { type Elevation, provideSurface, useElevation } from "@/registry/edmi/ui/elevation"
interface ToolProps extends /* @vue-ignore */ CollapsibleRootProps {
class?: HTMLAttributes["class"]
/** ✦ depth of the card: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation
}
const props = withDefaults(defineProps<ToolProps>(), {
elevation: undefined,
})
// surface role: same faces as the ui card
const toolElevation = {
sunken: 'border-sk-bd bg-sk-bg shadow-sunken',
flat: '',
raised: 'border-transparent shadow-raised',
floating: 'border-transparent shadow-floating',
}
const level = useElevation(() => props.elevation, 'surface')
provideSurface(() => level.value)
</script>
<template>
<!-- Collapsible props (open, defaultOpen, update:open) fall through as attrs. -->
<Collapsible
data-slot="ai-tool"
:class="cn(
'group/tool not-prose w-full overflow-hidden rounded-xl border border-border bg-card text-card-foreground',
toolElevation[level],
props.class,
)"
>
<slot />
</Collapsible>
</template>
registry/components/ai/tool/ToolContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleContent } from "@/registry/edmi/ui/collapsible"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<CollapsibleContent
data-slot="ai-tool-content"
:class="cn(
'overflow-hidden outline-none data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
props.class,
)"
>
<div class="space-y-3 border-t border-border px-3.5 py-3 text-[13px]">
<slot />
</div>
</CollapsibleContent>
</template>
registry/components/ai/tool/ToolHeader.vue
<script setup lang="ts">
import type { DynamicToolUIPart, ToolUIPart } from "ai"
import type { HTMLAttributes } from "vue"
import { ChevronDownIcon, SettingsIcon } from "@lucide/vue"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleTrigger } from "@/registry/edmi/ui/collapsible"
import ToolStatusBadge from "./ToolStatusBadge.vue"
type ToolHeaderProps = {
title?: string
class?: HTMLAttributes["class"]
} & (
| { type: ToolUIPart["type"], state: ToolUIPart["state"], toolName?: never }
| { type: DynamicToolUIPart["type"], state: DynamicToolUIPart["state"], toolName: string }
)
const props = defineProps<ToolHeaderProps>()
const derivedName = computed(() =>
props.type === "dynamic-tool"
? props.toolName
: props.type.split("-").slice(1).join("-"),
)
</script>
<template>
<CollapsibleTrigger
data-slot="ai-tool-header"
:class="cn(
'group/tool-trigger flex w-full items-center gap-2.5 px-3.5 py-2.5 text-left text-[13.5px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',
props.class,
)"
>
<SettingsIcon class="size-[15px] shrink-0 text-muted-foreground" />
<span class="font-mono text-[12.5px]">{{ props.title ?? derivedName }}</span>
<ToolStatusBadge :state="props.state" />
<ChevronDownIcon
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform group-data-[state=open]/tool-trigger:rotate-180"
/>
</CollapsibleTrigger>
</template>
registry/components/ai/tool/ToolInput.vue
<script setup lang="ts">
import type { DynamicToolUIPart, ToolUIPart } from "ai"
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
type ToolPart = ToolUIPart | DynamicToolUIPart
const props = defineProps<{
input: ToolPart["input"]
class?: HTMLAttributes["class"]
}>()
const formatted = computed(() => JSON.stringify(props.input, null, 2))
</script>
<template>
<div data-slot="ai-tool-input" :class="cn('overflow-hidden', props.class)">
<div class="mb-1.5 font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase">
Parameters
</div>
<pre class="m-0 overflow-x-auto rounded-lg bg-muted px-3 py-2.5 font-mono text-xs leading-[1.6] whitespace-pre text-foreground">{{ formatted }}</pre>
</div>
</template>
registry/components/ai/tool/ToolOutput.vue
<script setup lang="ts">
import type { DynamicToolUIPart, ToolUIPart } from "ai"
import type { HTMLAttributes } from "vue"
import { computed, isVNode } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
type ToolPart = ToolUIPart | DynamicToolUIPart
const props = defineProps<{
output: ToolPart["output"]
errorText: ToolPart["errorText"]
class?: HTMLAttributes["class"]
}>()
const show = computed(() => Boolean(props.output || props.errorText))
const isNode = computed(() => isVNode(props.output))
const text = computed(() => {
if (typeof props.output === "string")
return props.output
return JSON.stringify(props.output, null, 2)
})
const pre = "m-0 overflow-x-auto rounded-lg bg-muted px-3 py-2.5 font-mono text-xs leading-[1.6] whitespace-pre text-foreground"
</script>
<template>
<div v-if="show" data-slot="ai-tool-output" :class="props.class">
<div class="mb-1.5 font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase">
{{ props.errorText ? "Error" : "Result" }}
</div>
<pre v-if="props.errorText" :class="cn(pre, 'bg-destructive-soft text-destructive-text')">{{ props.errorText }}</pre>
<div v-else-if="isNode" class="overflow-x-auto">
<component :is="() => props.output" />
</div>
<pre v-else :class="pre">{{ text }}</pre>
</div>
</template>
registry/components/ai/tool/ToolStatusBadge.vue
<script setup lang="ts">
import type { DynamicToolUIPart, ToolUIPart } from "ai"
import type { Component } from "vue"
import {
CheckIcon,
CircleCheckIcon,
CircleIcon,
ClockIcon,
OctagonXIcon,
ShieldIcon,
XIcon,
} from "@lucide/vue"
import { computed } from "vue"
import { Badge } from "@/registry/edmi/ui/badge"
type ToolPart = ToolUIPart | DynamicToolUIPart
type State = ToolPart["state"]
const props = defineProps<{
state: State
}>()
const labels: Record<State, string> = {
"approval-requested": "Awaiting approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
}
const variants: Record<State, "secondary" | "info" | "warning" | "success" | "outline" | "destructive"> = {
"approval-requested": "warning",
"approval-responded": "secondary",
"input-available": "info",
"input-streaming": "secondary",
"output-available": "success",
"output-denied": "outline",
"output-error": "destructive",
}
const icons: Record<State, Component> = {
"approval-requested": ShieldIcon,
"approval-responded": CheckIcon,
"input-available": ClockIcon,
"input-streaming": CircleIcon,
"output-available": CircleCheckIcon,
"output-denied": XIcon,
"output-error": OctagonXIcon,
}
const icon = computed(() => icons[props.state])
</script>
<template>
<Badge data-slot="ai-tool-status" shape="pill" class="gap-1" :variant="variants[state]">
<component :is="icon" :class="state === 'input-available' && 'animate-pulse'" />
{{ labels[state] }}
</Badge>
</template>
registry/components/ai/tool/index.ts
export { default as Tool } from "./Tool.vue";
export { default as ToolContent } from "./ToolContent.vue";
export { default as ToolHeader } from "./ToolHeader.vue";
export { default as ToolInput } from "./ToolInput.vue";
export { default as ToolOutput } from "./ToolOutput.vue";
export { default as ToolStatusBadge } from "./ToolStatusBadge.vue";
src/lib/registry/ai/tool/index.ts
import Root from "./tool.svelte";
import Content from "./tool-content.svelte";
import Header from "./tool-header.svelte";
import Input from "./tool-input.svelte";
import Output from "./tool-output.svelte";
import StatusBadge from "./tool-status-badge.svelte";
export type * from "./types.js";
export {
Content,
Content as ToolContent,
Header,
Header as ToolHeader,
Input,
Input as ToolInput,
Output,
Output as ToolOutput,
Root,
//
Root as Tool,
StatusBadge,
StatusBadge as ToolStatusBadge,
};
src/lib/registry/ai/tool/tool-content.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { CollapsibleContent } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
let {
class: className,
children,
...restProps
}: ComponentProps<typeof CollapsibleContent> = $props();
</script>
<CollapsibleContent
data-slot="ai-tool-content"
class={cn(
"overflow-hidden outline-none data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className
)}
{...restProps}
>
<div class="space-y-3 border-t border-border px-3.5 py-3 text-[13px]">
{@render children?.()}
</div>
</CollapsibleContent>
src/lib/registry/ai/tool/tool-header.svelte
<script lang="ts">
import WrenchIcon from "phosphor-svelte/lib/Wrench";
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { cn } from "$lib/utils.js";
import { CollapsibleTrigger } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
import ToolStatusBadge from "./tool-status-badge.svelte";
import type { ToolState } from "./types.js";
let {
class: className,
title,
type,
state,
toolName,
...restProps
}: Omit<ComponentProps<typeof CollapsibleTrigger>, "children" | "title" | "type"> & {
/** `tool-<name>` (static tools) or `dynamic-tool` (then pass `toolName`). */
type: string;
state: ToolState;
title?: string;
toolName?: string;
} = $props();
const derivedName = $derived(
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-")
);
</script>
<CollapsibleTrigger
data-slot="ai-tool-header"
class={cn(
"group/tool-trigger flex w-full items-center gap-2.5 px-3.5 py-2.5 text-left text-[13.5px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
className
)}
{...restProps}
>
<WrenchIcon
class="size-[15px] shrink-0 text-muted-foreground"
/>
<span class="font-mono text-[12.5px]">{title ?? derivedName}</span>
<ToolStatusBadge {state} />
<CaretDownIcon
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform group-data-[state=open]/tool-trigger:rotate-180"
/>
</CollapsibleTrigger>
src/lib/registry/ai/tool/tool-input.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
class: className,
input,
...restProps
}: HTMLAttributes<HTMLDivElement> & { input: unknown } = $props();
const formatted = $derived(JSON.stringify(input, null, 2));
</script>
<div data-slot="ai-tool-input" class={cn("overflow-hidden", className)} {...restProps}>
<div class="mb-1.5 font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase">
Parameters
</div>
<pre
class="m-0 overflow-x-auto rounded-lg bg-muted px-3 py-2.5 font-mono text-xs leading-[1.6] whitespace-pre text-foreground">{formatted}</pre>
</div>
src/lib/registry/ai/tool/tool-output.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
class: className,
output,
errorText,
...restProps
}: HTMLAttributes<HTMLDivElement> & { output?: unknown; errorText?: string } = $props();
const text = $derived(typeof output === "string" ? output : JSON.stringify(output, null, 2));
const pre =
"m-0 overflow-x-auto rounded-lg bg-muted px-3 py-2.5 font-mono text-xs leading-[1.6] whitespace-pre text-foreground";
</script>
{#if output || errorText}
<div data-slot="ai-tool-output" class={className} {...restProps}>
<div class="mb-1.5 font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase">
{errorText ? "Error" : "Result"}
</div>
{#if errorText}
<pre class={cn(pre, "bg-destructive-soft text-destructive-text")}>{errorText}</pre>
{:else}
<pre class={pre}>{text}</pre>
{/if}
</div>
{/if}
src/lib/registry/ai/tool/tool-status-badge.svelte
<script lang="ts">
import ShieldIcon from "phosphor-svelte/lib/Shield";
import CheckIcon from "phosphor-svelte/lib/Check";
import ClockIcon from "phosphor-svelte/lib/Clock";
import CircleIcon from "phosphor-svelte/lib/Circle";
import CheckCircleIcon from "phosphor-svelte/lib/CheckCircle";
import XIcon from "phosphor-svelte/lib/X";
import XCircleIcon from "phosphor-svelte/lib/XCircle";
import { Badge, type BadgeVariant } from "$lib/registry/ui/badge/index.js";
import type { ToolState } from "./types.js";
let { state }: { state: ToolState } = $props();
const labels: Record<ToolState, string> = {
"approval-requested": "Awaiting approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const variants: Record<ToolState, BadgeVariant> = {
"approval-requested": "warning",
"approval-responded": "secondary",
"input-available": "info",
"input-streaming": "secondary",
"output-available": "success",
"output-denied": "outline",
"output-error": "destructive",
};
</script>
<Badge data-slot="ai-tool-status" shape="pill" class="gap-1" variant={variants[state]}>
{#if state === "approval-requested"}
<ShieldIcon
/>
{:else if state === "approval-responded"}
<CheckIcon
/>
{:else if state === "input-available"}
<ClockIcon
class="animate-pulse"
/>
{:else if state === "input-streaming"}
<CircleIcon
/>
{:else if state === "output-available"}
<CheckCircleIcon
/>
{:else if state === "output-denied"}
<XIcon
/>
{:else}
<XCircleIcon
/>
{/if}
{labels[state]}
</Badge>
src/lib/registry/ai/tool/tool.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Collapsible } from "$lib/registry/ui/collapsible/index.js";
import { type Elevation, setSurface, useElevation } from "$lib/registry/ui/elevation/index.js";
import type { ComponentProps } from "svelte";
let {
class: className,
elevation = "auto",
open = $bindable(false),
children,
...restProps
}: ComponentProps<typeof Collapsible> & {
/** ✦ depth of the card: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
} = $props();
// surface role: same faces as the ui card
const toolElevation = {
sunken: "border-sk-bd bg-sk-bg shadow-sunken",
flat: "",
raised: "border-transparent shadow-raised",
floating: "border-transparent shadow-floating",
};
const level = useElevation(() => elevation, "surface");
setSurface(() => level.current);
</script>
<Collapsible
bind:open
data-slot="ai-tool"
class={cn(
"group/tool not-prose w-full overflow-hidden rounded-xl border border-border bg-card text-card-foreground",
toolElevation[level.current],
className
)}
{...restProps}
>
{@render children?.()}
</Collapsible>
src/lib/registry/ai/tool/types.ts
import type { DynamicToolUIPart, ToolUIPart } from "ai";
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolState = ToolPart["state"];

See the Elevation guide for the levels, roles and layered mode.

The card are flat by default and take the surface level in layered mode (raised). sunken (-1) is a soft inset, raised (+1) a bevel and floating (+2) a bevel with a soft drop.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Flat (0)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Raised (+1)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Floating (+2)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Sunken (-1)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Flat (0)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Raised (+1)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Floating (+2)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Sunken (-1)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Flat (0)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Raised (+1)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}

Floating (+2)

Parameters
{
  "symbol": "NVDAx",
  "window": "30d"
}
Result
{
  "price": 188.2,
  "change": 0.024
}
Part Prop Type Default
Tool elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
Tool open, defaultOpen, onOpenChange collapsible state closed
ToolHeader type, state, title, toolName AI SDK ToolUIPart / DynamicToolUIPart fields
ToolInput input unknown (shown as JSON)
ToolOutput output, errorText unknown, string
getStatusBadge state returns the badge for one of the seven AI SDK states
  • One badge colour per state: pending (secondary), running (info), awaiting approval (warning), responded (secondary), completed (success), denied (outline), error (destructive).
  • The parameters and result render as plain mono blocks on --muted (the error block on --destructive-soft), so the item does not depend on ai-code-block.
  • elevation ✦ puts depth on the card (surface role); a nested card drops to flat.