{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elements-composer",
  "type": "registry:component",
  "title": "Composer",
  "description": "The unified input: attachments, commands, mentions, models, voice, and context in one surface.",
  "registryDependencies": [
    "https://r.assistant-ui.com/elements-surfaces.json",
    "https://r.assistant-ui.com/elements-range.json"
  ],
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "path": "components/assistant-ui/elements/composer.tsx",
      "sourcePath": "packages/ui/src/components/react/assistant-ui/elements/composer.tsx",
      "content": "\"use client\";\n\nimport { type ComponentProps, useMemo } from \"react\";\nimport {\n  ArrowUpIcon,\n  CheckIcon,\n  ChevronDownIcon,\n  FileArchiveIcon,\n  FileImageIcon,\n  FileTextIcon,\n  Loader2Icon,\n  MicIcon,\n  PlusIcon,\n  SquareIcon,\n  XIcon,\n  type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  field,\n  floating,\n  ghostButton,\n  iconSwap,\n  iconSwapIn,\n  iconSwapOut,\n  inkButton,\n  mono,\n  paper,\n  ShimmerLabel,\n} from \"./surfaces\";\nimport { clamp, pct } from \"../utils/range\";\n\nexport interface ComposerAttachment {\n  name: string;\n  meta: string;\n  state: \"uploading\" | \"done\" | \"error\";\n  progress?: number;\n  kind?: \"image\" | \"text\" | \"archive\";\n}\n\nexport interface ComposerCommand {\n  name: string;\n  description: string;\n  icon: LucideIcon;\n}\n\nexport interface ComposerPerson {\n  name: string;\n  role: \"agent\" | \"human\";\n}\n\nexport interface ComposerModel {\n  name: string;\n  meta: string;\n}\n\nexport interface ComposerUsage {\n  system: number;\n  tools: number;\n  messages: number;\n  total: number;\n}\n\nconst ATTACHMENT_ICONS: Record<\n  NonNullable<ComposerAttachment[\"kind\"]>,\n  LucideIcon\n> = {\n  image: FileImageIcon,\n  text: FileTextIcon,\n  archive: FileArchiveIcon,\n};\n\nconst BARS = Array.from({ length: 14 }, (_, i) => i);\n\nfunction barHeight(bar: number, tick: number): number {\n  return 5 + Math.abs(Math.sin(bar * 1.35 + tick * 0.55)) * 13;\n}\n\n/** Commands whose name starts with the slash query, or none when not typing one. */\nexport function useSlashMatches(\n  value: string,\n  commands: readonly ComposerCommand[] | undefined,\n): ComposerCommand[] {\n  return useMemo(() => {\n    if (!commands || !value.startsWith(\"/\")) return [];\n    const query = value.slice(1).toLowerCase();\n    return commands.filter((command) => command.name.startsWith(query));\n  }, [commands, value]);\n}\n\n/** People matching a trailing @mention, or none when the caret is not in one. */\nexport function useMentionMatches(\n  value: string,\n  people: readonly ComposerPerson[] | undefined,\n): ComposerPerson[] {\n  return useMemo(() => {\n    if (!people) return [];\n    const match = /@([\\w]*)$/.exec(value);\n    if (!match) return [];\n    const query = match[1]?.toLowerCase() ?? \"\";\n    return people.filter((person) =>\n      person.name.toLowerCase().startsWith(query),\n    );\n  }, [people, value]);\n}\n\n/** Replaces the trailing @mention with the chosen name. */\nexport function applyMention(value: string, name: string): string {\n  return value.replace(/@[\\w]*$/, `@${name} `);\n}\n\nexport function Composer({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"composer\"\n      className={cn(\"relative w-full max-w-lg\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function ComposerBar({\n  dragActive = false,\n  className,\n  ...props\n}: ComponentProps<\"div\"> & { dragActive?: boolean }) {\n  return (\n    <div\n      data-slot=\"composer-bar\"\n      data-drag-active={dragActive || undefined}\n      className={cn(\n        paper,\n        \"flex w-full flex-col gap-2 rounded-[24px] p-2.5 transition-colors\",\n        dragActive && \"bg-blue-500/[0.04] dark:bg-blue-500/10\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function ComposerMenu({\n  open,\n  align = \"start\",\n  className,\n  ...props\n}: ComponentProps<\"div\"> & { open: boolean; align?: \"start\" | \"end\" }) {\n  return (\n    <div\n      data-slot=\"composer-menu\"\n      data-open={open || undefined}\n      className={cn(\n        floating,\n        \"absolute bottom-full z-10 mb-2 flex w-72 flex-col gap-0.5 rounded-2xl p-1.5\",\n        align === \"start\"\n          ? \"start-0 origin-bottom-left\"\n          : \"end-0 origin-bottom-right\",\n        \"transition-[opacity,scale] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n        open\n          ? \"scale-100 opacity-100\"\n          : \"pointer-events-none scale-[0.97] opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function ComposerMenuItem({\n  active = false,\n  className,\n  ...props\n}: ComponentProps<\"button\"> & { active?: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"composer-menu-item\"\n      data-active={active || undefined}\n      className={cn(\n        \"flex w-full items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-[13.5px] transition-colors\",\n        active ? field : \"hover:bg-foreground/[0.04]\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function ComposerCommandItem({\n  command,\n  active,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"children\"> & {\n  command: ComposerCommand;\n  active: boolean;\n}) {\n  return (\n    <ComposerMenuItem active={active} {...props}>\n      <command.icon className=\"text-foreground/35 size-3.5 shrink-0\" />\n      <span className=\"font-medium\">/{command.name}</span>\n      <span className=\"text-foreground/45 flex-1 truncate text-start text-xs\">\n        {command.description}\n      </span>\n      {active && (\n        <kbd className=\"bg-foreground/[0.06] text-foreground/45 rounded px-1 font-mono text-[10px]\">\n          ↵\n        </kbd>\n      )}\n    </ComposerMenuItem>\n  );\n}\n\nexport function ComposerPersonItem({\n  person,\n  active,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"children\"> & {\n  person: ComposerPerson;\n  active: boolean;\n}) {\n  return (\n    <ComposerMenuItem active={active} {...props}>\n      <span className=\"bg-foreground/[0.06] text-foreground/45 flex size-5 shrink-0 items-center justify-center rounded-full text-[9px] font-medium\">\n        {person.name[0]}\n      </span>\n      <span className=\"flex-1 truncate text-start\">{person.name}</span>\n      <span className={cn(mono, \"text-foreground/35\")}>{person.role}</span>\n    </ComposerMenuItem>\n  );\n}\n\nexport function ComposerAttachments({\n  className,\n  ...props\n}: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"composer-attachments\"\n      className={cn(\"flex flex-wrap gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function ComposerAttachmentChip({\n  attachment,\n  onRemove,\n  className,\n  ...props\n}: Omit<ComponentProps<\"div\">, \"children\"> & {\n  attachment: ComposerAttachment;\n  onRemove?: (name: string) => void;\n}) {\n  const Icon = ATTACHMENT_ICONS[attachment.kind ?? \"text\"];\n  return (\n    <div\n      data-slot=\"composer-attachment\"\n      data-state={attachment.state}\n      className={cn(\n        field,\n        \"relative flex items-center gap-2.5 overflow-hidden rounded-[14px] py-1.5 ps-1.5 pe-2.5\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"bg-background text-foreground/45 flex size-8 shrink-0 items-center justify-center rounded-[10px] dark:bg-white/10\">\n        <Icon className=\"size-4\" />\n      </span>\n      <span className=\"flex flex-col\">\n        <span className=\"max-w-36 truncate text-xs font-medium\">\n          {attachment.name}\n        </span>\n        <span\n          className={cn(\n            \"text-[11px]\",\n            attachment.state === \"error\"\n              ? \"text-red-600/80 dark:text-red-400/80\"\n              : \"text-foreground/40\",\n          )}\n        >\n          {attachment.meta}\n        </span>\n      </span>\n      <span className=\"ms-1 flex w-5 items-center justify-end\">\n        {attachment.state === \"uploading\" ? (\n          <Loader2Icon className=\"text-foreground/35 size-3.5 animate-spin motion-reduce:animate-none\" />\n        ) : attachment.state === \"done\" && onRemove ? (\n          <button\n            type=\"button\"\n            aria-label={`Remove ${attachment.name}`}\n            onClick={() => onRemove(attachment.name)}\n            className={cn(ghostButton, \"size-5 [&_svg]:size-3\")}\n          >\n            <XIcon />\n          </button>\n        ) : attachment.state === \"done\" ? (\n          <CheckIcon className=\"size-3.5 text-emerald-500\" />\n        ) : null}\n      </span>\n      {attachment.state === \"uploading\" && (\n        <span\n          aria-hidden\n          className=\"absolute inset-x-0 bottom-0 h-0.5 bg-blue-500/70 transition-[width] duration-300 dark:bg-blue-400/70\"\n          style={{ width: `${pct(attachment.progress ?? 0, 100)}%` }}\n        />\n      )}\n    </div>\n  );\n}\n\nexport function ComposerInput({\n  onSubmit,\n  onKeyDown,\n  className,\n  ...props\n}: Omit<ComponentProps<\"input\">, \"onSubmit\"> & { onSubmit?: () => void }) {\n  return (\n    <input\n      data-slot=\"composer-input\"\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (event.defaultPrevented) return;\n        if (event.key !== \"Enter\" || event.nativeEvent.isComposing) return;\n        onSubmit?.();\n      }}\n      className={cn(\n        \"placeholder:text-foreground/35 min-h-11 w-full bg-transparent px-3 text-[15px] caret-blue-500 outline-none dark:caret-blue-400\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function ComposerVoice({\n  recording,\n  seconds,\n  className,\n  ...props\n}: Omit<ComponentProps<\"div\">, \"children\"> & {\n  recording: boolean;\n  seconds: number;\n}) {\n  return (\n    <div\n      data-slot=\"composer-voice\"\n      data-recording={recording || undefined}\n      className={cn(\"flex min-h-11 items-center gap-3 ps-3\", className)}\n      {...props}\n    >\n      {recording && (\n        <span\n          aria-hidden\n          className=\"size-1.5 animate-pulse rounded-full bg-blue-500 dark:bg-blue-400\"\n        />\n      )}\n      <div className=\"flex h-6 items-center gap-[3px]\" aria-hidden>\n        {BARS.map((bar) => (\n          <span\n            key={bar}\n            className={cn(\n              \"w-0.5 rounded-full transition-[height,background-color] duration-150 motion-reduce:transition-none\",\n              recording ? \"bg-foreground/50\" : \"bg-foreground/25\",\n            )}\n            style={{ height: recording ? barHeight(bar, seconds * 10) : 3 }}\n          />\n        ))}\n      </div>\n      {recording ? (\n        <span className={cn(mono, \"text-foreground/40 tabular-nums\")}>\n          0:{String(seconds).padStart(2, \"0\")}\n        </span>\n      ) : (\n        <ShimmerLabel className=\"text-foreground/55 relative text-[13px]\">\n          Transcribing\n        </ShimmerLabel>\n      )}\n    </div>\n  );\n}\n\nexport function ComposerToolbar({\n  className,\n  ...props\n}: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"composer-toolbar\"\n      className={cn(\"flex items-center justify-between\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function ComposerActions({\n  className,\n  ...props\n}: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"composer-actions\"\n      className={cn(\"flex items-center gap-1.5\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function ComposerAttachButton({\n  className,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"children\">) {\n  return (\n    <button\n      type=\"button\"\n      aria-label=\"Add attachment\"\n      data-slot=\"composer-attach\"\n      disabled={!props.onClick}\n      className={cn(\n        ghostButton,\n        \"size-8 disabled:pointer-events-none disabled:opacity-30\",\n        className,\n      )}\n      {...props}\n    >\n      <PlusIcon className=\"size-4\" />\n    </button>\n  );\n}\n\nexport function ComposerModelTrigger({\n  model,\n  open,\n  className,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"children\"> & {\n  model: string;\n  open: boolean;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-expanded={open}\n      data-slot=\"composer-model-trigger\"\n      className={cn(\n        \"text-foreground/55 hover:bg-foreground/[0.06] hover:text-foreground/90 dark:hover:bg-foreground/[0.09] flex h-8 items-center gap-1.5 rounded-full px-3 text-[12.5px] transition-colors\",\n        className,\n      )}\n      {...props}\n    >\n      {model}\n      <ChevronDownIcon className=\"size-3 opacity-60\" />\n    </button>\n  );\n}\n\nexport function ComposerModelItem({\n  entry,\n  selected,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"children\"> & {\n  entry: ComposerModel;\n  selected: boolean;\n}) {\n  return (\n    <ComposerMenuItem active={selected} {...props}>\n      <span className=\"flex-1 text-start\">{entry.name}</span>\n      <span className={cn(mono, \"text-foreground/35 tabular-nums\")}>\n        {entry.meta}\n      </span>\n      <span className=\"flex w-4 justify-end\">\n        {selected && (\n          <CheckIcon className=\"fade-in zoom-in-90 animate-in size-3.5 duration-200\" />\n        )}\n      </span>\n    </ComposerMenuItem>\n  );\n}\n\nexport function ComposerContext({\n  usage,\n  className,\n  ...props\n}: Omit<ComponentProps<\"div\">, \"children\"> & { usage: ComposerUsage }) {\n  const used = usage.system + usage.tools + usage.messages;\n  const fraction = usage.total === 0 ? 0 : used / usage.total;\n  const warn = fraction > 0.85;\n  const circumference = 2 * Math.PI * 6;\n  const segments = [\n    { label: \"System\", value: usage.system, className: \"bg-foreground/25\" },\n    { label: \"Tools\", value: usage.tools, className: \"bg-foreground/45\" },\n    { label: \"Messages\", value: usage.messages, className: \"bg-foreground/80\" },\n  ];\n\n  return (\n    <div\n      data-slot=\"composer-context\"\n      className={cn(\"group/ctx relative\", className)}\n      {...props}\n    >\n      <div\n        className={cn(\n          floating,\n          \"absolute end-0 bottom-full z-10 mb-2 flex w-60 origin-bottom-right flex-col gap-3.5 rounded-2xl p-4\",\n          \"transition-[opacity,scale] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n          \"pointer-events-none scale-[0.97] opacity-0\",\n          \"group-hover/ctx:pointer-events-auto group-hover/ctx:scale-100 group-hover/ctx:opacity-100\",\n          \"group-focus-within/ctx:pointer-events-auto group-focus-within/ctx:scale-100 group-focus-within/ctx:opacity-100\",\n        )}\n      >\n        <div className=\"flex items-baseline justify-between\">\n          <p className=\"text-[13.5px] font-medium\">Context</p>\n          <p\n            className={cn(\n              mono,\n              \"tabular-nums\",\n              warn ? \"text-red-500 dark:text-red-400\" : \"text-foreground/35\",\n            )}\n          >\n            {Math.round(fraction * 100)}%\n          </p>\n        </div>\n        <div className=\"bg-foreground/[0.06] flex h-[5px] w-full gap-px overflow-hidden rounded-full\">\n          {segments.map((segment) => (\n            <span\n              key={segment.label}\n              className={cn(\n                \"h-full transition-[width] duration-700 motion-reduce:transition-none\",\n                segment.className,\n              )}\n              style={{ width: `${pct(segment.value, usage.total)}%` }}\n            />\n          ))}\n        </div>\n        <div className=\"flex flex-col gap-2\">\n          {segments.map((segment) => (\n            <div\n              key={segment.label}\n              className=\"text-foreground/55 flex items-center gap-2.5 text-[13px]\"\n            >\n              <span\n                aria-hidden\n                className={cn(\"size-1.5 rounded-full\", segment.className)}\n              />\n              <span className=\"flex-1\">{segment.label}</span>\n              <span className={cn(mono, \"text-foreground/40 tabular-nums\")}>\n                {segment.value}k\n              </span>\n            </div>\n          ))}\n        </div>\n        <div className=\"bg-foreground/[0.06] h-px\" />\n        <div className=\"text-foreground/55 flex items-center justify-between text-[13px]\">\n          <span>Total</span>\n          <span className={cn(mono, \"text-foreground/40 tabular-nums\")}>\n            {used}k / {usage.total}k\n          </span>\n        </div>\n      </div>\n      <button\n        type=\"button\"\n        aria-label=\"Context usage\"\n        className={cn(\n          ghostButton,\n          \"size-8\",\n          warn && \"text-red-500 dark:text-red-400\",\n        )}\n      >\n        <svg viewBox=\"0 0 16 16\" className=\"size-4 -rotate-90\" aria-hidden>\n          <circle\n            cx=\"8\"\n            cy=\"8\"\n            r=\"6\"\n            fill=\"none\"\n            strokeWidth=\"2.5\"\n            className=\"stroke-foreground/10\"\n          />\n          <circle\n            cx=\"8\"\n            cy=\"8\"\n            r=\"6\"\n            fill=\"none\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            className=\"stroke-current transition-[stroke-dashoffset] duration-700 motion-reduce:transition-none\"\n            strokeDasharray={circumference}\n            strokeDashoffset={circumference * (1 - clamp(fraction, 0, 1))}\n          />\n        </svg>\n      </button>\n    </div>\n  );\n}\n\nexport function ComposerVoiceButton({\n  active,\n  className,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"children\"> & { active: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={active ? \"Stop recording\" : \"Start voice input\"}\n      data-slot=\"composer-voice-button\"\n      className={cn(\n        active\n          ? cn(\n              inkButton,\n              \"flex size-8 items-center justify-center rounded-full\",\n            )\n          : cn(ghostButton, \"size-8\"),\n        className,\n      )}\n      {...props}\n    >\n      {active ? (\n        <SquareIcon className=\"size-3 fill-current\" />\n      ) : (\n        <MicIcon className=\"size-4\" />\n      )}\n    </button>\n  );\n}\n\nexport function ComposerSend({\n  streaming,\n  idle,\n  className,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"children\"> & {\n  streaming: boolean;\n  idle: boolean;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={streaming ? \"Stop generating\" : \"Send message\"}\n      data-slot=\"composer-send\"\n      className={cn(\n        \"grid size-8 place-items-center rounded-full\",\n        streaming || !idle\n          ? inkButton\n          : \"bg-foreground/[0.06] text-foreground/30 dark:bg-foreground/[0.09] transition-colors\",\n        className,\n      )}\n      {...props}\n    >\n      <ArrowUpIcon\n        className={cn(iconSwap, \"size-4\", streaming ? iconSwapOut : iconSwapIn)}\n      />\n      <SquareIcon\n        className={cn(\n          iconSwap,\n          \"size-3 fill-current\",\n          streaming ? iconSwapIn : iconSwapOut,\n        )}\n      />\n    </button>\n  );\n}\n"
    }
  ]
}