{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-area",
  "title": "Prompt Area",
  "description": "A contentEditable rich text input with trigger-based chips (@mentions, /commands, #tags), inline markdown, undo/redo, and list auto-formatting.",
  "registryDependencies": [],
  "files": [
    {
      "path": "packages/prompt-area/src/prompt-area/prompt-area.tsx",
      "content": "'use client'\n\nimport { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'\nimport { cn } from '@/lib/utils'\nimport type { PromptAreaProps, PromptAreaHandle } from './types'\nimport { usePromptArea } from './use-prompt-area'\nimport { BLUR_DELAY_MS } from './use-prompt-area-events'\nimport { TriggerPopover } from './trigger-popover'\nimport { AnimatedPlaceholder } from './animated-placeholder'\nimport { ImageStrip } from './image-strip'\nimport { FileStrip } from './file-strip'\n\n/**\n * PromptArea - A lightweight rich text input with trigger support.\n *\n * Uses contentEditable to support inline chips (immutable pills) for\n * mentions, commands, and other triggered tokens. Each trigger character\n * can be configured to show a dropdown or fire a callback.\n *\n * @example\n * ```tsx\n * const [segments, setSegments] = useState<Segment[]>([])\n *\n * <PromptArea\n *   value={segments}\n *   onChange={setSegments}\n *   triggers={[\n *     { char: '@', position: 'any', mode: 'dropdown', onSearch: searchUsers },\n *     { char: '/', position: 'start', mode: 'dropdown', onSearch: searchCommands },\n *     { char: '#', position: 'any', mode: 'dropdown', onSearch: searchTags },\n *   ]}\n *   placeholder=\"Type a message...\"\n *   onSubmit={handleSubmit}\n *   autoGrow\n * />\n * ```\n */\nexport function PromptArea({\n  value,\n  onChange,\n  triggers,\n  placeholder,\n  className,\n  disabled = false,\n  markdown,\n  markdownHeadings,\n  normalizeBullets,\n  onSubmit,\n  onEscape,\n  onChipClick,\n  onChipAdd,\n  onChipDelete,\n  onLinkClick,\n  onPaste,\n  onUndo,\n  onRedo,\n  minHeight = 80,\n  maxHeight,\n  autoFocus = false,\n  autoGrow = false,\n  'aria-label': ariaLabel,\n  'data-test-id': dataTestId,\n  images = [],\n  imagePosition = 'above',\n  onImagePaste,\n  onImageRemove,\n  onImageClick,\n  files = [],\n  filePosition = 'above',\n  onFileRemove,\n  onFileClick,\n  onKeyDown,\n  onBlur,\n  onRawPaste,\n  submitOnEnter,\n  spellCheck,\n  maxLength,\n  'aria-describedby': ariaDescribedBy,\n  ref,\n}: PromptAreaProps & { ref?: React.Ref<PromptAreaHandle> }) {\n  const {\n    editorRef,\n    activeTrigger,\n    suggestions,\n    suggestionsLoading,\n    suggestionsError,\n    selectedSuggestionIndex,\n    handleInput,\n    handleKeyDown,\n    handleClick,\n    handleMouseDown,\n    selectSuggestion,\n    dismissTrigger,\n    handle,\n    triggerRect,\n    eventHandlers,\n  } = usePromptArea({\n    value,\n    onChange,\n    triggers,\n    disabled,\n    onSubmit,\n    onEscape,\n    onChipClick,\n    onChipAdd,\n    onChipDelete,\n    onLinkClick,\n    onPaste,\n    onRawPaste,\n    onUndo,\n    onRedo,\n    onImagePaste,\n    markdown,\n    markdownHeadings,\n    normalizeBullets,\n    submitOnEnter,\n    maxLength,\n  })\n\n  // Expose imperative handle via ref\n  useImperativeHandle(ref, () => handle, [handle])\n\n  // Auto-focus on mount\n  useEffect(() => {\n    if (autoFocus) {\n      editorRef.current?.focus()\n    }\n  }, [autoFocus, editorRef])\n\n  // -----------------------------------------------------------------------\n  // Auto-grow: expand on focus/input, shrink on blur\n  // -----------------------------------------------------------------------\n\n  const [isFocused, setIsFocused] = useState(false)\n  const [editorHeight, setEditorHeight] = useState<number | undefined>(undefined)\n\n  const syncHeight = useCallback(() => {\n    const el = editorRef.current\n    if (!el) return\n    // Temporarily set height to auto so scrollHeight reflects true content height\n    el.style.height = 'auto'\n    const contentHeight = el.scrollHeight\n    el.style.height = `${contentHeight}px`\n    setEditorHeight(contentHeight)\n  }, [editorRef])\n\n  // One height measurement per frame: the input handler and the value-change\n  // effect both request a sync on every keystroke, and each measurement is a\n  // forced reflow of the whole editor (the height:auto dance invalidates\n  // layout, then scrollHeight reads it). Coalescing them into a single\n  // pre-paint rAF callback halves the per-keystroke reflows without a\n  // visible frame at a stale height.\n  const heightRaf = useRef<number | null>(null)\n  const scheduleSyncHeight = useCallback(() => {\n    if (heightRaf.current !== null) return\n    heightRaf.current = requestAnimationFrame(() => {\n      heightRaf.current = null\n      syncHeight()\n    })\n  }, [syncHeight])\n\n  useEffect(() => {\n    return () => {\n      if (heightRaf.current !== null) {\n        cancelAnimationFrame(heightRaf.current)\n        // Clear the guard too: StrictMode's dev double-mount runs this\n        // cleanup and re-runs the effect on the same component instance, and\n        // a stale id would latch scheduleSyncHeight closed forever.\n        heightRaf.current = null\n      }\n    }\n  }, [])\n\n  const handleFocus = useCallback(() => {\n    if (!autoGrow) return\n    setIsFocused(true)\n    syncHeight()\n  }, [autoGrow, syncHeight])\n\n  const handleBlurWithShrink = useCallback(() => {\n    eventHandlers.onBlur()\n    if (!autoGrow) return\n    setTimeout(() => {\n      const editor = editorRef.current\n      if (!editor) return\n      // Only shrink if focus truly left the component\n      const activeEl = document.activeElement\n      if (activeEl && editor.parentElement?.contains(activeEl)) return\n      setIsFocused(false)\n      setEditorHeight(undefined)\n    }, BLUR_DELAY_MS)\n  }, [eventHandlers, autoGrow, editorRef])\n\n  const handleInputWithGrow = useCallback(() => {\n    handleInput()\n    if (autoGrow && isFocused) {\n      scheduleSyncHeight()\n    }\n  }, [handleInput, autoGrow, isFocused, scheduleSyncHeight])\n\n  // Re-measure on value changes (chip insertion, undo/redo, programmatic updates)\n  useEffect(() => {\n    if (autoGrow && isFocused) {\n      scheduleSyncHeight()\n    }\n  }, [value, autoGrow, isFocused, scheduleSyncHeight])\n\n  // -----------------------------------------------------------------------\n  // Overflow indicator: detect when collapsed content is clipped\n  // -----------------------------------------------------------------------\n\n  const [hasOverflow, setHasOverflow] = useState(false)\n  const overflowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  useEffect(() => {\n    if (!autoGrow) return\n\n    const checkOverflow = () => {\n      if (isFocused) {\n        setHasOverflow(false)\n        return\n      }\n      const el = editorRef.current\n      if (!el) return\n      setHasOverflow(el.scrollHeight > el.clientHeight)\n    }\n\n    // Delay the check so the CSS height transition (150ms) finishes first;\n    // on initial mount there is no transition so the check still runs quickly.\n    const delay = isFocused ? 0 : 160\n    overflowTimerRef.current = setTimeout(checkOverflow, delay)\n    return () => {\n      if (overflowTimerRef.current !== null) {\n        clearTimeout(overflowTimerRef.current)\n      }\n    }\n  }, [autoGrow, isFocused, value, editorRef])\n\n  // -----------------------------------------------------------------------\n  // Compute editor style\n  // -----------------------------------------------------------------------\n\n  const editorStyle = useMemo((): React.CSSProperties => {\n    if (!autoGrow) {\n      const style: React.CSSProperties = { minHeight: `${minHeight}px` }\n      if (maxHeight) {\n        style.maxHeight = `${maxHeight}px`\n        style.overflowY = 'auto'\n      }\n      return style\n    }\n    return {\n      height: isFocused && editorHeight ? `${editorHeight}px` : `${minHeight}px`,\n      minHeight: `${minHeight}px`,\n      // Respect an explicit maxHeight; otherwise fall back to a viewport-relative\n      // cap so the editor never grows past the screen.\n      maxHeight: maxHeight ? `${maxHeight}px` : '70dvh',\n      overflowY: isFocused ? 'auto' : 'hidden',\n      // `min-height` is eased so consumers that animate it (e.g. the compact\n      // prompt area's collapse/expand) morph smoothly instead of snapping.\n      transition: 'height 150ms ease-out, min-height 240ms cubic-bezier(0.33, 1, 0.68, 1)',\n    }\n  }, [autoGrow, minHeight, maxHeight, isFocused, editorHeight])\n\n  // Run a consumer's onKeyDown first; if it calls preventDefault, skip all of\n  // PromptArea's built-in key handling (submit, trigger nav, etc.).\n  const handleKeyDownCombined = useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      onKeyDown?.(e)\n      if (e.defaultPrevented) return\n      handleKeyDown(e)\n    },\n    [onKeyDown, handleKeyDown],\n  )\n\n  // Forward blur to the consumer (with relatedTarget) alongside the internal\n  // trigger-dismiss / auto-grow-shrink handling.\n  const handleBlurCombined = useCallback(\n    (e: React.FocusEvent<HTMLDivElement>) => {\n      onBlur?.(e)\n      // handleBlurWithShrink already calls eventHandlers.onBlur() and only\n      // shrinks when autoGrow is on, so it covers both modes.\n      handleBlurWithShrink()\n    },\n    [onBlur, handleBlurWithShrink],\n  )\n\n  const isEmpty =\n    value.length === 0 || (value.length === 1 && value[0].type === 'text' && value[0].text === '')\n\n  const imageStrip =\n    images.length > 0 ? (\n      <ImageStrip\n        images={images}\n        onRemove={onImageRemove}\n        onClick={onImageClick}\n        className={imagePosition === 'above' ? 'pb-2' : 'pt-2'}\n      />\n    ) : null\n\n  const fileStrip =\n    files.length > 0 ? (\n      <FileStrip\n        files={files}\n        onRemove={onFileRemove}\n        onClick={onFileClick}\n        className={filePosition === 'above' ? 'pb-2' : 'pt-2'}\n      />\n    ) : null\n\n  // Typography (font-size/line-height) lives on the container, not the editor, so\n  // it cascades to the editor AND the placeholder overlays — and a consumer can\n  // override all three at once via `className` (e.g. `text-base leading-6`).\n  return (\n    <div className={cn('prompt-area-container relative text-sm leading-relaxed', className)}>\n      {imagePosition === 'above' && imageStrip}\n      {filePosition === 'above' && fileStrip}\n\n      {/* Editor + placeholder wrapper */}\n      <div className=\"relative\">\n        <div\n          ref={editorRef}\n          contentEditable={!disabled}\n          suppressContentEditableWarning\n          role=\"textbox\"\n          aria-label={ariaLabel ?? 'Text input'}\n          aria-multiline=\"true\"\n          aria-disabled={disabled || undefined}\n          aria-describedby={ariaDescribedBy}\n          data-test-id={dataTestId}\n          spellCheck={spellCheck}\n          className={cn(\n            'prompt-area-editor',\n            'w-full min-w-0 break-words whitespace-pre-wrap outline-none',\n            disabled && 'cursor-not-allowed opacity-50',\n          )}\n          style={editorStyle}\n          onFocus={handleFocus}\n          onInput={autoGrow ? handleInputWithGrow : handleInput}\n          onKeyDown={handleKeyDownCombined}\n          onMouseDown={handleMouseDown}\n          onClick={handleClick}\n          onPaste={eventHandlers.onPaste}\n          onCopy={eventHandlers.onCopy}\n          onCut={eventHandlers.onCut}\n          onDrop={eventHandlers.onDrop}\n          onDragOver={eventHandlers.onDragOver}\n          onCompositionStart={eventHandlers.onCompositionStart}\n          onCompositionEnd={eventHandlers.onCompositionEnd}\n          onBlur={handleBlurCombined}\n        />\n\n        {/* Overflow gradient indicator – visible when auto-grow is collapsed and content is clipped */}\n        {autoGrow && hasOverflow && !isFocused && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-auto absolute right-0 bottom-0 left-0 cursor-pointer\"\n            style={{ height: '32px' }}\n            onClick={() => editorRef.current?.focus()}>\n            <div\n              className=\"h-full w-full\"\n              style={{\n                background:\n                  'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--prompt-area-surface, var(--background)) 80%, transparent), var(--prompt-area-surface, var(--background)))',\n              }}\n            />\n          </div>\n        )}\n\n        {/* Placeholder overlay */}\n        {isEmpty &&\n          placeholder &&\n          (Array.isArray(placeholder) ? (\n            <AnimatedPlaceholder texts={placeholder} />\n          ) : (\n            <div\n              className=\"pointer-events-none absolute top-0 left-0 select-none\"\n              style={{ color: 'var(--prompt-area-placeholder, var(--muted-foreground))' }}\n              aria-hidden=\"true\">\n              {placeholder}\n            </div>\n          ))}\n      </div>\n\n      {filePosition === 'below' && fileStrip}\n      {imagePosition === 'below' && imageStrip}\n\n      {/* Trigger suggestion popover */}\n      {activeTrigger && activeTrigger.config.mode === 'dropdown' && (\n        <TriggerPopover\n          suggestions={suggestions}\n          loading={suggestionsLoading}\n          error={suggestionsError}\n          emptyMessage={activeTrigger.config.emptyMessage}\n          selectedIndex={selectedSuggestionIndex}\n          onSelect={selectSuggestion}\n          onDismiss={dismissTrigger}\n          triggerRect={triggerRect}\n          triggerChar={activeTrigger.config.char}\n        />\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/trigger-popover.tsx",
      "content": "'use client'\n\nimport { useEffect, useRef } from 'react'\nimport { cn } from '@/lib/utils'\nimport type { TriggerSuggestion } from './types'\n\ntype TriggerPopoverProps = {\n  suggestions: TriggerSuggestion[]\n  loading: boolean\n  error?: string | null\n  emptyMessage?: string\n  selectedIndex: number\n  onSelect: (suggestion: TriggerSuggestion) => void\n  onDismiss: () => void\n  triggerRect: DOMRect | null\n  triggerChar: string\n}\n\n// Single source of truth for the popover height: drives both the flip-above\n// calculation and the maxHeight style.\nconst POPOVER_MAX_HEIGHT = 240\n\n/**\n * Floating popover that displays trigger suggestions.\n * Positioned relative to the trigger character location in the editor.\n */\nexport function TriggerPopover({\n  suggestions,\n  loading,\n  error,\n  emptyMessage,\n  selectedIndex,\n  onSelect,\n  onDismiss,\n  triggerRect,\n  triggerChar,\n}: TriggerPopoverProps) {\n  const popoverRef = useRef<HTMLDivElement>(null)\n  const selectedRef = useRef<HTMLButtonElement>(null)\n\n  // Scroll selected item into view\n  useEffect(() => {\n    selectedRef.current?.scrollIntoView({ block: 'nearest' })\n  }, [selectedIndex])\n\n  // Click outside to dismiss\n  useEffect(() => {\n    const handleClickOutside = (e: MouseEvent) => {\n      const target = e.target\n      if (popoverRef.current && target instanceof Node && !popoverRef.current.contains(target)) {\n        onDismiss()\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => document.removeEventListener('mousedown', handleClickOutside)\n  }, [onDismiss])\n\n  if (!triggerRect) return null\n  if (suggestions.length === 0 && !loading && !error && !emptyMessage) return null\n\n  // Position the popover relative to the trigger character, clamped to the\n  // viewport. Flip above the trigger when there isn't enough room below, so the\n  // suggestion list stays on-screen near the bottom edge.\n  const popoverMaxWidth = Math.min(320, window.innerWidth - 16)\n  const left = Math.min(triggerRect.left, window.innerWidth - popoverMaxWidth - 8)\n  const spaceBelow = window.innerHeight - triggerRect.bottom\n  const positionAbove = spaceBelow < POPOVER_MAX_HEIGHT && triggerRect.top > spaceBelow\n  const style: React.CSSProperties = {\n    position: 'fixed',\n    left: `${Math.max(8, left)}px`,\n    zIndex: 50,\n    maxWidth: `${popoverMaxWidth}px`,\n    maxHeight: `${POPOVER_MAX_HEIGHT}px`,\n    ...(positionAbove\n      ? { bottom: `${window.innerHeight - triggerRect.top + 4}px` }\n      : { top: `${triggerRect.bottom + 4}px` }),\n  }\n\n  return (\n    <div\n      ref={popoverRef}\n      className={cn(\n        'min-w-[200px] overflow-y-auto',\n        'bg-popover rounded-xl border p-2 shadow-md',\n        'animate-in fade-in-0 zoom-in-95',\n      )}\n      style={style}\n      role=\"listbox\"\n      aria-label={`${triggerChar} suggestions`}>\n      {loading ? (\n        <div\n          role=\"option\"\n          aria-selected={false}\n          className=\"text-muted-foreground px-3 py-2 text-sm\">\n          Loading suggestions...\n        </div>\n      ) : error ? (\n        <div role=\"option\" aria-selected={false} className=\"text-destructive px-3 py-2 text-sm\">\n          {error}\n        </div>\n      ) : suggestions.length === 0 && emptyMessage ? (\n        <div\n          role=\"option\"\n          aria-selected={false}\n          className=\"text-muted-foreground px-3 py-2 text-sm\">\n          {emptyMessage}\n        </div>\n      ) : (\n        suggestions.map((suggestion, index) => (\n          <button\n            key={suggestion.value}\n            ref={index === selectedIndex ? selectedRef : undefined}\n            type=\"button\"\n            role=\"option\"\n            aria-selected={index === selectedIndex}\n            className={cn(\n              'text-foreground flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm',\n              'hover:bg-accent cursor-pointer transition-colors',\n              index === selectedIndex && 'bg-accent',\n            )}\n            onMouseDown={(e) => {\n              e.preventDefault() // Prevent blur on the editor\n              onSelect(suggestion)\n            }}>\n            {suggestion.icon && <span className=\"mt-0.5 shrink-0\">{suggestion.icon}</span>}\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"truncate font-medium\">{suggestion.label}</div>\n              {suggestion.description && (\n                <div className=\"text-muted-foreground truncate text-xs\">\n                  {suggestion.description}\n                </div>\n              )}\n            </div>\n          </button>\n        ))\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/use-prompt-area.ts",
      "content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport type {\n  Segment,\n  TriggerConfig,\n  ActiveTrigger,\n  TriggerSuggestion,\n  ChipSegment,\n  PromptAreaHandle,\n} from './types'\nimport {\n  detectActiveTrigger,\n  isValidTriggerPosition,\n  segmentsToPlainText,\n  plainTextToSegments,\n  segmentsEqual,\n  resolveChip,\n  removeChipAtIndex,\n  revertChipAtIndex,\n  replaceTextRange,\n  toggleMarkdownWrap,\n  truncateSegmentsToLength,\n} from './prompt-area-engine'\nimport {\n  getListContext,\n  autoFormatListPrefix,\n  insertListContinuation,\n  indentListItem,\n  outdentListItem,\n  removeListPrefix,\n  normalizeListPrefixes,\n  renumberOrderedListSegments,\n  remapOffset,\n  hasOrderedListRun,\n  isPlainLineEdit,\n} from './prompt-area-list-ops'\nimport {\n  isHTMLElement,\n  isChipElement,\n  isLinkElement,\n  isBRElement,\n  isTextNode,\n  chipNodeTextLength,\n  chipNodeToSegment,\n  getChipAutoResolved,\n  getDirectChildContaining,\n  indexOfChildNode,\n  domChildIndexToSegmentIndex,\n  normalizeEditorDOM,\n  decorateEditor,\n  stripDecorationsInRange,\n  safeJsonStringify,\n  getSelectionRange,\n} from './dom-helpers'\nimport type { DecorateBounds } from './dom-helpers'\nimport {\n  getCursorOffset,\n  setCursorAtOffset,\n  createRangeAtOffset,\n  getSelectionOffsets,\n  setSelectionAtOffsets,\n  caretLineRect,\n  findDOMPosition,\n  getTextOffsetAtPoint,\n  nodeTextContribution,\n} from './cursor-helpers'\nimport { usePromptAreaEvents } from './use-prompt-area-events'\nimport { useTriggerSearch } from './use-trigger-search'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype UsePromptAreaOptions = {\n  value: Segment[]\n  onChange: (segments: Segment[]) => void\n  triggers?: TriggerConfig[]\n  disabled?: boolean\n  onSubmit?: (segments: Segment[]) => void\n  onEscape?: () => void\n  onChipClick?: (chip: ChipSegment) => void\n  onChipAdd?: (chip: ChipSegment) => void\n  onChipDelete?: (chip: ChipSegment) => void\n  onLinkClick?: (url: string) => void\n  onPaste?: (data: { segments: Segment[]; source: 'internal' | 'external' }) => void\n  onRawPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void\n  onUndo?: (segments: Segment[]) => void\n  onRedo?: (segments: Segment[]) => void\n  onImagePaste?: (file: File) => void\n  markdown?: boolean\n  markdownHeadings?: boolean\n  normalizeBullets?: boolean\n  submitOnEnter?: boolean\n  maxLength?: number\n}\n\ntype UsePromptAreaReturn = {\n  editorRef: React.RefObject<HTMLDivElement | null>\n  activeTrigger: ActiveTrigger | null\n  suggestions: TriggerSuggestion[]\n  suggestionsLoading: boolean\n  suggestionsError: string | null\n  selectedSuggestionIndex: number\n  handleInput: () => void\n  handleKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void\n  handleClick: (e: React.MouseEvent<HTMLDivElement>) => void\n  handleMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void\n  selectSuggestion: (suggestion: TriggerSuggestion) => void\n  dismissTrigger: () => void\n  handle: PromptAreaHandle\n  triggerRect: DOMRect | null\n  eventHandlers: {\n    onPaste: (e: React.ClipboardEvent<HTMLDivElement>) => void\n    onCopy: (e: React.ClipboardEvent<HTMLDivElement>) => void\n    onCut: (e: React.ClipboardEvent<HTMLDivElement>) => void\n    onDrop: (e: React.DragEvent<HTMLDivElement>) => void\n    onDragOver: (e: React.DragEvent<HTMLDivElement>) => void\n    onCompositionStart: () => void\n    onCompositionEnd: () => void\n    onBlur: () => void\n  }\n}\n\n/** Debounce interval for grouping typed characters into a single undo snapshot */\nconst UNDO_DEBOUNCE_MS = 300\n\n// ---------------------------------------------------------------------------\n// Single-pass editor scan (typing hot path)\n// ---------------------------------------------------------------------------\n\nexport type EditorScan = {\n  segments: Segment[]\n  plainText: string\n  /**\n   * A direct child the scan cannot model: a block wrapper (div/p) or an\n   * unknown inline element. Those change the plain text when unwrapped, so\n   * the caller must fall back to `normalizeEditorDOM` + `readSegmentsFromDOM`.\n   */\n  sawForeignElement: boolean\n  /**\n   * Some text run contains a literal newline. Our own render paths only ever\n   * represent newlines as `<br>`, so this flags DOM states where `<br>`-based\n   * line scoping cannot be trusted.\n   */\n  sawNewlineInText: boolean\n  /**\n   * Plain-text offset of the caret, when a `selection` was handed to the scan\n   * and it resolves inside the editor — otherwise null. Identical to what\n   * `getCursorOffset(editor)` returns for the same DOM and selection; folding\n   * it into this pass is what keeps a keystroke from walking the document\n   * twice.\n   */\n  cursorOffset: number | null\n}\n\n/** A DOM boundary point for {@link scanEditorDOM} to resolve into an offset. */\nexport type ScanSelection = { node: Node; offset: number }\n\n/**\n * Offset of `point` from the start of the editor direct child `child` that\n * holds it, under getTextOffsetAtPoint's rules: a chip is atomic (a boundary\n * anywhere inside counts the whole chip), and a non-HTML subtree (svg,\n * MathML) maps to its own start because the root walk never descends into it.\n */\nfunction caretOffsetWithinChild(child: Node, point: ScanSelection): number {\n  if (isChipElement(child)) return chipNodeTextLength(child)\n  if (isTextNode(child) || isHTMLElement(child)) {\n    return getTextOffsetAtPoint(child, point.node, point.offset) ?? 0\n  }\n  return 0\n}\n\n/**\n * Reads the editor's direct children into the segment model in one pass,\n * producing exactly what `normalizeEditorDOM(editor)` followed by\n * `readSegmentsFromDOM()` produces — without mutating the DOM and without\n * a second serialization for the plain text.\n *\n * Decoration elements (the markdown/bullet/indent/heading `<span data-md>`s\n * and URL `<a data-url>`s) contribute their textContent to the surrounding\n * text run, mirroring how `normalizeEditorDOM` inlines them to text nodes and\n * `editor.normalize()` merges the results. Chips and `<br>`s break runs the\n * same way real elements break `editor.normalize()`'s merging.\n */\nexport function scanEditorDOM(editor: HTMLElement, selection?: ScanSelection | null): EditorScan {\n  const segments: Segment[] = []\n  let plainText = ''\n  let buffer = ''\n  let hasRealContent = false\n  let hasSentinel = false\n  let sawForeignElement = false\n  let sawNewlineInText = false\n\n  // Caret resolution, folded into the same walk. `consumed` is the offset\n  // length of everything already passed, counted by nodeTextContribution's\n  // rules (which getTextOffsetAtPoint uses for the prefix) rather than by\n  // plainText's — the two differ only on a malformed chip, and matching the\n  // offset rules is what keeps this identical to getCursorOffset.\n  let cursorOffset: number | null = null\n  let consumed = 0\n  // The direct child holding the caret, or — when the selection is anchored on\n  // the editor itself — the child index the caret sits before.\n  let caretDirect: Node | null = null\n  let caretChildIndex = -1\n  if (selection && editor.contains(selection.node)) {\n    if (selection.node === editor) {\n      caretChildIndex = Math.min(selection.offset, editor.childNodes.length)\n    } else {\n      caretDirect = getDirectChildContaining(editor, selection.node)\n    }\n  }\n\n  const flushBuffer = (): void => {\n    if (!buffer) return\n    segments.push({ type: 'text', text: buffer })\n    plainText += buffer\n    hasRealContent = true\n    buffer = ''\n  }\n\n  let childIndex = 0\n  for (let node = editor.firstChild; node !== null; node = node.nextSibling, childIndex++) {\n    if (childIndex === caretChildIndex) cursorOffset = consumed\n    if (selection && node === caretDirect) {\n      cursorOffset = consumed + caretOffsetWithinChild(node, selection)\n    }\n    // One counting rule for the caret prefix, shared with getCursorOffset's\n    // walk, so the two can never drift apart per node type.\n    consumed += nodeTextContribution(node)\n\n    if (isTextNode(node)) {\n      const text = node.data\n      if (text) {\n        if (text.includes('\\n')) sawNewlineInText = true\n        buffer += text\n      }\n    } else if (isChipElement(node)) {\n      // A chip breaks the text run even when malformed (chipNodeToSegment\n      // null): normalize skips chip elements, so the element still separates\n      // its neighboring text nodes.\n      flushBuffer()\n      const chip = chipNodeToSegment(node)\n      if (chip) {\n        segments.push(chip)\n        plainText += `${chip.trigger}${chip.displayText}`\n        hasRealContent = true\n      }\n    } else if (isBRElement(node)) {\n      flushBuffer()\n      if (node.getAttribute('data-sentinel')) {\n        hasSentinel = true\n      } else {\n        segments.push({ type: 'text', text: '\\n' })\n        plainText += '\\n'\n      }\n    } else if (\n      isHTMLElement(node) &&\n      ((node.tagName === 'SPAN' && node.hasAttribute('data-md')) || isLinkElement(node))\n    ) {\n      const text = node.textContent ?? ''\n      if (text) {\n        if (text.includes('\\n')) sawNewlineInText = true\n        buffer += text\n      }\n    } else {\n      sawForeignElement = true\n    }\n  }\n  if (childIndex === caretChildIndex) cursorOffset = consumed\n  flushBuffer()\n\n  // Same emptiness rule as readSegmentsFromDOM: without real content or our\n  // sentinel, any <br>s present are the browser's filler for an emptied\n  // editor and must not read back as newline content.\n  if (!hasRealContent && !hasSentinel) {\n    return { segments: [], plainText: '', sawForeignElement, sawNewlineInText, cursorOffset }\n  }\n\n  return { segments, plainText, sawForeignElement, sawNewlineInText, cursorOffset }\n}\n\n/**\n * The `<br>`-delimited line around a caret boundary point, as exclusive\n * {@link DecorateBounds} over the editor's flat child list. This is the range\n * a native single-caret edit can have touched: typing lands in one line, and\n * a deletion that merged lines leaves all mutated content on the caret's\n * single merged line. Returns null when the container isn't anchored in the\n * editor's direct-child structure.\n */\nexport function findLineBounds(\n  editor: HTMLElement,\n  container: Node,\n  offset: number,\n): DecorateBounds | null {\n  let leftFrom: Node | null\n  let rightFrom: Node | null\n\n  if (container === editor) {\n    const index = Math.min(offset, editor.childNodes.length)\n    leftFrom = index > 0 ? editor.childNodes[index - 1] : null\n    rightFrom = index < editor.childNodes.length ? editor.childNodes[index] : null\n  } else {\n    const direct = getDirectChildContaining(editor, container)\n    if (!direct) return null\n    leftFrom = direct.previousSibling\n    rightFrom = direct\n  }\n\n  let after: Node | null = null\n  for (let node = leftFrom; node; node = node.previousSibling) {\n    if (isBRElement(node)) {\n      after = node\n      break\n    }\n  }\n\n  let before: Node | null = null\n  for (let node = rightFrom; node; node = node.nextSibling) {\n    if (isBRElement(node)) {\n      before = node\n      break\n    }\n  }\n\n  return { after, before }\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\nexport function usePromptArea({\n  value,\n  onChange,\n  triggers = [],\n  disabled = false,\n  onSubmit,\n  onEscape,\n  onChipClick,\n  onChipAdd,\n  onChipDelete,\n  onLinkClick,\n  onPaste,\n  onRawPaste,\n  onUndo,\n  onRedo,\n  onImagePaste,\n  markdown: markdownEnabled = true,\n  markdownHeadings: headingsEnabled = false,\n  normalizeBullets = true,\n  submitOnEnter = true,\n  maxLength,\n}: UsePromptAreaOptions): UsePromptAreaReturn {\n  const editorRef = useRef<HTMLDivElement | null>(null)\n  const [activeTrigger, setActiveTrigger] = useState<ActiveTrigger | null>(null)\n  const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0)\n  const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null)\n\n  // Chip whose dropdown was reopened via `reopenOnChipClick`. While set, the\n  // active dropdown edits this chip in place instead of resolving typed text.\n  // Cleared on dismiss and by any fresh trigger detection (typing).\n  //\n  // `segIndex` is the segment index at CLICK time (computed from the live DOM,\n  // so it's always accurate then) — deliberately not the DOM node itself,\n  // which can detach if `renderSegmentsToDOM` re-renders while the dropdown is\n  // open (external value update, undo/redo). At selection time we re-verify\n  // this index still holds the same chip and fall back to a trigger+value\n  // search if the model shifted underneath, instead of silently no-op'ing.\n  const editingChip = useRef<{ chip: ChipSegment; segIndex: number } | null>(null)\n\n  // The DOM node of the chip currently being edited via `reopenOnChipClick`,\n  // kept in lockstep with `editingChip`/`activeTrigger` (set when opened,\n  // cleared by `dismissTrigger`). Used only to answer \"is THIS exact chip\n  // element the one whose dropdown is open right now\" by reference identity —\n  // never by trigger+value, which can't distinguish two chips that happen to\n  // share the same value.\n  const openChipNode = useRef<HTMLElement | null>(null)\n\n  // Set by `handleMouseDown` to the chip node a mousedown landed on, but only\n  // when that node === `openChipNode.current` at that instant; read and\n  // cleared by the following `handleClick` to distinguish \"reopen\" from\n  // \"toggle closed\" for that one click. A real `onMouseDown` (bubble-phase,\n  // attached to the editor root) is used instead of piggybacking on\n  // `dismissTrigger` because DOM bubbling reaches the editor root before it\n  // reaches `document` (where TriggerPopover's outside-click dismiss listens),\n  // so this always observes `openChipNode` before that dismiss clears it —\n  // and unlike a `dismissTrigger`-driven flag, it is scoped to mousedowns on\n  // this exact node, so Escape/blur/an unrelated dismiss can never poison a\n  // later, unrelated click on the same chip.\n  const suppressReopenChip = useRef<HTMLElement | null>(null)\n\n  const {\n    suggestions,\n    suggestionsLoading,\n    suggestionsError,\n    search: runSearch,\n    reset: resetSearch,\n  } = useTriggerSearch()\n\n  // Guard against circular DOM <-> model syncs\n  const isSyncing = useRef(false)\n  const lastRenderedValue = useRef<Segment[]>([])\n\n  // The last model handleInput's ordered-list check settled (renumbered, or\n  // proven run-free), with its plain text. Keyed on the segments reference:\n  // any other path that writes lastRenderedValue (external value, paste,\n  // Enter/Tab, truncation) leaves a different array behind, which invalidates\n  // this without every such path having to know about it.\n  const settledListRun = useRef<{ segments: Segment[]; plainText: string } | null>(null)\n\n  // IME-composed input skips the decoration cycle entirely (mutating the DOM\n  // mid-composition breaks the composition), so the composed line is stale\n  // until a decorate reaches it. The baseline repaired it because the next\n  // keystroke re-decorated the whole document; with line scoping, this flag\n  // forces that next decorate to be a full pass wherever the caret is.\n  const imeDirty = useRef(false)\n\n  // Debounced undo: groups consecutive keystrokes into a single undo snapshot\n  const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const undoBaseState = useRef<Segment[] | null>(null)\n  // Undo base for the IME session in flight: the segments as they were when\n  // the composition began. Written at compositionstart (from the DOM) and by\n  // the value-sync effect when an external value lands mid-composition; read\n  // and cleared at compositionend, where it becomes the session's single undo\n  // entry and seeds postCompositionUndo below; dropped on blur, since an\n  // interrupted composition may never get its compositionend.\n  const compositionBaseState = useRef<Segment[] | null>(null)\n  // After compositionend, Chromium can fire one more non-composing input\n  // event carrying the committed mutation. That event belongs to the\n  // composition that just ended, not to a new typing session: this ref hands\n  // the composition's undo base (and whether compositionend already pushed\n  // it) to handleInput so the trailing event folds into the same single undo\n  // entry instead of seeding a second one. Set at compositionend and consumed\n  // by the next non-composing input; revoked by any real keydown, a new\n  // compositionstart, a blur, an external value render, or a mousedown — each\n  // means the next input cannot be the composition's trailing event.\n  const postCompositionUndo = useRef<{ base: Segment[]; pushed: boolean } | null>(null)\n\n  // -----------------------------------------------------------------------\n  // DOM -> Model: read segments from the contentEditable DOM\n  // -----------------------------------------------------------------------\n\n  const readSegmentsFromDOM = useCallback((): Segment[] => {\n    const editor = editorRef.current\n    if (!editor) return []\n\n    const segments: Segment[] = []\n    // Track whether the editor holds any real content (text/chip) or a sentinel\n    // <br> that renderSegmentsToDOM added. When it holds neither, any <br> nodes\n    // present are the browser's filler <br> (see the empty-editor check below).\n    let hasRealContent = false\n    let hasSentinel = false\n\n    for (let i = 0; i < editor.childNodes.length; i++) {\n      const node = editor.childNodes[i]\n\n      if (node.nodeType === Node.TEXT_NODE) {\n        const text = node.textContent ?? ''\n        if (text) {\n          segments.push({ type: 'text', text })\n          hasRealContent = true\n        }\n      } else if (isChipElement(node)) {\n        const chip = chipNodeToSegment(node)\n        if (chip) {\n          segments.push(chip)\n          hasRealContent = true\n        }\n      } else if (isBRElement(node)) {\n        if (node.dataset.sentinel) {\n          hasSentinel = true\n          continue // skip sentinel <br>\n        }\n        segments.push({ type: 'text', text: '\\n' })\n      } else if (isHTMLElement(node)) {\n        // Unknown element — extract text content\n        const text = node.textContent ?? ''\n        if (text) {\n          segments.push({ type: 'text', text })\n          hasRealContent = true\n        }\n      }\n    }\n\n    // When the user empties the editor (types something, then deletes it all),\n    // the browser leaves a lone filler <br> so the contentEditable block stays\n    // visible and focusable. Reading that <br> as a \"\\n\" text segment would make\n    // `value` permanently non-empty and keep the placeholder hidden forever.\n    // A newline we actually rendered always carries surrounding text/chip\n    // content or a trailing sentinel <br>, so when neither is present the only\n    // <br> nodes are filler and the editor is genuinely empty.\n    if (!hasRealContent && !hasSentinel) return []\n\n    return segments\n  }, [])\n\n  // -----------------------------------------------------------------------\n  // Model -> DOM: render segments into the contentEditable div\n  // -----------------------------------------------------------------------\n\n  const renderSegmentsToDOM = useCallback(\n    (segments: Segment[]) => {\n      const editor = editorRef.current\n      if (!editor) return\n\n      isSyncing.current = true\n\n      // Save the caret as a plain-text offset, never as a child-node index:\n      // the rebuild below ends with `decorateEditor`, whose passes each swap\n      // one text node for a text/<span>/text run, so the editor holds a\n      // different number of direct children than when the caret was captured.\n      // An index would land wherever that shifted count points (mid-document\n      // after a decoration-heavy paste); an offset survives because\n      // findDOMPosition maps it back through the decorations.\n      const savedCursor = getCursorOffset(editor)\n      const savedScrollTop = editor.scrollTop\n\n      // Clear DOM safely (no innerHTML assignment)\n      while (editor.firstChild) {\n        editor.removeChild(editor.firstChild)\n      }\n\n      for (const seg of segments) {\n        if (seg.type === 'text') {\n          const lines = seg.text.split('\\n')\n          for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {\n            if (lines[lineIdx]) {\n              editor.appendChild(document.createTextNode(lines[lineIdx]))\n            }\n            if (lineIdx < lines.length - 1) {\n              editor.appendChild(document.createElement('br'))\n            }\n          }\n        } else {\n          // Render chip as non-editable span\n          const chip = document.createElement('span')\n          chip.contentEditable = 'false'\n          chip.dataset.chipTrigger = seg.trigger\n          chip.dataset.chipValue = seg.value\n          chip.dataset.chipDisplay = seg.displayText\n          if (seg.data !== undefined) {\n            const json = safeJsonStringify(seg.data)\n            if (json) {\n              chip.dataset.chipData = json\n            }\n          }\n          if (seg.autoResolved) {\n            chip.dataset.chipAutoResolved = 'true'\n          }\n          const triggerConfig = triggers.find((t) => t.char === seg.trigger)\n          const chipStyle = triggerConfig?.chipStyle ?? 'pill'\n          chip.dataset.chipStyle = chipStyle\n          chip.className = cn(\n            'prompt-area-chip',\n            chipStyle === 'inline' && 'prompt-area-chip--inline',\n            triggerConfig?.chipClassName,\n          )\n          chip.textContent = `${seg.trigger}${seg.displayText}`\n          chip.setAttribute('role', 'button')\n          chip.setAttribute('tabindex', '-1')\n          editor.appendChild(chip)\n        }\n      }\n\n      // Append sentinel <br> so trailing newlines are visible in contentEditable\n      if (editor.lastChild && isBRElement(editor.lastChild)) {\n        const sentinel = document.createElement('br')\n        sentinel.dataset.sentinel = 'true'\n        editor.appendChild(sentinel)\n      }\n\n      // Decorate URLs, markdown formatting, and list bullets in text nodes\n      decorateEditor(editor, markdownEnabled, headingsEnabled)\n      imeDirty.current = false\n\n      // The child-clear above collapsed scrollHeight, which clamped scrollTop\n      // to 0. Put the viewport back before the caret restore so an in-view\n      // caret keeps its screen position (the restore then only nudges when\n      // the caret genuinely left the box) — and so re-renders without a\n      // selection to follow (blurred editor, external value updates) don't\n      // silently reset the user's scroll.\n      editor.scrollTop = savedScrollTop\n\n      // `!== null` — offset 0 is a real caret position, and would be falsy.\n      if (savedCursor !== null) {\n        setCursorAtOffset(editor, savedCursor)\n      }\n\n      lastRenderedValue.current = segments\n      isSyncing.current = false\n    },\n    [triggers, markdownEnabled, headingsEnabled],\n  )\n\n  // -----------------------------------------------------------------------\n  // Trigger detection (extracted so events module can call it)\n  // -----------------------------------------------------------------------\n\n  // Builds the insertChip handed to callback/launch activations: replaces the\n  // trigger's range with a chip and notifies onChipAdd.\n  const buildInsertChip = useCallback(\n    (segments: Segment[], trigger: ActiveTrigger) => (chip: Omit<ChipSegment, 'type'>) => {\n      const chipResult = resolveChip(segments, trigger, {\n        value: chip.value,\n        displayText: chip.displayText,\n        data: chip.data,\n      })\n      onChange(chipResult.segments)\n      renderSegmentsToDOM(chipResult.segments)\n      onChipAdd?.({\n        type: 'chip',\n        trigger: trigger.config.char,\n        value: chip.value,\n        displayText: chip.displayText,\n        ...(chip.data !== undefined ? { data: chip.data } : {}),\n      })\n      const editor = editorRef.current\n      if (editor) setCursorAtOffset(editor, chipResult.cursorOffset)\n    },\n    [onChange, renderSegmentsToDOM, onChipAdd],\n  )\n\n  const runTriggerDetection = useCallback(\n    // The typing hot path passes the segments/plainText/cursor it already\n    // computed; zero-arg callers (paste, chip ops, composition end) let the\n    // detection read the DOM itself.\n    (ctx?: { segments: Segment[]; plainText: string; cursorPos: number | null }) => {\n      const editor = editorRef.current\n      if (!editor) return\n\n      const segments = ctx ? ctx.segments : readSegmentsFromDOM()\n      const plainText = ctx ? ctx.plainText : segmentsToPlainText(segments)\n      const cursorPos = ctx ? ctx.cursorPos : getCursorOffset(editor)\n\n      if (cursorPos === null) return\n\n      const detected = detectActiveTrigger(plainText, cursorPos, triggers)\n\n      // Typing supersedes a chip-click dropdown: whichever branch we take next,\n      // the popover no longer edits the clicked chip.\n      editingChip.current = null\n      openChipNode.current = null\n\n      if (detected) {\n        setActiveTrigger(detected)\n        setSelectedSuggestionIndex(0)\n\n        // Position the popover at the trigger character, not the cursor.\n        // Build a range at detected.startOffset so the dropdown anchors to\n        // the trigger char even when the cursor has moved past it.\n        const triggerRange = createRangeAtOffset(editor, detected.startOffset)\n        if (triggerRange) {\n          // caretLineRect measures element-boundary positions too (a trigger\n          // typed right after a chip or <br>), so the popover anchors at the\n          // real trigger position instead of skipping the update. It returns\n          // null only when no geometry exists at all (jsdom) — keep the last\n          // valid rect rather than anchoring at the origin.\n          const rect = caretLineRect(triggerRange)\n          if (rect) {\n            setTriggerRect(rect)\n          }\n        }\n\n        // Fetch suggestions for dropdown mode\n        if (detected.config.mode === 'dropdown' && detected.config.onSearch) {\n          runSearch(detected.query, detected.config)\n        }\n\n        // Fire callback for callback mode\n        if (detected.config.mode === 'callback' && detected.config.onActivate) {\n          detected.config.onActivate({\n            text: plainText,\n            cursorPosition: cursorPos,\n            insertChip: buildInsertChip(segments, detected),\n          })\n        }\n      } else {\n        setActiveTrigger(null)\n        resetSearch()\n      }\n    },\n    [triggers, readSegmentsFromDOM, buildInsertChip, resetSearch, runSearch],\n  )\n\n  // -----------------------------------------------------------------------\n  // Dismiss trigger\n  // -----------------------------------------------------------------------\n\n  const dismissTrigger = useCallback(() => {\n    editingChip.current = null\n    openChipNode.current = null\n    setActiveTrigger(null)\n    setSelectedSuggestionIndex(0)\n    resetSearch()\n  }, [resetSearch])\n\n  // -----------------------------------------------------------------------\n  // Wire up edge-case event handlers\n  // -----------------------------------------------------------------------\n\n  const events = usePromptAreaEvents({\n    editorRef,\n    readSegmentsFromDOM,\n    onChange,\n    renderSegmentsToDOM,\n    runTriggerDetection,\n    dismissTrigger,\n    triggers,\n    markdownEnabled,\n    normalizeBullets,\n    onPaste,\n    onRawPaste,\n    onUndo,\n    onRedo,\n    onChipAdd,\n    onImagePaste,\n  })\n\n  // -----------------------------------------------------------------------\n  // Sync value prop -> DOM on external changes\n  // -----------------------------------------------------------------------\n\n  useEffect(() => {\n    if (isSyncing.current) return\n    if (segmentsEqual(value, lastRenderedValue.current)) return\n\n    // Normalize list prefixes (e.g., \"- \" → \"• \" when markdown is on)\n    // so externally-provided segments render bullet characters correctly.\n    if (markdownEnabled && normalizeBullets) {\n      const normalized = normalizeListPrefixes(value, true)\n      if (normalized !== value) {\n        onChange(normalized)\n        return // onChange will trigger a re-render with the normalized value\n      }\n    }\n\n    renderSegmentsToDOM(value)\n    // An authoritative external render invalidates the trailing-input handoff:\n    // the next input edits this value, not the ended composition's commit.\n    postCompositionUndo.current = null\n    if (events.isComposing.current) compositionBaseState.current = value\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- events.isComposing is a ref: identity-stable, and .current reads must not re-run the effect\n  }, [value, renderSegmentsToDOM, markdownEnabled, normalizeBullets, onChange])\n\n  // Re-render when markdown mode changes to apply/strip decorations\n  // Also convert bullet characters: • ↔ - in text segments\n  const prevMarkdown = useRef(markdownEnabled)\n  useEffect(() => {\n    if (prevMarkdown.current === markdownEnabled) return\n    prevMarkdown.current = markdownEnabled\n\n    const converted = normalizeBullets ? normalizeListPrefixes(value, markdownEnabled) : value\n    if (converted !== value) {\n      onChange(converted)\n    } else {\n      renderSegmentsToDOM(value)\n    }\n  }, [markdownEnabled, normalizeBullets, renderSegmentsToDOM, value, onChange])\n\n  // Clean up undo debounce timer on unmount\n  useEffect(() => {\n    return () => {\n      if (undoTimer.current) clearTimeout(undoTimer.current)\n    }\n  }, [])\n\n  // -----------------------------------------------------------------------\n  // Handle input events\n  // -----------------------------------------------------------------------\n\n  const handleInput = useCallback(() => {\n    if (isSyncing.current) return\n\n    // During IME composition, sync model but skip trigger detection\n    if (events.isComposing.current) {\n      imeDirty.current = true\n      const segments = readSegmentsFromDOM()\n      lastRenderedValue.current = segments\n      onChange(segments)\n      return\n    }\n\n    // A just-ended composition claims this one input event (the trailing\n    // input Chromium fires after compositionend), so detach the claim here,\n    // ahead of every early return below: whichever branch ends up handling\n    // this event owes the composition its undo entry, and no later unrelated\n    // input may inherit the claim.\n    const afterComposition = postCompositionUndo.current\n    postCompositionUndo.current = null\n\n    // The branches that return early still have to settle that claim: push\n    // the base when compositionend did not already and the content really\n    // moved away from it. The base was read by readSegmentsFromDOM, which\n    // splits decoration elements into their own segments where the scanner\n    // merges them, so identical content compares equal only as plain text.\n    const settleClaimedUndo = (finalText: string) => {\n      if (!afterComposition || afterComposition.pushed) return\n      if (segmentsToPlainText(afterComposition.base) !== finalText) {\n        events.pushUndo(afterComposition.base)\n      }\n    }\n\n    const editor = editorRef.current\n\n    // One snapshot of the selection, read BEFORE any DOM mutation below —\n    // stripping the decoration elements detaches the selection's anchor node\n    // and would lose the position. The scan resolves it into an offset and the\n    // scoped-decoration branch reuses the same boundary point, so a keystroke\n    // reads the selection once.\n    const selRange = editor ? getSelectionRange() : null\n\n    // One scan produces the segment model, the plain text, and the caret\n    // offset every branch below needs. Only when the browser inserted an\n    // element the scan cannot model (a block wrapper, an unknown inline tag)\n    // does the legacy normalize-then-read path run — it rewrites the DOM to\n    // the flat shape first, because unwrapping those elements changes the\n    // plain text.\n    let segments: Segment[] = []\n    let plainText = ''\n    let savedCursorOffset: number | null = null\n    let domNormalized = false\n    // Line scoping is only trustworthy when the DOM holds nothing but our own\n    // flat shapes and every newline is a real <br> (a literal \"\\n\" inside a\n    // text node would put line content out of the caret line's node range).\n    let scopedEligible = false\n    if (editor) {\n      const scan = scanEditorDOM(\n        editor,\n        selRange ? { node: selRange.startContainer, offset: selRange.startOffset } : null,\n      )\n      savedCursorOffset = scan.cursorOffset\n      if (scan.sawForeignElement) {\n        normalizeEditorDOM(editor)\n        domNormalized = true\n        segments = readSegmentsFromDOM()\n        plainText = segmentsToPlainText(segments)\n      } else {\n        segments = scan.segments\n        plainText = scan.plainText\n        scopedEligible = !scan.sawNewlineInText\n      }\n    }\n\n    // Enforce maxLength: if the edit pushed the editor past the cap, truncate\n    // back to maxLength characters and keep the caret where the user was\n    // editing (clamped to the cap) rather than forcing it to the end. On the\n    // clean-scan path the selection hasn't moved since savedCursorOffset was\n    // captured, so re-reading it would return the same offset; after the\n    // foreign-element normalize, the DOM (and possibly the text, via block\n    // unwrapping) changed, so measure the caret fresh — as the pre-scan\n    // implementation did.\n    if (maxLength != null && editor && plainText.length > maxLength) {\n      const caret = domNormalized ? getCursorOffset(editor) : savedCursorOffset\n      const truncated = truncateSegmentsToLength(segments, maxLength)\n      const truncatedText = segmentsToPlainText(truncated)\n      settleClaimedUndo(truncatedText)\n      lastRenderedValue.current = truncated\n      onChange(truncated)\n      renderSegmentsToDOM(truncated)\n      const clamped = caret != null ? Math.min(caret, maxLength) : maxLength\n      setCursorAtOffset(editor, clamped)\n      runTriggerDetection({\n        segments: truncated,\n        plainText: truncatedText,\n        cursorPos: clamped,\n      })\n      return\n    }\n\n    // Check for list auto-formatting (e.g., \"- \" -> \"bullet \")\n    if (markdownEnabled && normalizeBullets && editor && savedCursorOffset !== null) {\n      const formatted = autoFormatListPrefix(segments, savedCursorOffset, plainText)\n      if (formatted) {\n        const formattedText = segmentsToPlainText(formatted.segments)\n        settleClaimedUndo(formattedText)\n        lastRenderedValue.current = formatted.segments\n        onChange(formatted.segments)\n        renderSegmentsToDOM(formatted.segments)\n        setCursorAtOffset(editor, formatted.cursorOffset)\n        runTriggerDetection({\n          segments: formatted.segments,\n          plainText: formattedText,\n          cursorPos: formatted.cursorOffset,\n        })\n        return\n      }\n    }\n\n    // Native structural edits (e.g. a Backspace that deleted or merged a list\n    // row) bypass applyEditResult, so rebuild ordered-list numbering here too.\n    // handleInput fires on every keystroke, so gate on a genuine ordered-list\n    // run — this renumbers a real list (1,2,4 → 1,2,3) but leaves incidental\n    // numeric prose (\"1985. Born / 2020. Died\") untouched.\n    let nextSegments = segments\n    let nextPlainText = plainText\n    let renumberedCursor: number | null = null\n    if (markdownEnabled && savedCursorOffset !== null) {\n      // A keystroke on a plain line of an already-settled model cannot have\n      // changed the list structure, so the per-line scan of the whole\n      // document is skipped: a settled model with no run stays run-free, and\n      // a settled model with a run is already numbered.\n      const settled = settledListRun.current\n      const structureUnchanged =\n        settled !== null &&\n        settled.segments === lastRenderedValue.current &&\n        isPlainLineEdit(settled.plainText, plainText, savedCursorOffset)\n      if (!structureUnchanged && hasOrderedListRun(plainText)) {\n        const renumbered = renumberOrderedListSegments(segments, plainText)\n        if (renumbered.edits.length > 0) {\n          nextSegments = renumbered.segments\n          nextPlainText = segmentsToPlainText(renumbered.segments)\n          renumberedCursor = remapOffset(savedCursorOffset, renumbered.edits)\n        }\n      }\n    }\n\n    // Debounced undo: capture the pre-edit state at the start of a typing\n    // session and push it to the undo stack after UNDO_DEBOUNCE_MS of idle.\n    const contentChanged = !segmentsEqual(nextSegments, lastRenderedValue.current)\n\n    // The mutation a claimed event carries is part of the composition that\n    // just ended, so it must not open a new undo group seeded with the\n    // intermediate compositionend snapshot — the composition's own base is\n    // the only entry this session gets.\n    if (afterComposition) {\n      if (!afterComposition.pushed && contentChanged) {\n        events.pushUndo(afterComposition.base)\n      }\n    } else if (contentChanged) {\n      if (!undoBaseState.current) undoBaseState.current = lastRenderedValue.current\n      if (undoTimer.current) clearTimeout(undoTimer.current)\n      undoTimer.current = setTimeout(() => {\n        if (undoBaseState.current) {\n          events.pushUndo(undoBaseState.current)\n          undoBaseState.current = null\n        }\n        undoTimer.current = null\n      }, UNDO_DEBOUNCE_MS)\n    }\n\n    lastRenderedValue.current = nextSegments\n    settledListRun.current =\n      markdownEnabled && savedCursorOffset !== null\n        ? { segments: nextSegments, plainText: nextPlainText }\n        : null\n    if (contentChanged) onChange(nextSegments)\n\n    // Apply the recomputed model to the DOM. A renumber rewrites text nodes,\n    // so it needs a full re-render (which also re-decorates). Otherwise the\n    // decoration cycle — strip stale decorations, re-apply fresh ones — runs\n    // scoped to the caret's <br>-delimited line: a native single-caret edit\n    // can only have touched that line, and every decoration is line-local, so\n    // the other lines' decorations are still exactly what a full pass would\n    // produce. Anything that makes the scope uncertain (foreign elements,\n    // literal newlines in text, no collapsed in-editor selection) falls back\n    // to the full normalize + decorate.\n    let finalCursor = savedCursorOffset\n    if (editor) {\n      if (renumberedCursor !== null) {\n        renderSegmentsToDOM(nextSegments)\n        setCursorAtOffset(editor, renumberedCursor)\n        finalCursor = renumberedCursor\n      } else {\n        let scoped = false\n        // Whether the caret still has to be re-established. The browser\n        // already left it at savedCursorOffset when it applied the native\n        // edit, so the restore is only load-bearing when the decoration cycle\n        // replaced the nodes it was anchored in — and it is not free: it walks\n        // the document to map the offset back to a node, then hands the engine\n        // a new selection range, both of which cost O(document).\n        //\n        // Skipping is only provably identical when the caret sits in a text\n        // node: the offset then maps back to that same node at that same\n        // offset. Any other anchor (inside a chip, at an element boundary)\n        // can resolve to a different-but-equivalent spot, and re-placing it is\n        // what normalizes that — so those keep the restore. A caret inside a\n        // decoration is covered too: every decoration on the caret's line is\n        // stripped below, which reports a mutation.\n        let needsCaretRestore = true\n        if (scopedEligible && !imeDirty.current) {\n          if (selRange && selRange.collapsed && editor.contains(selRange.startContainer)) {\n            const bounds = findLineBounds(editor, selRange.startContainer, selRange.startOffset)\n            if (bounds) {\n              const stripped = stripDecorationsInRange(editor, bounds)\n              const decorated = decorateEditor(editor, markdownEnabled, headingsEnabled, bounds)\n              scoped = true\n              needsCaretRestore = stripped || decorated || !isTextNode(selRange.startContainer)\n            }\n          }\n        }\n        if (!scoped) {\n          if (!domNormalized) {\n            normalizeEditorDOM(editor)\n          }\n          decorateEditor(editor, markdownEnabled, headingsEnabled)\n          imeDirty.current = false\n        }\n        if (savedCursorOffset !== null && needsCaretRestore) {\n          // scroll: false — this placement only re-establishes the caret that\n          // native editing just revealed, and the correction's layout read\n          // would force a reflow on every keystroke.\n          setCursorAtOffset(editor, savedCursorOffset, { scroll: false })\n        }\n      }\n    }\n\n    runTriggerDetection({\n      segments: nextSegments,\n      plainText: nextPlainText,\n      cursorPos: finalCursor,\n    })\n  }, [\n    onChange,\n    readSegmentsFromDOM,\n    runTriggerDetection,\n    renderSegmentsToDOM,\n    markdownEnabled,\n    headingsEnabled,\n    normalizeBullets,\n    maxLength,\n    events,\n  ])\n\n  // -----------------------------------------------------------------------\n  // Chip click delegation\n  // -----------------------------------------------------------------------\n\n  const handleClick = useCallback(\n    (e: React.MouseEvent<HTMLDivElement>) => {\n      const target = e.target\n      if (!(target instanceof Node)) return\n\n      const editor = editorRef.current\n      if (!editor) return\n\n      // Walk from the click target up to find a link or chip element\n      let node: Node | null = target\n      while (node && node !== editor) {\n        // Check for URL link click — only navigate on Cmd/Ctrl+Click;\n        // plain click just positions the cursor for editing.\n        if (isLinkElement(node)) {\n          if (e.metaKey || e.ctrlKey) {\n            e.preventDefault()\n            onLinkClick?.(node.href)\n            window.open(node.href, '_blank', 'noopener,noreferrer')\n            return\n          }\n          // Plain click: let the browser place the cursor inside the link text\n          break\n        }\n\n        if (isChipElement(node)) {\n          // Spawn ripple effect. `isChipElement` has already narrowed `node`\n          // to HTMLElement, so no cast is needed.\n          const rect = node.getBoundingClientRect()\n          const ripple = document.createElement('span')\n          ripple.className = 'prompt-area-chip-ripple'\n          const size = Math.max(rect.width, rect.height)\n          ripple.style.width = `${size}px`\n          ripple.style.height = `${size}px`\n          ripple.style.left = `${e.clientX - rect.left - size / 2}px`\n          ripple.style.top = `${e.clientY - rect.top - size / 2}px`\n          node.appendChild(ripple)\n          ripple.addEventListener('animationend', () => ripple.remove())\n\n          const chip = chipNodeToSegment(node)\n          if (chip) {\n            // Native chip-click dropdown: reopen this trigger's suggestions\n            // anchored to the chip so the selection can replace it in place.\n            // Gated on `!disabled` — a disabled composer must not accept edits\n            // through any path, including this one.\n            const config = triggers.find((t) => t.char === chip.trigger)\n            // A click on THIS exact chip element while its own dropdown was\n            // open just closed it (see `suppressReopenChip` and\n            // `handleMouseDown`) — treat that as a toggle-close, not a reopen.\n            const wasOpenForThisChip = suppressReopenChip.current === node\n            suppressReopenChip.current = null\n            if (\n              !disabled &&\n              !wasOpenForThisChip &&\n              config?.reopenOnChipClick &&\n              config.mode === 'dropdown' &&\n              config.onSearch\n            ) {\n              const childIdx = indexOfChildNode(editor, node)\n              editingChip.current = {\n                chip,\n                segIndex: domChildIndexToSegmentIndex(editor, childIdx),\n              }\n              openChipNode.current = node\n              setActiveTrigger({ config, startOffset: 0, query: '' })\n              setSelectedSuggestionIndex(0)\n              setTriggerRect(rect)\n              runSearch('', config)\n            }\n            onChipClick?.(chip)\n          }\n          return\n        }\n        node = node.parentNode\n      }\n    },\n    [onChipClick, onLinkClick, triggers, runSearch, disabled],\n  )\n\n  // -----------------------------------------------------------------------\n  // Chip mousedown delegation — feeds `suppressReopenChip` for handleClick's\n  // toggle-close detection. See `openChipNode`/`suppressReopenChip` above for\n  // why this needs to be a real mousedown listener rather than piggybacking\n  // on `dismissTrigger`.\n  // -----------------------------------------------------------------------\n\n  const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {\n    // A click repositions the caret, so the next input is a fresh edit rather\n    // than an ended composition's trailing event.\n    postCompositionUndo.current = null\n\n    const target = e.target\n    const editor = editorRef.current\n    if (!editor || !(target instanceof Node)) {\n      suppressReopenChip.current = null\n      return\n    }\n\n    let node: Node | null = target\n    while (node && node !== editor) {\n      if (isChipElement(node)) {\n        suppressReopenChip.current = openChipNode.current === node ? node : null\n        return\n      }\n      node = node.parentNode\n    }\n    suppressReopenChip.current = null\n  }, [])\n\n  // -----------------------------------------------------------------------\n  // Remove a chip node from DOM and sync model\n  // -----------------------------------------------------------------------\n\n  const removeChipNodeFromDOM = useCallback(\n    (editor: HTMLElement, chipNode: HTMLElement): boolean => {\n      const segments = readSegmentsFromDOM()\n      const chipIdx = indexOfChildNode(editor, chipNode)\n      if (chipIdx === -1) return false\n\n      const segIdx = domChildIndexToSegmentIndex(editor, chipIdx)\n      const deletedChip = segments[segIdx]\n      const newSegments = removeChipAtIndex(segments, segIdx)\n      onChange(newSegments)\n      renderSegmentsToDOM(newSegments)\n\n      if (deletedChip?.type === 'chip') {\n        onChipDelete?.(deletedChip)\n      }\n\n      return true\n    },\n    [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipDelete],\n  )\n\n  // -----------------------------------------------------------------------\n  // Revert an auto-resolved chip back to plain text\n  // -----------------------------------------------------------------------\n\n  const revertChipNodeToText = useCallback(\n    (editor: HTMLElement, chipNode: HTMLElement): boolean => {\n      const segments = readSegmentsFromDOM()\n      const chipIdx = indexOfChildNode(editor, chipNode)\n      if (chipIdx === -1) return false\n\n      const segIdx = domChildIndexToSegmentIndex(editor, chipIdx)\n      const revertedChip = segments[segIdx]\n      const result = revertChipAtIndex(segments, segIdx)\n      if (!result) return false\n\n      // Compute cursor target: plain text offset at end of reverted text\n      let targetOffset = 0\n      for (let i = 0; i < segIdx; i++) {\n        const s = segments[i]\n        if (s.type === 'text') {\n          targetOffset += s.text.length\n        } else {\n          targetOffset += s.trigger.length + s.displayText.length\n        }\n      }\n      targetOffset += result.revertedText.length\n\n      onChange(result.segments)\n      renderSegmentsToDOM(result.segments)\n      setCursorAtOffset(editor, targetOffset)\n\n      if (revertedChip?.type === 'chip') {\n        onChipDelete?.(revertedChip)\n      }\n\n      return true\n    },\n    [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipDelete],\n  )\n\n  // -----------------------------------------------------------------------\n  // Chip backspace (delete chip behind cursor as whole unit)\n  // -----------------------------------------------------------------------\n\n  const handleChipBackspace = useCallback((): boolean => {\n    const editor = editorRef.current\n    if (!editor) return false\n\n    const range = getSelectionRange()\n    if (!range || !range.collapsed) return false\n\n    const node = range.startContainer\n    const offset = range.startOffset\n\n    // Case 1: cursor is at the editor level (between child nodes)\n    if (node === editor && offset > 0) {\n      const prevChild = editor.childNodes[offset - 1]\n      if (prevChild && isChipElement(prevChild)) {\n        if (getChipAutoResolved(prevChild)) {\n          return revertChipNodeToText(editor, prevChild)\n        }\n        return removeChipNodeFromDOM(editor, prevChild)\n      }\n    }\n\n    // Case 2: cursor is at start of a text node, check previous sibling\n    if (node.nodeType === Node.TEXT_NODE && offset === 0) {\n      const directChild = getDirectChildContaining(editor, node)\n      if (!directChild) return false\n\n      let prevSibling = directChild.previousSibling\n      while (\n        prevSibling &&\n        prevSibling.nodeType === Node.TEXT_NODE &&\n        prevSibling.textContent === ''\n      ) {\n        prevSibling = prevSibling.previousSibling\n      }\n      if (prevSibling && isChipElement(prevSibling)) {\n        if (getChipAutoResolved(prevSibling)) {\n          return revertChipNodeToText(editor, prevSibling)\n        }\n        return removeChipNodeFromDOM(editor, prevSibling)\n      }\n    }\n\n    return false\n  }, [removeChipNodeFromDOM, revertChipNodeToText])\n\n  // -----------------------------------------------------------------------\n  // Chip forward delete (delete chip in front of cursor)\n  // -----------------------------------------------------------------------\n\n  const handleChipForwardDelete = useCallback((): boolean => {\n    const editor = editorRef.current\n    if (!editor) return false\n\n    const range = getSelectionRange()\n    if (!range || !range.collapsed) return false\n\n    const node = range.startContainer\n    const offset = range.startOffset\n\n    // Case 1: cursor at the editor level\n    if (node === editor && offset < editor.childNodes.length) {\n      const nextChild = editor.childNodes[offset]\n      if (nextChild && isChipElement(nextChild)) {\n        return removeChipNodeFromDOM(editor, nextChild)\n      }\n    }\n\n    // Case 2: cursor at end of a text node, check next sibling\n    if (node.nodeType === Node.TEXT_NODE && offset === (node.textContent ?? '').length) {\n      const directChild = getDirectChildContaining(editor, node)\n      if (!directChild) return false\n\n      let nextSibling = directChild.nextSibling\n      while (\n        nextSibling &&\n        nextSibling.nodeType === Node.TEXT_NODE &&\n        nextSibling.textContent === ''\n      ) {\n        nextSibling = nextSibling.nextSibling\n      }\n      if (nextSibling && isChipElement(nextSibling)) {\n        return removeChipNodeFromDOM(editor, nextSibling)\n      }\n    }\n\n    return false\n  }, [removeChipNodeFromDOM])\n\n  // -----------------------------------------------------------------------\n  // Auto-resolve active trigger on space\n  // -----------------------------------------------------------------------\n\n  const autoResolveActiveTrigger = useCallback(\n    (trigger: ActiveTrigger) => {\n      const segments = readSegmentsFromDOM()\n      const query = trigger.query\n\n      // Create a synthetic suggestion so onSelect can customize display text\n      const syntheticSuggestion: TriggerSuggestion = {\n        value: query,\n        label: query,\n      }\n\n      const displayText = trigger.config.onSelect?.(syntheticSuggestion) ?? query\n\n      const chipData = {\n        value: query,\n        displayText: displayText || query,\n        autoResolved: true,\n      }\n      const result = resolveChip(segments, trigger, chipData)\n\n      onChange(result.segments)\n      renderSegmentsToDOM(result.segments)\n\n      onChipAdd?.({\n        type: 'chip',\n        trigger: trigger.config.char,\n        ...chipData,\n      })\n\n      // Position cursor after the auto-resolved chip + trailing space\n      const editor = editorRef.current\n      if (editor) {\n        setCursorAtOffset(editor, result.cursorOffset)\n      }\n\n      dismissTrigger()\n    },\n    [readSegmentsFromDOM, onChange, renderSegmentsToDOM, dismissTrigger, onChipAdd],\n  )\n\n  // -----------------------------------------------------------------------\n  // Select a suggestion from the dropdown\n  // -----------------------------------------------------------------------\n\n  const selectSuggestionInternal = useCallback(\n    (suggestion: TriggerSuggestion) => {\n      if (!activeTrigger) return\n\n      const segments = readSegmentsFromDOM()\n      const displayText = activeTrigger.config.onSelect?.(suggestion) ?? suggestion.label\n\n      const chipData = {\n        value: suggestion.value,\n        displayText: displayText || suggestion.label,\n        data: suggestion.data,\n      }\n\n      // Chip-click dropdown (`reopenOnChipClick`): replace the clicked chip in\n      // place instead of resolving typed trigger text at the caret. Disabled\n      // is re-checked here (not just at open time) in case the composer\n      // became disabled while the popover was still open.\n      const editing = editingChip.current\n      if (editing) {\n        const editor = editorRef.current\n        if (editor && !disabled) {\n          // Re-verify the click-time index still holds the same chip — the\n          // model may have shifted (external value update, undo/redo) while\n          // the dropdown was open. If it moved, recover ONLY when exactly one\n          // chip in the document now matches trigger+value: with duplicates,\n          // guessing risks silently editing the wrong instance, which is\n          // worse than the no-op this falls back to.\n          const atIndex = segments[editing.segIndex]\n          const stillThere =\n            atIndex?.type === 'chip' &&\n            atIndex.trigger === editing.chip.trigger &&\n            atIndex.value === editing.chip.value\n          const segIdx = stillThere\n            ? editing.segIndex\n            : (() => {\n                const matches: number[] = []\n                segments.forEach((seg, i) => {\n                  if (\n                    seg.type === 'chip' &&\n                    seg.trigger === editing.chip.trigger &&\n                    seg.value === editing.chip.value\n                  ) {\n                    matches.push(i)\n                  }\n                })\n                return matches.length === 1 ? matches[0] : -1\n              })()\n          const oldChip = segIdx !== -1 ? segments[segIdx] : undefined\n\n          if (oldChip?.type === 'chip') {\n            const newChip: ChipSegment = {\n              type: 'chip',\n              trigger: activeTrigger.config.char,\n              ...chipData,\n            }\n            let newSegments = segments.map((seg, i) => (i === segIdx ? newChip : seg))\n\n            // Guarantee a real landing spot after the replaced chip, mirroring\n            // resolveChip's trailing-space convention (prompt-area-engine.ts):\n            // if the new chip is now the last segment (or directly followed by\n            // another chip), the caret would land at a bare element boundary\n            // with no text node, which some engines fail to render/snap a\n            // visible caret at.\n            const nextSeg = newSegments[segIdx + 1]\n            const insertedSpace = !nextSeg || nextSeg.type !== 'text' || nextSeg.text.length === 0\n            if (insertedSpace) {\n              newSegments = [\n                ...newSegments.slice(0, segIdx + 1),\n                { type: 'text', text: ' ' },\n                ...newSegments.slice(segIdx + 1),\n              ]\n            }\n\n            events.pushUndo(segments)\n            onChange(newSegments)\n            renderSegmentsToDOM(newSegments)\n\n            // Same value + display text + data: treat as a no-op confirmation\n            // rather than a destructive delete+add — onChipDelete is\n            // documented as firing on backspace/forward-delete, not on\n            // re-confirming the already-selected suggestion.\n            const unchanged =\n              oldChip.value === newChip.value &&\n              oldChip.displayText === newChip.displayText &&\n              safeJsonStringify(oldChip.data) === safeJsonStringify(newChip.data)\n            if (!unchanged) {\n              onChipDelete?.(oldChip)\n              onChipAdd?.(newChip)\n            }\n\n            // +1 when a space was inserted, matching resolveChip's own\n            // \"+1 accounts for the trailing space after the chip\" placement —\n            // landing exactly at the chip's end would put the caret at the\n            // same bare element boundary the inserted space exists to avoid.\n            const caretOffset =\n              segmentsToPlainText(newSegments.slice(0, segIdx + 1)).length + (insertedSpace ? 1 : 0)\n            setCursorAtOffset(editor, caretOffset)\n          }\n        }\n\n        dismissTrigger()\n        setTimeout(() => {\n          editorRef.current?.focus()\n        }, 0)\n        return\n      }\n\n      const result = resolveChip(segments, activeTrigger, chipData)\n\n      onChange(result.segments)\n      renderSegmentsToDOM(result.segments)\n\n      onChipAdd?.({\n        type: 'chip',\n        trigger: activeTrigger.config.char,\n        ...chipData,\n      })\n\n      // Position cursor after the chip + trailing space\n      const editor = editorRef.current\n      if (editor) {\n        setCursorAtOffset(editor, result.cursorOffset)\n      }\n\n      dismissTrigger()\n\n      // Refocus editor after popover interaction\n      setTimeout(() => {\n        editorRef.current?.focus()\n      }, 0)\n    },\n    [\n      activeTrigger,\n      readSegmentsFromDOM,\n      onChange,\n      renderSegmentsToDOM,\n      dismissTrigger,\n      onChipAdd,\n      onChipDelete,\n      events,\n      disabled,\n    ],\n  )\n\n  const selectSuggestion = selectSuggestionInternal\n\n  // Chip-click dropdown: once the empty-query suggestions arrive, preselect\n  // the chip's current value so the list opens \"on\" the existing choice.\n  useEffect(() => {\n    const editing = editingChip.current\n    if (!editing || !activeTrigger?.config.reopenOnChipClick) return\n    const idx = suggestions.findIndex((s) => s.value === editing.chip.value)\n    if (idx > 0) setSelectedSuggestionIndex(idx)\n  }, [suggestions, activeTrigger])\n\n  // -----------------------------------------------------------------------\n  // Handle key events\n  // -----------------------------------------------------------------------\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      if (\n        events.isComposing.current ||\n        e.nativeEvent.isComposing ||\n        e.nativeEvent.keyCode === 229\n      ) {\n        return\n      }\n\n      // Any real keydown bounds a just-ended composition's claim on the next\n      // input event: Chromium's trailing input follows compositionend with no\n      // keydown possible in between, so a keydown here means the next input\n      // belongs to a new keystroke, not the ended composition. (Placed after\n      // the guard above so composition keydowns don't release the claim.)\n      postCompositionUndo.current = null\n\n      const applyEditResult = (\n        editor: HTMLDivElement,\n        result: { segments: Segment[]; cursorOffset: number },\n      ) => {\n        // Ordered-list numbers are a projection of position: rebuild them on\n        // every structural edit and remap the caret across any digit-run width\n        // changes. No-op (same reference) when there are no ordered lists.\n        let { segments, cursorOffset } = result\n        if (markdownEnabled) {\n          const renumbered = renumberOrderedListSegments(segments)\n          segments = renumbered.segments\n          cursorOffset = remapOffset(cursorOffset, renumbered.edits)\n        }\n        lastRenderedValue.current = segments\n        onChange(segments)\n        renderSegmentsToDOM(segments)\n        setCursorAtOffset(editor, cursorOffset)\n      }\n\n      const tryListContinuation = (editor: HTMLDivElement): boolean => {\n        if (!markdownEnabled) return false\n        const segments = readSegmentsFromDOM()\n        const cursorPos = getCursorOffset(editor)\n        if (cursorPos === null) return false\n        const plainText = segmentsToPlainText(segments)\n        if (!getListContext(plainText, cursorPos)) return false\n        const result = insertListContinuation(segments, cursorPos)\n        if (result) applyEditResult(editor, result)\n        return true\n      }\n\n      // 1. Flush pending undo debounce so Cmd+Z has the latest checkpoint\n      if ((e.metaKey || e.ctrlKey) && e.key === 'z' && undoBaseState.current) {\n        if (undoTimer.current) {\n          clearTimeout(undoTimer.current)\n          undoTimer.current = null\n        }\n        events.pushUndo(undoBaseState.current)\n        undoBaseState.current = null\n      }\n\n      // 1a. Undo/redo intercept\n      if (events.handleKeyDownForUndoRedo(e)) return\n\n      // 1.5 Markdown formatting shortcuts (Cmd+B bold, Cmd+I italic)\n      if (\n        markdownEnabled &&\n        (e.metaKey || e.ctrlKey) &&\n        !e.shiftKey &&\n        (e.key === 'b' || e.key === 'i')\n      ) {\n        e.preventDefault()\n        const editor = editorRef.current\n        if (!editor) return\n\n        const offsets = getSelectionOffsets(editor)\n        if (!offsets || offsets.start === offsets.end) return\n\n        const marker = e.key === 'b' ? '**' : '*'\n        const currentSegments = readSegmentsFromDOM()\n        events.pushUndo(currentSegments)\n\n        const result = toggleMarkdownWrap(currentSegments, offsets.start, offsets.end, marker)\n        if (!result) return\n\n        lastRenderedValue.current = result.segments\n        onChange(result.segments)\n        renderSegmentsToDOM(result.segments)\n        setSelectionAtOffsets(editor, result.selectionStart, result.selectionEnd)\n        return\n      }\n\n      // 1.75 Launch triggers: a trigger with mode 'launch' fires onActivate on\n      // keydown and suppresses the char so it never enters the editor — for\n      // opening an external surface (dialog, palette). The DOM read is gated on\n      // the typed key actually matching a launch char, so it stays off the hot\n      // path. insertChip still inserts a chip at the cursor if the consumer\n      // wants one after the external selection.\n      if (!e.metaKey && !e.ctrlKey && !e.altKey && e.key.length === 1) {\n        const launcher = triggers.find((t) => t.mode === 'launch' && t.char === e.key)\n        const editor = editorRef.current\n        if (launcher?.onActivate && editor) {\n          const cursorPos = getCursorOffset(editor)\n          if (cursorPos !== null) {\n            const segments = readSegmentsFromDOM()\n            const plainText = segmentsToPlainText(segments)\n            if (isValidTriggerPosition(plainText, cursorPos, launcher.position)) {\n              e.preventDefault()\n              launcher.onActivate({\n                text: plainText,\n                cursorPosition: cursorPos,\n                insertChip: buildInsertChip(\n                  replaceTextRange(segments, cursorPos, cursorPos, launcher.char),\n                  { config: launcher, startOffset: cursorPos, query: '' },\n                ),\n              })\n              return\n            }\n          }\n        }\n      }\n\n      // 2. Trigger dropdown navigation. Gated on the dropdown actually being\n      // ON SCREEN, which matches TriggerPopover's own render condition\n      // (non-empty suggestions, OR loading/error/emptyMessage) rather than\n      // just `suggestions.length > 0` — otherwise a popover left open in a\n      // loading/empty state (e.g. right after a chip-click reopen, before its\n      // empty-query search resolves) lets Enter fall through to onSubmit and\n      // Escape fall through to onEscape while still visibly on screen.\n      const dropdownVisible =\n        activeTrigger &&\n        activeTrigger.config.mode === 'dropdown' &&\n        (suggestions.length > 0 ||\n          suggestionsLoading ||\n          suggestionsError !== null ||\n          !!activeTrigger.config.emptyMessage)\n      if (dropdownVisible) {\n        if (e.key === 'ArrowDown') {\n          e.preventDefault()\n          if (suggestions.length > 0) {\n            setSelectedSuggestionIndex((prev) => Math.min(prev + 1, suggestions.length - 1))\n          }\n          return\n        }\n        if (e.key === 'ArrowUp') {\n          e.preventDefault()\n          if (suggestions.length > 0) {\n            setSelectedSuggestionIndex((prev) => Math.max(prev - 1, 0))\n          }\n          return\n        }\n        if (e.key === 'Enter' || e.key === 'Tab') {\n          e.preventDefault()\n          const selected = suggestions[selectedSuggestionIndex]\n          if (selected) {\n            selectSuggestionInternal(selected)\n          }\n          return\n        }\n        if (e.key === 'Escape') {\n          e.preventDefault()\n          dismissTrigger()\n          return\n        }\n      }\n\n      // 2.5. Auto-resolve on Space when trigger has resolveOnSpace\n      if (e.key === ' ' && activeTrigger && activeTrigger.config.resolveOnSpace) {\n        const query = activeTrigger.query.trim()\n        if (query.length > 0) {\n          e.preventDefault()\n          autoResolveActiveTrigger(activeTrigger)\n          return\n        }\n      }\n\n      // 2.6. Tab/Shift+Tab for list indentation (only when trigger dropdown is NOT open)\n      if (markdownEnabled && e.key === 'Tab' && !activeTrigger) {\n        const editor = editorRef.current\n        if (editor) {\n          const segments = readSegmentsFromDOM()\n          const plainText = segmentsToPlainText(segments)\n          const cursorPos = getCursorOffset(editor)\n          if (cursorPos !== null) {\n            const ctx = getListContext(plainText, cursorPos)\n            if (ctx) {\n              e.preventDefault()\n              const result = e.shiftKey\n                ? outdentListItem(segments, cursorPos)\n                : indentListItem(segments, cursorPos)\n              if (result) applyEditResult(editor, result)\n              return\n            }\n          }\n        }\n      }\n\n      // Insert a newline at the model level (avoids the browser's broken\n      // contentEditable behaviour near <a> elements).\n      const insertPlainNewline = (editor: HTMLDivElement): void => {\n        const offsets = getSelectionOffsets(editor)\n        if (!offsets) return\n        const scan = scanEditorDOM(editor)\n        const cleanScan = !scan.sawForeignElement && !scan.sawNewlineInText\n        const currentSegments = cleanScan ? scan.segments : readSegmentsFromDOM()\n        events.pushUndo(currentSegments)\n        const newSegments = replaceTextRange(currentSegments, offsets.start, offsets.end, '\\n')\n\n        // Surgical fast path: a full renderSegmentsToDOM tears down and\n        // rebuilds (and re-decorates) the entire document, which makes every\n        // newline stutter on large content. When the edit is a collapsed\n        // caret in a clean flat DOM with real content, and renumbering is\n        // provably a no-op, the same end state is reachable by splitting the\n        // caret's text node, inserting one <br>, and re-decorating only that\n        // line's range.\n        if (\n          cleanScan &&\n          offsets.start === offsets.end &&\n          scan.segments.length > 0 &&\n          !imeDirty.current\n        ) {\n          const plainAfter =\n            scan.plainText.slice(0, offsets.start) + '\\n' + scan.plainText.slice(offsets.start)\n          // Exact parity with applyEditResult, which renumbers unconditionally:\n          // the fast path is only taken when that renumber provably does\n          // nothing (renumberOrderedListLines has its own cheap no-list\n          // pre-gate, so this is string work only).\n          let renumberNoop = true\n          if (markdownEnabled) {\n            renumberNoop = renumberOrderedListSegments(newSegments, plainAfter).edits.length === 0\n          }\n          if (renumberNoop && insertNewlineSurgically(editor, offsets.start)) {\n            lastRenderedValue.current = newSegments\n            onChange(newSegments)\n            setCursorAtOffset(editor, offsets.start + 1)\n            return\n          }\n        }\n\n        applyEditResult(editor, { segments: newSegments, cursorOffset: offsets.start + 1 })\n      }\n\n      // The DOM half of the fast path above. Strips the caret line's\n      // decorations (so the plain-text offset maps into a direct-child text\n      // node), inserts the <br>, mirrors renderSegmentsToDOM's sentinel rule\n      // for a trailing newline, and re-decorates the original line's range —\n      // which now spans both halves of the split. Returns false to make the\n      // caller fall back to the full re-render.\n      const insertNewlineSurgically = (editor: HTMLDivElement, offset: number): boolean => {\n        const selRange = getSelectionRange()\n        if (!selRange || !selRange.collapsed || !editor.contains(selRange.startContainer)) {\n          return false\n        }\n        const bounds = findLineBounds(editor, selRange.startContainer, selRange.startOffset)\n        if (!bounds) return false\n\n        stripDecorationsInRange(editor, bounds)\n\n        const pos = findDOMPosition(editor, offset)\n        if (!pos) return false\n        // A boundary offset in front of an atomic child (a leading chip, a\n        // <br> on an empty line) resolves BEFORE that element, which is where\n        // the model puts the newline too, so those cases insert here. An\n        // offset that falls INSIDE a chip has no such position — the chip is\n        // atomic, so findDOMPosition answers with the spot after it, which is\n        // not where the model's newline goes. Only proceed when the mapping\n        // round-trips to the exact offset; otherwise the full re-render\n        // handles it.\n        if (getTextOffsetAtPoint(editor, pos.node, pos.offset) !== offset) return false\n\n        const br = document.createElement('br')\n        if (pos.node === editor) {\n          editor.insertBefore(br, editor.childNodes[pos.offset] ?? null)\n        } else if (isTextNode(pos.node) && pos.node.parentNode === editor) {\n          const text = pos.node.textContent ?? ''\n          if (pos.offset <= 0) {\n            editor.insertBefore(br, pos.node)\n          } else if (pos.offset >= text.length) {\n            editor.insertBefore(br, pos.node.nextSibling)\n          } else {\n            const tail = pos.node.splitText(pos.offset)\n            editor.insertBefore(br, tail)\n          }\n        } else {\n          // A position inside a nested element — shouldn't happen after the\n          // strip, so let the full re-render canonicalize instead.\n          return false\n        }\n\n        // renderSegmentsToDOM's rule: a trailing real <br> needs the sentinel\n        // so the final newline stays visible. Checking the actual last child\n        // (not just the inserted one) also repairs a bare trailing <br> the\n        // user exposed by deleting a previous sentinel.\n        const last = editor.lastChild\n        if (last && isBRElement(last) && !last.dataset.sentinel) {\n          const sentinel = document.createElement('br')\n          sentinel.dataset.sentinel = 'true'\n          editor.appendChild(sentinel)\n        }\n\n        decorateEditor(editor, markdownEnabled, headingsEnabled, bounds)\n        return true\n      }\n\n      // 2.8 Shift+Enter always inserts a newline (after a list-continuation check).\n      if (e.key === 'Enter' && e.shiftKey) {\n        e.preventDefault()\n        const editor = editorRef.current\n        if (editor && !tryListContinuation(editor)) insertPlainNewline(editor)\n        return\n      }\n\n      // 3. Enter without Shift (skipping IME): continue a list, else submit when\n      // `submitOnEnter` is set, else insert a newline.\n      if (e.key === 'Enter' && !e.shiftKey) {\n        const editor = editorRef.current\n        if (editor && tryListContinuation(editor)) {\n          e.preventDefault()\n          return\n        }\n        if (submitOnEnter) {\n          e.preventDefault()\n          onSubmit?.(readSegmentsFromDOM())\n          return\n        }\n        e.preventDefault()\n        if (editor) insertPlainNewline(editor)\n        return\n      }\n\n      // 4. Escape\n      if (e.key === 'Escape' && onEscape) {\n        onEscape()\n        return\n      }\n\n      // 4.5 Non-collapsed selection delete (Backspace/Delete across <a> boundaries)\n      if (e.key === 'Backspace' || e.key === 'Delete') {\n        const editor = editorRef.current\n        if (editor) {\n          const offsets = getSelectionOffsets(editor)\n          if (offsets && offsets.start !== offsets.end) {\n            e.preventDefault()\n            const currentSegments = readSegmentsFromDOM()\n            events.pushUndo(currentSegments)\n            const newSegments = replaceTextRange(currentSegments, offsets.start, offsets.end, '')\n            applyEditResult(editor, { segments: newSegments, cursorOffset: offsets.start })\n            runTriggerDetection()\n            return\n          }\n        }\n      }\n\n      // 5. Backspace: check list prefix removal, then chip deletion\n      if (e.key === 'Backspace') {\n        const editor = editorRef.current\n        if (editor) {\n          const segments = readSegmentsFromDOM()\n          const cursorPos = getCursorOffset(editor)\n          if (markdownEnabled && cursorPos !== null) {\n            const result = removeListPrefix(segments, cursorPos)\n            if (result) {\n              e.preventDefault()\n              applyEditResult(editor, result)\n              runTriggerDetection()\n              return\n            }\n          }\n        }\n        if (handleChipBackspace()) {\n          e.preventDefault()\n          runTriggerDetection()\n          return\n        }\n      }\n\n      // 6. Delete (forward): delete chip as whole unit\n      if (e.key === 'Delete' && handleChipForwardDelete()) {\n        e.preventDefault()\n        runTriggerDetection()\n        return\n      }\n    },\n    [\n      activeTrigger,\n      suggestions,\n      suggestionsLoading,\n      suggestionsError,\n      selectedSuggestionIndex,\n      onSubmit,\n      submitOnEnter,\n      onEscape,\n      readSegmentsFromDOM,\n      onChange,\n      renderSegmentsToDOM,\n      markdownEnabled,\n      headingsEnabled,\n      dismissTrigger,\n      handleChipBackspace,\n      handleChipForwardDelete,\n      autoResolveActiveTrigger,\n      runTriggerDetection,\n      selectSuggestionInternal,\n      events,\n      triggers,\n      buildInsertChip,\n    ],\n  )\n\n  // -----------------------------------------------------------------------\n  // Imperative handle (memoized to avoid identity changes)\n  // -----------------------------------------------------------------------\n\n  const handle: PromptAreaHandle = useMemo(\n    () => ({\n      focus: () => editorRef.current?.focus(),\n      blur: () => editorRef.current?.blur(),\n      insertChip: (chip) => {\n        const segments = readSegmentsFromDOM()\n        const newChip: ChipSegment = { type: 'chip', ...chip }\n        const newSegments: Segment[] = [...segments, newChip, { type: 'text', text: ' ' }]\n        onChange(newSegments)\n        renderSegmentsToDOM(newSegments)\n        onChipAdd?.(newChip)\n      },\n      getPlainText: () => segmentsToPlainText(readSegmentsFromDOM()),\n      clear: () => {\n        onChange([])\n        const editor = editorRef.current\n        if (editor) {\n          while (editor.firstChild) editor.removeChild(editor.firstChild)\n        }\n        events.resetUndoHistory()\n        if (undoTimer.current) {\n          clearTimeout(undoTimer.current)\n          undoTimer.current = null\n        }\n        undoBaseState.current = null\n      },\n      setText: (text) => {\n        events.pushUndo(readSegmentsFromDOM())\n        const segments = plainTextToSegments(text)\n        onChange(segments)\n        renderSegmentsToDOM(segments)\n        const editor = editorRef.current\n        if (editor) setCursorAtOffset(editor, text.length)\n      },\n      appendText: (text) => {\n        const segments = readSegmentsFromDOM()\n        events.pushUndo(segments)\n        // Merge into the trailing text segment so the onChange value doesn't\n        // carry two adjacent un-merged text segments.\n        const last = segments[segments.length - 1]\n        const next: Segment[] =\n          last?.type === 'text'\n            ? [...segments.slice(0, -1), { type: 'text', text: last.text + text }]\n            : [...segments, { type: 'text', text }]\n        onChange(next)\n        renderSegmentsToDOM(next)\n        const editor = editorRef.current\n        if (editor) setCursorAtOffset(editor, segmentsToPlainText(next).length)\n      },\n      getCursorPosition: () => {\n        const editor = editorRef.current\n        return editor ? getCursorOffset(editor) : null\n      },\n      setCursorPosition: (offset) => {\n        const editor = editorRef.current\n        if (editor) setCursorAtOffset(editor, offset)\n      },\n      setCursorToEnd: () => {\n        const editor = editorRef.current\n        if (editor) setCursorAtOffset(editor, segmentsToPlainText(readSegmentsFromDOM()).length)\n      },\n      getSelection: () => {\n        const editor = editorRef.current\n        return editor ? getSelectionOffsets(editor) : null\n      },\n      setSelection: (start, end) => {\n        const editor = editorRef.current\n        if (editor) setSelectionAtOffsets(editor, start, end)\n      },\n    }),\n    [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipAdd, events],\n  )\n\n  // -----------------------------------------------------------------------\n  // Compose event handlers\n  // -----------------------------------------------------------------------\n\n  const handleCompositionStart = useCallback(() => {\n    if (undoTimer.current) {\n      clearTimeout(undoTimer.current)\n      undoTimer.current = null\n    }\n    if (undoBaseState.current) {\n      events.pushUndo(undoBaseState.current)\n      undoBaseState.current = null\n    }\n    // A new composition supersedes any unconsumed trailing-input claim from\n    // the previous one.\n    postCompositionUndo.current = null\n    compositionBaseState.current = readSegmentsFromDOM()\n    events.handleCompositionStart()\n  }, [events, readSegmentsFromDOM])\n\n  const handleCompositionEnd = useCallback(() => {\n    events.handleCompositionEnd()\n    handleInput()\n\n    const before = compositionBaseState.current\n    compositionBaseState.current = null\n    if (!before) {\n      // No baseline: blur already closed this session's bookkeeping and the\n      // compositionend arrived late. There is no composition undo entry to\n      // record, and the debounced group the handleInput above just opened\n      // holds the pre-composition value — cancelling it here would make the\n      // committed text un-undoable. Leave the ordinary path to record it.\n      return\n    }\n\n    // The composition owns this session's undo entry, so the debounced group\n    // handleInput opened for the commit is redundant.\n    if (undoTimer.current) clearTimeout(undoTimer.current)\n    undoTimer.current = null\n    undoBaseState.current = null\n\n    // Push the pre-composition state as the composition's single undo entry\n    // (skipped when the commit didn't change anything — or hasn't reached\n    // the DOM yet). Either way, hand the base to handleInput for the one\n    // trailing input event Chromium may still fire for this composition.\n    const pushed = !segmentsEqual(before, readSegmentsFromDOM())\n    if (pushed) events.pushUndo(before)\n    postCompositionUndo.current = { base: before, pushed }\n  }, [events, handleInput, readSegmentsFromDOM])\n\n  const handleBlur = useCallback(() => {\n    // Blur ends any in-flight composition (events.handleBlur resets the flag);\n    // composition undo state must not survive into a future session.\n    compositionBaseState.current = null\n    postCompositionUndo.current = null\n    events.handleBlur()\n  }, [events])\n\n  const eventHandlers = useMemo(\n    () => ({\n      onPaste: events.handlePaste,\n      onCopy: events.handleCopy,\n      onCut: events.handleCut,\n      onDrop: events.handleDrop,\n      onDragOver: events.handleDragOver,\n      onCompositionStart: handleCompositionStart,\n      onCompositionEnd: handleCompositionEnd,\n      onBlur: handleBlur,\n    }),\n    [\n      events.handlePaste,\n      events.handleCopy,\n      events.handleCut,\n      events.handleDrop,\n      events.handleDragOver,\n      handleCompositionStart,\n      handleCompositionEnd,\n      handleBlur,\n    ],\n  )\n\n  return {\n    editorRef,\n    activeTrigger,\n    suggestions,\n    suggestionsLoading,\n    suggestionsError,\n    selectedSuggestionIndex,\n    handleInput,\n    handleKeyDown,\n    handleClick,\n    handleMouseDown,\n    selectSuggestion,\n    dismissTrigger,\n    handle,\n    triggerRect,\n    eventHandlers,\n  }\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/use-prompt-area-events.ts",
      "content": "'use client'\n\nimport { useCallback, useMemo, useRef } from 'react'\nimport type { Segment, ChipSegment, TriggerConfig } from './types'\nimport { resolveTriggersInSegments } from './prompt-area-engine'\nimport { normalizeEditorDOM, safeJsonStringify, getSelectionRange } from './dom-helpers'\nimport { applySelectionRange } from './cursor-helpers'\nimport {\n  serializeFragmentToPlainText,\n  serializeFragmentToSegments,\n  parseSegmentsFromClipboard,\n  insertSegmentsAtCursor,\n} from './clipboard-helpers'\nimport { htmlToMarkdown, isOfficeHtml } from './html-to-markdown'\nimport {\n  normalizeListPrefixText,\n  renumberOrderedListLines,\n  hasOrderedListRun,\n} from './prompt-area-list-ops'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype EventHandlerDeps = {\n  editorRef: React.RefObject<HTMLDivElement | null>\n  readSegmentsFromDOM: () => Segment[]\n  onChange: (segments: Segment[]) => void\n  renderSegmentsToDOM: (segments: Segment[]) => void\n  runTriggerDetection: () => void\n  dismissTrigger: () => void\n  triggers: TriggerConfig[]\n  /** When true, rich `text/html` on the clipboard is converted to markdown. */\n  markdownEnabled: boolean\n  /** When true, pasted list markers (\"- \") are normalized to the \"•\" glyph. */\n  normalizeBullets: boolean\n  onPaste?: (data: { segments: Segment[]; source: 'internal' | 'external' }) => void\n  onUndo?: (segments: Segment[]) => void\n  onRedo?: (segments: Segment[]) => void\n  onChipAdd?: (chip: ChipSegment) => void\n  onImagePaste?: (file: File) => void\n  onRawPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void\n}\n\ntype PromptAreaEventHandlers = {\n  handlePaste: (e: React.ClipboardEvent<HTMLDivElement>) => void\n  handleCopy: (e: React.ClipboardEvent<HTMLDivElement>) => void\n  handleCut: (e: React.ClipboardEvent<HTMLDivElement>) => void\n  handleDrop: (e: React.DragEvent<HTMLDivElement>) => void\n  handleDragOver: (e: React.DragEvent<HTMLDivElement>) => void\n  handleCompositionStart: () => void\n  handleCompositionEnd: () => void\n  handleBlur: () => void\n  handleKeyDownForUndoRedo: (e: React.KeyboardEvent<HTMLDivElement>) => boolean\n  pushUndo: (segments: Segment[]) => void\n  resetUndoHistory: () => void\n  isComposing: React.RefObject<boolean>\n}\n\n// ---------------------------------------------------------------------------\n// Undo/Redo Stack\n// ---------------------------------------------------------------------------\n\nconst MAX_UNDO_HISTORY = 100\n\n/** Delay before dismissing trigger on blur, so popover clicks register first */\nexport const BLUR_DELAY_MS = 150\n\ntype UndoState = {\n  undoStack: Segment[][]\n  redoStack: Segment[][]\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\n/**\n * Encapsulates all edge-case event handlers for the prompt area component:\n * paste, copy, cut, drag/drop, IME composition, blur, and undo/redo.\n */\nexport function usePromptAreaEvents(deps: EventHandlerDeps): PromptAreaEventHandlers {\n  const {\n    editorRef,\n    readSegmentsFromDOM,\n    onChange,\n    renderSegmentsToDOM,\n    runTriggerDetection,\n    dismissTrigger,\n    triggers,\n    markdownEnabled,\n    normalizeBullets,\n    onPaste: onPasteCallback,\n    onUndo,\n    onRedo,\n    onChipAdd,\n    onImagePaste,\n    onRawPaste,\n  } = deps\n\n  const isComposing = useRef(false)\n\n  // -----------------------------------------------------------------------\n  // Undo/redo stack (MAX_UNDO_HISTORY entries; clears redo on new push).\n  // Invariants: stacks live in refs (not useState) so pushUndo /\n  // resetUndoHistory / handleKeyDownForUndoRedo keep stable identity.\n  // Destabilizing this would re-create handleInput / handleKeyDown /\n  // imperative handle on every render and silently regress IME + debounced\n  // undo in the parent hook.\n  // -----------------------------------------------------------------------\n  const undoState = useRef<UndoState>({ undoStack: [], redoStack: [] })\n\n  const pushUndo = useCallback((segments: Segment[]) => {\n    const state = undoState.current\n    state.undoStack.push(segments)\n    if (state.undoStack.length > MAX_UNDO_HISTORY) {\n      state.undoStack.shift()\n    }\n    // Clear redo stack on new change\n    state.redoStack = []\n  }, [])\n\n  const resetUndoHistory = useCallback(() => {\n    undoState.current = { undoStack: [], redoStack: [] }\n  }, [])\n\n  // -----------------------------------------------------------------------\n  // Paste: strip HTML, insert plain text only\n  // -----------------------------------------------------------------------\n\n  const handlePaste = useCallback(\n    (e: React.ClipboardEvent<HTMLDivElement>) => {\n      // Let a consumer take over the paste entirely (e.g. divert large text or\n      // arbitrary files to an upload pipeline) by calling preventDefault().\n      onRawPaste?.(e)\n      if (e.defaultPrevented) return\n      e.preventDefault()\n\n      const editor = editorRef.current\n      if (!editor) return\n\n      // Check for image files in clipboard before processing text\n      // Some browsers/OSes provide pasted images via `items` instead of `files` (e.g. screenshots).\n      // Exception: Office apps (Word especially, on macOS) put a bitmap RENDERING\n      // of the copied selection on the clipboard alongside text/html; treating\n      // that as an image paste silently dropped the text, so for Office\n      // clipboards the text path wins and the image is only a last resort below.\n      const html = e.clipboardData.getData('text/html')\n      const imageFile =\n        Array.from(e.clipboardData.files).find((f) => f.type.startsWith('image/')) ??\n        (() => {\n          const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith('image/'))\n          return item?.getAsFile() ?? null\n        })()\n      if (imageFile && !isOfficeHtml(html)) {\n        onImagePaste?.(imageFile)\n        return\n      }\n\n      // Record undo snapshot\n      const currentSegments = readSegmentsFromDOM()\n      pushUndo(currentSegments)\n\n      // Check for internal segment data (copy/paste within the editor)\n      const segmentJson = e.clipboardData.getData('text/prompt-area-segments')\n      if (segmentJson) {\n        const parsed = parseSegmentsFromClipboard(segmentJson)\n        if (parsed && parsed.length > 0) {\n          // Insert the copied segments at cursor position\n          const range = getSelectionRange()\n          if (!range) return\n\n          range.deleteContents()\n\n          // Merge pasted segments into current segments at cursor position\n          const beforePaste = readSegmentsFromDOM()\n          const merged = insertSegmentsAtCursor(beforePaste, parsed, editor)\n          onChange(merged)\n          renderSegmentsToDOM(merged)\n\n          // Notify: internal paste with chip data preserved\n          onPasteCallback?.({ segments: merged, source: 'internal' })\n          for (const seg of parsed) {\n            if (seg.type === 'chip') {\n              onChipAdd?.(seg)\n            }\n          }\n\n          runTriggerDetection()\n          return\n        }\n      }\n\n      // When markdown mode is on, prefer the richest clipboard flavor:\n      //   1. text/markdown — some apps (e.g. Slack) hand out markdown directly,\n      //      preserving nested lists that their text/plain flattens.\n      //   2. text/html     — convert web/Notion/Docs/GitHub/Word HTML to markdown.\n      // Otherwise (markdown off, or neither present) fall back to plain text.\n      let text = ''\n      if (markdownEnabled) {\n        text = e.clipboardData.getData('text/markdown')\n        if (text) {\n          // Slack over-escapes inert punctuation (e.g. `\\(` `\\)`); unescape\n          // parentheses so the source reads cleanly. They carry no markdown\n          // meaning, unlike `\\*` / `\\.` / `\\-` which are left intact.\n          text = text.replace(/\\\\([()])/g, '$1')\n        } else if (html) {\n          // A converter failure (e.g. stack overflow on pathologically deep\n          // nesting) must not drop the paste — leave text empty so the\n          // text/plain fallback below still runs.\n          try {\n            text = htmlToMarkdown(html)\n          } catch {\n            text = ''\n          }\n        }\n      }\n      if (!text) text = e.clipboardData.getData('text/plain')\n      if (!text) {\n        // An Office clipboard whose html/plain flavors produced no text — e.g.\n        // an image or drawing object copied inside Word (its HTML is a file:///\n        // <img> that converts to nothing). Fall back to the image flavor\n        // bypassed above so image-only Office copies still reach onImagePaste.\n        if (imageFile) onImagePaste?.(imageFile)\n        return\n      }\n\n      // Normalize pasted list markers (\"- \" → \"•\") so pasted bullets match\n      // typed input. Applies to both the HTML→markdown and plain-text paths.\n      if (markdownEnabled && normalizeBullets) {\n        text = normalizeListPrefixText(text, true)\n      }\n\n      // Rebuild ordered-list numbering in the pasted block so a copied list with\n      // stale numbers lands sequential. Gated on `hasOrderedListRun` so incidental\n      // numeric-leading prose (e.g. `1985. Born / 2020. Died`) is left untouched.\n      // Done at the raw-string level (the caret collapses to end-of-content after\n      // insertion, so no offset remap needed).\n      //\n      // `seedFromFirstNumber` — a paste carries numbering the author chose\n      // elsewhere, so each run keeps its own starting number and only its\n      // contiguity is rebuilt. Without it, pasting a contract's section 7 (or\n      // any `<ol start=\"7\">` a word processor emits) renumbered it to 1.\n      if (markdownEnabled && hasOrderedListRun(text)) {\n        text = renumberOrderedListLines(text, { seedFromFirstNumber: true }).text\n      }\n\n      // Insert plain text at cursor position using Selection API\n      const range = getSelectionRange()\n      if (!range) return\n\n      range.deleteContents()\n\n      // Handle multi-line paste: split into lines with BR elements\n      const lines = text.split('\\n')\n      const fragment = document.createDocumentFragment()\n\n      for (let i = 0; i < lines.length; i++) {\n        if (lines[i]) {\n          fragment.appendChild(document.createTextNode(lines[i]))\n        }\n        if (i < lines.length - 1) {\n          fragment.appendChild(document.createElement('br'))\n        }\n      }\n\n      range.insertNode(fragment)\n\n      // Move cursor to end of pasted content, committed through\n      // applySelectionRange so the caret scrolls into view when the paste\n      // overflows the editor's scroll box (the Selection API never\n      // auto-scrolls on its own).\n      range.collapse(false)\n      applySelectionRange(editor, range)\n\n      // Normalize DOM, sync model, detect triggers\n      normalizeEditorDOM(editor)\n      const newSegments = readSegmentsFromDOM()\n\n      // Auto-resolve trigger patterns in pasted text (e.g., #readme -> chip)\n      const resolvedSegments = resolveTriggersInSegments(newSegments, triggers)\n\n      if (resolvedSegments !== newSegments) {\n        onChange(resolvedSegments)\n        renderSegmentsToDOM(resolvedSegments)\n\n        // Notify about auto-resolved chips from pasted text\n        for (const seg of resolvedSegments) {\n          if (\n            seg.type === 'chip' &&\n            !newSegments.some(\n              (s) =>\n                s.type === 'chip' &&\n                s.trigger === seg.trigger &&\n                s.value === seg.value &&\n                s.displayText === seg.displayText,\n            )\n          ) {\n            onChipAdd?.(seg)\n          }\n        }\n      } else {\n        onChange(newSegments)\n      }\n\n      onPasteCallback?.({ segments: resolvedSegments, source: 'external' })\n      runTriggerDetection()\n    },\n    [\n      editorRef,\n      readSegmentsFromDOM,\n      onChange,\n      pushUndo,\n      runTriggerDetection,\n      renderSegmentsToDOM,\n      triggers,\n      markdownEnabled,\n      normalizeBullets,\n      onPasteCallback,\n      onChipAdd,\n      onImagePaste,\n      onRawPaste,\n    ],\n  )\n\n  // -----------------------------------------------------------------------\n  // Copy: serialize chips into plain text\n  // -----------------------------------------------------------------------\n\n  const handleCopy = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {\n    e.preventDefault()\n\n    const range = getSelectionRange()\n    if (!range) return\n\n    const fragment = range.cloneContents()\n\n    // Walk fragment and serialize, converting chips to their text representation\n    const plainText = serializeFragmentToPlainText(fragment)\n    e.clipboardData.setData('text/plain', plainText)\n\n    // Also serialize chip segments as JSON for internal paste\n    const fragmentSegments = serializeFragmentToSegments(fragment)\n    const hasChips = fragmentSegments.some((s) => s.type === 'chip')\n    if (hasChips) {\n      const json = safeJsonStringify(fragmentSegments)\n      if (json) {\n        e.clipboardData.setData('text/prompt-area-segments', json)\n      }\n    }\n  }, [])\n\n  // -----------------------------------------------------------------------\n  // Cut: copy + delete\n  // -----------------------------------------------------------------------\n\n  const handleCut = useCallback(\n    (e: React.ClipboardEvent<HTMLDivElement>) => {\n      // First, do the copy\n      handleCopy(e)\n\n      // Then delete the selection\n      const range = getSelectionRange()\n      if (!range) return\n\n      const currentSegments = readSegmentsFromDOM()\n      pushUndo(currentSegments)\n\n      range.deleteContents()\n\n      const editor = editorRef.current\n      if (editor) {\n        normalizeEditorDOM(editor)\n      }\n\n      const newSegments = readSegmentsFromDOM()\n      onChange(newSegments)\n      runTriggerDetection()\n    },\n    [handleCopy, editorRef, readSegmentsFromDOM, onChange, pushUndo, runTriggerDetection],\n  )\n\n  // -----------------------------------------------------------------------\n  // Drag & Drop: prevent to avoid unpredictable DOM mutations\n  // -----------------------------------------------------------------------\n\n  const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {\n    e.preventDefault()\n  }, [])\n\n  const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {\n    e.preventDefault()\n  }, [])\n\n  // -----------------------------------------------------------------------\n  // IME Composition: track the composing flag. Trigger detection and undo\n  // bookkeeping for compositions live in usePromptArea's wrappers around\n  // these handlers.\n  // -----------------------------------------------------------------------\n\n  const handleCompositionStart = useCallback(() => {\n    isComposing.current = true\n  }, [])\n\n  const handleCompositionEnd = useCallback(() => {\n    isComposing.current = false\n  }, [])\n\n  // -----------------------------------------------------------------------\n  // Blur: dismiss trigger dropdown with delay (so popover clicks work)\n  // -----------------------------------------------------------------------\n\n  const handleBlur = useCallback(() => {\n    // A blurred contentEditable cannot still be mid-composition, and the\n    // browser may never deliver compositionend once focus is gone. Reset\n    // synchronously — the dismiss below is deliberately delayed, this must\n    // not be.\n    isComposing.current = false\n\n    setTimeout(() => {\n      const editor = editorRef.current\n      if (!editor) return\n\n      // Only dismiss if focus didn't move to an element within the editor container\n      const activeEl = document.activeElement\n      if (activeEl && editor.parentElement?.contains(activeEl)) return\n\n      dismissTrigger()\n    }, BLUR_DELAY_MS)\n  }, [editorRef, dismissTrigger])\n\n  // -----------------------------------------------------------------------\n  // Undo/Redo: intercept Ctrl+Z / Ctrl+Shift+Z\n  // -----------------------------------------------------------------------\n\n  const handleKeyDownForUndoRedo = useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>): boolean => {\n      const isMeta = e.metaKey || e.ctrlKey\n\n      if (!isMeta || e.key !== 'z') return false\n\n      e.preventDefault()\n      const state = undoState.current\n\n      if (e.shiftKey) {\n        // Redo: Ctrl+Shift+Z\n        if (state.redoStack.length === 0) return true\n\n        const segments = state.redoStack.pop()\n        if (!segments) return true\n\n        const current = readSegmentsFromDOM()\n        state.undoStack.push(current)\n\n        onChange(segments)\n        renderSegmentsToDOM(segments)\n        onRedo?.(segments)\n      } else {\n        // Undo: Ctrl+Z\n        if (state.undoStack.length === 0) return true\n\n        const segments = state.undoStack.pop()\n        if (!segments) return true\n\n        const current = readSegmentsFromDOM()\n        state.redoStack.push(current)\n\n        onChange(segments)\n        renderSegmentsToDOM(segments)\n        onUndo?.(segments)\n      }\n\n      return true\n    },\n    [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onUndo, onRedo],\n  )\n\n  // A stable object: every member is a stable useCallback (or a ref), and\n  // downstream hooks — handleInput, the imperative handle, the eventHandlers\n  // memo — list `events` in their deps. A fresh literal here would rebuild all\n  // of them (and re-run useImperativeHandle) on every keystroke's render.\n  return useMemo(\n    () => ({\n      handlePaste,\n      handleCopy,\n      handleCut,\n      handleDrop,\n      handleDragOver,\n      handleCompositionStart,\n      handleCompositionEnd,\n      handleBlur,\n      handleKeyDownForUndoRedo,\n      pushUndo,\n      resetUndoHistory,\n      isComposing,\n    }),\n    [\n      handlePaste,\n      handleCopy,\n      handleCut,\n      handleDrop,\n      handleDragOver,\n      handleCompositionStart,\n      handleCompositionEnd,\n      handleBlur,\n      handleKeyDownForUndoRedo,\n      pushUndo,\n      resetUndoHistory,\n      isComposing,\n    ],\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/html-to-markdown.ts",
      "content": "/**\n * Hand-rolled, dependency-free HTML -> Markdown converter.\n *\n * Used by the paste handler: when the editor is in markdown mode and the\n * clipboard carries rich `text/html` (web pages, Notion, Google Docs, GitHub,\n * Slack, Word, etc.), we convert it to markdown SOURCE text so the paste keeps its\n * formatting. The resulting string flows through the same insertion path as a\n * plain-text paste, and the editor's inline decorators render `*`/`**`/`***`\n * and bare URLs automatically.\n *\n * Design constraints (see .size-limit.json): no runtime deps. Parsing uses the\n * ambient `DOMParser`, walking is a small recursive switch. Type-safe: no\n * `any`, DOM narrowed via the guards in `dom-helpers.ts`.\n */\nimport { isHTMLElement, isTextNode } from './dom-helpers'\n\n// ---------------------------------------------------------------------------\n// Inline style / emphasis detection\n// ---------------------------------------------------------------------------\n\n/** Reads a single declaration value from an inline `style` attribute string. */\nfunction getStyleValue(style: string, prop: string): string {\n  const match = new RegExp(`(?:^|;)\\\\s*${prop}\\\\s*:\\\\s*([^;]+)`, 'i').exec(style)\n  return match ? match[1].trim().toLowerCase() : ''\n}\n\n/** Whether a CSS font-weight value is bold (`bold`, `bolder`, or >= 600). */\nfunction isBoldWeight(value: string): boolean {\n  if (value === 'bold' || value === 'bolder') return true\n  const numeric = Number.parseInt(value, 10)\n  return !Number.isNaN(numeric) && numeric >= 600\n}\n\n/**\n * Computes the markdown emphasis markers for an element from BOTH its tag and\n * its inline style. Google Docs emits `<span style=\"font-weight:700\">` rather\n * than `<b>`, and wraps everything in `<b style=\"font-weight:normal\">`, so an\n * explicit `font-weight`/`font-style` always wins over the tag name.\n */\nfunction inlineEmphasis(node: HTMLElement): { prefix: string; suffix: string } {\n  const tag = node.tagName\n  const style = node.getAttribute('style') ?? ''\n\n  const weight = getStyleValue(style, 'font-weight')\n  const bold = weight ? isBoldWeight(weight) : tag === 'B' || tag === 'STRONG'\n\n  const fontStyle = getStyleValue(style, 'font-style')\n  const italic = fontStyle\n    ? fontStyle === 'italic' || fontStyle === 'oblique'\n    : tag === 'I' || tag === 'EM'\n\n  const decoration = getStyleValue(style, 'text-decoration')\n  const strike =\n    tag === 'S' || tag === 'DEL' || tag === 'STRIKE' || decoration.includes('line-through')\n\n  const prefix = (strike ? '~~' : '') + (bold ? '**' : '') + (italic ? '*' : '')\n  const suffix = (italic ? '*' : '') + (bold ? '**' : '') + (strike ? '~~' : '')\n  return { prefix, suffix }\n}\n\n// ---------------------------------------------------------------------------\n// Text handling\n// ---------------------------------------------------------------------------\n\n/** Collapses HTML whitespace runs (incl. `&nbsp;` -> U+00A0) to single spaces. */\nfunction collapseWhitespace(text: string): string {\n  return text.replace(/\\s+/g, ' ')\n}\n\n/** Escapes literal `*` from HTML text so prose isn't re-read as emphasis. */\nfunction escapeText(text: string): string {\n  return text.replace(/\\*/g, '\\\\*')\n}\n\n// ---------------------------------------------------------------------------\n// Microsoft Office clipboard HTML (Word / Outlook / Excel)\n// ---------------------------------------------------------------------------\n\n/**\n * Office fingerprints: `urn:schemas-microsoft-com:office` xmlns declarations,\n * `<meta name=ProgId content=Word.Document>`, `Mso*` class names (quoted or\n * Word's unquoted `class=MsoNormal`), and `mso-*` style declarations.\n */\nconst OFFICE_HTML_MARKER =\n  /urn:schemas-microsoft-com:office|name=[\"']?ProgId|class=[\"']?Mso|\\bmso-[\\w-]+\\s*:/i\n\n/**\n * Whether clipboard HTML came from a Microsoft Office app. Office apps put a\n * bitmap RENDERING of the copied selection on the clipboard alongside the\n * HTML (macOS Word especially), so the paste handler uses this to keep a\n * text copy from being mistaken for an image paste.\n */\nexport function isOfficeHtml(html: string): boolean {\n  return OFFICE_HTML_MARKER.test(html)\n}\n\n/** Word's inline list metadata: `mso-list:l0 level2 lfo1` → nesting level 2. */\nconst MSO_LIST_LEVEL = /mso-list:\\s*l\\d+\\s+level(\\d+)/i\n/** `1.` / `12)` — numeric ordered marker; captures the digits. */\nconst NUMERIC_MARKER = /^(\\d+)[.)]/\n/** `a.` / `B)` / `iv.` — alphabetic/roman ordered marker. */\nconst ALPHA_MARKER = /^[a-z]{1,5}[.)]/i\n\ntype WordListItem = { p: HTMLElement; level: number }\n\n/**\n * Classifies a `<p>` as a Word list paragraph. Word clipboard HTML has no\n * `<ul>/<ol>`: each item is a paragraph whose inline style carries\n * `mso-list:l<id> level<n> lfo<id>`, with the visible marker glyph in a\n * `<span style='mso-list:Ignore'>` behind conditional comments. The class\n * (`MsoListParagraph*`) is deliberately NOT a signal — Word puts it on\n * marker-less continuation paragraphs too.\n */\nfunction wordListLevel(p: HTMLElement): number | null {\n  const match = MSO_LIST_LEVEL.exec(p.getAttribute('style') ?? '')\n  return match ? Number(match[1]) : null\n}\n\n/**\n * Detaches the `mso-list:Ignore` marker span and returns its text (`·`, `o`,\n * `§`, `1.`, `a.` …) for classification, keeping the glyph out of the item's\n * serialized content. Returns '' when no marker span is revealed (the\n * downlevel-hidden conditional-comment variant).\n */\nfunction takeWordListMarker(p: HTMLElement): string {\n  for (const span of Array.from(p.querySelectorAll('span'))) {\n    if (getStyleValue(span.getAttribute('style') ?? '', 'mso-list') === 'ignore') {\n      span.remove()\n      return (span.textContent ?? '').trim()\n    }\n  }\n  return ''\n}\n\n/**\n * Serializes a run of consecutive Word list paragraphs as markdown list lines —\n * single-newline separated, 2-space indent per level, the exact shape\n * `serializeList` emits for real `<ul>/<ol>`. Numeric markers seed the\n * per-level counter from their own digits (so a list continued after an\n * interrupting paragraph keeps Word's numbering); alpha/roman markers count\n * from 1; anything else (`·`, `o`, `§`, Wingdings, or a hidden marker) is a\n * bullet.\n */\nfunction serializeWordListRun(items: WordListItem[], depth: number): string {\n  const counters = new Map<number, number>()\n  const lines: string[] = []\n  for (const { p, level } of items) {\n    // Returning to a shallower level ends its deeper sublists.\n    for (const key of Array.from(counters.keys())) {\n      if (key > level) counters.delete(key)\n    }\n    const markerText = takeWordListMarker(p)\n    const numeric = NUMERIC_MARKER.exec(markerText)\n    let marker = '- '\n    if (numeric || ALPHA_MARKER.test(markerText)) {\n      const n = (counters.get(level) ?? (numeric ? Number(numeric[1]) - 1 : 0)) + 1\n      counters.set(level, n)\n      marker = `${n}. `\n    } else {\n      counters.delete(level)\n    }\n    lines.push(`${'  '.repeat(depth + level - 1)}${marker}${serializeChildren(p, depth).trim()}`)\n  }\n  return lines.join('\\n')\n}\n\n// ---------------------------------------------------------------------------\n// Block serializers\n// ---------------------------------------------------------------------------\n\n/** Derives a fenced-block language from `class=\"language-ts\"` or `lang=\"ts\"`. */\nfunction detectCodeLang(pre: HTMLElement): string {\n  const code = pre.querySelector('code')\n  const classNames = `${pre.className} ${code?.className ?? ''}`\n  const fromClass = /(?:language|lang)-([\\w-]+)/.exec(classNames)\n  if (fromClass) return fromClass[1]\n  return pre.getAttribute('lang') ?? code?.getAttribute('lang') ?? ''\n}\n\nfunction serializePre(pre: HTMLElement): string {\n  const lang = detectCodeLang(pre)\n  const raw = (pre.textContent ?? '').replace(/\\n$/, '')\n  return `\\n\\n\\`\\`\\`${lang}\\n${raw}\\n\\`\\`\\`\\n\\n`\n}\n\nfunction serializeInlineCode(node: HTMLElement): string {\n  const content = node.textContent ?? ''\n  if (content.includes('`')) return `\\`\\` ${content} \\`\\``\n  return `\\`${content}\\``\n}\n\n/** http(s)/mailto only; drops `#`, empty, and `javascript:` hrefs. */\nfunction isSafeHref(href: string): boolean {\n  return /^(https?:|mailto:)/i.test(href)\n}\n\nfunction serializeAnchor(node: HTMLElement, depth: number): string {\n  const href = node.getAttribute('href') ?? ''\n  const label = serializeChildren(node, depth).trim()\n  if (!isSafeHref(href)) return label\n  if (!label || label === href) return href\n  return `[${label}](${href})`\n}\n\nfunction serializeImage(node: HTMLElement): string {\n  const src = node.getAttribute('src') ?? ''\n  // Gate the src through the same allow-list as anchors: only http(s)/mailto\n  // survive, so a `javascript:`/`vbscript:`/`data:` src never reaches the\n  // emitted markdown (defense-in-depth for consumers that render it as HTML).\n  if (!src || !isSafeHref(src)) return ''\n  return `![${node.getAttribute('alt') ?? ''}](${src})`\n}\n\nfunction serializeBlockquote(node: HTMLElement, depth: number): string {\n  const inner = serializeChildren(node, depth).trim()\n  const quoted = inner\n    .split('\\n')\n    .map((line) => (line ? `> ${line}` : '>'))\n    .join('\\n')\n  return `\\n\\n${quoted}\\n\\n`\n}\n\n/**\n * Serializes a `<ul>`/`<ol>` at nesting `depth` (0 = top level). Each `<li>`'s\n * own inline content becomes the marker line; a nested `<ul>`/`<ol>` is\n * serialized at `depth + 1` and appended indented below its parent item.\n *\n * A sublist reaches us in one of two shapes. The spec-correct one nests it\n * INSIDE its parent `<li>`; Word, Outlook and Apple Notes instead emit it as a\n * SIBLING of the `<li>` it belongs to. Both are handled — skipping the sibling\n * form (anything that is not an `<li>`) silently dropped every nested item\n * from such a paste.\n */\nfunction serializeList(list: HTMLElement, depth: number): string {\n  const ordered = list.tagName === 'OL'\n  const start = Number.parseInt(list.getAttribute('start') ?? '', 10)\n  let index = Number.isNaN(start) ? 1 : start\n  const indent = '  '.repeat(depth)\n  const lines: string[] = []\n\n  for (const child of Array.from(list.childNodes)) {\n    if (!isHTMLElement(child)) continue\n\n    // Sublist emitted as a sibling of its parent item — indent it one level\n    // deeper and keep it in document order. It is not an item of THIS list,\n    // so it must not advance the ordered counter.\n    if (child.tagName === 'UL' || child.tagName === 'OL') {\n      const nestedSibling = serializeList(child, depth + 1)\n      if (nestedSibling) lines.push(nestedSibling)\n      continue\n    }\n\n    if (child.tagName !== 'LI') continue\n\n    const marker = ordered ? `${index}. ` : '- '\n    index++\n\n    let label = ''\n    let nested = ''\n    for (const liChild of Array.from(child.childNodes)) {\n      if (isHTMLElement(liChild) && (liChild.tagName === 'UL' || liChild.tagName === 'OL')) {\n        nested += `\\n${serializeList(liChild, depth + 1)}`\n      } else {\n        label += serializeNode(liChild, depth)\n      }\n    }\n    lines.push(`${indent}${marker}${label.trim()}${nested}`)\n  }\n\n  return lines.join('\\n')\n}\n\nfunction serializeTable(table: HTMLElement, depth: number): string {\n  const rows = Array.from(table.querySelectorAll('tr'))\n  if (rows.length === 0) return ''\n\n  const cells = rows.map((row) =>\n    Array.from(row.children)\n      .filter((cell) => cell.tagName === 'TD' || cell.tagName === 'TH')\n      .map((cell) =>\n        serializeChildren(cell, depth).replace(/\\n+/g, ' ').replace(/\\|/g, '\\\\|').trim(),\n      ),\n  )\n\n  const header = cells[0]\n  const separator = header.map(() => '---')\n  const toRow = (row: string[]): string => `| ${row.join(' | ')} |`\n\n  return [toRow(header), toRow(separator), ...cells.slice(1).map(toRow)].join('\\n')\n}\n\n// ---------------------------------------------------------------------------\n// Recursive walker\n// ---------------------------------------------------------------------------\n\nfunction serializeChildren(node: Node, depth: number): string {\n  let out = ''\n  const children = Array.from(node.childNodes)\n  for (let i = 0; i < children.length; i++) {\n    const child = children[i]\n    if (isHTMLElement(child) && child.tagName === 'P') {\n      const level = wordListLevel(child)\n      if (level !== null) {\n        // Consume the whole run of consecutive Word list items, skipping the\n        // whitespace text nodes and comments Word puts between them, and\n        // serialize it as one list block (same `\\n\\n…\\n\\n` contract as UL/OL).\n        const run: WordListItem[] = [{ p: child, level }]\n        let j = i + 1\n        for (; j < children.length; j++) {\n          const next = children[j]\n          if (isHTMLElement(next)) {\n            const nextLevel = next.tagName === 'P' ? wordListLevel(next) : null\n            if (nextLevel === null) break\n            run.push({ p: next, level: nextLevel })\n          } else if (isTextNode(next) && (next.textContent ?? '').trim() !== '') {\n            break\n          }\n        }\n        out += `\\n\\n${serializeWordListRun(run, depth)}\\n\\n`\n        i = j - 1\n        continue\n      }\n    }\n    out += serializeNode(child, depth)\n  }\n  return out\n}\n\nfunction serializeNode(node: Node, depth: number): string {\n  if (isTextNode(node)) return escapeText(collapseWhitespace(node.textContent ?? ''))\n  if (!isHTMLElement(node)) return ''\n\n  const tag = node.tagName\n  switch (tag) {\n    case 'SCRIPT':\n    case 'STYLE':\n    case 'NOSCRIPT':\n    case 'HEAD':\n    case 'TITLE':\n    case 'XML': // Word <xml> data island — the parser reparents it out of <head>\n    case 'O:P': // Word paragraph-mark placeholder (often wraps a lone &nbsp;)\n      return ''\n    case 'BR':\n      return '\\n'\n    case 'HR':\n      return '\\n\\n---\\n\\n'\n    case 'H1':\n    case 'H2':\n    case 'H3':\n    case 'H4':\n    case 'H5':\n    case 'H6':\n      return `\\n\\n${'#'.repeat(Number(tag[1]))} ${serializeChildren(node, depth).trim()}\\n\\n`\n    case 'P':\n      return `\\n\\n${serializeChildren(node, depth).trim()}\\n\\n`\n    case 'DIV':\n      return `\\n${serializeChildren(node, depth).trim()}\\n`\n    case 'BLOCKQUOTE':\n      return serializeBlockquote(node, depth)\n    case 'UL':\n    case 'OL':\n      return `\\n\\n${serializeList(node, depth)}\\n\\n`\n    case 'LI':\n      // A stray <li> outside a list wrapper — emit its content as a line.\n      return `${serializeChildren(node, depth).trim()}\\n`\n    case 'PRE':\n      return serializePre(node)\n    case 'CODE':\n      // Inline code only: <pre> handles its own <code> via textContent.\n      return serializeInlineCode(node)\n    case 'A':\n      return serializeAnchor(node, depth)\n    case 'IMG':\n      return serializeImage(node)\n    case 'TABLE':\n      return `\\n\\n${serializeTable(node, depth)}\\n\\n`\n    default: {\n      // Inline emphasis (B/STRONG/I/EM/S/DEL + styled SPAN/FONT) and the\n      // \"span soup\" unwrap case both resolve here: emphasis markers when the\n      // tag or inline style is meaningful, otherwise a bare unwrap.\n      const { prefix, suffix } = inlineEmphasis(node)\n      return prefix + serializeChildren(node, depth) + suffix\n    }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Output normalization\n// ---------------------------------------------------------------------------\n\n/** Trims trailing spaces and caps consecutive blank lines at one. */\nfunction normalizeOutput(markdown: string): string {\n  return markdown\n    .replace(/[ \\t]+\\n/g, '\\n')\n    .replace(/\\n[ \\t]*\\n[ \\t\\n]*/g, '\\n\\n')\n    .trim()\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Converts an HTML string to markdown source text. Returns '' for empty or\n * body-less input. Block markdown (headings, lists, quotes, fences, tables,\n * links) is emitted as literal markdown text — that is the editor's intended\n * display; only `*`/`**`/`***` and bare URLs get visually decorated inline.\n */\nexport function htmlToMarkdown(html: string): string {\n  if (!html) return ''\n  const doc = new DOMParser().parseFromString(html, 'text/html')\n  if (!doc.body) return ''\n  return normalizeOutput(serializeChildren(doc.body, 0))\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/prompt-area-engine.ts",
      "content": "/**\n * Pure logic engine for the PromptArea component.\n * No DOM dependencies - fully testable in Node.\n */\nimport type { Segment, ChipSegment, TriggerConfig, TriggerPosition, ActiveTrigger } from './types'\n\n// ---------------------------------------------------------------------------\n// Serialization\n// ---------------------------------------------------------------------------\n\n/**\n * Converts an array of segments to a plain text string.\n * Chips are represented as `{trigger}{displayText}` (e.g., \"@Alice\").\n */\nexport function segmentsToPlainText(segments: Segment[]): string {\n  return segments\n    .map((seg) => {\n      if (seg.type === 'text') return seg.text\n      return `${seg.trigger}${seg.displayText}`\n    })\n    .join('')\n}\n\n/**\n * Converts plain text into a single text segment.\n * Used for initial value conversion from plain strings.\n */\nexport function plainTextToSegments(text: string): Segment[] {\n  if (!text) return []\n  return [{ type: 'text', text }]\n}\n\n/**\n * Truncates segments so their combined plain-text length is at most `maxLength`.\n * Whole segments are kept while they fit; a text segment that crosses the limit\n * is sliced to fit, and a chip that would cross the limit is dropped (a chip\n * can't be partially represented).\n */\nexport function truncateSegmentsToLength(segments: Segment[], maxLength: number): Segment[] {\n  if (maxLength <= 0) return []\n  const result: Segment[] = []\n  let length = 0\n  for (const seg of segments) {\n    const segLength =\n      seg.type === 'text' ? seg.text.length : seg.trigger.length + seg.displayText.length\n    if (length + segLength <= maxLength) {\n      result.push(seg)\n      length += segLength\n      continue\n    }\n    if (seg.type === 'text') {\n      let remaining = maxLength - length\n      if (remaining > 0) {\n        // Don't split a surrogate pair: if the cut lands right after a high\n        // surrogate, drop the incomplete code point instead of a lone surrogate.\n        const code = seg.text.charCodeAt(remaining - 1)\n        if (code >= 0xd800 && code <= 0xdbff) remaining -= 1\n        if (remaining > 0) result.push({ type: 'text', text: seg.text.slice(0, remaining) })\n      }\n    }\n    break\n  }\n  return result\n}\n\n// ---------------------------------------------------------------------------\n// Whitespace / word boundaries\n// ---------------------------------------------------------------------------\n\n/**\n * Single source of truth for what counts as an inline-whitespace word boundary\n * in the editor model: a space, newline, or tab.\n *\n * Trigger detection, paste auto-resolution, and position validation all rely\n * on the *same* notion of a boundary — keeping it here prevents the three\n * call sites from silently drifting apart (e.g. one handling tabs and the\n * others not).\n */\nexport function isInlineWhitespace(char: string | undefined): boolean {\n  return char === ' ' || char === '\\n' || char === '\\t'\n}\n\n/**\n * Builds a lookup from trigger character to its config. When two triggers\n * share a character the first one wins, preserving the previous `Array.find`\n * semantics while turning the per-character scan into an O(1) map read.\n */\nfunction buildTriggerCharMap(triggers: TriggerConfig[]): Map<string, TriggerConfig> {\n  const map = new Map<string, TriggerConfig>()\n  for (const trigger of triggers) {\n    if (!map.has(trigger.char)) map.set(trigger.char, trigger)\n  }\n  return map\n}\n\n// ---------------------------------------------------------------------------\n// Trigger position validation\n// ---------------------------------------------------------------------------\n\n/**\n * Checks whether a trigger character at the given position in text\n * is valid according to the position rule.\n *\n * @param text - The full text content\n * @param charIndex - The index of the trigger character in the text\n * @param position - The position rule to validate against\n */\nexport function isValidTriggerPosition(\n  text: string,\n  charIndex: number,\n  position: TriggerPosition,\n): boolean {\n  if (charIndex === 0) return true\n\n  const prevChar = text[charIndex - 1]\n\n  if (position === 'start') {\n    return prevChar === '\\n'\n  }\n\n  // position === 'any': valid after any whitespace\n  return isInlineWhitespace(prevChar)\n}\n\n// ---------------------------------------------------------------------------\n// Trigger detection\n// ---------------------------------------------------------------------------\n\n/**\n * Scans backwards from the cursor position to detect if the user is\n * currently typing a trigger word.\n *\n * Returns the active trigger info, or null if no trigger is active.\n *\n * @param text - The full plain text content\n * @param cursorPos - The cursor position (character offset from start)\n * @param triggers - Available trigger configurations\n */\nexport function detectActiveTrigger(\n  text: string,\n  cursorPos: number,\n  triggers: TriggerConfig[],\n): ActiveTrigger | null {\n  if (!text || cursorPos === 0 || triggers.length === 0) return null\n\n  const triggerByChar = buildTriggerCharMap(triggers)\n\n  // Scan backwards from cursor to find the nearest trigger character.\n  // Stop at whitespace (trigger word has ended) or start of text.\n  for (let i = cursorPos - 1; i >= 0; i--) {\n    const char = text[i]\n\n    // If we hit whitespace before finding a trigger, check if this whitespace\n    // is immediately followed by a trigger character\n    if (isInlineWhitespace(char)) {\n      // The character after this whitespace could be a trigger\n      if (i + 1 < cursorPos) {\n        const nextChar = text[i + 1]\n        const matchingTrigger = triggerByChar.get(nextChar)\n        if (matchingTrigger && isValidTriggerPosition(text, i + 1, matchingTrigger.position)) {\n          return {\n            config: matchingTrigger,\n            startOffset: i + 1,\n            query: text.slice(i + 2, cursorPos),\n          }\n        }\n      }\n      // No trigger found after this whitespace, stop searching\n      return null\n    }\n\n    // Check if this character is a trigger character\n    const matchingTrigger = triggerByChar.get(char)\n    if (matchingTrigger && isValidTriggerPosition(text, i, matchingTrigger.position)) {\n      return {\n        config: matchingTrigger,\n        startOffset: i,\n        query: text.slice(i + 1, cursorPos),\n      }\n    }\n  }\n\n  return null\n}\n\n// ---------------------------------------------------------------------------\n// Chip resolution\n// ---------------------------------------------------------------------------\n\n/**\n * Resolves an active trigger into a chip within the segments array.\n * Replaces the trigger text (trigger char + query) with a chip segment.\n *\n * @param segments - Current document segments\n * @param activeTrigger - The active trigger to resolve\n * @param chip - The chip data (value, displayText, optional data)\n * @returns New segments array with the chip inserted, and the new cursor position\n */\nexport function resolveChip(\n  segments: Segment[],\n  activeTrigger: ActiveTrigger,\n  chip: { value: string; displayText: string; data?: unknown; autoResolved?: boolean },\n): { segments: Segment[]; cursorOffset: number } {\n  const triggerStart = activeTrigger.startOffset\n  const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char\n\n  // Build the new segments by mapping plain text positions back to segment boundaries\n  const newSegments: Segment[] = []\n  let offset = 0\n\n  for (const seg of segments) {\n    if (seg.type === 'chip') {\n      const chipText = `${seg.trigger}${seg.displayText}`\n      const chipStart = offset\n      const chipEnd = offset + chipText.length\n\n      // If the trigger range overlaps with this chip, something is wrong.\n      // Chips should not be partially replaced.\n      if (chipEnd <= triggerStart || chipStart >= triggerEnd) {\n        newSegments.push(seg)\n      }\n      offset = chipEnd\n    } else {\n      const textStart = offset\n      const textEnd = offset + seg.text.length\n\n      if (textEnd <= triggerStart) {\n        // Entirely before the trigger - keep as-is\n        newSegments.push(seg)\n      } else if (textStart >= triggerEnd) {\n        // Entirely after the trigger - keep as-is\n        newSegments.push(seg)\n      } else {\n        // This text segment contains (part of) the trigger range\n        const beforeText = seg.text.slice(0, Math.max(0, triggerStart - textStart))\n        const afterText = seg.text.slice(Math.min(seg.text.length, triggerEnd - textStart))\n\n        if (beforeText) {\n          newSegments.push({ type: 'text', text: beforeText })\n        }\n\n        const newChip: ChipSegment = {\n          type: 'chip',\n          trigger: activeTrigger.config.char,\n          value: chip.value,\n          displayText: chip.displayText,\n          ...(chip.data !== undefined ? { data: chip.data } : {}),\n          ...(chip.autoResolved ? { autoResolved: true } : {}),\n        }\n        newSegments.push(newChip)\n\n        // Add trailing space after chip, then any remaining text\n        if (afterText) {\n          newSegments.push({ type: 'text', text: ' ' + afterText.replace(/^\\s/, '') })\n        } else {\n          newSegments.push({ type: 'text', text: ' ' })\n        }\n      }\n\n      offset = textEnd\n    }\n  }\n\n  // Merge adjacent text segments\n  const merged = mergeAdjacentTextSegments(newSegments)\n\n  // Cursor should be placed after the chip + trailing space.\n  // Find the *last* matching chip so duplicates resolve correctly.\n  let lastChipEndOffset = -1\n  let runningOffset = 0\n  for (const seg of merged) {\n    if (seg.type === 'text') {\n      runningOffset += seg.text.length\n    } else {\n      runningOffset += seg.trigger.length + seg.displayText.length\n      if (\n        seg.value === chip.value &&\n        seg.displayText === chip.displayText &&\n        seg.trigger === activeTrigger.config.char\n      ) {\n        lastChipEndOffset = runningOffset\n      }\n    }\n  }\n  // +1 accounts for the trailing space after the chip\n  const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset + 1\n\n  return { segments: merged, cursorOffset }\n}\n\n// ---------------------------------------------------------------------------\n// Chip removal\n// ---------------------------------------------------------------------------\n\n/**\n * Removes a chip at the given segment index and merges adjacent text segments.\n *\n * @param segments - Current document segments\n * @param index - The segment index to remove\n * @returns New segments array with the chip removed\n */\nexport function removeChipAtIndex(segments: Segment[], index: number): Segment[] {\n  if (index < 0 || index >= segments.length) return segments\n  if (segments[index].type !== 'chip') return segments\n\n  const result = [...segments.slice(0, index), ...segments.slice(index + 1)]\n  return mergeAdjacentTextSegments(result)\n}\n\n/**\n * Reverts an auto-resolved chip at the given segment index back to plain text.\n * The text includes the trigger character + display text (e.g., \"#readme\").\n *\n * @param segments - Current document segments\n * @param index - The segment index to revert\n * @returns New segments with the chip replaced by text, or null if not applicable\n */\nexport function revertChipAtIndex(\n  segments: Segment[],\n  index: number,\n): { segments: Segment[]; revertedText: string } | null {\n  if (index < 0 || index >= segments.length) return null\n  const seg = segments[index]\n  if (seg.type !== 'chip' || !seg.autoResolved) return null\n\n  const revertedText = `${seg.trigger}${seg.displayText}`\n  const result = [\n    ...segments.slice(0, index),\n    { type: 'text' as const, text: revertedText },\n    ...segments.slice(index + 1),\n  ]\n  return { segments: mergeAdjacentTextSegments(result), revertedText }\n}\n\n// ---------------------------------------------------------------------------\n// Paste: resolve trigger patterns in segments\n// ---------------------------------------------------------------------------\n\n/**\n * Scans text segments for trigger patterns and auto-resolves them into chips.\n * Only resolves triggers that have `resolveOnSpace: true`.\n *\n * Trigger patterns must appear at word boundaries: start of text, after\n * whitespace, or after a newline. This avoids false positives like email\n * addresses (user@example.com).\n */\nexport function resolveTriggersInSegments(\n  segments: Segment[],\n  triggers: TriggerConfig[],\n): Segment[] {\n  const autoResolveTriggers = triggers.filter((t) => t.resolveOnSpace)\n  if (autoResolveTriggers.length === 0) return segments\n\n  const triggerByChar = buildTriggerCharMap(autoResolveTriggers)\n  const result: Segment[] = []\n\n  for (const seg of segments) {\n    if (seg.type === 'chip') {\n      result.push(seg)\n      continue\n    }\n\n    const parts = splitTextByTriggerPatterns(seg.text, triggerByChar)\n    result.push(...parts)\n  }\n\n  return mergeAdjacentTextSegments(result)\n}\n\n/**\n * Splits a text string into text and chip segments based on trigger patterns.\n * A trigger pattern is: (start-of-string | whitespace) + trigger_char + word_chars\n * followed by whitespace or end-of-string.\n */\nfunction splitTextByTriggerPatterns(\n  text: string,\n  triggerByChar: Map<string, TriggerConfig>,\n): Segment[] {\n  if (!text) return []\n\n  const segments: Segment[] = []\n  let i = 0\n\n  while (i < text.length) {\n    const char = text[i]\n\n    if (triggerByChar.has(char)) {\n      const isAtBoundary = i === 0 || isInlineWhitespace(text[i - 1])\n\n      if (isAtBoundary) {\n        const trigger = triggerByChar.get(char)\n        if (trigger && isValidTriggerPosition(text, i, trigger.position)) {\n          let end = i + 1\n          while (end < text.length && !isInlineWhitespace(text[end])) {\n            end++\n          }\n\n          const query = text.slice(i + 1, end)\n          if (query.length > 0) {\n            // Treat both undefined and '' from onSelect as \"no custom label\"\n            // and fall back to the query — an empty displayText would render\n            // a blank chip.\n            const displayText = trigger.onSelect?.({ value: query, label: query }) || query\n            segments.push({\n              type: 'chip',\n              trigger: char,\n              value: query,\n              displayText,\n              autoResolved: true,\n            })\n            i = end\n            continue\n          }\n        }\n      }\n    }\n\n    const start = i\n    i++\n    while (i < text.length && !(triggerByChar.has(text[i]) && isInlineWhitespace(text[i - 1]))) {\n      i++\n    }\n    segments.push({ type: 'text', text: text.slice(start, i) })\n  }\n\n  return segments\n}\n\n// ---------------------------------------------------------------------------\n// Text range replacement\n// ---------------------------------------------------------------------------\n\n/**\n * Replaces a range of plain text within the segments array.\n * Handles segment boundaries correctly, preserving chip segments.\n *\n * @param segments - Current document segments\n * @param start - Start offset in plain text\n * @param end - End offset in plain text\n * @param replacement - The replacement text\n * @returns New segments array with the replacement applied\n */\nexport function replaceTextRange(\n  segments: Segment[],\n  start: number,\n  end: number,\n  replacement: string,\n): Segment[] {\n  const newSegments: Segment[] = []\n  let offset = 0\n  let inserted = false\n\n  for (const seg of segments) {\n    if (seg.type === 'chip') {\n      const chipText = `${seg.trigger}${seg.displayText}`\n      const chipStart = offset\n      const chipEnd = offset + chipText.length\n\n      // For insertion (start === end), insert before this chip if position matches\n      if (!inserted && start === end && chipStart === start) {\n        newSegments.push({ type: 'text', text: replacement })\n        inserted = true\n      }\n\n      if (chipEnd <= start || chipStart >= end) {\n        newSegments.push(seg)\n      }\n      // Chips within the range are removed\n      offset = chipEnd\n    } else {\n      const textStart = offset\n      const textEnd = offset + seg.text.length\n\n      // Check if this segment contains the insertion/replacement point\n      const isBefore = start === end ? textEnd < start : textEnd <= start\n      const isAfter = start === end ? textStart > end : textStart >= end\n\n      if (isBefore) {\n        // Entirely before the range\n        newSegments.push(seg)\n      } else if (isAfter) {\n        // Entirely after the range\n        newSegments.push(seg)\n      } else {\n        // Overlaps with the range (or contains the insertion point)\n        const beforeText = seg.text.slice(0, Math.max(0, start - textStart))\n        const afterText = seg.text.slice(Math.min(seg.text.length, end - textStart))\n\n        if (beforeText) {\n          newSegments.push({ type: 'text', text: beforeText })\n        }\n        // Insert replacement only once (when we first enter the range)\n        if (!inserted && textStart <= start) {\n          newSegments.push({ type: 'text', text: replacement })\n          inserted = true\n        }\n        if (afterText) {\n          newSegments.push({ type: 'text', text: afterText })\n        }\n      }\n\n      offset = textEnd\n    }\n  }\n\n  // Fallback: if replacement wasn't inserted (e.g., insertion at very end)\n  if (!inserted && replacement) {\n    newSegments.push({ type: 'text', text: replacement })\n  }\n\n  return mergeAdjacentTextSegments(newSegments)\n}\n\n// ---------------------------------------------------------------------------\n// Markdown formatting shortcuts\n// ---------------------------------------------------------------------------\n\n/**\n * Toggles markdown wrap markers around a selected text range.\n * If the selection is already wrapped with the marker, unwraps it.\n * If not wrapped, wraps it.\n *\n * @param segments - Current document segments\n * @param selectionStart - Start offset in plain text\n * @param selectionEnd - End offset in plain text\n * @param marker - The markdown marker (e.g., '**' for bold, '*' for italic)\n * @returns New segments and selection offsets, or null if selection is collapsed\n */\nexport function toggleMarkdownWrap(\n  segments: Segment[],\n  selectionStart: number,\n  selectionEnd: number,\n  marker: string,\n): { segments: Segment[]; selectionStart: number; selectionEnd: number } | null {\n  if (selectionStart === selectionEnd) return null\n\n  const plainText = segmentsToPlainText(segments)\n  const markerLen = marker.length\n\n  // Check if already wrapped\n  const hasOpeningMarker =\n    selectionStart >= markerLen &&\n    plainText.slice(selectionStart - markerLen, selectionStart) === marker\n  const hasClosingMarker =\n    selectionEnd + markerLen <= plainText.length &&\n    plainText.slice(selectionEnd, selectionEnd + markerLen) === marker\n\n  let isWrapped = hasOpeningMarker && hasClosingMarker\n\n  // For single-char markers (e.g., '*'), ensure we're not matching\n  // inside a multi-char marker (e.g., '**')\n  if (isWrapped && markerLen === 1) {\n    const charBeforeOpening =\n      selectionStart > markerLen ? plainText[selectionStart - markerLen - 1] : ''\n    const charAfterClosing =\n      selectionEnd + markerLen < plainText.length ? plainText[selectionEnd + markerLen] : ''\n    if (charBeforeOpening === marker || charAfterClosing === marker) {\n      isWrapped = false\n    }\n  }\n\n  if (isWrapped) {\n    // Unwrap: remove closing marker first (preserves start offsets), then opening\n    const afterClosing = replaceTextRange(segments, selectionEnd, selectionEnd + markerLen, '')\n    const afterOpening = replaceTextRange(\n      afterClosing,\n      selectionStart - markerLen,\n      selectionStart,\n      '',\n    )\n    return {\n      segments: afterOpening,\n      selectionStart: selectionStart - markerLen,\n      selectionEnd: selectionEnd - markerLen,\n    }\n  }\n\n  // Wrap: insert closing marker first (preserves start offsets), then opening\n  const afterClosing = replaceTextRange(segments, selectionEnd, selectionEnd, marker)\n  const afterOpening = replaceTextRange(afterClosing, selectionStart, selectionStart, marker)\n  return {\n    segments: afterOpening,\n    selectionStart: selectionStart + markerLen,\n    selectionEnd: selectionEnd + markerLen,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Inline markdown parsing\n// ---------------------------------------------------------------------------\n\nexport type MarkdownToken =\n  | { type: 'plain'; text: string }\n  | { type: 'bold'; text: string }\n  | { type: 'italic'; text: string }\n  | { type: 'bold-italic'; text: string }\n  | { type: 'url'; text: string }\n\n/**\n * Parses text for simple inline markdown: bold, italic, bold-italic, and URLs.\n * Does NOT handle block-level markdown (lists, headings, etc.).\n */\nexport function parseInlineMarkdown(text: string): MarkdownToken[] {\n  if (!text) return []\n\n  const tokens: MarkdownToken[] = []\n  // Regex patterns for inline markdown elements:\n  // 1. ***text*** or ___text___ -> bold-italic\n  // 2. **text** or __text__   -> bold\n  // 3. *text* or _text_       -> italic\n  // 4. https://... or http://... -> URL\n  const pattern = /(\\*{3}(.+?)\\*{3})|(\\*{2}(.+?)\\*{2})|(\\*(.+?)\\*)|(https?:\\/\\/[^\\s)]+)/g\n\n  let lastIndex = 0\n  let match: RegExpExecArray | null\n\n  while ((match = pattern.exec(text)) !== null) {\n    // Add any plain text before this match\n    if (match.index > lastIndex) {\n      tokens.push({ type: 'plain', text: text.slice(lastIndex, match.index) })\n    }\n\n    if (match[1] && match[2]) {\n      // ***bold-italic***\n      tokens.push({ type: 'bold-italic', text: match[2] })\n    } else if (match[3] && match[4]) {\n      // **bold**\n      tokens.push({ type: 'bold', text: match[4] })\n    } else if (match[5] && match[6]) {\n      // *italic*\n      tokens.push({ type: 'italic', text: match[6] })\n    } else if (match[7]) {\n      // URL — trim trailing punctuation that's likely not part of the URL\n      let url = match[7]\n      while (url.length > 0 && /[.,;:!?]$/.test(url)) {\n        url = url.slice(0, -1)\n      }\n      if (url.length > 0) {\n        tokens.push({ type: 'url', text: url })\n      }\n      const trimmed = match[7].slice(url.length)\n      if (trimmed) {\n        tokens.push({ type: 'plain', text: trimmed })\n      }\n    }\n\n    lastIndex = match.index + match[0].length\n  }\n\n  // Add any remaining plain text\n  if (lastIndex < text.length) {\n    tokens.push({ type: 'plain', text: text.slice(lastIndex) })\n  }\n\n  return tokens\n}\n\n// ---------------------------------------------------------------------------\n// Segment comparison\n// ---------------------------------------------------------------------------\n\n/**\n * Shallow equality check for two segment arrays.\n * Compares type, text, trigger, value, displayText, and autoResolved fields.\n * Avoids JSON.stringify overhead for the common case.\n */\nexport function segmentsEqual(a: Segment[], b: Segment[]): boolean {\n  if (a === b) return true\n  if (a.length !== b.length) return false\n\n  for (let i = 0; i < a.length; i++) {\n    const sa = a[i]\n    const sb = b[i]\n    if (sa.type !== sb.type) return false\n    if (sa.type === 'text') {\n      if (sb.type !== 'text' || sa.text !== sb.text) return false\n    } else {\n      if (\n        sb.type !== 'chip' ||\n        sa.trigger !== sb.trigger ||\n        sa.value !== sb.value ||\n        sa.displayText !== sb.displayText ||\n        sa.autoResolved !== sb.autoResolved\n      )\n        return false\n    }\n  }\n  return true\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Merges adjacent text segments into single text segments.\n * Also removes empty text segments.\n */\nexport function mergeAdjacentTextSegments(segments: Segment[]): Segment[] {\n  const result: Segment[] = []\n\n  for (const seg of segments) {\n    if (seg.type === 'text' && seg.text === '') continue\n\n    const last = result[result.length - 1]\n    if (seg.type === 'text' && last?.type === 'text') {\n      // Merge with previous text segment\n      result[result.length - 1] = { type: 'text', text: last.text + seg.text }\n    } else {\n      result.push(seg)\n    }\n  }\n\n  return result\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/prompt-area-list-ops.ts",
      "content": "/**\n * List auto-formatting logic for the PromptArea component.\n * Pure — no DOM dependencies, fully testable in Node.\n */\nimport type { Segment } from './types'\nimport { replaceTextRange, segmentsToPlainText } from './prompt-area-engine'\n\n/**\n * Information about a list line at a given cursor position.\n */\nexport type ListContext = {\n  /** Offset in plain text where the line begins */\n  lineStart: number\n  /** The full prefix including indentation (e.g., \"  • \") */\n  prefix: string\n  /** Number of indentation levels (each = 2 spaces) */\n  indent: number\n  /** Type of list */\n  listType: 'bullet' | 'numbered'\n  /** For bullet lists, the marker char actually used (`•`, `-`, or `*`) */\n  marker?: string\n  /** For numbered lists, the number */\n  number?: number\n  /** Offset in plain text where content after the prefix starts */\n  contentStart: number\n}\n\n/**\n * Parsed shape of a single list line — the SINGLE source of truth for what\n * counts as a list line (both `getListContext` and the renumber engine derive\n * from this, so the bullet/number regexes live in exactly one place).\n *\n * Offsets (`numberStart`/`numberEnd`) are relative to the START of the line.\n */\ntype ParsedListLine =\n  | { kind: 'bullet'; indent: number; marker: string; prefixLen: number }\n  | {\n      kind: 'numbered'\n      indent: number\n      number: number\n      /** Offset of the first digit within the line. */\n      numberStart: number\n      /** Offset just past the last digit within the line (exclusive). */\n      numberEnd: number\n      prefixLen: number\n    }\n\n/** Classifies a single line as a bullet/numbered list item, or null. */\nfunction parseListLine(line: string): ParsedListLine | null {\n  const bulletMatch = line.match(/^(\\s*)([•\\-*]) /)\n  if (bulletMatch) {\n    return {\n      kind: 'bullet',\n      indent: Math.floor(bulletMatch[1].length / 2),\n      marker: bulletMatch[2],\n      prefixLen: bulletMatch[0].length,\n    }\n  }\n\n  const numberMatch = line.match(/^(\\s*)(\\d+)\\. /)\n  if (numberMatch) {\n    const numberStart = numberMatch[1].length\n    return {\n      kind: 'numbered',\n      indent: Math.floor(numberMatch[1].length / 2),\n      number: parseInt(numberMatch[2], 10),\n      numberStart,\n      numberEnd: numberStart + numberMatch[2].length,\n      prefixLen: numberMatch[0].length,\n    }\n  }\n\n  return null\n}\n\n/**\n * Detects if the cursor is in a list line and returns context about it.\n *\n * @param text - The full plain text content\n * @param cursorPos - The cursor position (character offset from start)\n * @returns List context if the cursor is in a list line, null otherwise\n */\nexport function getListContext(text: string, cursorPos: number): ListContext | null {\n  const lineStart = text.lastIndexOf('\\n', cursorPos - 1) + 1\n  const lineEnd = text.indexOf('\\n', cursorPos)\n  const line = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd)\n\n  const parsed = parseListLine(line)\n  if (!parsed) return null\n\n  return {\n    lineStart,\n    prefix: line.slice(0, parsed.prefixLen),\n    indent: parsed.indent,\n    listType: parsed.kind,\n    ...(parsed.kind === 'bullet' ? { marker: parsed.marker } : { number: parsed.number }),\n    contentStart: lineStart + parsed.prefixLen,\n  }\n}\n\n/**\n * Detects if the user just typed a list trigger pattern (e.g., \"- \" or \"* \")\n * and returns the segments with the replacement applied.\n */\nexport function autoFormatListPrefix(\n  segments: Segment[],\n  cursorPos: number,\n  // Callers on the typing hot path pass the plain text they already built so\n  // this doesn't re-serialize the whole document per keystroke.\n  plainText: string = segmentsToPlainText(segments),\n): { segments: Segment[]; cursorOffset: number } | null {\n  const lineStart = plainText.lastIndexOf('\\n', cursorPos - 1) + 1\n  const lineText = plainText.slice(lineStart, cursorPos)\n\n  const match = lineText.match(/^(\\s*)[-*] $/)\n  if (!match) return null\n\n  const indent = match[1]\n  const replacement = `${indent}• `\n  const rangeStart = lineStart\n  const rangeEnd = lineStart + lineText.length\n\n  const newSegments = replaceTextRange(segments, rangeStart, rangeEnd, replacement)\n  return {\n    segments: newSegments,\n    cursorOffset: lineStart + replacement.length,\n  }\n}\n\n/**\n * Handles Enter key in a list line — continues the list or exits.\n */\nexport function insertListContinuation(\n  segments: Segment[],\n  cursorPos: number,\n): { segments: Segment[]; cursorOffset: number } | null {\n  const plainText = segmentsToPlainText(segments)\n  const ctx = getListContext(plainText, cursorPos)\n  if (!ctx) return null\n\n  const lineEnd = plainText.indexOf('\\n', cursorPos)\n  const lineContent = plainText.slice(ctx.contentStart, lineEnd === -1 ? plainText.length : lineEnd)\n\n  if (lineContent.trim() === '') {\n    // Enter on an empty item: outdent one level if nested (Notion/Docs style),\n    // otherwise remove the prefix and exit the list to plain text.\n    if (ctx.indent > 0) {\n      const newSegments = replaceTextRange(segments, ctx.lineStart, ctx.lineStart + 2, '')\n      return {\n        segments: newSegments,\n        cursorOffset: Math.max(ctx.lineStart, cursorPos - 2),\n      }\n    }\n    const newSegments = replaceTextRange(\n      segments,\n      ctx.lineStart,\n      ctx.lineStart + ctx.prefix.length,\n      '',\n    )\n    return {\n      segments: newSegments,\n      cursorOffset: ctx.lineStart,\n    }\n  }\n\n  const indent = '  '.repeat(ctx.indent)\n  let nextPrefix: string\n  if (ctx.listType === 'bullet') {\n    nextPrefix = `${indent}${ctx.marker ?? '•'} `\n  } else {\n    const nextNum = (ctx.number ?? 1) + 1\n    nextPrefix = `${indent}${nextNum}. `\n  }\n\n  const insertion = `\\n${nextPrefix}`\n  const newSegments = replaceTextRange(segments, cursorPos, cursorPos, insertion)\n  return {\n    segments: newSegments,\n    cursorOffset: cursorPos + insertion.length,\n  }\n}\n\n/** Returns the indent level of the list line directly above `lineStart`, or null. */\nfunction getPrevListLineLevel(text: string, lineStart: number): number | null {\n  if (lineStart === 0) return null\n  const prevLineStart = text.lastIndexOf('\\n', lineStart - 2) + 1\n  const parsed = parseListLine(text.slice(prevLineStart, lineStart - 1))\n  return parsed ? parsed.indent : null\n}\n\n/**\n * Indents a list item by one level (adds 2 spaces before the prefix), capped at\n * one level deeper than the line above. An item can only nest under a preceding\n * sibling, so the first item of a list — or an item already one level below its\n * parent — cannot indent further (returns null). This keeps sub-items visually\n * connected to a parent instead of drifting arbitrarily deep.\n */\nexport function indentListItem(\n  segments: Segment[],\n  cursorPos: number,\n): { segments: Segment[]; cursorOffset: number } | null {\n  const plainText = segmentsToPlainText(segments)\n  const ctx = getListContext(plainText, cursorPos)\n  if (!ctx) return null\n\n  const prevLevel = getPrevListLineLevel(plainText, ctx.lineStart)\n  const maxLevel = prevLevel === null ? 0 : prevLevel + 1\n  if (ctx.indent >= maxLevel) return null\n\n  const newSegments = replaceTextRange(segments, ctx.lineStart, ctx.lineStart, '  ')\n  return {\n    segments: newSegments,\n    cursorOffset: cursorPos + 2,\n  }\n}\n\n/**\n * Outdents a list item by one level (removes 2 spaces from before the prefix).\n */\nexport function outdentListItem(\n  segments: Segment[],\n  cursorPos: number,\n): { segments: Segment[]; cursorOffset: number } | null {\n  const plainText = segmentsToPlainText(segments)\n  const ctx = getListContext(plainText, cursorPos)\n  if (!ctx || ctx.indent === 0) return null\n\n  const newSegments = replaceTextRange(segments, ctx.lineStart, ctx.lineStart + 2, '')\n  return {\n    segments: newSegments,\n    cursorOffset: Math.max(ctx.lineStart, cursorPos - 2),\n  }\n}\n\n/**\n * Removes the list prefix from the current line (e.g., on Backspace).\n */\nexport function removeListPrefix(\n  segments: Segment[],\n  cursorPos: number,\n): { segments: Segment[]; cursorOffset: number } | null {\n  const plainText = segmentsToPlainText(segments)\n  const ctx = getListContext(plainText, cursorPos)\n  if (!ctx) return null\n\n  if (cursorPos > ctx.contentStart) return null\n\n  const newSegments = replaceTextRange(\n    segments,\n    ctx.lineStart,\n    ctx.contentStart,\n    '  '.repeat(ctx.indent),\n  )\n  return {\n    segments: newSegments,\n    cursorOffset: ctx.lineStart + ctx.indent * 2,\n  }\n}\n\n/** A line that opens or closes a fenced code block (```), optionally indented. */\nconst FENCE_LINE = /^\\s*```/\n\n/** Swaps the leading list marker on a single line (\"- \" ↔ \"• \"). */\nfunction swapListPrefixLine(line: string, markdownEnabled: boolean): string {\n  return markdownEnabled ? line.replace(/^(\\s*)- /, '$1• ') : line.replace(/^(\\s*)• /, '$1- ')\n}\n\n/**\n * Returns the set of line indices that sit inside a *balanced* fenced code\n * block (a ```…``` pair), so their leading \"- \"/\"• \" markers are preserved\n * verbatim. An unterminated (unpaired) fence marker is NOT protective — its\n * following lines still normalize — so a stray \"```\" in prose does not silently\n * suppress bullet normalization for the rest of the text.\n */\nfunction fenceProtectedLineIndices(lines: string[]): Set<number> {\n  const protectedLines = new Set<number>()\n  let openIndex = -1\n  lines.forEach((line, i) => {\n    if (!FENCE_LINE.test(line)) return\n    if (openIndex === -1) {\n      openIndex = i\n    } else {\n      for (let k = openIndex; k <= i; k++) protectedLines.add(k)\n      openIndex = -1\n    }\n  })\n  return protectedLines\n}\n\n/**\n * Normalizes markdown list prefixes in a raw text string (single source of\n * truth for the bullet-glyph swap, shared by segment normalization and paste):\n * - When markdown is enabled, converts \"- \" at line starts to \"• \"\n * - When markdown is disabled, converts \"• \" at line starts to \"- \"\n *\n * Lines inside a balanced ```…``` block are preserved verbatim.\n */\nexport function normalizeListPrefixText(text: string, markdownEnabled: boolean): string {\n  const lines = text.split('\\n')\n  const protectedLines = fenceProtectedLineIndices(lines)\n  return lines\n    .map((line, i) => (protectedLines.has(i) ? line : swapListPrefixLine(line, markdownEnabled)))\n    .join('\\n')\n}\n\n/**\n * Normalizes markdown list prefixes across text segments. See\n * {@link normalizeListPrefixText} for the per-line rule. Fence detection spans\n * the whole document (text segments flattened to a global line sequence), so a\n * code block split into per-line text segments on paste still has its \"- \" lines\n * preserved, while an unterminated fence does not suppress later bullets.\n */\nexport function normalizeListPrefixes(segments: Segment[], markdownEnabled: boolean): Segment[] {\n  const globalLines: string[] = []\n  segments.forEach((seg) => {\n    if (seg.type === 'text') globalLines.push(...seg.text.split('\\n'))\n  })\n  const protectedLines = fenceProtectedLineIndices(globalLines)\n\n  let globalIndex = 0\n  let changed = false\n  const result = segments.map((seg) => {\n    if (seg.type !== 'text') return seg\n    const newText = seg.text\n      .split('\\n')\n      .map((line) => {\n        const out = protectedLines.has(globalIndex)\n          ? line\n          : swapListPrefixLine(line, markdownEnabled)\n        globalIndex++\n        return out\n      })\n      .join('\\n')\n    if (newText === seg.text) return seg\n    changed = true\n    return { ...seg, text: newText }\n  })\n  return changed ? result : segments\n}\n\n// ---------------------------------------------------------------------------\n// Ordered-list renumbering\n//\n// The visible number is a projection of position (like BlockNote/ProseMirror/\n// Notion), recomputed on every structural edit rather than trusted as stored\n// text. A per-indent-level counter STACK models nested lists: descending starts\n// a fresh counter at 1, ascending continues the shallower level and clears\n// deeper ones. Every run restarts at 1, so `1. 1. 1.` rebuilds to `1. 2. 3.`\n// and Tab-indenting an item restarts its sublist at 1.\n// ---------------------------------------------------------------------------\n\n/**\n * A single rewritten number's digit run, in the INPUT text's coordinates.\n * `[oldStart, oldEnd)` spans only the digits (never the indentation or `. `).\n */\nexport type NumberEdit = { oldStart: number; oldEnd: number; newText: string }\n\n/**\n * The cheapest possible proof that a numbered line might exist anywhere in the\n * text. Deliberately mirrors `parseListLine`'s `^(\\s*)(\\d+)\\. ` so it can\n * never rule out a line the parser would accept; a looser match than the\n * parser's only costs the full scan that would have run anyway. Not\n * sticky/global, so it carries no `lastIndex` state between calls.\n */\nconst ORDERED_LINE_GATE = /^\\s*\\d+\\. /m\n\n/**\n * Whether the text holds a genuine ordered-list run worth renumbering — a run\n * of 2+ consecutive same-level numbered lines that either starts at 1 or is\n * already a contiguous `n, n+1, …` sequence. Used to gate the paste path so a\n * copied list fragment (`3. 4. 5.` → renumber, or a broken `1. 1. 1.`) is\n * rebuilt, while incidental numeric-leading prose that `parseListLine` would\n * otherwise treat as a list — `1985. Born / 2020. Died`, `5. / 10. / 15.` — is\n * left untouched.\n */\nexport function hasOrderedListRun(text: string): boolean {\n  // Cheap pre-gate, mirroring renumberOrderedListLines': this runs on every\n  // keystroke to decide whether renumbering is even possible, and without it\n  // a long prompt pays a full split-and-parse of every line per character.\n  // A run needs at least one `n. ` line, so no match means no run.\n  if (!ORDERED_LINE_GATE.test(text)) return false\n\n  let runLevel: number | null = null\n  let runStart = 0\n  let prevNumber = 0\n  let runLength = 0\n  let sequential = true\n\n  for (const line of text.split('\\n')) {\n    const parsed = parseListLine(line)\n    if (parsed?.kind === 'numbered' && parsed.indent === runLevel) {\n      sequential = sequential && parsed.number === prevNumber + 1\n      prevNumber = parsed.number\n      runLength++\n      if (runLength >= 2 && (runStart === 1 || sequential)) return true\n    } else if (parsed?.kind === 'numbered') {\n      // Start a fresh run at this line's level.\n      runLevel = parsed.indent\n      runStart = parsed.number\n      prevNumber = parsed.number\n      runLength = 1\n      sequential = true\n    } else {\n      runLevel = null\n      runLength = 0\n    }\n  }\n\n  return false\n}\n\n/**\n * Whether `next` differs from `prev` only inside the line holding `cursorPos`,\n * and that region is a plain (non-list) line in both versions. Plain lines\n * break every list run in both {@link hasOrderedListRun} and\n * {@link renumberOrderedListLines}, so an edit confined to one cannot change\n * the list structure: if `prev` needed no renumbering, neither does `next`.\n *\n * Conservative by construction. Any change outside the caret line (undo,\n * drag and drop, a split or merge that moved list content) fails the\n * prefix/suffix check and reports false, and the caller runs the full scan.\n * The prefix/suffix compare is a native memcmp, unlike the per-line regex\n * scan it replaces on the typing hot path.\n */\nexport function isPlainLineEdit(prev: string, next: string, cursorPos: number): boolean {\n  const lineStart = cursorPos === 0 ? 0 : next.lastIndexOf('\\n', cursorPos - 1) + 1\n  const newlineAfter = next.indexOf('\\n', cursorPos)\n  const lineEnd = newlineAfter === -1 ? next.length : newlineAfter\n  const suffixLength = next.length - lineEnd\n  if (prev.length < lineStart + suffixLength) return false\n  if (!prev.startsWith(next.slice(0, lineStart))) return false\n  if (!prev.endsWith(next.slice(lineEnd))) return false\n\n  if (parseListLine(next.slice(lineStart, lineEnd))) return false\n  // The same region in `prev` may span several lines (a Backspace merged\n  // them); every one must have been plain too.\n  for (const line of prev.slice(lineStart, prev.length - suffixLength).split('\\n')) {\n    if (parseListLine(line)) return false\n  }\n  return true\n}\n\n/**\n * Recomputes ordered-list numbering across the whole text. Returns the new text\n * plus the list of changed digit runs (ascending by `oldStart`) for cursor\n * remapping. When nothing changes, returns the SAME text reference and an empty\n * `edits` array — the no-op guard that keeps this off the typing hot path.\n *\n * `seedFromFirstNumber` changes where each run's counter STARTS, not how it\n * counts: the run continues from its own first item's number instead of from\n * 1. Pasted content is authored elsewhere and its starting number is meaningful\n * — an annex numbered `7. 8. 9.` must stay at 7, and a section resuming after\n * a paragraph of prose must not silently drop back to 1. Contiguity is still\n * rebuilt within the run, so a stale `7. 7. 7.` lands `7. 8. 9.`. Typing leaves\n * the flag off and keeps the restart-at-1 model described above.\n */\nexport function renumberOrderedListLines(\n  text: string,\n  opts?: { seedFromFirstNumber?: boolean },\n): { text: string; edits: NumberEdit[] } {\n  // Cheap pre-gate: with no ordered-list line there is nothing to renumber, so\n  // skip the per-line scan and throwaway rebuild. This runs on every structural\n  // edit (Enter, Tab, bold/italic wrap) and each paste, most of which never\n  // touch a numbered list.\n  if (!/^[ \\t]*\\d+\\. /m.test(text)) return { text, edits: [] }\n\n  const counters = new Map<number, number>()\n  const edits: NumberEdit[] = []\n  const lines = text.split('\\n')\n  let out = ''\n  let lineStart = 0\n\n  const clearDeeperThan = (level: number, inclusive: boolean) => {\n    for (const key of counters.keys()) {\n      if (inclusive ? key >= level : key > level) counters.delete(key)\n    }\n  }\n\n  for (let i = 0; i < lines.length; i++) {\n    const line = lines[i]\n    const parsed = parseListLine(line)\n\n    if (!parsed) {\n      // A blank or plain (non-list) line breaks every open list run.\n      counters.clear()\n      out += line\n    } else if (parsed.kind === 'bullet') {\n      // A bullet interrupts numbered runs at its level and deeper; a shallower\n      // numbered list continues across it.\n      clearDeeperThan(parsed.indent, true)\n      out += line\n    } else {\n      const level = parsed.indent\n      clearDeeperThan(level, false)\n      const current = counters.get(level)\n      // First item of a run at this level seeds the counter; the rest just\n      // increment. `clearDeeperThan` already dropped this level's counter when\n      // the run was interrupted, so a resumed run reseeds from its own number.\n      const seed = opts?.seedFromFirstNumber ? parsed.number : 1\n      const n = current === undefined ? seed : current + 1\n      counters.set(level, n)\n\n      const newDigits = String(n)\n      if (newDigits === String(parsed.number)) {\n        out += line\n      } else {\n        edits.push({\n          oldStart: lineStart + parsed.numberStart,\n          oldEnd: lineStart + parsed.numberEnd,\n          newText: newDigits,\n        })\n        out += line.slice(0, parsed.numberStart) + newDigits + line.slice(parsed.numberEnd)\n      }\n    }\n\n    if (i < lines.length - 1) out += '\\n'\n    lineStart += line.length + 1 // + 1 for the consumed '\\n'\n  }\n\n  return edits.length === 0 ? { text, edits } : { text: out, edits }\n}\n\n/**\n * Remaps a caret/selection offset (in the renumber INPUT's coordinates) across\n * the digit-run edits. A single scalar delta is wrong: many spans each change\n * width, so the shift depends on how many changed spans lie strictly before the\n * offset, with a clamp when the offset sits inside a resized number.\n */\nexport function remapOffset(old: number, edits: NumberEdit[]): number {\n  let shift = 0\n  for (const e of edits) {\n    if (e.oldEnd <= old) {\n      shift += e.newText.length - (e.oldEnd - e.oldStart) // fully before\n    } else if (e.oldStart >= old) {\n      break // this and every later span is after the offset\n    } else {\n      return e.oldStart + shift + Math.min(old - e.oldStart, e.newText.length) // inside → clamp\n    }\n  }\n  return old + shift\n}\n\n/**\n * Segment-level renumber used by the edit-commit path. Applies the digit-run\n * edits to the segments (right-to-left so earlier offsets stay valid) and\n * returns the changed spans for {@link remapOffset}. Digit runs are pure text at\n * line starts, so chips are never touched.\n */\nexport function renumberOrderedListSegments(\n  segments: Segment[],\n  // Optional precomputed serialization, same rationale as autoFormatListPrefix.\n  plainText: string = segmentsToPlainText(segments),\n): {\n  segments: Segment[]\n  edits: NumberEdit[]\n} {\n  const { edits } = renumberOrderedListLines(plainText)\n  if (edits.length === 0) return { segments, edits }\n\n  let result = segments\n  for (let i = edits.length - 1; i >= 0; i--) {\n    const e = edits[i]\n    result = replaceTextRange(result, e.oldStart, e.oldEnd, e.newText)\n  }\n  return { segments: result, edits }\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/cursor-helpers.ts",
      "content": "/**\n * Cursor/selection utilities for the PromptArea contentEditable.\n *\n * Invariants:\n * - All functions are synchronous. Never return a promise or cross a microtask\n *   boundary after a DOM mutation — that can cause the browser to fire\n *   `selectionchange` and reset the caret.\n * - Never cache a `Selection` or `Range` across calls. Ranges become detached\n *   after DOM mutations. Each function calls `window.getSelection()` or\n *   `getSelectionRange()` fresh.\n * - Chip nodes are treated atomically via `isChipElement` — we never descend\n *   into a contentEditable=false subtree when mapping offsets.\n * - A caret is addressed by its plain-text offset, never by a child-node\n *   index. Every render ends in `decorateEditor`, whose passes replace a text\n *   node with a text/<span>/text run, so child indices do not survive a\n *   re-render — offsets do, because findDOMPosition descends into decorations.\n */\nimport {\n  chipNodeTextLength,\n  getSelectionRange,\n  isBRElement,\n  isChipElement,\n  isHTMLElement,\n  isTextNode,\n} from './dom-helpers'\n\nexport function getCursorOffset(editor: HTMLElement): number | null {\n  const range = getSelectionRange()\n  if (!range) return null\n  if (!editor.contains(range.startContainer)) return null\n\n  return getTextOffsetAtPoint(editor, range.startContainer, range.startOffset)\n}\n\n/**\n * Plain-text length a whole node contributes to cursor offsets. Counting rules\n * are shared with {@link getTextLengthInRange}'s fragment walk: text nodes by\n * length, chips atomically via {@link chipNodeTextLength}, `<br>` as one\n * character (sentinel `<br>` as zero), other elements by their children.\n */\nexport function nodeTextContribution(node: Node): number {\n  // `Text.length` is the character count as a plain attribute — unlike\n  // `textContent`, it never materializes the node's string, which matters\n  // because this runs per direct child on every caret query.\n  if (isTextNode(node)) return node.length\n  if (isChipElement(node)) return chipNodeTextLength(node)\n  // The same guard the typing scan uses, so a <br> counts identically on both\n  // paths (including a cross-realm node that `instanceof` would miss).\n  if (isBRElement(node)) return node.getAttribute('data-sentinel') ? 0 : 1\n  if (isHTMLElement(node)) {\n    let length = 0\n    const children = node.childNodes\n    for (let i = 0; i < children.length; i++) {\n      length += nodeTextContribution(children[i])\n    }\n    return length\n  }\n  return 0\n}\n\n/**\n * Plain-text offset of the DOM boundary point `(container, offset)` inside the\n * editor — the length of everything before the point, under the same counting\n * rules as {@link getTextLengthInRange}, without the `Range.cloneContents()`\n * that function needs. Cloning allocates a deep copy of the whole document\n * prefix, which made every caret-offset query on the typing hot path cost\n * O(document) in allocations; this walk only reads.\n *\n * Returns null when `container` is not inside the editor.\n */\nexport function getTextOffsetAtPoint(\n  // `Node`, not `HTMLElement`: the typing scan resolves a caret relative to a\n  // single direct child, which may itself be the text node holding the caret.\n  editor: Node,\n  container: Node,\n  offset: number,\n): number | null {\n  // Ancestor path container → … → direct child of editor (empty when the\n  // container is the editor itself).\n  const path: Node[] = []\n  let ancestor: Node | null = container\n  while (ancestor && ancestor !== editor) {\n    path.push(ancestor)\n    ancestor = ancestor.parentNode\n  }\n  if (!ancestor) return null\n\n  let length = 0\n  let parent: Node = editor\n  for (let depth = path.length - 1; depth >= 0; depth--) {\n    const pathChild = path[depth]\n    const siblings = parent.childNodes\n    for (let i = 0; i < siblings.length; i++) {\n      const sibling = siblings[i]\n      if (sibling === pathChild) break\n      length += nodeTextContribution(sibling)\n    }\n    // Chips are atomic: a boundary inside a chip subtree counts the whole chip,\n    // matching the clone walk (a partial clone keeps the chip shell and its\n    // data attributes, so chipNodeTextLength reads the full length).\n    if (isChipElement(pathChild)) {\n      return length + chipNodeTextLength(pathChild)\n    }\n    // Non-HTML subtrees (svg, MathML) contribute nothing in the clone walk —\n    // it only recurses through HTMLElements — so a boundary inside one maps\n    // to the subtree's start rather than counting its internal text.\n    if (!isHTMLElement(pathChild) && !isTextNode(pathChild)) {\n      return length\n    }\n    parent = pathChild\n  }\n\n  if (isTextNode(container)) {\n    return length + Math.min(offset, container.length)\n  }\n  if (isBRElement(container)) {\n    // setEnd(<br>, 0) partially includes the <br>, whose cloned shell the\n    // clone walk counts as one character (zero for the sentinel).\n    return length + nodeTextContribution(container)\n  }\n  // Element container: children before the offset index are fully included.\n  const children = container.childNodes\n  const limit = Math.min(offset, children.length)\n  for (let i = 0; i < limit; i++) {\n    length += nodeTextContribution(children[i])\n  }\n  return length\n}\n\n/**\n * Create a collapsed Range at the given plain-text offset inside the editor.\n * Returns null if the offset can't be mapped to a DOM position.\n */\nexport function createRangeAtOffset(editor: HTMLElement, targetOffset: number): Range | null {\n  const pos = findDOMPosition(editor, targetOffset)\n  if (!pos) return null\n\n  const range = document.createRange()\n  range.setStart(pos.node, pos.offset)\n  range.collapse(true)\n  return range\n}\n\nexport function setCursorAtOffset(\n  editor: HTMLElement,\n  targetOffset: number,\n  opts?: { scroll?: boolean },\n): void {\n  const range = document.createRange()\n  const pos = findDOMPosition(editor, targetOffset)\n  if (pos) {\n    range.setStart(pos.node, pos.offset)\n    range.collapse(true)\n  } else {\n    // Fallback: place cursor at end\n    range.selectNodeContents(editor)\n    range.collapse(false)\n  }\n  applySelectionRange(editor, range, opts)\n}\n\nexport function getTextLengthInRange(range: Range): number {\n  const fragment = range.cloneContents()\n  let length = 0\n\n  const walk = (node: Node): void => {\n    if (node.nodeType === Node.TEXT_NODE) {\n      length += (node.textContent ?? '').length\n    } else if (isChipElement(node)) {\n      length += chipNodeTextLength(node)\n    } else if (isHTMLElement(node) && node.tagName === 'BR') {\n      if (node.dataset.sentinel) return // skip sentinel <br>\n      length += 1\n    } else if (isHTMLElement(node)) {\n      node.childNodes.forEach(walk)\n    }\n  }\n\n  fragment.childNodes.forEach(walk)\n  return length\n}\n\n/**\n * Returns the start and end plain-text offsets of the current selection.\n * Returns null if there's no selection or it's outside the editor.\n */\nexport function getSelectionOffsets(editor: HTMLElement): { start: number; end: number } | null {\n  const range = getSelectionRange()\n  if (!range) return null\n  if (!editor.contains(range.startContainer)) return null\n\n  const start = getTextOffsetAtPoint(editor, range.startContainer, range.startOffset)\n  if (start === null) return null\n\n  if (range.collapsed) return { start, end: start }\n\n  // A selection dragged past the editor's edge has its end outside; treat it\n  // as collapsed rather than mapping a foreign node to an arbitrary offset.\n  const end = getTextOffsetAtPoint(editor, range.endContainer, range.endOffset)\n  if (end === null) return { start, end: start }\n\n  return { start, end }\n}\n\n/**\n * Sets a (potentially non-collapsed) selection at the given plain-text offsets.\n * Used to restore selection after markdown wrap/unwrap operations.\n */\nexport function setSelectionAtOffsets(\n  editor: HTMLElement,\n  startOffset: number,\n  endOffset: number,\n): void {\n  if (startOffset === endOffset) {\n    setCursorAtOffset(editor, startOffset)\n    return\n  }\n\n  const startPos = findDOMPosition(editor, startOffset)\n  const endPos = findDOMPosition(editor, endOffset)\n  if (!startPos || !endPos) return\n\n  const range = document.createRange()\n  range.setStart(startPos.node, startPos.offset)\n  range.setEnd(endPos.node, endPos.offset)\n  applySelectionRange(editor, range)\n}\n\n/**\n * Commit a Range as the document selection, then keep the caret visible.\n *\n * This is the one primitive every programmatic selection placement must go\n * through (directly, or via the helpers above) so that no placement site can\n * forget the scroll correction — the 0.6.3 bug class was exactly a placement\n * that scrolled nowhere. Pass `scroll: false` only when native editing has\n * already revealed the caret (the per-keystroke decorate echo), where the\n * correction would be a redundant forced reflow.\n */\nexport function applySelectionRange(\n  editor: HTMLElement,\n  range: Range,\n  opts?: { scroll?: boolean },\n): void {\n  const sel = window.getSelection()\n  if (!sel) return\n  sel.removeAllRanges()\n  sel.addRange(range)\n  if (opts?.scroll !== false) {\n    scrollCaretIntoView(editor)\n  }\n}\n\n/**\n * Scrolls the editor's own scroll box so the caret is visible.\n *\n * Browsers only auto-scroll the caret into view for native editing (typing,\n * arrow keys) — never for selections placed via the Selection API — hence\n * this correction after programmatic placements. Its scope limits are each\n * load-bearing:\n * - Collapsed selections only. A range selection has no single caret, and a\n *   re-rendered range restore (Cmd+B over a backwards selection) must not\n *   yank the viewport to the range's document-order end — for those,\n *   renderSegmentsToDOM's scrollTop preservation keeps the view stable.\n * - Focused editors only. Imperative placements into a blurred editor\n *   (handle.appendText from a toolbar button) must not pan autoGrow's\n *   collapsed preview: its overflow is hidden, but hidden boxes still honor\n *   programmatic scrollTop and the user would have no way to scroll back.\n * - Only the editor's own scrollTop moves — never ancestor scroll containers\n *   or the page.\n */\nexport function scrollCaretIntoView(editor: HTMLElement): void {\n  // Layout-free guards first — everything up to the overflow check reads no\n  // geometry, so early exits (blurred editor, range selection) cost nothing.\n  const selRange = getSelectionRange()\n  if (!selRange || !selRange.collapsed) return\n  if (!editor.contains(selRange.startContainer)) return\n  if (!editor.contains(editor.ownerDocument.activeElement)) return\n  if (editor.scrollHeight <= editor.clientHeight) return\n\n  const rect = caretLineRect(selRange)\n  if (!rect) return\n\n  // getBoundingClientRect returns visual (post-transform) pixels while\n  // clientTop/clientHeight/scrollTop are layout pixels; inside a scaled\n  // ancestor (zoomed canvas, a dialog's entry animation) they differ by the\n  // scale factor, so convert at the boundary — otherwise visibility is\n  // misjudged and the scroll delta lands in the wrong units.\n  const editorRect = editor.getBoundingClientRect()\n  const scale = editor.offsetHeight > 0 ? editorRect.height / editor.offsetHeight : 1\n  const visibleTop = editorRect.top + editor.clientTop * scale\n  const visibleBottom = visibleTop + editor.clientHeight * scale\n\n  // scrollTop assignments beyond the scrollable extent are clamped by the\n  // browser, so over-asking near the edges is safe.\n  if (rect.bottom > visibleBottom) {\n    editor.scrollTop += (rect.bottom - visibleBottom) / scale\n  } else if (rect.top < visibleTop) {\n    editor.scrollTop -= (visibleTop - rect.top) / scale\n  }\n}\n\n/**\n * Measures the viewport rect of the caret line at the end of `range`,\n * working around the element-boundary gap: a collapsed range at an element\n * boundary (right after a <br> or chip) reports an all-zero rect in every\n * engine, so a temporary zero-width space is inserted at the position and\n * measured instead. Returns null when no geometry is available (jsdom).\n *\n * The marker is only ever inserted at an element boundary, never into a Text\n * node: Range.insertNode on a Text container always splits it (even at\n * offset 0) and removing the marker does not re-merge the halves, so a\n * Text-anchored position (an empty text node, or any text node in a\n * layout-less test environment) is first re-aimed at the equivalent boundary\n * in its parent. Insertion at the boundary leaves the live selection\n * untouched (range boundaries only shift for nodes inserted strictly before\n * them), so removing the marker restores the exact DOM and selection.\n *\n * Also used to anchor the trigger popover, which previously skipped its rect\n * update entirely for element-boundary trigger positions.\n */\nexport function caretLineRect(range: Range): DOMRect | null {\n  const caretRange = range.cloneRange()\n  caretRange.collapse(false)\n\n  let rect = caretRange.getBoundingClientRect()\n  if (!isZeroRect(rect)) return rect\n\n  const container = caretRange.startContainer\n  if (isTextNode(container)) {\n    if (!container.parentNode) return null\n    caretRange.selectNode(container)\n    caretRange.collapse(true)\n  }\n  const marker = document.createTextNode('\\u200b')\n  caretRange.insertNode(marker)\n  caretRange.selectNodeContents(marker)\n  rect = caretRange.getBoundingClientRect()\n  marker.remove()\n  return isZeroRect(rect) ? null : rect\n}\n\n/** All-zero rects mean \"geometry unavailable\" (element-boundary caret, jsdom). */\nfunction isZeroRect(rect: DOMRect): boolean {\n  return rect.top === 0 && rect.bottom === 0 && rect.left === 0 && rect.right === 0\n}\n\n/**\n * Maps a plain-text offset to a DOM node + offset pair.\n * Recurses into decoration elements (markdown spans, URL anchors).\n */\nexport function findDOMPosition(\n  container: HTMLElement,\n  targetOffset: number,\n): { node: Node; offset: number } | null {\n  let remaining = targetOffset\n\n  for (let i = 0; i < container.childNodes.length; i++) {\n    const child = container.childNodes[i]\n\n    if (isTextNode(child)) {\n      const len = child.length\n      if (remaining <= len) {\n        return { node: child, offset: remaining }\n      }\n      remaining -= len\n    } else if (isChipElement(child)) {\n      // A remaining of 0 means the caret belongs BEFORE this node. Text nodes\n      // express that for free (offset 0 inside the node), but atomic children\n      // have to say it explicitly — otherwise offset 0 collapses into the same\n      // \"after the node\" answer as offset === its length, landing the caret in\n      // a spot normal editing never produces. On the filler <br> the browser\n      // leaves in an emptied contentEditable, that spot is also unstable: the\n      // filler is dropped as soon as real content arrives, so an IME\n      // composition anchored there loses its node mid-flight and is cancelled.\n      if (remaining === 0) return { node: container, offset: i }\n      const chipLen = chipNodeTextLength(child)\n      if (remaining <= chipLen) {\n        // Position after the chip element\n        return { node: container, offset: i + 1 }\n      }\n      remaining -= chipLen\n    } else if (isBRElement(child)) {\n      // Checked ahead of the sentinel skip: a lone sentinel is still a node the\n      // caret can sit before, and skipping it would fall through to the\n      // end-of-container fallback, i.e. after it.\n      if (remaining === 0) return { node: container, offset: i }\n      if (child.getAttribute('data-sentinel')) continue // skip sentinel <br>\n      if (remaining <= 1) {\n        return { node: container, offset: i + 1 }\n      }\n      remaining -= 1\n    } else if (isHTMLElement(child)) {\n      // Decoration element (markdown span, URL anchor) — recurse\n      const textLen = (child.textContent ?? '').length\n      if (remaining <= textLen) {\n        const result = findDOMPosition(child, remaining)\n        if (result) return result\n      }\n      remaining -= textLen\n    }\n  }\n\n  // Fallback: end of container\n  return { node: container, offset: container.childNodes.length }\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/clipboard-helpers.ts",
      "content": "/**\n * Clipboard-related DOM utilities for the PromptArea component.\n * Handles serializing selections and inserting pasted segments at the cursor.\n *\n * Not merged into dom-helpers.ts to avoid overcrowding that file with\n * clipboard I/O concerns alongside its DOM traversal and chip-accessor helpers.\n */\nimport type { Segment, ChipSegment } from './types'\nimport {\n  chipNodeToSegment,\n  getChipDisplay,\n  getChipTrigger,\n  getSelectionRange,\n  isChipElement,\n  isHTMLElement,\n} from './dom-helpers'\nimport { mergeAdjacentTextSegments } from './prompt-area-engine'\nimport { getTextLengthInRange } from './cursor-helpers'\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Visitor callbacks for {@link walkFragmentNodes}. Each fires for the\n * corresponding node kind encountered during a depth-first walk.\n */\ntype FragmentVisitor = {\n  /** A text node — receives its text content (may be empty). */\n  onText: (text: string) => void\n  /** A chip element (has `data-chip-trigger`). */\n  onChip: (node: HTMLElement) => void\n  /** A `<br>` line break. */\n  onBreak: () => void\n}\n\n/**\n * Depth-first walk of a selection fragment, classifying each node as text,\n * chip, or line break and recursing into any other element (decoration spans,\n * anchors, browser-inserted wrappers).\n *\n * Both fragment serializers share this single traversal so they cannot drift\n * on which nodes count as chips/breaks or how nested decorations are unwrapped.\n */\nfunction walkFragmentNodes(fragment: DocumentFragment, visitor: FragmentVisitor): void {\n  const walk = (node: Node): void => {\n    if (node.nodeType === Node.TEXT_NODE) {\n      visitor.onText(node.textContent ?? '')\n    } else if (isChipElement(node)) {\n      visitor.onChip(node)\n    } else if (isHTMLElement(node) && node.tagName === 'BR') {\n      visitor.onBreak()\n    } else {\n      node.childNodes.forEach(walk)\n    }\n  }\n\n  fragment.childNodes.forEach(walk)\n}\n\n/**\n * Serializes a DocumentFragment (from selection) to plain text,\n * converting chip elements to their `trigger + displayText` form.\n */\nexport function serializeFragmentToPlainText(fragment: DocumentFragment): string {\n  let text = ''\n\n  walkFragmentNodes(fragment, {\n    onText: (value) => {\n      text += value\n    },\n    onChip: (node) => {\n      text += (getChipTrigger(node) ?? '') + (getChipDisplay(node) ?? '')\n    },\n    onBreak: () => {\n      text += '\\n'\n    },\n  })\n\n  return text\n}\n\n/**\n * Serializes a DocumentFragment to an array of Segment objects,\n * preserving chip data for internal copy/paste.\n */\nexport function serializeFragmentToSegments(fragment: DocumentFragment): Segment[] {\n  const segments: Segment[] = []\n\n  walkFragmentNodes(fragment, {\n    onText: (value) => {\n      if (value) segments.push({ type: 'text', text: value })\n    },\n    onChip: (node) => {\n      const chip = chipNodeToSegment(node)\n      if (chip) segments.push(chip)\n    },\n    onBreak: () => {\n      segments.push({ type: 'text', text: '\\n' })\n    },\n  })\n\n  return segments\n}\n\n/**\n * Parses segment JSON from the clipboard. Returns null if invalid.\n */\nexport function parseSegmentsFromClipboard(json: string): Segment[] | null {\n  try {\n    const parsed: unknown = JSON.parse(json)\n    if (!Array.isArray(parsed)) return null\n\n    const segments: Segment[] = []\n    for (const item of parsed) {\n      if (!isRecord(item)) return null\n\n      if (item.type === 'text' && typeof item.text === 'string') {\n        segments.push({ type: 'text', text: item.text })\n      } else if (\n        item.type === 'chip' &&\n        typeof item.trigger === 'string' &&\n        typeof item.value === 'string' &&\n        typeof item.displayText === 'string'\n      ) {\n        const chip: ChipSegment = {\n          type: 'chip',\n          trigger: item.trigger,\n          value: item.value,\n          displayText: item.displayText,\n          ...(item.data !== undefined ? { data: item.data } : {}),\n          ...(item.autoResolved ? { autoResolved: true } : {}),\n        }\n        segments.push(chip)\n      } else {\n        return null\n      }\n    }\n\n    return segments\n  } catch {\n    return null\n  }\n}\n\n/**\n * Inserts pasted segments at the current cursor position within existing segments.\n * Splits any text segment that straddles the cursor so the pasted content lands\n * exactly at the cursor and nothing before or after is lost.\n */\nexport function insertSegmentsAtCursor(\n  currentSegments: Segment[],\n  pastedSegments: Segment[],\n  editor: HTMLElement,\n): Segment[] {\n  const range = getSelectionRange()\n  if (!range) return [...currentSegments, ...pastedSegments]\n\n  const preRange = document.createRange()\n  preRange.selectNodeContents(editor)\n  preRange.setEnd(range.startContainer, range.startOffset)\n  const cursorOffset = getTextLengthInRange(preRange)\n\n  const result: Segment[] = []\n  let offset = 0\n  let inserted = false\n\n  const insertOnce = (): void => {\n    if (!inserted) {\n      result.push(...pastedSegments)\n      inserted = true\n    }\n  }\n\n  for (const seg of currentSegments) {\n    if (seg.type === 'chip') {\n      const chipLen = seg.trigger.length + seg.displayText.length\n      if (offset >= cursorOffset) insertOnce()\n      result.push(seg)\n      offset += chipLen\n      continue\n    }\n\n    const segEnd = offset + seg.text.length\n    if (segEnd <= cursorOffset) {\n      // Entirely before the cursor\n      result.push(seg)\n    } else if (offset >= cursorOffset) {\n      // Entirely after the cursor\n      insertOnce()\n      result.push(seg)\n    } else {\n      // Cursor falls inside this text segment — split it.\n      const splitAt = cursorOffset - offset\n      const before = seg.text.slice(0, splitAt)\n      const after = seg.text.slice(splitAt)\n      if (before) result.push({ type: 'text', text: before })\n      insertOnce()\n      if (after) result.push({ type: 'text', text: after })\n    }\n    offset = segEnd\n  }\n\n  insertOnce()\n  return mergeAdjacentTextSegments(result)\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/dom-helpers.ts",
      "content": "/**\n * Type-safe DOM helper functions for PromptArea.\n *\n * These replace all `as` type assertions with proper type guards,\n * following the codebase rule: \"Never use `any` or `as` assertions.\"\n */\n\nimport type { ChipSegment } from './types'\n\n// ---------------------------------------------------------------------------\n// Type Guards\n// ---------------------------------------------------------------------------\n\n/**\n * Type guard: checks if a DOM node is an HTMLElement.\n */\nexport function isHTMLElement(node: Node): node is HTMLElement {\n  return node instanceof HTMLElement\n}\n\n/**\n * Type guard: checks if a DOM node is a chip element\n * (an HTMLElement with data-chip-trigger attribute).\n *\n * `nodeType` + `hasAttribute` rather than `instanceof` + `dataset`: these\n * guards run once per direct child on every keystroke, and reading `dataset`\n * materializes a `DOMStringMap` wrapper per call — measurably the single most\n * expensive step of the typing scan on a long prompt. `hasAttribute` matches\n * `dataset.chipTrigger !== undefined` exactly, including `data-chip-trigger=\"\"`.\n */\nexport function isChipElement(node: Node): node is HTMLElement {\n  return node.nodeType === Node.ELEMENT_NODE && (node as Element).hasAttribute('data-chip-trigger')\n}\n\n/**\n * Type guard: checks if a DOM node is a BR element.\n *\n * `tagName` is uppercase for HTML elements only, so this cannot match a\n * same-named element in a foreign (SVG/MathML) namespace.\n */\nexport function isBRElement(node: Node): node is HTMLBRElement {\n  return node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'BR'\n}\n\n/**\n * Type guard: checks if a DOM node is a Text node.\n *\n * `nodeType` is also more robust than `instanceof Text`, which is false for a\n * node adopted from another realm (an iframe or a parsed document).\n */\nexport function isTextNode(node: Node): node is Text {\n  return node.nodeType === Node.TEXT_NODE\n}\n\n/**\n * Checks whether a chip element was auto-resolved (created by pressing space\n * on resolveOnSpace triggers, rather than explicit dropdown selection).\n */\nexport function getChipAutoResolved(node: Node): boolean {\n  return isChipElement(node) && node.dataset.chipAutoResolved === 'true'\n}\n\n/**\n * Type guard: checks if a DOM node is a URL link element\n * (an HTMLAnchorElement with data-url attribute).\n */\nexport function isLinkElement(node: Node): node is HTMLAnchorElement {\n  return (\n    node.nodeType === Node.ELEMENT_NODE &&\n    (node as Element).tagName === 'A' &&\n    (node as Element).getAttribute('data-url') === 'true'\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Safe JSON\n// ---------------------------------------------------------------------------\n\n/**\n * Safely parses a JSON string, returning `unknown` instead of `any`.\n * Returns `undefined` if parsing fails.\n */\nexport function safeJsonParse(json: string): unknown {\n  try {\n    // JSON.parse returns `any` by default. We narrow it to `unknown`\n    // which is the safest pattern — callers must validate before use.\n    const parsed: unknown = JSON.parse(json)\n    return parsed\n  } catch {\n    return undefined\n  }\n}\n\n/**\n * Safely serializes a value to JSON, returning undefined on failure.\n */\nexport function safeJsonStringify(value: unknown): string | undefined {\n  try {\n    return JSON.stringify(value)\n  } catch {\n    return undefined\n  }\n}\n\n// ---------------------------------------------------------------------------\n// DOM reading helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Reads the chip trigger character from a chip element's dataset.\n * Returns undefined if the node is not a chip element.\n */\nexport function getChipTrigger(node: Node): string | undefined {\n  if (!isChipElement(node)) return undefined\n  return node.dataset.chipTrigger\n}\n\n/**\n * Reads the chip value from a chip element's dataset.\n */\nexport function getChipValue(node: Node): string | undefined {\n  if (!isChipElement(node)) return undefined\n  return node.dataset.chipValue\n}\n\n/**\n * Reads the chip display text from a chip element's dataset.\n */\nexport function getChipDisplay(node: Node): string | undefined {\n  if (!isChipElement(node)) return undefined\n  return node.dataset.chipDisplay ?? node.textContent ?? undefined\n}\n\n/**\n * Reads and safely parses the chip data from a chip element's dataset.\n */\nexport function getChipData(node: Node): unknown {\n  if (!isChipElement(node)) return undefined\n  const raw = node.dataset.chipData\n  if (!raw) return undefined\n  return safeJsonParse(raw)\n}\n\n/**\n * Length of a chip's plain-text representation (`trigger + displayText`).\n *\n * This is the single definition used wherever DOM offsets are mapped to the\n * plain-text model (cursor mapping, selection sizing). The fallbacks mirror\n * how chips are rendered: `chipDisplay` is always set, but we degrade to\n * `textContent` for resilience against externally-mutated nodes.\n */\nexport function chipNodeTextLength(node: HTMLElement): number {\n  const trigger = node.getAttribute('data-chip-trigger') ?? ''\n  const display = node.getAttribute('data-chip-display') ?? node.textContent ?? ''\n  return trigger.length + display.length\n}\n\n/**\n * Reads a chip element into a `ChipSegment`, mirroring how chips are written\n * in `renderSegmentsToDOM`. Returns null when the node is not a chip or is\n * missing a required attribute (trigger, value, or display text).\n *\n * This is the single chip reader shared by the DOM->model sync, chip-click\n * delegation, and clipboard serialization, so they cannot diverge on which\n * fields are required or how optional `data` / `autoResolved` are attached.\n */\nexport function chipNodeToSegment(node: Node): ChipSegment | null {\n  if (!isChipElement(node)) return null\n\n  const trigger = getChipTrigger(node)\n  const value = getChipValue(node)\n  const displayText = getChipDisplay(node)\n  if (!trigger || value === undefined || !displayText) return null\n\n  const data = getChipData(node)\n  const autoResolved = getChipAutoResolved(node)\n\n  return {\n    type: 'chip',\n    trigger,\n    value,\n    displayText,\n    ...(data !== undefined ? { data } : {}),\n    ...(autoResolved ? { autoResolved: true } : {}),\n  }\n}\n\n// ---------------------------------------------------------------------------\n// DOM manipulation helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Finds the index of a direct child node within a parent element.\n * Returns -1 if not found.\n */\nexport function indexOfChildNode(parent: HTMLElement, child: Node): number {\n  const children = parent.childNodes\n  for (let i = 0; i < children.length; i++) {\n    if (children[i] === child) return i\n  }\n  return -1\n}\n\n/**\n * Whether a direct editor child node produces a segment when read by\n * `readSegmentsFromDOM` in use-prompt-area.ts. This is the single predicate\n * shared with `domChildIndexToSegmentIndex` below, so a DOM child index can\n * never map to a different segment index than the one the reader actually\n * produces — decoration elements (the URL `<a>` from `decorateURLsInEditor`,\n * the markdown `<span data-md>` from `decorateMarkdownInEditor`) fall through\n * to the reader's \"unknown element\" branch and DO produce a text segment, so\n * they must count here too, not just chips/text/`<br>`. A chip element only\n * counts if `chipNodeToSegment` would actually accept it — the reader skips a\n * chip missing a required attribute (trigger/value/display), so this must too.\n */\nexport function childProducesSegment(child: Node): boolean {\n  if (child.nodeType === Node.TEXT_NODE) return (child.textContent ?? '') !== ''\n  if (isBRElement(child)) return !child.dataset.sentinel\n  if (isChipElement(child)) return chipNodeToSegment(child) !== null\n  if (isHTMLElement(child)) return (child.textContent ?? '') !== ''\n  return false\n}\n\n/**\n * Maps the index of a direct child node within the editor to the index of the\n * corresponding segment in the model array, by counting `childProducesSegment`\n * matches up to (but not including) `childIndex`.\n *\n * Keeping this in one place ensures chip-removal, chip-revert, and chip\n * in-place replacement all agree on the exact same mapping rules as the reader.\n */\nexport function domChildIndexToSegmentIndex(editor: HTMLElement, childIndex: number): number {\n  let segIdx = 0\n  for (let i = 0; i < childIndex; i++) {\n    if (childProducesSegment(editor.childNodes[i])) segIdx++\n  }\n  return segIdx\n}\n\n/**\n * Gets the direct child of `ancestor` that contains `descendant`.\n * Walks up from descendant until we find a node whose parent is ancestor.\n * Returns null if descendant is not inside ancestor.\n */\nexport function getDirectChildContaining(ancestor: HTMLElement, descendant: Node): Node | null {\n  let node: Node | null = descendant\n  while (node !== null) {\n    if (node.parentNode === ancestor) return node\n    node = node.parentNode\n  }\n  return null\n}\n\n/**\n * Unwraps a block element (div, p) by replacing it with its child nodes\n * plus a trailing BR. Used for browser DOM normalization.\n */\nexport function unwrapBlockElement(parent: HTMLElement, block: HTMLElement): void {\n  const fragment = document.createDocumentFragment()\n\n  // Move all children to fragment\n  while (block.firstChild) {\n    fragment.appendChild(block.firstChild)\n  }\n\n  // Add a BR after the unwrapped content\n  fragment.appendChild(document.createElement('br'))\n\n  parent.replaceChild(fragment, block)\n}\n\n/**\n * Normalizes the editor DOM after browser mutations.\n *\n * Browsers insert various wrapper elements on Enter/paste:\n * - Chrome wraps new lines in <div>\n * - Safari may use <div><br></div>\n * - Some use <p> tags\n *\n * This function unwraps all non-chip block elements, leaving only:\n * - Text nodes\n * - <br> elements\n * - Chip <span> elements (with data-chip-trigger)\n */\nexport function normalizeEditorDOM(editor: HTMLElement): boolean {\n  let changed = false\n  const blockTags = new Set(['DIV', 'P', 'SECTION', 'ARTICLE', 'BLOCKQUOTE'])\n\n  // Iterate backwards since we're modifying the DOM\n  for (let i = editor.childNodes.length - 1; i >= 0; i--) {\n    const child = editor.childNodes[i]\n\n    // Skip non-element nodes, chip elements, and BR elements\n    if (!(child instanceof HTMLElement)) continue\n    if (child.dataset.chipTrigger !== undefined) continue\n    if (child instanceof HTMLBRElement) continue\n\n    const tag = child.tagName\n    if (blockTags.has(tag)) {\n      unwrapBlockElement(editor, child)\n      changed = true\n    } else if (\n      tag === 'FONT' ||\n      tag === 'B' ||\n      tag === 'I' ||\n      tag === 'U' ||\n      tag === 'STRONG' ||\n      tag === 'EM' ||\n      tag === 'A' ||\n      tag === 'SPAN'\n    ) {\n      // Unwrap inline formatting/decoration elements (browser-inserted or markdown decorations)\n      const text = child.textContent ?? ''\n      if (text) {\n        editor.replaceChild(document.createTextNode(text), child)\n      } else {\n        editor.removeChild(child)\n      }\n      changed = true\n    }\n  }\n\n  // Merge adjacent text nodes\n  editor.normalize()\n\n  return changed\n}\n\n// ---------------------------------------------------------------------------\n// Decoration bounds (line scoping)\n// ---------------------------------------------------------------------------\n\n/**\n * Exclusive boundaries limiting decoration work to one `<br>`-delimited line.\n * `after`/`before` are the `<br>` nodes on either side (null = document edge).\n * `<br>` is the one node kind no decoration pass ever replaces, so the\n * boundary nodes stay valid across every pass of a decorate cycle.\n */\nexport type DecorateBounds = {\n  after: Node | null\n  before: Node | null\n}\n\n/** Iterates the direct children strictly inside `bounds` (all children when undefined). */\nfunction forEachChildInBounds(\n  root: HTMLElement,\n  bounds: DecorateBounds | undefined,\n  callback: (node: Node) => void,\n): void {\n  let node = bounds?.after ? bounds.after.nextSibling : root.firstChild\n  const stop = bounds?.before ?? null\n  while (node && node !== stop) {\n    callback(node)\n    node = node.nextSibling\n  }\n}\n\n/**\n * Scoped stand-in for {@link normalizeEditorDOM}'s decoration strip and\n * `editor.normalize()`: unwraps decoration elements (markdown `<span data-md>`,\n * URL `<a data-url>`) strictly inside the bounds back to plain text, drops the\n * empty ones, and merges the resulting adjacent text nodes — leaving every\n * node outside the bounds untouched. Only safe when the surrounding DOM holds\n * no foreign elements (the caller establishes that via scanEditorDOM).\n */\nexport function stripDecorationsInRange(editor: HTMLElement, bounds: DecorateBounds): boolean {\n  const stop = bounds.before\n  let changed = false\n\n  let node: Node | null = bounds.after ? bounds.after.nextSibling : editor.firstChild\n  while (node && node !== stop) {\n    const next: Node | null = node.nextSibling\n    // Chips carry data-chip-trigger and are never decorations — leave them.\n    if (isHTMLElement(node) && !node.hasAttribute('data-chip-trigger')) {\n      if ((node.tagName === 'SPAN' && node.hasAttribute('data-md')) || isLinkElement(node)) {\n        const text = node.textContent ?? ''\n        if (text) {\n          editor.replaceChild(document.createTextNode(text), node)\n        } else {\n          editor.removeChild(node)\n        }\n        changed = true\n      }\n    }\n    node = next\n  }\n\n  // Bounded equivalent of editor.normalize(): drop empty text nodes, merge\n  // adjacent ones.\n  node = bounds.after ? bounds.after.nextSibling : editor.firstChild\n  while (node && node !== stop) {\n    if (isTextNode(node)) {\n      if (node.length === 0) {\n        const next: Node | null = node.nextSibling\n        editor.removeChild(node)\n        node = next\n        changed = true\n        continue\n      }\n      let sibling: Node | null = node.nextSibling\n      while (sibling && sibling !== stop && isTextNode(sibling)) {\n        node.appendData(sibling.data)\n        const nextSibling: Node | null = sibling.nextSibling\n        editor.removeChild(sibling)\n        sibling = nextSibling\n        changed = true\n      }\n    }\n    node = node.nextSibling\n  }\n\n  return changed\n}\n\n// ---------------------------------------------------------------------------\n// URL decoration\n// ---------------------------------------------------------------------------\n\n/** URL pattern for detecting URLs in text content. Commas are allowed\n * mid-URL (e.g. Wikipedia paths); trailing punctuation is trimmed below. */\nconst URL_PATTERN = /https?:\\/\\/[^\\s)]+/g\n\n/**\n * Walks direct-child text nodes in the editor and wraps URL text in\n * `<a>` elements for visual styling and clickability.\n *\n * This is a DOM-only decoration — it does NOT modify the segment model.\n * The `<a>` elements are stripped by `normalizeEditorDOM` on every input cycle,\n * so they are re-applied fresh each time.\n *\n * @param editor - The contentEditable root element\n * @returns Whether any decorations were applied\n */\nexport function decorateURLsInEditor(editor: HTMLElement, bounds?: DecorateBounds): boolean {\n  let decorated = false\n\n  // Collect text nodes first (avoid modifying while iterating). The includes\n  // check is a cheap pre-gate: URL_PATTERN can only match text containing\n  // \"http\", so everything else skips the regex entirely.\n  const textNodes: Text[] = []\n  forEachChildInBounds(editor, bounds, (node) => {\n    if (isTextNode(node) && node.textContent && node.textContent.includes('http')) {\n      textNodes.push(node)\n    }\n  })\n\n  for (const textNode of textNodes) {\n    const text = textNode.textContent ?? ''\n    URL_PATTERN.lastIndex = 0\n    const matches: Array<{ url: string; index: number }> = []\n    let match: RegExpExecArray | null\n\n    while ((match = URL_PATTERN.exec(text)) !== null) {\n      // Trim trailing punctuation that's likely not part of the URL\n      let url = match[0]\n      while (url.length > 0 && /[.,;:!?]$/.test(url)) {\n        url = url.slice(0, -1)\n      }\n      if (url.length > 0) {\n        matches.push({ url, index: match.index })\n      }\n    }\n\n    if (matches.length === 0) continue\n\n    const parent = textNode.parentNode\n    if (!parent) continue\n\n    // Validate URLs upfront – only keep those with safe protocols (CWE-79)\n    const safeMatches: Array<{ url: string; href: string; index: number }> = []\n    for (const { url, index } of matches) {\n      try {\n        const parsed = new URL(url)\n        if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {\n          safeMatches.push({ url, href: parsed.href, index })\n        }\n      } catch {\n        // skip malformed URLs\n      }\n    }\n\n    if (safeMatches.length === 0) continue\n\n    decorated = true\n    const fragment = document.createDocumentFragment()\n    let lastIndex = 0\n\n    for (const { url, href, index } of safeMatches) {\n      // Text before this URL\n      if (index > lastIndex) {\n        fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))\n      }\n\n      // Create the link element\n      const anchor = document.createElement('a')\n      anchor.href = href\n      anchor.target = '_blank'\n      anchor.rel = 'noopener noreferrer'\n      anchor.dataset.url = 'true'\n      anchor.className = 'text-primary hover:text-primary/80 underline cursor-pointer'\n      anchor.textContent = url\n      fragment.appendChild(anchor)\n\n      lastIndex = index + url.length\n    }\n\n    // Text after the last URL\n    if (lastIndex < text.length) {\n      fragment.appendChild(document.createTextNode(text.slice(lastIndex)))\n    }\n\n    parent.replaceChild(fragment, textNode)\n  }\n\n  return decorated\n}\n\n// ---------------------------------------------------------------------------\n// Markdown inline decoration\n// ---------------------------------------------------------------------------\n\n/** Pattern to find ***bold-italic***, **bold**, and *italic* markdown spans */\nconst MARKDOWN_INLINE_PATTERN = /(\\*{3})(.+?)\\*{3}|(\\*{2})(.+?)\\*{2}|(\\*)(.+?)\\*/g\n\n/**\n * Walks direct-child text nodes in the editor and wraps markdown-formatted\n * text (`**bold**`, `*italic*`, `***bold-italic***`) in styled `<span>` elements.\n *\n * This is a DOM-only decoration — it does NOT modify the segment model.\n * The `<span>` elements are stripped by `normalizeEditorDOM` on every input cycle,\n * so they are re-applied fresh each time.\n *\n * The `*` markers stay visible in the text; only the CSS styling changes.\n *\n * @param editor - The contentEditable root element\n * @returns Whether any decorations were applied\n */\nexport function decorateMarkdownInEditor(editor: HTMLElement, bounds?: DecorateBounds): boolean {\n  let decorated = false\n\n  // Collect text nodes first (avoid modifying while iterating). Emphasis\n  // needs at least one asterisk, so plain text skips the regex.\n  const textNodes: Text[] = []\n  forEachChildInBounds(editor, bounds, (node) => {\n    if (isTextNode(node) && node.textContent && node.textContent.includes('*')) {\n      textNodes.push(node)\n    }\n  })\n\n  for (const textNode of textNodes) {\n    const text = textNode.textContent ?? ''\n    MARKDOWN_INLINE_PATTERN.lastIndex = 0\n    const matches: Array<{\n      fullMatch: string\n      marker: string\n      content: string\n      index: number\n      className: string\n    }> = []\n    let match: RegExpExecArray | null\n\n    while ((match = MARKDOWN_INLINE_PATTERN.exec(text)) !== null) {\n      if (match[1] && match[2]) {\n        // ***bold-italic***\n        matches.push({\n          fullMatch: match[0],\n          marker: match[1],\n          content: match[2],\n          index: match.index,\n          className: 'font-bold italic',\n        })\n      } else if (match[3] && match[4]) {\n        // **bold**\n        matches.push({\n          fullMatch: match[0],\n          marker: match[3],\n          content: match[4],\n          index: match.index,\n          className: 'font-bold',\n        })\n      } else if (match[5] && match[6]) {\n        // *italic*\n        matches.push({\n          fullMatch: match[0],\n          marker: match[5],\n          content: match[6],\n          index: match.index,\n          className: 'italic',\n        })\n      }\n    }\n\n    if (matches.length === 0) continue\n\n    decorated = true\n    const parent = textNode.parentNode\n    if (!parent) continue\n\n    const fragment = document.createDocumentFragment()\n    let lastIndex = 0\n\n    for (const { fullMatch, marker, content, index, className } of matches) {\n      // Text before this match\n      if (index > lastIndex) {\n        fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))\n      }\n\n      // Parent span — textContent still returns full match (e.g. \"**world**\")\n      const span = document.createElement('span')\n      span.dataset.md = 'true'\n\n      // Opening marker (visually hidden)\n      const openMarker = document.createElement('span')\n      openMarker.className = 'prompt-area-md-marker'\n      openMarker.textContent = marker\n\n      // Styled content\n      const styledContent = document.createElement('span')\n      styledContent.className = className\n      styledContent.textContent = content\n\n      // Closing marker (visually hidden)\n      const closeMarker = document.createElement('span')\n      closeMarker.className = 'prompt-area-md-marker'\n      closeMarker.textContent = marker\n\n      span.appendChild(openMarker)\n      span.appendChild(styledContent)\n      span.appendChild(closeMarker)\n      fragment.appendChild(span)\n\n      lastIndex = index + fullMatch.length\n    }\n\n    // Text after the last match\n    if (lastIndex < text.length) {\n      fragment.appendChild(document.createTextNode(text.slice(lastIndex)))\n    }\n\n    parent.replaceChild(fragment, textNode)\n  }\n\n  return decorated\n}\n\n/** Matches a `•` bullet glyph at the start of a line (optionally indented). */\nconst LIST_BULLET_PATTERN = /(^|\\n)([ \\t]*)•/g\n\n/**\n * Walks direct-child text nodes and wraps each line-leading `•` bullet glyph in\n * a `<span class=\"prompt-area-list-bullet\">` so CSS can size it up (the raw\n * U+2022 glyph renders much smaller than the surrounding text).\n *\n * Like {@link decorateMarkdownInEditor}, this is a DOM-only decoration: the span\n * is stripped by {@link normalizeEditorDOM} on every input cycle, so the `•`\n * stays a plain character in the segment model and is re-decorated each render.\n *\n * @param editor - The contentEditable root element\n * @returns Whether any decorations were applied\n */\nexport function decorateBulletsInEditor(editor: HTMLElement, bounds?: DecorateBounds): boolean {\n  let decorated = false\n\n  const textNodes: Text[] = []\n  forEachChildInBounds(editor, bounds, (node) => {\n    if (isTextNode(node) && node.textContent?.includes('•')) {\n      textNodes.push(node)\n    }\n  })\n\n  for (const textNode of textNodes) {\n    const text = textNode.textContent ?? ''\n    LIST_BULLET_PATTERN.lastIndex = 0\n    const bulletIndices: number[] = []\n    let match: RegExpExecArray | null\n    while ((match = LIST_BULLET_PATTERN.exec(text)) !== null) {\n      bulletIndices.push(match.index + match[1].length + match[2].length)\n    }\n\n    if (bulletIndices.length === 0) continue\n\n    decorated = true\n    const parent = textNode.parentNode\n    if (!parent) continue\n\n    const fragment = document.createDocumentFragment()\n    let lastIndex = 0\n\n    for (const index of bulletIndices) {\n      if (index > lastIndex) {\n        fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))\n      }\n      const span = document.createElement('span')\n      span.dataset.md = 'true'\n      span.className = 'prompt-area-list-bullet'\n      span.textContent = '•'\n      fragment.appendChild(span)\n      lastIndex = index + 1 // the bullet is a single character\n    }\n\n    if (lastIndex < text.length) {\n      fragment.appendChild(document.createTextNode(text.slice(lastIndex)))\n    }\n\n    parent.replaceChild(fragment, textNode)\n  }\n\n  return decorated\n}\n\n/** An ATX heading — `# ` through `###### ` — at the start of a line. */\nconst HEADING_PATTERN = /^(#{1,6}) (.*)$/\n\n/**\n * Renders markdown headings at their real size: `## Risks` becomes a heading\n * line, with the hashes collapsed to nothing rather than left on screen.\n *\n * Like the other decorations this is display-only. The `#` characters stay in\n * the DOM inside a zero-size `.prompt-area-md-marker`, so `textContent` — and\n * therefore the segment model and every caret offset — is unchanged, and\n * {@link normalizeEditorDOM} unwraps the whole thing back to plain text on the\n * next input cycle.\n *\n * Must run BEFORE the node-splitting passes ({@link decorateURLsInEditor},\n * {@link decorateMarkdownInEditor}), which need whole-line text nodes. That\n * puts the heading's text out of their reach, so {@link decorateEditor} runs\n * the inline pass over each heading's content span afterwards — otherwise\n * `## Risks and *caveats*` would show its asterisks raw.\n *\n * A heading is only recognised at a true line start — the first text node, one\n * following a `<br>`, or just after a `\\n`. A text node that merely continues a\n * line (after a chip, say) is left alone, so `see #4 and ## notes` stays prose.\n *\n * @param editor - The contentEditable root element\n * @returns Whether any decorations were applied\n */\nexport function decorateHeadingsInEditor(editor: HTMLElement, bounds?: DecorateBounds): boolean {\n  let decorated = false\n\n  const textNodes: Text[] = []\n  forEachChildInBounds(editor, bounds, (node) => {\n    if (isTextNode(node) && node.textContent?.includes('#')) {\n      textNodes.push(node)\n    }\n  })\n\n  for (const textNode of textNodes) {\n    const text = textNode.textContent ?? ''\n    const previous = textNode.previousSibling\n    const opensLine = previous === null || isBRElement(previous)\n\n    const lines = text.split('\\n')\n    const fragment = document.createDocumentFragment()\n    let touched = false\n\n    lines.forEach((line, index) => {\n      if (index > 0) fragment.appendChild(document.createTextNode('\\n'))\n\n      const atLineStart = index === 0 ? opensLine : true\n      const match = atLineStart ? HEADING_PATTERN.exec(line) : null\n      if (!match) {\n        fragment.appendChild(document.createTextNode(line))\n        return\n      }\n\n      const hashes = match[1] ?? ''\n      const content = match[2] ?? ''\n      touched = true\n\n      const wrapper = document.createElement('span')\n      wrapper.dataset.md = 'true'\n      wrapper.dataset.mdHeading = String(hashes.length)\n      wrapper.className = 'prompt-area-md-heading'\n\n      const marker = document.createElement('span')\n      marker.className = 'prompt-area-md-marker'\n      // The trailing space belongs to the marker: it's syntax, and leaving it\n      // visible would indent every heading by a space.\n      marker.textContent = `${hashes} `\n\n      const body = document.createElement('span')\n      body.className = 'prompt-area-md-heading-text'\n      body.textContent = content\n\n      wrapper.appendChild(marker)\n      wrapper.appendChild(body)\n      fragment.appendChild(wrapper)\n    })\n\n    if (!touched) continue\n\n    const parent = textNode.parentNode\n    if (!parent) continue\n\n    decorated = true\n    parent.replaceChild(fragment, textNode)\n  }\n\n  return decorated\n}\n\n/**\n * Matches the line-leading whitespace run of an indented list line (bullet or\n * numbered). The lookahead keeps the list prefix itself out of the capture, so\n * only the indentation is wrapped.\n */\nconst LIST_INDENT_PATTERN = /(^|\\n)([ \\t]+)(?=(?:[•\\-*] |\\d+\\. ))/g\n\n/**\n * Wraps each list line's leading indentation in an inline-block\n * `<span class=\"prompt-area-list-indent\">` sized per nesting level, so nested\n * items read with a wide, Notion-like indent instead of the raw 2-space gap.\n *\n * Like the other decorations this is display-only: the span keeps the original\n * whitespace as its textContent (so plain-text length and caret offsets are\n * unchanged) and is stripped by {@link normalizeEditorDOM} each input cycle.\n * Must run BEFORE the node-splitting passes ({@link decorateURLsInEditor},\n * {@link decorateMarkdownInEditor}, {@link decorateBulletsInEditor}) so every\n * direct-child text node is still a whole line — otherwise a mid-line split\n * fragment beginning with whitespace would let the `^` anchor false-match\n * non-line-leading whitespace.\n *\n * @returns Whether any decorations were applied\n */\nexport function decorateListIndentInEditor(editor: HTMLElement, bounds?: DecorateBounds): boolean {\n  let decorated = false\n\n  // Pre-gate: the indent pattern only matches leading whitespace at the node\n  // start or after an embedded newline, so unindented single-line nodes (the\n  // overwhelming majority) skip the regex.\n  const textNodes: Text[] = []\n  forEachChildInBounds(editor, bounds, (node) => {\n    if (!isTextNode(node)) return\n    const text = node.textContent\n    if (text && (text[0] === ' ' || text[0] === '\\t' || text.includes('\\n'))) {\n      textNodes.push(node)\n    }\n  })\n\n  for (const textNode of textNodes) {\n    const text = textNode.textContent ?? ''\n    LIST_INDENT_PATTERN.lastIndex = 0\n    const runs: { start: number; end: number }[] = []\n    let match: RegExpExecArray | null\n    while ((match = LIST_INDENT_PATTERN.exec(text)) !== null) {\n      const start = match.index + match[1].length\n      runs.push({ start, end: start + match[2].length })\n    }\n\n    if (runs.length === 0) continue\n\n    decorated = true\n    const parent = textNode.parentNode\n    if (!parent) continue\n\n    const fragment = document.createDocumentFragment()\n    let lastIndex = 0\n\n    for (const { start, end } of runs) {\n      if (start > lastIndex) {\n        fragment.appendChild(document.createTextNode(text.slice(lastIndex, start)))\n      }\n      const whitespace = text.slice(start, end)\n      const level = Math.floor(whitespace.length / 2)\n      const span = document.createElement('span')\n      span.dataset.md = 'true'\n      span.className = 'prompt-area-list-indent'\n      span.style.width = `calc(var(--prompt-area-indent-size, 1.5em) * ${level})`\n      span.textContent = whitespace\n      fragment.appendChild(span)\n      lastIndex = end\n    }\n\n    if (lastIndex < text.length) {\n      fragment.appendChild(document.createTextNode(text.slice(lastIndex)))\n    }\n\n    parent.replaceChild(fragment, textNode)\n  }\n\n  return decorated\n}\n\n/**\n * Applies every display-only decoration to the editor in one pass: URL links\n * always, plus markdown emphasis, list indentation, and list bullets when\n * markdown mode is on. Each decoration is stripped by {@link normalizeEditorDOM}\n * on the next input cycle and re-applied here, so the segment model is never\n * mutated.\n *\n * List indentation runs FIRST, while each direct-child text node is still a\n * whole line: the URL and markdown passes split text nodes mid-line, and a tail\n * fragment starting with whitespace would let the indent regex's `^` anchor\n * false-match non-line-leading whitespace (e.g. `see http://x   1. y`).\n *\n * @param headingsEnabled - Also render `# ` … `###### ` as sized headings.\n *   Separate from `markdownEnabled` and off by default: a comment box is prose,\n *   and silently turning someone's `## ` into a display heading there would be\n *   a surprise rather than a feature.\n */\nexport function decorateEditor(\n  editor: HTMLElement,\n  markdownEnabled: boolean,\n  headingsEnabled = false,\n  bounds?: DecorateBounds,\n): boolean {\n  // Whole-line passes run FIRST, while each direct-child text node is still a\n  // full line. The URL and markdown passes split text nodes mid-line, so a tail\n  // fragment beginning with \"•\" would let the bullet regex's `^` anchor\n  // false-match a mid-line separator (e.g. `**bold** • middle`).\n  let changed = false\n  if (markdownEnabled) {\n    if (headingsEnabled) changed = decorateHeadingsInEditor(editor, bounds) || changed\n    changed = decorateListIndentInEditor(editor, bounds) || changed\n    changed = decorateBulletsInEditor(editor, bounds) || changed\n  }\n  changed = decorateURLsInEditor(editor, bounds) || changed\n  if (markdownEnabled) changed = decorateMarkdownInEditor(editor, bounds) || changed\n\n  // Inline emphasis *inside* a heading. The heading pass consumed the line's\n  // text node into its own span, so the editor-level pass above can't reach it\n  // — without this, `## Risks and *caveats*` would show its asterisks raw.\n  if (markdownEnabled && headingsEnabled) {\n    if (bounds) {\n      // Scoped cycle: the only heading wrappers needing the inline pass are\n      // the ones the bounded heading pass just created inside this line.\n      forEachChildInBounds(editor, bounds, (node) => {\n        if (isHTMLElement(node) && node.dataset.mdHeading !== undefined) {\n          const body = node.querySelector('.prompt-area-md-heading-text')\n          if (body && isHTMLElement(body)) changed = decorateMarkdownInEditor(body) || changed\n        }\n      })\n    } else {\n      const headingTexts = editor.querySelectorAll('.prompt-area-md-heading-text')\n      for (let i = 0; i < headingTexts.length; i++) {\n        const body = headingTexts[i]\n        if (isHTMLElement(body)) changed = decorateMarkdownInEditor(body) || changed\n      }\n    }\n  }\n\n  return changed\n}\n\n// ---------------------------------------------------------------------------\n// Selection helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Returns the first Range from the current window selection, or null if\n * there is no selection or it has no ranges.\n */\nexport function getSelectionRange(): Range | null {\n  const sel = window.getSelection()\n  if (!sel || sel.rangeCount === 0) return null\n  return sel.getRangeAt(0)\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/use-trigger-search.ts",
      "content": "'use client'\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport type { TriggerConfig, TriggerSuggestion } from './types'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype UseTriggerSearchReturn = {\n  suggestions: TriggerSuggestion[]\n  suggestionsLoading: boolean\n  suggestionsError: string | null\n  /** Run a search for the given query using the trigger's onSearch config. */\n  search: (query: string, config: TriggerConfig) => void\n  /** Cancel any in-flight search and reset state. */\n  reset: () => void\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\n/**\n * Manages async trigger search lifecycle: debouncing, AbortController\n * cancellation, race-condition prevention, loading/error state.\n *\n * Extracted from `usePromptArea` so the main hook stays focused on\n * editing concerns while this hook owns the data-fetching side.\n */\nexport function useTriggerSearch(): UseTriggerSearchReturn {\n  const [suggestions, setSuggestions] = useState<TriggerSuggestion[]>([])\n  const [suggestionsLoading, setSuggestionsLoading] = useState(false)\n  const [suggestionsError, setSuggestionsError] = useState<string | null>(null)\n\n  // Version counter – belt-and-suspenders alongside AbortController\n  const searchVersion = useRef(0)\n  // AbortController for cancelling in-flight async searches\n  const abortController = useRef<AbortController | null>(null)\n  // Debounce timer for search queries\n  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  const reset = useCallback(() => {\n    abortController.current?.abort()\n    if (debounceTimer.current) clearTimeout(debounceTimer.current)\n    // Keep the previous array when already empty: reset runs on every\n    // trigger-less keystroke, and a fresh [] would defeat React's setState\n    // bailout and force a render of the whole PromptArea per keystroke.\n    setSuggestions((prev) => (prev.length === 0 ? prev : []))\n    setSuggestionsLoading(false)\n    setSuggestionsError(null)\n  }, [])\n\n  const search = useCallback((query: string, config: TriggerConfig) => {\n    if (!config.onSearch) return\n\n    // Cancel any previous in-flight request and pending debounce\n    abortController.current?.abort()\n    if (debounceTimer.current) clearTimeout(debounceTimer.current)\n\n    setSuggestionsLoading(true)\n    setSuggestionsError(null)\n    searchVersion.current++\n    const version = searchVersion.current\n\n    const controller = new AbortController()\n    abortController.current = controller\n    const { onSearch, onSearchError, searchDebounceMs } = config\n\n    const executeSearch = () => {\n      const result = onSearch(query, { signal: controller.signal })\n\n      if (result instanceof Promise) {\n        void result.then(\n          (items) => {\n            if (controller.signal.aborted || searchVersion.current !== version) return\n            setSuggestions(items)\n            setSuggestionsLoading(false)\n          },\n          (error: unknown) => {\n            if (controller.signal.aborted || searchVersion.current !== version) return\n            // Silently ignore AbortError (expected when superseded)\n            if (error instanceof DOMException && error.name === 'AbortError') return\n            setSuggestionsError(error instanceof Error ? error.message : 'Search failed')\n            setSuggestionsLoading(false)\n            onSearchError?.(error)\n          },\n        )\n      } else {\n        setSuggestions(result)\n        setSuggestionsLoading(false)\n      }\n    }\n\n    // Debounce subsequent searches but fire immediately for the initial empty query\n    if (searchDebounceMs && searchDebounceMs > 0 && query.length > 0) {\n      debounceTimer.current = setTimeout(executeSearch, searchDebounceMs)\n    } else {\n      executeSearch()\n    }\n  }, [])\n\n  // Clean up on unmount\n  useEffect(() => {\n    return () => {\n      abortController.current?.abort()\n      if (debounceTimer.current) clearTimeout(debounceTimer.current)\n    }\n  }, [])\n\n  return {\n    suggestions,\n    suggestionsLoading,\n    suggestionsError,\n    search,\n    reset,\n  }\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/types.ts",
      "content": "/**\n * PromptArea component types\n *\n * A lightweight contentEditable-based text input that supports:\n * - Trigger characters (/, @, #) that activate handlers\n * - Immutable chips for resolved mentions/commands\n * - Configurable trigger behavior (dropdown vs callback)\n * - Simple inline markdown rendering\n */\n\n/**\n * A segment of content within the editable text.\n * The document model is an ordered array of these segments.\n */\nexport type TextSegment = {\n  type: 'text'\n  text: string\n}\n\nexport type ChipSegment = {\n  type: 'chip'\n  /** The trigger character that created this chip (e.g., '@', '#') */\n  trigger: string\n  /** The resolved value/ID (e.g., user ID, file ID) */\n  value: string\n  /** The display text shown in the chip */\n  displayText: string\n  /** Optional data payload attached to the chip */\n  data?: unknown\n  /**\n   * True when this chip was auto-created by pressing space (resolveOnSpace).\n   * Backspace on an auto-resolved chip reverts it to plain text instead of deleting.\n   */\n  autoResolved?: boolean\n}\n\nexport type Segment = TextSegment | ChipSegment\n\n/**\n * Determines where a trigger character is valid.\n * - 'start': Only valid at the very start of input or after a newline (e.g., slash commands)\n * - 'any': Valid after any whitespace boundary (e.g., @mentions)\n */\nexport type TriggerPosition = 'start' | 'any'\n\n/**\n * Defines how a trigger behaves when activated.\n * - 'dropdown': Shows a popover with suggestions from `onSearch`\n * - 'callback': Inserts the char, then fires `onActivate` with the typed query\n * - 'launch': Fires `onActivate` on keydown and SUPPRESSES the char (it never\n *   enters the editor) — for opening an external surface (dialog, palette) where\n *   no in-editor text should appear. Honors `position` like the other modes.\n */\nexport type TriggerMode = 'dropdown' | 'callback' | 'launch'\n\n/**\n * Visual style for rendered chips.\n * - 'pill': Button-like pill with background color, padding, border-radius (default)\n * - 'inline': Bold inline text that flows naturally with surrounding content\n */\nexport type ChipStyle = 'pill' | 'inline'\n\n/**\n * A suggestion item shown in the trigger dropdown.\n */\nexport type TriggerSuggestion = {\n  /** Unique value/ID for this suggestion */\n  value: string\n  /** Display label shown in the dropdown */\n  label: string\n  /** Optional description shown below the label */\n  description?: string\n  /** Optional icon element rendered before the label */\n  icon?: React.ReactNode\n  /** Optional arbitrary data passed through on selection */\n  data?: unknown\n}\n\n/**\n * Configuration for a trigger character.\n */\nexport type TriggerConfig = {\n  /** The trigger character (e.g., '/', '@', '#') */\n  char: string\n  /** Where this trigger is valid */\n  position: TriggerPosition\n  /** How this trigger behaves */\n  mode: TriggerMode\n  /**\n   * For 'dropdown' mode: called with the current query to fetch suggestions.\n   * Should return a list of suggestions to display.\n   *\n   * Receives an options object with an `AbortSignal` that is aborted when a\n   * newer search supersedes this one. Pass it to `fetch()` or other async\n   * APIs to cancel in-flight work automatically.\n   */\n  onSearch?: (\n    query: string,\n    options: { signal: AbortSignal },\n  ) => TriggerSuggestion[] | Promise<TriggerSuggestion[]>\n  /**\n   * For 'dropdown' mode: called when a suggestion is selected.\n   * Return the display text for the chip, or void to use `suggestion.label`.\n   */\n  onSelect?: (suggestion: TriggerSuggestion) => string | void\n  /**\n   * For 'callback' and 'launch' modes: called when the trigger is activated.\n   * Receives the full input text and cursor position. For 'launch' it fires on\n   * keydown (before the char would insert); for 'callback' it fires after.\n   */\n  onActivate?: (context: TriggerActivateContext) => void\n  /**\n   * When true, pressing space while this trigger is active (with a non-empty query)\n   * auto-resolves the typed text into a chip without selecting from the dropdown.\n   * The auto-resolved chip can be reverted to plain text with backspace.\n   * Useful for free-form tags (e.g., #hashtag).\n   */\n  resolveOnSpace?: boolean\n  /**\n   * For 'dropdown' mode: when true, clicking a chip created by this trigger\n   * reopens the suggestion dropdown anchored to the chip, and selecting a\n   * suggestion replaces the chip in place. The empty-query suggestions are\n   * shown with the chip's current value preselected. `onChipClick` still\n   * fires, so side effects (analytics, etc.) keep working.\n   */\n  reopenOnChipClick?: boolean\n  /**\n   * Visual style for chips created by this trigger.\n   * - 'pill' (default): Button-like pill with background, padding, border-radius\n   * - 'inline': Bold inline text without pill styling\n   */\n  chipStyle?: ChipStyle\n  /** CSS class name(s) applied to chips created by this trigger */\n  chipClassName?: string\n  /** Label used for accessibility (e.g., \"mention\", \"command\") */\n  accessibilityLabel?: string\n  /**\n   * Debounce delay in milliseconds before calling `onSearch`.\n   * Defaults to 0 (immediate). The initial empty-query search always fires\n   * immediately regardless of this setting so the dropdown appears instantly.\n   */\n  searchDebounceMs?: number\n  /**\n   * Called when `onSearch` rejects or throws (non-abort errors only).\n   * Use this to log errors or show toast notifications.\n   */\n  onSearchError?: (error: unknown) => void\n  /**\n   * Message shown in the dropdown when `onSearch` returns an empty array.\n   * If omitted, the popover hides when there are no results (current behavior).\n   */\n  emptyMessage?: string\n}\n\n/**\n * Context passed to callback-mode trigger handlers.\n */\nexport type TriggerActivateContext = {\n  /** The full plain text content at the time of activation */\n  text: string\n  /** The cursor offset position */\n  cursorPosition: number\n  /** Function to insert a chip at the current cursor position */\n  insertChip: (chip: Omit<ChipSegment, 'type'>) => void\n}\n\n/**\n * Represents an active trigger being typed by the user.\n */\nexport type ActiveTrigger = {\n  /** The trigger config that was activated */\n  config: TriggerConfig\n  /** Position (character offset) where the trigger character was typed */\n  startOffset: number\n  /** The text typed after the trigger character so far */\n  query: string\n}\n\n/**\n * An image attachment displayed in the prompt area.\n * State is managed externally by the parent component.\n */\nexport type PromptAreaImage = {\n  /** Unique identifier for this image */\n  id: string\n  /** URL to display (CDN URL or temporary blob URL for preview) */\n  url: string\n  /** Optional alt text for accessibility */\n  alt?: string\n  /** When true, shows a loading indicator over the thumbnail */\n  loading?: boolean\n}\n\n/**\n * A file attachment displayed in the prompt area.\n * State is managed externally by the parent component.\n */\nexport type PromptAreaFile = {\n  /** Unique identifier for this file */\n  id: string\n  /** Display filename (e.g., \"report.pdf\") */\n  name: string\n  /** File size in bytes */\n  size?: number\n  /** MIME type (used for icon selection, e.g., \"application/pdf\") */\n  type?: string\n  /** When true, shows a loading indicator over the file card */\n  loading?: boolean\n}\n\n/**\n * Props for the PromptArea component.\n */\nexport type PromptAreaProps = {\n  /** The document segments (controlled) */\n  value: Segment[]\n  /** Called when the content changes */\n  onChange: (segments: Segment[]) => void\n  /** Trigger configurations */\n  triggers?: TriggerConfig[]\n  /** Placeholder text when empty. Pass an array of strings to animate between them. */\n  placeholder?: string | string[]\n  /** Additional CSS class for the container */\n  className?: string\n  /** Whether the input is disabled */\n  disabled?: boolean\n  /** Whether to render simple inline markdown (bold, italic, URLs, lists) */\n  markdown?: boolean\n  /**\n   * Also render ATX headings — `# ` through `###### ` — at their real size,\n   * with the hashes hidden. Requires `markdown`; off by default because a\n   * comment box is prose, and silently turning a typed `## ` into a display\n   * heading there would surprise rather than help. Turn it on for editors that\n   * hold a document. Default `false`.\n   */\n  markdownHeadings?: boolean\n  /**\n   * When markdown is on, the editor rewrites typed list markers (`- ` / `* `)\n   * to a `•` bullet glyph in the model. Set to `false` to keep the original\n   * marker in the value/`onChange` text — needed when a host renders the output\n   * as real markdown, where `•` is not a valid list marker. Default `true`.\n   */\n  normalizeBullets?: boolean\n  /** Called when Enter is pressed (without Shift) */\n  onSubmit?: (segments: Segment[]) => void\n  /** Called when Escape is pressed */\n  onEscape?: () => void\n  /** Called when a chip element is clicked. Receives the chip's segment data. */\n  onChipClick?: (chip: ChipSegment) => void\n  /** Called when a new chip is added (dropdown selection, auto-resolve, paste, or imperative insert) */\n  onChipAdd?: (chip: ChipSegment) => void\n  /** Called when a chip is deleted (backspace or forward delete) */\n  onChipDelete?: (chip: ChipSegment) => void\n  /** Called when a URL link is clicked. Receives the URL string. */\n  onLinkClick?: (url: string) => void\n  /** Called after content is pasted. Receives the resulting segments and the paste source. */\n  onPaste?: (data: { segments: Segment[]; source: 'internal' | 'external' }) => void\n  /** Called after an undo operation. Receives the restored segments. */\n  onUndo?: (segments: Segment[]) => void\n  /** Called after a redo operation. Receives the restored segments. */\n  onRedo?: (segments: Segment[]) => void\n  /** Minimum height in pixels */\n  minHeight?: number\n  /** Maximum height in pixels */\n  maxHeight?: number\n  /**\n   * Maximum number of plain-text characters allowed, enforced on typed input:\n   * once the editor exceeds the cap it is truncated back to this length, with\n   * the caret kept where the edit happened. Chips count as their\n   * `trigger + displayText` length.\n   *\n   * The cap applies to typing only. Paste is not capped — divert it via\n   * `onRawPaste` if needed — and the imperative `setText` / `appendText` also\n   * bypass it, so a programmatic write can exceed the cap until the next\n   * keystroke truncates.\n   */\n  maxLength?: number\n  /** Auto-focus on mount */\n  autoFocus?: boolean\n  /** When true, the area auto-grows to fit content on focus and shrinks on blur */\n  autoGrow?: boolean\n  /** Accessible label for the input */\n  'aria-label'?: string\n  /** data-test-id for e2e testing */\n  'data-test-id'?: string\n  /** Array of image attachments to display */\n  images?: PromptAreaImage[]\n  /** Where to render the image strip relative to the text area. Defaults to 'above'. */\n  imagePosition?: 'above' | 'below'\n  /** Called when the user pastes an image from clipboard. Receives the File object. */\n  onImagePaste?: (file: File) => void\n  /** Called when the user clicks the remove button on an image */\n  onImageRemove?: (image: PromptAreaImage) => void\n  /** Called when the user clicks an image thumbnail */\n  onImageClick?: (image: PromptAreaImage) => void\n  /** Array of file attachments to display */\n  files?: PromptAreaFile[]\n  /** Where to render the file strip relative to the text area. Defaults to 'above'. */\n  filePosition?: 'above' | 'below'\n  /** Called when the user clicks the remove button on a file */\n  onFileRemove?: (file: PromptAreaFile) => void\n  /** Called when the user clicks a file attachment */\n  onFileClick?: (file: PromptAreaFile) => void\n  /**\n   * Called on keydown before PromptArea's own handling. Call `preventDefault()`\n   * to suppress the built-in behaviour (submit, trigger navigation, etc.) for\n   * that key and take over entirely.\n   */\n  onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void\n  /**\n   * Called on blur with the native FocusEvent, so consumers can inspect\n   * `relatedTarget` (e.g. to retain focus when a composer toolbar is clicked).\n   */\n  onBlur?: (e: React.FocusEvent<HTMLDivElement>) => void\n  /**\n   * Called at the start of a paste, before PromptArea reads the clipboard. Call\n   * `preventDefault()` to take over the paste completely — e.g. to divert large\n   * text or non-image files to an upload pipeline. The built-in segment/image\n   * paste handling is skipped when the event's default is prevented.\n   */\n  onRawPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void\n  /**\n   * Whether pressing Enter (without Shift) submits. Defaults to true. Set false\n   * to make Enter insert a newline instead (e.g. on touch devices where submit\n   * is a dedicated button).\n   */\n  submitOnEnter?: boolean\n  /** Forwarded to the editable element. */\n  spellCheck?: boolean\n  /** Forwarded to the editable element as `aria-describedby`. */\n  'aria-describedby'?: string\n}\n\n/**\n * Ref handle exposed by PromptArea via useImperativeHandle.\n */\nexport type PromptAreaHandle = {\n  /** Focus the editable area */\n  focus: () => void\n  /** Blur the editable area */\n  blur: () => void\n  /** Insert a chip at the current cursor position */\n  insertChip: (chip: Omit<ChipSegment, 'type'>) => void\n  /** Get the current plain text (without chip markup) */\n  getPlainText: () => string\n  /** Clear all content */\n  clear: () => void\n  /**\n   * Replace all content with plain text (chips dropped), caret moved to the\n   * end. Not capped by `maxLength` (see its docs); undoable.\n   */\n  setText: (text: string) => void\n  /**\n   * Append plain text at the end (existing chips preserved), caret moved to the\n   * end. Not capped by `maxLength` (see its docs); undoable.\n   */\n  appendText: (text: string) => void\n  /** Caret offset in plain-text characters, or null when unavailable. */\n  getCursorPosition: () => number | null\n  /** Move the caret to a plain-text offset. */\n  setCursorPosition: (offset: number) => void\n  /** Move the caret to the end of the content. */\n  setCursorToEnd: () => void\n  /** Current selection as plain-text offsets, or null when there is none. */\n  getSelection: () => { start: number; end: number } | null\n  /** Set the selection between two plain-text offsets. */\n  setSelection: (start: number, end: number) => void\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/animated-placeholder.tsx",
      "content": "'use client'\n\nimport { useEffect, useState } from 'react'\n\ntype AnimatedPlaceholderProps = {\n  texts: string[]\n  interval?: number\n}\n\n/**\n * Cross-fading placeholder that rotates through `texts`.\n *\n * Each text is keyed so React remounts it on change, replaying the\n * `tw-animate-css` enter animation (slide down + fade in). No animation\n * library is required.\n */\nexport function AnimatedPlaceholder({ texts, interval = 3000 }: AnimatedPlaceholderProps) {\n  const [index, setIndex] = useState(0)\n\n  useEffect(() => {\n    if (texts.length <= 1) return\n\n    const id = setInterval(() => {\n      setIndex((prev) => (prev + 1) % texts.length)\n    }, interval)\n\n    return () => clearInterval(id)\n  }, [texts.length, interval])\n\n  return (\n    <div\n      className=\"pointer-events-none absolute top-0 left-0 overflow-hidden select-none\"\n      style={{ color: 'var(--prompt-area-placeholder, var(--muted-foreground))' }}\n      aria-hidden=\"true\">\n      <div\n        key={index}\n        className=\"animate-in fade-in-0 slide-in-from-top-4 duration-300 ease-in-out\">\n        {texts[index]}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/image-strip.tsx",
      "content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { RemoveButton } from './remove-button'\nimport type { PromptAreaImage } from './types'\n\ntype ImageStripProps = {\n  images: PromptAreaImage[]\n  onRemove?: (image: PromptAreaImage) => void\n  onClick?: (image: PromptAreaImage) => void\n  className?: string\n}\n\nexport function ImageStrip({ images, onRemove, onClick, className }: ImageStripProps) {\n  if (images.length === 0) return null\n\n  return (\n    <div className={cn('flex flex-wrap gap-2', className)} role=\"list\" aria-label=\"Attached images\">\n      {images.map((image) => (\n        <div\n          key={image.id}\n          role=\"listitem\"\n          className={cn(\n            'border-border relative h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border',\n            onClick && 'cursor-pointer',\n          )}\n          onClick={() => onClick?.(image)}>\n          {/* eslint-disable-next-line @next/next/no-img-element -- registry component used outside Next.js */}\n          <img\n            src={image.url}\n            alt={image.alt ?? 'Attached image'}\n            className=\"h-full w-full object-cover\"\n          />\n          {image.loading && (\n            <div className=\"absolute inset-0 flex items-center justify-center bg-black/40\">\n              <div className=\"h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent\" />\n            </div>\n          )}\n          {onRemove && (\n            <RemoveButton\n              onClick={() => onRemove(image)}\n              label={`Remove ${image.alt ?? 'image'}`}\n            />\n          )}\n        </div>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/file-strip.tsx",
      "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { cn } from '@/lib/utils'\nimport { RemoveButton } from './remove-button'\nimport type { PromptAreaFile } from './types'\n\ntype IconProps = { className?: string }\n\n/** Shared SVG wrapper matching the lucide icon defaults (no dependency). */\nfunction Svg({ className, children }: IconProps & { children: React.ReactNode }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      className={className}>\n      {children}\n    </svg>\n  )\n}\n\nconst FileBody = (\n  <>\n    <path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" />\n    <path d=\"M14 2v5a1 1 0 0 0 1 1h5\" />\n  </>\n)\n\nconst File = ({ className }: IconProps) => <Svg className={className}>{FileBody}</Svg>\nconst FileText = ({ className }: IconProps) => (\n  <Svg className={className}>\n    {FileBody}\n    <path d=\"M10 9H8\" />\n    <path d=\"M16 13H8\" />\n    <path d=\"M16 17H8\" />\n  </Svg>\n)\nconst FileSpreadsheet = ({ className }: IconProps) => (\n  <Svg className={className}>\n    {FileBody}\n    <path d=\"M8 13h2\" />\n    <path d=\"M14 13h2\" />\n    <path d=\"M8 17h2\" />\n    <path d=\"M14 17h2\" />\n  </Svg>\n)\nconst FileCode = ({ className }: IconProps) => (\n  <Svg className={className}>\n    {FileBody}\n    <path d=\"M10 12.5 8 15l2 2.5\" />\n    <path d=\"m14 12.5 2 2.5-2 2.5\" />\n  </Svg>\n)\nconst ImageIcon = ({ className }: IconProps) => (\n  <Svg className={className}>\n    <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" />\n    <circle cx=\"9\" cy=\"9\" r=\"2\" />\n    <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />\n  </Svg>\n)\n\ntype FileStripProps = {\n  files: PromptAreaFile[]\n  onRemove?: (file: PromptAreaFile) => void\n  onClick?: (file: PromptAreaFile) => void\n  className?: string\n}\n\n/** Threshold above which collapse activates automatically. */\nconst COLLAPSE_THRESHOLD = 3\n\n/** Pick a lucide icon key based on MIME type. */\nfunction getFileIconKey(type?: string): 'pdf' | 'spreadsheet' | 'code' | 'image' | 'default' {\n  if (!type) return 'default'\n  if (type === 'application/pdf') return 'pdf'\n  if (type.includes('spreadsheet') || type === 'text/csv') return 'spreadsheet'\n  if (\n    type.startsWith('text/') ||\n    type.includes('javascript') ||\n    type.includes('json') ||\n    type.includes('xml')\n  )\n    return 'code'\n  if (type.startsWith('image/')) return 'image'\n  return 'default'\n}\n\nconst FILE_ICONS = {\n  pdf: FileText,\n  spreadsheet: FileSpreadsheet,\n  code: FileCode,\n  image: ImageIcon,\n  default: File,\n} as const\n\n/** Format bytes into a human-readable string. */\nfunction formatFileSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`\n}\n\n/** Extract a short extension label from a filename (e.g., \"PDF\", \"CSV\"). */\nfunction getExtensionLabel(name: string): string | null {\n  const dot = name.lastIndexOf('.')\n  if (dot === -1 || dot === name.length - 1) return null\n  return name.slice(dot + 1).toUpperCase()\n}\n\nfunction FileCard({\n  file,\n  compact,\n  onRemove,\n  onClick,\n}: {\n  file: PromptAreaFile\n  compact: boolean\n  onRemove?: (file: PromptAreaFile) => void\n  onClick?: (file: PromptAreaFile) => void\n}) {\n  const ext = getExtensionLabel(file.name)\n  const sizeStr = file.size != null ? formatFileSize(file.size) : null\n  const meta = [ext, sizeStr].filter(Boolean).join(' · ')\n\n  return (\n    <div\n      role=\"listitem\"\n      className={cn(\n        'border-border relative flex flex-shrink-0 items-center gap-2 overflow-hidden rounded-lg border transition-colors',\n        'hover:bg-accent',\n        compact ? 'h-10 w-36 px-2' : 'h-14 w-48 px-3',\n        onClick && 'cursor-pointer',\n      )}\n      onClick={() => onClick?.(file)}>\n      {(() => {\n        const Icon = FILE_ICONS[getFileIconKey(file.type)]\n        return (\n          <Icon\n            className={cn('text-muted-foreground flex-shrink-0', compact ? 'h-4 w-4' : 'h-5 w-5')}\n          />\n        )\n      })()}\n      <div className=\"min-w-0 flex-1\">\n        <div\n          className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')}\n          title={file.name}>\n          {file.name}\n        </div>\n        {!compact && meta && <div className=\"text-muted-foreground truncate text-xs\">{meta}</div>}\n      </div>\n\n      {file.loading && (\n        <div className=\"absolute inset-0 flex items-center justify-center bg-black/40\">\n          <div className=\"h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent\" />\n        </div>\n      )}\n\n      {onRemove && <RemoveButton onClick={() => onRemove(file)} label={`Remove ${file.name}`} />}\n    </div>\n  )\n}\n\nexport function FileStrip({ files, onRemove, onClick, className }: FileStripProps) {\n  const [expanded, setExpanded] = useState(false)\n  const popoverRef = useRef<HTMLDivElement>(null)\n  const toggleRef = useRef<HTMLButtonElement>(null)\n\n  useEffect(() => {\n    if (!expanded) return\n    const handleClick = (e: MouseEvent) => {\n      const target = e.target\n      if (!(target instanceof Node)) return\n      if (\n        popoverRef.current &&\n        !popoverRef.current.contains(target) &&\n        !toggleRef.current?.contains(target)\n      ) {\n        setExpanded(false)\n      }\n    }\n    document.addEventListener('mousedown', handleClick)\n    return () => document.removeEventListener('mousedown', handleClick)\n  }, [expanded])\n\n  if (files.length === 0) return null\n\n  const collapsible = files.length > COLLAPSE_THRESHOLD\n  const compact = collapsible\n  const hiddenCount = files.length - COLLAPSE_THRESHOLD\n  const visibleFiles = files.slice(0, COLLAPSE_THRESHOLD)\n\n  return (\n    <div className={cn('relative', className)}>\n      <div className=\"flex flex-wrap gap-2\" role=\"list\" aria-label=\"Attached files\">\n        {(collapsible ? visibleFiles : files).map((file) => (\n          <FileCard\n            key={file.id}\n            file={file}\n            compact={compact}\n            onRemove={onRemove}\n            onClick={onClick}\n          />\n        ))}\n        {collapsible && (\n          <div role=\"listitem\">\n            <button\n              ref={toggleRef}\n              type=\"button\"\n              onClick={() => setExpanded((v) => !v)}\n              className={cn(\n                'border-border text-muted-foreground hover:bg-accent flex flex-shrink-0 cursor-pointer items-center justify-center rounded-lg border transition-colors',\n                compact ? 'h-10 px-3 text-xs' : 'h-14 px-4 text-sm',\n              )}>\n              {expanded ? 'Show less' : `+${hiddenCount} more`}\n            </button>\n          </div>\n        )}\n      </div>\n\n      {expanded && (\n        <div\n          ref={popoverRef}\n          className={cn(\n            'bg-popover border-border absolute bottom-full left-0 z-10 mb-2 max-h-48 overflow-y-auto rounded-lg border p-2 shadow-lg',\n          )}>\n          <div className=\"flex flex-wrap gap-2\" role=\"list\" aria-label=\"More attached files\">\n            {files.slice(COLLAPSE_THRESHOLD).map((file) => (\n              <FileCard\n                key={file.id}\n                file={file}\n                compact={compact}\n                onRemove={onRemove}\n                onClick={onClick}\n              />\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/remove-button.tsx",
      "content": "import { cn } from '@/lib/utils'\n\ntype RemoveButtonProps = {\n  onClick: () => void\n  label: string\n  className?: string\n}\n\nexport function RemoveButton({ onClick, label, className }: RemoveButtonProps) {\n  return (\n    <button\n      type=\"button\"\n      onClick={(e) => {\n        e.stopPropagation()\n        onClick()\n      }}\n      className={cn(\n        'absolute top-0.5 right-0.5 grid h-3.5 w-3.5 cursor-pointer place-items-center',\n        'rounded-full bg-black/60 text-white hover:bg-black/80 dark:bg-white/60 dark:text-black dark:hover:bg-white/80',\n        'transition-colors',\n        className,\n      )}\n      aria-label={label}>\n      <svg\n        width=\"8\"\n        height=\"8\"\n        viewBox=\"0 0 10 10\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\">\n        <line x1=\"2.75\" y1=\"2.75\" x2=\"7.25\" y2=\"7.25\" />\n        <line x1=\"7.25\" y1=\"2.75\" x2=\"2.75\" y2=\"7.25\" />\n      </svg>\n    </button>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/segment-helpers.ts",
      "content": "/**\n * Convenience helpers for creating and inspecting Segments.\n *\n * These reduce boilerplate when building AI chat UIs that work with the\n * PromptArea document model.\n *\n * @example\n * ```ts\n * import { text, chip, isSegmentsEmpty, segmentsToPlainText } from './segment-helpers'\n *\n * const greeting = [text('Hello '), chip({ trigger: '@', value: 'u1', displayText: 'Alice' })]\n * isSegmentsEmpty(greeting) // false\n * segmentsToPlainText(greeting) // \"Hello @Alice\"\n * ```\n */\n\nimport type { Segment, TextSegment, ChipSegment } from './types'\nimport { segmentsToPlainText, plainTextToSegments } from './prompt-area-engine'\n\n// Re-export serialization utilities so consumers have a single import.\nexport { segmentsToPlainText, plainTextToSegments }\n\n// ---------------------------------------------------------------------------\n// Factories\n// ---------------------------------------------------------------------------\n\n/** Create a text segment. */\nexport function text(value: string): TextSegment {\n  return { type: 'text', text: value }\n}\n\n/** Create a chip segment. */\nexport function chip(opts: Omit<ChipSegment, 'type'>): ChipSegment {\n  return { type: 'chip', ...opts }\n}\n\n// ---------------------------------------------------------------------------\n// Predicates\n// ---------------------------------------------------------------------------\n\n/** Returns `true` when the segment array is empty or contains only whitespace text. */\nexport function isSegmentsEmpty(segments: Segment[]): boolean {\n  if (segments.length === 0) return true\n  return segments.every((seg) => seg.type === 'text' && seg.text.trim() === '')\n}\n\n/** Returns `true` when the segment array contains at least one chip. */\nexport function hasChips(segments: Segment[]): boolean {\n  return segments.some((seg) => seg.type === 'chip')\n}\n\n/** Extracts all chip segments from a segment array. */\nexport function getChips(segments: Segment[]): ChipSegment[] {\n  return segments.filter((seg): seg is ChipSegment => seg.type === 'chip')\n}\n\n/** Extracts chips matching a specific trigger character. */\nexport function getChipsByTrigger(segments: Segment[], trigger: string): ChipSegment[] {\n  return segments.filter(\n    (seg): seg is ChipSegment => seg.type === 'chip' && seg.trigger === trigger,\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/trigger-presets.ts",
      "content": "/**\n * Pre-built trigger configuration factories for common AI chat patterns.\n *\n * Each factory returns a full `TriggerConfig` with sensible defaults.\n * Pass only what you need to override.\n *\n * @example\n * ```tsx\n * <PromptArea\n *   triggers={[\n *     mentionTrigger({ onSearch: searchUsers }),\n *     commandTrigger({ onSearch: searchCommands }),\n *     hashtagTrigger(),\n *   ]}\n * />\n * ```\n */\n\nimport type { TriggerConfig, TriggerPosition } from './types'\n\n// ---------------------------------------------------------------------------\n// Shared option type — everything in TriggerConfig except the keys each\n// factory sets by default.\n// ---------------------------------------------------------------------------\n\ntype TriggerPresetOptions = Omit<Partial<TriggerConfig>, 'char' | 'position' | 'mode'>\n\n// ---------------------------------------------------------------------------\n// @mention — dropdown at any position\n// ---------------------------------------------------------------------------\n\nexport type MentionTriggerOptions = TriggerPresetOptions & {\n  /** Override the trigger character. Defaults to `'@'`. */\n  char?: string\n}\n\n/**\n * Creates a **mention** trigger (`@`).\n *\n * Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'pill'`,\n * accessible label `\"mention\"`.\n */\nexport function mentionTrigger(opts: MentionTriggerOptions = {}): TriggerConfig {\n  const { char = '@', ...rest } = opts\n  return {\n    char,\n    position: 'any',\n    mode: 'dropdown',\n    chipStyle: 'pill',\n    accessibilityLabel: 'mention',\n    ...rest,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// /command — dropdown anywhere (opt into line-start-only with `position`)\n// ---------------------------------------------------------------------------\n\nexport type CommandTriggerOptions = TriggerPresetOptions & {\n  /** Override the trigger character. Defaults to `'/'`. */\n  char?: string\n  /**\n   * Where the command trigger is valid. Defaults to `'any'`, so commands fire\n   * anywhere a `/` follows whitespace — not just at the start of a line.\n   * Set to `'start'` to restrict the dropdown to the very start of the input\n   * or immediately after a newline (the classic slash-command behavior).\n   */\n  position?: TriggerPosition\n}\n\n/**\n * Creates a **command** trigger (`/`).\n *\n * Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'inline'`,\n * accessible label `\"command\"`.\n *\n * By default commands work everywhere in the input. Pass `position: 'start'`\n * to limit them to the start of a line.\n */\nexport function commandTrigger(opts: CommandTriggerOptions = {}): TriggerConfig {\n  const { char = '/', position = 'any', ...rest } = opts\n  return {\n    char,\n    position,\n    mode: 'dropdown',\n    chipStyle: 'inline',\n    accessibilityLabel: 'command',\n    ...rest,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// #hashtag — dropdown at any position, auto-resolve on space\n// ---------------------------------------------------------------------------\n\nexport type HashtagTriggerOptions = TriggerPresetOptions & {\n  /** Override the trigger character. Defaults to `'#'`. */\n  char?: string\n}\n\n/**\n * Creates a **hashtag / tag** trigger (`#`).\n *\n * Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'pill'`,\n * `resolveOnSpace: true`, accessible label `\"tag\"`.\n */\nexport function hashtagTrigger(opts: HashtagTriggerOptions = {}): TriggerConfig {\n  const { char = '#', ...rest } = opts\n  return {\n    char,\n    position: 'any',\n    mode: 'dropdown',\n    chipStyle: 'pill',\n    resolveOnSpace: true,\n    accessibilityLabel: 'tag',\n    ...rest,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Generic callback trigger (e.g., for file pickers, model selectors)\n// ---------------------------------------------------------------------------\n\nexport type CallbackTriggerOptions = Omit<Partial<TriggerConfig>, 'mode'> & {\n  /** The trigger character. Required. */\n  char: string\n}\n\n/**\n * Creates a **callback** trigger that fires `onActivate` instead of showing\n * a dropdown. Useful for opening file pickers, model selectors, etc.\n *\n * Defaults: `position: 'start'`, `mode: 'callback'`.\n */\nexport function callbackTrigger(opts: CallbackTriggerOptions): TriggerConfig {\n  const { char, ...rest } = opts\n  return {\n    char,\n    position: 'start',\n    mode: 'callback',\n    ...rest,\n  }\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/use-prompt-area-state.ts",
      "content": "/**\n * Convenience hook that wires up all the boilerplate state for a PromptArea.\n *\n * Instead of manually managing `useState<Segment[]>`, `useRef<PromptAreaHandle>`,\n * and computing derived values, call `usePromptAreaState()` once and spread\n * `bind` into your `<PromptArea>`.\n *\n * @example\n * ```tsx\n * function ChatInput() {\n *   const { bind, plainText, isEmpty, chips, clear, focus } = usePromptAreaState()\n *\n *   return (\n *     <PromptArea\n *       {...bind}\n *       onSubmit={() => {\n *         sendMessage(plainText)\n *         clear()\n *       }}\n *     />\n *   )\n * }\n * ```\n */\n\n'use client'\n\nimport { useCallback, useMemo, useRef, useState } from 'react'\nimport type { Segment, ChipSegment, PromptAreaHandle } from './types'\nimport { segmentsToPlainText } from './prompt-area-engine'\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\nexport type UsePromptAreaStateOptions = {\n  /** Initial segment value. Defaults to `[]`. */\n  initialValue?: Segment[]\n}\n\n// ---------------------------------------------------------------------------\n// Return type\n// ---------------------------------------------------------------------------\n\nexport type PromptAreaBind = {\n  /** Ref to attach to PromptArea — gives access to imperative methods. */\n  ref: React.RefObject<PromptAreaHandle | null>\n  /** Current segment array — pass as `value` prop. */\n  value: Segment[]\n  /** Setter — pass as `onChange` prop. */\n  onChange: (segments: Segment[]) => void\n}\n\nexport type PromptAreaState = {\n  /** Props to spread directly onto `<PromptArea {...bind} />`. Contains ref, value, and onChange. */\n  bind: PromptAreaBind\n  /** Derived plain text representation of the current value. */\n  plainText: string\n  /** `true` when the value is empty or whitespace-only. */\n  isEmpty: boolean\n  /** `true` when the value contains at least one chip. */\n  hasChips: boolean\n  /** All chip segments in the current value. */\n  chips: ChipSegment[]\n  /** Clear all content (both state and the editor DOM). */\n  clear: () => void\n  /** Focus the editor. */\n  focus: () => void\n  /** Blur the editor. */\n  blur: () => void\n  /** Insert a chip at the current cursor position. */\n  insertChip: (chip: Omit<ChipSegment, 'type'>) => void\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\nexport function usePromptAreaState(options: UsePromptAreaStateOptions = {}): PromptAreaState {\n  const { initialValue = [] } = options\n\n  const [value, setValue] = useState<Segment[]>(initialValue)\n  const ref = useRef<PromptAreaHandle>(null)\n\n  // Derived\n  const plainText = useMemo(() => segmentsToPlainText(value), [value])\n\n  const isEmpty = useMemo(() => {\n    if (value.length === 0) return true\n    return value.every((seg) => seg.type === 'text' && seg.text.trim() === '')\n  }, [value])\n\n  const hasChips = useMemo(() => value.some((seg) => seg.type === 'chip'), [value])\n\n  const chips = useMemo(\n    () => value.filter((seg): seg is ChipSegment => seg.type === 'chip'),\n    [value],\n  )\n\n  // Bind object — safe to spread onto <PromptArea>\n  const bind = useMemo<PromptAreaBind>(() => ({ ref, value, onChange: setValue }), [value])\n\n  // Actions that proxy to the imperative handle\n  const clear = useCallback(() => {\n    if (ref.current) {\n      ref.current.clear()\n    } else {\n      setValue([])\n    }\n  }, [])\n\n  const focus = useCallback(() => ref.current?.focus(), [])\n  const blur = useCallback(() => ref.current?.blur(), [])\n\n  const insertChip = useCallback(\n    (chip: Omit<ChipSegment, 'type'>) => ref.current?.insertChip(chip),\n    [],\n  )\n\n  return {\n    bind,\n    plainText,\n    isEmpty,\n    hasChips,\n    chips,\n    clear,\n    focus,\n    blur,\n    insertChip,\n  }\n}\n",
      "type": "registry:component"
    },
    {
      "path": "packages/prompt-area/src/prompt-area/use-markdown-mode.ts",
      "content": "/**\n * Hook that toggles a PromptArea between its markdown and plain-text variants.\n *\n * PromptArea's `markdown` prop controls whether inline markdown (bold, italic,\n * URLs, and list bullets) is rendered and whether typed list markers normalize\n * to `•`. Flipping it at runtime is non-destructive: the segment value is kept,\n * only its rendering changes (bullets convert `•` ↔ `-` when `normalizeBullets`\n * is on). This hook owns that boolean as a named \"mode\" and hands you a `toggle`\n * plus a `markdown` value ready to spread onto the component.\n *\n * @example\n * ```tsx\n * function Composer() {\n *   const { bind } = usePromptAreaState()\n *   const { markdown, mode, toggle } = useMarkdownMode()\n *\n *   return (\n *     <>\n *       <PromptArea {...bind} markdown={markdown} />\n *       <button onClick={toggle} aria-pressed={markdown}>\n *         {mode === 'markdown' ? 'Markdown' : 'Plain text'}\n *       </button>\n *     </>\n *   )\n * }\n * ```\n */\n\n'use client'\n\nimport { useCallback, useMemo, useState } from 'react'\n\n// ---------------------------------------------------------------------------\n// Mode\n// ---------------------------------------------------------------------------\n\n/**\n * The two rendering variants of a PromptArea.\n * - `'markdown'`: inline markdown is decorated and list markers normalize to `•`.\n * - `'plain'`: raw source text is shown verbatim with no decoration.\n */\nexport type PromptAreaMode = 'markdown' | 'plain'\n\n/** Returns the other mode. Pure — handy for building custom toggles. */\nexport function oppositeMode(mode: PromptAreaMode): PromptAreaMode {\n  return mode === 'markdown' ? 'plain' : 'markdown'\n}\n\n// ---------------------------------------------------------------------------\n// Options / return type\n// ---------------------------------------------------------------------------\n\nexport type UseMarkdownModeOptions = {\n  /** Starting mode for the uncontrolled hook. Defaults to `'markdown'`. */\n  initialMode?: PromptAreaMode\n  /**\n   * Controlled mode. When provided, the hook mirrors this value and never owns\n   * its own state — drive changes through `onModeChange`.\n   */\n  mode?: PromptAreaMode\n  /** Called with the next mode whenever `toggle`/`setMode` change it. */\n  onModeChange?: (mode: PromptAreaMode) => void\n}\n\nexport type MarkdownModeState = {\n  /** The active mode. */\n  mode: PromptAreaMode\n  /** `true` in markdown mode — spread onto `<PromptArea markdown={markdown} />`. */\n  markdown: boolean\n  /** `true` in plain-text mode (the inverse of `markdown`). */\n  isPlainText: boolean\n  /** Switch to an explicit mode. No-op if already in it. */\n  setMode: (mode: PromptAreaMode) => void\n  /** Flip between markdown and plain text. */\n  toggle: () => void\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\nexport function useMarkdownMode(options: UseMarkdownModeOptions = {}): MarkdownModeState {\n  const { initialMode = 'markdown', mode: controlledMode, onModeChange } = options\n  const isControlled = controlledMode !== undefined\n\n  const [internalMode, setInternalMode] = useState<PromptAreaMode>(\n    () => controlledMode ?? initialMode,\n  )\n\n  const mode = isControlled ? controlledMode : internalMode\n\n  const setMode = useCallback(\n    (next: PromptAreaMode) => {\n      if (next === mode) return\n      if (!isControlled) setInternalMode(next)\n      onModeChange?.(next)\n    },\n    [mode, isControlled, onModeChange],\n  )\n\n  const toggle = useCallback(() => setMode(oppositeMode(mode)), [mode, setMode])\n\n  return useMemo(\n    () => ({\n      mode,\n      markdown: mode === 'markdown',\n      isPlainText: mode === 'plain',\n      setMode,\n      toggle,\n    }),\n    [mode, setMode, toggle],\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}