{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streaming-text",
  "type": "registry:ui",
  "title": "Streaming Text",
  "description": "Token-by-token model output with an even reveal cadence, word-group fade/blur-in and a soft caret. Takes a growing string or an async iterable.",
  "dependencies": [],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/streaming-text/index.ts",
      "type": "registry:ui",
      "target": "components/kura/streaming-text/index.ts",
      "content": "export { StreamingText, type StreamingTextProps } from \"./streaming-text\";\nexport {\n  type StreamingChunk,\n  type UseStreamingTextOptions,\n  type UseStreamingTextResult,\n  useStreamingText,\n} from \"./use-streaming-text\";\n"
    },
    {
      "path": "registry/kura/streaming-text/streaming-text.tsx",
      "type": "registry:ui",
      "target": "components/kura/streaming-text/streaming-text.tsx",
      "content": "\"use client\";\n\nimport type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { type UseStreamingTextOptions, useStreamingText } from \"./use-streaming-text\";\n\nexport interface StreamingTextProps\n  extends Omit<React.ComponentProps<\"div\">, \"children\">,\n    UseStreamingTextOptions {\n  /** How each newly revealed word group enters. `blur` = fade + 2px blur (default). */\n  animation?: \"blur\" | \"fade\" | \"none\";\n  /** Show the caret while streaming. It fades out when the stream completes. Default true. */\n  caret?: boolean;\n}\n\nconst enter = {\n  blur: \"transition-[opacity,filter] duration-(--kura-duration-slower) ease-kura-out starting:opacity-0 starting:[filter:blur(3px)] motion-reduce:starting:[filter:none]\",\n  fade: \"transition-opacity duration-(--kura-duration-slow) ease-kura-out starting:opacity-0\",\n  none: \"\",\n} as const;\n\n/**\n * Renders model output that arrives token by token. Bursty input is buffered into an even\n * cadence, new word groups fade (and un-blur) in, and a soft caret marks the live edge. Accepts a\n * growing string (`text`) or an async iterable (`stream`). Never reflows earlier text.\n */\nexport function StreamingText({\n  text,\n  stream,\n  speed,\n  streaming,\n  onComplete,\n  animation = \"blur\",\n  caret = true,\n  className,\n  ...props\n}: StreamingTextProps) {\n  const { settled, chunks, isStreaming } = useStreamingText({\n    text,\n    stream,\n    speed,\n    streaming,\n    onComplete,\n  });\n  return (\n    <div\n      data-slot=\"streaming-text\"\n      data-status={isStreaming ? \"streaming\" : \"done\"}\n      aria-busy={isStreaming || undefined}\n      className={cn(\n        \"whitespace-pre-wrap break-words font-kura-sans text-[15px] text-kura-fg leading-relaxed\",\n        className,\n      )}\n      {...props}\n    >\n      {settled}\n      {chunks.map((chunk) => (\n        <span key={chunk.id} data-slot=\"streaming-text-chunk\" className={enter[animation]}>\n          {chunk.text}\n        </span>\n      ))}\n      {caret && (\n        <span\n          aria-hidden\n          data-slot=\"streaming-text-caret\"\n          data-visible={isStreaming}\n          className={cn(\n            \"ml-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.17em] rounded-full bg-kura-accent\",\n            \"transition-opacity duration-(--kura-duration-slow) ease-kura-out\",\n            \"data-[visible=true]:animate-kura-pulse data-[visible=false]:opacity-0 motion-reduce:animate-none\",\n          )}\n        />\n      )}\n    </div>\n  );\n}\n"
    },
    {
      "path": "registry/kura/streaming-text/use-streaming-text.ts",
      "type": "registry:ui",
      "target": "components/kura/streaming-text/use-streaming-text.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface UseStreamingTextOptions {\n  /** The text received so far. Pass a growing string; a non-append change restarts the reveal. */\n  text?: string;\n  /** An async iterable of chunks (e.g. tokens from `fetch` or the AI SDK). Takes precedence over `text`. */\n  stream?: AsyncIterable<string> | null;\n  /**\n   * Minimum reveal cadence in characters per second. Bursty input is buffered and released at\n   * this rate (faster when a backlog builds up, so the reveal never trails by more than ~½s).\n   * `0` disables smoothing: text appears exactly as it arrives. Default 90.\n   */\n  speed?: number;\n  /** More text is still coming. Defaults to true while `stream` is being read, otherwise false. */\n  streaming?: boolean;\n  /** Called once when the stream has ended and every character has been revealed. */\n  onComplete?: () => void;\n}\n\nexport interface StreamingChunk {\n  id: number;\n  text: string;\n}\n\nexport interface UseStreamingTextResult {\n  /** Everything received so far (not yet necessarily revealed). */\n  text: string;\n  /** The revealed text: `settled + chunks.join(\"\")`. */\n  displayed: string;\n  /** Revealed text whose enter animation has finished; render it as one plain text node. */\n  settled: string;\n  /** Recently revealed chunks, still animating in. Render each one in its own element. */\n  chunks: StreamingChunk[];\n  /** True while more text is expected or the reveal is still catching up. */\n  isStreaming: boolean;\n}\n\n/** How long a chunk keeps its own element before it is merged into the settled text node. */\nconst SETTLE_MS = 700;\n/** Minimum time between two revealed chunks — groups characters into words, not letters. */\nconst CADENCE_MS = 45;\n/** Extra characters/second per character of backlog, so the reveal drains a burst in ~⅓s. */\nconst CATCH_UP = 3;\n\nconst now = () => (typeof performance !== \"undefined\" ? performance.now() : Date.now());\nconst raf = (cb: (t: number) => void): number =>\n  typeof requestAnimationFrame === \"function\"\n    ? requestAnimationFrame(cb)\n    : (setTimeout(() => cb(now()), 16) as unknown as number);\nconst caf = (id: number) =>\n  typeof cancelAnimationFrame === \"function\" ? cancelAnimationFrame(id) : clearTimeout(id);\n\ninterface StreamReader {\n  text: string;\n  done: boolean;\n  subscribe: (listener: () => void) => () => void;\n}\n\n/**\n * One reader per iterable: an async iterator can only be consumed once, so effects that re-run\n * (React StrictMode, remounts) share it. The iterator is closed shortly after the last\n * subscriber leaves, which lets StrictMode's unmount/remount keep reading.\n */\nconst readers = new WeakMap<AsyncIterable<string>, StreamReader>();\n\nfunction readerFor(stream: AsyncIterable<string>): StreamReader {\n  const existing = readers.get(stream);\n  if (existing) return existing;\n  const iterator = stream[Symbol.asyncIterator]();\n  const listeners = new Set<() => void>();\n  let stopTimer: ReturnType<typeof setTimeout> | undefined;\n  const notify = () => {\n    for (const l of listeners) l();\n  };\n  const reader: StreamReader = {\n    text: \"\",\n    done: false,\n    subscribe(listener) {\n      clearTimeout(stopTimer);\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n        if (listeners.size === 0 && !reader.done) {\n          stopTimer = setTimeout(() => {\n            reader.done = true;\n            void iterator.return?.();\n          }, 50);\n        }\n      };\n    },\n  };\n  readers.set(stream, reader);\n  (async () => {\n    try {\n      while (!reader.done) {\n        const { value, done } = await iterator.next();\n        if (done || reader.done) break;\n        reader.text += value;\n        notify();\n      }\n    } catch {\n      // A failed stream simply ends; the consumer surfaces the error state.\n    }\n    reader.done = true;\n    notify();\n  })();\n  return reader;\n}\n\n/**\n * Turns a growing string or an async iterable into a calm, evenly paced reveal. Returns the\n * revealed text split into a settled prefix and a few still-animating chunks (word groups),\n * so a renderer can fade only the newest words in without creating thousands of elements.\n */\nexport function useStreamingText({\n  text,\n  stream,\n  speed = 90,\n  streaming,\n  onComplete,\n}: UseStreamingTextOptions = {}): UseStreamingTextResult {\n  // --- source: a prop string or an async iterable ---------------------------------------------\n  const [streamed, setStreamed] = React.useState({ text: \"\", done: !stream });\n\n  React.useEffect(() => {\n    if (!stream) return;\n    const reader = readerFor(stream);\n    const sync = () => setStreamed({ text: reader.text, done: reader.done });\n    sync();\n    return reader.subscribe(sync);\n  }, [stream]);\n\n  const reading = !!stream && !streamed.done;\n  const source = stream ? streamed.text : (text ?? \"\");\n  const more = streaming ?? (stream ? reading : false);\n\n  // --- reveal ----------------------------------------------------------------------------------\n  // Text present at mount is shown immediately (no replay on remount or hydration).\n  const initial = stream ? \"\" : source;\n  const [state, setState] = React.useState<{ settled: string; chunks: StreamingChunk[] }>({\n    settled: initial,\n    chunks: [],\n  });\n  const shown = React.useRef(initial.length);\n  const sourceRef = React.useRef(source);\n  sourceRef.current = source;\n  const chunkTimes = React.useRef(new Map<number, number>());\n  const nextId = React.useRef(0);\n  const [revealing, setRevealing] = React.useState(false);\n\n  const settle = React.useCallback((all: boolean) => {\n    const t = now();\n    setState((s) => {\n      let i = 0;\n      while (\n        i < s.chunks.length &&\n        (all || t - (chunkTimes.current.get(s.chunks[i]!.id) ?? 0) >= SETTLE_MS)\n      )\n        i++;\n      if (i === 0) return s;\n      for (const c of s.chunks.slice(0, i)) chunkTimes.current.delete(c.id);\n      return {\n        settled:\n          s.settled +\n          s.chunks\n            .slice(0, i)\n            .map((c) => c.text)\n            .join(\"\"),\n        chunks: s.chunks.slice(i),\n      };\n    });\n  }, []);\n\n  const revealed = React.useRef(initial);\n  const emit = React.useCallback(\n    (piece: string) => {\n      revealed.current += piece;\n      const id = nextId.current++;\n      chunkTimes.current.set(id, now());\n      setState((s) => ({ settled: s.settled, chunks: [...s.chunks, { id, text: piece }] }));\n      settle(false);\n    },\n    [settle],\n  );\n\n  const speedRef = React.useRef(speed);\n  speedRef.current = speed;\n  const loop = React.useRef({ frame: 0, running: false, last: 0, lastEmit: 0, budget: 0 });\n\n  const tick = React.useCallback(\n    (t: number) => {\n      const l = loop.current;\n      const target = sourceRef.current;\n      const dt = Math.max(0, Math.min(0.1, (t - l.last) / 1000));\n      l.last = t;\n      const backlog = target.length - shown.current;\n      l.budget += (speedRef.current + backlog * CATCH_UP) * dt;\n      if (t - l.lastEmit >= CADENCE_MS && l.budget >= 1) {\n        let end = Math.min(target.length, shown.current + Math.floor(l.budget));\n        // Finish the current word so chunks are word groups, never broken letters.\n        const rest = target.slice(end);\n        const ws = rest.search(/\\s/);\n        if (ws > 0 && ws <= 12) end += ws;\n        else if (ws < 0 && rest.length <= 12) end = target.length;\n        l.budget = Math.max(0, l.budget - (end - shown.current));\n        emit(target.slice(shown.current, end));\n        shown.current = end;\n        l.lastEmit = t;\n      }\n      if (shown.current < target.length) {\n        l.frame = raf(tick);\n      } else {\n        l.running = false;\n        l.budget = 0;\n        setRevealing(false);\n      }\n    },\n    [emit],\n  );\n\n  React.useEffect(() => {\n    const displayed = revealed.current;\n    // Non-append change (new message, regenerate): restart from the common prefix.\n    if (!source.startsWith(displayed)) {\n      let common = 0;\n      while (common < source.length && source[common] === displayed[common]) common++;\n      shown.current = common;\n      revealed.current = source.slice(0, common);\n      chunkTimes.current.clear();\n      setState({ settled: source.slice(0, common), chunks: [] });\n    }\n    if (shown.current >= source.length) return;\n    if (speed <= 0) {\n      emit(source.slice(shown.current));\n      shown.current = source.length;\n      return;\n    }\n    const l = loop.current;\n    if (l.running) return;\n    l.running = true;\n    l.last = now();\n    setRevealing(true);\n    l.frame = raf(tick);\n  }, [source, speed, emit, tick]);\n\n  React.useEffect(\n    () => () => {\n      caf(loop.current.frame);\n      loop.current.running = false;\n    },\n    [],\n  );\n\n  // Merge the last chunks into the settled node once their animation has finished.\n  React.useEffect(() => {\n    if (revealing || state.chunks.length === 0) return;\n    const id = setTimeout(() => settle(true), SETTLE_MS);\n    return () => clearTimeout(id);\n  }, [revealing, state.chunks.length, settle]);\n\n  // --- completion ------------------------------------------------------------------------------\n  const displayed = state.settled + state.chunks.map((c) => c.text).join(\"\");\n  // Derived from the render, not the loop state, so a stream that ends in the same tick as its\n  // last token does not flip to \"done\" before the reveal has started.\n  const isStreaming = more || revealing || displayed !== source;\n  const onCompleteRef = React.useRef(onComplete);\n  onCompleteRef.current = onComplete;\n  const wasStreaming = React.useRef(isStreaming);\n  React.useEffect(() => {\n    if (wasStreaming.current && !isStreaming) onCompleteRef.current?.();\n    wasStreaming.current = isStreaming;\n  }, [isStreaming]);\n\n  return { text: source, displayed, settled: state.settled, chunks: state.chunks, isStreaming };\n}\n"
    }
  ],
  "categories": [
    "conversation",
    "streaming",
    "tokens",
    "llm",
    "text"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/streaming-text · Markdown for agents: https://kuro.abstergo.fyi/components/streaming-text.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "Render an LLM answer while it streams in (AI SDK, OpenAI/Anthropic SSE, a ReadableStream adapted to an async iterable).",
        "Smooth out bursty token delivery so text appears at a calm, even rhythm instead of in jumps.",
        "Show a live caret at the end of text that is still being generated.",
        "Build a custom renderer (markdown, rich text) on top of the `useStreamingText` hook exported from the same folder."
      ],
      "whenNotToUse": [
        "Static text that is already complete — render it directly (StreamingText shows initial text instantly anyway).",
        "A “Thinking…” label before the first token — use shimmer-text (or reasoning for a collapsible trace).",
        "A full chat bubble with avatar and actions — use message, which composes streaming-text.",
        "Streaming code — use code-block with `streaming` so partial lines do not flicker."
      ]
    }
  }
}
