{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-input","type":"registry:component","title":"Prompt Input","description":"Chat composer with an auto-growing textarea, model picker and attachment chips; the send button morphs into a stop button while a reply streams.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ai/prompt-input.tsx","type":"registry:component","target":"components/ai/prompt-input.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUp, Check, ChevronDown, FileCode2, FileText, ImageIcon, Paperclip, Square, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PromptAttachment {\n  id: string;\n  name: string;\n  /** Size in bytes, shown beside the name. */\n  size?: number;\n  kind?: \"file\" | \"image\" | \"code\";\n}\n\nexport interface PromptModel {\n  id: string;\n  label: string;\n  description?: string;\n}\n\nexport interface PromptInputProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Called with the trimmed prompt and the current attachments. */\n  onSubmit?: (value: string, attachments: PromptAttachment[]) => void;\n  /** While true the send button morphs into a stop button. */\n  streaming?: boolean;\n  /** Called when the stop button is pressed. */\n  onStop?: () => void;\n  attachments?: PromptAttachment[];\n  defaultAttachments?: PromptAttachment[];\n  onAttachmentsChange?: (attachments: PromptAttachment[]) => void;\n  models?: PromptModel[];\n  model?: string;\n  defaultModel?: string;\n  onModelChange?: (id: string) => void;\n  placeholder?: string;\n  /** \"mod-enter\" submits on Cmd/Ctrl+Enter (Enter adds a line); \"enter\" submits on Enter (Shift+Enter adds a line). */\n  submitOn?: \"mod-enter\" | \"enter\";\n  /** Rows the textarea grows to before it scrolls. */\n  maxRows?: number;\n  /** Clear the text and attachments after submit. */\n  clearOnSubmit?: boolean;\n  disabled?: boolean;\n  className?: string;\n}\n\nconst spring = { type: \"spring\", stiffness: 420, damping: 30 } as const;\n\nfunction formatBytes(n?: number) {\n  if (n === undefined) return null;\n  if (n < 1024) return `${n} B`;\n  if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;\n  return `${(n / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction kindOf(name: string): PromptAttachment[\"kind\"] {\n  if (/\\.(png|jpe?g|gif|webp|svg|avif)$/i.test(name)) return \"image\";\n  if (/\\.(tsx?|jsx?|css|json|md|py|go|rs|sh|html)$/i.test(name)) return \"code\";\n  return \"file\";\n}\n\nfunction AttachmentIcon({ kind }: { kind?: PromptAttachment[\"kind\"] }) {\n  if (kind === \"image\") return <ImageIcon aria-hidden className=\"size-3.5\" />;\n  if (kind === \"code\") return <FileCode2 aria-hidden className=\"size-3.5\" />;\n  return <FileText aria-hidden className=\"size-3.5\" />;\n}\n\nfunction ModelMenu({\n  models,\n  value,\n  onChange,\n  disabled,\n}: {\n  models: PromptModel[];\n  value: string;\n  onChange: (id: string) => void;\n  disabled?: boolean;\n}) {\n  const [open, setOpen] = React.useState(false);\n  const [active, setActive] = React.useState(0);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const listId = React.useId();\n  const current = models.find((m) => m.id === value) ?? models[0];\n\n  React.useEffect(() => {\n    if (!open) return;\n    listRef.current?.focus();\n    const onDown = (e: PointerEvent) => {\n      const t = e.target as Node;\n      if (!listRef.current?.contains(t) && !triggerRef.current?.contains(t)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [open]);\n\n  function openMenu() {\n    setActive(Math.max(0, models.findIndex((m) => m.id === value)));\n    setOpen(true);\n  }\n\n  function close(restore = true) {\n    setOpen(false);\n    if (restore) triggerRef.current?.focus();\n  }\n\n  function onListKey(e: React.KeyboardEvent) {\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setActive((i) => (i + (e.key === \"ArrowDown\" ? 1 : -1) + models.length) % models.length);\n    } else if (e.key === \"Home\" || e.key === \"End\") {\n      e.preventDefault();\n      setActive(e.key === \"Home\" ? 0 : models.length - 1);\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      onChange(models[active].id);\n      close();\n    } else if (e.key === \"Escape\" || e.key === \"Tab\") {\n      if (e.key === \"Escape\") e.preventDefault();\n      close(e.key === \"Escape\");\n    }\n  }\n\n  if (!current) return null;\n  return (\n    <div className=\"relative\">\n      <motion.button\n        ref={triggerRef}\n        type=\"button\"\n        whileTap={{ scale: 0.96 }}\n        disabled={disabled}\n        aria-haspopup=\"listbox\"\n        aria-expanded={open}\n        aria-controls={open ? listId : undefined}\n        aria-label={`Model: ${current.label}`}\n        onClick={() => (open ? close(false) : openMenu())}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            openMenu();\n          }\n        }}\n        className=\"inline-flex h-8 items-center gap-1.5 rounded-full border border-border bg-surface px-3 text-[12.5px] font-medium text-ink-muted outline-none transition-colors hover:border-border-strong hover:text-ink focus-visible:shadow-[var(--focus-ring)] disabled:opacity-45\"\n      >\n        <span aria-hidden className=\"size-1.5 rounded-full bg-volt\" />\n        {current.label}\n        <ChevronDown aria-hidden className={cn(\"size-3.5 transition-transform duration-200\", open && \"rotate-180\")} />\n      </motion.button>\n      <AnimatePresence>\n        {open && (\n          <motion.div\n            ref={listRef}\n            id={listId}\n            role=\"listbox\"\n            tabIndex={-1}\n            aria-label=\"Choose a model\"\n            aria-activedescendant={`${listId}-${active}`}\n            onKeyDown={onListKey}\n            initial={{ opacity: 0, y: 6, scale: 0.96, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, y: 4, scale: 0.97, filter: \"blur(4px)\", transition: { duration: 0.12 } }}\n            transition={spring}\n            className=\"absolute bottom-[calc(100%+8px)] left-0 z-50 w-64 origin-bottom-left rounded-md border border-border bg-surface-raised p-1 shadow-lg outline-none\"\n          >\n            {models.map((m, i) => {\n              const selected = m.id === value;\n              return (\n                <div\n                  key={m.id}\n                  id={`${listId}-${i}`}\n                  role=\"option\"\n                  aria-selected={selected}\n                  onPointerEnter={() => setActive(i)}\n                  onClick={() => {\n                    onChange(m.id);\n                    close();\n                  }}\n                  className={cn(\n                    \"flex cursor-pointer items-start gap-2 rounded-sm px-2.5 py-2 text-left\",\n                    i === active ? \"bg-surface-hover\" : \"\",\n                  )}\n                >\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"block text-[13px] font-medium text-ink\">{m.label}</span>\n                    {m.description && <span className=\"block text-xs text-ink-subtle\">{m.description}</span>}\n                  </span>\n                  {selected && <Check aria-hidden className=\"mt-0.5 size-4 text-volt-ink\" />}\n                </div>\n              );\n            })}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/** Chat composer: auto-growing textarea, animated attachment chips, model pill and a send button that morphs into stop while streaming. */\nexport function PromptInput({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onSubmit,\n  streaming = false,\n  onStop,\n  attachments,\n  defaultAttachments = [],\n  onAttachmentsChange,\n  models,\n  model,\n  defaultModel,\n  onModelChange,\n  placeholder = \"Ask anything…\",\n  submitOn = \"mod-enter\",\n  maxRows = 8,\n  clearOnSubmit = true,\n  disabled,\n  className,\n}: PromptInputProps) {\n  const reduce = useReducedMotion();\n  const [innerValue, setInnerValue] = React.useState(defaultValue);\n  const [innerFiles, setInnerFiles] = React.useState<PromptAttachment[]>(defaultAttachments);\n  const [innerModel, setInnerModel] = React.useState(defaultModel ?? models?.[0]?.id ?? \"\");\n  const text = value ?? innerValue;\n  const files = attachments ?? innerFiles;\n  const modelId = model ?? innerModel;\n  const areaRef = React.useRef<HTMLTextAreaElement>(null);\n  const fileRef = React.useRef<HTMLInputElement>(null);\n  const hintId = React.useId();\n\n  const setText = (v: string) => {\n    if (value === undefined) setInnerValue(v);\n    onValueChange?.(v);\n  };\n  const setFiles = (next: PromptAttachment[]) => {\n    if (attachments === undefined) setInnerFiles(next);\n    onAttachmentsChange?.(next);\n  };\n\n  React.useLayoutEffect(() => {\n    const el = areaRef.current;\n    if (!el) return;\n    const line = parseFloat(getComputedStyle(el).lineHeight) || 22;\n    el.style.height = \"auto\";\n    el.style.height = `${Math.min(el.scrollHeight, line * maxRows)}px`;\n    el.style.overflowY = el.scrollHeight > line * maxRows ? \"auto\" : \"hidden\";\n  }, [text, maxRows]);\n\n  const canSend = !disabled && (text.trim().length > 0 || files.length > 0);\n\n  function submit() {\n    if (streaming || !canSend) return;\n    onSubmit?.(text.trim(), files);\n    if (clearOnSubmit) {\n      setText(\"\");\n      setFiles([]);\n    }\n  }\n\n  function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (e.key !== \"Enter\" || e.nativeEvent.isComposing) return;\n    const mod = e.metaKey || e.ctrlKey;\n    if (submitOn === \"mod-enter\" ? mod : !e.shiftKey) {\n      e.preventDefault();\n      submit();\n    }\n  }\n\n  function onPick(e: React.ChangeEvent<HTMLInputElement>) {\n    const list = Array.from(e.target.files ?? []);\n    if (!list.length) return;\n    setFiles([\n      ...files,\n      ...list.map((f, i) => ({ id: `${f.name}-${f.size}-${f.lastModified}-${i}`, name: f.name, size: f.size, kind: kindOf(f.name) })),\n    ]);\n    e.target.value = \"\";\n  }\n\n  return (\n    <div\n      className={cn(\n        \"relative w-full rounded-lg border border-border bg-surface-raised p-2 shadow-sm transition-[border-color,box-shadow] duration-200\",\n        \"focus-within:border-border-strong focus-within:shadow-md\",\n        disabled && \"opacity-60\",\n        className,\n      )}\n      onClick={(e) => {\n        if (e.target === e.currentTarget) areaRef.current?.focus();\n      }}\n    >\n      <AnimatePresence initial={false}>\n        {files.length > 0 && (\n          <motion.ul\n            aria-label=\"Attachments\"\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={spring}\n            className=\"flex flex-wrap gap-1.5 overflow-hidden px-1\"\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {files.map((f) => (\n                <motion.li\n                  key={f.id}\n                  layout={!reduce}\n                  initial={{ opacity: 0, scale: 0.8, filter: \"blur(6px)\" }}\n                  animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, scale: 0.8, filter: \"blur(6px)\" }}\n                  transition={spring}\n                  className=\"mt-1 inline-flex h-8 max-w-[220px] items-center gap-1.5 rounded-full border border-border bg-surface pl-2.5 pr-1 text-xs text-ink\"\n                >\n                  <span className=\"text-ink-muted\">\n                    <AttachmentIcon kind={f.kind} />\n                  </span>\n                  <span className=\"truncate font-medium\">{f.name}</span>\n                  {f.size !== undefined && <span className=\"shrink-0 font-mono text-[11px] text-ink-subtle\">{formatBytes(f.size)}</span>}\n                  <button\n                    type=\"button\"\n                    aria-label={`Remove ${f.name}`}\n                    onClick={() => setFiles(files.filter((x) => x.id !== f.id))}\n                    className=\"grid size-6 shrink-0 place-items-center rounded-full text-ink-subtle outline-none transition-colors hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)]\"\n                  >\n                    <X aria-hidden className=\"size-3.5\" />\n                  </button>\n                </motion.li>\n              ))}\n            </AnimatePresence>\n          </motion.ul>\n        )}\n      </AnimatePresence>\n\n      <textarea\n        ref={areaRef}\n        rows={1}\n        value={text}\n        disabled={disabled}\n        placeholder={placeholder}\n        aria-label=\"Prompt\"\n        aria-describedby={hintId}\n        onChange={(e) => setText(e.target.value)}\n        onKeyDown={onKeyDown}\n        className=\"block w-full resize-none bg-transparent px-2.5 pb-1 pt-2 text-[15px] leading-[22px] text-ink outline-none placeholder:text-ink-subtle\"\n      />\n\n      <div className=\"flex items-center gap-1.5 pt-1\">\n        <input ref={fileRef} type=\"file\" multiple hidden onChange={onPick} tabIndex={-1} aria-hidden />\n        <motion.button\n          type=\"button\"\n          whileTap={{ scale: 0.92 }}\n          disabled={disabled}\n          aria-label=\"Attach files\"\n          onClick={() => fileRef.current?.click()}\n          className=\"grid size-8 place-items-center rounded-full text-ink-muted outline-none transition-colors hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)] disabled:opacity-45\"\n        >\n          <Paperclip aria-hidden className=\"size-4\" />\n        </motion.button>\n        {models && models.length > 0 && (\n          <ModelMenu\n            models={models}\n            value={modelId}\n            disabled={disabled}\n            onChange={(id) => {\n              if (model === undefined) setInnerModel(id);\n              onModelChange?.(id);\n            }}\n          />\n        )}\n        <span id={hintId} className=\"ml-auto hidden pr-1 text-[11.5px] text-ink-subtle sm:inline\">\n          {submitOn === \"mod-enter\" ? (\n            <>\n              <kbd className=\"font-mono\">⌘</kbd>\n              <kbd className=\"font-mono\">↵</kbd> to send\n            </>\n          ) : (\n            <>\n              <kbd className=\"font-mono\">↵</kbd> to send, <kbd className=\"font-mono\">⇧↵</kbd> for a new line\n            </>\n          )}\n        </span>\n        <motion.button\n          type=\"button\"\n          whileTap={{ scale: 0.9 }}\n          onClick={streaming ? onStop : submit}\n          disabled={streaming ? !onStop : !canSend}\n          aria-label={streaming ? \"Stop generating\" : \"Send prompt\"}\n          animate={{ borderRadius: streaming ? 12 : 999 }}\n          transition={spring}\n          className={cn(\n            \"relative ml-auto grid size-9 shrink-0 place-items-center overflow-hidden outline-none transition-[background-color,color,opacity] duration-200 focus-visible:shadow-[var(--focus-ring)] sm:ml-0\",\n            streaming ? \"bg-ink text-bg\" : \"bg-volt text-on-volt disabled:bg-surface disabled:text-ink-subtle\",\n          )}\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={streaming ? \"stop\" : \"send\"}\n              initial={{ opacity: 0, scale: 0.4, rotate: reduce ? 0 : -90, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, scale: 1, rotate: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, scale: 0.4, rotate: reduce ? 0 : 90, filter: \"blur(4px)\" }}\n              transition={spring}\n              className=\"col-start-1 row-start-1\"\n            >\n              {streaming ? <Square aria-hidden className=\"size-3.5 fill-current\" /> : <ArrowUp aria-hidden className=\"size-[18px]\" strokeWidth={2.25} />}\n            </motion.span>\n          </AnimatePresence>\n        </motion.button>\n      </div>\n    </div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["ai","ai","chat","composer","textarea","input","attachments","model selector"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/prompt-input"}}