Install

npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/tool.json

Dependencies

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

AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92

Source

tool.tsx
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.logic.ts
/** * 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

NameTypeDefaultDescription
defaultOpenboolean | undefinedfalse—
onOpenChange((open: boolean) => void) | undefined——
childrenReact.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> )