{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-composer",
  "registryDependencies": [
    "https://turnui.xyz/r/turn-button.json",
    "utils",
    "https://turnui.xyz/r/command-menu.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/prompt-composer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"./turn-button\";\nimport { CommandMenu, filterCommands, optionId, type SlashCommand } from \"./command-menu\";\n\nexport function PromptComposer({\n  onSubmit,\n  onStop,\n  isStreaming = false,\n  placeholder = \"Ask anything…\",\n  attachments,\n  toolbar,\n  onFiles,\n  accept,\n  commands,\n  onCommand,\n  allowEmpty = false,\n  submitDisabled = false,\n  className,\n}: {\n  onSubmit: (value: string) => void;\n  onStop?: () => void;\n  isStreaming?: boolean;\n  placeholder?: string;\n  /** Rendered above the input, e.g. an <AttachmentList />. */\n  attachments?: React.ReactNode;\n  /** Enables the attach button, drag-and-drop and paste of files. */\n  onFiles?: (files: File[]) => void;\n  /** Passed to the file picker, e.g. \"image/*,.pdf\". */\n  accept?: string;\n  /** Rendered below the input, e.g. a <ModelPicker />. */\n  toolbar?: React.ReactNode;\n  /** Typing \"/\" at the start of the input opens a command menu. */\n  commands?: SlashCommand[];\n  /** Called when a command without `insert` is chosen. The \"/query\" text is cleared. */\n  onCommand?: (command: SlashCommand) => void;\n  /** Allow sending with no text (e.g. attachments only). */\n  allowEmpty?: boolean;\n  /** Block sending, e.g. while uploads are in progress. */\n  submitDisabled?: boolean;\n  className?: string;\n}) {\n  const [value, setValue] = React.useState(\"\");\n  const [dragging, setDragging] = React.useState(false);\n  const ref = React.useRef<HTMLTextAreaElement>(null);\n  const fileInput = React.useRef<HTMLInputElement>(null);\n  const dragDepth = React.useRef(0);\n  const menuId = React.useId();\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const [dismissed, setDismissed] = React.useState(false);\n  const caretToEnd = React.useRef(false);\n\n  // The menu is open while the whole input is a single \"/token\".\n  const slashQuery = commands ? /^\\/(\\S*)$/.exec(value)?.[1] : undefined;\n  const matches = React.useMemo(\n    () => (commands && slashQuery !== undefined ? filterCommands(commands, slashQuery) : []),\n    [commands, slashQuery],\n  );\n  const menuOpen = !dismissed && matches.length > 0;\n  const active = Math.min(activeIndex, Math.max(0, matches.length - 1));\n\n  React.useLayoutEffect(() => {\n    if (!caretToEnd.current) return;\n    caretToEnd.current = false;\n    const el = ref.current;\n    el?.focus();\n    el?.setSelectionRange(el.value.length, el.value.length);\n  }, [value]);\n\n  function selectCommand(command: SlashCommand) {\n    setDismissed(false);\n    setActiveIndex(0);\n    if (command.insert !== undefined) {\n      caretToEnd.current = true;\n      setValue(command.insert);\n    } else {\n      setValue(\"\");\n      onCommand?.(command);\n    }\n  }\n\n  const autosize = React.useCallback(() => {\n    const el = ref.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${Math.min(el.scrollHeight, 200)}px`;\n  }, []);\n\n  React.useLayoutEffect(autosize, [value, autosize]);\n\n  // Re-measure when the width changes. A composer mounted in a hidden tab or collapsed panel is\n  // first measured at zero width, which would otherwise leave it stuck at its maximum height.\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    let width = el.clientWidth;\n    const observer = new ResizeObserver(() => {\n      if (el.clientWidth === width) return;\n      width = el.clientWidth;\n      autosize();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [autosize]);\n\n  const canSend = !submitDisabled && (allowEmpty || value.trim().length > 0);\n\n  function submit() {\n    if (!canSend || isStreaming) return;\n    onSubmit(value.trim());\n    setValue(\"\");\n  }\n\n  const hasFiles = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes(\"Files\");\n\n  return (\n    <form\n      onSubmit={(e) => {\n        e.preventDefault();\n        submit();\n      }}\n      onDragEnter={(e) => {\n        if (!onFiles || !hasFiles(e)) return;\n        dragDepth.current += 1;\n        setDragging(true);\n      }}\n      onDragOver={(e) => {\n        if (onFiles && hasFiles(e)) e.preventDefault();\n      }}\n      onDragLeave={() => {\n        if (!onFiles) return;\n        dragDepth.current = Math.max(0, dragDepth.current - 1);\n        if (dragDepth.current === 0) setDragging(false);\n      }}\n      onDrop={(e) => {\n        if (!onFiles || !hasFiles(e)) return;\n        e.preventDefault();\n        dragDepth.current = 0;\n        setDragging(false);\n        onFiles(Array.from(e.dataTransfer.files));\n      }}\n      className={cn(\n        \"group/composer relative rounded border bg-background p-2 transition-colors focus-within:border-foreground/40\",\n        dragging && \"border-primary\",\n        className,\n      )}\n    >\n      {menuOpen && (\n        <CommandMenu\n          id={menuId}\n          commands={matches}\n          activeIndex={active}\n          onActiveChange={setActiveIndex}\n          onSelect={selectCommand}\n          className=\"absolute inset-x-0 bottom-full z-20 mb-2\"\n        />\n      )}\n      {attachments}\n      <div className=\"flex items-end gap-2\">\n        {onFiles && (\n          <>\n            <input\n              ref={fileInput}\n              type=\"file\"\n              multiple\n              hidden\n              accept={accept}\n              onChange={(e) => {\n                const files = Array.from(e.target.files ?? []);\n                if (files.length) onFiles(files);\n                e.target.value = \"\";\n              }}\n            />\n            <Button type=\"button\" variant=\"ghost\" size=\"icon\" aria-label=\"Attach files\" onClick={() => fileInput.current?.click()}>\n              <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"m13.5 7.5-5.25 5.25a3.18 3.18 0 0 1-4.5-4.5l5.5-5.5a2.12 2.12 0 0 1 3 3l-5.5 5.5a1.06 1.06 0 0 1-1.5-1.5L10 4.25\" /></svg>\n            </Button>\n          </>\n        )}\n        <textarea\n          ref={ref}\n          rows={1}\n          value={value}\n          placeholder={placeholder}\n          aria-label=\"Message\"\n          // role=\"combobox\" isn't allowed on <textarea>, so use the attributes a textbox supports.\n          {...(commands && {\n            \"aria-autocomplete\": \"list\" as const,\n            \"aria-controls\": menuOpen ? menuId : undefined,\n            \"aria-activedescendant\": menuOpen ? optionId(menuId, active) : undefined,\n          })}\n          onChange={(e) => {\n            setValue(e.target.value);\n            setDismissed(false);\n            setActiveIndex(0);\n          }}\n          onPaste={(e) => {\n            if (!onFiles || !e.clipboardData.files.length) return;\n            e.preventDefault();\n            onFiles(Array.from(e.clipboardData.files));\n          }}\n          onKeyDown={(e) => {\n            if (menuOpen && !e.nativeEvent.isComposing) {\n              if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n                e.preventDefault();\n                const step = e.key === \"ArrowDown\" ? 1 : -1;\n                setActiveIndex((active + step + matches.length) % matches.length);\n                return;\n              }\n              if (e.key === \"Enter\" || e.key === \"Tab\") {\n                e.preventDefault();\n                selectCommand(matches[active]);\n                return;\n              }\n              if (e.key === \"Escape\") {\n                e.preventDefault();\n                setDismissed(true);\n                return;\n              }\n            }\n            if (e.key === \"Escape\" && isStreaming && onStop) {\n              e.preventDefault();\n              onStop();\n              return;\n            }\n            if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n              e.preventDefault();\n              submit();\n            }\n          }}\n          className=\"max-h-[200px] flex-1 resize-none bg-transparent px-2 py-1.5 text-[15px] outline-none placeholder:text-muted-foreground\"\n        />\n        {/* One element for both states so keyboard focus survives send -> stop. */}\n        <Button\n          type={isStreaming ? \"button\" : \"submit\"}\n          variant={isStreaming ? \"outline\" : \"primary\"}\n          size=\"icon\"\n          disabled={!isStreaming && !canSend}\n          onClick={isStreaming ? onStop : undefined}\n          aria-label={isStreaming ? \"Stop generating\" : \"Send message\"}\n        >\n          {isStreaming ? (\n            <svg viewBox=\"0 0 16 16\" fill=\"currentColor\"><rect x=\"3.5\" y=\"3.5\" width=\"9\" height=\"9\" rx=\"1.5\" /></svg>\n          ) : (\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M8 13V3M3.5 7.5 8 3l4.5 4.5\" /></svg>\n          )}\n        </Button>\n      </div>\n      <div className=\"flex items-center gap-1 px-1 pt-1\">\n        {toolbar}\n        <span aria-hidden className=\"ml-auto hidden font-mono text-[11px] text-muted-foreground [@media(hover:hover)]:group-focus-within/composer:inline\">\n          {isStreaming && onStop ? \"esc stop\" : \"↵ send · ⇧↵ newline\"}\n        </span>\n      </div>\n      {dragging && (\n        <div aria-hidden className=\"pointer-events-none absolute inset-0 flex items-center justify-center rounded bg-background/80 text-sm font-medium text-primary\">\n          Drop files to attach\n        </div>\n      )}\n    </form>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}