{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"prompt-input","type":"registry:ui","title":"PromptInput","description":"PromptInput from react-fancy","package":"react-fancy","dependencies":[],"registryDependencies":["button","tooltip"],"files":[{"path":"components\/fancy\/prompt-input\/PromptInput.tsx","content":"import { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\r\nimport { Button } from \"..\/Button\";\r\nimport { Tooltip } from \"..\/Tooltip\";\r\n\r\n\/**\r\n * PromptInput \u2014 the chat composer every AI app rebuilds. Auto-growing\r\n * multi-line input with:\r\n *\r\n *   \u2022 slash-command picker (`\/` triggers a filtered command palette,\r\n *     \u2191\/\u2193 navigate, Enter inserts)\r\n *   \u2022 mention picker (`@` triggers, filtered against `mentions`)\r\n *   \u2022 drop-to-attach (drag files anywhere on the surface) + chip bar\r\n *   \u2022 submit on \u2318\/Ctrl+Enter (plain Enter inserts a newline)\r\n *   \u2022 live token-budget meter (green \u2192 amber \u2192 red as headroom drops)\r\n *\r\n * Wire it up:\r\n *\r\n *   <PromptInput\r\n *     budgetTokens={32000}\r\n *     commands={[{ name: \"\/rewrite\", hint: \"rewrite the selection\" }]}\r\n *     mentions={[{ id: \"ada\", name: \"Ada\", kind: \"person\" }]}\r\n *     onSubmit={(text, attachments) => sendToAgent(text, attachments)}\r\n *   \/>\r\n *\/\r\nexport type PromptCmd = { name: string; hint: string };\r\nexport type PromptMention = {\r\n  id: string;\r\n  name: string;\r\n  kind: \"agent\" | \"file\" | \"person\" | string;\r\n};\r\nexport type PromptAttachment = { id: string; name: string; bytes: number };\r\n\r\nexport interface PromptInputProps {\r\n  \/** Token budget for the meter. *\/\r\n  budgetTokens: number;\r\n  \/** Slash-commands. Names must start with `\/`. *\/\r\n  commands?: PromptCmd[];\r\n  \/** @-mentions. *\/\r\n  mentions?: PromptMention[];\r\n  \/** Show the keyboard hint (\"\u2318 + Enter to send\"). *\/\r\n  showHint?: boolean;\r\n  \/** Called on \u2318\/Ctrl+Enter or send button. *\/\r\n  onSubmit: (text: string, attachments: PromptAttachment[]) => void;\r\n  \/** Placeholder text. *\/\r\n  placeholder?: string;\r\n  \/** Rough estimator: chars-per-token. Defaults to 4. *\/\r\n  charsPerToken?: number;\r\n  \/** Color \u2192 CSS chip mapping for mention kinds. *\/\r\n  mentionColor?: Record<string, string>;\r\n  \/** Optional max textarea height in px. Defaults to 280. *\/\r\n  maxHeight?: number;\r\n  \/**\r\n   * Rendered INSIDE the rounded shell, ABOVE the attachments bar and\r\n   * textarea. Use this slot for a drawer of tools\/files\/prompts\/etc. so\r\n   * the drawer and composer share one visual panel. See {@link ChatDrawer}.\r\n   *\/\r\n  aboveInput?: ReactNode;\r\n}\r\n\r\nconst DEFAULT_MENTION_COLOR: Record<string, string> = {\r\n  agent: \"#a855f7\",\r\n  file: \"#10b981\",\r\n  person: \"#3b82f6\",\r\n};\r\n\r\nexport function PromptInput({\r\n  budgetTokens,\r\n  commands = [],\r\n  mentions = [],\r\n  showHint = true,\r\n  onSubmit,\r\n  placeholder = \"Ask anything. Type \/ for commands, @ for mentions. \u2318\/Ctrl+Enter to send.\",\r\n  charsPerToken = 4,\r\n  mentionColor,\r\n  maxHeight = 280,\r\n  aboveInput,\r\n}: PromptInputProps) {\r\n  const [text, setText] = useState(\"\");\r\n  const [attachments, setAttachments] = useState<PromptAttachment[]>([]);\r\n  const [picker, setPicker] = useState<null | {\r\n    kind: \"cmd\" | \"mention\";\r\n    start: number;\r\n    query: string;\r\n    cursor: number;\r\n  }>(null);\r\n  const taRef = useRef<HTMLTextAreaElement>(null);\r\n  const [dragOver, setDragOver] = useState(false);\r\n\r\n  const colors = mentionColor ?? DEFAULT_MENTION_COLOR;\r\n\r\n  const tokens = useMemo(\r\n    () => Math.ceil(text.length \/ Math.max(1, charsPerToken)),\r\n    [text, charsPerToken],\r\n  );\r\n  const ratio = Math.min(1, tokens \/ budgetTokens);\r\n  const meterColor = ratio < 0.6 ? \"#10b981\" : ratio < 0.85 ? \"#f59e0b\" : \"#ef4444\";\r\n\r\n  const filteredCmds = useMemo(\r\n    () =>\r\n      picker?.kind === \"cmd\"\r\n        ? commands.filter((c) =>\r\n            c.name.slice(1).toLowerCase().startsWith(picker.query.toLowerCase()),\r\n          )\r\n        : [],\r\n    [picker, commands],\r\n  );\r\n  const filteredMentions = useMemo(\r\n    () =>\r\n      picker?.kind === \"mention\"\r\n        ? mentions.filter((m) =>\r\n            m.name.toLowerCase().includes(picker.query.toLowerCase()),\r\n          )\r\n        : [],\r\n    [picker, mentions],\r\n  );\r\n  const items: Array<PromptCmd | PromptMention> =\r\n    picker?.kind === \"cmd\"\r\n      ? filteredCmds\r\n      : picker?.kind === \"mention\"\r\n        ? filteredMentions\r\n        : [];\r\n\r\n  useEffect(() => {\r\n    const ta = taRef.current;\r\n    if (!ta) return;\r\n    ta.style.height = \"auto\";\r\n    ta.style.height = Math.min(maxHeight, ta.scrollHeight) + \"px\";\r\n  }, [text, maxHeight]);\r\n\r\n  const updateText = (next: string, caret: number) => {\r\n    setText(next);\r\n    let triggerIdx = -1;\r\n    let triggerKind: \"cmd\" | \"mention\" | null = null;\r\n    for (let i = caret - 1; i >= 0; i--) {\r\n      const ch = next[i];\r\n      if (ch === \"@\") {\r\n        triggerKind = \"mention\";\r\n        triggerIdx = i;\r\n        break;\r\n      }\r\n      if (ch === \"\/\" && (i === 0 || \/\\s\/.test(next[i - 1] ?? \"\"))) {\r\n        triggerKind = \"cmd\";\r\n        triggerIdx = i;\r\n        break;\r\n      }\r\n      if (\/\\s\/.test(ch)) break;\r\n    }\r\n    if (triggerKind !== null && triggerIdx >= 0) {\r\n      const query = next.slice(triggerIdx + 1, caret);\r\n      setPicker({ kind: triggerKind, start: triggerIdx, query, cursor: 0 });\r\n    } else {\r\n      setPicker(null);\r\n    }\r\n  };\r\n\r\n  const insertChoice = (i: number) => {\r\n    if (!picker || items.length === 0) return;\r\n    const choice = items[i] ?? items[0];\r\n    const insert =\r\n      picker.kind === \"cmd\"\r\n        ? (choice as PromptCmd).name + \" \"\r\n        : `@${(choice as PromptMention).id} `;\r\n    const before = text.slice(0, picker.start);\r\n    const after = text.slice(picker.start + 1 + picker.query.length);\r\n    const next = before + insert + after;\r\n    setText(next);\r\n    setPicker(null);\r\n    requestAnimationFrame(() => {\r\n      const ta = taRef.current;\r\n      if (!ta) return;\r\n      ta.focus();\r\n      const pos = before.length + insert.length;\r\n      ta.setSelectionRange(pos, pos);\r\n    });\r\n  };\r\n\r\n  const submit = () => {\r\n    if (!text.trim() && attachments.length === 0) return;\r\n    onSubmit(text, attachments);\r\n    setText(\"\");\r\n    setAttachments([]);\r\n    setPicker(null);\r\n  };\r\n\r\n  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\r\n    if (picker) {\r\n      if (e.key === \"ArrowDown\") {\r\n        e.preventDefault();\r\n        setPicker((p) =>\r\n          p ? { ...p, cursor: Math.min(items.length - 1, p.cursor + 1) } : p,\r\n        );\r\n        return;\r\n      }\r\n      if (e.key === \"ArrowUp\") {\r\n        e.preventDefault();\r\n        setPicker((p) => (p ? { ...p, cursor: Math.max(0, p.cursor - 1) } : p));\r\n        return;\r\n      }\r\n      if (e.key === \"Enter\" && !e.metaKey && !e.ctrlKey && items.length > 0) {\r\n        e.preventDefault();\r\n        insertChoice(picker.cursor);\r\n        return;\r\n      }\r\n      if (e.key === \"Escape\") {\r\n        e.preventDefault();\r\n        setPicker(null);\r\n        return;\r\n      }\r\n    }\r\n    if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\r\n      e.preventDefault();\r\n      submit();\r\n    }\r\n  };\r\n\r\n  const onDrop = (e: React.DragEvent<HTMLDivElement>) => {\r\n    e.preventDefault();\r\n    setDragOver(false);\r\n    const files = Array.from(e.dataTransfer.files);\r\n    setAttachments((cur) => [\r\n      ...cur,\r\n      ...files.map((f) => ({\r\n        id: `${f.name}-${Date.now()}-${Math.random()}`,\r\n        name: f.name,\r\n        bytes: f.size,\r\n      })),\r\n    ]);\r\n  };\r\n\r\n  return (\r\n    <div\r\n      onDragOver={(e) => {\r\n        e.preventDefault();\r\n        setDragOver(true);\r\n      }}\r\n      onDragLeave={() => setDragOver(false)}\r\n      onDrop={onDrop}\r\n      className={`relative rounded-md border transition ${\r\n        dragOver\r\n          ? \"border-violet-400 bg-violet-50\/50 dark:border-violet-600 dark:bg-violet-950\/30\"\r\n          : \"border-zinc-200 dark:border-zinc-800\"\r\n      } bg-white dark:bg-zinc-900`}\r\n    >\r\n      {aboveInput && (\r\n        <div className=\"border-b border-zinc-200 dark:border-zinc-800\">\r\n          {aboveInput}\r\n        <\/div>\r\n      )}\r\n      {attachments.length > 0 && (\r\n        <div className=\"flex flex-wrap gap-1.5 border-b border-zinc-200 px-3 py-2 dark:border-zinc-800\">\r\n          {attachments.map((a) => (\r\n            <span\r\n              key={a.id}\r\n              className=\"inline-flex items-center gap-1.5 rounded-md bg-zinc-100 px-2 py-0.5 text-[11px] dark:bg-zinc-800\"\r\n            >\r\n              <span>\ud83d\udcce<\/span>\r\n              <span className=\"font-mono\">{a.name}<\/span>\r\n              <span className=\"text-zinc-400\">{fmtSize(a.bytes)}<\/span>\r\n              <button\r\n                onClick={() =>\r\n                  setAttachments((cur) => cur.filter((x) => x.id !== a.id))\r\n                }\r\n                className=\"opacity-50 hover:opacity-100\"\r\n                aria-label=\"Remove attachment\"\r\n              >\r\n                \u00d7\r\n              <\/button>\r\n            <\/span>\r\n          ))}\r\n        <\/div>\r\n      )}\r\n\r\n      <div className=\"relative\">\r\n        <textarea\r\n          ref={taRef}\r\n          value={text}\r\n          onChange={(e) => updateText(e.target.value, e.target.selectionStart)}\r\n          onKeyDown={onKeyDown}\r\n          placeholder={placeholder}\r\n          spellCheck={false}\r\n          className=\"block w-full resize-none bg-transparent px-3 py-2.5 text-[14px] leading-relaxed outline-none placeholder:text-zinc-400\"\r\n          rows={3}\r\n        \/>\r\n\r\n        {picker && items.length > 0 && (\r\n          <div className=\"absolute bottom-full left-2 z-10 mb-1 w-72 overflow-hidden rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900\">\r\n            <div className=\"border-b border-zinc-100 bg-zinc-50 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950\">\r\n              {picker.kind === \"cmd\" ? \"Commands\" : \"Mention\"} \u00b7 {items.length}\r\n            <\/div>\r\n            <ul className=\"max-h-56 overflow-y-auto\">\r\n              {items.map((item, i) => {\r\n                const active = i === picker.cursor;\r\n                if (picker.kind === \"cmd\") {\r\n                  const c = item as PromptCmd;\r\n                  return (\r\n                    <li\r\n                      key={c.name}\r\n                      onMouseDown={(e) => {\r\n                        e.preventDefault();\r\n                        insertChoice(i);\r\n                      }}\r\n                      onMouseEnter={() =>\r\n                        setPicker((p) => (p ? { ...p, cursor: i } : p))\r\n                      }\r\n                      className={`cursor-pointer px-2 py-1.5 text-[12px] ${\r\n                        active\r\n                          ? \"bg-violet-100 dark:bg-violet-900\/30\"\r\n                          : \"hover:bg-zinc-50 dark:hover:bg-zinc-800\"\r\n                      }`}\r\n                    >\r\n                      <div className=\"font-mono font-medium text-violet-700 dark:text-violet-300\">\r\n                        {c.name}\r\n                      <\/div>\r\n                      <div className=\"text-[11px] text-zinc-500\">{c.hint}<\/div>\r\n                    <\/li>\r\n                  );\r\n                }\r\n                const m = item as PromptMention;\r\n                return (\r\n                  <li\r\n                    key={m.id}\r\n                    onMouseDown={(e) => {\r\n                      e.preventDefault();\r\n                      insertChoice(i);\r\n                    }}\r\n                    onMouseEnter={() =>\r\n                      setPicker((p) => (p ? { ...p, cursor: i } : p))\r\n                    }\r\n                    className={`flex cursor-pointer items-center gap-2 px-2 py-1.5 text-[12px] ${\r\n                      active\r\n                        ? \"bg-violet-100 dark:bg-violet-900\/30\"\r\n                        : \"hover:bg-zinc-50 dark:hover:bg-zinc-800\"\r\n                    }`}\r\n                  >\r\n                    <span\r\n                      className=\"h-2 w-2 rounded-full\"\r\n                      style={{ backgroundColor: colors[m.kind] ?? \"#71717a\" }}\r\n                    \/>\r\n                    <span className=\"font-medium\">{m.name}<\/span>\r\n                    <span className=\"ml-auto text-[10px] uppercase tracking-wider text-zinc-400\">\r\n                      {m.kind}\r\n                    <\/span>\r\n                  <\/li>\r\n                );\r\n              })}\r\n            <\/ul>\r\n          <\/div>\r\n        )}\r\n      <\/div>\r\n\r\n      <div className=\"flex items-center gap-2 border-t border-zinc-200 bg-zinc-50\/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900\/40\">\r\n        <Tooltip content=\"Drop files here, or click\">\r\n          <Button\r\n            variant=\"ghost\"\r\n            size=\"sm\"\r\n            className=\"shrink-0\"\r\n            onClick={() => {\r\n              \/* host can wire its own file picker; default no-op *\/\r\n            }}\r\n          >\r\n            \ud83d\udcce attach\r\n          <\/Button>\r\n        <\/Tooltip>\r\n        <div className=\"ml-2 flex min-w-0 items-center gap-1.5 overflow-hidden\">\r\n          <div className=\"h-1.5 w-24 shrink overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700\">\r\n            <div\r\n              className=\"h-full rounded-full transition-all\"\r\n              style={{ width: `${ratio * 100}%`, backgroundColor: meterColor }}\r\n            \/>\r\n          <\/div>\r\n          <span className=\"font-mono text-[11px]\" style={{ color: meterColor }}>\r\n            {fmtTokens(tokens)} \/ {fmtTokens(budgetTokens)}\r\n          <\/span>\r\n        <\/div>\r\n        <div className=\"ml-auto flex shrink-0 items-center gap-2\">\r\n          {showHint && (\r\n            <span className=\"hidden text-[10px] text-zinc-500 sm:inline\">\r\n              <kbd className=\"rounded border border-zinc-300 bg-zinc-50 px-1 py-0.5 font-mono text-[9px] text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300\">\r\n                \u2318\r\n              <\/kbd>{\" \"}\r\n              +{\" \"}\r\n              <kbd className=\"rounded border border-zinc-300 bg-zinc-50 px-1 py-0.5 font-mono text-[9px] text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300\">\r\n                Enter\r\n              <\/kbd>{\" \"}\r\n              to send\r\n            <\/span>\r\n          )}\r\n          <Button color=\"violet\" size=\"sm\" className=\"shrink-0\" onClick={submit}>\r\n            send \u2192\r\n          <\/Button>\r\n        <\/div>\r\n      <\/div>\r\n    <\/div>\r\n  );\r\n}\r\n\r\nfunction fmtTokens(n: number): string {\r\n  if (n < 1000) return `${n}`;\r\n  return `${(n \/ 1000).toFixed(1)}k`;\r\n}\r\n\r\nfunction fmtSize(b: number): string {\r\n  if (b < 1024) return `${b} B`;\r\n  if (b < 1024 * 1024) return `${(b \/ 1024).toFixed(0)} KB`;\r\n  return `${(b \/ 1024 \/ 1024).toFixed(1)} MB`;\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/prompt-input\/PromptInput.tsx"},{"path":"components\/fancy\/prompt-input\/index.ts","content":"export { PromptInput } from \".\/PromptInput\";\r\nexport type {\r\n  PromptInputProps,\r\n  PromptCmd,\r\n  PromptMention,\r\n  PromptAttachment,\r\n} from \".\/PromptInput\";\r\n","type":"registry:ui","target":"components\/fancy\/prompt-input\/index.ts"}]}