{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-input","type":"registry:ui","title":"Prompt Input","description":"The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.","author":"Scrim UI (https://scrimui.dev)","categories":["prompt-input"],"docs":"https://scrimui.dev/components/prompt-input","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/prompt-input/prompt-input.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type Attachment = {\n  id: string;\n  name: string;\n  size?: string;\n  type?: \"image\" | \"file\";\n};\n\nexport type ModelOption = {\n  id: string;\n  name: string;\n  hint?: string;\n  /**\n   * Optional leading mark, e.g. the provider's logo. A slot rather than a\n   * built-in lookup so this component stays dependency-free — pass whatever\n   * icon element you already have.\n   */\n  icon?: React.ReactNode;\n};\n\nexport type PromptInputProps = {\n  placeholder?: string;\n  models?: ModelOption[];\n  defaultModel?: string;\n  attachments?: Attachment[];\n  onAttach?: () => void;\n  onRemoveAttachment?: (id: string) => void;\n  onVoice?: () => void;\n  onSubmit?: (value: string, model?: string) => void;\n  onStop?: () => void;\n  loading?: boolean;\n  disabled?: boolean;\n  error?: string | null;\n  showWebSearch?: boolean;\n  showTools?: boolean;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons (inline SVG, no dependencies)                                 */\n/* ------------------------------------------------------------------ */\n\nfunction PlusIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"16\" height=\"16\" {...props}>\n      <path d=\"M12 5v14M5 12h14\" />\n    </svg>\n  );\n}\n\nfunction GlobeIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"M2 12h20M12 2a15.3 15.3 0 0 1 0 20 15.3 15.3 0 0 1 0-20Z\" />\n    </svg>\n  );\n}\n\nfunction WrenchIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\" />\n    </svg>\n  );\n}\n\nfunction MicIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"16\" height=\"16\" {...props}>\n      <path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z\" />\n      <path d=\"M19 10v2a7 7 0 0 1-14 0v-2M12 19v3\" />\n    </svg>\n  );\n}\n\nfunction ArrowUpIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"16\" height=\"16\" {...props}>\n      <path d=\"M12 19V5M5 12l7-7 7 7\" />\n    </svg>\n  );\n}\n\nfunction StopIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" {...props}>\n      <rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" rx=\"2\" />\n    </svg>\n  );\n}\n\nfunction ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <path d=\"m6 9 6 6 6-6\" />\n    </svg>\n  );\n}\n\nfunction XIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"M18 6 6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n\nfunction FileIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <path d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2Z\" />\n      <path d=\"M14 2v6h6\" />\n    </svg>\n  );\n}\n\nfunction ImageIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />\n      <circle cx=\"9\" cy=\"9\" r=\"2\" />\n      <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />\n    </svg>\n  );\n}\n\nfunction AlertIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"M12 8v4M12 16h.01\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* PromptInput                                                         */\n/* ------------------------------------------------------------------ */\n\nexport function PromptInput({\n  placeholder = \"Ask anything…\",\n  models,\n  defaultModel,\n  attachments = [],\n  onAttach,\n  onRemoveAttachment,\n  onVoice,\n  onSubmit,\n  onStop,\n  loading = false,\n  disabled = false,\n  error = null,\n  showWebSearch = true,\n  showTools = true,\n  className = \"\",\n}: PromptInputProps) {\n  const [value, setValue] = React.useState(\"\");\n  const [model, setModel] = React.useState(defaultModel ?? models?.[0]?.id);\n  const [modelOpen, setModelOpen] = React.useState(false);\n  const [webSearch, setWebSearch] = React.useState(false);\n  const [tools, setTools] = React.useState(false);\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null);\n  const menuRef = React.useRef<HTMLDivElement>(null);\n\n  const activeModel = models?.find((m) => m.id === model);\n  const canSubmit = value.trim().length > 0 && !disabled && !loading;\n\n  /* Auto-grow textarea */\n  React.useEffect(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"0px\";\n    el.style.height = Math.min(el.scrollHeight, 200) + \"px\";\n  }, [value]);\n\n  /* Close model menu on outside click / Escape */\n  React.useEffect(() => {\n    if (!modelOpen) return;\n    function onClick(e: MouseEvent) {\n      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {\n        setModelOpen(false);\n      }\n    }\n    function onKey(e: KeyboardEvent) {\n      if (e.key === \"Escape\") setModelOpen(false);\n    }\n    document.addEventListener(\"mousedown\", onClick);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"mousedown\", onClick);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [modelOpen]);\n\n  function submit() {\n    if (!canSubmit) return;\n    onSubmit?.(value.trim(), activeModel?.id);\n    setValue(\"\");\n  }\n\n  function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (e.key === \"Enter\" && !e.shiftKey) {\n      e.preventDefault();\n      submit();\n    }\n  }\n\n  const iconButton =\n    \"inline-flex h-8 w-8 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-40 disabled:pointer-events-none dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\";\n\n  const toggleButton = (active: boolean) =>\n    `inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium transition-colors disabled:opacity-40 disabled:pointer-events-none ${\n      active\n        ? \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\"\n        : \"text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n    }`;\n\n  return (\n    <div className={`w-full ${className}`}>\n      <div\n        className={`w-full rounded-2xl border bg-white shadow-sm transition-colors dark:bg-zinc-900 ${\n          error\n            ? \"border-red-300 dark:border-red-900\"\n            : \"border-zinc-200 focus-within:border-zinc-400 dark:border-zinc-800 dark:focus-within:border-zinc-600\"\n        } ${disabled ? \"opacity-60\" : \"\"}`}\n      >\n        {/* Attachments */}\n        {attachments.length > 0 && (\n          <div className=\"flex flex-wrap gap-2 px-3 pt-3\">\n            {attachments.map((file) => (\n              <span\n                key={file.id}\n                className=\"inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-zinc-50 py-1 pl-2 pr-1 text-xs text-zinc-700 dark:border-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-300\"\n              >\n                {file.type === \"image\" ? <ImageIcon /> : <FileIcon />}\n                <span className=\"max-w-40 truncate\">{file.name}</span>\n                {file.size && <span className=\"text-zinc-500 dark:text-zinc-400\">{file.size}</span>}\n                <button\n                  type=\"button\"\n                  aria-label={`Remove ${file.name}`}\n                  onClick={() => onRemoveAttachment?.(file.id)}\n                  className=\"rounded p-0.5 text-zinc-400 transition-colors hover:bg-zinc-200 hover:text-zinc-700 dark:hover:bg-zinc-700 dark:hover:text-zinc-200\"\n                >\n                  <XIcon />\n                </button>\n              </span>\n            ))}\n          </div>\n        )}\n\n        {/* Textarea */}\n        <textarea\n          ref={textareaRef}\n          rows={1}\n          value={value}\n          onChange={(e) => setValue(e.target.value)}\n          onKeyDown={onKeyDown}\n          placeholder={placeholder}\n          disabled={disabled}\n          aria-label=\"Prompt\"\n          className=\"block w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 text-[15px] leading-6 text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500\"\n        />\n\n        {/* Toolbar */}\n        <div className=\"flex items-center gap-1 px-2.5 pb-2.5\">\n          <button\n            type=\"button\"\n            onClick={onAttach}\n            disabled={disabled}\n            aria-label=\"Add attachment\"\n            className={iconButton}\n          >\n            <PlusIcon />\n          </button>\n\n          {showWebSearch && (\n            <button\n              type=\"button\"\n              onClick={() => setWebSearch((v) => !v)}\n              disabled={disabled}\n              aria-pressed={webSearch}\n              aria-label=\"Web search\"\n              className={toggleButton(webSearch)}\n            >\n              <GlobeIcon />\n              <span className=\"hidden sm:inline\">Search</span>\n            </button>\n          )}\n\n          {showTools && (\n            <button\n              type=\"button\"\n              onClick={() => setTools((v) => !v)}\n              disabled={disabled}\n              aria-pressed={tools}\n              aria-label=\"Tools\"\n              className={toggleButton(tools)}\n            >\n              <WrenchIcon />\n              <span className=\"hidden sm:inline\">Tools</span>\n            </button>\n          )}\n\n          {/* Model selector */}\n          {models && models.length > 0 && (\n            <div className=\"relative\" ref={menuRef}>\n              <button\n                type=\"button\"\n                onClick={() => setModelOpen((v) => !v)}\n                disabled={disabled}\n                aria-haspopup=\"listbox\"\n                aria-expanded={modelOpen}\n                className=\"inline-flex h-8 items-center gap-1 rounded-lg px-2.5 text-xs font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-40 disabled:pointer-events-none dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n              >\n                {activeModel?.icon}\n                {activeModel?.name ?? \"Model\"}\n                <ChevronDownIcon className={modelOpen ? \"rotate-180 transition-transform\" : \"transition-transform\"} />\n              </button>\n              {modelOpen && (\n                <div\n                  role=\"listbox\"\n                  className=\"absolute bottom-full left-0 z-20 mb-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white p-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-900\"\n                >\n                  {models.map((m) => (\n                    <button\n                      key={m.id}\n                      type=\"button\"\n                      role=\"option\"\n                      aria-selected={m.id === model}\n                      onClick={() => {\n                        setModel(m.id);\n                        setModelOpen(false);\n                        textareaRef.current?.focus();\n                      }}\n                      className={`flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 ${\n                        m.id === model ? \"text-zinc-900 dark:text-zinc-100\" : \"text-zinc-600 dark:text-zinc-400\"\n                      }`}\n                    >\n                      <span className=\"flex min-w-0 items-center gap-1.5\">\n                        {m.icon}\n                        <span className=\"truncate\">{m.name}</span>\n                      </span>\n                      {m.hint && <span className=\"shrink-0 text-xs text-zinc-500 dark:text-zinc-400\">{m.hint}</span>}\n                    </button>\n                  ))}\n                </div>\n              )}\n            </div>\n          )}\n\n          <div className=\"flex-1\" />\n\n          <button\n            type=\"button\"\n            onClick={onVoice}\n            disabled={disabled}\n            aria-label=\"Voice input\"\n            className={iconButton}\n          >\n            <MicIcon />\n          </button>\n\n          {loading ? (\n            <button\n              type=\"button\"\n              onClick={onStop}\n              aria-label=\"Stop generating\"\n              className=\"inline-flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-white transition-opacity hover:opacity-80 dark:bg-zinc-100 dark:text-zinc-900\"\n            >\n              <StopIcon />\n            </button>\n          ) : (\n            <button\n              type=\"button\"\n              onClick={submit}\n              disabled={!canSubmit}\n              aria-label=\"Send message\"\n              className=\"inline-flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-white transition-opacity hover:opacity-80 disabled:opacity-30 dark:bg-zinc-100 dark:text-zinc-900\"\n            >\n              <ArrowUpIcon />\n            </button>\n          )}\n        </div>\n      </div>\n\n      {/* Error */}\n      {error && (\n        <p role=\"alert\" className=\"mt-2 flex items-center gap-1.5 px-1 text-xs text-red-600 dark:text-red-400\">\n          <AlertIcon />\n          {error}\n        </p>\n      )}\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/prompt-input.tsx"}]}