Let users @-mention tools or custom items in the AI chat composer to guide the LLM. Mention picker built into assistant-ui's React composer.
Mentions let users type @ in the composer to open a popover picker, select an item (e.g. a tool), and insert a directive into the message text. The LLM can then use the directive as a hint.
How It Works
The mention system has three layers:
- Trigger detection — the composer input watches for a trigger character (
@by default) and extracts the query - Adapter — provides the categories and items to display in the popover (e.g. registered tools)
- Formatter — serializes a selected item into directive text (
:type[label]{name=id}) and parses it back for rendering
Under the hood, mentions are one kind of trigger popover. A mention declares its behavior with a <TriggerPopover.Directive> sub-primitive, which writes the formatter-serialized directive into the composer on selection.
Quick Start
The fastest path is the pre-built Mention UI components, which wire everything together with two shadcn components — the popover picker and the message-side chip renderer:
npx shadcn@latest add @assistant-ui/composer-trigger-popover @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/composer-trigger-popover.json https://r.assistant-ui.com/base/directive-text.jsonMain Component
npm install @assistant-ui/react"use client";import { memo, useRef, type ComponentPropsWithoutRef, type FC } from "react";import { ComposerPrimitive, unstable_defaultDirectiveFormatter, unstable_useTriggerPopoverScopeContext, type Unstable_DirectiveFormatter, type Unstable_TriggerItem,} from "@assistant-ui/react";import { ChevronLeftIcon, ChevronRightIcon, SparklesIcon } from "lucide-react";import { cn } from "@/lib/utils";type IconComponent = FC<{ className?: string }>;type DirectiveBehaviorProps = { /** Formatter used to serialize the selected item into composer text. */ formatter?: Unstable_DirectiveFormatter | undefined; /** Called after the directive text has been inserted into the composer. */ onInserted?: ((item: Unstable_TriggerItem) => void) | undefined;};type ActionBehaviorProps = { /** Formatter used to serialize the audit-trail chip (when `removeOnExecute` is false). */ formatter?: Unstable_DirectiveFormatter | undefined; /** Invoked with the selected item at the moment of selection. */ onExecute: (item: Unstable_TriggerItem) => void; /** If `true`, strip the trigger text from the composer after executing. @default false */ removeOnExecute?: boolean | undefined;};type ComposerTriggerPopoverBaseProps = Omit< ComponentPropsWithoutRef<typeof ComposerPrimitive.Unstable_TriggerPopover>, "children"> & { /** * Maps icon keys to components. Items look up via `item.metadata?.icon` * (string); categories look up via their `id`. */ iconMap?: Record<string, IconComponent>; /** Fallback icon when no entry in `iconMap` matches. */ fallbackIcon?: IconComponent; /** Label shown on the back button. @default "Back" */ backLabel?: string; /** Label shown when no categories are available. @default "No items available" */ emptyCategoriesLabel?: string; /** Label shown when no items match. @default "No matching items" */ emptyItemsLabel?: string; /** Label shown while an async adapter is resolving items. @default "Loading…" */ loadingLabel?: string;};type ComposerTriggerPopoverProps = ComposerTriggerPopoverBaseProps & ( | { /** Insert-directive behavior. */ directive: DirectiveBehaviorProps; action?: never; } | { /** Action behavior. */ action: ActionBehaviorProps; directive?: never; } );function resolveIcon( iconKey: string | undefined, iconMap: Record<string, IconComponent> | undefined, fallback: IconComponent,): IconComponent { if (iconKey && iconMap?.[iconKey]) return iconMap[iconKey]!; return fallback;}type CategoriesProps = { iconMap: Record<string, IconComponent> | undefined; fallbackIcon: IconComponent; emptyLabel: string;};const Categories: FC<CategoriesProps> = ({ iconMap, fallbackIcon, emptyLabel,}) => ( <ComposerPrimitive.Unstable_TriggerPopoverCategories> {(categories) => ( <div data-slot="composer-trigger-popover-categories" className="flex flex-col py-1" > {categories.map((cat) => { const Icon = resolveIcon(cat.id, iconMap, fallbackIcon); return ( <ComposerPrimitive.Unstable_TriggerPopoverCategoryItem key={cat.id} categoryId={cat.id} className="hover:bg-accent focus:bg-accent data-[highlighted]:bg-accent flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm transition-colors outline-none" > <span className="flex items-center gap-2"> <Icon className="text-muted-foreground size-4" /> {cat.label} </span> <ChevronRightIcon className="text-muted-foreground size-4" /> </ComposerPrimitive.Unstable_TriggerPopoverCategoryItem> ); })} {categories.length === 0 && ( <div className="text-muted-foreground px-3 py-2 text-sm"> {emptyLabel} </div> )} </div> )} </ComposerPrimitive.Unstable_TriggerPopoverCategories>);type ItemsProps = { iconMap: Record<string, IconComponent> | undefined; fallbackIcon: IconComponent; backLabel: string; emptyLabel: string; loadingLabel: string;};const Items: FC<ItemsProps> = ({ iconMap, fallbackIcon, backLabel, emptyLabel, loadingLabel,}) => { const { isLoading } = unstable_useTriggerPopoverScopeContext(); return ( <ComposerPrimitive.Unstable_TriggerPopoverItems> {(items) => ( <div data-slot="composer-trigger-popover-items" className="flex flex-col" > <ComposerPrimitive.Unstable_TriggerPopoverBack className="text-muted-foreground hover:bg-accent flex cursor-pointer items-center gap-1.5 border-b px-3 py-2 text-xs tracking-wide uppercase transition-colors"> <ChevronLeftIcon className="size-3.5" /> {backLabel} </ComposerPrimitive.Unstable_TriggerPopoverBack> <div className="py-1"> {items.map((item, index) => { const iconKey = typeof item.metadata?.icon === "string" ? item.metadata.icon : undefined; const Icon = resolveIcon(iconKey, iconMap, fallbackIcon); return ( <ComposerPrimitive.Unstable_TriggerPopoverItem key={item.id} item={item} index={index} className="hover:bg-accent focus:bg-accent data-[highlighted]:bg-accent flex w-full cursor-pointer flex-col items-start gap-0.5 px-3 py-2 text-start transition-colors outline-none" > <span className="flex items-center gap-2 text-sm font-medium"> <Icon className="text-primary size-3.5" /> {item.label} </span> {item.description && ( <span className="text-muted-foreground ms-5.5 text-xs leading-tight"> {item.description} </span> )} </ComposerPrimitive.Unstable_TriggerPopoverItem> ); })} {items.length === 0 && ( <div className="text-muted-foreground px-3 py-2 text-sm"> {isLoading ? loadingLabel : emptyLabel} </div> )} </div> </div> )} </ComposerPrimitive.Unstable_TriggerPopoverItems> );};/** * Pre-built popover UI for a trigger-driven picker (mentions, slash commands, etc). * Pass exactly one of `directive` (inserts a chip) or `action` (fires a handler). */const ComposerTriggerPopoverImpl: FC<ComposerTriggerPopoverProps> = ({ iconMap, fallbackIcon = SparklesIcon, backLabel = "Back", emptyCategoriesLabel = "No items available", emptyItemsLabel = "No matching items", loadingLabel = "Loading…", className, directive, action, ...props}) => { const warnedRef = useRef(false); if ( process.env.NODE_ENV !== "production" && !warnedRef.current && Boolean(directive) === Boolean(action) ) { warnedRef.current = true; console.warn( "[assistant-ui] ComposerTriggerPopover requires exactly one of `directive` or `action` props.", ); } return ( <ComposerPrimitive.Unstable_TriggerPopover data-slot="composer-trigger-popover" className={cn( "aui-composer-trigger-popover bg-popover text-popover-foreground absolute start-0 bottom-full z-50 mb-2 w-64 overflow-hidden rounded-xl border shadow-lg", className, )} {...props} > {directive ? ( <ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={directive.formatter ?? unstable_defaultDirectiveFormatter} onInserted={directive.onInserted} /> ) : action ? ( <ComposerPrimitive.Unstable_TriggerPopover.Action formatter={action.formatter ?? unstable_defaultDirectiveFormatter} onExecute={action.onExecute} removeOnExecute={action.removeOnExecute} /> ) : null} <Categories iconMap={iconMap} fallbackIcon={fallbackIcon} emptyLabel={emptyCategoriesLabel} /> <Items iconMap={iconMap} fallbackIcon={fallbackIcon} backLabel={backLabel} emptyLabel={emptyItemsLabel} loadingLabel={loadingLabel} /> </ComposerPrimitive.Unstable_TriggerPopover> );};ComposerTriggerPopoverImpl.displayName = "ComposerTriggerPopover";export const ComposerTriggerPopover = memo( ComposerTriggerPopoverImpl,) as FC<ComposerTriggerPopoverProps>;"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 };See the Composer Trigger Popover and Directive Text guides for setup steps.
The rest of this guide covers the underlying concepts and customization points.
Trigger Adapter
A Unstable_TriggerAdapter provides the data for the popover. All methods are synchronous — use external state management (React Query, SWR, local state) for async data, then expose loaded results through the adapter.
import type { Unstable_TriggerAdapter } from "@assistant-ui/core";
const myAdapter: Unstable_TriggerAdapter = {
categories() {
return [
{ id: "tools", label: "Tools" },
{ id: "users", label: "Users" },
];
},
categoryItems(categoryId) {
if (categoryId === "tools") {
return [
{ id: "search", type: "tool", label: "Search" },
{ id: "calculator", type: "tool", label: "Calculator" },
];
}
if (categoryId === "users") {
return [
{ id: "alice", type: "user", label: "Alice" },
{ id: "bob", type: "user", label: "Bob" },
];
}
return [];
},
// Optional — global search across all categories
search(query) {
const lower = query.toLowerCase();
const all = [
...this.categoryItems("tools"),
...this.categoryItems("users"),
];
return all.filter(
(item) =>
item.label.toLowerCase().includes(lower) ||
item.id.toLowerCase().includes(lower),
);
},
};Async Mention Search
The built-in unstable_useLiveCompletionAdapter wraps an async fetcher with debouncing, stale-request cancellation (results for an outdated query are dropped), and a single-entry cache. Its search returns the last results synchronously and schedules a debounced fetch when the query changes; when results arrive the returned adapter re-creates, which re-runs the popover lookup so the fresh items render. It also reports isLoading, which you pass to the popover to show a loading state.
import {
unstable_useLiveCompletionAdapter,
unstable_defaultDirectiveFormatter,
} from "@assistant-ui/react";
import { ComposerTriggerPopover } from "@/components/assistant-ui/composer-trigger-popover";
function MentionPopover() {
const mentions = unstable_useLiveCompletionAdapter({
fetcher: async (query) => {
const users = await fetchUsers(query);
return users.map((u) => ({ id: u.id, type: "user", label: u.name }));
},
});
return (
<ComposerTriggerPopover
char="@"
adapter={mentions.adapter}
isLoading={mentions.isLoading}
directive={{ formatter: unstable_defaultDirectiveFormatter }}
/>
);
}The adapter interface is itself synchronous, so you can also wire async data by hand when you need a custom cache, no debounce, or an existing query client. Load results into React state (or a query cache) and read the current snapshot inside the adapter methods. The adapter re-creates on each render, so the popover always sees the latest results.
With React state and useEffect:
import { useState, useEffect, useMemo } from "react";
import type { Unstable_TriggerAdapter } from "@assistant-ui/core";
function useUserMentionAdapter(query: string) {
const [users, setUsers] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
if (!query) return;
let cancelled = false;
fetchUsers(query).then((results) => {
if (!cancelled) setUsers(results);
});
return () => { cancelled = true; };
}, [query]);
const adapter: Unstable_TriggerAdapter = useMemo(() => ({
categories: () => [],
categoryItems: () => [],
search: () =>
users.map((u) => ({ id: u.id, type: "user", label: u.name })),
}), [users]);
return adapter;
}query here is the text the user typed after @. You can read it from unstable_useTriggerPopoverScopeContext if you need it inside the component tree, or pass it as state from a controlled input.
With React Query:
import { useQuery } from "@tanstack/react-query";
import type { Unstable_TriggerAdapter } from "@assistant-ui/core";
function useMentionAdapter(query: string): Unstable_TriggerAdapter {
const { data = [] } = useQuery({
queryKey: ["mention-search", query],
queryFn: () => fetchUsers(query),
enabled: query.length > 0,
});
return useMemo(() => ({
categories: () => [],
categoryItems: () => [],
search: () =>
data.map((u) => ({ id: u.id, type: "user", label: u.name })),
}), [data]);
}Pass the adapter to TriggerPopover and declare a Directive sub-primitive to bind the insertion behavior:
import { ComposerPrimitive } from "@assistant-ui/react";
import { unstable_defaultDirectiveFormatter } from "@assistant-ui/react";
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root>
<ComposerPrimitive.Input placeholder="Type @ to mention..." />
<ComposerPrimitive.Unstable_TriggerPopover
char="@"
adapter={myAdapter}
>
<ComposerPrimitive.Unstable_TriggerPopover.Directive
formatter={unstable_defaultDirectiveFormatter}
/>
<ComposerPrimitive.Unstable_TriggerPopoverCategories>
{(categories) =>
categories.map((cat) => (
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
key={cat.id}
categoryId={cat.id}
>
{cat.label}
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
))
}
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{(items) =>
items.map((item) => (
<ComposerPrimitive.Unstable_TriggerPopoverItem
key={item.id}
item={item}
>
{item.label}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
))
}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>Exactly one behavior sub-primitive (Directive or Action) is allowed per TriggerPopover. The parent reads the registered behavior and wires the selection machinery.
Built-in Mention Adapter
unstable_useMentionAdapter covers the common cases: mention registered tools, add your own items, mix tools with custom items, or show multi-category drill-down.
Tools from model context (default):
import { unstable_useMentionAdapter } from "@assistant-ui/react";
const mention = unstable_useMentionAdapter();
// → { adapter, directive } — spread into <ComposerTriggerPopover {...mention} />
// Default: single "Tools" category reading from toolkit registrationsCustom items only (no tools):
const mention = unstable_useMentionAdapter({
items: [
{ id: "alice", type: "user", label: "Alice", icon: "User" },
{ id: "bob", type: "user", label: "Bob", icon: "User" },
],
});Mix custom items with model-context tools (flat):
const mention = unstable_useMentionAdapter({
items: [{ id: "kb", type: "doc", label: "Knowledge Base", icon: "Book" }],
includeModelContextTools: true,
});Multi-category drill-down:
const mention = unstable_useMentionAdapter({
categories: [
{
id: "users",
label: "Users",
items: [
{ id: "alice", type: "user", label: "Alice", icon: "User" },
{ id: "bob", type: "user", label: "Bob", icon: "User" },
],
},
{
id: "files",
label: "Files",
items: [
{ id: "readme", type: "file", label: "README.md", icon: "FileText" },
],
},
],
// Tools auto-appended as their own category (default id "tools", label "Tools")
includeModelContextTools: true,
});Tool formatting and category override:
const mention = unstable_useMentionAdapter({
categories: [{ id: "users", label: "Users", items: [...] }],
includeModelContextTools: {
category: { id: "integrations", label: "Integrations" },
formatLabel: (name) =>
name.replaceAll("_", " ").replace(/\b\w/g, (c) => c.toUpperCase()),
icon: "Wrench",
},
});Options summary:
| Option | Type | Behavior |
|---|---|---|
items | Unstable_Mention[] | Flat list (ignored when categories is set) |
categories | {id, label, items}[] | Drill-down groups |
includeModelContextTools | boolean | object | Default: true iff neither items nor categories |
formatter | Unstable_DirectiveFormatter | Override directive serialization (default: unstable_defaultDirectiveFormatter) |
onInserted | (item) => void | Fires after the directive is inserted into the composer |
iconMap | Record<string, IconComponent> | Maps metadata.icon / category id strings to React components |
fallbackIcon | IconComponent | Fallback when no entry in iconMap matches |
icon on each mention is a shortcut for metadata.icon that the picker UI resolves via iconMap. Dedup between custom items and model-context tools is by id — explicit items win.
The hook returns { adapter, directive, iconMap?, fallbackIcon? } — spread into <ComposerTriggerPopover {...mention} /> for one-line wiring. Callers consuming the raw primitives instead destructure: mention.adapter, mention.directive.formatter, etc.
Directive Format
When a user selects a mention item, it is serialized into the composer text as a directive. The default format is:
:type[label]{name=id}For example, selecting a tool named "get_weather" with label "Get Weather" produces:
:tool[Get Weather]{name=get_weather}When id equals label, the {name=…} attribute is omitted for brevity:
:tool[search]Custom Formatter
Implement Unstable_DirectiveFormatter to use a different format:
import type { Unstable_DirectiveFormatter } from "@assistant-ui/react";
const slashFormatter: Unstable_DirectiveFormatter = {
serialize(item) {
return `/${item.id}`;
},
parse(text) {
const segments = [];
const re = /\/(\w+)/g;
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ kind: "text" as const, text: text.slice(lastIndex, match.index) });
}
segments.push({
kind: "mention" as const,
type: "tool",
label: match[1]!,
id: match[1]!,
});
lastIndex = re.lastIndex;
}
if (lastIndex < text.length) {
segments.push({ kind: "text" as const, text: text.slice(lastIndex) });
}
return segments;
},
};Pass it to the trigger's Directive sub-primitive and the message renderer:
// Composer
<ComposerPrimitive.Unstable_TriggerPopover
char="@"
adapter={adapter}
>
<ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={slashFormatter} />
...
</ComposerPrimitive.Unstable_TriggerPopover>
// User messages
const SlashDirectiveText = createDirectiveText(slashFormatter);
<MessagePrimitive.Parts components={{ Text: SlashDirectiveText }} />Textarea vs Lexical
The mention system supports two input modes:
| Textarea (default) | Lexical | |
|---|---|---|
| Input component | ComposerPrimitive.Input | LexicalComposerInput |
| Mention display in composer | Raw directive text (:tool[Label]) | Inline chips (atomic nodes) |
| Dependencies | None | @assistant-ui/react-lexical, lexical, @lexical/react |
| Best for | Simple setups, minimal bundle | Rich editing, polished UX |
With textarea, selecting a mention inserts the directive string directly into the text. The user sees :tool[Get Weather]{name=get_weather} in the input.
With Lexical, selected mentions appear as styled inline chips that behave as atomic units — they can be selected, deleted, and undone as a whole. The underlying text still uses the directive format.
import { LexicalComposerInput } from "@assistant-ui/react-lexical";
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root>
<LexicalComposerInput placeholder="Type @ to mention..." />
<ComposerPrimitive.Send />
<ComposerPrimitive.Unstable_TriggerPopover
char="@"
adapter={adapter}
>
<ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={formatter} />
...
</ComposerPrimitive.Unstable_TriggerPopover>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>LexicalComposerInput automatically discovers every Directive trigger registered under TriggerPopoverRoot and renders their selections as inline chips.
Custom Lexical Plugins
Children of LexicalComposerInput render inside the LexicalComposer context after the built-in plugins, so standard Lexical plugin components built on useLexicalComposerContext work for editor concerns the mention system does not cover, such as paste normalization or length limits. Custom plugins import Lexical APIs directly, so install lexical and @lexical/react as direct dependencies of your app. The example below registers an update listener that flags messages over a maximum length.
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useEffect } from "react";
import { $getRoot } from "lexical";
function MaxLengthPlugin({ maxLength }: { maxLength: number }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
if ($getRoot().getTextContent().length > maxLength) {
console.warn(`Message exceeds ${maxLength} characters`);
}
});
});
}, [editor, maxLength]);
return null;
}
<LexicalComposerInput placeholder="Ask anything...">
<MaxLengthPlugin maxLength={2000} />
</LexicalComposerInput>Rendering Mentions in Messages
Use DirectiveText as the Text component for user messages so directives render as inline chips instead of raw syntax. See the Directive Text guide for setup and customization.
Processing Mentions on the Backend
The message text arrives at your backend with directives inline. Parse them to extract mentioned items:
// Default format: :type[label]{name=id}
const DIRECTIVE_RE = /:([\w-]+)\[([^\]]+)\](?:\{name=([^}]+)\})?/g;
function parseMentions(text: string) {
const mentions = [];
let match;
while ((match = DIRECTIVE_RE.exec(text)) !== null) {
mentions.push({
type: match[1], // e.g. "tool"
label: match[2], // e.g. "Get Weather"
id: match[3] ?? match[2], // e.g. "get_weather"
});
}
return mentions;
}
// Example:
// parseMentions("Use :tool[Get Weather]{name=get_weather} to check")
// → [{ type: "tool", label: "Get Weather", id: "get_weather" }]You can use the extracted mentions to:
- Force-enable specific tools for the LLM call
- Add context about mentioned users or documents to the system prompt
- Log which tools users request most often
Reading Mention State
Use unstable_useTriggerPopoverScopeContext inside the TriggerPopover to programmatically access the popover state for that trigger:
import { unstable_useTriggerPopoverScopeContext } from "@assistant-ui/react";
function MyPopoverContent() {
const scope = unstable_useTriggerPopoverScopeContext();
// scope.open — whether the popover is visible
// scope.query — current search text after the trigger
// scope.categories — filtered category list
// scope.items — filtered item list
// scope.highlightedIndex — keyboard-navigated index
// scope.isSearchMode — true when global search is active
// scope.selectItem(item) — programmatically select an item
// scope.close() — close the popover
return null;
}This hook must be used inside a ComposerPrimitive.Unstable_TriggerPopover.
To iterate every registered trigger (e.g. from a custom input implementation), use unstable_useTriggerPopoverTriggers inside TriggerPopoverRoot.
Building a Custom Popover
Use the trigger popover primitives to build a fully custom popover:
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root>
<ComposerPrimitive.Input />
<ComposerPrimitive.Unstable_TriggerPopover
char="@"
adapter={adapter}
className="popover"
>
<ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={formatter} />
<ComposerPrimitive.Unstable_TriggerPopoverBack>
← Back
</ComposerPrimitive.Unstable_TriggerPopoverBack>
<ComposerPrimitive.Unstable_TriggerPopoverCategories>
{(categories) =>
categories.map((cat) => (
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
key={cat.id}
categoryId={cat.id}
>
{cat.label}
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
))
}
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{(items) =>
items.map((item) => (
<ComposerPrimitive.Unstable_TriggerPopoverItem
key={item.id}
item={item}
>
{item.label}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
))
}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
<ComposerPrimitive.Send />
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>Primitives Reference
See the Composer Primitives reference for the full list of trigger popover primitives and their props.
Combining with Slash Commands
Mentions and slash commands coexist on the same composer. See Combining Slash Commands and Mentions for the full pattern.
Related
- ComposerTriggerPopover UI Component — pre-built shadcn component
- DirectiveText UI Component — renders mention chips in user messages
- Slash Commands Guide —
/command system built on the same architecture - Tools Guide — register tools that appear in the mention picker
- Composer Primitives — underlying composer primitives