{
  "name": "ai-inline-edit",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @kokonutui\n * @description: AI Inline Edit\n * @version: 1.1.0\n * @date: 2026-10-21\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  ArrowUp,\n  ChevronLeft,\n  ChevronRight,\n  PenLine,\n  RotateCw,\n  Square,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  MotionConfig,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/** A rewrite: the text, a Promise of it, or a stream of text deltas. */\nexport type Rewrite = string | Promise<string> | AsyncIterable<string>;\n\nexport interface AIInlineEditProps {\n  title?: string;\n  /** The document, one string per paragraph. Read once, on mount. */\n  paragraphs?: string[];\n  /**\n   * Rewrites the selected text. Return the text, a Promise of it, or a stream\n   * of deltas (like the AI SDK's `textStream`); the bar shows it working until\n   * the text is complete, then writes the changes in. `attempt` counts up with\n   * each Try again, so you can ask for a different take. `signal` aborts when\n   * the person presses Stop. Throw and the bar shows the error's message.\n   */\n  rewrite?: (\n    text: string,\n    instruction: string,\n    attempt: number,\n    signal: AbortSignal\n  ) => Rewrite;\n  /** Called with every paragraph once a rewrite is accepted. */\n  onChange?: (paragraphs: string[]) => void;\n  className?: string;\n}\n\ninterface Span {\n  p: number;\n  start: number;\n  end: number;\n}\n\ninterface Op {\n  kind: \"same\" | \"del\" | \"add\";\n  word: string;\n  /** Whitespace after the word, kept outside the styled span. */\n  gap: string;\n  key: string;\n}\n\ntype Phase =\n  | \"idle\"\n  | \"prompt\"\n  | \"thinking\"\n  | \"streaming\"\n  | \"review\"\n  | \"settling\"\n  | \"discarding\";\ntype Action = \"shorter\" | \"clearer\" | \"friendlier\" | \"grammar\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst BAR_WIDTH = 384;\nconst PAD = 16;\nconst WORD = /\\S+\\s*/g;\nconst TRAILING = /\\s+$/;\nconst SPACE = /\\s/;\nconst APPLE = /Mac|iPhone|iPad/;\nconst SENTENCE = /[^.!?]+[.!?]+\\s*/g;\nconst SHORT_HINT = /short|concise|trim|tight|brief/;\nconst FRIENDLY_HINT = /friend|warm|casual|kind|human/;\nconst GRAMMAR_HINT = /grammar|typo|fix|spell|proof/;\n\nconst ACTIONS: { id: Action; label: string }[] = [\n  { id: \"shorter\", label: \"Shorter\" },\n  { id: \"clearer\", label: \"Clearer\" },\n  { id: \"friendlier\", label: \"Friendlier\" },\n  { id: \"grammar\", label: \"Fix grammar\" },\n];\n\nconst DEMO_PARAGRAPHS = [\n  \"We are really excited to share that the new billing dashboard is finally ready to ship next week. In order to make the rollout as smooth as possible, we will be enabling it for a small number of workspaces first.\",\n  \"Due to the fact that invoices are now generated in real time, some customers might notice that their totals look different then before. Support should reach out to anyone who's account has more then three seats.\",\n  \"If you have any questions at all, please don't hesitate to reach out to the team in #billing.\",\n];\n\n/** Opens on the sentence that needs the most help. */\nconst DEMO_SELECTION: Span = {\n  p: 1,\n  start: 0,\n  end: DEMO_PARAGRAPHS[1].indexOf(\"before.\") + \"before.\".length,\n};\n\n/** Hand-written takes for the demo sentences, two per action. */\nconst CANNED: Record<string, Partial<Record<Action, string[]>>> = {\n  \"We are really excited to share that the new billing dashboard is finally ready to ship next week.\":\n    {\n      shorter: [\n        \"The new billing dashboard ships next week.\",\n        \"The billing dashboard is ready and ships next week.\",\n      ],\n      clearer: [\n        \"The new billing dashboard is ready and ships next week.\",\n        \"Next week we ship the new billing dashboard.\",\n      ],\n      friendlier: [\n        \"Good news: the new billing dashboard is ready and ships next week!\",\n        \"We're thrilled to say the new billing dashboard ships next week.\",\n      ],\n    },\n  \"In order to make the rollout as smooth as possible, we will be enabling it for a small number of workspaces first.\":\n    {\n      shorter: [\n        \"To keep the rollout smooth, we'll enable it for a few workspaces first.\",\n        \"We'll start with a few workspaces to keep the rollout smooth.\",\n      ],\n      clearer: [\n        \"We'll turn it on for a few workspaces first so the rollout goes smoothly.\",\n        \"To keep the rollout smooth, a few workspaces get it first.\",\n      ],\n      friendlier: [\n        \"To keep things smooth, we'll switch it on for a handful of workspaces first.\",\n        \"We're starting with a handful of workspaces so everything goes smoothly.\",\n      ],\n    },\n  \"Due to the fact that invoices are now generated in real time, some customers might notice that their totals look different then before.\":\n    {\n      shorter: [\n        \"Invoices now update in real time, so some totals may look different than before.\",\n        \"Since invoices now update live, some totals may differ from before.\",\n      ],\n      clearer: [\n        \"Invoices are now generated in real time, so some customers will see totals that differ from before.\",\n        \"Because invoices now update in real time, some customers' totals will change from what they saw before.\",\n      ],\n      friendlier: [\n        \"Heads up: invoices now update in real time, so a few customers might spot totals that look a little different than before.\",\n        \"Invoices now update live, so don't worry if a few totals look a little different than before.\",\n      ],\n      grammar: [\n        \"Due to the fact that invoices are now generated in real time, some customers might notice that their totals look different than before.\",\n      ],\n    },\n  \"Support should reach out to anyone who's account has more then three seats.\":\n    {\n      shorter: [\n        \"Support should contact accounts with more than three seats.\",\n        \"Support will contact accounts over three seats.\",\n      ],\n      clearer: [\n        \"Support should contact every account with more than three seats.\",\n        \"Any account with more than three seats will hear from Support.\",\n      ],\n      friendlier: [\n        \"Support will check in with anyone whose account has more than three seats.\",\n        \"Our Support team will say hi to every account with more than three seats.\",\n      ],\n      grammar: [\n        \"Support should reach out to anyone whose account has more than three seats.\",\n      ],\n    },\n  \"If you have any questions at all, please don't hesitate to reach out to the team in #billing.\":\n    {\n      shorter: [\n        \"Questions? Ask the team in #billing.\",\n        \"Send questions to #billing.\",\n      ],\n      clearer: [\n        \"Send any questions to the team in #billing.\",\n        \"The team in #billing can answer any questions.\",\n      ],\n      friendlier: [\n        \"Got questions? Just ping the team in #billing, we're happy to help.\",\n        \"Questions are always welcome in #billing!\",\n      ],\n    },\n};\n\n/** Plain-language fixes for any text the demo has no hand-written take for. */\nconst RULES: Record<Action, [RegExp, string][]> = {\n  grammar: [\n    [/\\bthen before\\b/g, \"than before\"],\n    [/\\bmore then\\b/g, \"more than\"],\n    [/\\bwho's account\\b/g, \"whose account\"],\n    [/ {2,}/g, \" \"],\n  ],\n  shorter: [\n    [/^We are really excited to share that /, \"\"],\n    [/\\bIn order to\\b/g, \"To\"],\n    [/\\bin order to\\b/g, \"to\"],\n    [/\\bDue to the fact that\\b/g, \"Because\"],\n    [/\\bas smooth as possible\\b/g, \"smooth\"],\n    [/\\ba small number of\\b/g, \"a few\"],\n    [/\\bwe will be enabling\\b/g, \"we'll enable\"],\n    [/\\bmight notice that\\b/g, \"may see that\"],\n    [/ (really|finally|at all|just)\\b/g, \"\"],\n    [/\\bplease don't hesitate to reach out to\\b/g, \"ask\"],\n  ],\n  clearer: [\n    [/\\bDue to the fact that\\b/g, \"Because\"],\n    [/\\bIn order to\\b/g, \"To\"],\n    [/\\bin order to\\b/g, \"to\"],\n    [/\\ba small number of\\b/g, \"a few\"],\n    [/\\bthen before\\b/g, \"than before\"],\n    [/\\bmore then\\b/g, \"more than\"],\n    [/\\bwho's account\\b/g, \"whose account\"],\n  ],\n  friendlier: [\n    [/\\bwe will be\\b/g, \"we'll be\"],\n    [/\\bWe are\\b/g, \"We're\"],\n    [/\\bShould\\b/g, \"Could\"],\n    [/\\bshould reach out to\\b/g, \"will check in with\"],\n    [/\\bplease don't hesitate to reach out to\\b/g, \"just ping\"],\n    [/\\bsome customers\\b/g, \"a few folks\"],\n  ],\n};\n\n/** A second pass for Try again when nothing hand-written is left. */\nconst AGAIN: [RegExp, string][] = [\n  [/\\bwe will\\b/g, \"we'll\"],\n  [/\\bit is\\b/g, \"it's\"],\n  [/\\bdo not\\b/g, \"don't\"],\n  [/\\bmight\\b/g, \"may\"],\n  [/\\bshould\\b/g, \"will\"],\n];\n\nconst actionFor = (instruction: string): Action => {\n  const s = instruction.toLowerCase();\n  if (SHORT_HINT.test(s)) {\n    return \"shorter\";\n  }\n  if (FRIENDLY_HINT.test(s)) {\n    return \"friendlier\";\n  }\n  if (GRAMMAR_HINT.test(s)) {\n    return \"grammar\";\n  }\n  return \"clearer\";\n};\n\nconst applyRules = (text: string, rules: [RegExp, string][]) => {\n  let out = text;\n  for (const [pattern, to] of rules) {\n    out = out.replace(pattern, to);\n  }\n  return out;\n};\n\nconst capitalize = (text: string, like: string) =>\n  like[0] && like[0] === like[0].toUpperCase()\n    ? text.charAt(0).toUpperCase() + text.slice(1)\n    : text;\n\nconst demoRewrite = (text: string, instruction: string, attempt: number) => {\n  const action = actionFor(instruction);\n  const sentences = (text.match(SENTENCE) ?? []).map((s) => s.trim());\n  if (sentences.length > 0 && sentences.join(\" \") === text.trim()) {\n    const takes = sentences.map((s) => {\n      const canned = CANNED[s]?.[action];\n      return canned ? canned[attempt % canned.length] : undefined;\n    });\n    if (takes.every(Boolean)) {\n      return takes.join(\" \");\n    }\n  }\n  let out = applyRules(text, RULES[action]);\n  if (attempt > 0) {\n    out = applyRules(out, AGAIN);\n  }\n  return capitalize(out.trim(), text);\n};\n\nconst split = (text: string) =>\n  (text.match(WORD) ?? []).map((w) => {\n    const gap = w.match(TRAILING)?.[0] ?? \"\";\n    return { word: w.slice(0, w.length - gap.length), gap };\n  });\n\n/** Word-level diff, deletions before insertions in each changed run. */\nconst diff = (from: string, to: string): Op[] => {\n  const a = split(from);\n  const b = split(to);\n  const lcs = Array.from({ length: a.length + 1 }, () =>\n    Array.from({ length: b.length + 1 }, () => 0)\n  );\n  for (let i = a.length - 1; i >= 0; i--) {\n    for (let j = b.length - 1; j >= 0; j--) {\n      lcs[i][j] =\n        a[i].word === b[j].word\n          ? lcs[i + 1][j + 1] + 1\n          : Math.max(lcs[i + 1][j], lcs[i][j + 1]);\n    }\n  }\n  const ops: Op[] = [];\n  let dels: Op[] = [];\n  let adds: Op[] = [];\n  const flush = () => {\n    ops.push(...dels, ...adds);\n    dels = [];\n    adds = [];\n  };\n  const make = (kind: Op[\"kind\"], w: { word: string; gap: string }) => ({\n    kind,\n    word: w.word,\n    gap: w.gap || \" \",\n    key: `${kind}${ops.length + dels.length + adds.length}:${w.word}`,\n  });\n  let i = 0;\n  let j = 0;\n  while (i < a.length || j < b.length) {\n    if (i < a.length && j < b.length && a[i].word === b[j].word) {\n      flush();\n      ops.push(make(\"same\", b[j]));\n      i++;\n      j++;\n    } else if (\n      j >= b.length ||\n      (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1])\n    ) {\n      dels.push(make(\"del\", a[i]));\n      i++;\n    } else {\n      adds.push(make(\"add\", b[j]));\n      j++;\n    }\n  }\n  flush();\n  // The selection ends on a word, so the last op should not add a space.\n  const last = ops.at(-1);\n  if (last) {\n    last.gap = \"\";\n  }\n  return ops;\n};\n\ninterface Indexed {\n  op: Op;\n  index: number;\n}\n\n/** Groups neighbouring removed or added words; kept words stand alone. */\nconst runs = (ops: Op[]) => {\n  const out: Indexed[][] = [];\n  for (const [index, op] of ops.entries()) {\n    const last = out.at(-1);\n    if (last && op.kind !== \"same\" && last[0].op.kind === op.kind) {\n      last.push({ op, index });\n    } else {\n      out.push([{ op, index }]);\n    }\n  }\n  return out;\n};\n\nconst join = (run: Indexed[]) =>\n  run.map((r, k) => r.op.word + (k < run.length - 1 ? r.op.gap : \"\")).join(\"\");\n\n/** Character offset of a DOM position inside a paragraph. */\nconst offsetIn = (para: HTMLElement, node: Node, at: number) => {\n  const pre = document.createRange();\n  pre.selectNodeContents(para);\n  pre.setEnd(node, at);\n  return pre.toString().length;\n};\n\n/** Grows a range to whole words and trims the spaces at its ends. */\nconst snap = (text: string, start: number, end: number) => {\n  let from = start;\n  let to = end;\n  while (from > 0 && !SPACE.test(text[from - 1])) {\n    from--;\n  }\n  while (to < text.length && !SPACE.test(text[to])) {\n    to++;\n  }\n  while (from < to && SPACE.test(text[from])) {\n    from++;\n  }\n  while (to > from && SPACE.test(text[to - 1])) {\n    to--;\n  }\n  return { from, to };\n};\n\n/** The page selection as a word-snapped span of one paragraph, if any. */\nconst readSelection = (\n  selection: Selection | null,\n  root: HTMLElement,\n  doc: string[]\n): Span | null => {\n  if (!selection || selection.isCollapsed || selection.rangeCount === 0) {\n    return null;\n  }\n  const range = selection.getRangeAt(0);\n  const start = range.startContainer;\n  const el = start.nodeType === 1 ? (start as Element) : start.parentElement;\n  const para = el?.closest<HTMLElement>(\"[data-para]\");\n  if (!(para && root.contains(para))) {\n    return null;\n  }\n  const p = Number(para.dataset.para);\n  const text = doc[p];\n  const end = para.contains(range.endContainer)\n    ? offsetIn(para, range.endContainer, range.endOffset)\n    : text.length;\n  const { from, to } = snap(\n    text,\n    offsetIn(para, start, range.startOffset),\n    end\n  );\n  return to - from < 2 ? null : { p, start: from, end: to };\n};\n\n/** ⌘ on Apple devices, Ctrl elsewhere. Starts as ⌘ so the server render matches. */\nconst useModKey = () => {\n  const [mod, setMod] = useState(\"⌘\");\n  useEffect(() => {\n    if (!APPLE.test(navigator.userAgent)) {\n      setMod(\"Ctrl \");\n    }\n  }, []);\n  return mod;\n};\n\nconst stepDelay = (op: Op) => {\n  if (op.kind === \"add\") {\n    return 34 + Math.min(42, op.word.length * 6);\n  }\n  return op.kind === \"del\" ? 26 : 16;\n};\n\nconst isStream = (value: Rewrite): value is AsyncIterable<string> =>\n  typeof value === \"object\" && Symbol.asyncIterator in value;\n\n/** The whole text of a rewrite, gathering a stream's deltas. */\nasync function collect(result: Rewrite) {\n  if (!isStream(result)) {\n    return await result;\n  }\n  let text = \"\";\n  for await (const delta of result) {\n    text += delta;\n  }\n  return text;\n}\n\nconst reasonOf = (error: unknown) =>\n  error instanceof Error && error.message\n    ? error.message\n    : \"Couldn’t rewrite that. Try again.\";\n\nexport default function AIInlineEdit({\n  title = \"Billing launch update\",\n  paragraphs = DEMO_PARAGRAPHS,\n  rewrite = demoRewrite,\n  onChange,\n  className,\n}: AIInlineEditProps) {\n  const reduceMotion = useReducedMotion();\n  const [doc, setDoc] = useState(paragraphs);\n  const [span, setSpan] = useState<Span | null>(\n    paragraphs === DEMO_PARAGRAPHS ? DEMO_SELECTION : null\n  );\n  const [phase, setPhase] = useState<Phase>(\n    paragraphs === DEMO_PARAGRAPHS ? \"prompt\" : \"idle\"\n  );\n  const [instruction, setInstruction] = useState(\"\");\n  const [label, setLabel] = useState(\"\");\n  const [versions, setVersions] = useState<string[]>([]);\n  const [version, setVersion] = useState(0);\n  const [attempt, setAttempt] = useState(0);\n  const [head, setHead] = useState(0);\n  const [flash, setFlash] = useState<Span | null>(null);\n  const [flashOn, setFlashOn] = useState(false);\n  const [error, setError] = useState<string>();\n  const abortRef = useRef<AbortController>(undefined);\n  /** Null until measured, so the bar never renders (or animates in) at 0,0. */\n  const [bar, setBar] = useState<{\n    x: number;\n    y: number;\n    width: number;\n  } | null>(null);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const docRef = useRef<HTMLDivElement>(null);\n  const regionRef = useRef<HTMLSpanElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const acceptRef = useRef<HTMLButtonElement>(null);\n  const discardRef = useRef<HTMLButtonElement>(null);\n  const skipRef = useRef<HTMLButtonElement>(null);\n  const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const barRef = useRef<HTMLDivElement>(null);\n  const pointerDown = useRef(false);\n\n  const selected = span ? doc[span.p].slice(span.start, span.end) : \"\";\n  const current = versions[version] ?? \"\";\n  const ops = useMemo(\n    () => (current ? diff(selected, current) : []),\n    [selected, current]\n  );\n  const editing = phase !== \"idle\" && phase !== \"prompt\";\n  const mod = useModKey();\n  const unchanged = phase === \"review\" && current === selected;\n\n  // Place the bar under the selection, clamped to the card.\n  const place = useCallback(() => {\n    const region = regionRef.current;\n    const box = docRef.current;\n    if (!(region && box)) {\n      return;\n    }\n    const rects = Array.from(region.getClientRects());\n    const lastRect = rects.at(-1);\n    if (!lastRect) {\n      return;\n    }\n    const b = box.getBoundingClientRect();\n    const width = Math.min(BAR_WIDTH, b.width - PAD * 2);\n    const left = Math.min(...rects.map((r) => r.left)) - b.left;\n    setBar({\n      x: Math.max(PAD, Math.min(left - 6, b.width - width - PAD)),\n      y: lastRect.bottom - b.top + 10,\n      width,\n    });\n  }, []);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: head, version and doc move the selection\n  useLayoutEffect(() => {\n    if (span) {\n      place();\n    }\n  }, [span, place, head, version, doc]);\n\n  useEffect(() => {\n    const box = docRef.current;\n    if (!box) {\n      return;\n    }\n    const observer = new ResizeObserver(() => place());\n    observer.observe(box);\n    return () => observer.disconnect();\n  }, [place]);\n\n  // Run the write head through the diff.\n  useEffect(() => {\n    if (phase !== \"streaming\") {\n      return;\n    }\n    if (reduceMotion || head >= ops.length) {\n      setHead(ops.length);\n      setPhase(\"review\");\n      return;\n    }\n    const id = setTimeout(() => setHead((h) => h + 1), stepDelay(ops[head]));\n    return () => clearTimeout(id);\n  }, [phase, head, ops, reduceMotion]);\n\n  // Keep focus inside the bar as its controls swap, so Escape keeps working.\n  useEffect(() => {\n    let target: HTMLButtonElement | null = null;\n    if (phase === \"streaming\") {\n      target = skipRef.current;\n    } else if (phase === \"review\") {\n      const accept = acceptRef.current;\n      target = accept && !accept.disabled ? accept : discardRef.current;\n    }\n    target?.focus({ preventScroll: true });\n  }, [phase]);\n\n  useEffect(() => () => clearTimeout(timerRef.current), []);\n\n  // The accepted words glow for a moment, then settle into the text.\n  useEffect(() => {\n    if (!flash) {\n      return;\n    }\n    const on = setTimeout(() => setFlashOn(false), 80);\n    const off = setTimeout(() => setFlash(null), 1300);\n    return () => {\n      clearTimeout(on);\n      clearTimeout(off);\n    };\n  }, [flash]);\n\n  const clear = useCallback(() => {\n    setSpan(null);\n    setBar(null);\n    setPhase(\"idle\");\n    setVersions([]);\n    setVersion(0);\n    setAttempt(0);\n    setInstruction(\"\");\n  }, []);\n\n  // Clicking away drops a selection that has not been sent yet.\n  useEffect(() => {\n    if (phase !== \"prompt\") {\n      return;\n    }\n    const onDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) {\n        clear();\n      }\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [phase, clear]);\n\n  const capture = useCallback(() => {\n    const selection = window.getSelection();\n    const next =\n      docRef.current && readSelection(selection, docRef.current, doc);\n    if (!next) {\n      return false;\n    }\n    selection?.removeAllRanges();\n    setSpan(next);\n    setPhase(\"prompt\");\n    setInstruction(\"\");\n    requestAnimationFrame(() =>\n      inputRef.current?.focus({ preventScroll: true })\n    );\n    return true;\n  }, [doc]);\n\n  // Touch selections settle without a pointerup, so watch them too.\n  useEffect(() => {\n    let id: ReturnType<typeof setTimeout> | undefined;\n    const onChange = () => {\n      clearTimeout(id);\n      if (pointerDown.current || editing) {\n        return;\n      }\n      id = setTimeout(capture, 450);\n    };\n    document.addEventListener(\"selectionchange\", onChange);\n    return () => {\n      clearTimeout(id);\n      document.removeEventListener(\"selectionchange\", onChange);\n    };\n  }, [capture, editing]);\n\n  const show = (next: string) => {\n    const seen = versions.indexOf(next);\n    if (seen >= 0) {\n      setVersion(seen);\n      setPhase(\"review\");\n      return;\n    }\n    setVersions((v) => [...v, next]);\n    setVersion(versions.length);\n    setHead(0);\n    setPhase(\"streaming\");\n  };\n\n  const run = (text: string, nextAttempt: number) => {\n    abortRef.current?.abort();\n    const controller = new AbortController();\n    abortRef.current = controller;\n    setLabel(text);\n    setAttempt(nextAttempt);\n    setError(undefined);\n    const result = rewrite(selected, text, nextAttempt, controller.signal);\n    if (typeof result === \"string\") {\n      show(result);\n      return;\n    }\n    setPhase(\"thinking\");\n    collect(result).then(\n      (next) => {\n        if (!controller.signal.aborted) {\n          show(next);\n        }\n      },\n      (e: unknown) => {\n        if (!controller.signal.aborted) {\n          setError(reasonOf(e));\n          setPhase(\"prompt\");\n        }\n      }\n    );\n  };\n\n  /** Stop waiting on the model, back to where the person was. */\n  const stop = () => {\n    abortRef.current?.abort();\n    setPhase(versions.length > 0 ? \"review\" : \"prompt\");\n  };\n\n  const accept = () => {\n    if (!span || phase !== \"review\") {\n      return;\n    }\n    setPhase(\"settling\");\n    timerRef.current = setTimeout(\n      () => {\n        const text = doc[span.p];\n        const next = [...doc];\n        next[span.p] =\n          text.slice(0, span.start) + current + text.slice(span.end);\n        setDoc(next);\n        onChange?.(next);\n        setFlash({\n          p: span.p,\n          start: span.start,\n          end: span.start + current.length,\n        });\n        setFlashOn(true);\n        clear();\n      },\n      reduceMotion ? 0 : 180\n    );\n  };\n\n  /** Added words fade and struck ones come back before the text resets. */\n  const discard = () => {\n    if (phase !== \"review\") {\n      return;\n    }\n    setPhase(\"discarding\");\n    timerRef.current = setTimeout(clear, reduceMotion ? 0 : 160);\n  };\n\n  /** Escape steps back one phase. */\n  const stepBack = () => {\n    if (phase === \"thinking\") {\n      stop();\n    } else if (phase === \"streaming\") {\n      setVersions((v) => v.slice(0, -1));\n      setVersion((v) => Math.max(0, v - 1));\n      setPhase(versions.length > 1 ? \"review\" : \"prompt\");\n    } else if (phase === \"review\") {\n      discard();\n    } else if (phase === \"prompt\") {\n      clear();\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      stepBack();\n    } else if (\n      event.key === \"Enter\" &&\n      (event.metaKey || event.ctrlKey) &&\n      phase === \"review\"\n    ) {\n      event.preventDefault();\n      accept();\n    }\n  };\n\n  // Escape and Mod+Enter work anywhere inside the card.\n  const keyRef = useRef(onKeyDown);\n  useEffect(() => {\n    keyRef.current = onKeyDown;\n  });\n  useEffect(() => {\n    const root = rootRef.current;\n    const onKey = (event: KeyboardEvent) => keyRef.current(event);\n    root?.addEventListener(\"keydown\", onKey);\n    return () => root?.removeEventListener(\"keydown\", onKey);\n  }, []);\n\n  const renderRegion = () => {\n    if (!editing || phase === \"thinking\") {\n      return (\n        <mark className=\"rounded-[3px] bg-sky-500/[0.16] text-inherit [box-decoration-break:clone] dark:bg-sky-400/20\">\n          {selected}\n        </mark>\n      );\n    }\n    return runs(ops).map((run) => (\n      <DiffRun head={head} key={run[0].op.key} phase={phase} run={run} />\n    ));\n  };\n  const renderParagraph = (text: string, p: number) => {\n    if (span?.p === p) {\n      return (\n        <>\n          {text.slice(0, span.start)}\n          <span ref={regionRef}>{renderRegion()}</span>\n          {text.slice(span.end)}\n        </>\n      );\n    }\n    if (flash?.p === p) {\n      return (\n        <>\n          {text.slice(0, flash.start)}\n          <span\n            className={cn(\n              \"rounded-[3px] transition-colors duration-1000 [box-decoration-break:clone]\",\n              flashOn\n                ? \"bg-emerald-500/[0.14] dark:bg-emerald-400/[0.16]\"\n                : \"bg-transparent\"\n            )}\n          >\n            {text.slice(flash.start, flash.end)}\n          </span>\n          {text.slice(flash.end)}\n        </>\n      );\n    }\n    return text;\n  };\n\n  const lastPara = span?.p === doc.length - 1;\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div\n        className={cn(\n          \"w-full max-w-lg rounded-2xl bg-white ring-1 ring-black/[0.08] dark:bg-zinc-900 dark:ring-white/10\",\n          className\n        )}\n        ref={rootRef}\n      >\n        <header className=\"flex items-center gap-2 border-black/[0.06] border-b px-5 py-3 dark:border-white/[0.08]\">\n          <PenLine\n            aria-hidden=\"true\"\n            className=\"size-3.5 text-zinc-400 dark:text-zinc-500\"\n          />\n          <h2 className=\"min-w-0 flex-1 truncate font-medium text-[13px] text-zinc-900 dark:text-zinc-100\">\n            {title}\n          </h2>\n          <span className=\"text-[12px] text-zinc-400 dark:text-zinc-500\">\n            Draft\n          </span>\n        </header>\n\n        <div\n          className={cn(\n            \"relative px-5 pt-4 transition-[padding] duration-200\",\n            editing && \"select-none\"\n          )}\n          onPointerDown={() => {\n            pointerDown.current = true;\n          }}\n          onPointerUp={(event) => {\n            pointerDown.current = false;\n            if (editing || barRef.current?.contains(event.target as Node)) {\n              return;\n            }\n            if (!capture() && phase === \"prompt\") {\n              clear();\n            }\n          }}\n          ref={docRef}\n          style={{ paddingBottom: span && lastPara ? 132 : 20 }}\n        >\n          {doc.map((text, p) => (\n            <p\n              className=\"mb-3 cursor-text text-[15px] text-zinc-700 leading-7 selection:bg-sky-500/[0.16] last:mb-0 dark:text-zinc-300 dark:selection:bg-sky-400/20\"\n              data-para={p}\n              // biome-ignore lint/suspicious/noArrayIndexKey: paragraphs never reorder\n              key={p}\n            >\n              {renderParagraph(text, p)}\n            </p>\n          ))}\n\n          <AnimatePresence>\n            {span && bar && (\n              <motion.div\n                animate={{ opacity: 1, scale: 1, x: bar.x, y: bar.y }}\n                aria-label=\"Edit with AI\"\n                className=\"absolute top-0 left-0 z-20 origin-top-left overflow-hidden rounded-xl bg-white shadow-[0_12px_32px_-12px_rgb(0_0_0/0.25)] ring-1 ring-black/[0.08] dark:bg-zinc-800 dark:shadow-[0_12px_32px_-12px_rgb(0_0_0/0.7)] dark:ring-white/10\"\n                exit={{\n                  opacity: 0,\n                  scale: 0.98,\n                  transition: { duration: 0.12, ease: EASE_OUT },\n                }}\n                initial={{ opacity: 0, scale: 0.97, x: bar.x, y: bar.y - 4 }}\n                key=\"bar\"\n                ref={barRef}\n                role=\"group\"\n                style={{ width: bar.width }}\n                transition={{ duration: 0.2, ease: EASE_OUT }}\n              >\n                <BarBody phase={barPhase(phase)}>\n                  {phase === \"prompt\" && (\n                    <PromptBar\n                      error={error}\n                      inputRef={inputRef}\n                      instruction={instruction}\n                      onInstruction={setInstruction}\n                      onRun={(text) => run(text, 0)}\n                    />\n                  )}\n                  {(phase === \"streaming\" || phase === \"thinking\") && (\n                    <div className=\"flex h-11 items-center gap-2 pr-1.5 pl-3\">\n                      <Dots />\n                      <p className=\"min-w-0 flex-1 truncate text-[13px] text-zinc-600 dark:text-zinc-300\">\n                        Rewriting\n                        <span className=\"text-zinc-400 dark:text-zinc-500\">\n                          {\" \"}\n                          · {label}\n                        </span>\n                      </p>\n                      <button\n                        className=\"flex h-7 items-center gap-1.5 rounded-lg px-2 text-[12px] text-zinc-600 transition-colors duration-100 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-300 dark:hover:bg-white/[0.07] dark:hover:text-white\"\n                        onClick={() => {\n                          if (phase === \"thinking\") {\n                            stop();\n                            return;\n                          }\n                          setHead(ops.length);\n                          setPhase(\"review\");\n                        }}\n                        ref={skipRef}\n                        type=\"button\"\n                      >\n                        <Square\n                          aria-hidden=\"true\"\n                          className=\"size-2.5 fill-current\"\n                        />\n                        {phase === \"thinking\" ? \"Stop\" : \"Skip\"}\n                      </button>\n                    </div>\n                  )}\n                  {(phase === \"review\" ||\n                    phase === \"settling\" ||\n                    phase === \"discarding\") && (\n                    <ReviewBar\n                      acceptRef={acceptRef}\n                      count={versions.length}\n                      discardRef={discardRef}\n                      label={unchanged ? \"Already reads well\" : label}\n                      mod={mod}\n                      onAccept={accept}\n                      onAgain={() => run(label, attempt + 1)}\n                      onDiscard={discard}\n                      onVersion={setVersion}\n                      unchanged={unchanged}\n                      version={version}\n                    />\n                  )}\n                </BarBody>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <footer className=\"flex h-10 items-center border-black/[0.06] border-t px-5 dark:border-white/[0.08]\">\n          <p className=\"text-[12px] text-zinc-500 dark:text-zinc-400\">\n            Select any text to edit it with AI\n          </p>\n        </footer>\n        <p aria-live=\"polite\" className=\"sr-only\">\n          {phase === \"review\" ? \"Rewrite ready. Accept or discard.\" : \"\"}\n        </p>\n      </div>\n    </MotionConfig>\n  );\n}\n\n/** Phases that look the same share one bar, so it doesn't crossfade. */\nfunction barPhase(phase: Phase): Phase {\n  if (phase === \"settling\" || phase === \"discarding\") {\n    return \"review\";\n  }\n  return phase === \"thinking\" ? \"streaming\" : phase;\n}\n\nfunction PromptBar({\n  instruction,\n  onInstruction,\n  onRun,\n  inputRef,\n  error,\n}: {\n  error?: string;\n  instruction: string;\n  onInstruction: (value: string) => void;\n  onRun: (text: string) => void;\n  inputRef: RefObject<HTMLInputElement | null>;\n}) {\n  return (\n    <>\n      <form\n        className=\"flex h-11 items-center gap-2 pr-1.5 pl-3\"\n        onSubmit={(event) => {\n          event.preventDefault();\n          if (instruction.trim()) {\n            onRun(instruction.trim());\n          }\n        }}\n      >\n        <input\n          aria-label=\"Tell AI how to edit the selection\"\n          className=\"min-w-0 flex-1 bg-transparent text-[13px] text-zinc-900 outline-hidden placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500 [@media(pointer:coarse)]:text-base\"\n          onChange={(event) => onInstruction(event.target.value)}\n          placeholder=\"Ask AI to edit…\"\n          ref={inputRef}\n          value={instruction}\n        />\n        <button\n          aria-label=\"Rewrite\"\n          className=\"flex size-7 shrink-0 items-center justify-center rounded-lg bg-zinc-900 text-white transition-[background-color,opacity] duration-150 hover:bg-zinc-700 active:bg-zinc-600 disabled:opacity-30 dark:bg-white dark:text-zinc-900 dark:active:bg-zinc-300 dark:hover:bg-zinc-200\"\n          disabled={!instruction.trim()}\n          type=\"submit\"\n        >\n          <ArrowUp className=\"size-3.5\" />\n        </button>\n      </form>\n      {error ? (\n        <p\n          className=\"px-3 pb-2 text-[12px] text-rose-600 dark:text-rose-400\"\n          role=\"alert\"\n        >\n          {error}\n        </p>\n      ) : null}\n      <div className=\"flex flex-wrap gap-1 border-black/[0.06] border-t p-1.5 dark:border-white/[0.08]\">\n        {ACTIONS.map((action) => (\n          <button\n            className=\"h-7 rounded-lg px-2.5 text-[12px] text-zinc-600 outline-hidden transition-colors duration-100 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:bg-zinc-100 dark:text-zinc-300 dark:focus-visible:bg-white/[0.07] dark:hover:bg-white/[0.07] dark:hover:text-white\"\n            key={action.id}\n            onClick={() => onRun(action.label)}\n            type=\"button\"\n          >\n            {action.label}\n          </button>\n        ))}\n      </div>\n    </>\n  );\n}\n\nfunction ReviewBar({\n  label,\n  count,\n  version,\n  unchanged,\n  acceptRef,\n  discardRef,\n  onVersion,\n  onAgain,\n  onDiscard,\n  onAccept,\n  mod,\n}: {\n  label: string;\n  mod: string;\n  count: number;\n  version: number;\n  unchanged: boolean;\n  acceptRef: RefObject<HTMLButtonElement | null>;\n  discardRef: RefObject<HTMLButtonElement | null>;\n  onVersion: (v: number) => void;\n  onAgain: () => void;\n  onDiscard: () => void;\n  onAccept: () => void;\n}) {\n  const ghost =\n    \"flex h-7 items-center gap-1.5 rounded-lg px-2 text-[12px] text-zinc-600 outline-hidden transition-colors duration-100 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-900/15 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-300 dark:focus-visible:ring-white/20 dark:hover:bg-white/[0.07] dark:hover:text-white\";\n  return (\n    <div className=\"flex h-11 items-center gap-1 pr-1.5 pl-2\">\n      {count > 1 && (\n        <div className=\"flex shrink-0 items-center text-[12px] text-zinc-500 tabular-nums dark:text-zinc-400\">\n          <button\n            aria-label=\"Previous version\"\n            className={cn(ghost, \"px-1\")}\n            disabled={version === 0}\n            onClick={() => onVersion(version - 1)}\n            type=\"button\"\n          >\n            <ChevronLeft className=\"size-3.5\" />\n          </button>\n          {version + 1}/{count}\n          <button\n            aria-label=\"Next version\"\n            className={cn(ghost, \"px-1\")}\n            disabled={version === count - 1}\n            onClick={() => onVersion(version + 1)}\n            type=\"button\"\n          >\n            <ChevronRight className=\"size-3.5\" />\n          </button>\n        </div>\n      )}\n      <p\n        className={cn(\n          \"min-w-0 truncate text-[12px] text-zinc-500 dark:text-zinc-400\",\n          count > 1 ? \"pl-0.5\" : \"pl-1\"\n        )}\n      >\n        {label}\n      </p>\n      <div className=\"ml-auto flex shrink-0 items-center gap-0.5\">\n        <button\n          aria-label=\"Try again\"\n          className={ghost}\n          onClick={onAgain}\n          title=\"Try again\"\n          type=\"button\"\n        >\n          <RotateCw className=\"size-3.5\" />\n        </button>\n        <button\n          className={ghost}\n          onClick={onDiscard}\n          ref={discardRef}\n          type=\"button\"\n        >\n          Discard\n        </button>\n        <button\n          className=\"flex h-7 items-center gap-1.5 rounded-lg bg-zinc-900 px-2.5 font-medium text-[12px] text-white outline-hidden transition-colors duration-150 hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-zinc-900/30 focus-visible:ring-offset-1 active:bg-zinc-600 dark:bg-white dark:text-zinc-900 dark:active:bg-zinc-300 dark:focus-visible:ring-white/40 dark:focus-visible:ring-offset-zinc-800 dark:hover:bg-zinc-200\"\n          disabled={unchanged}\n          onClick={onAccept}\n          ref={acceptRef}\n          type=\"button\"\n        >\n          Accept\n          <kbd className=\"font-sans text-[11px] opacity-60\">{mod}↵</kbd>\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction DiffRun({\n  run,\n  head,\n  phase,\n}: {\n  run: Indexed[];\n  head: number;\n  phase: Phase;\n}) {\n  const streaming = phase === \"streaming\";\n  const settling = phase === \"settling\";\n  const discarding = phase === \"discarding\";\n  const done = run.filter((r) => !streaming || r.index < head);\n  const pending = run.filter((r) => streaming && r.index >= head);\n  const caret =\n    streaming && done.some((r) => r.index === head - 1) ? <Caret /> : null;\n  const kind = run[0].op.kind;\n  const gap = run.at(-1)?.op.gap ?? \"\";\n\n  if (kind === \"same\") {\n    return (\n      <span>\n        {run[0].op.word}\n        {caret}\n        {gap}\n      </span>\n    );\n  }\n  if (kind === \"del\") {\n    return (\n      <span\n        className={cn(\n          \"transition-opacity duration-150\",\n          settling && \"opacity-0\"\n        )}\n      >\n        {done.length > 0 && (\n          <del\n            className={cn(\n              \"transition-[color,text-decoration-color] duration-150\",\n              discarding\n                ? \"text-inherit no-underline decoration-transparent\"\n                : \"text-zinc-400 decoration-red-500/60 dark:text-zinc-500 dark:decoration-red-400/60\"\n            )}\n          >\n            {join(done)}\n          </del>\n        )}\n        {caret}\n        {done.length > 0 && pending.length > 0 && \" \"}\n        {join(pending)}\n        {gap}\n      </span>\n    );\n  }\n  if (done.length === 0) {\n    return null;\n  }\n  return (\n    <span>\n      <ins\n        className={cn(\n          \"rounded-[3px] no-underline transition-[background-color,color,opacity] duration-300 [box-decoration-break:clone]\",\n          discarding && \"opacity-0 duration-150\",\n          settling\n            ? \"bg-transparent\"\n            : \"bg-emerald-500/[0.14] text-emerald-950 dark:bg-emerald-400/[0.16] dark:text-emerald-100\"\n        )}\n      >\n        {done.map((r, k) => (\n          <motion.span\n            animate={{ opacity: 1, filter: \"blur(0px)\" }}\n            initial={{ opacity: 0, filter: \"blur(3px)\" }}\n            key={r.op.key}\n            transition={{ duration: 0.24, ease: EASE_OUT }}\n          >\n            {r.op.word}\n            {k < done.length - 1 ? r.op.gap : \"\"}\n          </motion.span>\n        ))}\n      </ins>\n      {caret}\n      {gap}\n    </span>\n  );\n}\n\n/** Grows and shrinks with its content, and crossfades between phases. */\nfunction BarBody({ phase, children }: { phase: Phase; children: ReactNode }) {\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n\n  useLayoutEffect(() => {\n    const el = innerRef.current;\n    if (!el) {\n      return;\n    }\n    const observer = new ResizeObserver(() => setHeight(el.offsetHeight));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <motion.div\n      animate={{ height }}\n      initial={false}\n      transition={{ duration: 0.2, ease: EASE_OUT }}\n    >\n      <div ref={innerRef}>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.div\n            animate={{ opacity: 1, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, filter: \"blur(2px)\" }}\n            initial={{ opacity: 0, filter: \"blur(2px)\" }}\n            key={phase}\n            transition={{ duration: 0.15, ease: EASE_OUT }}\n          >\n            {children}\n          </motion.div>\n        </AnimatePresence>\n      </div>\n    </motion.div>\n  );\n}\n\nfunction Caret() {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"ml-px inline-block h-[1.05em] w-[2px] translate-y-[3px] rounded-full bg-zinc-900 dark:bg-zinc-100\"\n    />\n  );\n}\n\nfunction Dots() {\n  return (\n    <span aria-hidden=\"true\" className=\"flex gap-[3px]\">\n      {[0, 1, 2].map((i) => (\n        <motion.span\n          animate={{ opacity: [0.25, 1, 0.25] }}\n          className=\"size-1 rounded-full bg-zinc-500 dark:bg-zinc-400\"\n          key={i}\n          transition={{\n            duration: 1,\n            repeat: Number.POSITIVE_INFINITY,\n            delay: i * 0.15,\n            ease: \"easeInOut\",\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n",
      "path": "/components/kokonutui/ai-inline-edit.tsx",
      "target": "components/kokonutui/ai-inline-edit.tsx"
    }
  ]
}