{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ghost-text",
  "title": "Ghost Text",
  "description": "An input or textarea that shows a suggested completion right after the caret. Tab takes it, Escape puts it away, and typing the suggested letters keeps it in place.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/ghost-text/ghost-text.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype FieldElement = HTMLInputElement | HTMLTextAreaElement\n\nexport type GhostTextProps = Omit<\n  React.HTMLAttributes<FieldElement>,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> & {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  /** Returns the text to append. An empty string means no suggestion. */\n  getSuggestion: (text: string, signal: AbortSignal) => string | Promise<string>\n  /** Milliseconds of quiet typing before a suggestion is asked for. */\n  debounce?: number\n  multiline?: boolean\n  rows?: number\n  name?: string\n  placeholder?: string\n  disabled?: boolean\n  readOnly?: boolean\n  onAccept?: (accepted: string) => void\n  onSuggestionError?: (error: unknown) => void\n  /** Read to screen readers as the field's description. */\n  hint?: string\n}\n\ntype Suggestion = { base: string; text: string }\n\nconst MIRRORED = [\n  \"box-sizing\",\n  \"font-family\",\n  \"font-size\",\n  \"font-style\",\n  \"font-weight\",\n  \"letter-spacing\",\n  \"line-height\",\n  \"padding-top\",\n  \"padding-right\",\n  \"padding-bottom\",\n  \"padding-left\",\n  \"border-top-width\",\n  \"border-right-width\",\n  \"border-bottom-width\",\n  \"border-left-width\",\n  \"tab-size\",\n  \"text-align\",\n  \"text-indent\",\n  \"text-transform\",\n  \"word-spacing\",\n] as const\n\n/**\n * What is still left to show once the person has typed past the point the\n * suggestion was made for. Typing a character the suggestion predicted uses it\n * up, so nothing has to be fetched or redrawn for it.\n */\nfunction remainderOf(suggestion: Suggestion | null, text: string) {\n  if (!suggestion || !text.startsWith(suggestion.base)) return \"\"\n\n  const typed = text.slice(suggestion.base.length)\n\n  return suggestion.text.startsWith(typed)\n    ? suggestion.text.slice(typed.length)\n    : \"\"\n}\n\n/** A completion that starts mid word is read with the start of that word. */\nfunction spoken(suggestion: Suggestion) {\n  const partial = /\\S*$/.exec(suggestion.base)?.[0] ?? \"\"\n  return (partial + suggestion.text).trim()\n}\n\nfunction firstWord(rest: string) {\n  return /^\\s*\\S+/.exec(rest)?.[0] ?? rest\n}\n\nexport const GhostText = React.forwardRef<FieldElement, GhostTextProps>(\n  function GhostText(\n    {\n      value,\n      defaultValue = \"\",\n      onValueChange,\n      getSuggestion,\n      debounce = 300,\n      multiline = false,\n      rows = 3,\n      disabled = false,\n      readOnly = false,\n      onAccept,\n      onSuggestionError,\n      hint = \"When a suggestion appears, press Tab to accept it or Escape to dismiss it.\",\n      className,\n      onBlur,\n      onFocus,\n      onKeyDown,\n      onScroll,\n      \"aria-describedby\": describedBy,\n      ...fieldProps\n    },\n    forwardedRef\n  ) {\n    const fieldRef = React.useRef<FieldElement | null>(null)\n    const mirrorRef = React.useRef<HTMLDivElement>(null)\n    const caret = React.useRef<number | null>(null)\n    const hintId = React.useId()\n\n    const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n    const [suggestion, setSuggestion] = React.useState<Suggestion | null>(null)\n    const [dismissedAt, setDismissedAt] = React.useState<string | null>(null)\n    const [focused, setFocused] = React.useState(false)\n    const [atEnd, setAtEnd] = React.useState(true)\n\n    const text = value ?? uncontrolled\n    const rest = remainderOf(suggestion, text)\n    const covered = rest.length > 0\n    const editable = !disabled && !readOnly\n    const shown = covered && focused && atEnd && editable\n\n    const latest = React.useRef({ getSuggestion, onSuggestionError })\n\n    React.useEffect(() => {\n      latest.current = { getSuggestion, onSuggestionError }\n    })\n\n    React.useEffect(() => {\n      if (!focused || !editable || covered) return\n      if (text.length === 0 || text === dismissedAt) return\n\n      const controller = new AbortController()\n      const timer = window.setTimeout(async () => {\n        try {\n          const next = await latest.current.getSuggestion(\n            text,\n            controller.signal\n          )\n          if (controller.signal.aborted) return\n          setSuggestion(next ? { base: text, text: next } : null)\n        } catch (error) {\n          if (controller.signal.aborted) return\n          latest.current.onSuggestionError?.(error)\n        }\n      }, debounce)\n\n      return () => {\n        window.clearTimeout(timer)\n        controller.abort()\n      }\n    }, [covered, debounce, dismissedAt, editable, focused, text])\n\n    const sync = React.useCallback(() => {\n      const field = fieldRef.current\n      const mirror = mirrorRef.current\n      if (!field || !mirror) return\n\n      const style = window.getComputedStyle(field)\n      for (const property of MIRRORED) {\n        mirror.style.setProperty(property, style.getPropertyValue(property))\n      }\n\n      const borderX =\n        Number.parseFloat(style.borderLeftWidth || \"0\") +\n        Number.parseFloat(style.borderRightWidth || \"0\")\n      const borderY =\n        Number.parseFloat(style.borderTopWidth || \"0\") +\n        Number.parseFloat(style.borderBottomWidth || \"0\")\n\n      // clientWidth leaves out a textarea's scrollbar, which is what the text\n      // actually wraps against.\n      mirror.style.width = `${field.clientWidth + borderX}px`\n      mirror.style.height = `${field.clientHeight + borderY}px`\n      mirror.scrollTop = field.scrollTop\n      mirror.scrollLeft = field.scrollLeft\n    }, [])\n\n    React.useLayoutEffect(() => {\n      if (shown) sync()\n    }, [shown, sync, text, rest])\n\n    React.useEffect(() => {\n      const field = fieldRef.current\n      if (!shown || !field || typeof ResizeObserver === \"undefined\") return\n\n      const observer = new ResizeObserver(sync)\n      observer.observe(field)\n      return () => observer.disconnect()\n    }, [shown, sync])\n\n    React.useLayoutEffect(() => {\n      const field = fieldRef.current\n      if (caret.current == null || !field) return\n\n      field.setSelectionRange(caret.current, caret.current)\n      caret.current = null\n    }, [text])\n\n    const setRefs = React.useCallback(\n      (node: FieldElement | null) => {\n        fieldRef.current = node\n        if (typeof forwardedRef === \"function\") forwardedRef(node)\n        else if (forwardedRef) forwardedRef.current = node\n      },\n      [forwardedRef]\n    )\n\n    const setText = (next: string) => {\n      if (value === undefined) setUncontrolled(next)\n      onValueChange?.(next)\n    }\n\n    const trackCaret = () => {\n      const field = fieldRef.current\n      if (!field) return\n\n      setAtEnd(\n        field.selectionStart === field.selectionEnd &&\n          field.selectionEnd === field.value.length\n      )\n    }\n\n    const accept = (part: string) => {\n      const next = text + part\n      caret.current = next.length\n      setText(next)\n      onAccept?.(part)\n    }\n\n    const handleKeyDown = (event: React.KeyboardEvent<FieldElement>) => {\n      onKeyDown?.(event)\n      if (event.defaultPrevented || !shown) return\n      if (event.nativeEvent.isComposing) return\n\n      const plain =\n        !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey\n\n      if (event.key === \"Tab\" && plain) {\n        event.preventDefault()\n        accept(rest)\n      } else if (\n        event.key === \"ArrowRight\" &&\n        (event.metaKey || event.ctrlKey) &&\n        !event.shiftKey &&\n        !event.altKey\n      ) {\n        event.preventDefault()\n        accept(firstWord(rest))\n      } else if (event.key === \"Escape\") {\n        // A suggestion is the innermost thing Escape can close, so the dialog\n        // or menu around the field keeps its own Escape for the next press.\n        event.preventDefault()\n        event.stopPropagation()\n        setSuggestion(null)\n        setDismissedAt(text)\n      }\n    }\n\n    const field = {\n      ...fieldProps,\n      ref: setRefs,\n      value: text,\n      disabled,\n      readOnly,\n      \"aria-autocomplete\": \"inline\" as const,\n      \"aria-describedby\": [describedBy, hintId].filter(Boolean).join(\" \"),\n      \"data-slot\": \"ghost-text-field\",\n      className: cn(\n        \"border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring block w-full rounded-md border px-3 py-2 text-sm leading-relaxed focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60\",\n        multiline && \"resize-y\",\n        className\n      ),\n      onChange: (event: React.ChangeEvent<FieldElement>) => {\n        setText(event.target.value)\n        trackCaret()\n      },\n      onSelect: trackCaret,\n      onKeyUp: trackCaret,\n      onPointerUp: trackCaret,\n      onKeyDown: handleKeyDown,\n      onFocus: (event: React.FocusEvent<FieldElement>) => {\n        onFocus?.(event)\n        setFocused(true)\n        trackCaret()\n      },\n      onBlur: (event: React.FocusEvent<FieldElement>) => {\n        onBlur?.(event)\n        setFocused(false)\n      },\n      onScroll: (event: React.UIEvent<FieldElement>) => {\n        onScroll?.(event)\n        if (shown) sync()\n      },\n    }\n\n    return (\n      <div\n        data-slot=\"ghost-text\"\n        data-state={shown ? \"suggesting\" : \"idle\"}\n        className=\"relative\"\n      >\n        {multiline ? <textarea rows={rows} {...field} /> : <input {...field} />}\n\n        {shown ? (\n          <div\n            ref={mirrorRef}\n            data-slot=\"ghost-text-mirror\"\n            className={cn(\n              \"pointer-events-none absolute top-0 left-0 overflow-hidden border-solid border-transparent transition-opacity duration-150 ease-out motion-reduce:transition-none starting:opacity-0\",\n              multiline ? \"break-words whitespace-pre-wrap\" : \"whitespace-pre\"\n            )}\n          >\n            <span aria-hidden=\"true\" className=\"text-transparent\">\n              {text}\n            </span>\n            <span\n              aria-hidden=\"true\"\n              data-slot=\"ghost-text-suggestion\"\n              className=\"text-muted-foreground\"\n            >\n              {rest}\n            </span>\n            {/* There is no Tab key on a touch keyboard, so the hint doubles\n                as the way to take the suggestion there. */}\n            <button\n              type=\"button\"\n              tabIndex={-1}\n              aria-label=\"Accept suggestion\"\n              onPointerDown={(event) => event.preventDefault()}\n              onClick={() => accept(rest)}\n              className=\"border-border bg-background text-muted-foreground hover:text-foreground pointer-events-auto relative ms-1.5 inline-flex h-[1.35em] items-center rounded border px-1 align-text-bottom font-sans text-[0.7em] leading-none font-medium transition-colors duration-150 after:absolute after:-inset-2 after:content-[''] motion-reduce:transition-none\"\n            >\n              Tab\n            </button>\n          </div>\n        ) : null}\n\n        <span id={hintId} className=\"sr-only\">\n          {hint}\n        </span>\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {shown && suggestion ? `Suggestion: ${spoken(suggestion)}` : \"\"}\n        </span>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "input",
    "form",
    "autocomplete",
    "agent"
  ],
  "type": "registry:ui"
}