{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"streaming-response","type":"registry:component","title":"Token Stream","description":"Renders an AI reply token by token from a string or async stream, fading each chunk in behind a blinking caret, with safe markdown.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion"],"registryDependencies":[],"files":[{"path":"registry/geturui/ai/streaming-response.tsx","type":"registry:component","target":"components/ai/streaming-response.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------\n   Tiny markdown parser. Produces a tree whose text leaves remember their\n   offset in the source, so freshly streamed chunks can fade in on their own.\n   Supported: paragraphs, # headings, - / 1. lists, ``` fenced code,\n   **bold**, *italic* and `inline code`. Everything renders as React\n   elements — never as HTML strings.\n   ------------------------------------------------------------------ */\n\ntype Inline =\n  | { type: \"text\"; text: string; start: number }\n  | { type: \"strong\"; children: Inline[] }\n  | { type: \"em\"; children: Inline[] }\n  | { type: \"code\"; text: string; start: number };\n\ntype Block =\n  | { type: \"p\"; children: Inline[] }\n  | { type: \"h\"; level: 1 | 2 | 3; children: Inline[] }\n  | { type: \"ul\" | \"ol\"; items: Inline[][] }\n  | { type: \"code\"; lang: string; text: string; start: number };\n\nconst INLINE = /(\\*\\*[^*\\n]+\\*\\*|`[^`\\n]+`|\\*[^*\\n]+\\*)/g;\n\nfunction parseInline(s: string, base: number): Inline[] {\n  const out: Inline[] = [];\n  let last = 0;\n  for (const m of s.matchAll(INLINE)) {\n    const i = m.index ?? 0;\n    if (i > last) out.push({ type: \"text\", text: s.slice(last, i), start: base + last });\n    const tok = m[0];\n    if (tok.startsWith(\"**\")) out.push({ type: \"strong\", children: parseInline(tok.slice(2, -2), base + i + 2) });\n    else if (tok.startsWith(\"`\")) out.push({ type: \"code\", text: tok.slice(1, -1), start: base + i + 1 });\n    else out.push({ type: \"em\", children: parseInline(tok.slice(1, -1), base + i + 1) });\n    last = i + tok.length;\n  }\n  if (last < s.length) out.push({ type: \"text\", text: s.slice(last), start: base + last });\n  return out;\n}\n\n/** Parses a small markdown subset into blocks. Unclosed code fences run to the end (useful mid-stream). */\nexport function parseMarkdown(src: string): Block[] {\n  const lines: { text: string; start: number }[] = [];\n  let off = 0;\n  for (const text of src.split(\"\\n\")) {\n    lines.push({ text, start: off });\n    off += text.length + 1;\n  }\n  const blocks: Block[] = [];\n  let i = 0;\n  while (i < lines.length) {\n    const { text, start } = lines[i];\n    const fence = text.match(/^\\s*```\\s*([\\w+-]*)\\s*$/);\n    if (fence) {\n      const body: string[] = [];\n      const bodyStart = lines[i + 1]?.start ?? start + text.length;\n      i++;\n      while (i < lines.length && !/^\\s*```\\s*$/.test(lines[i].text)) body.push(lines[i++].text);\n      i++;\n      blocks.push({ type: \"code\", lang: fence[1] || \"text\", text: body.join(\"\\n\"), start: bodyStart });\n      continue;\n    }\n    if (!text.trim()) {\n      i++;\n      continue;\n    }\n    const heading = text.match(/^(#{1,3})\\s+(.*)$/);\n    if (heading) {\n      const lead = text.length - heading[2].length;\n      blocks.push({ type: \"h\", level: heading[1].length as 1 | 2 | 3, children: parseInline(heading[2], start + lead) });\n      i++;\n      continue;\n    }\n    const listRe = /^\\s*([-*•]|\\d+[.)])\\s+/;\n    const lm = text.match(listRe);\n    if (lm) {\n      const ordered = /\\d/.test(lm[1]);\n      const items: Inline[][] = [];\n      while (i < lines.length) {\n        const m = lines[i].text.match(listRe);\n        if (!m || /\\d/.test(m[1]) !== ordered) break;\n        items.push(parseInline(lines[i].text.slice(m[0].length), lines[i].start + m[0].length));\n        i++;\n      }\n      blocks.push({ type: ordered ? \"ol\" : \"ul\", items });\n      continue;\n    }\n    const para: Inline[] = [];\n    while (i < lines.length && lines[i].text.trim() && !listRe.test(lines[i].text) && !/^(#{1,3})\\s|^\\s*```/.test(lines[i].text)) {\n      if (para.length) para.push({ type: \"text\", text: \" \", start: lines[i].start - 1 });\n      para.push(...parseInline(lines[i].text, lines[i].start));\n      i++;\n    }\n    blocks.push({ type: \"p\", children: para });\n  }\n  return blocks;\n}\n\n/* ------------------------------------------------------------------ */\n\ninterface FadeCtx {\n  /** Text before this offset renders plain; after it, per-chunk fading spans. */\n  fadeFrom: number;\n  chunks: number[];\n  reduce: boolean;\n}\n\nfunction renderText(text: string, start: number, ctx: FadeCtx | null): React.ReactNode {\n  const end = start + text.length;\n  if (!ctx || end <= ctx.fadeFrom) return text;\n  const parts: React.ReactNode[] = [];\n  let cursor = start;\n  if (ctx.fadeFrom > start) {\n    parts.push(<React.Fragment key=\"plain\">{text.slice(0, ctx.fadeFrom - start)}</React.Fragment>);\n    cursor = ctx.fadeFrom;\n  }\n  const bounds = ctx.chunks.filter((c) => c > cursor && c < end);\n  const edges = [cursor, ...bounds, end];\n  for (let k = 0; k < edges.length - 1; k++) {\n    const a = edges[k];\n    const b = edges[k + 1];\n    parts.push(\n      <motion.span\n        key={a}\n        initial={ctx.reduce ? { opacity: 0 } : { opacity: 0, filter: \"blur(4px)\" }}\n        animate={{ opacity: 1, filter: \"blur(0px)\" }}\n        transition={{ duration: 0.38, ease: [0.22, 1, 0.36, 1] }}\n      >\n        {text.slice(a - start, b - start)}\n      </motion.span>,\n    );\n  }\n  return parts;\n}\n\nfunction renderInline(nodes: Inline[], ctx: FadeCtx | null): React.ReactNode[] {\n  return nodes.map((n, i) => {\n    if (n.type === \"text\") return <React.Fragment key={i}>{renderText(n.text, n.start, ctx)}</React.Fragment>;\n    if (n.type === \"code\")\n      return (\n        <code key={i} className=\"rounded-[6px] border border-border bg-surface-sunken px-1.5 py-px font-mono text-[0.88em] text-ink\">\n          {renderText(n.text, n.start, ctx)}\n        </code>\n      );\n    if (n.type === \"strong\")\n      return (\n        <strong key={i} className=\"font-semibold text-ink\">\n          {renderInline(n.children, ctx)}\n        </strong>\n      );\n    return (\n      <em key={i} className=\"italic\">\n        {renderInline(n.children, ctx)}\n      </em>\n    );\n  });\n}\n\nfunction renderBlocks(blocks: Block[], ctx: FadeCtx | null, caret: React.ReactNode) {\n  return blocks.map((b, i) => {\n    const tail = i === blocks.length - 1 ? caret : null;\n    switch (b.type) {\n      case \"p\":\n        return (\n          <p key={i} className=\"my-0 [&:not(:first-child)]:mt-3\">\n            {renderInline(b.children, ctx)}\n            {tail}\n          </p>\n        );\n      case \"h\": {\n        const Tag = (`h${b.level + 2}` as \"h3\" | \"h4\" | \"h5\");\n        return (\n          <Tag key={i} className={cn(\"font-semibold text-ink [&:not(:first-child)]:mt-4\", b.level === 1 ? \"text-[17px]\" : \"text-[15.5px]\")}>\n            {renderInline(b.children, ctx)}\n            {tail}\n          </Tag>\n        );\n      }\n      case \"ul\":\n      case \"ol\": {\n        const List = b.type;\n        return (\n          <List\n            key={i}\n            className={cn(\n              \"my-0 space-y-1 pl-5 [&:not(:first-child)]:mt-3 marker:text-ink-subtle\",\n              b.type === \"ul\" ? \"list-disc\" : \"list-decimal\",\n            )}\n          >\n            {b.items.map((item, j) => (\n              <li key={j} className=\"pl-1\">\n                {renderInline(item, ctx)}\n                {j === b.items.length - 1 ? tail : null}\n              </li>\n            ))}\n          </List>\n        );\n      }\n      case \"code\":\n        return (\n          <div key={i} className=\"overflow-hidden rounded-md border border-border bg-surface-sunken [&:not(:first-child)]:mt-3\">\n            <div className=\"border-b border-border px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.5px] text-ink-subtle\">{b.lang}</div>\n            <pre className=\"overflow-x-auto px-3 py-2.5 font-mono text-[12.5px] leading-[20px] text-ink\">\n              <code>\n                {renderText(b.text, b.start, ctx)}\n                {tail}\n              </code>\n            </pre>\n          </div>\n        );\n    }\n  });\n}\n\n/** Renders a markdown string as React elements (no HTML injection). */\nexport function Markdown({ children, className }: { children: string; className?: string }) {\n  const blocks = React.useMemo(() => parseMarkdown(children), [children]);\n  return <div className={cn(\"text-[14.5px] leading-[23px] text-ink\", className)}>{renderBlocks(blocks, null, null)}</div>;\n}\n\n/* ------------------------------------------------------------------ */\n\nexport interface StreamingResponseProps {\n  /** A full string (revealed token by token) or an async stream of chunks. */\n  content: string | AsyncIterable<string>;\n  /** Milliseconds between tokens when `content` is a string. */\n  speed?: number;\n  /** false renders `content` immediately (strings only) without fades or caret. */\n  animate?: boolean;\n  /** Show the blinking caret while streaming. */\n  caret?: boolean;\n  /** Called once with the full text when the stream ends. */\n  onComplete?: (text: string) => void;\n  /** Called with the text revealed so far, after each chunk. */\n  onProgress?: (text: string) => void;\n  className?: string;\n}\n\ninterface StreamState {\n  source: string | AsyncIterable<string>;\n  text: string;\n  chunks: number[];\n  done: boolean;\n}\n\nconst FADE_CHUNKS = 10;\n\n/** Renders a response token by token with a blinking caret and a soft fade on each new chunk; supports a safe markdown subset. */\nexport function StreamingResponse({ content, speed = 28, animate = true, caret = true, onComplete, onProgress, className }: StreamingResponseProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [state, setState] = React.useState<StreamState>({ source: content, text: \"\", chunks: [], done: false });\n  if (state.source !== content) setState({ source: content, text: \"\", chunks: [], done: false });\n\n  const staticText = !animate && typeof content === \"string\";\n  const cbs = React.useRef({ onComplete, onProgress });\n  React.useEffect(() => {\n    cbs.current = { onComplete, onProgress };\n  });\n\n  React.useEffect(() => {\n    if (staticText) return;\n    let cancelled = false;\n    const push = (chunk: string) =>\n      setState((s) => (s.source !== content ? s : { ...s, text: s.text + chunk, chunks: [...s.chunks, s.text.length] }));\n    const finish = (full: string) => {\n      setState((s) => (s.source !== content ? s : { ...s, done: true }));\n      cbs.current.onComplete?.(full);\n    };\n\n    if (typeof content === \"string\") {\n      const tokens = content.match(/\\s*\\S+/g) ?? [];\n      const trailing = content.slice(tokens.join(\"\").length);\n      let i = 0;\n      let acc = \"\";\n      let timer: ReturnType<typeof setTimeout>;\n      const tick = () => {\n        if (cancelled) return;\n        if (i >= tokens.length) {\n          if (trailing) push(trailing);\n          finish(content);\n          return;\n        }\n        const tok = tokens[i++];\n        acc += tok;\n        push(tok);\n        cbs.current.onProgress?.(acc);\n        timer = setTimeout(tick, speed * (0.6 + ((i * 7919) % 10) / 12.5));\n      };\n      timer = setTimeout(tick, speed);\n      return () => {\n        cancelled = true;\n        clearTimeout(timer);\n      };\n    }\n\n    (async () => {\n      let acc = \"\";\n      try {\n        for await (const chunk of content) {\n          if (cancelled) return;\n          acc += chunk;\n          push(chunk);\n          cbs.current.onProgress?.(acc);\n        }\n      } catch {\n        /* stream errored — keep what arrived */\n      }\n      if (!cancelled) finish(acc);\n    })();\n    return () => {\n      cancelled = true;\n    };\n  }, [content, speed, staticText]);\n\n  const text = staticText ? (content as string) : state.text;\n  const blocks = React.useMemo(() => parseMarkdown(text), [text]);\n  const streaming = !staticText && !state.done;\n  const ctx: FadeCtx | null = streaming && animate\n    ? { fadeFrom: state.chunks[Math.max(0, state.chunks.length - FADE_CHUNKS)] ?? 0, chunks: state.chunks, reduce }\n    : null;\n\n  const caretEl =\n    caret && streaming ? (\n      <motion.span\n        key=\"caret\"\n        aria-hidden\n        className=\"ml-0.5 inline-block h-[1.05em] w-[7px] translate-y-[0.18em] rounded-[2px] bg-volt\"\n        animate={{ opacity: [1, 1, 0, 0] }}\n        transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1], ease: \"linear\" }}\n      />\n    ) : null;\n\n  return (\n    <div className={cn(\"text-[14.5px] leading-[23px] text-ink\", className)} aria-busy={streaming}>\n      {blocks.length ? renderBlocks(blocks, ctx, caretEl) : caretEl}\n    </div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["ai","ai","streaming","markdown","typewriter","llm","response"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/streaming-response"}}