120 / Agent UI
Slash Menu
A message field that opens a command menu when a word starts with a slash, then spells out the chosen command's arguments and marks the one you are typing.
Try /translate Spanish Where is the station?
- /translate
- Translate text into another language
- /summarize
- Summarize the conversation so far
- /rename
- Rename a symbol across the project
- /model
- Switch the model for this chat
- /clear
- Start a new conversation
"use client"
import * as React from "react"
import {
parseSlashCommand,
SlashMenu,
type SlashCommand,
} from "mischief-ui/slash-menu"
const commands: SlashCommand[] = [
{
name: "translate",
description: "Translate text into another language",
args: ["language", "text"],
},
{
name: "summarize",
description: "Summarize the conversation so far",
args: ["length"],
},
{
name: "rename",
description: "Rename a symbol across the project",
args: ["from", "to"],
},
{
name: "model",
description: "Switch the model for this chat",
args: ["name"],
},
{ name: "clear", description: "Start a new conversation" },
]
export function SlashMenuDemo() {
const [text, setText] = React.useState("")
const [result, setResult] = React.useState<string | null>(null)
const submit = () => {
const parsed = parseSlashCommand(text, commands)
if (!text.trim()) return
if (parsed) {
const names = parsed.command.args ?? []
const filled = names.map(
(name, index) => `${name}: ${parsed.args[index] ?? "(missing)"}`
)
setResult(
`Ran /${parsed.command.name}${filled.length ? ` with ${filled.join(", ")}` : ""}`
)
} else {
setResult(`Sent: ${text.trim()}`)
}
setText("")
}
return (
<div className="grid min-h-[19rem] w-full max-w-xl content-start gap-3">
<SlashMenu
commands={commands}
placeholder="Type / for commands"
rows={2}
value={text}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey) return
if (event.nativeEvent.isComposing) return
event.preventDefault()
submit()
}}
onValueChange={setText}
/>
<p className="text-muted-foreground text-xs" role="status">
{result ?? "Try /translate Spanish Where is the station?"}
</p>
<dl className="border-border grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 border-t pt-3 text-xs">
{commands.map((command) => (
<React.Fragment key={command.name}>
<dt className="font-[family-name:var(--font-mono),monospace]">
/{command.name}
</dt>
<dd className="text-muted-foreground">{command.description}</dd>
</React.Fragment>
))}
</dl>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/slash-menuimport { SlashMenu } from "mischief-ui/slash-menu"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react" import { cn } from "@/lib/utils" export type SlashCommand = { /** Typed after the slash, such as `translate`. */ name: string description?: string /** Argument names in order. The last one takes the rest of the line. */ args?: readonly string[]} Usage
const commands: SlashCommand[] = [
{
name: "translate",
description: "Translate text into another language",
args: ["language", "text"],
},
{ name: "clear", description: "Start a new conversation" },
]
export function Composer() {
const [text, setText] = useState("")
return (
<SlashMenu
commands={commands}
value={text}
onValueChange={setText}
placeholder="Type / for commands"
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey) return
event.preventDefault()
run(parseSlashCommand(text, commands) ?? text)
setText("")
}}
/>
)
}Arguments
args names a command's arguments in order. After a command, a line under the field shows its signature, such as /translate <language> <text>, with the argument the caret is in marked. The last argument takes the rest of the line, so a sentence counts as one <text>.
parseSlashCommand("/translate Spanish Where is the station?", commands)
// { command: translate, args: ["Spanish", "Where is the station?"] }API
commandsreadonly SlashCommand[]The commands the menu offers.valuestringThe field text, when controlled.defaultValuestringThe starting text, when uncontrolled.onValueChange(value: string) => voidReceives the text after every change, including a pick.onCommandSelect(command: SlashCommand) => voidRuns when a command is picked from the menu.side"auto" | "top" | "bottom"Where the menu opens. Auto picks the side with room. Defaults to auto.name, description, args (SlashCommand)string, string, readonly string[]What is typed after the slash, a line of help, and the argument names in order.parseSlashCommand (parseSlashCommand)(text: string, commands: readonly SlashCommand[]) => ParsedSlashCommand | nullSplits a message into its command and arguments.command, args (ParsedSlashCommand)SlashCommand, string[]What parseSlashCommand returns.classNamestringClasses for the textarea. Its other attributes pass through....textareaPropsTextareaHTMLAttributesNative textarea attributes. aria-label defaults to "Message".Accessibility
The field follows the combobox pattern: role combobox with aria-autocomplete list, aria-expanded, aria-controls and aria-activedescendant pointing at the highlighted option, so focus never leaves the text while the arrow keys move through a listbox of options. Escape closes the menu without reaching handlers further up, such as a stop-generating shortcut. The argument hint is attached to the field with aria-describedby, and a polite live region names the argument the caret has moved into and how many commands match. The menu appears and disappears without animation, so reduced motion changes nothing.