Let users type / in the composer to trigger predefined actions from a popover picker.
Slash commands let users type / in the composer to open a popover, browse available commands, and execute one. Unlike mentions (which only insert a directive into the message), slash commands additionally fire an action callback at the moment of selection.
How It Works
User types "/" → Trigger detected → Adapter provides commands
↓
Callback fired ← User selects command from popover
↓
Directive chip left in composer (or removed if removeOnExecute)The slash command system is built on the same trigger popover architecture as mentions. A slash command declares its behavior with a <TriggerPopover.Action> sub-primitive whose onExecute callback fires when an item is chosen.
By default Action leaves a directive chip in the composer — giving the user (and the LLM) an audit trail of which commands were invoked. Pass removeOnExecute to strip the /command text entirely.
Quick Start
1. Define Commands with unstable_useSlashCommandAdapter
Declare commands (data + execute bundled together, like useAssistantTool). The hook returns { adapter, action } — wire both into a single <TriggerPopover>:
import {
ComposerPrimitive,
unstable_useSlashCommandAdapter,
type Unstable_SlashCommand,
} from "@assistant-ui/react";
const SLASH_COMMANDS: readonly Unstable_SlashCommand[] = [
{
id: "summarize",
description: "Summarize the conversation",
execute: () => console.log("Summarize!"),
},
{
id: "translate",
description: "Translate text to another language",
execute: () => console.log("Translate!"),
},
{
id: "help",
description: "List all available commands",
execute: () => console.log("Help!"),
},
];
function MyComposer() {
const slash = unstable_useSlashCommandAdapter({ commands: SLASH_COMMANDS });
return (
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root>
<ComposerPrimitive.Input placeholder="Type / for commands..." />
<ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
<ComposerPrimitive.Unstable_TriggerPopover
char="/"
adapter={slash.adapter}
className="popover"
>
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{(items) =>
items.map((item, index) => (
<ComposerPrimitive.Unstable_TriggerPopoverItem
key={item.id}
item={item}
index={index}
className="popover-item"
>
<strong>{item.label}</strong>
{item.description && <span>{item.description}</span>}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
))
}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
);
}The label defaults to /${id}; override via label on the command. Icons are strings that your iconMap on the picker UI resolves to components (see ComposerTriggerPopover).
2. Controlling the Chip
By default, a selected /summarize is converted into a directive chip (:command[/summarize]{name=summarize}) in the composer text and the command's execute fires. This keeps an audit trail of which commands were invoked.
To strip the trigger text entirely — useful for purely transient commands — pass removeOnExecute on the hook options:
const slash = unstable_useSlashCommandAdapter({
commands: SLASH_COMMANDS,
removeOnExecute: true,
});3. Custom Dispatch
For side effects on top of execute (logging, analytics, intercept), wrap the hook's onExecute:
<ComposerPrimitive.Unstable_TriggerPopover.Action
onExecute={(item) => {
logCommandUsed(item.id);
slash.action.onExecute(item);
}}
/>Categorized Commands
For categorized navigation (drill-down into groups), return categories from categories() and items from categoryItems(). The popover shows categories first, then items within the selected category:
import type { Unstable_TriggerAdapter } from "@assistant-ui/core";
const adapter: Unstable_TriggerAdapter = {
categories() {
return [
{ id: "actions", label: "Actions" },
{ id: "export", label: "Export" },
];
},
categoryItems(categoryId) {
if (categoryId === "actions") {
return [
{ id: "summarize", type: "command", label: "/summarize", description: "Summarize the conversation" },
{ id: "translate", type: "command", label: "/translate", description: "Translate text" },
];
}
if (categoryId === "export") {
return [
{ id: "pdf", type: "command", label: "/export pdf", description: "Export as PDF" },
{ id: "markdown", type: "command", label: "/export md", description: "Export as Markdown" },
];
}
return [];
},
// Optional — enables search across all categories
search(query) {
const lower = query.toLowerCase();
const all = [...this.categoryItems("actions"), ...this.categoryItems("export")];
return all.filter(
(item) => item.label.toLowerCase().includes(lower) || item.description?.toLowerCase().includes(lower),
);
},
};When using a categorized adapter, add TriggerPopoverCategories to your popover UI:
const commandHandlers: Record<string, () => void> = {
summarize: () => {/* ... */},
pdf: () => {/* ... */},
};
<ComposerPrimitive.Unstable_TriggerPopover
char="/"
adapter={adapter}
>
<ComposerPrimitive.Unstable_TriggerPopover.Action
formatter={unstable_defaultDirectiveFormatter}
onExecute={(item) => commandHandlers[item.id]?.()}
/>
<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, index) => (
<ComposerPrimitive.Unstable_TriggerPopoverItem key={item.id} item={item} index={index}>
{item.label}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
))}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>Combining with Mentions
Slash commands and mentions live under the same TriggerPopoverRoot. Declare one TriggerPopover per trigger — each with its own behavior sub-primitive:
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root>
<ComposerPrimitive.Input placeholder="Type @ to mention, / for commands..." />
<ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
{/* @ mention popover */}
<ComposerPrimitive.Unstable_TriggerPopover
char="@"
adapter={mention.adapter}
>
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
<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>
{/* / slash command popover */}
<ComposerPrimitive.Unstable_TriggerPopover
char="/"
adapter={slash.adapter}
>
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{(items) => items.map((item, index) => (
<ComposerPrimitive.Unstable_TriggerPopoverItem key={item.id} item={item} index={index}>
{item.label}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
))}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>Each TriggerPopover is its own scope — the @ popover and the / popover read state from their own declaration and never collide. Keyboard events route to whichever popover is currently active.
Keyboard Navigation
Same keyboard bindings as mentions:
| Key | Action |
|---|---|
| ArrowDown | Highlight next item |
| ArrowUp | Highlight previous item |
| Enter | Execute highlighted command / drill into category |
| Escape | Close popover |
| Backspace | Go back to categories (when query is empty) |
Trigger Popover Architecture
Both mentions and slash commands are built on a generic trigger popover system:
ComposerPrimitive.Unstable_TriggerPopoverRoot— root provider that groups triggers and owns the input plugin registryComposerPrimitive.Unstable_TriggerPopover— declares one trigger (id, char, adapter) and renders its popover container- Behavior sub-primitives — exactly one per
TriggerPopover:Unstable_TriggerPopover.Directive— writes a formatted directive on selection ("mention" path)Unstable_TriggerPopover.Action— fires a callback on selection ("slash" path); inserts a chip by default, strip withremoveOnExecute
- Shared sub-primitives (
TriggerPopoverCategories,TriggerPopoverItems,TriggerPopoverBack) live inside aTriggerPopover
You can declare any number of triggers under one root and mix behavior types.
ComposerInput Plugin Protocol
Under the hood, each TriggerPopover registers a ComposerInputPlugin with the composer input. This is a generic protocol that decouples the input from any specific trigger:
type ComposerInputPlugin = {
handleKeyDown(e: KeyboardEvent): boolean;
setCursorPosition(pos: number): void;
};The input iterates over registered plugins for keyboard events and cursor changes. This is what enables multiple triggers to coexist without conflict.
Primitives Reference
| Primitive | Description |
|---|---|
Unstable_TriggerPopoverRoot | Root — groups triggers, provides input plugin registry |
Unstable_TriggerPopover | Declares a trigger and renders its popover container |
Unstable_TriggerPopover.Directive | Behavior sub-primitive — inserts a formatted directive on selection |
Unstable_TriggerPopover.Action | Behavior sub-primitive — runs onExecute on selection; chip-by-default |
Unstable_TriggerPopoverCategories | Render-function for the top-level category list |
Unstable_TriggerPopoverCategoryItem | Button that drills into a category (role="option", auto data-highlighted) |
Unstable_TriggerPopoverItems | Render-function for items within the active category or search results |
Unstable_TriggerPopoverItem | Button that selects an item (role="option", auto data-highlighted) |
Unstable_TriggerPopoverBack | Button that navigates back from items to categories |
Related
- Mentions Guide —
@-mention system built on the same architecture - Suggestions Guide — static follow-up prompts (different from slash commands)
- Composer Primitives — underlying composer primitives