{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slash-menu",
  "title": "Slash Menu",
  "description": "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.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/slash-menu/slash-menu.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type SlashCommand = {\n  /** Typed after the slash, such as `translate`. */\n  name: string\n  description?: string\n  /** Argument names in order. The last one takes the rest of the line. */\n  args?: readonly string[]\n}\n\nexport type SlashMenuProps = Omit<\n  React.TextareaHTMLAttributes<HTMLTextAreaElement>,\n  \"value\" | \"defaultValue\" | \"onChange\"\n> & {\n  commands: readonly SlashCommand[]\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  onCommandSelect?: (command: SlashCommand) => void\n  /** Where the menu opens. Auto picks the side with room for it. */\n  side?: \"auto\" | \"top\" | \"bottom\"\n}\n\nexport type ParsedSlashCommand = {\n  command: SlashCommand\n  args: string[]\n}\n\n/** Splits `/name a b c` into its command and arguments. */\nexport function parseSlashCommand(\n  text: string,\n  commands: readonly SlashCommand[]\n): ParsedSlashCommand | null {\n  const match = /^\\s*\\/([\\w-]+)(?:\\s+([\\s\\S]*))?$/.exec(text)\n  if (!match) return null\n\n  const command = commands.find((candidate) => candidate.name === match[1])\n  if (!command) return null\n\n  const rest = (match[2] ?? \"\").trim()\n  const count = command.args?.length ?? 0\n  if (!rest || count === 0) return { command, args: [] }\n\n  const words = rest.split(/\\s+/)\n  const args = words.slice(0, count - 1)\n  const tail = words.slice(count - 1).join(\" \")\n  if (tail) args.push(tail)\n\n  return { command, args }\n}\n\nfunction tokenAt(text: string, caret: number) {\n  const match = /(?:^|\\s)\\/([\\w-]*)$/.exec(text.slice(0, caret))\n  if (!match) return null\n\n  const query = match[1] ?? \"\"\n  return { start: caret - query.length - 1, query }\n}\n\nfunction commandBefore(\n  text: string,\n  caret: number,\n  commands: readonly SlashCommand[]\n) {\n  const before = text.slice(0, caret)\n  const pattern = /(^|\\s)\\/([\\w-]+)/g\n  let found: { command: SlashCommand; end: number } | null = null\n\n  for (const match of before.matchAll(pattern)) {\n    const command = commands.find((candidate) => candidate.name === match[2])\n    const end = (match.index ?? 0) + match[0].length\n    if (command && end < caret) found = { command, end }\n  }\n\n  if (!found) return null\n\n  const count = found.command.args?.length ?? 0\n  if (count === 0) return { command: found.command, arg: -1 }\n\n  const segment = before.slice(found.end)\n  if (!/^\\s/.test(segment)) return null\n\n  const words = segment.trim() ? segment.trim().split(/\\s+/) : []\n  const typing = /\\S$/.test(segment)\n  const index = typing ? words.length - 1 : words.length\n\n  return { command: found.command, arg: Math.min(index, count - 1) }\n}\n\nfunction rank(commands: readonly SlashCommand[], query: string) {\n  const needle = query.toLowerCase()\n  const starts = commands.filter((command) =>\n    command.name.toLowerCase().startsWith(needle)\n  )\n  const contains = commands.filter(\n    (command) =>\n      !command.name.toLowerCase().startsWith(needle) &&\n      command.name.toLowerCase().includes(needle)\n  )\n  return [...starts, ...contains]\n}\n\n/** The tallest the menu gets: its list's max height plus padding and border. */\nconst MENU_ROOM = 256\n\nconst mirrored = [\n  \"boxSizing\",\n  \"width\",\n  \"paddingTop\",\n  \"paddingRight\",\n  \"paddingBottom\",\n  \"paddingLeft\",\n  \"borderTopWidth\",\n  \"borderRightWidth\",\n  \"borderBottomWidth\",\n  \"borderLeftWidth\",\n  \"fontFamily\",\n  \"fontSize\",\n  \"fontWeight\",\n  \"fontStyle\",\n  \"letterSpacing\",\n  \"lineHeight\",\n  \"textTransform\",\n  \"wordSpacing\",\n  \"tabSize\",\n] as const\n\n/** Where a character index sits inside a textarea, measured on a copy. */\nfunction caretPoint(field: HTMLTextAreaElement, index: number) {\n  const styles = window.getComputedStyle(field)\n  const mirror = document.createElement(\"div\")\n\n  for (const property of mirrored) mirror.style[property] = styles[property]\n  mirror.style.position = \"absolute\"\n  mirror.style.visibility = \"hidden\"\n  mirror.style.whiteSpace = \"pre-wrap\"\n  mirror.style.overflowWrap = \"break-word\"\n  mirror.style.top = \"0\"\n  mirror.style.left = \"-9999px\"\n  mirror.textContent = field.value.slice(0, index)\n\n  const marker = document.createElement(\"span\")\n  marker.textContent = \"​\"\n  mirror.append(marker)\n  document.body.append(mirror)\n\n  const point = {\n    top: marker.offsetTop - field.scrollTop,\n    left: marker.offsetLeft - field.scrollLeft,\n    height: marker.offsetHeight || Number.parseFloat(styles.lineHeight) || 20,\n  }\n\n  mirror.remove()\n  return point\n}\n\nfunction Signature({\n  command,\n  active = -1,\n}: {\n  command: SlashCommand\n  active?: number\n}) {\n  return (\n    <span className=\"font-[family-name:var(--font-mono),monospace]\">\n      <span className=\"text-foreground\">/{command.name}</span>\n      {command.args?.map((arg, index) => (\n        <React.Fragment key={arg}>\n          {\" \"}\n          <span\n            className={cn(\n              \"rounded-sm px-0.5\",\n              index === active\n                ? \"bg-primary text-primary-foreground\"\n                : \"text-muted-foreground\"\n            )}\n            data-active={index === active ? \"\" : undefined}\n          >\n            &lt;{arg}&gt;\n          </span>\n        </React.Fragment>\n      ))}\n    </span>\n  )\n}\n\nexport const SlashMenu = React.forwardRef<HTMLTextAreaElement, SlashMenuProps>(\n  function SlashMenu(\n    {\n      commands,\n      value,\n      defaultValue = \"\",\n      onValueChange,\n      onCommandSelect,\n      side = \"auto\",\n      className,\n      onBlur,\n      onClick,\n      onFocus,\n      onKeyDown,\n      onSelect,\n      \"aria-describedby\": describedBy,\n      \"aria-label\": ariaLabel = \"Message\",\n      ...fieldProps\n    },\n    forwardedRef\n  ) {\n    const id = React.useId()\n    const listId = `${id}-commands`\n    const hintId = `${id}-hint`\n    const rootRef = React.useRef<HTMLDivElement>(null)\n    const fieldRef = React.useRef<HTMLTextAreaElement | null>(null)\n    const menuRef = React.useRef<HTMLDivElement>(null)\n    const pendingCaret = React.useRef<number | null>(null)\n\n    const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n    const [caret, setCaret] = React.useState(defaultValue.length)\n    const [focused, setFocused] = React.useState(false)\n    const [dismissed, setDismissed] = React.useState<number | null>(null)\n    const [highlight, setHighlight] = React.useState({ key: \"\", index: 0 })\n\n    const text = value ?? uncontrolled\n    const token = focused ? tokenAt(text, caret) : null\n    const open = token !== null && token.start !== dismissed\n    const matches = open ? rank(commands, token.query) : []\n    const expanded = open && matches.length > 0\n\n    const key = token ? `${token.start}:${token.query}` : \"\"\n    const index =\n      highlight.key === key\n        ? Math.min(highlight.index, Math.max(matches.length - 1, 0))\n        : 0\n    const activeCommand = expanded ? matches[index] : undefined\n\n    const context = open ? null : commandBefore(text, caret, commands)\n\n    const setText = (next: string) => {\n      if (value === undefined) setUncontrolled(next)\n      onValueChange?.(next)\n    }\n\n    const setRefs = (node: HTMLTextAreaElement | null) => {\n      fieldRef.current = node\n      if (typeof forwardedRef === \"function\") forwardedRef(node)\n      else if (forwardedRef) forwardedRef.current = node\n    }\n\n    React.useLayoutEffect(() => {\n      const field = fieldRef.current\n      const at = pendingCaret.current\n      if (!field || at === null) return\n      pendingCaret.current = null\n      field.setSelectionRange(at, at)\n    }, [text])\n\n    const anchor = open ? token.start : null\n\n    React.useLayoutEffect(() => {\n      const root = rootRef.current\n      const field = fieldRef.current\n      const menu = menuRef.current\n      if (anchor === null || !root || !field || !menu) return\n\n      const point = caretPoint(field, anchor)\n      const left = Math.max(\n        0,\n        Math.min(\n          field.offsetLeft + point.left - 8,\n          root.clientWidth - menu.offsetWidth\n        )\n      )\n\n      const caretTop = field.getBoundingClientRect().top + point.top\n      const below = window.innerHeight - (caretTop + point.height)\n      const placed =\n        side !== \"auto\"\n          ? side\n          : below >= MENU_ROOM || below >= caretTop\n            ? \"bottom\"\n            : \"top\"\n\n      menu.dataset.side = placed\n      menu.style.left = `${left}px`\n      if (placed === \"top\") {\n        menu.style.top = \"\"\n        menu.style.bottom = `${root.clientHeight - (field.offsetTop + point.top) + 4}px`\n      } else {\n        menu.style.bottom = \"\"\n        menu.style.top = `${field.offsetTop + point.top + point.height + 4}px`\n      }\n    }, [anchor, side, expanded])\n\n    const activeId = activeCommand\n      ? `${id}-option-${activeCommand.name}`\n      : undefined\n\n    React.useLayoutEffect(() => {\n      if (!activeId) return\n      document.getElementById(activeId)?.scrollIntoView?.({ block: \"nearest\" })\n    }, [activeId])\n\n    const pick = (command: SlashCommand) => {\n      if (!token) return\n\n      const after = text.slice(caret)\n      const inserted = `/${command.name}${/^\\s/.test(after) ? \"\" : \" \"}`\n      const next = text.slice(0, token.start) + inserted + after\n      const at = token.start + inserted.length + (/^\\s/.test(after) ? 1 : 0)\n\n      pendingCaret.current = at\n      setCaret(at)\n      setText(next)\n      onCommandSelect?.(command)\n    }\n\n    const readCaret = (field: HTMLTextAreaElement) =>\n      setCaret(field.selectionStart ?? field.value.length)\n\n    return (\n      <div\n        className=\"relative grid gap-1.5\"\n        data-slot=\"slash-menu\"\n        ref={rootRef}\n      >\n        <textarea\n          {...fieldProps}\n          aria-activedescendant={activeId}\n          aria-autocomplete=\"list\"\n          aria-controls={expanded ? listId : undefined}\n          aria-describedby={\n            [describedBy, context ? hintId : null].filter(Boolean).join(\" \") ||\n            undefined\n          }\n          aria-expanded={expanded}\n          aria-label={ariaLabel}\n          className={cn(\n            \"border-border bg-background placeholder:text-muted-foreground focus-visible:ring-ring/40 block w-full resize-none rounded-[var(--radius)] border px-3.5 py-3 text-sm leading-relaxed outline-none focus-visible:ring-2\",\n            className\n          )}\n          data-slot=\"slash-menu-field\"\n          ref={setRefs}\n          role=\"combobox\"\n          value={text}\n          onBlur={(event) => {\n            setFocused(false)\n            onBlur?.(event)\n          }}\n          onChange={(event) => {\n            const field = event.currentTarget\n            const at = field.selectionStart ?? field.value.length\n            setText(field.value)\n            setCaret(at)\n            if (!tokenAt(field.value, at)) setDismissed(null)\n          }}\n          onClick={(event) => {\n            readCaret(event.currentTarget)\n            onClick?.(event)\n          }}\n          onFocus={(event) => {\n            setFocused(true)\n            readCaret(event.currentTarget)\n            onFocus?.(event)\n          }}\n          onKeyDown={(event) => {\n            if (open && !event.nativeEvent.isComposing) {\n              if (event.key === \"Escape\") {\n                event.preventDefault()\n                event.stopPropagation()\n                setDismissed(token.start)\n                return\n              }\n\n              if (expanded && activeCommand) {\n                const step =\n                  event.key === \"ArrowDown\"\n                    ? 1\n                    : event.key === \"ArrowUp\"\n                      ? -1\n                      : 0\n\n                if (step) {\n                  event.preventDefault()\n                  setHighlight({\n                    key,\n                    index: (index + step + matches.length) % matches.length,\n                  })\n                  return\n                }\n\n                if (\n                  (event.key === \"Tab\" || event.key === \"Enter\") &&\n                  !event.shiftKey\n                ) {\n                  event.preventDefault()\n                  pick(activeCommand)\n                  return\n                }\n              }\n            }\n\n            onKeyDown?.(event)\n          }}\n          onSelect={(event) => {\n            readCaret(event.currentTarget)\n            onSelect?.(event)\n          }}\n        />\n\n        <div\n          className={cn(\n            \"bg-popover text-popover-foreground border-border absolute z-50 w-72 max-w-full overflow-hidden rounded-[var(--radius)] border p-1 shadow-lg\",\n            !open && \"hidden\"\n          )}\n          data-slot=\"slash-menu-popup\"\n          ref={menuRef}\n        >\n          {expanded ? (\n            <div\n              aria-label=\"Commands\"\n              className=\"max-h-60 overflow-y-auto\"\n              id={listId}\n              role=\"listbox\"\n            >\n              {matches.map((command, position) => {\n                const selected = position === index\n                return (\n                  <div\n                    aria-selected={selected}\n                    className={cn(\n                      \"grid cursor-default gap-0.5 rounded-[calc(var(--radius)-0.25rem)] px-2.5 py-2 text-sm\",\n                      selected && \"bg-accent text-accent-foreground\"\n                    )}\n                    data-slot=\"slash-menu-option\"\n                    id={`${id}-option-${command.name}`}\n                    key={command.name}\n                    role=\"option\"\n                    onClick={() => pick(command)}\n                    onMouseDown={(event) => event.preventDefault()}\n                    onMouseMove={() => {\n                      if (!selected) setHighlight({ key, index: position })\n                    }}\n                  >\n                    <Signature command={command} />\n                    {command.description ? (\n                      <span className=\"text-muted-foreground text-xs\">\n                        {command.description}\n                      </span>\n                    ) : null}\n                  </div>\n                )\n              })}\n            </div>\n          ) : open ? (\n            <p className=\"text-muted-foreground px-2.5 py-2 text-sm\">\n              No command matches /{token.query}\n            </p>\n          ) : null}\n        </div>\n\n        {context ? (\n          <p\n            className=\"text-muted-foreground flex flex-wrap items-baseline gap-x-2 px-1 text-xs\"\n            data-slot=\"slash-menu-hint\"\n            id={hintId}\n          >\n            <Signature active={context.arg} command={context.command} />\n            {context.command.description ? (\n              <span>{context.command.description}</span>\n            ) : null}\n          </p>\n        ) : null}\n\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {context && context.arg >= 0\n            ? context.command.args?.[context.arg]\n            : expanded\n              ? `${matches.length} ${matches.length === 1 ? \"command\" : \"commands\"}`\n              : \"\"}\n        </span>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "chat",
    "command",
    "input"
  ],
  "type": "registry:ui"
}