Tool
The tool-call card: status badge and disclosure for arguments and result. Covers every AI SDK tool-part state with no unmapped case; input renders while arguments are still streaming and never throws on partial JSON; both output and errorText undefined renders empty, exactly as the web original.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/tool.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/tool.jsonDependencies
react-native-reanimated
https://reactnativereusables.com/r/nativewind/badge.json |
https://reactnativereusables.com/r/nativewind/collapsible.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/mono.json |
https://rnraielements.com/r/nativewind/status.json |
https://reactnativereusables.com/r/uniwind/badge.json |
https://reactnativereusables.com/r/uniwind/collapsible.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/mono.json |
https://rnraielements.com/r/uniwind/status.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Badge } from '@/registry/nativewind/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/registry/nativewind/components/ui/collapsible';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Text, TextClassContext } from '@/registry/nativewind/components/ui/text';
import { monoStyle } from '@/registry/nativewind/lib/mono';
import { cn } from '@/registry/nativewind/lib/utils';
import {
CircleCheckIcon,
CircleHelpIcon,
CircleIcon,
CircleSlashIcon,
CircleXIcon,
ChevronDownIcon,
ClockIcon,
type LucideIcon,
} from 'lucide-react-native';
import * as React from 'react';
import { Platform, ScrollView, View, type ViewProps } from 'react-native';
import Animated, {
ReduceMotion,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import {
formatToolInput,
toolDisplayName,
toolStatusMeta,
type ToolStatus,
type ToolStatusIconName,
} from './tool.logic';
/**
* Tool — the tool-call card. Name, status badge, and a disclosure with the arguments
* and the result. It is the primary reason a chat UI reads as an agent.
*
* COMPOSITION IS THE WEB ORIGINAL'S: Tool > ToolHeader (state → badge) + ToolContent >
* (ToolInput + ToolOutput). `state` sits on the HEADER exactly where the web original
* puts it, so a consumer streams `part.state` straight in — no adapter — and a state
* change swaps the badge in place without reflowing the transcript (UC-AGENT-01 AC-1).
* The status map itself is pure data in tool.logic.ts, covering EVERY AI SDK tool-part
* state; no state renders nothing.
*
* THE COLLAPSIBLE IS UNSTYLED at the primitive layer, so the disclosure look is
* invented here and kept minimal: `rounded-md border` with NO shadow — the tool card is
* an inset panel, which is exactly the shape law the design contract encodes. The
* chevron rotates with the house motion scale (250ms open / 200ms close) chained to
* ReduceMotion.System; the Running badge's clock pulses on RNR Skeleton's own
* 1000ms signature and is gated on useReducedMotion — one of the looping animations the
* design lens explicitly calls out, and the one place this registry is stricter than
* RNR's own Skeleton.
*
* INPUTS RENDER WHILE STILL STREAMING. During input-streaming the arguments are a
* half-arrived string; formatToolInput displays them verbatim and never throws, so a
* malformed partial never crashes the transcript. Long lines SCROLL horizontally — raw
* JSON never wraps on a phone.
*
* Both output and errorText undefined renders EMPTY — not a spinner, not an error. The
* web original's documented contract, preserved.
*/
type ToolContextValue = { open: boolean };
const ToolContext = React.createContext<ToolContextValue | null>(null);
function useTool() {
const ctx = React.useContext(ToolContext);
if (!ctx) throw new Error('Tool sub-components must be used within <Tool>');
return ctx;
}
type ToolProps = Omit<ViewProps, 'children'> & {
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
};
function Tool({ defaultOpen = false, onOpenChange, className, children, ...props }: ToolProps) {
const [open, setOpen] = React.useState(defaultOpen);
function handleOpenChange(next: boolean) {
setOpen(next);
onOpenChange?.(next);
}
return (
<ToolContext.Provider value={{ open }}>
<Collapsible
open={open}
onOpenChange={handleOpenChange}
className={cn('rounded-md border border-border bg-muted/50', className)}
{...props}
>
{children}
</Collapsible>
</ToolContext.Provider>
);
}
/** iconName (from tool.logic.ts) → Lucide component. Static and exhaustive by type. */
const STATUS_ICONS: Record<ToolStatusIconName, LucideIcon> = {
circle: CircleIcon,
clock: ClockIcon,
'circle-check': CircleCheckIcon,
'circle-x': CircleXIcon,
'circle-slash': CircleSlashIcon,
'circle-help': CircleHelpIcon,
};
/**
* The status icon. Streaming carries the web's plain circle; Running carries the
* clock, and it is the one icon that pulses (remediation row 4, web tool.tsx:61-63).
* For the running state it pulses on RNR Skeleton's signature (1→0.5, 1000ms,
* reversed repeat) — and stands perfectly still when the OS asks for reduced motion,
* which changes nothing visually when the setting is off.
*/
function ToolStatusIcon({ state }: { state: ToolStatus }) {
const meta = toolStatusMeta(state);
const reduced = useReducedMotion();
const looping = state === 'input-available';
const opacity = useSharedValue(1);
React.useEffect(() => {
if (!looping || reduced) {
opacity.value = 1;
return;
}
opacity.value = withRepeat(
withTiming(0.5, { duration: 1000, reduceMotion: ReduceMotion.System }),
-1,
true,
);
}, [looping, reduced, opacity]);
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
return (
<Animated.View style={style}>
<Icon as={STATUS_ICONS[meta.iconName]} size={12} className={cn(meta.className)} />
</Animated.View>
);
}
/** House motion scale for the chevron: 250ms open, 200ms close, system-reduced. */
function Chevron({ open }: { open: boolean }) {
const rotation = useSharedValue(open ? 1 : 0);
React.useEffect(() => {
rotation.value = withTiming(open ? 1 : 0, {
duration: open ? 250 : 200,
reduceMotion: ReduceMotion.System,
});
}, [open, rotation]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value * 180}deg` }],
}));
return (
<Animated.View style={style}>
<Icon as={ChevronDownIcon} size={14} className="text-muted-foreground" />
</Animated.View>
);
}
type ToolHeaderProps = {
/** The AI SDK part type — `tool-fetch_weather_data` or `dynamic-tool`. */
type: string;
/** The AI SDK tool-part `state`, streamed straight in. */
state: ToolStatus;
/** Wins over everything derived from `type`. */
title?: string;
/** A dynamic tool's label comes from here. */
toolName?: string;
className?: string;
};
function ToolHeader({ type, state, title, toolName, className }: ToolHeaderProps) {
const { open } = useTool();
const meta = toolStatusMeta(state);
return (
<CollapsibleTrigger
className={cn(
'flex-row items-center gap-2 rounded-md p-3 active:bg-accent',
Platform.select({ web: 'transition-colors hover:bg-accent/50' }),
className,
)}
>
<Text numberOfLines={1} className="flex-1 text-sm font-medium text-foreground">
{toolDisplayName(type, title, toolName)}
</Text>
<Badge
variant="secondary"
// Color is never the sole channel — the label and icon carry status for a
// screen reader, and the badge is a legitimate live region: it changes at human
// pace (a few times per tool call), unlike streaming text.
accessibilityLabel={`Status: ${meta.label}`}
accessibilityLiveRegion="polite"
// Remediation row 4: the badge family converges to the web's — secondary
// variant, rounded-full, gap-1.5 (web tool.tsx:74).
className="gap-1.5 rounded-full"
>
<ToolStatusIcon state={state} />
<Text className={cn('text-xs', meta.className)}>{meta.label}</Text>
</Badge>
<Chevron open={open} />
</CollapsibleTrigger>
);
}
function ToolContent({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
// The design contract publishes the popover text family here; it must arrive through
// TextClassContext — a parent className never reaches a descendant Text on native.
return (
<TextClassContext.Provider value="text-foreground">
<CollapsibleContent className={cn('px-3 pb-3', className)} {...props}>
<View className="gap-2">{children}</View>
</CollapsibleContent>
</TextClassContext.Provider>
);
}
function ToolSectionLabel({ children }: { children: string }) {
return (
<Text className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{children}
</Text>
);
}
type ToolInputProps = {
input?: unknown;
className?: string;
};
function ToolInput({ input, className }: ToolInputProps) {
const text = React.useMemo(() => formatToolInput(input), [input]);
// Nothing has arrived yet — render nothing rather than an empty Parameters shell.
if (!text) return null;
return (
<View className={cn('gap-1', className)}>
<ToolSectionLabel>Parameters</ToolSectionLabel>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="py-0.5"
>
<Text selectable style={monoStyle} className="text-xs leading-5 text-foreground">
{text}
</Text>
</ScrollView>
</View>
);
}
type ToolOutputProps = {
output?: React.ReactNode;
errorText?: string;
className?: string;
};
function ToolOutput({ output, errorText, className }: ToolOutputProps) {
// The web original's contract, verbatim: both undefined renders EMPTY. Not a
// spinner, not an error — a tool call may legitimately have no result to show.
if (output === undefined && errorText === undefined) return null;
return (
<View className={cn('gap-1', className)}>
{errorText !== undefined ? (
<View className="gap-1">
<ToolSectionLabel>Error</ToolSectionLabel>
<Text selectable className="text-xs leading-5 text-destructive">
{errorText}
</Text>
</View>
) : (
<View className="gap-1">
<ToolSectionLabel>Result</ToolSectionLabel>
{/* A bare string inside a View crashes React Native ("Text strings must be
rendered within a <Text> component") while typechecking as a valid
ReactNode and passing web Storybook — the device gate's exact blind spot.
Mirror ToolInput: strings get a selectable Text; nodes pass through. */}
{typeof output === 'string' ? (
<Text selectable className="text-xs leading-5 text-foreground">
{output}
</Text>
) : (
output
)}
</View>
)}
</View>
);
}
export { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, useTool };
/**
* Tool — pure logic. Everything here is data-in, data-out with zero react-native
* imports, which is what lets the Vitest tier own it (see the header of
* lib/reasoning-lifecycle.ts for why component files themselves cannot load under
* Node). tool.tsx holds only the static iconName → Lucide component table, typed
* exhaustively against `iconName` so a key added here without an icon there fails tsc.
*
* THE INTEGRATION SEAM. `ToolStatus` mirrors the AI SDK tool-part `state` union
* verbatim ('input-streaming' | 'input-available' | 'output-available' | 'output-error'
* | 'output-denied' | 'approval-requested' | 'approval-responded'). It is declared
* locally, not imported from `ai` — message.tsx's precedent: the registry is stateless
* about the model and takes no runtime dependency on any SDK. A consumer streams
* `part.state` straight into `<Tool state={part.state}>` with no adapter.
*/
import { statusColor, type StatusTone } from '@/registry/nativewind/lib/status';
export type ToolStatus =
| 'input-streaming'
| 'input-available'
| 'output-available'
| 'output-error'
| 'output-denied'
| 'approval-requested'
| 'approval-responded';
/** Every AI SDK tool-part state, spelled out. UC-AGENT-01 AC-4: none may be unhandled. */
export const TOOL_STATUS_KEYS = [
'input-streaming',
'input-available',
'output-available',
'output-error',
'output-denied',
'approval-requested',
'approval-responded',
] as const satisfies readonly ToolStatus[];
/** Lucide icon NAME (kebab-case); the component resolves the component. */
export type ToolStatusIconName =
| 'clock'
| 'circle'
| 'circle-check'
| 'circle-x'
| 'circle-slash'
| 'circle-help';
export type ToolStatusMeta = {
/** Badge text — verbatim from the web original's header map. */
label: string;
tone: StatusTone;
iconName: ToolStatusIconName;
/**
* Precomposed text class. '' inherits the Badge's own color — the web gives the
* streaming/running states no hue (remediation row 4). Everything else is either a
* shared statusColor value or the web's approval hues (yellow/blue, dark twins).
*/
className: string;
};
/**
* The web original maps state → badge with NO mapping layer, and so do we — one record,
* every state, labels byte-identical to the original: input-streaming→Pending,
* input-available→Running, output-available→Completed, output-error→Error,
* approval-requested→Awaiting Approval, approval-responded→Responded,
* output-denied→Denied.
*
* HUES CONVERGE TO THE WEB (remediation row 4, web tool.tsx:62-72): streaming and
* running get NO color ('' — the badge's secondary surface carries them, exactly the
* web's uncolored Pending/Running); approval-requested is yellow-600 (dark:yellow-400)
* and approval-responded is blue-600 (dark:blue-400), the web's exact approval pair.
* output-available/output-error/output-denied keep the shared statusColor values — the
* destructive token for error is the recorded keep versus the web's red-600, and
* denied's orange-600 already matches the web. The shared lib/status.ts map is
* INTENTIONALLY UNTOUCHED: its other consumers keep the recorded 5-tone compression.
*
* Icon + color ride along because color is never the sole channel (WCAG 1.4.1): a
* completed call and an errored one must tell apart with the badge text hidden. The
* web pairs the plain circle with streaming and the clock with running (web
* tool.tsx:61-63); the pulse is the one icon the component animates, and it stays on
* input-available only — gated on reduced motion, per the design lens.
*/
export const TOOL_STATUS_META: Record<ToolStatus, ToolStatusMeta> = {
'input-streaming': { label: 'Pending', tone: 'pending', iconName: 'circle', className: '' },
'input-available': { label: 'Running', tone: 'running', iconName: 'clock', className: '' },
'output-available': { label: 'Completed', tone: 'success', iconName: 'circle-check', className: statusColor.success },
'output-error': { label: 'Error', tone: 'error', iconName: 'circle-x', className: statusColor.error },
'output-denied': { label: 'Denied', tone: 'denied', iconName: 'circle-slash', className: statusColor.denied },
'approval-requested': { label: 'Awaiting Approval', tone: 'running', iconName: 'circle-help', className: 'text-yellow-600 dark:text-yellow-400' },
'approval-responded': { label: 'Responded', tone: 'pending', iconName: 'circle-check', className: 'text-blue-600 dark:text-blue-400' },
};
export function toolStatusMeta(state: ToolStatus): ToolStatusMeta {
return TOOL_STATUS_META[state];
}
/**
* The header's display name, with the web original's precedence: an explicit `title`
* wins over everything; `type="tool-web-search"` derives "web-search" by stripping the
* prefix; a dynamic tool's label is its `toolName`; anything else shows the type itself.
*/
export function toolDisplayName(type: string, title?: string, toolName?: string): string {
if (title) return title;
if (type.startsWith('tool-')) return type.slice('tool-'.length);
if (type === 'dynamic-tool' && toolName) return toolName;
return type;
}
/**
* ToolInput must render WHILE ARGUMENTS ARE STILL STREAMING IN (input-streaming), so it
* has to tolerate partial or invalid JSON. Strings pass through verbatim — a
* half-arrived `{"query": "wea` is displayed exactly as received, never parsed, never
* repaired. Objects pretty-print at indent 2, the web original's format. Nothing in
* this function may throw: a circular structure or a stringify-undefined value degrades
* to String() instead of crashing the transcript.
*/
export function formatToolInput(input: unknown): string {
if (input === undefined) return '';
if (typeof input === 'string') return input;
if (input === null) return 'null';
try {
return JSON.stringify(input, null, 2) ?? String(input);
} catch {
return String(input);
}
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| defaultOpen | boolean | undefined | false | — |
| onOpenChange | ((open: boolean) => void) | undefined | — | — |
| children | React.ReactNode | — | — |
Theming
Themed via border-border, bg-muted, text-muted-foreground, bg-accent, text-foreground, text-destructive.
Usage
(o) => (
<Tool>
<ToolHeader
type={(o.type as string) ?? 'tool-fetch_registry_item'}
state={(o.state as 'input-streaming' | 'input-available' | 'output-available' | 'output-error') ?? 'output-available'}
/>
<ToolContent>
<Text className="text-sm text-muted-foreground">
{'{ "item": "conversation", "engine": "uniwind", "tag": "v0.2.0" }'}
</Text>
</ToolContent>
</Tool>
)