{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "composer",
  "type": "registry:ui",
  "title": "Composer",
  "description": "Prompt input for chat and agents: auto-growing textarea, attachments, toolbar slot, send→stop button, paste/drop files and a length hint.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/attachment.json",
    "https://kuro.abstergo.fyi/r/icon-button.json",
    "https://kuro.abstergo.fyi/r/kura-merge-refs.json",
    "https://kuro.abstergo.fyi/r/kura-motion.json",
    "https://kuro.abstergo.fyi/r/tooltip.json",
    "https://kuro.abstergo.fyi/r/use-controllable-state.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/composer/composer.tsx",
      "type": "registry:ui",
      "target": "components/kura/composer/composer.tsx",
      "content": "\"use client\";\n\nimport { ArrowUp, Paperclip } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\nimport { springs } from \"@/lib/kura/motion\";\nimport { AttachmentList, AttachmentProgressRing } from \"@/components/kura/attachment\";\nimport {\n  type AttachmentItem,\n  acceptsFile,\n  createAttachment,\n  dragHasFiles,\n  filesFromDataTransfer,\n} from \"@/components/kura/attachment/attachment-utils\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { Tooltip } from \"@/components/kura/tooltip\";\n\nexport type ComposerStatus = \"idle\" | \"submitted\" | \"streaming\" | \"error\";\n\nexport interface ComposerMessage {\n  /** The trimmed text. */\n  text: string;\n  attachments: AttachmentItem[];\n}\n\ninterface ComposerContextValue {\n  value: string;\n  setValue: (next: string) => void;\n  attachments: AttachmentItem[];\n  addFiles: (files: File[]) => void;\n  removeAttachment: (id: string) => void;\n  openFilePicker: () => void;\n  submit: () => void;\n  stop: () => void;\n  status: ComposerStatus;\n  disabled: boolean;\n  busy: boolean;\n  canSubmit: boolean;\n  maxLength: number | undefined;\n  submitKey: \"enter\" | \"mod+enter\";\n  acceptsFiles: boolean;\n  textareaRef: React.RefObject<HTMLTextAreaElement | null>;\n  inputId: string;\n  hintId: string;\n}\n\nconst ComposerContext = React.createContext<ComposerContextValue | null>(null);\n\n/** Read composer state from a custom part. Must be used inside <Composer>. */\nexport function useComposer(): ComposerContextValue {\n  const ctx = React.useContext(ComposerContext);\n  if (!ctx) throw new Error(\"useComposer must be used inside <Composer>.\");\n  return ctx;\n}\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\n// ---------------------------------------------------------------------------------------------\n// Root\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerProps\n  extends Omit<React.ComponentProps<\"div\">, \"onSubmit\" | \"defaultValue\" | \"onChange\"> {\n  /** Controlled text. */\n  value?: string;\n  /** Initial text when uncontrolled. Default \"\". */\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Controlled attachments. */\n  attachments?: AttachmentItem[];\n  /** Initial attachments when uncontrolled. */\n  defaultAttachments?: AttachmentItem[];\n  onAttachmentsChange?: (attachments: AttachmentItem[]) => void;\n  /** Called with files added by paste, drop or the picker (after filtering), e.g. to start uploads. */\n  onFilesAdded?: (items: AttachmentItem[]) => void;\n  /** Called with files rejected by `accept`, `maxFileSize` or `maxFiles`. */\n  onFilesRejected?: (files: File[]) => void;\n  /** Send handler. Receives the trimmed text and the attachments. */\n  onSubmit?: (message: ComposerMessage) => void;\n  /** Called by the stop button (and Escape) while status is \"submitted\" or \"streaming\". */\n  onStop?: () => void;\n  /**\n   * Request lifecycle. `submitted` = waiting for the first token, `streaming` = the model is\n   * writing. Both turn the send button into a stop button. Default \"idle\".\n   */\n  status?: ComposerStatus;\n  /** Disable typing, attaching and sending. */\n  disabled?: boolean;\n  /** Character limit. The hint turns amber at 90% and sending is blocked above it. */\n  maxLength?: number;\n  /** `enter` = Enter sends, Shift+Enter is a newline. `mod+enter` = ⌘/Ctrl+Enter sends. Default \"enter\". */\n  submitKey?: \"enter\" | \"mod+enter\";\n  /** Allowed files (same syntax as `<input accept>`). Pass `false` to disable attachments. Default \"\" (any). */\n  accept?: string | false;\n  /** Maximum number of attachments. */\n  maxFiles?: number;\n  /** Maximum size of one file in bytes. */\n  maxFileSize?: number;\n  /** Clear text and attachments after a successful submit. Default true. */\n  clearOnSubmit?: boolean;\n  /** Placeholder for the default layout (used when Composer has no children). */\n  placeholder?: string;\n}\n\n/**\n * The prompt input. Compound: `<Composer>` owns the text, attachments and request status;\n * `ComposerInput`, `ComposerAttachments`, `ComposerToolbar`, `ComposerSubmit` and friends read\n * them from context. Render `<Composer />` with no children for the complete default layout.\n */\nexport function Composer({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  attachments: attachmentsProp,\n  defaultAttachments,\n  onAttachmentsChange,\n  onFilesAdded,\n  onFilesRejected,\n  onSubmit,\n  onStop,\n  status = \"idle\",\n  disabled = false,\n  maxLength,\n  submitKey = \"enter\",\n  accept = \"\",\n  maxFiles,\n  maxFileSize,\n  clearOnSubmit = true,\n  placeholder,\n  className,\n  children,\n  onFocus,\n  onBlur,\n  onMouseDown,\n  ...props\n}: ComposerProps) {\n  const [value, setValue] = useControllableState({\n    value: valueProp,\n    defaultValue,\n    onChange: onValueChange,\n  });\n  const [attachments, setAttachments] = useControllableState<AttachmentItem[]>({\n    value: attachmentsProp,\n    defaultValue: defaultAttachments ?? [],\n    onChange: onAttachmentsChange,\n  });\n  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);\n  const fileRef = React.useRef<HTMLInputElement>(null);\n  const [focused, setFocused] = React.useState(false);\n  const [dragging, setDragging] = React.useState(false);\n  const dragDepth = React.useRef(0);\n  const inputId = React.useId();\n  const hintId = React.useId();\n\n  const acceptsFiles = accept !== false && !disabled;\n  const busy = status === \"submitted\" || status === \"streaming\";\n  const overLimit = maxLength !== undefined && value.length > maxLength;\n  const uploading = attachments.some((a) => a.status === \"uploading\");\n  const hasContent = value.trim().length > 0 || attachments.length > 0;\n  const canSubmit = !disabled && !busy && hasContent && !overLimit && !uploading;\n\n  const addFiles = React.useCallback(\n    (files: File[]) => {\n      if (accept === false || disabled || files.length === 0) return;\n      const ok: AttachmentItem[] = [];\n      const rejected: File[] = [];\n      let room = maxFiles === undefined ? Number.POSITIVE_INFINITY : maxFiles - attachments.length;\n      for (const file of files) {\n        const fits = maxFileSize === undefined || file.size <= maxFileSize;\n        if (room > 0 && fits && acceptsFile(file, accept || undefined)) {\n          ok.push(createAttachment(file));\n          room--;\n        } else rejected.push(file);\n      }\n      if (ok.length) {\n        setAttachments((list) => [...list, ...ok]);\n        onFilesAdded?.(ok);\n      }\n      if (rejected.length) onFilesRejected?.(rejected);\n    },\n    [\n      accept,\n      disabled,\n      maxFiles,\n      maxFileSize,\n      attachments.length,\n      setAttachments,\n      onFilesAdded,\n      onFilesRejected,\n    ],\n  );\n\n  const removeAttachment = React.useCallback(\n    (id: string) => {\n      setAttachments((list) => {\n        const gone = list.find((a) => a.id === id);\n        if (gone?.file && gone.url?.startsWith(\"blob:\")) URL.revokeObjectURL(gone.url);\n        return list.filter((a) => a.id !== id);\n      });\n      textareaRef.current?.focus();\n    },\n    [setAttachments],\n  );\n\n  const submit = React.useCallback(() => {\n    if (!canSubmit) return;\n    onSubmit?.({ text: value.trim(), attachments });\n    if (clearOnSubmit) {\n      setValue(\"\");\n      setAttachments([]);\n    }\n    textareaRef.current?.focus();\n  }, [canSubmit, onSubmit, value, attachments, clearOnSubmit, setValue, setAttachments]);\n\n  const stop = React.useCallback(() => {\n    if (busy) onStop?.();\n  }, [busy, onStop]);\n\n  const ctx: ComposerContextValue = {\n    value,\n    setValue,\n    attachments,\n    addFiles,\n    removeAttachment,\n    openFilePicker: () => fileRef.current?.click(),\n    submit,\n    stop,\n    status,\n    disabled,\n    busy,\n    canSubmit,\n    maxLength,\n    submitKey,\n    acceptsFiles,\n    textareaRef,\n    inputId,\n    hintId,\n  };\n\n  return (\n    <ComposerContext.Provider value={ctx}>\n      {/* biome-ignore lint/a11y/noStaticElementInteractions: focus tracking, click-to-focus and file drop; all controls inside are native. */}\n      <div\n        data-slot=\"composer\"\n        data-status={status}\n        data-focused={focused}\n        data-dragging={dragging || undefined}\n        data-disabled={disabled || undefined}\n        aria-busy={busy || undefined}\n        onFocus={(e) => {\n          setFocused(true);\n          onFocus?.(e);\n        }}\n        onBlur={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);\n          onBlur?.(e);\n        }}\n        onMouseDown={(e) => {\n          onMouseDown?.(e);\n          // Clicking the padding or toolbar background focuses the textarea, like a native field.\n          const t = e.target as HTMLElement;\n          if (\n            !e.defaultPrevented &&\n            textareaRef.current &&\n            t !== textareaRef.current &&\n            (t === e.currentTarget || t.dataset.slot === \"composer-toolbar\")\n          ) {\n            e.preventDefault();\n            textareaRef.current.focus();\n          }\n        }}\n        onDragEnter={(e) => {\n          if (!acceptsFiles || !dragHasFiles(e.dataTransfer)) return;\n          e.preventDefault();\n          dragDepth.current += 1;\n          setDragging(true);\n        }}\n        onDragOver={(e) => {\n          if (!acceptsFiles || !dragHasFiles(e.dataTransfer)) return;\n          e.preventDefault();\n          e.dataTransfer.dropEffect = \"copy\";\n        }}\n        onDragLeave={() => {\n          dragDepth.current = Math.max(0, dragDepth.current - 1);\n          if (dragDepth.current === 0) setDragging(false);\n        }}\n        onDrop={(e) => {\n          if (!acceptsFiles || !dragHasFiles(e.dataTransfer)) return;\n          e.preventDefault();\n          dragDepth.current = 0;\n          setDragging(false);\n          addFiles(filesFromDataTransfer(e.dataTransfer));\n        }}\n        className={cn(\n          \"group/composer relative isolate flex w-full cursor-text flex-col rounded-kura-2xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n          \"transition-[box-shadow,opacity] duration-(--kura-duration-base) ease-kura-out\",\n          \"data-[focused=true]:shadow-[0_0_0_1px_var(--kura-border-strong),0_0_0_4px_color-mix(in_oklch,var(--kura-ring)_14%,transparent),var(--kura-shadow-md)]\",\n          disabled && \"cursor-not-allowed opacity-60\",\n          className,\n        )}\n        {...props}\n      >\n        {children ?? (\n          <>\n            <ComposerAttachments />\n            <ComposerInput placeholder={placeholder} />\n            <ComposerToolbar>\n              <ComposerTools>{acceptsFiles && <ComposerAttachButton />}</ComposerTools>\n              <ComposerActions>\n                <ComposerHint />\n                <ComposerSubmit />\n              </ComposerActions>\n            </ComposerToolbar>\n          </>\n        )}\n        {accept !== false && (\n          <input\n            ref={fileRef}\n            type=\"file\"\n            multiple\n            tabIndex={-1}\n            aria-hidden\n            className=\"hidden\"\n            accept={accept || undefined}\n            disabled={disabled}\n            onChange={(e) => {\n              addFiles(Array.from(e.currentTarget.files ?? []));\n              e.currentTarget.value = \"\";\n              textareaRef.current?.focus();\n            }}\n          />\n        )}\n        <ComposerDropOverlay visible={dragging} />\n      </div>\n    </ComposerContext.Provider>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Input\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerInputProps\n  extends Omit<React.ComponentProps<\"textarea\">, \"value\" | \"defaultValue\" | \"rows\"> {\n  /** Rows shown when empty. Default 1. */\n  minRows?: number;\n  /** Rows before the textarea scrolls instead of growing. Default 8. */\n  maxRows?: number;\n}\n\n/**\n * Auto-growing textarea. Enter sends (Shift+Enter inserts a newline), IME composition is never\n * interrupted, pasted files become attachments, and Escape stops a streaming response.\n */\nexport function ComposerInput({\n  minRows = 1,\n  maxRows = 8,\n  className,\n  onKeyDown,\n  onPaste,\n  onChange,\n  placeholder = \"Ask anything…\",\n  ref,\n  id,\n  disabled: disabledProp,\n  ...props\n}: ComposerInputProps) {\n  const ctx = useComposer();\n  const { textareaRef, value } = ctx;\n  const disabled = disabledProp ?? ctx.disabled;\n  const overLimit = ctx.maxLength !== undefined && value.length > ctx.maxLength;\n\n  const resize = React.useCallback(() => {\n    const el = textareaRef.current;\n    if (!el || typeof window === \"undefined\") return;\n    const style = window.getComputedStyle(el);\n    const line = Number.parseFloat(style.lineHeight) || 24;\n    const pad = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom) || 0;\n    const min = line * minRows + pad;\n    const max = line * maxRows + pad;\n    el.style.height = \"auto\";\n    const next = Math.min(max, Math.max(min, el.scrollHeight));\n    el.style.height = `${next}px`;\n    el.style.overflowY = el.scrollHeight > max + 1 ? \"auto\" : \"hidden\";\n  }, [textareaRef, minRows, maxRows]);\n\n  // Re-measure whenever the text changes.\n  useIsoLayoutEffect(() => {\n    if (value !== undefined) resize();\n  }, [resize, value]);\n\n  React.useEffect(() => {\n    const el = textareaRef.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    let width = el.clientWidth;\n    const ro = new ResizeObserver(() => {\n      if (el.clientWidth !== width) {\n        width = el.clientWidth;\n        resize();\n      }\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [textareaRef, resize]);\n\n  return (\n    <textarea\n      data-slot=\"composer-input\"\n      ref={mergeRefs(textareaRef, ref)}\n      id={id ?? ctx.inputId}\n      rows={minRows}\n      value={value}\n      disabled={disabled}\n      placeholder={placeholder}\n      aria-invalid={overLimit || undefined}\n      aria-describedby={ctx.maxLength !== undefined ? ctx.hintId : undefined}\n      aria-label={props[\"aria-label\"] ?? (props[\"aria-labelledby\"] ? undefined : \"Message\")}\n      enterKeyHint=\"send\"\n      onChange={(e) => {\n        onChange?.(e);\n        if (!e.defaultPrevented) ctx.setValue(e.currentTarget.value);\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented) return;\n        if (e.key === \"Escape\" && ctx.busy) {\n          e.preventDefault();\n          ctx.stop();\n          return;\n        }\n        if (e.key !== \"Enter\") return;\n        // Never submit while an IME is composing (Japanese, Chinese, Korean input).\n        if (e.nativeEvent.isComposing || e.keyCode === 229) return;\n        const mod = e.metaKey || e.ctrlKey;\n        if (ctx.submitKey === \"enter\" ? !e.shiftKey && !e.altKey : mod) {\n          e.preventDefault();\n          ctx.submit();\n        }\n      }}\n      onPaste={(e) => {\n        onPaste?.(e);\n        if (e.defaultPrevented || !ctx.acceptsFiles) return;\n        const files = filesFromDataTransfer(e.clipboardData);\n        if (files.length > 0) {\n          e.preventDefault();\n          ctx.addFiles(files);\n        }\n      }}\n      className={cn(\n        \"block w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 text-[15px] text-kura-fg leading-6 outline-none\",\n        \"placeholder:text-kura-fg-subtle disabled:cursor-not-allowed [scrollbar-gutter:stable] [scrollbar-width:thin]\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Attachments\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerAttachmentsProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Collapse into a \"+N\" stack after this many. */\n  max?: number;\n}\n\n/** The composer's attachment row. Renders nothing while there are no attachments. */\nexport function ComposerAttachments({ max, className, ...props }: ComposerAttachmentsProps) {\n  const ctx = useComposer();\n  if (ctx.attachments.length === 0) return null;\n  return (\n    <div\n      data-slot=\"composer-attachments\"\n      className={cn(\"cursor-default px-3 pt-3\", className)}\n      {...props}\n    >\n      <AttachmentList\n        items={ctx.attachments}\n        max={max}\n        onRemove={ctx.disabled ? undefined : ctx.removeAttachment}\n      />\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Toolbar\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerToolbarProps extends React.ComponentProps<\"div\"> {}\n\n/** Bottom row. Put `ComposerTools` (left) and `ComposerActions` (right) inside. */\nexport function ComposerToolbar({ className, ...props }: ComposerToolbarProps) {\n  return (\n    <div\n      data-slot=\"composer-toolbar\"\n      className={cn(\"flex min-h-12 items-center justify-between gap-2 p-2 pt-1\", className)}\n      {...props}\n    />\n  );\n}\n\nexport interface ComposerToolsProps extends React.ComponentProps<\"div\"> {}\n\n/** Left toolbar group for the attach button, model picker, tool toggles. */\nexport function ComposerTools({ className, ...props }: ComposerToolsProps) {\n  return (\n    <div\n      data-slot=\"composer-tools\"\n      className={cn(\"flex min-w-0 flex-1 items-center gap-1\", className)}\n      {...props}\n    />\n  );\n}\n\nexport interface ComposerActionsProps extends React.ComponentProps<\"div\"> {}\n\n/** Right toolbar group for the hint, voice input and the submit button. */\nexport function ComposerActions({ className, ...props }: ComposerActionsProps) {\n  return (\n    <div\n      data-slot=\"composer-actions\"\n      className={cn(\"flex shrink-0 items-center gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\nexport interface ComposerAttachButtonProps\n  extends Omit<React.ComponentProps<typeof IconButton>, \"label\" | \"icon\"> {\n  /** Accessible label and tooltip. Default \"Attach files\". */\n  label?: string;\n  icon?: React.ReactNode;\n}\n\n/** Opens the file picker. Hidden automatically when the composer has `accept={false}`. */\nexport function ComposerAttachButton({\n  label = \"Attach files\",\n  icon,\n  onClick,\n  className,\n  ...props\n}: ComposerAttachButtonProps) {\n  const ctx = useComposer();\n  if (!ctx.acceptsFiles && !ctx.disabled) return null;\n  return (\n    <IconButton\n      data-slot=\"composer-attach\"\n      label={label}\n      icon={icon ?? <Paperclip />}\n      disabled={ctx.disabled}\n      onClick={(e) => {\n        onClick?.(e);\n        if (!e.defaultPrevented) ctx.openFilePicker();\n      }}\n      className={cn(\"rounded-kura-lg\", className)}\n      {...props}\n    />\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Hint (characters / tokens)\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerHintProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** `characters` counts characters (default with `maxLength`); `tokens` shows a ~4 chars/token estimate. */\n  mode?: \"characters\" | \"tokens\";\n  /** Only show once the text reaches this fraction of `maxLength`. Default 0 (always, when not empty). */\n  showAt?: number;\n  /** Custom token counter for `mode=\"tokens\"`. */\n  countTokens?: (text: string) => number;\n}\n\nconst nf = new Intl.NumberFormat(\"en-US\");\n\n/**\n * Character or token count with a tiny gauge. Quiet until it matters: amber at 90% of\n * `maxLength`, red when over (sending is then blocked).\n */\nexport function ComposerHint({\n  mode,\n  showAt = 0,\n  countTokens,\n  className,\n  ...props\n}: ComposerHintProps) {\n  const { value, maxLength, hintId } = useComposer();\n  const resolved = mode ?? (maxLength !== undefined ? \"characters\" : \"tokens\");\n  const length = value.length;\n  const tokens = countTokens ? countTokens(value) : Math.ceil(value.trim().length / 4);\n  const ratio = maxLength ? length / maxLength : 0;\n  const level = ratio > 1 ? \"over\" : ratio >= 0.9 ? \"near\" : \"ok\";\n  const visible = length > 0 && (!maxLength || ratio >= showAt);\n\n  const label =\n    resolved === \"tokens\"\n      ? `~${nf.format(tokens)} ${tokens === 1 ? \"token\" : \"tokens\"}`\n      : maxLength\n        ? `${nf.format(length)} / ${nf.format(maxLength)}`\n        : nf.format(length);\n\n  return (\n    <span\n      data-slot=\"composer-hint\"\n      data-level={level}\n      id={hintId}\n      aria-hidden={!visible || undefined}\n      className={cn(\n        \"inline-flex select-none items-center gap-1.5 text-xs text-kura-fg-subtle tabular-nums\",\n        \"transition-[opacity,color] duration-(--kura-duration-base) ease-kura-out\",\n        !visible && \"pointer-events-none opacity-0\",\n        level === \"near\" && \"text-kura-warning-text\",\n        level === \"over\" && \"text-kura-danger-text\",\n        className,\n      )}\n      {...props}\n    >\n      {maxLength !== undefined && resolved === \"characters\" && (\n        <AttachmentProgressRing progress={Math.min(1, ratio)} thickness={3} className=\"size-3.5\" />\n      )}\n      <span aria-hidden>{label}</span>\n      {maxLength !== undefined && (\n        <span className=\"sr-only\">\n          {level === \"over\"\n            ? `Message exceeds the ${nf.format(maxLength)} character limit`\n            : `${nf.format(maxLength)} character limit`}\n        </span>\n      )}\n    </span>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Submit / stop\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerSubmitProps\n  extends Omit<React.ComponentProps<\"button\">, \"children\" | \"type\"> {\n  /** Accessible label while idle. Default \"Send message\". */\n  sendLabel?: string;\n  /** Accessible label while busy. Default \"Stop generating\". */\n  stopLabel?: string;\n  /** Show a tooltip with the shortcut. Default true. */\n  tooltip?: boolean;\n}\n\n/**\n * Send button that becomes a stop button while the composer is `submitted` or `streaming`.\n * The icons cross-fade with a 2px blur; a thin arc orbits the button while waiting for the\n * first token.\n */\nexport function ComposerSubmit({\n  sendLabel = \"Send message\",\n  stopLabel = \"Stop generating\",\n  tooltip = true,\n  className,\n  onClick,\n  disabled: disabledProp,\n  ...props\n}: ComposerSubmitProps) {\n  const ctx = useComposer();\n  const reduced = useReducedMotion();\n  const mode = ctx.busy ? \"stop\" : \"send\";\n  const disabled = disabledProp ?? (ctx.busy ? false : !ctx.canSubmit);\n  const ready = mode === \"stop\" || ctx.canSubmit;\n\n  const swap = reduced\n    ? {\n        initial: { opacity: 0 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        transition: { duration: 0.12 },\n      }\n    : {\n        initial: { opacity: 0, scale: 0.5, filter: \"blur(2px)\" },\n        animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n        exit: { opacity: 0, scale: 0.5, filter: \"blur(2px)\" },\n        transition: springs.snappy,\n      };\n\n  const button = (\n    <button\n      type=\"button\"\n      data-slot=\"composer-submit\"\n      data-mode={mode}\n      data-ready={ready}\n      aria-label={mode === \"stop\" ? stopLabel : sendLabel}\n      disabled={disabled}\n      onClick={(e) => {\n        onClick?.(e);\n        if (e.defaultPrevented) return;\n        if (mode === \"stop\") ctx.stop();\n        else ctx.submit();\n      }}\n      className={cn(\n        \"relative flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-kura-lg outline-none\",\n        \"transition-[background-color,color,box-shadow,transform] duration-(--kura-duration-fast) ease-kura-out\",\n        \"focus-visible:shadow-kura-focus active:not-disabled:scale-[0.94] motion-reduce:active:not-disabled:scale-100\",\n        \"disabled:cursor-not-allowed\",\n        ready\n          ? \"bg-kura-ink text-kura-fg-inverted shadow-kura-xs hover:bg-kura-ink-hover\"\n          : \"bg-kura-active text-kura-fg-subtle\",\n        className,\n      )}\n      {...props}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span key={mode} className=\"flex items-center justify-center\" {...swap}>\n          {mode === \"stop\" ? (\n            <span aria-hidden className=\"size-2.5 rounded-[2.5px] bg-current\" />\n          ) : (\n            <ArrowUp aria-hidden className=\"size-4\" strokeWidth={2.25} />\n          )}\n        </motion.span>\n      </AnimatePresence>\n      {ctx.status === \"submitted\" && (\n        <svg\n          aria-hidden\n          data-slot=\"composer-submit-pending\"\n          viewBox=\"0 0 36 36\"\n          className=\"-inset-0.5 pointer-events-none absolute size-9 text-kura-fg-subtle motion-reduce:animate-kura-pulse\"\n          fill=\"none\"\n        >\n          {/* a short arc travelling around the button's own rounded outline */}\n          <motion.rect\n            x=\"1\"\n            y=\"1\"\n            width=\"34\"\n            height=\"34\"\n            rx=\"13\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.5\"\n            pathLength={100}\n            strokeDasharray=\"22 78\"\n            strokeLinecap=\"round\"\n            initial={{ strokeDashoffset: 0 }}\n            animate={reduced ? { strokeDashoffset: 0 } : { strokeDashoffset: -100 }}\n            transition={\n              reduced\n                ? { duration: 0 }\n                : { duration: 1.4, ease: \"linear\", repeat: Number.POSITIVE_INFINITY }\n            }\n          />\n        </svg>\n      )}\n    </button>\n  );\n\n  if (!tooltip) return button;\n  return (\n    <Tooltip\n      content={mode === \"stop\" ? \"Stop\" : \"Send\"}\n      shortcut={\n        mode === \"stop\" ? [\"esc\"] : ctx.submitKey === \"enter\" ? [\"enter\"] : [\"mod\", \"enter\"]\n      }\n    >\n      {button}\n    </Tooltip>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Drop overlay\n// ---------------------------------------------------------------------------------------------\n\nfunction ComposerDropOverlay({ visible }: { visible: boolean }) {\n  return (\n    <div\n      aria-hidden\n      data-slot=\"composer-drop-overlay\"\n      data-state={visible ? \"visible\" : \"hidden\"}\n      className={cn(\n        \"pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-[inherit] bg-kura-overlay backdrop-blur-sm\",\n        \"transition-opacity duration-(--kura-duration-fast) ease-kura-out\",\n        visible ? \"opacity-100\" : \"opacity-0\",\n      )}\n    >\n      <svg\n        aria-hidden\n        className=\"absolute inset-0 size-full overflow-visible text-kura-border-strong\"\n      >\n        <rect\n          x=\"4.5\"\n          y=\"4.5\"\n          rx=\"15.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeDasharray=\"5 4\"\n          style={{ width: \"calc(100% - 9px)\", height: \"calc(100% - 9px)\" }}\n        />\n      </svg>\n      <span\n        className={cn(\n          \"flex items-center gap-2 font-medium text-kura-fg text-sm transition-transform duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n          visible ? \"translate-y-0\" : \"translate-y-1\",\n        )}\n      >\n        <Paperclip className=\"size-4 text-kura-fg-muted\" aria-hidden />\n        Drop to attach\n      </span>\n    </div>\n  );\n}\n"
    },
    {
      "path": "registry/kura/composer/index.ts",
      "type": "registry:ui",
      "target": "components/kura/composer/index.ts",
      "content": "export {\n  Composer,\n  ComposerActions,\n  type ComposerActionsProps,\n  ComposerAttachButton,\n  type ComposerAttachButtonProps,\n  ComposerAttachments,\n  type ComposerAttachmentsProps,\n  ComposerHint,\n  type ComposerHintProps,\n  ComposerInput,\n  type ComposerInputProps,\n  type ComposerMessage,\n  type ComposerProps,\n  type ComposerStatus,\n  ComposerSubmit,\n  type ComposerSubmitProps,\n  ComposerToolbar,\n  type ComposerToolbarProps,\n  ComposerTools,\n  type ComposerToolsProps,\n  useComposer,\n} from \"./composer\";\n"
    }
  ],
  "categories": [
    "input",
    "prompt",
    "chat",
    "input",
    "textarea"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/composer · Markdown for agents: https://kuro.abstergo.fyi/components/composer.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "The message box of a chat UI or agent console (“Ask anything…”).",
        "Any prompt field that should send on Enter, accept pasted or dropped files, and stop a streaming response.",
        "A prompt input that hosts a model picker, tool toggles, voice input or a slash-command menu in its toolbar."
      ],
      "whenNotToUse": [
        "A single-line search or filter field — use a plain input or command-palette.",
        "Showing attachments outside an input (sent messages, file lists) — use attachment.",
        "Picking a model on its own — use model-picker.",
        "Voice-only input — use voice-dictation (it can also feed a composer)."
      ]
    }
  }
}
