{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-bar",
  "title": "Prompt bar",
  "description": "Rounded or Pill composer with source mentions, slash commands, attachments, model selection, and dictation state.",
  "registryDependencies": [
    "https://beadkit.dev/r/ai-core.json"
  ],
  "files": [
    {
      "path": "components/ai/PromptBar.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\n\nexport type PromptBarVariant = \"Rounded\" | \"Pill\";\nexport interface PromptSource { id: string; name: string; description?: string; icon?: ReactNode; connected?: boolean; }\nexport interface PromptCommand { id: string; name: string; description?: string; }\nexport interface PromptModel { id: string; name: string; description?: string; tag?: string; }\nexport interface PromptAttachment { id: string; name: string; }\nexport interface PromptBarProps {\n  sources?: readonly PromptSource[]; commands?: readonly PromptCommand[]; models?: readonly PromptModel[];\n  value?: string; defaultValue?: string; selectedModel?: PromptModel; attachments?: readonly PromptAttachment[];\n  variant?: PromptBarVariant; placeholder?: string; listening?: boolean; disabled?: boolean;\n  busy?: boolean;\n  onChange?: (value: string) => void; onSubmit?: (value: string, attachments: readonly PromptAttachment[], model?: PromptModel) => void;\n  onSelectSource?: (source: PromptSource) => void; onSelectCommand?: (command: PromptCommand) => void; onSelectModel?: (model: PromptModel) => void;\n  onAttach?: () => void; onRemoveAttachment?: (attachment: PromptAttachment) => void; onDictate?: () => void; onStop?: () => void; className?: string;\n}\nconst Icon = ({ children, size = 16 }: { children: ReactNode; size?: number }) => <svg aria-hidden=\"true\" width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.9\" strokeLinecap=\"round\" strokeLinejoin=\"round\">{children}</svg>;\nconst Caret = () => <Icon size={13}><path d=\"m6 9 6 6 6-6\" /></Icon>;\nfunction parseToken(value: string) { const match = /(^|\\s)([@/])([\\w-]*)$/.exec(value); return match ? { kind: match[2] as \"@\" | \"/\", query: match[3].toLowerCase(), start: match.index + match[1].length } : null; }\n\nexport function PromptBar({ sources = [], commands = [], models = [], value, defaultValue = \"\", selectedModel, attachments = [], variant = \"Rounded\", placeholder = \"Ask anything…\", listening = false, disabled = false, busy = false, onChange, onSubmit, onSelectSource, onSelectCommand, onSelectModel, onAttach, onRemoveAttachment, onDictate, onStop, className = \"\" }: PromptBarProps) {\n  const controlled = value !== undefined; const [draft, setDraft] = useState(defaultValue); const [menuOpen, setMenuOpen] = useState<\"@\" | \"/\" | false | null>(null); const [modelOpen, setModelOpen] = useState(false); const [active, setActive] = useState(0); const [internalModel, setInternalModel] = useState(selectedModel ?? models[0]);\n  const listboxId = useId();\n  const modelListboxId = useId();\n  const inputRef = useRef<HTMLTextAreaElement>(null); const menuRef = useRef<HTMLDivElement>(null); const modelButtonRef = useRef<HTMLButtonElement>(null); const modelOptionRefs = useRef<Array<HTMLButtonElement | null>>([]); const current = controlled ? value : draft; const parsed = parseToken(current); const kind = menuOpen === false ? null : menuOpen ?? parsed?.kind ?? null; const query = menuOpen ? \"\" : parsed?.query ?? \"\";\n  const options = kind === \"@\" ? sources.filter((item) => item.name.toLowerCase().includes(query)) : commands.filter((item) => item.name.toLowerCase().replace(/^\\//, \"\").startsWith(query)); const model = selectedModel ?? internalModel;\n  const setValue = (next: string) => { if (!controlled) setDraft(next); onChange?.(next); }; useEffect(() => setActive(0), [kind, query]);\n  useEffect(() => { if (!kind && !modelOpen) return; const close = (event: MouseEvent) => { if (!(event.target instanceof Node) || !menuRef.current?.contains(event.target)) { setMenuOpen(false); setModelOpen(false); } }; document.addEventListener(\"mousedown\", close); return () => document.removeEventListener(\"mousedown\", close); }, [kind, modelOpen]);\n  const choose = (item: PromptSource | PromptCommand) => { const name = kind === \"/\" && !item.name.startsWith(\"/\") ? `/${item.name}` : item.name; setValue(parsed ? `${current.slice(0, parsed.start)}${kind === \"@\" ? `@${name}` : name} ` : `${current}${name} `); if (kind === \"@\") onSelectSource?.(item as PromptSource); else onSelectCommand?.(item as PromptCommand); setMenuOpen(false); inputRef.current?.focus(); };\n  const submit = () => { if (!disabled && !busy && (current.trim() || attachments.length)) { onSubmit?.(current.trim(), attachments, model); setValue(\"\"); } };\n  const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { if (kind) { if (options.length && (event.key === \"ArrowDown\" || event.key === \"ArrowUp\")) { event.preventDefault(); setActive((index) => (index + (event.key === \"ArrowDown\" ? 1 : -1) + options.length) % options.length); return; } if (options.length && event.key === \"Enter\" && !event.shiftKey) { event.preventDefault(); choose(options[active]); return; } if (event.key === \"Escape\") { event.preventDefault(); setMenuOpen(false); return; } } if (event.key === \"Enter\" && !event.shiftKey) { event.preventDefault(); submit(); } };\n  const onInput = (next: string) => { setValue(next); setMenuOpen(parseToken(next)?.kind ?? null); };\n  const chooseModel = (item: PromptModel) => { setInternalModel(item); onSelectModel?.(item); setModelOpen(false); window.requestAnimationFrame(() => modelButtonRef.current?.focus()); };\n  const toggleModels = () => { if (modelOpen) { setModelOpen(false); return; } const selected = Math.max(0, models.findIndex((item) => item.id === model?.id)); setModelOpen(true); window.requestAnimationFrame(() => modelOptionRefs.current[selected]?.focus()); };\n  const onModelKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => { if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") { event.preventDefault(); const next = (index + (event.key === \"ArrowDown\" ? 1 : -1) + models.length) % models.length; modelOptionRefs.current[next]?.focus(); } else if (event.key === \"Escape\") { event.preventDefault(); setModelOpen(false); window.requestAnimationFrame(() => modelButtonRef.current?.focus()); } };\n  return <div ref={menuRef} data-ai-state=\"prompt-bar\" data-variant={variant} className={`relative w-full max-w-2xl ${className}`}>\n    {kind ? <div id={listboxId} role=\"listbox\" aria-label={kind === \"@\" ? \"Data sources\" : \"Commands\"} className=\"bg-card border-border absolute inset-x-0 bottom-full z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border p-1 shadow-lg\">{options.length ? options.map((item, index) => <button id={`${listboxId}-${index}`} key={item.id} type=\"button\" role=\"option\" aria-selected={index === active} onMouseDown={(event) => event.preventDefault()} onMouseEnter={() => setActive(index)} onClick={() => choose(item)} className={`hover:bg-muted flex min-h-10 w-full items-center gap-2 rounded-lg px-2.5 text-left ${index === active ? \"bg-muted\" : \"\"}`}><span className=\"text-muted-foreground flex size-5 shrink-0 items-center justify-center\">{kind === \"@\" ? (item as PromptSource).icon ?? <Icon><path d=\"M4 5h16M4 12h16M4 19h16\" /></Icon> : <span className=\"font-mono text-sm\">/</span>}</span><span className=\"min-w-0 flex-1 truncate text-sm font-medium\">{item.name}</span><span className=\"text-muted-foreground max-w-[48%] truncate text-xs\">{item.description}</span></button>) : <p className=\"text-muted-foreground px-3 py-4 text-center text-xs\">No matching {kind === \"@\" ? \"sources\" : \"commands\"}.</p>}</div> : null}\n    {modelOpen && models.length ? <div id={modelListboxId} role=\"listbox\" aria-label=\"Models\" className=\"bg-card border-border absolute bottom-full right-0 z-20 mb-2 w-56 rounded-xl border p-1 shadow-lg\">{models.map((item, index) => <button ref={(node) => { modelOptionRefs.current[index] = node; }} key={item.id} type=\"button\" role=\"option\" aria-selected={item.id === model?.id} onKeyDown={(event) => onModelKeyDown(event, index)} onClick={() => chooseModel(item)} className=\"hover:bg-muted flex min-h-10 w-full items-center gap-2 rounded-lg px-2.5 text-left\"><span className=\"min-w-0 flex-1 truncate text-sm font-medium\">{item.name}</span><span className=\"text-muted-foreground text-xs\">{item.tag}</span></button>)}</div> : null}\n    <div className={`bg-card border-border focus-within:ring-ring/40 flex flex-wrap items-end gap-2 border p-2 shadow-sm focus-within:ring-2 ${variant === \"Pill\" ? \"rounded-full\" : \"rounded-xl\"}`}>\n      {attachments.length ? <div className=\"flex w-full flex-wrap gap-1.5 px-1\">{attachments.map((file) => <span key={file.id} className=\"bg-muted text-muted-foreground inline-flex max-w-full items-center gap-1 rounded-md px-2 py-1 text-xs\"><span className=\"truncate\">{file.name}</span>{onRemoveAttachment ? <button type=\"button\" aria-label={`Remove ${file.name}`} onClick={() => onRemoveAttachment(file)} className=\"hover:text-foreground\">×</button> : null}</span>)}</div> : null}\n      <button type=\"button\" disabled={disabled || busy || !onAttach} aria-label=\"Attach files\" onClick={onAttach} className=\"text-muted-foreground hover:bg-muted shrink-0 rounded-lg p-2 disabled:opacity-50\"><Icon><path d=\"m21.4 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48\" /></Icon></button>\n      <textarea ref={inputRef} rows={1} role=\"combobox\" aria-autocomplete=\"list\" aria-expanded={Boolean(kind)} aria-controls={kind ? listboxId : undefined} aria-activedescendant={kind && options.length ? `${listboxId}-${active}` : undefined} value={current} disabled={disabled} placeholder={listening ? \"Listening…\" : placeholder} aria-label=\"Prompt\" aria-busy={listening} onChange={(event) => onInput(event.target.value)} onKeyDown={onKeyDown} className=\"min-h-9 min-w-[120px] flex-1 resize-none bg-transparent px-1 py-2 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50\" />\n      <div className=\"flex shrink-0 items-center gap-1\"><button type=\"button\" aria-label={listening ? \"Stop dictation\" : \"Start dictation\"} aria-pressed={listening} disabled={disabled || busy || !onDictate} onClick={onDictate} className={`text-muted-foreground hover:bg-muted rounded-lg p-2 disabled:opacity-50 ${listening ? \"text-destructive\" : \"\"}`}><Icon><path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 10v2a7 7 0 0 1-14 0v-2M12 19v3M8 22h8\" /></Icon></button><button ref={modelButtonRef} type=\"button\" aria-label=\"Model\" aria-haspopup=\"listbox\" aria-controls={modelListboxId} aria-expanded={modelOpen} disabled={disabled || busy || !models.length} onClick={toggleModels} className=\"text-muted-foreground hover:bg-muted flex max-w-32 items-center gap-1 rounded-lg px-2 py-2 text-xs disabled:opacity-50\"><span className=\"truncate\">{model?.name ?? \"Model\"}</span><Caret /></button>{busy ? <button type=\"button\" aria-label=\"Stop generating\" disabled={!onStop} onClick={onStop} className=\"bg-secondary text-secondary-foreground hover:bg-muted rounded-lg p-2 disabled:opacity-40\"><Icon><rect x=\"8\" y=\"8\" width=\"8\" height=\"8\" rx=\"1\" fill=\"currentColor\" stroke=\"none\" /></Icon></button> : <button type=\"button\" aria-label=\"Send prompt\" disabled={disabled || (!current.trim() && !attachments.length)} onClick={submit} className=\"bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg p-2 disabled:opacity-40\"><Icon><path d=\"m5 12 14-7-3 14-4-6-7-1z\" /><path d=\"m12 13 4 6\" /></Icon></button>}</div>\n    </div>\n  </div>;\n}\nexport default PromptBar;\n",
      "type": "registry:component",
      "target": "@components/ai/PromptBar.tsx"
    }
  ],
  "type": "registry:component"
}
