Render mention directives as inline chips in user messages.
DirectiveText parses the directive syntax written by ComposerTriggerPopover (default: :type[label]{name=id}) and renders each segment as an inline chip. Use it as the Text component in user messages so the raw directive syntax never shows up.
Getting Started
Add directive-text
npx shadcn@latest add @assistant-ui/directive-textThe @assistant-uinamespace resolves the Radix or Base UI flavor from your project's style through the style-aware registry entry in components.json. Without that entry, add by direct URL instead:
npx shadcn@latest add https://r.assistant-ui.com/base/directive-text.jsonMain Component
npm install @assistant-ui/react"use client";import { memo, type FC } from "react";import type { TextMessagePartComponent } from "@assistant-ui/react";import type { Unstable_DirectiveFormatter } from "@assistant-ui/react";import { unstable_defaultDirectiveFormatter } from "@assistant-ui/react";import { Badge } from "./badge";type IconComponent = FC<{ className?: string }>;export type CreateDirectiveTextOptions = { /** Maps a directive `type` to an icon component. */ iconMap?: Record<string, IconComponent>; /** Icon rendered when `iconMap` has no entry for the segment type. */ fallbackIcon?: IconComponent;};/** Creates a `Text` message part component that parses directive syntax and renders inline chips. */export function createDirectiveText( formatter: Unstable_DirectiveFormatter, options?: CreateDirectiveTextOptions,): TextMessagePartComponent { const iconMap = options?.iconMap; const fallbackIcon = options?.fallbackIcon; const Component: TextMessagePartComponent = ({ text }) => { const segments = formatter.parse(text); if (segments.length === 1 && segments[0]!.kind === "text") { return <>{text}</>; } return ( <> {segments.map((seg, i) => { if (seg.kind === "text") { return ( <span key={i} className="whitespace-pre-wrap"> {seg.text} </span> ); } const Icon = iconMap?.[seg.type] ?? fallbackIcon; return ( <Badge key={i} variant="info" size="sm" data-slot="directive-text-chip" data-directive-type={seg.type} data-directive-id={seg.id} aria-label={`${seg.type}: ${seg.label}`} className="aui-directive-chip items-baseline text-[13px] leading-none [&_svg]:self-center" > {Icon && <Icon />} {seg.label} </Badge> ); })} </> ); }; Component.displayName = "DirectiveText"; return Component;}const DirectiveTextImpl = createDirectiveText( unstable_defaultDirectiveFormatter,);/** `Text` message part component that renders directive syntax as inline chips. */export const DirectiveText: TextMessagePartComponent = memo(DirectiveTextImpl);assistant-ui dependencies
npm install @base-ui/react class-variance-authority"use client";import type { ComponentProps } from "react";import { mergeProps } from "@base-ui/react/merge-props";import { useRender } from "@base-ui/react/use-render";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";const badgeVariants = cva( "inline-flex items-center justify-center gap-1 rounded-md text-xs font-medium transition-colors [&_svg]:size-3 [&_svg]:shrink-0", { variants: { variant: { outline: "border-input text-muted-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground border bg-transparent", secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80", muted: "bg-muted text-muted-foreground [a&]:hover:bg-muted/80 [a&]:hover:text-foreground", ghost: "text-muted-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground bg-transparent", info: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300 [a&]:hover:bg-blue-100/80", warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300 [a&]:hover:bg-amber-100/80", success: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 [a&]:hover:bg-emerald-100/80", destructive: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300 [a&]:hover:bg-red-100/80", }, size: { sm: "px-1.5 py-0.5", default: "px-2 py-1", lg: "px-2.5 py-1.5 text-sm", }, }, defaultVariants: { variant: "outline", size: "default", }, },);export type BadgeProps = useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>;function Badge({ className, variant, size, render, ...props }: BadgeProps) { const dataProps = { "data-slot": "badge", "data-variant": variant, "data-size": size, } as ComponentProps<"span">; return useRender({ defaultTagName: "span", props: mergeProps<"span">( dataProps, { className: cn(badgeVariants({ variant, size }), className), }, props, ), render, });}export { Badge, badgeVariants };This adds /components/assistant-ui/directive-text.tsx with DirectiveText and the createDirectiveText(formatter, options?) factory.
Use in user messages
Pass DirectiveText as the Text component in MessagePrimitive.Parts:
import { DirectiveText } from "@/components/assistant-ui/directive-text";
const UserMessage = () => (
<MessagePrimitive.Root>
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
</MessagePrimitive.Root>
);Keep your markdown renderer (e.g. MarkdownText) for assistant messages — assistants rarely emit directive syntax.
Icons per Directive Type
DirectiveText ships icon-free by default — every parsed segment renders as a plain label chip regardless of its type. To add icons, use the createDirectiveText factory and pass an iconMap that routes each type string to an icon component, plus an optional fallbackIcon for unmapped types:
import { createDirectiveText } from "@/components/assistant-ui/directive-text";
import { unstable_defaultDirectiveFormatter } from "@assistant-ui/react";
import { FileTextIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
const DirectiveTextWithIcons = createDirectiveText(
unstable_defaultDirectiveFormatter,
{
iconMap: {
tool: WrenchIcon,
user: UserIcon,
file: FileTextIcon,
},
fallbackIcon: SparklesIcon,
},
);
<MessagePrimitive.Parts components={{ Text: DirectiveTextWithIcons }} />;A matching iconMap / fallbackIcon option is accepted by ComposerTriggerPopover, where it routes item.metadata.icon strings instead of type — keeping icon configuration consistent across the composer and the rendered message.
Custom Formatter
The default format is :type[label]{name=id}. For a different format, build a custom Unstable_DirectiveFormatter and wrap it with createDirectiveText:
import type { Unstable_DirectiveFormatter } from "@assistant-ui/react";
import { createDirectiveText } from "@/components/assistant-ui/directive-text";
const slashFormatter: Unstable_DirectiveFormatter = {
serialize: (item) => `/${item.id}`,
parse: (text) => {
/* return alternating text / mention segments */
},
};
const SlashDirectiveText = createDirectiveText(slashFormatter);Pass the same formatter to the composer trigger's directive={{ formatter }} (or action={{ formatter }}) so insertion and rendering stay consistent.
Customizing the Chip
Because directive-text.tsx is copied into your project, you can also edit it directly — swap the chip styling, read data-directive-id to link to a detail page, or replace the chip wrapper entirely.
API Reference
DirectiveText
A TextMessagePartComponent that parses :type[label]{name=id} directives and renders them as inline chips. Uses the default formatter from @assistant-ui/react and renders chips without icons. For per-type icons, build a component with createDirectiveText(formatter, { iconMap }).
createDirectiveText(formatter, options?)
Factory that returns a TextMessagePartComponent bound to a custom Unstable_DirectiveFormatter.
| Option | Type | Description |
|---|---|---|
iconMap | Record<string, ComponentType<{ className?: string }>> | Maps a directive segment's type to an icon rendered inside the chip |
fallbackIcon | ComponentType<{ className?: string }> | Icon used when no iconMap entry matches. When neither option resolves, the chip renders without an icon. |
Related
- Composer Trigger Popover — the picker that inserts directives
- Mentions guide — directive format, backend parsing