{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-edit",
  "title": "Inline Edit",
  "description": "Select a span of a document, say how it should change, and watch the replacement stream in beside the struck-out original. Nothing changes until you accept it.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/inline-edit/inline-edit.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type InlineEditSelection = { start: number; end: number; text: string }\n\nexport type InlineEditRequest = {\n  selection: InlineEditSelection\n  instruction: string\n  signal: AbortSignal\n}\n\nexport type InlineEditResult =\n  AsyncIterable<string> | ReadableStream<string> | Promise<string> | string\n\nexport type InlineEditPhase =\n  \"idle\" | \"prompting\" | \"streaming\" | \"review\" | \"error\"\n\nexport type InlineEditProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> & {\n  value?: string\n  defaultValue?: string\n  onChange?: (value: string) => void\n  /** Return the replacement whole, or stream it in chunks. */\n  onRequestEdit: (request: InlineEditRequest) => InlineEditResult\n  /** Blocks typing. Selecting and editing through a request still work. */\n  readOnly?: boolean\n  label?: string\n  placeholder?: string\n  errorMessage?: string\n  /** Shown when the selected text changed before the edit was accepted. */\n  changedMessage?: string\n}\n\ntype Edit = {\n  before: string\n  original: string\n  after: string\n  instruction: string\n  replacement: string\n}\n\n/**\n * The same edit, cut against the text as it is now. The original keeps its\n * offsets when it can, and otherwise moves to its nearest occurrence. Null\n * when the selected text is gone.\n */\nfunction rebase(edit: Edit, text: string): Edit | null {\n  const was = edit.before.length\n  let at =\n    text.slice(was, was + edit.original.length) === edit.original ? was : -1\n\n  for (\n    let found = at === -1 ? text.indexOf(edit.original) : -1;\n    found !== -1;\n    found = text.indexOf(edit.original, found + 1)\n  ) {\n    if (at === -1 || Math.abs(found - was) < Math.abs(at - was)) at = found\n  }\n\n  if (at === -1) return null\n\n  return {\n    ...edit,\n    before: text.slice(0, at),\n    after: text.slice(at + edit.original.length),\n  }\n}\n\nfunction isReadableStream(\n  result: InlineEditResult\n): result is ReadableStream<string> {\n  return typeof (result as ReadableStream<string>).getReader === \"function\"\n}\n\nfunction isAsyncIterable(\n  result: InlineEditResult\n): result is AsyncIterable<string> {\n  return typeof result === \"object\" && Symbol.asyncIterator in result\n}\n\nasync function* chunksOf(stream: ReadableStream<string>) {\n  const reader = stream.getReader()\n\n  try {\n    for (;;) {\n      const { done, value } = await reader.read()\n      if (done) return\n      if (value != null) yield value\n    }\n  } finally {\n    reader.releaseLock()\n  }\n}\n\nconst subscribeNever = () => () => {}\n\nconst surface =\n  \"border-input bg-background block w-full rounded-md border px-3 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap\"\n\nconst action =\n  \"focus-visible:ring-ring inline-flex min-h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\"\n\nfunction Key({ children }: { children: React.ReactNode }) {\n  return (\n    <kbd className=\"border-border text-muted-foreground rounded border px-1 font-sans text-[0.65rem] leading-4 font-medium\">\n      {children}\n    </kbd>\n  )\n}\n\nexport function InlineEdit({\n  value,\n  defaultValue = \"\",\n  onChange,\n  onRequestEdit,\n  readOnly = false,\n  label = \"Document\",\n  placeholder = \"Rewrite…\",\n  errorMessage = \"The rewrite did not come through.\",\n  changedMessage = \"The document changed under this edit.\",\n  className,\n  ...rootProps\n}: InlineEditProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const fieldRef = React.useRef<HTMLTextAreaElement>(null)\n  const viewRef = React.useRef<HTMLDivElement>(null)\n  const floatingRef = React.useRef<HTMLDivElement>(null)\n  const controller = React.useRef<AbortController | null>(null)\n  const restore = React.useRef<{ start: number; end: number } | null>(null)\n  const hintId = React.useId()\n\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const [selection, setSelection] = React.useState({ start: 0, end: 0 })\n  const [focusWithin, setFocusWithin] = React.useState(false)\n  const [phase, setPhase] = React.useState<InlineEditPhase>(\"idle\")\n  const [edit, setEdit] = React.useState<Edit | null>(null)\n  const [instruction, setInstruction] = React.useState(\"\")\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  const isMac = React.useSyncExternalStore(\n    subscribeNever,\n    () => /Mac|iPhone|iPad/.test(navigator.platform),\n    () => false\n  )\n\n  const text = value ?? uncontrolled\n  const idle = phase === \"idle\"\n  const hasSelection = selection.end > selection.start\n  const offerEdit = idle && focusWithin && hasSelection\n\n  // Accept only writes where the original still sits at the offsets it was\n  // selected from. Anything else would overwrite what the host changed.\n  const current = edit && !idle ? rebase(edit, text) : null\n  const inPlace =\n    current && edit && current.before.length === edit.before.length\n      ? current\n      : null\n  const stale = phase === \"review\" && inPlace == null\n\n  const setText = (next: string) => {\n    if (value === undefined) setUncontrolled(next)\n    onChange?.(next)\n  }\n\n  React.useEffect(() => () => controller.current?.abort(), [])\n\n  React.useLayoutEffect(() => {\n    const field = fieldRef.current\n    if (!idle || !field) return\n\n    field.style.height = \"auto\"\n    field.style.height = `${field.scrollHeight}px`\n  }, [idle, text])\n\n  React.useLayoutEffect(() => {\n    const field = fieldRef.current\n    if (!idle || !field || !restore.current) return\n\n    const { start, end } = restore.current\n    restore.current = null\n    field.focus()\n    field.setSelectionRange(start, end)\n  }, [idle])\n\n  React.useLayoutEffect(() => {\n    const floating = floatingRef.current\n    const root = rootRef.current\n    const anchor = viewRef.current?.querySelector(\"[data-anchor]\")\n    if (!floating || !root || !anchor) return\n\n    const rects = anchor.getClientRects()\n    const end = rects[rects.length - 1] ?? anchor.getBoundingClientRect()\n    const box = root.getBoundingClientRect()\n\n    floating.style.top = `${end.bottom - box.top + 6}px`\n    floating.style.left = `${Math.max(\n      0,\n      Math.min(end.left - box.left, box.width - floating.offsetWidth)\n    )}px`\n  })\n\n  React.useLayoutEffect(() => {\n    const floating = floatingRef.current\n    if (!floating) return\n\n    if (phase === \"review\" && !stale) {\n      floating\n        .querySelector<HTMLElement>('[data-slot=\"inline-edit-accept\"]')\n        ?.focus()\n    } else if (phase !== \"idle\" && phase !== \"prompting\") {\n      floating.focus()\n    }\n  }, [phase, stale])\n\n  React.useEffect(() => {\n    if (phase !== \"prompting\") return\n\n    const dismiss = (event: PointerEvent) => {\n      if (rootRef.current?.contains(event.target as Node)) return\n      setPhase(\"idle\")\n      setEdit(null)\n    }\n\n    document.addEventListener(\"pointerdown\", dismiss)\n    return () => document.removeEventListener(\"pointerdown\", dismiss)\n  }, [phase])\n\n  const trackSelection = () => {\n    const field = fieldRef.current\n    if (!field) return\n    setSelection({ start: field.selectionStart, end: field.selectionEnd })\n  }\n\n  const openPrompt = () => {\n    if (!hasSelection) return\n\n    const { start, end } = selection\n    setEdit({\n      before: text.slice(0, start),\n      original: text.slice(start, end),\n      after: text.slice(end),\n      instruction: \"\",\n      replacement: \"\",\n    })\n    setInstruction(\"\")\n    setPhase(\"prompting\")\n  }\n\n  const run = async (current: Edit, ask: string) => {\n    controller.current?.abort()\n    const own = new AbortController()\n    controller.current = own\n\n    setEdit({ ...current, instruction: ask, replacement: \"\" })\n    setPhase(\"streaming\")\n    setAnnouncement(\"Rewriting the selection.\")\n\n    const start = current.before.length\n\n    try {\n      const result = onRequestEdit({\n        selection: {\n          start,\n          end: start + current.original.length,\n          text: current.original,\n        },\n        instruction: ask,\n        signal: own.signal,\n      })\n\n      let full = \"\"\n\n      if (typeof result === \"string\") {\n        full = result\n      } else if (isReadableStream(result) || isAsyncIterable(result)) {\n        const chunks = isReadableStream(result) ? chunksOf(result) : result\n        for await (const chunk of chunks) {\n          if (own.signal.aborted) return\n          full += chunk\n          setEdit((edit) => edit && { ...edit, replacement: full })\n        }\n      } else {\n        full = await result\n      }\n\n      if (own.signal.aborted) return\n\n      setEdit((edit) => edit && { ...edit, replacement: full })\n      setPhase(\"review\")\n      setAnnouncement(\n        `Suggested replacement: ${full || \"remove the selection\"}. Press Enter to accept or Escape to reject.`\n      )\n    } catch {\n      if (own.signal.aborted) return\n      setPhase(\"error\")\n      setAnnouncement(errorMessage)\n    }\n  }\n\n  const finish = (keep: boolean) => {\n    if (keep && !inPlace) return\n\n    controller.current?.abort()\n    controller.current = null\n\n    if (!edit) {\n      setPhase(\"idle\")\n      return\n    }\n\n    const start = edit.before.length\n\n    if (keep && inPlace) {\n      setText(inPlace.before + edit.replacement + inPlace.after)\n      restore.current = { start, end: start + edit.replacement.length }\n      setAnnouncement(\"Edit accepted.\")\n    } else {\n      const at = current?.before.length ?? start\n      restore.current = { start: at, end: at + edit.original.length }\n      if (phase !== \"prompting\") setAnnouncement(\"Edit rejected.\")\n    }\n\n    setSelection(restore.current)\n    setEdit(null)\n    setPhase(\"idle\")\n  }\n\n  const handleFloatingKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault()\n      event.stopPropagation()\n      finish(false)\n      return\n    }\n\n    if (event.key !== \"Enter\" || phase !== \"review\") return\n\n    // A plain Enter on Reject is Reject's own click. Cmd or Ctrl+Enter\n    // accepts from anywhere in the bar.\n    if (\n      event.metaKey ||\n      event.ctrlKey ||\n      event.target === event.currentTarget\n    ) {\n      event.preventDefault()\n      finish(true)\n    }\n  }\n\n  const mod = isMac ? \"⌘\" : \"Ctrl \"\n\n  const marked = (content: React.ReactNode) => (\n    <span data-anchor=\"\">{content}</span>\n  )\n\n  let body: React.ReactNode = null\n\n  if (idle) {\n    body = (\n      <>\n        {text.slice(0, selection.start)}\n        {marked(text.slice(selection.start, selection.end))}\n        {text.slice(selection.end)}\n      </>\n    )\n  } else if (edit) {\n    const changed = phase === \"streaming\" || phase === \"review\"\n\n    body = (\n      <>\n        {edit.before}\n        {changed\n          ? marked(\n              <>\n                <del className=\"decoration-destructive/70 text-muted-foreground me-1\">\n                  <span className=\"sr-only\">Removed: </span>\n                  {edit.original}\n                </del>\n                <ins\n                  data-slot=\"inline-edit-replacement\"\n                  className=\"bg-primary/15 text-foreground rounded-sm no-underline\"\n                >\n                  <span className=\"sr-only\">Added: </span>\n                  {edit.replacement}\n                  {phase === \"streaming\" ? (\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"bg-foreground ml-0.5 inline-block h-[1em] w-[0.5ch] translate-y-[0.12em] animate-pulse motion-reduce:animate-none\"\n                    />\n                  ) : null}\n                </ins>\n              </>\n            )\n          : marked(\n              <mark className=\"bg-primary/15 text-foreground rounded-sm\">\n                {edit.original}\n              </mark>\n            )}\n        {edit.after}\n      </>\n    )\n  }\n\n  let floating: React.ReactNode = null\n\n  if (offerEdit) {\n    floating = (\n      <button\n        type=\"button\"\n        aria-keyshortcuts=\"Meta+K Control+K\"\n        onPointerDown={(event) => event.preventDefault()}\n        onClick={openPrompt}\n        className={cn(\n          action,\n          \"bg-popover text-popover-foreground border-border hover:bg-muted border shadow-md\"\n        )}\n      >\n        Edit selection\n        <Key>{mod}K</Key>\n      </button>\n    )\n  }\n\n  if (phase === \"prompting\" && edit) {\n    floating = (\n      <form\n        className=\"bg-popover text-popover-foreground border-border focus-within:ring-ring/40 flex w-[min(22rem,100%)] items-center gap-1 rounded-lg border p-1 shadow-lg focus-within:ring-2\"\n        onSubmit={(event) => {\n          event.preventDefault()\n          if (instruction.trim()) void run(edit, instruction.trim())\n        }}\n      >\n        <input\n          autoFocus\n          aria-label=\"How should the selection change?\"\n          value={instruction}\n          onChange={(event) => setInstruction(event.target.value)}\n          placeholder={placeholder}\n          className=\"placeholder:text-muted-foreground min-h-8 min-w-0 flex-1 bg-transparent px-2 text-sm outline-none\"\n        />\n        <button\n          type=\"submit\"\n          aria-label=\"Rewrite\"\n          disabled={!instruction.trim()}\n          className=\"bg-primary text-primary-foreground focus-visible:ring-ring inline-flex size-8 shrink-0 items-center justify-center rounded-md transition-opacity duration-150 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-40 motion-reduce:transition-none\"\n        >\n          <ArrowUp aria-hidden=\"true\" className=\"size-4\" />\n        </button>\n      </form>\n    )\n  } else if (phase === \"streaming\") {\n    floating = (\n      <>\n        <span className=\"text-muted-foreground px-1.5 text-xs\">Rewriting</span>\n        <button\n          type=\"button\"\n          onClick={() => finish(false)}\n          className={cn(action, \"hover:bg-muted\")}\n        >\n          Stop <Key>Esc</Key>\n        </button>\n      </>\n    )\n  } else if (phase === \"review\" && stale && edit) {\n    floating = (\n      <>\n        <span className=\"text-destructive px-1.5 text-xs\">\n          {changedMessage}\n        </span>\n        {current ? (\n          <button\n            type=\"button\"\n            onClick={() => void run(current, edit.instruction)}\n            className={cn(action, \"hover:bg-muted\")}\n          >\n            Retry\n          </button>\n        ) : null}\n        <button\n          type=\"button\"\n          onClick={() => finish(false)}\n          className={cn(action, \"hover:bg-muted\")}\n        >\n          Dismiss <Key>Esc</Key>\n        </button>\n      </>\n    )\n  } else if (phase === \"review\") {\n    floating = (\n      <>\n        <button\n          type=\"button\"\n          data-slot=\"inline-edit-accept\"\n          onClick={() => finish(true)}\n          className={cn(action, \"bg-primary text-primary-foreground\")}\n        >\n          Accept\n          <kbd className=\"font-sans text-[0.65rem] opacity-80\">\n            {isMac ? \"⌘⏎\" : \"Ctrl Enter\"}\n          </kbd>\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => finish(false)}\n          className={cn(action, \"hover:bg-muted\")}\n        >\n          Reject <Key>Esc</Key>\n        </button>\n      </>\n    )\n  } else if (phase === \"error\" && edit) {\n    floating = (\n      <>\n        <span className=\"text-destructive px-1.5 text-xs\">{errorMessage}</span>\n        <button\n          type=\"button\"\n          onClick={() => void run(current ?? edit, edit.instruction)}\n          className={cn(action, \"hover:bg-muted\")}\n        >\n          Try again\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => finish(false)}\n          className={cn(action, \"hover:bg-muted\")}\n        >\n          Dismiss\n        </button>\n      </>\n    )\n  }\n\n  const toolbar =\n    phase === \"streaming\" || phase === \"review\" || phase === \"error\"\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"inline-edit\"\n      data-phase={phase}\n      className={cn(\"relative\", className)}\n      onFocus={() => setFocusWithin(true)}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n          setFocusWithin(false)\n        }\n      }}\n      {...rootProps}\n    >\n      <textarea\n        ref={fieldRef}\n        aria-label={label}\n        aria-describedby={hintId}\n        aria-keyshortcuts=\"Meta+K Control+K\"\n        data-slot=\"inline-edit-field\"\n        readOnly={readOnly}\n        rows={1}\n        value={text}\n        hidden={!idle}\n        onChange={(event) => {\n          setText(event.target.value)\n          trackSelection()\n        }}\n        onSelect={trackSelection}\n        onKeyUp={trackSelection}\n        onPointerUp={trackSelection}\n        onKeyDown={(event) => {\n          if (\n            event.key.toLowerCase() === \"k\" &&\n            (event.metaKey || event.ctrlKey) &&\n            !event.altKey &&\n            !event.shiftKey\n          ) {\n            event.preventDefault()\n            trackSelection()\n            const field = event.currentTarget\n            if (field.selectionEnd > field.selectionStart) openPrompt()\n          }\n        }}\n        className={cn(\n          surface,\n          \"focus-visible:ring-ring resize-none overflow-hidden focus-visible:ring-2 focus-visible:outline-none\"\n        )}\n      />\n\n      <div\n        ref={viewRef}\n        aria-hidden={idle || undefined}\n        data-slot=\"inline-edit-view\"\n        className={cn(\n          surface,\n          idle && \"pointer-events-none invisible absolute inset-x-0 top-0\"\n        )}\n      >\n        {body}\n        {\"​\"}\n      </div>\n\n      {floating ? (\n        <div\n          key={phase}\n          ref={floatingRef}\n          role={toolbar ? \"toolbar\" : undefined}\n          aria-label={toolbar ? \"Review the edit\" : undefined}\n          tabIndex={toolbar ? -1 : undefined}\n          onKeyDown={handleFloatingKeyDown}\n          data-slot=\"inline-edit-floating\"\n          className={cn(\n            \"absolute top-0 left-0 z-20 transition-[opacity,translate] duration-150 ease-out focus-visible:outline-none motion-reduce:transition-none starting:translate-y-1 starting:opacity-0\",\n            toolbar &&\n              \"bg-popover text-popover-foreground border-border flex items-center gap-1 rounded-lg border p-1 shadow-lg\"\n          )}\n        >\n          {floating}\n        </div>\n      ) : null}\n\n      <span id={hintId} className=\"sr-only\">\n        Select text, then press {isMac ? \"Command\" : \"Control\"} K to rewrite it.\n      </span>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {stale ? changedMessage : announcement}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "agent",
    "editor",
    "streaming",
    "review"
  ],
  "type": "registry:ui"
}