{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-input",
  "type": "registry:ui",
  "title": "Prompt Input",
  "description": "The AI composer: grows as you write, takes files, and Send becomes Stop.",
  "categories": [
    "ai-chat"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/prompt-input.tsx",
      "content": "\"use client\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUp, ChevronDown, File as FileIcon, Paperclip, Upload, Warning, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Types\n * -----------------------------------------------------------------------------------------------*/\n\nexport type PromptAttachment = {\n  id: string;\n  name: string;\n  /** Bytes. */\n  size: number;\n  /** MIME type. Images get a thumbnail. */\n  type: string;\n  /** The picked file, when it came from this composer. */\n  file?: File;\n  /** An image URL for the thumbnail. The composer makes one for images it picks. */\n  preview?: string;\n  /** 0–1 while uploading. Send waits until every attachment is done. */\n  progress?: number;\n  /** A few words on why it failed. */\n  error?: string;\n};\n\nexport type PromptModel = { id: string; label: string; description?: string };\nexport type PromptTool = {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  /** One line for the tooltip: what turning it on does. */\n  description?: string;\n};\n\nexport type PromptMessage = {\n  text: string;\n  attachments: PromptAttachment[];\n  model?: string;\n  tools: string[];\n};\n\n/* -------------------------------------------------------------------------------------------------\n * Small helpers\n * -----------------------------------------------------------------------------------------------*/\n\nconst units = [\"B\", \"KB\", \"MB\", \"GB\"];\n/** \"940 KB\", \"2.4 MB\". */\nexport function formatBytes(bytes: number) {\n  let n = bytes;\n  let u = 0;\n  while (n >= 1000 && u < units.length - 1) {\n    n /= 1000;\n    u++;\n  }\n  return `${u === 0 ? n : n < 10 ? n.toFixed(1) : Math.round(n)} ${units[u]}`;\n}\n\nconst splitName = (name: string) => {\n  const dot = name.lastIndexOf(\".\");\n  return dot > 0 && dot > name.length - 8 ? [name.slice(0, dot), name.slice(dot)] : [name, \"\"];\n};\n\nlet seq = 0;\nconst nextId = () => `att-${Date.now().toString(36)}-${(seq++).toString(36)}`;\n\nconst matchesAccept = (file: File, accept?: string) => {\n  if (!accept) return true;\n  return accept.split(\",\").some((raw) => {\n    const rule = raw.trim().toLowerCase();\n    if (!rule) return false;\n    if (rule.startsWith(\".\")) return file.name.toLowerCase().endsWith(rule);\n    if (rule.endsWith(\"/*\")) return file.type.toLowerCase().startsWith(rule.slice(0, -1));\n    return file.type.toLowerCase() === rule;\n  });\n};\n\nconst coarseQuery = \"(pointer: coarse)\";\nconst subscribeCoarse = (cb: () => void) => {\n  const mq = window.matchMedia(coarseQuery);\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n};\nconst useCoarse = () => useSyncExternalStore(subscribeCoarse, () => window.matchMedia(coarseQuery).matches, () => false);\n\nconst subscribeNothing = () => () => {};\nconst useApple = () => useSyncExternalStore(subscribeNothing, () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent), () => true);\n\nfunction markEdges(row: HTMLElement) {\n  const end = row.scrollWidth - row.clientWidth - Math.abs(row.scrollLeft);\n  row.toggleAttribute(\"data-more-start\", Math.abs(row.scrollLeft) > 2);\n  row.toggleAttribute(\"data-more-end\", end > 2);\n}\n\nfunction assignRef<T>(ref: React.Ref<T> | undefined, node: T | null) {\n  if (typeof ref === \"function\") ref(node);\n  else if (ref) (ref as React.RefObject<T | null>).current = node;\n}\n\nconst draft = {\n  read(key: string) {\n    try {\n      return window.localStorage.getItem(key) ?? \"\";\n    } catch {\n      return \"\";\n    }\n  },\n  write(key: string, value: string) {\n    try {\n      if (value) window.localStorage.setItem(key, value);\n      else window.localStorage.removeItem(key);\n    } catch {\n      /* Private mode or blocked storage: the draft just isn't kept. */\n    }\n  },\n};\n\n/* -------------------------------------------------------------------------------------------------\n * PromptInput\n * -----------------------------------------------------------------------------------------------*/\n\nexport type PromptInputProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"children\" | \"defaultValue\"> & {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Called with the message when it's sent. The composer clears itself and keeps focus. */\n  onSend: (message: PromptMessage) => void;\n  /** A reply is being generated. The send button becomes Stop and Escape stops it. */\n  generating?: boolean;\n  onStop?: () => void;\n  placeholder?: string;\n  /** The textarea's accessible name. */\n  label?: string;\n  disabled?: boolean;\n  /** \"enter\": Enter sends and Shift+Enter adds a line. \"mod-enter\": Enter adds a line and ⌘/Ctrl+Enter sends. ⌘/Ctrl+Enter always sends. On touch screens Enter always adds a line. */\n  submitOn?: \"enter\" | \"mod-enter\";\n  minRows?: number;\n  /** Lines before the text starts to scroll. */\n  maxRows?: number;\n  /** Characters. A count appears within 10% of it; past it, sending is blocked. */\n  maxLength?: number;\n  attachments?: PromptAttachment[];\n  defaultAttachments?: PromptAttachment[];\n  onAttachmentsChange?: (attachments: PromptAttachment[]) => void;\n  /** Hide the attach button and ignore dropped and pasted files. */\n  allowAttachments?: boolean;\n  /** Same syntax as the file input's accept: \"image/*,.pdf,.csv\". */\n  accept?: string;\n  maxFiles?: number;\n  /** Bytes per file. */\n  maxFileSize?: number;\n  models?: PromptModel[];\n  model?: string;\n  defaultModel?: string;\n  onModelChange?: (model: string) => void;\n  tools?: PromptTool[];\n  activeTools?: string[];\n  defaultActiveTools?: string[];\n  onActiveToolsChange?: (tools: string[]) => void;\n  /** Keep an unsent draft in local storage under this key. */\n  persistKey?: string;\n  /** The textarea, for focusing it from outside (after filling in a suggestion). */\n  textareaRef?: React.Ref<HTMLTextAreaElement>;\n};\n\nconst EMPTY: PromptAttachment[] = [];\nconst NO_TOOLS: string[] = [];\n\nexport function PromptInput({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onSend,\n  generating = false,\n  onStop,\n  placeholder = \"Ask anything\",\n  label = \"Message\",\n  disabled = false,\n  submitOn = \"enter\",\n  minRows = 1,\n  maxRows = 8,\n  maxLength,\n  attachments: attachmentsProp,\n  defaultAttachments = EMPTY,\n  onAttachmentsChange,\n  allowAttachments = true,\n  accept,\n  maxFiles = 10,\n  maxFileSize = 20_000_000,\n  models,\n  model: modelProp,\n  defaultModel,\n  onModelChange,\n  tools,\n  activeTools: activeToolsProp,\n  defaultActiveTools = NO_TOOLS,\n  onActiveToolsChange,\n  persistKey,\n  textareaRef,\n  className,\n  ...rest\n}: PromptInputProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [attachments, setAttachments] = useControllableState({ value: attachmentsProp, defaultValue: defaultAttachments, onChange: onAttachmentsChange });\n  const [model, setModel] = useControllableState({ value: modelProp, defaultValue: defaultModel ?? models?.[0]?.id ?? \"\", onChange: onModelChange });\n  const [activeTools, setActiveTools] = useControllableState({ value: activeToolsProp, defaultValue: defaultActiveTools, onChange: onActiveToolsChange });\n\n  const reduce = useReducedMotion();\n  const coarse = useCoarse();\n  const apple = useApple();\n  const id = useId();\n  const area = useRef<HTMLTextAreaElement | null>(null);\n  const fileInput = useRef<HTMLInputElement>(null);\n  const madeUrls = useRef(new Set<string>());\n  const [dragging, setDragging] = useState(false);\n  const dragDepth = useRef(0);\n  const [notice, setNotice] = useState<string | null>(null);\n\n  const length = value.length;\n  const over = maxLength != null && length > maxLength;\n  const nearLimit = maxLength != null && length >= maxLength * 0.9;\n  const uploading = attachments.some((a) => a.progress != null && a.progress < 1 && !a.error);\n  const hasContent = value.trim().length > 0 || attachments.some((a) => !a.error);\n  const canSend = !disabled && !generating && hasContent && !uploading && !over;\n\n  // Restore an unsent draft once, after hydration, so server and client render the same first frame.\n  useEffect(() => {\n    if (!persistKey || valueProp !== undefined) return;\n    const saved = draft.read(persistKey);\n    if (saved) setValue((v) => v || saved);\n    // Only on mount and when the key changes.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [persistKey]);\n  useEffect(() => {\n    if (!persistKey) return;\n    const t = window.setTimeout(() => draft.write(persistKey, value), 300);\n    return () => window.clearTimeout(t);\n  }, [persistKey, value]);\n\n  // Object URLs made for thumbnails live until the chip is removed or the composer unmounts.\n  useEffect(() => {\n    const urls = madeUrls.current;\n    return () => urls.forEach((u) => URL.revokeObjectURL(u));\n  }, []);\n\n  useEffect(() => {\n    if (!notice) return;\n    const t = window.setTimeout(() => setNotice(null), 5000);\n    return () => window.clearTimeout(t);\n  }, [notice]);\n\n  const focus = () => area.current?.focus({ preventScroll: true });\n\n  // New chips arrive at the end of the row: bring the row to them.\n  const chipRow = useRef<HTMLUListElement>(null);\n  const chipCount = useRef(attachments.length);\n  useEffect(() => {\n    const row = chipRow.current;\n    const grew = attachments.length > chipCount.current;\n    chipCount.current = attachments.length;\n    if (!row) return;\n    if (grew) row.scrollTo({ left: row.scrollWidth, behavior: reduce ? \"auto\" : \"smooth\" });\n    markEdges(row);\n    const ro = new ResizeObserver(() => markEdges(row));\n    ro.observe(row);\n    return () => ro.disconnect();\n  }, [attachments.length, reduce]);\n\n  const addFiles = useCallback(\n    (files: File[]) => {\n      if (!allowAttachments || disabled || !files.length) return;\n      const room = Math.max(0, maxFiles - attachments.length);\n      const accepted: PromptAttachment[] = [];\n      const problems: string[] = [];\n      for (const file of files) {\n        if (!matchesAccept(file, accept)) problems.push(`${file.name} isn’t a supported file type`);\n        else if (file.size > maxFileSize) problems.push(`${file.name} is over ${formatBytes(maxFileSize)}`);\n        else if (accepted.length >= room) problems.push(`You can attach up to ${maxFiles} files`);\n        else {\n          let preview: string | undefined;\n          if (file.type.startsWith(\"image/\")) {\n            preview = URL.createObjectURL(file);\n            madeUrls.current.add(preview);\n          }\n          accepted.push({ id: nextId(), name: file.name, size: file.size, type: file.type, file, preview });\n        }\n      }\n      if (accepted.length) setAttachments((prev) => [...prev, ...accepted]);\n      setNotice(problems.length ? (problems.length > 1 && !problems.every((p) => p === problems[0]) ? `${problems[0]}, and ${problems.length - 1} more` : problems[0]) : null);\n    },\n    [accept, allowAttachments, attachments.length, disabled, maxFileSize, maxFiles, setAttachments],\n  );\n\n  const remove = (a: PromptAttachment) => {\n    if (a.preview && madeUrls.current.has(a.preview)) {\n      URL.revokeObjectURL(a.preview);\n      madeUrls.current.delete(a.preview);\n    }\n    setAttachments((prev) => prev.filter((x) => x.id !== a.id));\n    focus();\n  };\n\n  const send = () => {\n    if (!canSend) return;\n    const sent = attachments.filter((a) => !a.error);\n    // Sent previews now belong to the message; stop tracking them so they aren't revoked here.\n    sent.forEach((a) => a.preview && madeUrls.current.delete(a.preview));\n    onSend({ text: value.trim(), attachments: sent, model: model || undefined, tools: activeTools });\n    setValue(\"\");\n    setAttachments(EMPTY);\n    setNotice(null);\n    if (persistKey) draft.write(persistKey, \"\");\n    focus();\n  };\n\n  const stop = () => {\n    onStop?.();\n    focus();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    // Never send in the middle of composing a character (Japanese, Chinese, Korean input).\n    if (e.nativeEvent.isComposing || e.keyCode === 229) return;\n    if (e.key === \"Escape\" && generating) {\n      e.preventDefault();\n      stop();\n      return;\n    }\n    if (e.key !== \"Enter\") return;\n    const mod = e.metaKey || e.ctrlKey;\n    if (mod || (submitOn === \"enter\" && !e.shiftKey && !coarse)) {\n      e.preventDefault();\n      send();\n    }\n  };\n\n  const hasFiles = (e: React.DragEvent) => allowAttachments && !disabled && Array.from(e.dataTransfer.types).includes(\"Files\");\n\n  const lineHeight = 20;\n  const padY = 12 + 4;\n  const sizeStyle = { minHeight: minRows * lineHeight + padY, maxHeight: maxRows * lineHeight + padY } as React.CSSProperties;\n  const noticeId = `${id}-notice`;\n  const shortcut = submitOn === \"mod-enter\" || coarse ? [apple ? \"⌘\" : \"Ctrl\", \"↵\"] : [\"↵\"];\n\n  return (\n    <Tooltip.Provider delay={500}>\n      <form\n        data-state={generating ? \"generating\" : \"idle\"}\n        data-disabled={disabled || undefined}\n        data-dragging={dragging || undefined}\n        onSubmit={(e) => {\n          e.preventDefault();\n          send();\n        }}\n        onDragEnter={(e) => {\n          if (!hasFiles(e)) return;\n          e.preventDefault();\n          dragDepth.current++;\n          setDragging(true);\n        }}\n        onDragOver={(e) => {\n          if (!hasFiles(e)) return;\n          e.preventDefault();\n          e.dataTransfer.dropEffect = \"copy\";\n        }}\n        onDragLeave={(e) => {\n          if (!hasFiles(e)) return;\n          dragDepth.current = Math.max(0, dragDepth.current - 1);\n          if (!dragDepth.current) setDragging(false);\n        }}\n        onDrop={(e) => {\n          if (!hasFiles(e)) return;\n          e.preventDefault();\n          dragDepth.current = 0;\n          setDragging(false);\n          addFiles(Array.from(e.dataTransfer.files));\n          focus();\n        }}\n        onPointerDown={(e) => {\n          // A press on the box's empty space puts the caret in the text, like a big text field.\n          if (e.target === e.currentTarget) {\n            e.preventDefault();\n            focus();\n          }\n        }}\n        className={cn(\n          \"@container relative isolate flex w-full cursor-text flex-col rounded-2xl border border-line-2 bg-raised shadow-[var(--shadow)]\",\n          \"transition-[border-color,box-shadow] duration-150 ease-out\",\n          \"focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/[0.06]\",\n          \"data-dragging:border-fg-3\",\n          \"data-disabled:cursor-not-allowed data-disabled:opacity-60\",\n          className,\n        )}\n        {...rest}\n      >\n        {/* Attachments */}\n        <AnimatePresence initial={false}>\n          {attachments.length > 0 && (\n            <motion.div\n              key=\"attachments\"\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0 }}\n              transition={{ duration: reduce ? 0 : 0.22, ease: ease.inOut }}\n              className=\"overflow-hidden\"\n            >\n              <ul\n                ref={chipRow}\n                aria-label=\"Attachments\"\n                onScroll={(e) => markEdges(e.currentTarget)}\n                className={cn(\n                  \"flex gap-2 overflow-x-auto overscroll-x-contain px-2 pt-2 pb-0.5 [scrollbar-width:none]\",\n                  // Fade whichever edge has more chips beyond it.\n                  \"data-more-end:[mask-image:linear-gradient(to_right,black_calc(100%-32px),transparent)]\",\n                  \"data-more-start:[mask-image:linear-gradient(to_right,transparent,black_32px)]\",\n                  \"data-more-start:data-more-end:[mask-image:linear-gradient(to_right,transparent,black_32px,black_calc(100%-32px),transparent)]\",\n                )}\n              >\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  {attachments.map((a) => (\n                    <AttachmentChip key={a.id} attachment={a} onRemove={() => remove(a)} reduce={!!reduce} disabled={disabled} />\n                  ))}\n                </AnimatePresence>\n              </ul>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        {/* The text. The invisible twin behind it sets the height, so the box grows line by line with no measuring. */}\n        <div className=\"grid\">\n          <div\n            aria-hidden\n            style={sizeStyle}\n            className=\"invisible col-start-1 row-start-1 overflow-hidden whitespace-pre-wrap px-3.5 pt-3 pb-1 text-base leading-5 [overflow-wrap:anywhere] sm:text-[13px]\"\n          >\n            {value + \" \"}\n          </div>\n          <textarea\n            ref={(node) => {\n              area.current = node;\n              assignRef(textareaRef, node);\n            }}\n            id={`${id}-text`}\n            value={value}\n            onChange={(e) => setValue(e.target.value)}\n            onKeyDown={onKeyDown}\n            onPaste={(e) => {\n              const files = Array.from(e.clipboardData.files);\n              if (files.length && allowAttachments) {\n                e.preventDefault();\n                addFiles(files);\n              }\n            }}\n            rows={minRows}\n            disabled={disabled}\n            placeholder={placeholder}\n            aria-label={label}\n            aria-describedby={notice ? noticeId : undefined}\n            aria-invalid={over || undefined}\n            enterKeyHint={coarse || submitOn === \"mod-enter\" ? \"enter\" : \"send\"}\n            autoComplete=\"off\"\n            spellCheck\n            style={sizeStyle}\n            className={cn(\n              \"col-start-1 row-start-1 resize-none overflow-y-auto overscroll-contain bg-transparent px-3.5 pt-3 pb-1 text-base leading-5 text-fg outline-none [overflow-wrap:anywhere] sm:text-[13px]\",\n              \"placeholder:text-fg-4 disabled:cursor-not-allowed [scrollbar-width:thin]\",\n            )}\n          />\n        </div>\n\n        {/* Rejected files */}\n        <div className={cn(\"grid transition-[grid-template-rows] duration-200 ease-in-out-quart motion-reduce:transition-none\", notice ? \"grid-rows-[1fr]\" : \"grid-rows-[0fr]\")}>\n          <div className=\"min-h-0 overflow-hidden\">\n            <p id={noticeId} role=\"alert\" className=\"flex items-center gap-1.5 px-3.5 pt-1.5 text-[12px] leading-4 text-danger\">\n              {notice && (\n                <>\n                  <Warning size={14} className=\"shrink-0\" />\n                  <span className=\"min-w-0 truncate\">{notice}</span>\n                </>\n              )}\n            </p>\n          </div>\n        </div>\n\n        {/* Toolbar */}\n        <div className=\"flex items-center gap-1 p-2\">\n          {allowAttachments && (\n            <>\n              <Hint label=\"Attach files\" side=\"top\">\n                <IconButton aria-label=\"Attach files\" disabled={disabled} onClick={() => fileInput.current?.click()}>\n                  <Paperclip />\n                </IconButton>\n              </Hint>\n              <input\n                ref={fileInput}\n                type=\"file\"\n                multiple\n                accept={accept}\n                tabIndex={-1}\n                aria-hidden\n                className=\"sr-only\"\n                onChange={(e) => {\n                  addFiles(Array.from(e.target.files ?? []));\n                  e.target.value = \"\";\n                  focus();\n                }}\n              />\n            </>\n          )}\n\n          {tools?.map((tool) => (\n            <ToolToggle\n              key={tool.id}\n              tool={tool}\n              disabled={disabled}\n              pressed={activeTools.includes(tool.id)}\n              reduce={!!reduce}\n              onPressedChange={(on) => setActiveTools((prev) => (on ? [...prev.filter((t) => t !== tool.id), tool.id] : prev.filter((t) => t !== tool.id)))}\n            />\n          ))}\n\n          <div className=\"ms-auto flex min-w-0 items-center gap-1\">\n            {nearLimit && maxLength != null && (\n              <span className={cn(\"me-1 shrink-0 font-mono text-2xs tabular\", over ? \"text-danger\" : \"text-fg-3\")} aria-live=\"polite\">\n                {length.toLocaleString(\"en-US\")}/{maxLength.toLocaleString(\"en-US\")}\n              </span>\n            )}\n            {models && models.length > 0 && <ModelMenu models={models} value={model} onValueChange={setModel} disabled={disabled} reduce={!!reduce} />}\n            <SendButton\n              mode={generating ? \"stop\" : \"send\"}\n              ready={canSend}\n              disabled={disabled || (!generating && !canSend)}\n              onSend={send}\n              onStop={stop}\n              reduce={!!reduce}\n              hint={\n                generating\n                  ? { label: \"Stop generating\", keys: [\"Esc\"] }\n                  : uploading\n                    ? { label: \"Waiting for uploads to finish\" }\n                    : over\n                      ? { label: \"Shorten your message to send it\" }\n                      : { label: \"Send\", keys: shortcut }\n              }\n            />\n          </div>\n        </div>\n\n        {/* Drop target */}\n        <AnimatePresence>\n          {dragging && (\n            <motion.div\n              key=\"drop\"\n              aria-hidden\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.985 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.16, ease: ease.out }}\n              className=\"pointer-events-none absolute inset-1 z-10 grid place-items-center rounded-xl border border-dashed border-fg-3 bg-raised/95\"\n            >\n              <div className=\"flex flex-col items-center gap-1 text-center\">\n                <motion.span\n                  initial={reduce ? false : { y: 4 }}\n                  animate={{ y: 0 }}\n                  transition={spring.pop}\n                  className=\"grid size-8 place-items-center rounded-lg border border-line-2 bg-frame text-fg-2\"\n                >\n                  <Upload size={16} />\n                </motion.span>\n                <span className=\"text-[13px] font-medium text-fg\">Drop to attach</span>\n                <span className=\"text-[12px] text-fg-3\">\n                  Up to {maxFiles} files, {formatBytes(maxFileSize)} each\n                </span>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </form>\n    </Tooltip.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Parts\n * -----------------------------------------------------------------------------------------------*/\n\nconst iconButton = cn(\n  \"relative inline-grid size-8 shrink-0 place-items-center rounded-lg text-fg-3 outline-none\",\n  \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"disabled:pointer-events-none disabled:opacity-50\",\n  // 32px drawn, 44px to a finger.\n  \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n);\n\nfunction IconButton({ className, ...rest }: React.ComponentProps<\"button\">) {\n  return <button type=\"button\" className={cn(iconButton, className)} {...rest} />;\n}\n\ntype HintProps = { label: string; keys?: string[]; side?: \"top\" | \"bottom\"; children: React.ReactElement<Record<string, unknown>> };\n\n/** A tooltip that grows out of its trigger, with the shortcut beside the label. */\nfunction Hint({ label, keys, side = \"top\", children }: HintProps) {\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger render={children} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side={side} sideOffset={8} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"flex min-h-[26px] items-center gap-2 rounded-lg border border-line-2 bg-raised px-2 py-[3px] text-[12px] leading-4 text-fg shadow-pop outline-none\",\n              \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo data-ending-style:duration-100\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-[side=top]:data-starting-style:translate-y-0.5 data-[side=bottom]:data-starting-style:-translate-y-0.5\",\n              \"data-ending-style:opacity-0 data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-0\",\n            )}\n          >\n            <span>{label}</span>\n            {keys && (\n              <kbd className=\"-me-0.5 flex items-center gap-0.5\">\n                {keys.map((k) => (\n                  <span\n                    key={k}\n                    className={cn(\n                      \"inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-line-2 bg-frame px-1 leading-none text-fg-2\",\n                      /^[⌘⇧⌥⌃↵]$/.test(k) ? \"font-sans text-[12px]\" : \"font-mono text-[10.5px]\",\n                    )}\n                  >\n                    {k}\n                  </span>\n                ))}\n              </kbd>\n            )}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\nfunction ToolToggle({\n  tool,\n  pressed,\n  onPressedChange,\n  disabled,\n  reduce,\n}: {\n  tool: PromptTool;\n  pressed: boolean;\n  onPressedChange: (pressed: boolean) => void;\n  disabled: boolean;\n  reduce: boolean;\n}) {\n  // Count the times it has been switched on, so the icon pops on each switch but not on first paint.\n  const [pops, setPops] = useState(0);\n  const [was, setWas] = useState(pressed);\n  if (was !== pressed) {\n    setWas(pressed);\n    if (pressed) setPops((n) => n + 1);\n  }\n  const toggle = (\n    <Toggle\n      pressed={pressed}\n      onPressedChange={onPressedChange}\n      disabled={disabled}\n      aria-label={tool.label}\n      className={cn(\n        \"group/tool relative inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-transparent px-2 text-[12.5px] font-medium text-fg-3 outline-none\",\n        \"transition-[background-color,border-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.96] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 disabled:pointer-events-none disabled:opacity-50\",\n        \"data-pressed:border-line-2 data-pressed:bg-fg/[0.06] data-pressed:text-fg\",\n        \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n        // Narrow composers keep the icon and move the name to the tooltip.\n        \"@max-md:w-8 @max-md:justify-center @max-md:px-0\",\n      )}\n    >\n      {tool.icon && (\n        // The icon gives a small pop when the tool turns on; nothing when it turns off.\n        <motion.span\n          key={pops}\n          initial={pops > 0 && !reduce ? { scale: 0.7, rotate: -12 } : false}\n          animate={{ scale: 1, rotate: 0 }}\n          transition={spring.bouncy}\n          className=\"grid size-4 shrink-0 place-items-center [&_svg]:size-4\"\n        >\n          {tool.icon}\n        </motion.span>\n      )}\n      <span className=\"@max-md:sr-only\">{tool.label}</span>\n    </Toggle>\n  );\n  return <Hint label={tool.description ?? tool.label}>{toggle}</Hint>;\n}\n\nfunction ModelMenu({ models, value, onValueChange, disabled, reduce }: { models: PromptModel[]; value: string; onValueChange: (v: string) => void; disabled: boolean; reduce: boolean }) {\n  const current = models.find((m) => m.id === value) ?? models[0];\n  return (\n    <Menu.Root modal={false}>\n      <Menu.Trigger\n        disabled={disabled}\n        aria-label={`Model: ${current.label}`}\n        className={cn(\n          \"group/model relative inline-flex h-8 min-w-0 items-center gap-1 rounded-lg ps-2.5 pe-1.5 text-[12.5px] font-medium text-fg-2 outline-none\",\n          \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75 data-popup-open:bg-hover data-popup-open:text-fg\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 disabled:pointer-events-none disabled:opacity-50\",\n          \"before:absolute before:-inset-y-1.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n        )}\n      >\n        <span className=\"grid min-w-0 max-w-[9rem] overflow-hidden\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={current.id}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" }}\n              transition={{ duration: 0.2, ease: ease.out }}\n              className=\"col-start-1 row-start-1 truncate\"\n            >\n              {current.label}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <ChevronDown size={14} className=\"shrink-0 text-fg-3 transition-transform duration-200 ease-out-expo group-data-popup-open/model:rotate-180 motion-reduce:transition-none\" />\n      </Menu.Trigger>\n      <Menu.Portal>\n        <Menu.Positioner side=\"top\" align=\"end\" sideOffset={8} collisionPadding={8} className=\"z-(--z-dropdown) outline-none\">\n          <Menu.Popup\n            className={cn(\n              \"w-[min(17rem,var(--available-width))] rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n              \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1\",\n              \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120 data-instant:duration-0\",\n              \"motion-reduce:scale-100 motion-reduce:translate-none\",\n            )}\n          >\n            <div className=\"px-2 pt-1.5 pb-1 font-mono text-2xs uppercase tracking-[0.08em] text-fg-4\">Model</div>\n            <Menu.RadioGroup value={current.id} onValueChange={(v) => onValueChange(String(v))}>\n              {models.map((m) => (\n                <Menu.RadioItem\n                  key={m.id}\n                  value={m.id}\n                  closeOnClick\n                  className={cn(\n                    \"group/item relative flex min-h-8 cursor-default select-none items-start gap-2.5 rounded-lg px-2 py-1.5 text-[13px] leading-[18px] outline-none\",\n                    \"transition-colors duration-100 data-highlighted:bg-fg/[0.06] pointer-coarse:min-h-10\",\n                  )}\n                >\n                  <span className=\"flex min-w-0 flex-1 flex-col\">\n                    <span className=\"truncate text-fg\">{m.label}</span>\n                    {m.description && <span className=\"line-clamp-2 text-[12px] leading-4 text-fg-3\">{m.description}</span>}\n                  </span>\n                  <Menu.RadioItemIndicator keepMounted className=\"mt-px grid size-4 shrink-0 place-items-center text-fg\">\n                    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden>\n                      <path\n                        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                        stroke=\"currentColor\"\n                        strokeWidth={1.4}\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        pathLength={1}\n                        className=\"[stroke-dasharray:1] [stroke-dashoffset:1] transition-[stroke-dashoffset] duration-100 ease-out in-data-checked:[stroke-dashoffset:0] in-data-checked:duration-280 in-data-checked:ease-out-expo motion-reduce:transition-none\"\n                      />\n                    </svg>\n                  </Menu.RadioItemIndicator>\n                </Menu.RadioItem>\n              ))}\n            </Menu.RadioGroup>\n          </Menu.Popup>\n        </Menu.Positioner>\n      </Menu.Portal>\n    </Menu.Root>\n  );\n}\n\nfunction SendButton({\n  mode,\n  ready,\n  disabled,\n  onSend,\n  onStop,\n  reduce,\n  hint,\n}: {\n  mode: \"send\" | \"stop\";\n  ready: boolean;\n  disabled: boolean;\n  onSend: () => void;\n  onStop: () => void;\n  reduce: boolean;\n  hint: { label: string; keys?: string[] };\n}) {\n  const stop = mode === \"stop\";\n  const [arms, setArms] = useState(0);\n  const [wasReady, setWasReady] = useState(ready);\n  if (wasReady !== ready) {\n    setWasReady(ready);\n    if (ready) setArms((n) => n + 1);\n  }\n  // Going to Stop, the arrow leaves upward as if sent; coming back, it rises from below.\n  const variants = {\n    enter: (to: \"send\" | \"stop\") => (reduce ? { opacity: 0 } : to === \"stop\" ? { opacity: 0, scale: 0.4, filter: \"blur(2px)\" } : { opacity: 0, y: 10, filter: \"blur(2px)\" }),\n    center: { opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" },\n    exit: (to: \"send\" | \"stop\") => (reduce ? { opacity: 0 } : to === \"stop\" ? { opacity: 0, y: -14, filter: \"blur(2px)\", transition: { duration: 0.16, ease: ease.in } } : { opacity: 0, scale: 0.4, transition: { duration: 0.12 } }),\n  };\n  return (\n    <Hint label={hint.label} keys={hint.keys}>\n      <button\n        type={stop ? \"button\" : \"submit\"}\n        aria-label={stop ? \"Stop generating\" : \"Send message\"}\n        aria-disabled={disabled || undefined}\n        data-mode={mode}\n        data-ready={ready || undefined}\n        onClick={(e) => {\n          if (disabled) {\n            e.preventDefault();\n            return;\n          }\n          if (stop) {\n            e.preventDefault();\n            onStop();\n          } else {\n            e.preventDefault();\n            onSend();\n          }\n        }}\n        className={cn(\n          \"relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg outline-none\",\n          \"transition-[background-color,color,scale] duration-200 ease-out active:scale-[0.92] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n          disabled ? \"cursor-not-allowed bg-fg/[0.08] text-fg-4 active:scale-100\" : \"bg-fg text-frame hover:bg-fg/85\",\n        )}\n      >\n        <AnimatePresence initial={false} custom={mode}>\n          <motion.span\n            key={mode}\n            custom={mode}\n            variants={variants}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n            className=\"col-start-1 row-start-1 grid place-items-center\"\n          >\n            {stop ? (\n              <span className=\"relative grid size-5 place-items-center\">\n                {/* Working: a quarter arc turning around the stop square. */}\n                <svg viewBox=\"0 0 20 20\" className=\"absolute inset-0 animate-spin-slow motion-reduce:animate-none\" aria-hidden>\n                  <circle cx=\"10\" cy=\"10\" r=\"8.5\" fill=\"none\" stroke=\"currentColor\" strokeOpacity={0.25} strokeWidth={1.5} />\n                  <circle cx=\"10\" cy=\"10\" r=\"8.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeDasharray=\"13.35 53.4\" />\n                </svg>\n                <span className=\"size-[7px] rounded-[1.5px] bg-current\" />\n              </span>\n            ) : (\n              // The arrow lifts a couple of pixels the moment there is something to send.\n              <motion.span key={arms} initial={arms > 0 && !reduce ? { y: 3 } : false} animate={{ y: 0 }} transition={spring.pop} className=\"grid place-items-center\">\n                <ArrowUp size={16} strokeWidth={1.8} />\n              </motion.span>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </button>\n    </Hint>\n  );\n}\n\nfunction AttachmentChip({ attachment: a, onRemove, reduce, disabled }: { attachment: PromptAttachment; onRemove: () => void; reduce: boolean; disabled: boolean }) {\n  const image = a.type.startsWith(\"image/\") && a.preview;\n  const [base, ext] = splitName(a.name);\n  const busy = a.progress != null && a.progress < 1 && !a.error;\n  const meta = a.error ? a.error : busy ? `Uploading ${Math.round((a.progress ?? 0) * 100)}%` : formatBytes(a.size);\n\n  return (\n    <motion.li\n      layout={!reduce}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(3px)\" }}\n      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n      transition={reduce ? { duration: 0.12 } : spring.snappy}\n      data-error={a.error ? \"\" : undefined}\n      className={cn(\n        \"group/chip relative flex h-12 shrink-0 items-center rounded-xl border border-line-2 bg-frame\",\n        image ? \"w-12\" : \"max-w-[15rem] gap-2.5 ps-1.5 pe-3\",\n        \"data-error:border-danger/50\",\n      )}\n    >\n      {image ? (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={a.preview} alt={a.name} width={48} height={48} className=\"size-full rounded-[11px] object-cover\" draggable={false} />\n      ) : (\n        <>\n          <span className={cn(\"relative grid size-9 shrink-0 place-items-center rounded-lg\", a.error ? \"bg-danger-soft text-danger\" : \"bg-fg/[0.06] text-fg-2\")}>\n            {busy ? <ProgressRing value={a.progress ?? 0} /> : a.error ? <Warning size={16} /> : <FileIcon size={16} />}\n          </span>\n          <span className=\"flex min-w-0 flex-col\">\n            <span className=\"flex min-w-0 text-[12.5px] leading-4 font-medium text-fg\" title={a.name}>\n              <span className=\"truncate\">{base}</span>\n              <span className=\"shrink-0\">{ext}</span>\n            </span>\n            <span className={cn(\"truncate text-[11.5px] leading-4 tabular\", a.error ? \"text-danger\" : \"text-fg-3\")}>{meta}</span>\n          </span>\n        </>\n      )}\n      <button\n        type=\"button\"\n        aria-label={`Remove ${a.name}`}\n        disabled={disabled}\n        onClick={onRemove}\n        className={cn(\n          \"absolute -top-1.5 -end-1.5 grid size-5 place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] outline-none\",\n          \"transition-[opacity,scale,color,background-color] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-90\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          // Always visible to a finger; revealed by hover or focus where there's a pointer.\n          \"pointer-fine:scale-75 pointer-fine:opacity-0 pointer-fine:group-hover/chip:scale-100 pointer-fine:group-hover/chip:opacity-100 pointer-fine:focus-visible:scale-100 pointer-fine:focus-visible:opacity-100\",\n          \"before:absolute before:-inset-3 before:content-[''] pointer-fine:before:-inset-1\",\n        )}\n      >\n        <X size={12} strokeWidth={1.8} />\n      </button>\n    </motion.li>\n  );\n}\n\nfunction ProgressRing({ value }: { value: number }) {\n  const c = 2 * Math.PI * 7;\n  return (\n    <svg viewBox=\"0 0 18 18\" className=\"size-[18px] -rotate-90\" aria-hidden>\n      <circle cx=\"9\" cy=\"9\" r=\"7\" fill=\"none\" stroke=\"currentColor\" strokeOpacity={0.2} strokeWidth={1.75} />\n      <circle\n        cx=\"9\"\n        cy=\"9\"\n        r=\"7\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.75}\n        strokeLinecap=\"round\"\n        strokeDasharray={c}\n        strokeDashoffset={c * (1 - Math.max(0.04, Math.min(1, value)))}\n        className=\"transition-[stroke-dashoffset] duration-300 ease-out motion-reduce:transition-none\"\n      />\n    </svg>\n  );\n}\n\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/prompt-input"
}
