{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-composer",
  "type": "registry:component",
  "title": "ChatComposer",
  "description": "Prompt composer with controlled text, submit state, and trigger-menu support.",
  "docs": "Add these imports once to app/globals.css: @import \"../components/control-ui/styles/chat-composer-editor.css\";",
  "dependencies": [
    "prosemirror-commands@^1.7.1",
    "prosemirror-history@^1.5.0",
    "prosemirror-keymap@^1.2.3",
    "prosemirror-model@^1.25.11",
    "prosemirror-state@^1.4.4",
    "prosemirror-view@^1.42.2"
  ],
  "registryDependencies": [
    "http://127.0.0.1:3000/r/button.json",
    "http://127.0.0.1:3000/r/core.json",
    "http://127.0.0.1:3000/r/trigger-menu.json"
  ],
  "files": [
    {
      "path": "src/registry/hooks/use-chat-composer.ts",
      "target": "@components/control-ui/hooks/use-chat-composer.ts",
      "type": "registry:hook",
      "content": "import type { SubmitEvent } from \"react\";\nimport { useState } from \"react\";\n\nimport type { ChatComposerProps, ChatComposerSubmitPayload } from \"../contracts\";\n\nfunction useControllableText({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n}: Pick<ChatComposerProps, \"value\" | \"defaultValue\" | \"onValueChange\">) {\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const isControlled = value !== undefined;\n  const currentValue = isControlled ? value : internalValue;\n\n  function setValue(nextValue: string) {\n    if (!isControlled) setInternalValue(nextValue);\n    onValueChange?.(nextValue);\n  }\n\n  return [currentValue, setValue] as const;\n}\n\nexport function useChatComposer({\n  value,\n  defaultValue,\n  onValueChange,\n  onSubmit,\n  state = \"idle\",\n  density = \"comfortable\",\n  disabled = false,\n  trackSends = false,\n}: Pick<ChatComposerProps, \"value\" | \"defaultValue\" | \"onValueChange\" | \"onSubmit\" | \"state\" | \"density\" | \"disabled\"> & {\n  /** Count successful submits — only enabled when something reads counter (send-layer anchor), so idle apps pay no extra state update. */\n  trackSends?: boolean;\n}) {\n  const [inputValue, setInputValue] = useControllableText({ value, defaultValue, onValueChange });\n  const [sendCount, setSendCount] = useState(0);\n  const normalizedValue = inputValue.trim();\n  const isDisabled = disabled || state === \"disabled\" || state === \"submitting\";\n  const canSubmit = normalizedValue.length > 0 && !isDisabled;\n  const isCompact = density === \"compact\";\n\n  function clear() {\n    setInputValue(\"\");\n  }\n\n  // shared path: plain textarea via handleSubmit, rich editor calls submit() directly with extras (mentions)\n  function submit(extra?: Partial<ChatComposerSubmitPayload>) {\n    if (!canSubmit) return;\n    if (trackSends) setSendCount((count) => count + 1);\n    // onSubmit may return Promise; surface rejected send without unhandled rejection\n    Promise.resolve(onSubmit?.({ value: normalizedValue, clear, ...extra })).catch(reportError);\n  }\n\n  function handleSubmit(event: SubmitEvent<HTMLFormElement>) {\n    event.preventDefault();\n    submit();\n  }\n\n  return {\n    value: inputValue,\n    setValue: setInputValue,\n    normalizedValue,\n    state,\n    density,\n    isCompact,\n    isDisabled,\n    canSubmit,\n    rows: isCompact ? 2 : 4,\n    sendCount,\n    clear,\n    submit,\n    handleSubmit,\n  };\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/chat-composer-editor.css",
      "target": "@components/control-ui/styles/chat-composer-editor.css",
      "type": "registry:file",
      "content": "/*\n * Blur choreography, token-driven (kill-switch collapses to 0).\n * Entry: pill blurs+scales IN via @starting-style (freshly appended by ProseMirror). Exit: pill-delete/message-submit blur OUT via [data-chat-composer-exit] on a throwaway clone (ghost.ts) since ProseMirror removes nodes synchronously.\n * Reads the shared --duration-* and --ease-* tokens.\n */\n\n@layer components {\n  /* resting look + blur-IN transition; :not([data-chat-composer-exit] *) excludes exit ghosts/cloned pills from entry choreography */\n  :where([data-control-ui=\"chat-composer\"][data-slot=\"mention\"]):not([data-chat-composer-exit]):not([data-chat-composer-exit] *) {\n    opacity: 1;\n    filter: blur(0);\n    scale: 1;\n    transition:\n      opacity var(--duration-base) var(--ease-emphasized),\n      filter var(--duration-base) var(--ease-emphasized),\n      scale var(--duration-base) var(--ease-emphasized);\n  }\n\n  @starting-style {\n    :where([data-control-ui=\"chat-composer\"][data-slot=\"mention\"]):not([data-chat-composer-exit]):not([data-chat-composer-exit] *) {\n      opacity: 0;\n      filter: blur(6px);\n      scale: 0.82;\n    }\n  }\n\n  /* exit ghost: fixed-position clone, keyframe blurs it out then self-removes on animationend; finite duration lets kill-switch snap it away invisibly */\n  :where([data-chat-composer-exit][data-exiting]) {\n    opacity: 1;\n    filter: blur(0);\n    scale: 1;\n    translate: 0 0;\n    animation: chat-composer-blur-out var(--duration-base) var(--ease-emphasized) forwards;\n  }\n}\n\n@keyframes chat-composer-blur-out {\n  to {\n    opacity: 0;\n    filter: blur(8px);\n    scale: 0.94;\n    translate: 0 -0.25rem;\n  }\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/chat-composer-editor.tsx",
      "target": "@components/control-ui/chat-composer-editor.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { baseKeymap, splitBlock } from \"prosemirror-commands\";\nimport { history, redo, undo } from \"prosemirror-history\";\nimport { keymap } from \"prosemirror-keymap\";\nimport { EditorState, Plugin } from \"prosemirror-state\";\nimport { EditorView } from \"prosemirror-view\";\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { useChatComposerContext } from \"@/components/control-ui/chat-composer\";\nimport type { ChatComposerSubmitPayload } from \"@/components/control-ui/contracts\";\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { MESSAGE_GHOST_INHERIT, spawnExitGhost } from \"./chat-composer-editor/ghost\";\nimport { createEditorSchema } from \"./chat-composer-editor/schema\";\nimport { docFromText, serializeDoc } from \"./chat-composer-editor/serialize\";\nimport type { ChatComposerEditorApi, ChatComposerEditorProps } from \"./chat-composer-editor/types\";\n\nconst SUBMIT_KEY = \"Enter\";\n\n// doc is source of truth: it re-hydrates only on external value changes, never on its own keystrokes, which would fight caret.\nexport function ChatComposerEditor({ className, placeholder, extensions = [] }: ChatComposerEditorProps) {\n  const input = useChatComposerContext();\n\n  const mountRef = useRef<HTMLDivElement>(null);\n  const viewRef = useRef<EditorView | null>(null);\n  const inputRef = useRef(input);\n  useEffect(() => {\n    inputRef.current = input;\n  }, [input]);\n  // lets reset effect tell external value change from its own keystroke round-trip\n  const lastSerialized = useRef(input.value);\n  const [mounted, setMounted] = useState(false);\n\n  // stable for editor's lifetime, so extensions never close over stale state\n  const listenersRef = useRef(new Set<() => void>());\n  const keyHandlersRef = useRef(new Set<(event: KeyboardEvent) => boolean>());\n  const [api] = useState<ChatComposerEditorApi>(() => ({\n    getView: () => viewRef.current,\n    subscribe: (listener) => {\n      listenersRef.current.add(listener);\n      return () => {\n        listenersRef.current.delete(listener);\n      };\n    },\n    registerKeyHandler: (handler) => {\n      keyHandlersRef.current.add(handler);\n      return () => {\n        keyHandlersRef.current.delete(handler);\n      };\n    },\n  }));\n  // mount-time config only — rebuilding schema mid-life would tear down live editor\n  const [initialExtensions] = useState(extensions);\n\n  // value, extensions, and api are read through refs so this never re-runs\n  useEffect(() => {\n    const mount = mountRef.current;\n    if (!mount) return;\n    const exts = initialExtensions;\n    const schema = createEditorSchema(exts);\n\n    const extensionKeymap: Record<string, (typeof baseKeymap)[string]> = {};\n    for (const extension of exts) Object.assign(extensionKeymap, extension.keymap?.(schema) ?? {});\n    const extensionPlugins = exts.flatMap((extension) => extension.plugins?.(schema, api) ?? []);\n    const submitMessage = (editorState: EditorState) => {\n      const extra: Partial<ChatComposerSubmitPayload> = {};\n      for (const extension of exts) Object.assign(extra, extension.submitPayload?.(editorState.doc) ?? {});\n      inputRef.current.submit(extra);\n      return true;\n    };\n\n    const state = EditorState.create({\n      schema,\n      doc: docFromText(schema, inputRef.current.value),\n      plugins: [\n        // first, so open overlay consumes arrows, Enter, and Esc before keymaps below\n        new Plugin({\n          props: {\n            handleKeyDown: (_view, event) => {\n              for (const handler of keyHandlersRef.current) if (handler(event)) return true;\n              return false;\n            },\n          },\n        }),\n        ...extensionPlugins,\n        history(),\n        // before baseKeymap so extension bindings win\n        keymap(extensionKeymap),\n        keymap({\n          \"Mod-z\": undo,\n          \"Mod-y\": redo,\n          \"Shift-Mod-z\": redo,\n          \"Shift-Enter\": splitBlock,\n          [SUBMIT_KEY]: submitMessage,\n        }),\n        keymap(baseKeymap),\n      ],\n    });\n    const view = new EditorView(mount, {\n      state,\n      attributes: { \"aria-label\": \"Message\", \"aria-multiline\": \"true\", role: \"textbox\" },\n      dispatchTransaction(transaction) {\n        const next = view.state.apply(transaction);\n        view.updateState(next);\n        if (transaction.docChanged) {\n          const text = serializeDoc(next.doc);\n          lastSerialized.current = text;\n          inputRef.current.setValue(text);\n        }\n        // every transaction, selection moves included, so overlays can mirror caret\n        for (const listener of listenersRef.current) listener();\n      },\n    });\n    viewRef.current = view;\n    setMounted(true);\n    return () => {\n      view.destroy();\n      viewRef.current = null;\n    };\n  }, [api, initialExtensions]);\n\n  useEffect(() => {\n    const view = viewRef.current;\n    if (!view) return;\n    if (input.value === lastSerialized.current) return;\n    // only text→empty is ghosted, so message blurs out instead of snapping blank; prefill is left alone\n    if (input.value === \"\" && lastSerialized.current !== \"\" && view.dom instanceof HTMLElement) {\n      spawnExitGhost(view.dom, MESSAGE_GHOST_INHERIT);\n    }\n    lastSerialized.current = input.value;\n    const { schema } = view.state;\n    view.updateState(EditorState.create({ schema, doc: docFromText(schema, input.value), plugins: view.state.plugins }));\n  }, [input.value]);\n\n  return (\n    <div data-control-ui=\"chat-composer-editor\" data-slot=\"root\" className={cn(\"relative\", className)}>\n      {input.value === \"\" && placeholder ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-[var(--padding-x)] top-[var(--padding-y)] text-sm leading-6 text-muted-foreground\"\n        >\n          {placeholder}\n        </div>\n      ) : null}\n      <div\n        ref={mountRef}\n        className={cn(\n          \"[&_.ProseMirror]:max-h-[40dvh] [&_.ProseMirror]:min-h-16 [&_.ProseMirror]:w-full [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words [&_.ProseMirror]:px-[var(--padding-x)] [&_.ProseMirror]:py-[var(--padding-y)] [&_.ProseMirror]:text-sm [&_.ProseMirror]:leading-6 [&_.ProseMirror]:outline-none\",\n          mounted ? \"\" : \"hidden\",\n        )}\n      />\n      {/* keeps field visible before editor mounts */}\n      {mounted ? null : (\n        <textarea\n          aria-label=\"Message\"\n          defaultValue={input.value}\n          readOnly\n          rows={2}\n          placeholder={placeholder}\n          className=\"min-h-16 w-full resize-none bg-transparent px-[var(--padding-x)] py-[var(--padding-y)] text-sm leading-6 outline-none placeholder:text-muted-foreground\"\n        />\n      )}\n      {mounted\n        ? initialExtensions.map((extension) => (extension.Overlay ? <extension.Overlay key={extension.name} editor={api} /> : null))\n        : null}\n    </div>\n  );\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/chat-composer-editor/extensions/mention.tsx",
      "target": "@components/control-ui/chat-composer-editor/extensions/mention.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport type { DOMOutputSpec, NodeSpec, Node as ProseMirrorNode } from \"prosemirror-model\";\nimport type { Command } from \"prosemirror-state\";\nimport type { EditorView } from \"prosemirror-view\";\nimport { useEffect } from \"react\";\nimport type { MentionItem, TriggerConfig, TriggerMenuItemData } from \"@/components/control-ui/contracts\";\nimport { useTriggerMenu } from \"@/components/control-ui/hooks/use-trigger-menu\";\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { detectTrigger } from \"@/components/control-ui/lib/trigger-detect\";\nimport { skinSlot } from \"@/components/control-ui/skin\";\nimport { TriggerMenu, TriggerMenuEmpty, TriggerMenuIcon, TriggerMenuItem, TriggerMenuList } from \"@/components/control-ui/ui/trigger-menu\";\nimport { spawnExitGhost } from \"../ghost\";\nimport type { ChatComposerEditorApi, ChatComposerEditorExtension } from \"../types\";\n\n// Ships the \"@\" pill as one composable unit, so base editor stays mention-free.\n\n// ProseMirror types attrs as `any`, so each is read through typeof guard rather than asserted\nfunction attrString(value: unknown, fallback: string): string {\n  return typeof value === \"string\" ? value : fallback;\n}\n\n// atom + contenteditable=false makes it one non-editable unit arrows skip over; leafText serializes it back to \"@label\"\nconst mentionNode: NodeSpec = {\n  group: \"inline\",\n  inline: true,\n  atom: true,\n  selectable: true,\n  draggable: false,\n  attrs: {\n    id: { default: \"\" },\n    label: { default: \"\" },\n    kind: { default: \"mention\" },\n    icon: { default: null },\n  },\n  parseDOM: [\n    {\n      tag: \"span[data-mention]\",\n      getAttrs: (dom) => {\n        if (typeof dom === \"string\") return false;\n        return {\n          id: dom.getAttribute(\"data-id\") ?? \"\",\n          label: dom.textContent ?? \"\",\n          kind: dom.getAttribute(\"data-mention\") ?? \"mention\",\n          icon: dom.getAttribute(\"data-icon\"),\n        };\n      },\n    },\n  ],\n  toDOM: (node): DOMOutputSpec => {\n    const label = attrString(node.attrs.label, \"\");\n    const id = attrString(node.attrs.id, \"\");\n    const kind = attrString(node.attrs.kind, \"mention\");\n    const icon = typeof node.attrs.icon === \"string\" ? node.attrs.icon : null;\n    const attrs = {\n      class: cn(\n        \"inline-flex items-center gap-1 rounded-[var(--radius-popup-item)] bg-primary/10 px-1 align-baseline font-medium text-primary-text\",\n        skinSlot(\"chat-composer\", \"mention\", {}),\n      ),\n      \"data-control-ui\": \"chat-composer\",\n      \"data-slot\": \"mention\",\n      \"data-mention\": kind,\n      \"data-id\": id,\n      \"data-icon\": icon ?? \"\",\n      contenteditable: \"false\",\n    };\n    const labelSpan: DOMOutputSpec = [\"span\", { class: \"truncate\" }, label];\n    return icon === null\n      ? [\"span\", attrs, labelSpan]\n      : [\"span\", attrs, [\"img\", { src: icon, alt: \"\", class: \"size-4 shrink-0 rounded-[3px]\" }], labelSpan];\n  },\n  leafText: (node) => `@${attrString(node.attrs.label, \"\")}`,\n};\n\ntype EditorTriggerState = {\n  active: boolean;\n  from: number;\n  to: number;\n  char: string;\n  query: string;\n  rect: DOMRect | null;\n};\n\nconst INACTIVE: EditorTriggerState = { active: false, from: 0, to: 0, char: \"\", query: \"\", rect: null };\n\n// reads off live view so positions are never stale\nfunction readEditorTrigger(view: EditorView, triggerChars: readonly string[]): EditorTriggerState {\n  const { selection } = view.state;\n  if (!selection.empty) return INACTIVE;\n  const { $from } = selection;\n  if (!$from.parent.isTextblock) return INACTIVE;\n  const start = $from.start();\n  // atoms collapse to one sentinel char so string offsets map 1:1 onto doc positions\n  const textBefore = view.state.doc.textBetween(start, $from.pos, undefined, () => \"￼\");\n  const match = detectTrigger(textBefore, triggerChars);\n  if (match === null) return INACTIVE;\n  const coords = view.coordsAtPos($from.pos);\n  return {\n    active: true,\n    from: start + match.start,\n    to: $from.pos,\n    char: match.char,\n    query: match.query,\n    rect: new DOMRect(coords.left, coords.top, 1, coords.bottom - coords.top),\n  };\n}\n\ntype MentionNodeAttrs = { id: string; label: string; kind?: string; icon?: string | null };\n\nfunction insertMention(view: EditorView, from: number, to: number, attrs: MentionNodeAttrs) {\n  const node = view.state.schema.nodes.mention.create({\n    id: attrs.id,\n    label: attrs.label,\n    kind: attrs.kind ?? \"mention\",\n    icon: attrs.icon ?? null,\n  });\n  const transaction = view.state.tr.replaceWith(from, to, node);\n  transaction.insertText(\" \");\n  view.dispatch(transaction);\n  view.focus();\n}\n\n// for slash-commands that run action instead of inserting\nfunction deleteTriggerToken(view: EditorView, from: number, to: number) {\n  view.dispatch(view.state.tr.delete(from, to));\n  view.focus();\n}\n\n// must run before transaction lands, while nodeDOM can still return pill to clone\nfunction ghostPill(view: EditorView | undefined, pillPos: number): void {\n  const dom = view?.nodeDOM(pillPos);\n  if (dom instanceof HTMLElement) spawnExitGhost(dom);\n}\n\n// lone atom at block end gets trailing <br>, so plain Backspace reads as adding newline rather than removing pill.\n// Deleting pill and its auto-inserted space in one keystroke avoids ever leaving that lone trailing atom.\nexport const deleteMentionBackward: Command = (state, dispatch, view) => {\n  const { selection } = state;\n  if (!selection.empty) return false;\n  const { $from } = selection;\n  const before = $from.nodeBefore;\n  if (!before) return false;\n  if (before.type.name === \"mention\") {\n    const pillPos = $from.pos - before.nodeSize;\n    if (dispatch) {\n      ghostPill(view, pillPos);\n      dispatch(state.tr.delete(pillPos, $from.pos).scrollIntoView());\n    }\n    return true;\n  }\n  // at block end space and pill go together, or pill becomes last node and draws phantom line\n  if (before.isText && before.text === \" \" && $from.pos === $from.end()) {\n    const beforeSpace = state.doc.resolve($from.pos - before.nodeSize).nodeBefore;\n    if (beforeSpace && beforeSpace.type.name === \"mention\") {\n      const pillPos = $from.pos - before.nodeSize - beforeSpace.nodeSize;\n      if (dispatch) {\n        ghostPill(view, pillPos);\n        dispatch(state.tr.delete(pillPos, $from.pos).scrollIntoView());\n      }\n      return true;\n    }\n  }\n  return false;\n};\n\n// forward-delete symmetry with Backspace commands above\nexport const deleteMentionForward: Command = (state, dispatch, view) => {\n  const { selection } = state;\n  if (!selection.empty) return false;\n  const { $from } = selection;\n  const after = $from.nodeAfter;\n  if (after?.type.name !== \"mention\") return false;\n  if (dispatch) {\n    ghostPill(view, $from.pos);\n    dispatch(state.tr.delete($from.pos, $from.pos + after.nodeSize).scrollIntoView());\n  }\n  return true;\n};\n\n// ids agent consumes, ridden along on submit payload beside plain text\nfunction collectMentions(doc: ProseMirrorNode): MentionItem[] {\n  const mentions: MentionItem[] = [];\n  doc.descendants((node) => {\n    if (node.type.name === \"mention\") {\n      mentions.push({\n        id: attrString(node.attrs.id, \"\"),\n        label: attrString(node.attrs.label, \"\"),\n        kind: attrString(node.attrs.kind, \"mention\"),\n      });\n    }\n  });\n  return mentions;\n}\n\ntype MentionOverlayProps<Item extends TriggerMenuItemData> = {\n  editor: ChatComposerEditorApi;\n  triggers: readonly TriggerConfig<Item>[];\n  side: \"top\" | \"bottom\";\n  align: \"start\" | \"center\" | \"end\";\n};\n\n// talks to doc only through editor api, so no ProseMirror plugin is needed and base editor stays mention-unaware\nfunction MentionOverlay<Item extends TriggerMenuItemData>({ editor, triggers, side, align }: MentionOverlayProps<Item>) {\n  const chars = triggers.map((trigger) => trigger.char);\n\n  const controller = useTriggerMenu<Item>({\n    triggers,\n    onCommit: (item, trigger) => {\n      const view = editor.getView();\n      if (!view) return;\n      const state = readEditorTrigger(view, chars);\n      if (!state.active) return;\n      if ((trigger.insert ?? \"replace\") === \"none\") {\n        deleteTriggerToken(view, state.from, state.to);\n      } else {\n        insertMention(view, state.from, state.to, { id: item.id, label: item.label, kind: item.kind, icon: item.image ?? null });\n      }\n      trigger.onSelect?.(item, { char: state.char, query: state.query });\n    },\n  });\n\n  useEffect(() => {\n    return editor.subscribe(() => {\n      const view = editor.getView();\n      if (!view) return;\n      const state = readEditorTrigger(view, chars);\n      controller.report(state.active ? { char: state.char, query: state.query, start: state.from, end: state.to } : null, state.rect);\n    });\n  }, [editor, controller, chars]);\n\n  // open menu eats arrows, Enter, and Esc before editor's keymaps see them\n  useEffect(() => {\n    return editor.registerKeyHandler((event) => {\n      const view = editor.getView();\n      if (!view) return false;\n      if (!readEditorTrigger(view, chars).active) return false;\n      return controller.handleKeyDown(event.key);\n    });\n  }, [editor, controller, chars]);\n\n  if (triggers.length === 0) return null;\n  return (\n    <TriggerMenu open={controller.open} onOpenChange={controller.setOpen} anchorRect={controller.anchorRect} side={side} align={align}>\n      <TriggerMenuList>\n        {controller.items.length === 0 ? (\n          <TriggerMenuEmpty>No results</TriggerMenuEmpty>\n        ) : (\n          controller.items.map((item, index) => (\n            <TriggerMenuItem\n              key={item.id}\n              active={index === controller.activeIndex}\n              disabled={item.disabled}\n              onPointerMove={() => controller.setActiveIndex(index)}\n              onClick={() => controller.select(item)}\n            >\n              {item.icon ? <TriggerMenuIcon>{item.icon}</TriggerMenuIcon> : null}\n              <span className=\"flex-1 truncate\">{item.label}</span>\n              {item.description ? <span className=\"truncate text-micro text-muted-foreground\">{item.description}</span> : null}\n            </TriggerMenuItem>\n          ))\n        )}\n      </TriggerMenuList>\n    </TriggerMenu>\n  );\n}\n\nexport type MentionExtensionConfig<Item extends TriggerMenuItemData = TriggerMenuItemData> = {\n  triggers: readonly TriggerConfig<Item>[];\n  side?: \"top\" | \"bottom\";\n  align?: \"start\" | \"center\" | \"end\";\n};\n\nexport function mentionExtension<Item extends TriggerMenuItemData = TriggerMenuItemData>({\n  triggers,\n  side = \"top\",\n  align = \"start\",\n}: MentionExtensionConfig<Item>): ChatComposerEditorExtension {\n  return {\n    name: \"mention\",\n    nodes: { mention: mentionNode },\n    keymap: () => ({ Backspace: deleteMentionBackward, Delete: deleteMentionForward }),\n    submitPayload: (doc) => ({ mentions: collectMentions(doc) }),\n    Overlay: ({ editor }) => <MentionOverlay editor={editor} triggers={triggers} side={side} align={align} />,\n  };\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/chat-composer-editor/ghost.ts",
      "target": "@components/control-ui/chat-composer-editor/ghost.ts",
      "type": "registry:component",
      "content": "\"use client\";\n\n// ProseMirror removes nodes synchronously, so they are gone before any transition runs and @starting-style cannot cover exit.\n// element is cloned into fixed-position ghost over its last rect, animated out by chat-composer-editor.css, and self-removes on animationend with timeout backstop.\n\n// .ProseMirror is styled by scoped utilities on its mount wrapper, which body-appended clone never inherits\nconst MESSAGE_INHERIT = [\n  \"font-family\",\n  \"font-size\",\n  \"font-weight\",\n  \"font-style\",\n  \"line-height\",\n  \"letter-spacing\",\n  \"color\",\n  \"padding\",\n  \"white-space\",\n  \"overflow-wrap\",\n  \"word-break\",\n  \"text-align\",\n] as const;\n\nfunction prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && typeof window.matchMedia === \"function\"\n    ? window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    : false;\n}\n\nexport function spawnExitGhost(source: HTMLElement, inherit: readonly string[] = []): void {\n  if (typeof document === \"undefined\") return;\n  if (prefersReducedMotion()) return;\n\n  const rect = source.getBoundingClientRect();\n  if (rect.width === 0 && rect.height === 0) return;\n\n  const ghost = document.createElement(source.tagName.toLowerCase());\n  // carries class and data-slot across so utility and skin styling render identically\n  for (const name of source.getAttributeNames()) {\n    const value = source.getAttribute(name);\n    if (value !== null) ghost.setAttribute(name, value);\n  }\n  ghost.innerHTML = source.innerHTML;\n  ghost.setAttribute(\"data-chat-composer-exit\", \"\");\n  ghost.setAttribute(\"data-exiting\", \"\");\n  ghost.setAttribute(\"aria-hidden\", \"true\");\n  ghost.removeAttribute(\"contenteditable\");\n\n  if (inherit.length > 0 && typeof getComputedStyle === \"function\") {\n    const computed = getComputedStyle(source);\n    for (const property of inherit) ghost.style.setProperty(property, computed.getPropertyValue(property));\n  }\n\n  ghost.style.position = \"fixed\";\n  ghost.style.left = `${rect.left}px`;\n  ghost.style.top = `${rect.top}px`;\n  ghost.style.width = `${rect.width}px`;\n  ghost.style.height = `${rect.height}px`;\n  ghost.style.margin = \"0\";\n  ghost.style.overflow = \"hidden\";\n  ghost.style.pointerEvents = \"none\";\n  ghost.style.zIndex = \"50\";\n\n  document.body.appendChild(ghost);\n\n  const remove = () => ghost.remove();\n  ghost.addEventListener(\"animationend\", remove, { once: true });\n  window.setTimeout(remove, 600);\n}\n\nexport const MESSAGE_GHOST_INHERIT = MESSAGE_INHERIT;\n"
    },
    {
      "path": "src/registry/sources/control-ui/chat-composer-editor/schema.ts",
      "target": "@components/control-ui/chat-composer-editor/schema.ts",
      "type": "registry:component",
      "content": "import type { DOMOutputSpec, NodeSpec } from \"prosemirror-model\";\nimport { Schema } from \"prosemirror-model\";\n\nimport type { ChatComposerEditorExtension } from \"./types\";\n\n// stays mention-free — rich nodes arrive through createEditorSchema\nconst baseNodes: Record<string, NodeSpec> = {\n  doc: { content: \"block+\" },\n  paragraph: {\n    group: \"block\",\n    content: \"inline*\",\n    parseDOM: [{ tag: \"p\" }],\n    toDOM: (): DOMOutputSpec => [\"p\", 0],\n  },\n  text: { group: \"inline\" },\n};\n\n// later extensions win node-name collision\nexport function createEditorSchema(extensions: readonly ChatComposerEditorExtension[]): Schema {\n  let nodes: Record<string, NodeSpec> = { ...baseNodes };\n  for (const extension of extensions) {\n    if (extension.nodes) nodes = { ...nodes, ...extension.nodes };\n  }\n  return new Schema({ nodes, marks: {} });\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/chat-composer-editor/serialize.ts",
      "target": "@components/control-ui/chat-composer-editor/serialize.ts",
      "type": "registry:component",
      "content": "import type { Node as ProseMirrorNode, Schema } from \"prosemirror-model\";\n\n// Each leaf serializes through its own schema `leafText`, so extensions stay self-describing and this stays mention-unaware.\nexport function serializeDoc(doc: ProseMirrorNode): string {\n  return doc.textBetween(0, doc.content.size, \"\\n\", (leaf) => {\n    const leafText = leaf.type.spec.leafText;\n    return typeof leafText === \"function\" ? leafText(leaf) : \"\";\n  });\n}\n\n// external resets only — rebuilding doc from its own output would fight caret\nexport function docFromText(schema: Schema, text: string): ProseMirrorNode {\n  const paragraph = schema.nodes.paragraph;\n  const blocks = text.split(\"\\n\").map((line) => paragraph.create(null, line === \"\" ? null : schema.text(line)));\n  return schema.nodes.doc.create(null, blocks.length === 0 ? paragraph.create() : blocks);\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/chat-composer-editor/types.ts",
      "target": "@components/control-ui/chat-composer-editor/types.ts",
      "type": "registry:component",
      "content": "import type { NodeSpec, Node as ProseMirrorNode, Schema } from \"prosemirror-model\";\nimport type { Command, Plugin } from \"prosemirror-state\";\nimport type { EditorView } from \"prosemirror-view\";\nimport type { ReactNode } from \"react\";\n\nimport type { ChatComposerSubmitPayload } from \"@/components/control-ui/contracts\";\n\n// stable for editor's lifetime, so extension never closes over stale view\nexport type ChatComposerEditorApi = {\n  getView: () => EditorView | null;\n  subscribe: (listener: () => void) => () => void;\n  registerKeyHandler: (handler: (event: KeyboardEvent) => boolean) => () => void;\n};\n\nexport type ChatComposerEditorOverlayProps = { editor: ChatComposerEditorApi };\n\n// every field optional — mentions are one extension among possible others\nexport type ChatComposerEditorExtension = {\n  // doubles as overlay's React key, so it must be stable and unique\n  name: string;\n  nodes?: Record<string, NodeSpec>;\n  plugins?: (schema: Schema, editor: ChatComposerEditorApi) => Plugin[];\n  keymap?: (schema: Schema) => Record<string, Command>;\n  submitPayload?: (doc: ProseMirrorNode) => Partial<ChatComposerSubmitPayload>;\n  Overlay?: (props: ChatComposerEditorOverlayProps) => ReactNode;\n};\n\nexport type ChatComposerEditorProps = {\n  className?: string;\n  placeholder?: string;\n  extensions?: readonly ChatComposerEditorExtension[];\n};\n"
    },
    {
      "path": "src/registry/sources/control-ui/chat-composer.tsx",
      "target": "@components/control-ui/chat-composer.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport type { ChangeEvent, ComponentProps } from \"react\";\nimport { createContext, useContext } from \"react\";\n\nimport type { ChatComposerProps } from \"@/components/control-ui/contracts\";\nimport { useChatComposer } from \"@/components/control-ui/hooks/use-chat-composer\";\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { hasSkinAdornment, skinAdornment, skinSlot } from \"@/components/control-ui/skin\";\nimport { Button } from \"@/components/control-ui/ui/button\";\n\ntype ChatComposerContextValue = ReturnType<typeof useChatComposer>;\n\nconst ChatComposerContext = createContext<ChatComposerContextValue | null>(null);\n\n// exported so opt-in editor shares this context without dragging ProseMirror into base file\nexport function useChatComposerContext() {\n  const context = useContext(ChatComposerContext);\n  if (!context) throw new Error(\"ChatComposer compound components must be rendered inside <ChatComposer>.\");\n  return context;\n}\n\nexport function ChatComposer({\n  value,\n  defaultValue,\n  onValueChange,\n  onSubmit,\n  state = \"idle\",\n  density = \"comfortable\",\n  disabled = false,\n  className,\n  children,\n  ...props\n}: ChatComposerProps) {\n  const input = useChatComposer({\n    value,\n    defaultValue,\n    onValueChange,\n    onSubmit,\n    state,\n    density,\n    disabled,\n    trackSends: hasSkinAdornment(\"chat-composer\", \"send-layer\"),\n  });\n  const sendLayer = skinAdornment(\"chat-composer\", \"send-layer\", { sendCount: input.sendCount });\n\n  return (\n    <ChatComposerContext.Provider value={input}>\n      <form\n        data-control-ui=\"chat-composer\"\n        data-slot=\"root\"\n        data-state={state}\n        data-density={density}\n        onSubmit={input.handleSubmit}\n        className={cn(\n          // sticky already gives send-layer anchor containing block and stacking context it needs\n          \"sticky bottom-0 w-full bg-background/80 px-2 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/70\",\n          skinSlot(\"chat-composer\", \"root\", {}),\n          className,\n        )}\n        {...props}\n      >\n        {/* component owns this position contract and skin supplies only visuals — no skin, no DOM */}\n        {sendLayer !== undefined && sendLayer !== null ? (\n          <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 -z-10 overflow-clip contain-paint\">\n            {sendLayer}\n          </div>\n        ) : null}\n        {children}\n      </form>\n    </ChatComposerContext.Provider>\n  );\n}\n\nexport type ChatComposerShellProps = ComponentProps<\"div\">;\n\nexport function ChatComposerShell({ className, ...props }: ChatComposerShellProps) {\n  const input = useChatComposerContext();\n\n  return (\n    <div\n      data-control-ui=\"chat-composer\"\n      data-slot=\"shell\"\n      data-state={input.state}\n      className={cn(\n        \"relative overflow-hidden rounded-field border bg-card/78 shadow-sm ring-1 ring-foreground/4 transition-[box-shadow,filter,translate] duration-[var(--duration-base)] ease-[var(--ease-emphasized)]\",\n        \"data-[state=submitting]:-translate-y-0.5 data-[state=submitting]:saturate-[1.02] data-[state=submitting]:shadow-md\",\n        skinSlot(\"chat-composer\", \"shell\", {}),\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport type ChatComposerAccentProps = ComponentProps<\"div\">;\n\nexport function ChatComposerAccent({ className, ...props }: ChatComposerAccentProps) {\n  return (\n    <div\n      data-control-ui=\"chat-composer\"\n      data-slot=\"accent\"\n      aria-hidden=\"true\"\n      className={cn(\n        \"pointer-events-none absolute inset-x-5 top-0 h-px bg-linear-to-r from-transparent via-foreground/20 to-transparent\",\n        skinSlot(\"chat-composer\", \"accent\", {}),\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport type ChatComposerTextareaProps = ComponentProps<\"textarea\">;\n\nexport function ChatComposerTextarea({ className, rows, disabled, onChange, ...props }: ChatComposerTextareaProps) {\n  const input = useChatComposerContext();\n\n  function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {\n    onChange?.(event);\n    if (!event.defaultPrevented) input.setValue(event.currentTarget.value);\n  }\n\n  return (\n    <textarea\n      {...props}\n      data-control-ui=\"chat-composer\"\n      data-slot=\"textarea\"\n      aria-label=\"Message\"\n      value={input.value}\n      onChange={handleChange}\n      disabled={disabled ?? input.isDisabled}\n      rows={rows ?? input.rows}\n      className={cn(\n        // field-sizing-content auto-grows it with no ref or ResizeObserver; `rows` covers browsers without it\n        \"field-sizing-content min-h-16 max-h-[40dvh] w-full resize-none bg-transparent px-[var(--padding-x)] py-[var(--padding-y)] text-sm leading-6 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60\",\n        skinSlot(\"chat-composer\", \"textarea\", {}),\n        className,\n      )}\n    />\n  );\n}\n\nexport type ChatComposerToolbarProps = ComponentProps<\"div\">;\n\nexport function ChatComposerToolbar({ className, ...props }: ChatComposerToolbarProps) {\n  return (\n    <div\n      data-control-ui=\"chat-composer\"\n      data-slot=\"toolbar\"\n      className={cn(\"flex min-h-10 items-center justify-between gap-2 px-2.5 pb-2\", skinSlot(\"chat-composer\", \"toolbar\", {}), className)}\n      {...props}\n    />\n  );\n}\n\nexport type ChatComposerToolsProps = ComponentProps<\"div\">;\n\nexport function ChatComposerTools({ className, ...props }: ChatComposerToolsProps) {\n  return (\n    <div\n      data-control-ui=\"chat-composer\"\n      data-slot=\"tools\"\n      className={cn(\"flex min-w-0 items-center gap-1.5 text-muted-foreground\", skinSlot(\"chat-composer\", \"tools\", {}), className)}\n      {...props}\n    />\n  );\n}\n\nexport type ChatComposerFooterProps = ComponentProps<\"div\">;\n\nexport function ChatComposerFooter({ className, ...props }: ChatComposerFooterProps) {\n  return (\n    <div\n      data-control-ui=\"chat-composer\"\n      data-slot=\"footer\"\n      className={cn(\"px-3 pb-2 text-caption text-muted-foreground\", skinSlot(\"chat-composer\", \"footer\", {}), className)}\n      {...props}\n    />\n  );\n}\n\nexport type ChatComposerSubmitProps = ComponentProps<typeof Button>;\n\nexport function ChatComposerSubmit({ className, disabled, children = \"Send\", ...props }: ChatComposerSubmitProps) {\n  const input = useChatComposerContext();\n\n  return (\n    <Button\n      data-control-ui=\"chat-composer\"\n      data-slot=\"submit\"\n      type=\"submit\"\n      variant=\"solid\"\n      tone=\"primary\"\n      size=\"xs\"\n      disabled={disabled ?? !input.canSubmit}\n      className={cn(skinSlot(\"chat-composer\", \"submit\", {}), className)}\n      {...props}\n    >\n      {children}\n    </Button>\n  );\n}\n"
    }
  ],
  "meta": {}
}
