{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-input",
  "title": "Chat input",
  "description": "Chat input with mentions and streaming status.",
  "dependencies": [
    "@tiptap/core",
    "@tiptap/react",
    "@tiptap/starter-kit",
    "@tiptap/extension-mention",
    "@tiptap/extension-placeholder",
    "@tiptap/suggestion",
    "ai"
  ],
  "registryDependencies": [
    "input-group"
  ],
  "files": [
    {
      "path": "packages/registry/registry/ui/chat-input.tsx",
      "content": "\"use client\";\n\nimport { Extension, mergeAttributes } from \"@tiptap/core\";\nimport { Mention as MentionExtension } from \"@tiptap/extension-mention\";\nimport Placeholder from \"@tiptap/extension-placeholder\";\nimport type { Editor, JSONContent } from \"@tiptap/react\";\nimport { EditorContent, ReactRenderer, useEditor } from \"@tiptap/react\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport type {\n  SuggestionKeyDownProps,\n  SuggestionProps,\n} from \"@tiptap/suggestion\";\nimport type { ChatStatus } from \"ai\";\nimport {\n  ArrowUpIcon,\n  AtSignIcon,\n  Loader2Icon,\n  SquareIcon,\n  XIcon,\n} from \"lucide-react\";\nimport {\n  type ComponentProps,\n  createContext,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { InputGroup, InputGroupButton } from \"@/components/ui/input-group\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface BaseMentionItem {\n  id: string;\n  name: string;\n}\n\nexport interface MentionConfig<T extends BaseMentionItem> {\n  /** Fixed at mount — changing it later has no effect. */\n  trigger: string;\n  items: T[] | ((query: string) => T[] | Promise<T[]>);\n  render?: (item: T, selected: boolean) => ReactNode;\n  chipClassName?: string;\n}\n\nexport type MentionConfigs = Record<string, MentionConfig<BaseMentionItem>>;\n\ntype SelectedMentionItems = Record<string, Map<string, BaseMentionItem>>;\n\n// Mapped + intersection shape — cannot be an interface.\nexport type ChatInputParsed<Items extends Record<string, BaseMentionItem>> = {\n  text: string;\n} & { [K in keyof Items]?: Items[K][] };\n\nexport interface ChatInputHandle {\n  clear: () => void;\n  focus: () => void;\n  getText: () => string;\n  setText: (text: string) => void;\n  insertText: (text: string) => void;\n}\n\ninterface ChatInputHelpers {\n  clear: () => void;\n  focus: () => void;\n}\n\ninterface ChatInputContextValue {\n  editor: Editor | null;\n  setEditor: (editor: Editor | null) => void;\n  submit: () => void;\n  status?: ChatStatus;\n  onStop?: () => void;\n  disabled: boolean;\n  defaultValue?: string;\n  mentions: MentionConfigs | undefined;\n  mentionsRef: RefObject<MentionConfigs | undefined>;\n  selectedItemsRef: RefObject<SelectedMentionItems>;\n}\n\nfunction filterStaticItems<T extends BaseMentionItem>(\n  items: T[],\n  query: string\n): T[] {\n  const q = query.toLowerCase();\n  return items.filter((item) => item.name.toLowerCase().startsWith(q));\n}\n\nfunction resolveMentionItems<T extends BaseMentionItem>(\n  config: MentionConfig<T>,\n  query: string\n): T[] | Promise<T[]> {\n  if (typeof config.items === \"function\") {\n    return config.items(query);\n  }\n  return filterStaticItems(config.items, query);\n}\n\nfunction textToDoc(text: string): JSONContent {\n  return {\n    type: \"doc\",\n    content: text.split(\"\\n\").map((line) => ({\n      type: \"paragraph\",\n      ...(line ? { content: [{ type: \"text\", text: line }] } : {}),\n    })),\n  };\n}\n\ninterface MentionListHandle {\n  onKeyDown: (props: SuggestionKeyDownProps) => boolean;\n}\n\ninterface MentionListProps<T extends BaseMentionItem> {\n  items: T[];\n  loading?: boolean;\n  command: (item: { id: string; label: string }) => void;\n  renderItem?: (item: T, selected: boolean) => ReactNode;\n  onSelectItem?: (item: T) => void;\n  ref?: React.Ref<MentionListHandle>;\n}\n\nfunction MentionList<T extends BaseMentionItem>({\n  items,\n  loading,\n  command,\n  renderItem,\n  onSelectItem,\n  ref,\n}: MentionListProps<T>) {\n  const [selectedIndex, setSelectedIndex] = useState(0);\n  const [prevItems, setPrevItems] = useState(items);\n  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  if (prevItems !== items) {\n    setPrevItems(items);\n    setSelectedIndex(0);\n  }\n\n  const selectItem = (index: number) => {\n    const item = items[index];\n    if (!item) {\n      return;\n    }\n    onSelectItem?.(item);\n    command({ id: item.id, label: item.name });\n  };\n\n  const moveSelection = (delta: number) => {\n    setSelectedIndex((prev) => {\n      const length = Math.max(items.length, 1);\n      const next = (prev + delta + length) % length;\n      itemRefs.current[next]?.scrollIntoView({\n        behavior: \"smooth\",\n        block: \"nearest\",\n      });\n      return next;\n    });\n  };\n\n  useImperativeHandle(ref, () => ({\n    onKeyDown: ({ event }) => {\n      if (event.key === \"ArrowUp\") {\n        moveSelection(-1);\n        return true;\n      }\n      if (event.key === \"ArrowDown\") {\n        moveSelection(1);\n        return true;\n      }\n      if (event.key === \"Enter\") {\n        selectItem(selectedIndex);\n        return true;\n      }\n      return false;\n    },\n  }));\n\n  if (loading && items.length === 0) {\n    return (\n      <div className=\"min-w-48 rounded-md bg-popover px-2 py-1.5 text-muted-foreground text-sm shadow-md ring-1 ring-foreground/10\">\n        Loading…\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex max-h-48 min-w-48 max-w-64 flex-col overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10\">\n      {items.length ? (\n        items.map((item, index) => (\n          <button\n            className={cn(\n              \"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-hidden [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n              selectedIndex === index && \"bg-accent text-accent-foreground\"\n            )}\n            key={item.id}\n            onClick={() => selectItem(index)}\n            ref={(el) => {\n              itemRefs.current[index] = el;\n            }}\n            type=\"button\"\n          >\n            {renderItem ? (\n              renderItem(item, selectedIndex === index)\n            ) : (\n              <span className=\"truncate\">{item.name}</span>\n            )}\n          </button>\n        ))\n      ) : (\n        <div className=\"px-2 py-1.5 text-muted-foreground text-sm\">\n          No results found\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction createMentionSuggestion(\n  key: string,\n  mentionsRef: RefObject<MentionConfigs | undefined>,\n  selectedItemsRef: RefObject<SelectedMentionItems>\n) {\n  return {\n    items: ({ query }: { query: string }) => {\n      const config = mentionsRef.current?.[key];\n      if (!config) {\n        return [];\n      }\n      return resolveMentionItems(config, query);\n    },\n    render: () => {\n      let component: ReactRenderer<MentionListHandle> | null = null;\n      let unmount: (() => void) | undefined;\n\n      const rememberItem = (item: BaseMentionItem) => {\n        if (!selectedItemsRef.current[key]) {\n          selectedItemsRef.current[key] = new Map();\n        }\n        selectedItemsRef.current[key].set(item.id, item);\n      };\n\n      return {\n        onStart: (props: SuggestionProps<BaseMentionItem>) => {\n          const config = mentionsRef.current?.[key];\n          component = new ReactRenderer(MentionList, {\n            props: {\n              items: props.items,\n              loading: props.loading,\n              command: props.command,\n              renderItem: config?.render,\n              onSelectItem: rememberItem,\n            },\n            editor: props.editor,\n          });\n          // The mount wrapper is the positioned element (appended to body,\n          // position:absolute, no z-index) — without this it stacks below\n          // elevated surfaces like the z-50 chat dock.\n          component.element.style.zIndex = \"50\";\n          unmount = props.mount(component.element);\n        },\n        onUpdate: (props: SuggestionProps<BaseMentionItem>) => {\n          const config = mentionsRef.current?.[key];\n          component?.updateProps({\n            items: props.items,\n            loading: props.loading,\n            command: props.command,\n            renderItem: config?.render,\n            onSelectItem: rememberItem,\n          });\n        },\n        onKeyDown: (props: SuggestionKeyDownProps) => {\n          if (props.event.key === \"Escape\") {\n            unmount?.();\n            unmount = undefined;\n            return true;\n          }\n          return component?.ref?.onKeyDown(props) ?? false;\n        },\n        onExit: () => {\n          unmount?.();\n          unmount = undefined;\n          component?.destroy();\n          component = null;\n        },\n      };\n    },\n  };\n}\n\nfunction buildMentionExtensions(\n  mentionsRef: RefObject<MentionConfigs | undefined>,\n  selectedItemsRef: RefObject<SelectedMentionItems>,\n  initialMentions: MentionConfigs | undefined\n) {\n  return Object.entries(initialMentions ?? {}).map(([key, config]) => {\n    const trigger = config.trigger || \"@\";\n    const MentionPlugin = MentionExtension.extend({\n      name: `${key}-mention`,\n      renderHTML({ node, HTMLAttributes }) {\n        const chipClassName = mentionsRef.current?.[key]?.chipClassName;\n        return [\n          \"span\",\n          mergeAttributes(HTMLAttributes, {\n            class: cn(\n              \"rounded-sm bg-primary px-1 py-0.5 text-primary-foreground no-underline\",\n              chipClassName\n            ),\n          }),\n          `${trigger}${node.attrs.label ?? node.attrs.id}`,\n        ];\n      },\n    });\n\n    return MentionPlugin.configure({\n      suggestion: {\n        char: trigger,\n        ...createMentionSuggestion(key, mentionsRef, selectedItemsRef),\n      },\n    });\n  });\n}\n\nfunction appendMentionFromNode(\n  node: JSONContent,\n  mentions: MentionConfigs | undefined,\n  selectedItems: SelectedMentionItems,\n  buckets: Record<string, BaseMentionItem[]>\n): string {\n  const key = (node.type ?? \"\").slice(0, -\"-mention\".length);\n  const config = mentions?.[key];\n  const attrs = node.attrs ?? {};\n  const id = String(attrs.id ?? \"\");\n  const label = String(attrs.label ?? \"\");\n  const trigger = config?.trigger ?? \"\";\n\n  const cached = selectedItems[key]?.get(id);\n  const item: BaseMentionItem = cached ?? { id, name: label };\n  if (!buckets[key]) {\n    buckets[key] = [];\n  }\n  if (!buckets[key].some((existing) => existing.id === id)) {\n    buckets[key].push(item);\n  }\n  return `${trigger}${label}`;\n}\n\nexport function parseEditorContent(\n  json: JSONContent,\n  mentions: MentionConfigs | undefined,\n  selectedItems: SelectedMentionItems\n) {\n  let text = \"\";\n  const buckets: Record<string, BaseMentionItem[]> = {};\n\n  function recurse(node: JSONContent) {\n    if (node.type === \"text\" && node.text) {\n      text += node.text;\n      return;\n    }\n    if (node.type === \"hardBreak\") {\n      text += \"\\n\";\n      return;\n    }\n    if (node.type?.endsWith(\"-mention\")) {\n      text += appendMentionFromNode(node, mentions, selectedItems, buckets);\n      return;\n    }\n    if (node.content) {\n      for (const child of node.content) {\n        recurse(child);\n      }\n      if (node.type === \"paragraph\") {\n        text += \"\\n\\n\";\n      }\n    }\n  }\n\n  if (json.content) {\n    for (const node of json.content) {\n      recurse(node);\n    }\n  }\n\n  return { text: text.trim(), ...buckets };\n}\n\nconst ChatInputContext = createContext<ChatInputContextValue | null>(null);\n\nfunction useChatInputContext() {\n  const ctx = useContext(ChatInputContext);\n  if (!ctx) {\n    throw new Error(\"ChatInput components must be used within <ChatInput>\");\n  }\n  return ctx;\n}\n\ntype SharedChatInputProps = {\n  status?: ChatStatus;\n  onStop?: () => void;\n  disabled?: boolean;\n  defaultValue?: string;\n  className?: string;\n  children: ReactNode;\n  /** Imperative handle (clear/focus/getText/setText/insertText), not the DOM node. */\n  ref?: Ref<ChatInputHandle>;\n} & Omit<\n  ComponentProps<\"div\">,\n  \"children\" | \"onSubmit\" | \"defaultValue\" | \"ref\"\n>;\n\ntype ChatInputPropsWithMentions<Items extends Record<string, BaseMentionItem>> =\n  SharedChatInputProps & {\n    mentions: { [K in keyof Items]: MentionConfig<Items[K]> };\n    onSubmit: (\n      parsed: ChatInputParsed<Items>,\n      helpers: ChatInputHelpers\n    ) => void;\n  };\n\ntype ChatInputPropsWithoutMentions = SharedChatInputProps & {\n  mentions?: undefined;\n  onSubmit: (parsed: { text: string }, helpers: ChatInputHelpers) => void;\n};\n\nexport function ChatInput<Items extends Record<string, BaseMentionItem>>(\n  props: ChatInputPropsWithMentions<Items>\n): React.JSX.Element;\nexport function ChatInput(\n  props: ChatInputPropsWithoutMentions\n): React.JSX.Element;\nexport function ChatInput({\n  mentions,\n  onSubmit,\n  status,\n  onStop,\n  disabled = false,\n  defaultValue,\n  className,\n  children,\n  ref,\n  ...props\n}: SharedChatInputProps & {\n  mentions?: MentionConfigs;\n  // Runtime parse is untyped; overloads restore Items at the call site.\n  // biome-ignore lint/suspicious/noExplicitAny: overload boundary\n  onSubmit: (parsed: any, helpers: ChatInputHelpers) => void;\n}) {\n  const [editor, setEditor] = useState<Editor | null>(null);\n  const mentionsRef = useRef(mentions);\n  const onSubmitRef = useRef(onSubmit);\n  const selectedItemsRef = useRef<SelectedMentionItems>({});\n\n  mentionsRef.current = mentions;\n  onSubmitRef.current = onSubmit;\n\n  const parse = useCallback(() => {\n    if (!editor) {\n      return { text: \"\" };\n    }\n    return parseEditorContent(\n      editor.getJSON(),\n      mentionsRef.current,\n      selectedItemsRef.current\n    );\n  }, [editor]);\n\n  const clear = useCallback(() => {\n    editor?.commands.clearContent(true);\n    selectedItemsRef.current = {};\n  }, [editor]);\n\n  const focus = useCallback(() => {\n    editor?.commands.focus(\"end\");\n  }, [editor]);\n\n  const submit = useCallback(() => {\n    if (disabled) {\n      return;\n    }\n    const parsed = parse();\n    editor?.commands.blur();\n    onSubmitRef.current(parsed, { clear, focus });\n  }, [clear, disabled, editor, focus, parse]);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      clear,\n      focus,\n      getText: () => parse().text,\n      setText: (text) => {\n        editor?.commands.setContent(textToDoc(text));\n      },\n      insertText: (text) => {\n        editor?.chain().focus().insertContent(text).run();\n      },\n    }),\n    [clear, editor, focus, parse]\n  );\n\n  const contextValue = useMemo<ChatInputContextValue>(\n    () => ({\n      editor,\n      setEditor,\n      submit,\n      status,\n      onStop,\n      disabled,\n      defaultValue,\n      mentions,\n      mentionsRef,\n      selectedItemsRef,\n    }),\n    [defaultValue, disabled, editor, mentions, onStop, status, submit]\n  );\n\n  return (\n    <ChatInputContext.Provider value={contextValue}>\n      <InputGroup\n        className={cn(\"h-auto\", className)}\n        data-slot=\"chat-input\"\n        {...props}\n      >\n        {children}\n      </InputGroup>\n    </ChatInputContext.Provider>\n  );\n}\n\nconst SubmitEnter = Extension.create({\n  name: \"chatInputSubmitEnter\",\n  addOptions() {\n    return {\n      getOnEnter: (): (() => void) => () => undefined,\n    };\n  },\n  addKeyboardShortcuts() {\n    return {\n      Enter: () => {\n        this.options.getOnEnter()?.();\n        return true;\n      },\n    };\n  },\n});\n\nexport function ChatInputEditor({\n  placeholder = \"Type a message...\",\n  className,\n  autoFocus,\n}: {\n  placeholder?: string;\n  className?: string;\n  autoFocus?: boolean;\n}) {\n  const {\n    setEditor,\n    submit,\n    disabled,\n    defaultValue,\n    mentions,\n    mentionsRef,\n    selectedItemsRef,\n  } = useChatInputContext();\n\n  const initialMentionsRef = useRef(mentions);\n  const placeholderRef = useRef(placeholder);\n  placeholderRef.current = placeholder;\n\n  const onEnterRef = useRef(submit);\n  onEnterRef.current = submit;\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: editor built once; live config via refs\n  const extensions = useMemo(\n    () => [\n      StarterKit.configure({\n        heading: false,\n        blockquote: false,\n        codeBlock: false,\n        horizontalRule: false,\n        listItem: false,\n        bulletList: false,\n        orderedList: false,\n      }),\n      Placeholder.configure({\n        placeholder: () => placeholderRef.current,\n      }),\n      SubmitEnter.configure({\n        getOnEnter: () => onEnterRef.current,\n      }),\n      ...buildMentionExtensions(\n        mentionsRef,\n        selectedItemsRef,\n        initialMentionsRef.current\n      ),\n    ],\n    []\n  );\n\n  const editor = useEditor({\n    extensions,\n    content: defaultValue ?? \"\",\n    editable: !disabled,\n    autofocus: autoFocus ? \"end\" : false,\n    immediatelyRender: false,\n    editorProps: {\n      attributes: {\n        \"data-slot\": \"input-group-control\",\n        class: cn(\n          \"tiptap max-w-none flex-1 rounded-none border-0 bg-transparent py-2 shadow-none outline-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent\"\n        ),\n      },\n    },\n  });\n\n  useLayoutEffect(() => {\n    setEditor(editor);\n    return () => setEditor(null);\n  }, [editor, setEditor]);\n\n  useEffect(() => {\n    if (editor) {\n      editor.setEditable(!disabled);\n    }\n  }, [disabled, editor]);\n\n  return (\n    <EditorContent\n      className={cn(\n        \"max-h-48 min-h-16 w-full flex-1 overflow-y-auto px-3 py-0\",\n        \"[&_.tiptap]:outline-none\",\n        \"[&_.tiptap_p.is-editor-empty:first-child]:before:pointer-events-none\",\n        \"[&_.tiptap_p.is-editor-empty:first-child]:before:float-left\",\n        \"[&_.tiptap_p.is-editor-empty:first-child]:before:h-0\",\n        \"[&_.tiptap_p.is-editor-empty:first-child]:before:text-muted-foreground\",\n        \"[&_.tiptap_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]\",\n        className\n      )}\n      editor={editor}\n    />\n  );\n}\n\nexport function ChatInputSubmitButton({\n  className,\n  disabled,\n  children,\n  ...props\n}: ComponentProps<typeof InputGroupButton>) {\n  const {\n    submit,\n    status,\n    onStop,\n    disabled: contextDisabled,\n  } = useChatInputContext();\n\n  const isInFlight = status === \"submitted\" || status === \"streaming\";\n  const actAsStop = isInFlight && onStop !== undefined;\n\n  let icon = <ArrowUpIcon className=\"size-4\" />;\n  if (actAsStop) {\n    icon = <SquareIcon className=\"size-4\" />;\n  } else if (isInFlight) {\n    icon = <Loader2Icon className=\"size-4 animate-spin\" />;\n  } else if (status === \"error\") {\n    icon = <XIcon className=\"size-4\" />;\n  }\n\n  return (\n    <InputGroupButton\n      aria-label={actAsStop ? \"Stop\" : \"Send\"}\n      className={className}\n      disabled={(disabled ?? contextDisabled) || (isInFlight && !actAsStop)}\n      onClick={(event) => {\n        event.preventDefault();\n        if (actAsStop) {\n          onStop();\n        } else {\n          submit();\n        }\n      }}\n      size=\"icon-sm\"\n      type=\"button\"\n      variant=\"default\"\n      {...props}\n    >\n      {children ?? icon}\n      <span className=\"sr-only\">{actAsStop ? \"Stop\" : \"Send\"}</span>\n    </InputGroupButton>\n  );\n}\n\nexport function ChatInputMentionButton({\n  trigger,\n  className,\n  children,\n  ...props\n}: ComponentProps<typeof InputGroupButton> & { trigger?: string }) {\n  const { editor, mentions } = useChatInputContext();\n\n  const configs = mentions ? Object.values(mentions) : [];\n  const resolvedTrigger = trigger ?? configs[0]?.trigger;\n  if (!resolvedTrigger) {\n    return null;\n  }\n\n  return (\n    <InputGroupButton\n      aria-label={`Insert ${resolvedTrigger}`}\n      className={cn(\"shrink-0\", className)}\n      onClick={() => {\n        editor?.chain().focus().insertContent(resolvedTrigger).run();\n      }}\n      size=\"icon-sm\"\n      type=\"button\"\n      variant=\"outline\"\n      {...props}\n    >\n      {children ?? <AtSignIcon className=\"size-4\" />}\n      <span className=\"sr-only\">Insert {resolvedTrigger}</span>\n    </InputGroupButton>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}