Mischief

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

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/slash-menu
import { 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.

registry/default/slash-menu/slash-menu.tsx
"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?"] }
The same rule, exported for the moment the message is sent. It returns null for text that is not a known command.

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.