{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streaming-text",
  "type": "registry:ui",
  "title": "Streaming Text",
  "description": "A model's reply formatted as it streams, each chunk fading in, never flashing syntax.",
  "categories": [
    "ai-chat"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/streaming-text.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, ArrowUpRight, Refresh } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\nexport type StreamStatus = \"streaming\" | \"done\" | \"stopped\" | \"error\";\n\ntype Chunk = { start: number; at: number };\ntype View = { shown: string; chunks: Chunk[] };\n\n/** How long a chunk takes to fade in, and how long it keeps its own span. */\nconst FADE = 380;\nconst FRESH = 700;\n/** Revealing at most every other frame keeps the number of chunks (and spans) down. */\nconst MIN_STEP = 32;\n\n// The server's pacing is bursty: forty characters at once, then nothing for 300ms.\n// This reveals whatever has arrived over the next few frames, a whole word at a\n// time, so the text flows instead of landing in lumps. It never runs behind the\n// stream by more than ~100ms, and never slower than it: the whole backlog is\n// always out within a handful of frames.\nfunction useRevealedText(text: string, { streaming, smooth }: { streaming: boolean; smooth: boolean }): View {\n  const model = useRef<View & { last: number }>({ shown: text, chunks: [], last: 0 });\n  const [view, setView] = useState<View>({ shown: text, chunks: [] });\n\n  useEffect(() => {\n    const m = model.current;\n    let raf = 0;\n    let dirty = false;\n\n    // A different text (a regenerated reply, another message) starts over. When\n    // nothing is streaming there is nothing to reveal: show it as it is.\n    if (!text.startsWith(m.shown)) {\n      m.shown = streaming ? \"\" : text;\n      m.chunks = [];\n      m.last = 0;\n      dirty = true;\n    }\n\n    const step = (now: number) => {\n      const backlog = text.length - m.shown.length;\n      if (backlog > 0 && !(smooth && now - m.last < MIN_STEP)) {\n        const dt = m.last ? Math.min(now - m.last, 100) : MIN_STEP;\n        // Catch up on about a third of the backlog per step, faster once the stream has ended.\n        let end = smooth ? m.shown.length + Math.max(3, Math.ceil(backlog * Math.min(1, dt / (streaming ? 100 : 50)))) : text.length;\n        if (end < text.length) {\n          // Finish the word in progress so a word never arrives in two halves.\n          const next = text.slice(end, end + 18).search(/\\s/);\n          end = next >= 0 ? end + next : end + 18;\n        }\n        end = Math.min(end, text.length);\n        m.chunks = [...m.chunks.filter((c) => now - c.at < FRESH * 2), { start: m.shown.length, at: now }];\n        m.shown = text.slice(0, end);\n        m.last = now;\n        dirty = true;\n      }\n      if (dirty) {\n        setView({ shown: m.shown, chunks: m.chunks });\n        dirty = false;\n      }\n      if (m.shown.length < text.length) raf = requestAnimationFrame(step);\n    };\n    raf = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(raf);\n  }, [text, streaming, smooth]);\n\n  return view;\n}\n\n/**\n * The text a streaming reply should show right now, revealed a word at a time\n * without falling behind the stream. Use it on its own when you render the text\n * yourself.\n */\nexport function useSmoothText(text: string, streaming = true) {\n  return useRevealedText(text, { streaming, smooth: true }).shown;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Blocks: a small, forgiving markdown reader. It knows what an unfinished block\n * looks like, so a reply never flashes raw syntax on its way to being formatted.\n * -----------------------------------------------------------------------------------------------*/\n\ntype Range = [number, number];\ntype Block =\n  | { kind: \"p\"; start: number; lines: Range[] }\n  | { kind: \"h\"; start: number; level: number; lines: Range[] }\n  | { kind: \"quote\"; start: number; lines: Range[] }\n  | { kind: \"list\"; start: number; ordered: boolean; items: { start: number; n: number; depth: number; lines: Range[] }[] }\n  | { kind: \"code\"; start: number; lang: string; body: Range; closed: boolean }\n  | { kind: \"hr\"; start: number };\n\nconst fenceRe = /^ {0,3}(`{3,}|~{3,})\\s*([\\w+#.-]*)/;\nconst headingRe = /^ {0,3}(#{1,6})\\s+/;\nconst bulletRe = /^(\\s*)([-*+])\\s+/;\nconst orderedRe = /^(\\s*)(\\d{1,9})[.)]\\s+/;\nconst quoteRe = /^ {0,3}>\\s?/;\nconst hrRe = /^ {0,3}([-*_])(?:\\s*\\1){2,}\\s*$/;\n// A last line that could still turn into a heading, list, quote, rule or fence:\n// hold it back until the next character decides, instead of flashing \"#\" or \"-\".\nconst pendingRe = /^\\s*(#{1,6}|[-*+]|\\d{1,9}[.)]?|>|`{1,3}[\\w+#.-]*|~{1,3}|-{2,}|\\*{2,}|_{2,})$/;\n\nfunction parse(src: string, streaming: boolean): { blocks: Block[]; end: number } {\n  const lines: Range[] = [];\n  let at = 0;\n  while (at <= src.length) {\n    const nl = src.indexOf(\"\\n\", at);\n    const e = nl < 0 ? src.length : nl;\n    lines.push([at, e]);\n    if (nl < 0) break;\n    at = nl + 1;\n  }\n  let end = src.length;\n  const last = lines[lines.length - 1];\n  if (streaming && last && pendingRe.test(src.slice(last[0], last[1]))) {\n    end = last[0];\n    lines.pop();\n  }\n\n  const blocks: Block[] = [];\n  const line = (r: Range) => src.slice(r[0], r[1]);\n  let i = 0;\n  while (i < lines.length) {\n    const r = lines[i];\n    const text = line(r);\n    if (!text.trim()) {\n      i++;\n      continue;\n    }\n    const fence = fenceRe.exec(text);\n    if (fence) {\n      const marker = fence[1];\n      const bodyStart = r[1] + 1;\n      let j = i + 1;\n      while (j < lines.length && !line(lines[j]).trimStart().startsWith(marker)) j++;\n      const closed = j < lines.length;\n      const bodyEnd = closed ? Math.max(bodyStart, lines[j][0] - 1) : end;\n      blocks.push({ kind: \"code\", start: r[0], lang: fence[2], body: [Math.min(bodyStart, end), Math.max(Math.min(bodyStart, end), bodyEnd)], closed });\n      i = closed ? j + 1 : lines.length;\n      continue;\n    }\n    if (hrRe.test(text)) {\n      blocks.push({ kind: \"hr\", start: r[0] });\n      i++;\n      continue;\n    }\n    const heading = headingRe.exec(text);\n    if (heading) {\n      blocks.push({ kind: \"h\", start: r[0], level: heading[1].length, lines: [[r[0] + heading[0].length, r[1]]] });\n      i++;\n      continue;\n    }\n    if (quoteRe.test(text)) {\n      const out: Range[] = [];\n      while (i < lines.length && quoteRe.test(line(lines[i]))) {\n        const m = quoteRe.exec(line(lines[i]))!;\n        out.push([lines[i][0] + m[0].length, lines[i][1]]);\n        i++;\n      }\n      blocks.push({ kind: \"quote\", start: r[0], lines: out });\n      continue;\n    }\n    const first = bulletRe.exec(text) ?? orderedRe.exec(text);\n    if (first) {\n      const ordered = !bulletRe.test(text);\n      const items: { start: number; n: number; depth: number; lines: Range[] }[] = [];\n      while (i < lines.length) {\n        const t = line(lines[i]);\n        const m = ordered ? orderedRe.exec(t) : bulletRe.exec(t);\n        if (m) {\n          items.push({ start: lines[i][0], n: ordered ? Number(m[2]) : 0, depth: Math.min(3, Math.floor(m[1].replace(/\\t/g, \"  \").length / 2)), lines: [[lines[i][0] + m[0].length, lines[i][1]]] });\n          i++;\n        } else if (t.trim() && /^\\s{2,}/.test(t) && items.length) {\n          // An indented line continues the item above it.\n          const lead = t.length - t.trimStart().length;\n          items[items.length - 1].lines.push([lines[i][0] + lead, lines[i][1]]);\n          i++;\n        } else break;\n      }\n      blocks.push({ kind: \"list\", start: r[0], ordered, items });\n      continue;\n    }\n    const out: Range[] = [];\n    while (i < lines.length) {\n      const t = line(lines[i]);\n      if (!t.trim() || fenceRe.test(t) || headingRe.test(t) || quoteRe.test(t) || bulletRe.test(t) || orderedRe.test(t) || hrRe.test(t)) break;\n      out.push(lines[i]);\n      i++;\n    }\n    blocks.push({ kind: \"p\", start: r[0], lines: out });\n  }\n  return { blocks, end };\n}\n\nconst safeHref = (href: string) => (/^(https?:|mailto:)/i.test(href.trim()) ? href.trim() : undefined);\n\ntype Ctx = {\n  src: string;\n  /** Where the visible text ends. Unclosed marks are only forgiven here, and only while streaming. */\n  tail: number;\n  streaming: boolean;\n  fresh: Chunk[];\n};\n\n/** Plain text for [a, b), cut into one span per recent chunk so each can fade on its own. */\nfunction runs(ctx: Ctx, a: number, b: number): React.ReactNode[] {\n  if (a >= b) return [];\n  const out: React.ReactNode[] = [];\n  const cuts = ctx.fresh.filter((c) => c.start < b);\n  const firstFresh = cuts.find((c, k) => c.start >= a || (cuts[k + 1]?.start ?? Infinity) > a);\n  let p = a;\n  if (!firstFresh) return [ctx.src.slice(a, b)];\n  if (firstFresh.start > a) {\n    out.push(ctx.src.slice(a, firstFresh.start));\n    p = firstFresh.start;\n  }\n  for (let k = cuts.indexOf(firstFresh); k < cuts.length && p < b; k++) {\n    const q = Math.min(b, cuts[k + 1]?.start ?? b);\n    if (q > p) {\n      out.push(\n        <span key={p} data-chunk=\"\" data-at={cuts[k].at}>\n          {ctx.src.slice(p, q)}\n        </span>,\n      );\n    }\n    p = q;\n  }\n  return out;\n}\n\nconst closer = (src: string, from: number, to: number, mark: string) => {\n  for (let j = src.indexOf(mark, from); j >= 0 && j + mark.length <= to; j = src.indexOf(mark, j + 1)) {\n    if (src[j - 1] !== \" \" && src[j - 1] !== \"\\\\\") return j;\n  }\n  return -1;\n};\n\nfunction inline(ctx: Ctx, a: number, b: number): React.ReactNode[] {\n  const { src } = ctx;\n  const atTail = ctx.streaming && b >= ctx.tail;\n  const out: React.ReactNode[] = [];\n  let text = a;\n  let i = a;\n  const flush = (to: number) => {\n    out.push(...runs(ctx, text, to));\n  };\n  while (i < b) {\n    const ch = src[i];\n    if (ch === \"\\\\\" && i + 1 < b && /[\\\\`*_[\\]()#>~-]/.test(src[i + 1])) {\n      flush(i);\n      text = i + 1;\n      i += 2;\n      continue;\n    }\n    if (ch === \"`\") {\n      const j = src.indexOf(\"`\", i + 1);\n      const end = j >= 0 && j < b ? j : atTail ? b : -1;\n      if (end > i) {\n        flush(i);\n        out.push(\n          <code key={`c${i}`} className=\"rounded-[5px] border border-line bg-fg/[0.04] px-[5px] py-px font-mono text-[0.88em] text-fg [overflow-wrap:anywhere]\">\n            {runs(ctx, i + 1, end)}\n          </code>,\n        );\n        i = text = Math.min(b, end + 1);\n        continue;\n      }\n    }\n    const double = (src.startsWith(\"**\", i) || src.startsWith(\"__\", i)) && src[i + 2] && src[i + 2] !== \" \";\n    if (double) {\n      const mark = src.slice(i, i + 2);\n      const j = closer(src, i + 2, b, mark);\n      const end = j >= 0 ? j : atTail ? b : -1;\n      if (end > i) {\n        flush(i);\n        out.push(\n          <strong key={`s${i}`} className=\"font-medium text-fg\">\n            {inline(ctx, i + 2, end)}\n          </strong>,\n        );\n        i = text = Math.min(b, end + 2);\n        continue;\n      }\n    }\n    const single = (ch === \"*\" || (ch === \"_\" && !/\\w/.test(src[i - 1] ?? \"\"))) && src[i + 1] && src[i + 1] !== \" \" && src[i + 1] !== ch;\n    if (single) {\n      const j = closer(src, i + 1, b, ch);\n      const end = j >= 0 && src[j + 1] !== ch ? j : atTail && j < 0 ? b : -1;\n      if (end > i) {\n        flush(i);\n        out.push(<em key={`e${i}`}>{inline(ctx, i + 1, end)}</em>);\n        i = text = Math.min(b, end + 1);\n        continue;\n      }\n    }\n    if (ch === \"[\") {\n      const close = src.indexOf(\"]\", i + 1);\n      if (close >= 0 && close < b && src[close + 1] === \"(\") {\n        const paren = src.indexOf(\")\", close + 2);\n        if (paren >= 0 && paren < b) {\n          flush(i);\n          const href = safeHref(src.slice(close + 2, paren));\n          const label = inline(ctx, i + 1, close);\n          out.push(\n            href ? (\n              <a\n                key={`a${i}`}\n                href={href}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"text-fg underline decoration-fg-4 underline-offset-[3px] transition-[text-decoration-color] duration-150 hover:decoration-fg-2\"\n              >\n                {label}\n                <ArrowUpRight size={12} className=\"ml-0.5 inline-block -translate-y-px align-middle text-fg-3\" />\n              </a>\n            ) : (\n              <Fragment key={`a${i}`}>{label}</Fragment>\n            ),\n          );\n          i = text = paren + 1;\n          continue;\n        }\n        if (atTail) {\n          // The address is still arriving: show the words, hide the half-written URL.\n          flush(i);\n          out.push(<span key={`a${i}`} className=\"text-fg underline decoration-fg-4 underline-offset-[3px]\">{inline(ctx, i + 1, close)}</span>);\n          i = text = b;\n          continue;\n        }\n      } else if (atTail && (close < 0 || close >= b)) {\n        flush(i);\n        out.push(<Fragment key={`a${i}`}>{inline(ctx, i + 1, b)}</Fragment>);\n        i = text = b;\n        continue;\n      }\n    }\n    i++;\n  }\n  flush(b);\n  return out;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * StreamingText\n * -----------------------------------------------------------------------------------------------*/\n\nexport type StreamingTextProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Everything received so far. Append to it as tokens arrive; replace it to start over. */\n  text: string;\n  /** \"streaming\" while tokens arrive. \"stopped\" and \"error\" keep the partial text and say so beneath it. */\n  status?: StreamStatus;\n  /** Reveal bursts a word at a time over a few frames instead of in lumps. */\n  smooth?: boolean;\n  /** \"md\" for the reply itself; \"sm\" for secondary streams such as reasoning or a side panel. */\n  size?: \"sm\" | \"md\";\n  /** Shown under a stopped reply. */\n  stoppedLabel?: string;\n  /** Shown under a reply that failed partway. */\n  errorLabel?: string;\n  /** Adds a Continue button to a stopped reply. */\n  onContinue?: () => void;\n  /** Adds a Try again button to a failed reply. */\n  onRetry?: () => void;\n};\n\nexport function StreamingText({\n  text,\n  status = \"done\",\n  smooth = true,\n  size = \"md\",\n  stoppedLabel = \"Stopped\",\n  errorLabel = \"The reply didn’t finish\",\n  onContinue,\n  onRetry,\n  className,\n  ref,\n  ...rest\n}: StreamingTextProps) {\n  const reduce = !!useReducedMotion();\n  const streaming = status === \"streaming\";\n  const { shown, chunks } = useRevealedText(text, { streaming, smooth: smooth && !reduce });\n  const rootRef = useRef<HTMLDivElement | null>(null);\n\n  const latest = chunks.length ? chunks[chunks.length - 1].at : 0;\n  const fresh = chunks.filter((c) => latest - c.at < FRESH);\n  const { blocks, end } = parse(shown, streaming);\n  const ctx: Ctx = { src: shown, tail: end, streaming, fresh };\n  const lastBlock = blocks[blocks.length - 1];\n\n  // Each new chunk fades in once. A span that React re-creates (because the\n  // text around it became bold or a list) resumes its fade where it was rather\n  // than starting again, so formatting never makes old words flicker.\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root || reduce) return;\n    const now = performance.now();\n    root.querySelectorAll<HTMLElement>(\"[data-chunk]:not([data-faded])\").forEach((el) => {\n      el.setAttribute(\"data-faded\", \"\");\n      const age = now - Number(el.dataset.at);\n      if (age >= FADE) return;\n      const anim = el.animate([{ opacity: 0, filter: \"blur(2px)\" }, { opacity: 1, filter: \"blur(0px)\" }], {\n        duration: FADE,\n        easing: `cubic-bezier(${ease.out.join(\",\")})`,\n        fill: \"backwards\",\n      });\n      anim.currentTime = Math.max(0, age);\n    });\n  });\n\n  // The caret is re-keyed by every chunk: it holds steady while words arrive and\n  // starts to blink only after the stream has gone quiet for half a second.\n  const caret = streaming ? <Caret key={`caret-${latest}`} /> : null;\n\n  return (\n    <div\n      ref={(node) => {\n        rootRef.current = node;\n        if (typeof ref === \"function\") ref(node);\n        else if (ref) ref.current = node;\n      }}\n      data-status={status}\n      data-size={size}\n      aria-busy={streaming || undefined}\n      className={cn(\n        \"flex min-w-0 flex-col [overflow-wrap:break-word]\",\n        size === \"sm\" ? \"gap-2 text-[12.5px] leading-5 text-fg-2\" : \"gap-3 text-[13.5px] leading-[22px] text-fg\",\n        className,\n      )}\n      {...rest}\n    >\n      {blocks.map((block) => (\n        <BlockView key={block.start} block={block} ctx={ctx} caret={block === lastBlock ? caret : null} />\n      ))}\n      {!blocks.length && streaming && <p className=\"min-h-[1lh]\">{caret}</p>}\n\n      <AnimatePresence initial={false}>\n        {(status === \"stopped\" || status === \"error\") && (\n          <motion.div\n            key={status}\n            role=\"status\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, transition: { duration: 0.12 } }}\n            transition={{ duration: 0.24, ease: ease.out }}\n            className={cn(\"-mt-0.5 flex min-h-7 flex-wrap items-center gap-x-2 gap-y-1 text-[12px] leading-4\", status === \"error\" ? \"text-danger\" : \"text-fg-3\")}\n          >\n            {status === \"error\" ? <Alert size={14} className=\"shrink-0\" /> : <span aria-hidden className=\"size-2 shrink-0 rounded-[2px] bg-current\" />}\n            <span>{status === \"error\" ? errorLabel : stoppedLabel}</span>\n            {status === \"stopped\" && onContinue && <FooterButton onClick={onContinue}>Continue</FooterButton>}\n            {status === \"error\" && onRetry && (\n              <FooterButton onClick={onRetry} icon>\n                Try again\n              </FooterButton>\n            )}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction FooterButton({ children, icon, ...rest }: React.ComponentProps<\"button\"> & { icon?: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        \"relative -my-1 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-2 outline-none\",\n        \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n      )}\n      {...rest}\n    >\n      {icon && <Refresh size={14} className=\"text-fg-3\" />}\n      {children}\n    </button>\n  );\n}\n\nfunction Caret() {\n  return (\n    <span\n      aria-hidden\n      data-caret=\"\"\n      className={cn(\n        \"ml-[2px] inline-block h-[1.1em] w-[0.52em] translate-y-[0.2em] rounded-[2px] bg-current opacity-80\",\n        \"animate-caret [animation-delay:500ms] motion-reduce:animate-none\",\n      )}\n    />\n  );\n}\n\nconst headingClass: Record<number, string> = {\n  1: \"text-[15px] leading-[22px] font-medium tracking-[-0.015em] text-fg\",\n  2: \"text-[14px] leading-[22px] font-medium tracking-[-0.01em] text-fg\",\n  3: \"text-[13.5px] leading-[22px] font-medium text-fg\",\n};\n\nfunction Lines({ ctx, lines, caret }: { ctx: Ctx; lines: Range[]; caret?: React.ReactNode }) {\n  return (\n    <>\n      {lines.map((r, k) => (\n        <Fragment key={r[0]}>\n          {k > 0 && <br />}\n          {inline(ctx, r[0], r[1])}\n        </Fragment>\n      ))}\n      {caret}\n    </>\n  );\n}\n\nfunction BlockView({ block, ctx, caret }: { block: Block; ctx: Ctx; caret: React.ReactNode }) {\n  switch (block.kind) {\n    case \"p\":\n      return (\n        <p>\n          <Lines ctx={ctx} lines={block.lines} caret={caret} />\n        </p>\n      );\n    case \"h\": {\n      // A reply is not a page: its headings sit a level or two below the app's.\n      const Tag = ([\"h3\", \"h4\", \"h5\"] as const)[Math.min(block.level, 3) - 1];\n      return (\n        <Tag className={cn(\"-mb-1 pt-1\", headingClass[Math.min(block.level, 3)])}>\n          <Lines ctx={ctx} lines={block.lines} caret={caret} />\n        </Tag>\n      );\n    }\n    case \"quote\":\n      return (\n        <blockquote className=\"border-s-2 border-line-2 ps-3 text-fg-2\">\n          <Lines ctx={ctx} lines={block.lines} caret={caret} />\n        </blockquote>\n      );\n    case \"hr\":\n      return <hr className=\"my-1 border-0 border-t border-line\" />;\n    case \"list\": {\n      const List = block.ordered ? \"ol\" : \"ul\";\n      const last = block.items[block.items.length - 1];\n      return (\n        <List role=\"list\" className=\"flex flex-col gap-1.5\">\n          {block.items.map((item) => (\n            <li key={item.start} value={block.ordered ? item.n : undefined} style={{ marginInlineStart: item.depth * 18 }} className=\"relative ps-5\">\n              {block.ordered ? (\n                <span aria-hidden className=\"absolute start-0 top-0 font-mono text-[12px] text-fg-3 tabular\">\n                  {item.n}.\n                </span>\n              ) : (\n                <span aria-hidden className=\"absolute start-1.5 top-[calc(0.5lh-2.5px)] size-[5px] rounded-full bg-fg-4\" />\n              )}\n              <Lines ctx={ctx} lines={item.lines} caret={item === last ? caret : null} />\n            </li>\n          ))}\n        </List>\n      );\n    }\n    case \"code\": {\n      const code = ctx.src.slice(block.body[0], block.body[1]);\n      const done = block.closed || !ctx.streaming;\n      return (\n        <div className=\"overflow-hidden rounded-lg border border-line bg-raised\">\n          <div className=\"flex h-8 items-center justify-between gap-2 border-b border-line pl-3 pr-1\">\n            <span className=\"truncate font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">{block.lang || \"code\"}</span>\n            <motion.span initial={false} animate={{ opacity: done ? 1 : 0 }} transition={{ duration: 0.16 }} inert={!done}>\n              <CopyButton value={code} iconOnly variant=\"ghost\" size=\"sm\" label=\"Copy code\" />\n            </motion.span>\n          </div>\n          <pre className=\"overflow-x-auto px-3 py-2.5 font-mono text-[12.5px] leading-5 text-fg [scrollbar-width:thin]\">\n            <code>\n              {runs(ctx, block.body[0], block.body[1])}\n              {caret}\n            </code>\n          </pre>\n        </div>\n      );\n    }\n  }\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/streaming-text"
}
