{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streaming-text",
  "title": "Streaming Text",
  "description": "Text that arrives a piece at a time from an async source, with sentence-level announcements.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/streaming-text/streaming-text.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type StreamSource = AsyncIterable<string> | ReadableStream<string>\n\nexport type StreamingTextStatus = \"idle\" | \"streaming\" | \"done\" | \"error\"\n\nexport type StreamingTextProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  text?: string\n  source?: StreamSource\n  speed?: number\n  streaming?: boolean\n  cursor?: React.ReactNode | false\n  announce?: \"sentences\" | \"off\"\n  onDone?: (text: string) => void\n  onError?: (error: unknown) => void\n  onStatusChange?: (status: StreamingTextStatus) => void\n}\n\nconst SENTENCE_END = /[.!?\\n]/g\nconst IDLE_FLUSH_MS = 1000\n\ntype Run = {\n  key: unknown\n  text: string\n  status: StreamingTextStatus\n  announcedTo: number\n  announced: string\n  error?: unknown\n}\n\nfunction isReadableStream(\n  source: StreamSource\n): source is ReadableStream<string> {\n  return typeof (source as ReadableStream<string>).getReader === \"function\"\n}\n\nasync function* readSource(source: StreamSource) {\n  if (!isReadableStream(source)) {\n    yield* source\n    return\n  }\n\n  const reader = source.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\nfunction lastSentenceBreak(value: string, from: number) {\n  SENTENCE_END.lastIndex = from\n\n  let index = -1\n  let match: RegExpExecArray | null\n\n  while ((match = SENTENCE_END.exec(value)) !== null) {\n    index = match.index + 1\n  }\n\n  return index\n}\n\nfunction startRun(key: unknown): Run {\n  return {\n    key,\n    text: \"\",\n    status: key == null ? \"idle\" : \"streaming\",\n    announcedTo: 0,\n    announced: \"\",\n  }\n}\n\nfunction appendChunk(run: Run, chunk: string): Run {\n  const text = run.text + chunk\n  const boundary = lastSentenceBreak(text, run.announcedTo)\n\n  if (boundary <= run.announcedTo) return { ...run, text }\n\n  return {\n    ...run,\n    text,\n    announcedTo: boundary,\n    announced: text.slice(run.announcedTo, boundary),\n  }\n}\n\nfunction flushRun(run: Run, status: StreamingTextStatus): Run {\n  const tail = run.text.slice(run.announcedTo)\n\n  return {\n    ...run,\n    status,\n    announcedTo: run.text.length,\n    announced: tail.trim() ? tail : \"\",\n  }\n}\n\nexport function StreamingText({\n  text,\n  source,\n  speed = 0,\n  streaming,\n  cursor,\n  announce = \"sentences\",\n  onDone,\n  onError,\n  onStatusChange,\n  className,\n  ...rootProps\n}: StreamingTextProps) {\n  const replays = source == null && text != null && speed > 0\n  const runKey = source ?? (replays ? text : null)\n\n  const [run, setRun] = React.useState<Run>(() => startRun(runKey))\n\n  if (run.key !== runKey) setRun(startRun(runKey))\n\n  const callbacks = React.useRef({ onDone, onError, onStatusChange })\n\n  React.useEffect(() => {\n    callbacks.current = { onDone, onError, onStatusChange }\n  })\n\n  React.useEffect(() => {\n    callbacks.current.onStatusChange?.(run.status)\n  }, [run.status])\n\n  React.useEffect(() => {\n    if (run.status === \"done\") callbacks.current.onDone?.(run.text)\n    if (run.status === \"error\") callbacks.current.onError?.(run.error)\n  }, [run.status, run.text, run.error])\n\n  React.useEffect(() => {\n    if (source == null) return\n\n    const controller = new AbortController()\n\n    void (async () => {\n      try {\n        for await (const chunk of readSource(source)) {\n          if (controller.signal.aborted) return\n          setRun((current) =>\n            current.key === source ? appendChunk(current, chunk) : current\n          )\n        }\n\n        if (controller.signal.aborted) return\n\n        setRun((current) =>\n          current.key === source ? flushRun(current, \"done\") : current\n        )\n      } catch (error) {\n        if (controller.signal.aborted) return\n        setRun((current) =>\n          current.key === source\n            ? { ...flushRun(current, \"error\"), error }\n            : current\n        )\n      }\n    })()\n\n    return () => controller.abort()\n  }, [source])\n\n  React.useEffect(() => {\n    if (!replays) return\n\n    const full = text ?? \"\"\n    const interval = Math.max(1000 / speed, 16)\n    const step = Math.max(1, Math.round(speed / 60))\n    let index = 0\n\n    const timer = window.setInterval(() => {\n      const next = Math.min(index + step, full.length)\n      const chunk = full.slice(index, next)\n      index = next\n\n      setRun((current) => {\n        if (current.key !== full) return current\n\n        const advanced = appendChunk(current, chunk)\n        if (index < full.length) return advanced\n\n        return flushRun(advanced, \"done\")\n      })\n\n      if (index >= full.length) window.clearInterval(timer)\n    }, interval)\n\n    return () => window.clearInterval(timer)\n  }, [replays, text, speed])\n\n  const isLive = runKey != null || streaming != null\n  const isStreaming = streaming ?? run.status === \"streaming\"\n  const value = runKey == null ? (text ?? \"\") : run.text\n\n  React.useEffect(() => {\n    if (announce === \"off\" || !isLive || !isStreaming) return\n\n    const timer = window.setTimeout(\n      () =>\n        setRun((current) =>\n          current.announcedTo < current.text.length\n            ? flushRun(current, current.status)\n            : current\n        ),\n      IDLE_FLUSH_MS\n    )\n\n    return () => window.clearTimeout(timer)\n  }, [announce, isLive, isStreaming, run.text])\n\n  const showCursor = cursor !== false && isStreaming\n\n  return (\n    <div\n      data-slot=\"streaming-text\"\n      data-status={run.status}\n      className={cn(\"text-pretty whitespace-pre-wrap\", className)}\n      {...rootProps}\n    >\n      <span\n        aria-hidden={isStreaming || undefined}\n        data-slot=\"streaming-text-value\"\n      >\n        {value}\n      </span>\n\n      {showCursor\n        ? (cursor ?? (\n            <span\n              aria-hidden=\"true\"\n              data-slot=\"streaming-text-cursor\"\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          ))\n        : null}\n\n      {announce === \"off\" || !isLive ? null : (\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {run.announced}\n        </span>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "streaming",
    "text",
    "agent"
  ],
  "type": "registry:ui"
}