{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"AI prompt composer with autogrowing textarea, attachment chips and a send-to-stop path morph.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { ArrowUp, Paperclip, Square, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  type MotionStyle,\n  motion,\n  type Transition,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ChangeEvent,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  type AIState,\n  getAIStateAccentColor,\n  getAIStateMotion,\n} from \"../ai-core\";\n\nconst SUBMIT_ICON_SIZE = 17;\nconst STOP_ICON_SIZE = 13;\n\nconst MIN_ROWS = 1;\nconst MAX_ROWS = 8;\nconst COUNTER_VISIBLE_RATIO = 0.8;\nconst SPRING_DEFAULT: Transition = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\",\n};\nconst SPRING_SNAPPY: Transition = {\n  bounce: 0,\n  duration: 0.2,\n  type: \"spring\",\n};\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst ATTACHMENT_STAGGER = 0.035;\n\nexport type AIPromptAttachment = {\n  id: string;\n  /** Bytes. Rendered as a human-readable size when present. */\n  size?: number;\n  name: string;\n};\n\nexport type AIPromptInputProps = {\n  /** Files already attached to the draft. */\n  attachments?: AIPromptAttachment[];\n  /** Extra controls rendered on the left of the toolbar — model pickers etc. */\n  children?: ReactNode;\n  className?: string;\n  disabled?: boolean;\n  /** Shows a counter once the draft passes 80% of the limit. */\n  maxLength?: number;\n  onAttach?: () => void;\n  onRemoveAttachment?: (id: string) => void;\n  /** Called when the submit control is pressed while `state` is `streaming`. */\n  onStop?: () => void;\n  onSubmit?: (value: string) => void;\n  onValueChange?: (value: string) => void;\n  placeholder?: string;\n  /** Shared AI state. `streaming` turns submit into stop. */\n  state?: AIState;\n  /** Controlled draft. Leave undefined to let the component own it. */\n  value?: string;\n};\n\nconst formatSize = (bytes: number): string => {\n  const kilobyte = 1024;\n  if (bytes < kilobyte) {\n    return `${bytes} B`;\n  }\n  const megabyte = kilobyte * kilobyte;\n  if (bytes < megabyte) {\n    return `${Math.round(bytes / kilobyte)} KB`;\n  }\n  return `${(bytes / megabyte).toFixed(1)} MB`;\n};\n\n/**\n * The prompt composer.\n *\n * Growth is animated with a layout spring rather than a CSS height transition,\n * so a pasted paragraph settles instead of snapping — and because the height is\n * measured from the textarea's own scroll height, the surrounding page never\n * reflows mid-keystroke.\n */\nconst AIPromptInput = ({\n  attachments = [],\n  children,\n  className,\n  disabled = false,\n  maxLength,\n  onAttach,\n  onRemoveAttachment,\n  onStop,\n  onSubmit,\n  onValueChange,\n  placeholder = \"Ask anything…\",\n  state = \"idle\",\n  value,\n}: AIPromptInputProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const stateMotion = getAIStateMotion(state);\n  const accentColor = getAIStateAccentColor(state, \"transparent\");\n\n  const textareaRef = useRef<HTMLTextAreaElement | null>(null);\n  const [internalValue, setInternalValue] = useState(\"\");\n  const [isFocused, setIsFocused] = useState(false);\n\n  const isControlled = value !== undefined;\n  const draft = isControlled ? value : internalValue;\n  const isStreaming = state === \"streaming\";\n  const canSubmit = draft.trim().length > 0 && !disabled;\n\n  const resize = useCallback(() => {\n    const textarea = textareaRef.current;\n    if (!textarea) {\n      return;\n    }\n    // Collapse first, otherwise scrollHeight only ever grows.\n    textarea.style.height = \"auto\";\n    const lineHeight = Number.parseFloat(\n      getComputedStyle(textarea).lineHeight || \"20\"\n    );\n    const maxHeight = lineHeight * MAX_ROWS;\n    textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;\n    textarea.style.overflowY =\n      textarea.scrollHeight > maxHeight ? \"auto\" : \"hidden\";\n  }, []);\n\n  useLayoutEffect(() => {\n    // Collapsing on an empty draft matters after submit: the box has to return\n    // to one row instead of holding the height of the message just sent.\n    if (draft.length === 0 && textareaRef.current) {\n      textareaRef.current.style.height = \"\";\n    }\n    resize();\n  }, [draft, resize]);\n\n  const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {\n    const next = event.target.value;\n    if (!isControlled) {\n      setInternalValue(next);\n    }\n    onValueChange?.(next);\n  };\n\n  const submit = () => {\n    if (isStreaming) {\n      onStop?.();\n      return;\n    }\n    if (!canSubmit) {\n      return;\n    }\n    onSubmit?.(draft.trim());\n    if (!isControlled) {\n      setInternalValue(\"\");\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n    // Enter sends, Shift+Enter breaks the line — the convention every chat UI\n    // has trained users on.\n    if (event.key === \"Enter\" && !event.shiftKey) {\n      event.preventDefault();\n      submit();\n    }\n  };\n\n  const counterVisible =\n    maxLength !== undefined &&\n    draft.length >= maxLength * COUNTER_VISIBLE_RATIO;\n  const overLimit = maxLength !== undefined && draft.length > maxLength;\n\n  return (\n    <motion.div\n      className={cn(\n        \"relative w-full rounded-2xl border bg-background\",\n        isFocused ? \"border-foreground/40\" : \"border-border\",\n        disabled && \"opacity-60\",\n        className\n      )}\n      layout={shouldReduceMotion ? false : \"position\"}\n      style={\n        {\n          // The accent ring is the only place status shows here: a composer that\n          // changes shape per state would fight the text the user is writing.\n          boxShadow:\n            state === \"error\" || state === \"done\"\n              ? `0 0 0 1px ${accentColor}`\n              : undefined,\n        } as MotionStyle\n      }\n      transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n    >\n      <AnimatePresence initial={false}>\n        {attachments.length > 0 && (\n          <motion.ul\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"flex list-none flex-wrap gap-2 overflow-hidden px-3 pt-3\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { height: 0, opacity: 0 }\n            }\n            initial={\n              shouldReduceMotion\n                ? { height: \"auto\", opacity: 1 }\n                : { height: 0, opacity: 0 }\n            }\n            transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n          >\n            {attachments.map((attachment, index) => (\n              <motion.li\n                animate={{ opacity: 1, scale: 1, y: 0 }}\n                className=\"flex items-center gap-1.5 rounded-lg border border-border bg-muted/50 py-1 pr-1 pl-2 text-xs\"\n                exit={\n                  shouldReduceMotion\n                    ? { opacity: 0, transition: { duration: 0 } }\n                    : { opacity: 0, scale: 0.9 }\n                }\n                initial={\n                  shouldReduceMotion\n                    ? { opacity: 1, scale: 1, y: 0 }\n                    : { opacity: 0, scale: 0.9, y: 4 }\n                }\n                key={attachment.id}\n                layout={!shouldReduceMotion}\n                transition={\n                  shouldReduceMotion\n                    ? { duration: 0 }\n                    : { ...SPRING_DEFAULT, delay: index * ATTACHMENT_STAGGER }\n                }\n              >\n                <span className=\"max-w-40 truncate\">{attachment.name}</span>\n                {attachment.size !== undefined && (\n                  <span className=\"text-muted-foreground\">\n                    {formatSize(attachment.size)}\n                  </span>\n                )}\n                {onRemoveAttachment ? (\n                  <button\n                    aria-label={`Remove ${attachment.name}`}\n                    className=\"cursor-pointer rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n                    onClick={() => onRemoveAttachment(attachment.id)}\n                    type=\"button\"\n                  >\n                    <X aria-hidden=\"true\" size={12} />\n                  </button>\n                ) : null}\n              </motion.li>\n            ))}\n          </motion.ul>\n        )}\n      </AnimatePresence>\n\n      <textarea\n        className=\"max-h-64 w-full resize-none bg-transparent px-4 pt-3 pb-2 text-sm outline-none placeholder:text-muted-foreground\"\n        disabled={disabled}\n        onBlur={() => setIsFocused(false)}\n        onChange={handleChange}\n        onFocus={() => setIsFocused(true)}\n        onKeyDown={handleKeyDown}\n        placeholder={placeholder}\n        ref={textareaRef}\n        rows={MIN_ROWS}\n        value={draft}\n      />\n\n      <div className=\"flex items-center justify-between gap-2 px-2 pb-2\">\n        <div className=\"flex min-w-0 items-center gap-1\">\n          {onAttach ? (\n            <button\n              aria-label=\"Attach a file\"\n              className=\"cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n              disabled={disabled}\n              onClick={onAttach}\n              type=\"button\"\n            >\n              <Paperclip aria-hidden=\"true\" size={16} />\n            </button>\n          ) : null}\n          {children}\n        </div>\n\n        <div className=\"flex items-center gap-2\">\n          <AnimatePresence initial={false}>\n            {counterVisible ? (\n              <motion.span\n                animate={{ opacity: 1, y: 0 }}\n                className={cn(\n                  \"text-xs tabular-nums\",\n                  overLimit ? \"text-destructive\" : \"text-muted-foreground\"\n                )}\n                exit={\n                  shouldReduceMotion\n                    ? { opacity: 0, transition: { duration: 0 } }\n                    : { opacity: 0, y: 4 }\n                }\n                initial={\n                  shouldReduceMotion\n                    ? { opacity: 1, y: 0 }\n                    : { opacity: 0, y: 4 }\n                }\n                transition={\n                  shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY\n                }\n              >\n                {draft.length}/{maxLength}\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n\n          <AIPromptSubmit\n            disabled={disabled || !(canSubmit || isStreaming)}\n            isStreaming={isStreaming}\n            onClick={submit}\n            shouldReduceMotion={Boolean(shouldReduceMotion)}\n            speed={stateMotion.speed}\n          />\n        </div>\n      </div>\n    </motion.div>\n  );\n};\n\ntype AIPromptSubmitProps = {\n  disabled: boolean;\n  isStreaming: boolean;\n  onClick: () => void;\n  shouldReduceMotion: boolean;\n  speed: number;\n};\n\nconst AIPromptSubmit = ({\n  disabled,\n  isStreaming,\n  onClick,\n  shouldReduceMotion,\n  speed,\n}: AIPromptSubmitProps) => (\n  <motion.button\n    aria-label={isStreaming ? \"Stop generating\" : \"Send message\"}\n    className={cn(\n      \"flex size-9 items-center justify-center rounded-xl\",\n      disabled\n        ? \"bg-muted text-muted-foreground\"\n        : \"bg-foreground text-background\"\n    )}\n    disabled={disabled}\n    onClick={onClick}\n    transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n    type=\"button\"\n    whileHover={disabled || shouldReduceMotion ? undefined : { scale: 1.05 }}\n    whileTap={disabled || shouldReduceMotion ? undefined : { scale: 0.95 }}\n  >\n    {/* Real lucide glyphs, swapped rather than morphed.\n        An earlier version interpolated one hand-authored `d` into another, which\n        bought a nice fold at the cost of not being a lucide icon at all — and a\n        filled wedge reads as \"play\", not \"send\". A thin stroked arrow is the\n        convention, and lucide's own geometry is what the rest of the library\n        uses. The swap is scale-and-fade so it still reads as one control. */}\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span\n        animate={{ opacity: 1, scale: 1 }}\n        className=\"flex items-center justify-center\"\n        exit={\n          shouldReduceMotion\n            ? { opacity: 0, transition: { duration: 0 } }\n            : { opacity: 0, scale: 0.6 }\n        }\n        initial={\n          shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.6 }\n        }\n        key={isStreaming ? \"stop\" : \"send\"}\n        transition={\n          shouldReduceMotion\n            ? { duration: 0 }\n            : { duration: 0.18 / speed, ease: EASE_OUT }\n        }\n      >\n        {isStreaming ? (\n          <Square\n            aria-hidden=\"true\"\n            className=\"fill-current\"\n            size={STOP_ICON_SIZE}\n            strokeWidth={0}\n          />\n        ) : (\n          <ArrowUp aria-hidden=\"true\" size={SUBMIT_ICON_SIZE} />\n        )}\n      </motion.span>\n    </AnimatePresence>\n  </motion.button>\n);\n\nexport default AIPromptInput;\n","path":"index.tsx","target":"components/smoothui/ai-prompt-input/index.tsx","type":"registry:ui"}],"name":"ai-prompt-input","registryDependencies":["https://smoothui.dev/r/ai-core.json"],"title":"Ai Prompt Input","type":"registry:ui"}