{
  "name": "ai-autocomplete",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: AI Autocomplete\n * @version: 1.0.0\n * @date: 2026-10-16\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type ChangeEvent,\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AIAutocompleteProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Returns how the text could go on, or null for no suggestion. Called once\n   * typing pauses, only while the caret is at the end. Demo completer when\n   * omitted.\n   */\n  suggest?: (text: string) => string | null | Promise<string | null>;\n  /** How long typing has to pause before asking for a suggestion, in ms. */\n  delay?: number;\n  placeholder?: string;\n  /** Accessible name for the text box. */\n  label?: string;\n  className?: string;\n}\n\nconst WORD = /^\\s*\\S+\\s?/;\nconst WORDS = /\\s*\\S+\\s*/g;\n\n/** The next word of a suggestion, with the space around it. */\nfunction nextWord(ghost: string) {\n  return ghost.match(WORD)?.[0] ?? ghost;\n}\n\n/**\n * A text box that offers how your sentence could end, in grey after the\n * caret. Tab takes all of it, → takes one word, Esc waves it off, and\n * typing the same letters keeps it instead of asking again.\n */\nexport function AIAutocomplete({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  suggest = demoSuggest,\n  delay = 400,\n  placeholder = \"Start writing…\",\n  label = \"Message\",\n  className,\n}: AIAutocompleteProps) {\n  const [ownValue, setOwnValue] = useState(defaultValue);\n  const value = valueProp ?? ownValue;\n  const [ghost, setGhost] = useState(\"\");\n  // Bumped for each new suggestion so its words fade in once, not on every\n  // keystroke that types through them.\n  const [batch, setBatch] = useState(0);\n  const [atEnd, setAtEnd] = useState(true);\n  const areaRef = useRef<HTMLTextAreaElement>(null);\n  const request = useRef(0);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const setValue = useCallback(\n    (next: string) => {\n      setOwnValue(next);\n      onValueChange?.(next);\n    },\n    [onValueChange]\n  );\n\n  const ask = useCallback(\n    (text: string) => {\n      if (timer.current) {\n        clearTimeout(timer.current);\n      }\n      const id = ++request.current;\n      timer.current = setTimeout(async () => {\n        if (!text.trim()) {\n          return;\n        }\n        const result = await suggest(text);\n        // A reply to text that has since changed is thrown away.\n        if (id === request.current && result) {\n          setGhost(result);\n          setBatch((b) => b + 1);\n        }\n      }, delay);\n    },\n    [suggest, delay]\n  );\n\n  useEffect(\n    () => () => {\n      if (timer.current) {\n        clearTimeout(timer.current);\n      }\n    },\n    []\n  );\n\n  // The first suggestion for the starting text, once it's on screen.\n  const asked = useRef(false);\n  useEffect(() => {\n    if (!asked.current) {\n      asked.current = true;\n      ask(value);\n    }\n  }, [ask, value]);\n\n  const caretAtEnd = () => {\n    const area = areaRef.current;\n    return (\n      !!area &&\n      area.selectionStart === area.selectionEnd &&\n      area.selectionEnd === area.value.length\n    );\n  };\n\n  const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => {\n    const next = event.target.value;\n    const added = next.startsWith(value) ? next.slice(value.length) : null;\n    setValue(next);\n    const end = caretAtEnd();\n    setAtEnd(end);\n    // Typing what the suggestion says eats into it rather than replacing it.\n    if (end && added && ghost.startsWith(added)) {\n      const rest = ghost.slice(added.length);\n      setGhost(rest);\n      if (!rest) {\n        ask(next);\n      }\n      return;\n    }\n    request.current++;\n    setGhost(\"\");\n    if (end) {\n      ask(next);\n    }\n  };\n\n  const accept = (part: string) => {\n    const next = value + part;\n    setValue(next);\n    setGhost((g) => g.slice(part.length));\n    requestAnimationFrame(() => {\n      const area = areaRef.current;\n      if (area) {\n        area.focus();\n        area.setSelectionRange(next.length, next.length);\n      }\n    });\n    if (part.length >= ghost.length) {\n      ask(next);\n    }\n  };\n\n  const showGhost = ghost.length > 0 && atEnd;\n\n  const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n    if (!showGhost) {\n      return;\n    }\n    const plain = !(event.metaKey || event.ctrlKey || event.altKey);\n    if (event.key === \"Tab\" && plain && !event.shiftKey) {\n      event.preventDefault();\n      accept(ghost);\n    } else if (event.key === \"ArrowRight\" && plain && caretAtEnd()) {\n      event.preventDefault();\n      accept(nextWord(ghost));\n    } else if (event.key === \"Escape\") {\n      event.preventDefault();\n      request.current++;\n      setGhost(\"\");\n    }\n  };\n\n  // Moving the caret off the end hides the suggestion; back at the end, it\n  // returns. Clicks and arrow keys both land here.\n  const onSelect = () => setAtEnd(caretAtEnd());\n\n  const words = ghost.match(WORDS) ?? [];\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      <div className=\"relative\">\n        {/* The mirror: the same text, laid out identically under the text\n          box, with the suggestion after it. It also sets the height, so\n          the box grows with the suggestion and never scrolls. */}\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none min-h-32 whitespace-pre-wrap break-words px-4 py-3 text-[15px] leading-6\"\n        >\n          <span className=\"text-transparent\">{value || \"​\"}</span>\n          {value.endsWith(\"\\n\") && \"​\"}\n          {showGhost && (\n            <span className=\"text-zinc-400 dark:text-zinc-500\" key={batch}>\n              {words.map((word, i) => {\n                const last = i === words.length - 1;\n                return (\n                  <span\n                    // The Tab hint stays on the line of the last word.\n                    className={cn(\"fade-word\", last && \"whitespace-nowrap\")}\n                    // biome-ignore lint/suspicious/noArrayIndexKey: words of one suggestion, in order\n                    key={i}\n                    style={{ animationDelay: `${Math.min(i, 12) * 28}ms` }}\n                  >\n                    {last ? word.trimEnd() : word}\n                    {last && (\n                      <kbd className=\"ml-1.5 inline-flex h-5 translate-y-[-1px] items-center rounded border border-zinc-200 px-1 align-middle font-sans text-[10px] text-zinc-400 dark:border-zinc-800 dark:text-zinc-500\">\n                        Tab\n                      </kbd>\n                    )}\n                  </span>\n                );\n              })}\n            </span>\n          )}\n        </div>\n        <textarea\n          aria-label={label}\n          className=\"absolute inset-0 h-full w-full resize-none overflow-hidden bg-transparent px-4 py-3 text-[15px] text-zinc-900 leading-6 caret-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:caret-zinc-100 dark:placeholder:text-zinc-500\"\n          onChange={onChange}\n          onFocus={onSelect}\n          onKeyDown={onKeyDown}\n          onSelect={onSelect}\n          placeholder={placeholder}\n          ref={areaRef}\n          spellCheck={false}\n          value={value}\n        />\n      </div>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {showGhost ? `Suggestion: ${ghost}. Press Tab to accept.` : \"\"}\n      </p>\n      <style>{`\n        .fade-word {\n          animation: ai-autocomplete-in 180ms cubic-bezier(0.23, 1, 0.32, 1) both;\n        }\n        @keyframes ai-autocomplete-in {\n          from { opacity: 0; filter: blur(2px); }\n        }\n        @media (prefers-reduced-motion: reduce) {\n          .fade-word { animation-duration: 1ms; }\n        }\n      `}</style>\n      <AcceptBar\n        onAccept={() => accept(ghost)}\n        onWord={() => accept(nextWord(ghost))}\n        visible={showGhost}\n      />\n    </div>\n  );\n}\n\n/** Tap targets for touch screens, which have no Tab key. */\nfunction AcceptBar({\n  visible,\n  onAccept,\n  onWord,\n}: {\n  visible: boolean;\n  onAccept: () => void;\n  onWord: () => void;\n}) {\n  const keep = (event: { preventDefault: () => void }) =>\n    // Keep focus (and the caret) in the text box.\n    event.preventDefault();\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-1 px-3 pb-3 text-xs text-zinc-500 transition-opacity duration-150 dark:text-zinc-400\",\n        !visible && \"pointer-events-none opacity-0\"\n      )}\n    >\n      <button\n        className=\"flex items-center gap-1.5 rounded-md px-1.5 py-1 transition-colors duration-150 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\"\n        onClick={onAccept}\n        onMouseDown={keep}\n        tabIndex={-1}\n        type=\"button\"\n      >\n        <kbd className=\"rounded border border-zinc-200 px-1 font-sans text-[10px] dark:border-zinc-800\">\n          Tab\n        </kbd>\n        Accept\n      </button>\n      <button\n        className=\"flex items-center gap-1.5 rounded-md px-1.5 py-1 transition-colors duration-150 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\"\n        onClick={onWord}\n        onMouseDown={keep}\n        tabIndex={-1}\n        type=\"button\"\n      >\n        <kbd className=\"rounded border border-zinc-200 px-1 font-sans text-[10px] dark:border-zinc-800\">\n          →\n        </kbd>\n        Word\n      </button>\n      <span className=\"ml-1 hidden sm:inline\">\n        <kbd className=\"rounded border border-zinc-200 px-1 font-sans text-[10px] dark:border-zinc-800\">\n          Esc\n        </kbd>{\" \"}\n        Dismiss\n      </span>\n    </div>\n  );\n}\n\n// The reply the demo completer knows, sentence by sentence. A real app\n// would call a model here; this one finds where you are in the script.\nconst SCRIPT = [\n  \"Hi Maya,\\n\\n\",\n  \"Thanks for the update on the launch timeline. \",\n  \"Thursday works for me, so let's move the review to 3pm and keep the rest of the plan as it is. \",\n  \"I'll share the updated deck with the team before then. \",\n  \"Let me know if anything changes on your side.\\n\\n\",\n  \"Best,\\nSam\",\n];\nconst FULL = SCRIPT.join(\"\");\nconst TRAILING_SPACE = /\\s+$/;\n\nfunction demoSuggest(text: string): Promise<string | null> {\n  return new Promise((resolve) => {\n    setTimeout(\n      () => {\n        // Match the end of what's written against the script, loosely.\n        const tail = text.slice(-24).toLowerCase();\n        const at = FULL.toLowerCase().lastIndexOf(tail);\n        if (!tail.trim() || at < 0) {\n          resolve(null);\n          return;\n        }\n        const from = at + tail.length;\n        // Offer up to the end of the current sentence.\n        let end = from;\n        let sum = 0;\n        for (const part of SCRIPT) {\n          sum += part.length;\n          if (sum > from) {\n            end = sum;\n            break;\n          }\n        }\n        const rest = FULL.slice(from, end).replace(TRAILING_SPACE, (s) =>\n          s.includes(\"\\n\") ? s : \" \"\n        );\n        resolve(rest.trim() ? rest : null);\n      },\n      180 + Math.random() * 220\n    );\n  });\n}\n\n/** An email reply with an assistant finishing your sentences. */\nexport default function AIAutocompleteDemo({\n  className,\n}: {\n  className?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-lg overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950\",\n        className\n      )}\n    >\n      <div className=\"divide-y divide-zinc-100 border-zinc-200 border-b text-sm dark:divide-zinc-900 dark:border-zinc-800\">\n        <p className=\"flex gap-3 px-4 py-2.5\">\n          <span className=\"w-14 text-zinc-400 dark:text-zinc-500\">To</span>\n          <span className=\"text-zinc-900 dark:text-zinc-100\">Maya Chen</span>\n        </p>\n        <p className=\"flex gap-3 px-4 py-2.5\">\n          <span className=\"w-14 text-zinc-400 dark:text-zinc-500\">Subject</span>\n          <span className=\"truncate text-zinc-900 dark:text-zinc-100\">\n            Re: Launch review\n          </span>\n        </p>\n      </div>\n      <AIAutocomplete\n        defaultValue={\"Hi Maya,\\n\\nThanks for the update on the \"}\n        label=\"Reply to Maya\"\n      />\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/ai-autocomplete.tsx",
      "target": "components/kokonutui/ai-autocomplete.tsx"
    }
  ]
}