{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"streaming-markdown","type":"registry:ui","title":"Streaming Markdown","description":"Markdown that renders correctly while it is still arriving — bold, code spans, links and tables settle without ever flashing raw syntax or reflowing.","author":"Scrim UI (https://scrimui.dev)","categories":["messages"],"docs":"https://scrimui.dev/components/streaming-markdown","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/streaming-markdown/streaming-markdown.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Markdown that renders correctly *while it is still arriving*.\n *\n * The received wisdom is that you cannot do this — render plain text while\n * streaming, then swap in rendered markdown when the turn ends. That advice\n * exists because the naive approach genuinely looks broken: `**bol` renders\n * as two literal asterisks, then snaps to bold when the closing pair lands.\n * A paragraph of prose flashes syntax half a dozen times on its way in, and\n * a table sits as a row of raw pipes until its separator arrives.\n *\n * Swapping at the end trades one flaw for a worse one: the entire message\n * reflows at the exact moment the reader is furthest down it.\n *\n * Three ideas make streaming markdown work.\n *\n * 1. COMPLETENESS IS POSITIONAL. Only the tail of the text can be\n *    incomplete. An unmatched `**` in the middle of the text is not a bold\n *    span waiting to close — more text arrived after it, so it is literally\n *    two asterisks. Everything before the tail is parsed strictly, exactly\n *    as a finished document would be.\n *\n * 2. SPECULATIVE CLOSING. In the tail, an unclosed construct is closed\n *    virtually and rendered in its final form: `**bol` renders as bold \"bol\"\n *    with the asterisks hidden. When `**bold**` completes, nothing changes —\n *    it was already bold. No flash, and more importantly no reflow.\n *\n * 3. WHEN AMBIGUOUS, HOLD. A lone `#`, a single backtick, a table row whose\n *    next line has not arrived — these could become several different\n *    things. Rendering nothing for one token is imperceptible. Rendering the\n *    wrong thing and correcting it is the flicker we came to remove. This is\n *    the rule people skip, because holding output feels like doing less.\n *\n * Speculation is only valid while text is still coming, which is what\n * `streaming` controls. Once the turn is done, unmatched delimiters are\n * literal characters and get rendered as such — the same text parsed by the\n * same rules any other markdown renderer would use.\n */\n\nexport type StreamingMarkdownProps = {\n  /** The markdown so far. Safe to pass a value that ends mid-token. */\n  text: string;\n  /**\n   * Whether more text is still coming. Drives speculation *and* correctness:\n   * with `false`, a trailing `**` is two asterisks, because it is.\n   */\n  streaming?: boolean;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Inline — speculative closing                                        */\n/* ------------------------------------------------------------------ */\n\ntype Unclosed = { index: number; kind: \"code\" | \"strong\" | \"em\" | \"link\" };\n\n/**\n * The first delimiter in `text` that is opened and never closed.\n *\n * Scans left to right and skips over *matched* pairs rather than searching\n * from the end, so `a *b* c **d` finds the `**` and not the earlier `*`\n * that already closed.\n */\nfunction findUnclosed(text: string): Unclosed | null {\n  let i = 0;\n  while (i < text.length) {\n    const ch = text[i];\n    if (ch === \"`\") {\n      const close = text.indexOf(\"`\", i + 1);\n      if (close === -1) return { index: i, kind: \"code\" };\n      i = close + 1;\n      continue;\n    }\n    if (ch === \"*\" && text[i + 1] === \"*\") {\n      const close = text.indexOf(\"**\", i + 2);\n      if (close === -1) return { index: i, kind: \"strong\" };\n      i = close + 2;\n      continue;\n    }\n    if (ch === \"*\") {\n      const close = text.indexOf(\"*\", i + 1);\n      if (close === -1) return { index: i, kind: \"em\" };\n      i = close + 1;\n      continue;\n    }\n    if (ch === \"[\") {\n      /* A link is only settled once its closing paren lands — `[a](htt` is\n         still in flight even though the bracket pair is complete. */\n      const close = text.indexOf(\")\", i);\n      if (close === -1) return { index: i, kind: \"link\" };\n      i = close + 1;\n      continue;\n    }\n    i++;\n  }\n  return null;\n}\n\nconst LINK_CLASS = \"text-(--foreground) underline underline-offset-2\";\n\nfunction renderInline(line: string, speculate: boolean, keyBase: string): React.ReactNode[] {\n  const nodes: React.ReactNode[] = [];\n  const tokenRe = /(`[^`]+`)|(\\*\\*[^*]+\\*\\*)|(\\*[^*]+\\*)|(\\[[^\\]]+\\]\\([^)]*\\))/g;\n  let last = 0;\n  let m: RegExpExecArray | null;\n  let i = 0;\n\n  while ((m = tokenRe.exec(line)) !== null) {\n    if (m.index > last) {\n      nodes.push(<React.Fragment key={`${keyBase}-t${i++}`}>{line.slice(last, m.index)}</React.Fragment>);\n    }\n    const tok = m[0];\n    const k = `${keyBase}-k${i++}`;\n    if (tok.startsWith(\"`\")) nodes.push(<code key={k}>{tok.slice(1, -1)}</code>);\n    else if (tok.startsWith(\"**\")) nodes.push(<strong key={k}>{tok.slice(2, -2)}</strong>);\n    else if (tok.startsWith(\"[\")) {\n      const md = tok.match(/^\\[([^\\]]+)\\]\\(([^)]*)\\)$/);\n      nodes.push(\n        md && md[2] ? (\n          <a key={k} href={md[2]} target=\"_blank\" rel=\"noreferrer noopener\" className={LINK_CLASS}>\n            {md[1]}\n          </a>\n        ) : (\n          /* Bracket pair closed, href still empty: style it as a link now so\n             that filling the href in later changes nothing visible. */\n          <span key={k} className={LINK_CLASS}>\n            {md ? md[1] : tok}\n          </span>\n        ),\n      );\n    } else nodes.push(<em key={k}>{tok.slice(1, -1)}</em>);\n    last = m.index + tok.length;\n  }\n\n  const rest = line.slice(last);\n  if (!rest) return nodes;\n\n  const unclosed = speculate ? findUnclosed(rest) : null;\n  if (!unclosed) {\n    nodes.push(<React.Fragment key={`${keyBase}-t${i++}`}>{rest}</React.Fragment>);\n    return nodes;\n  }\n\n  /* Text before the opener is ordinary. */\n  if (unclosed.index > 0) {\n    nodes.push(<React.Fragment key={`${keyBase}-t${i++}`}>{rest.slice(0, unclosed.index)}</React.Fragment>);\n  }\n\n  const k = `${keyBase}-spec`;\n  if (unclosed.kind === \"code\") {\n    nodes.push(<code key={k}>{rest.slice(unclosed.index + 1)}</code>);\n  } else if (unclosed.kind === \"strong\") {\n    nodes.push(<strong key={k}>{rest.slice(unclosed.index + 2)}</strong>);\n  } else if (unclosed.kind === \"em\") {\n    nodes.push(<em key={k}>{rest.slice(unclosed.index + 1)}</em>);\n  } else {\n    /* `[label` or `[label](ur` — render the label, already link-styled. The\n       href is the only thing still missing and it is not visible anyway, so\n       when it lands the text does not move. */\n    const body = rest.slice(unclosed.index + 1);\n    const bracket = body.indexOf(\"]\");\n    nodes.push(\n      <span key={k} className={LINK_CLASS}>\n        {bracket === -1 ? body : body.slice(0, bracket)}\n      </span>,\n    );\n  }\n  return nodes;\n}\n\n/* ------------------------------------------------------------------ */\n/* Pieces                                                              */\n/* ------------------------------------------------------------------ */\n\nfunction CodeBlock({\n  lang,\n  code,\n  caret,\n}: {\n  lang: string;\n  code: string;\n  caret?: React.ReactNode;\n}) {\n  const [copied, setCopied] = React.useState(false);\n  return (\n    <div className=\"my-3 overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-800\">\n      <div className=\"flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-900/60\">\n        <span className=\"font-mono text-[11px] text-(--muted-foreground)\">{lang || \"text\"}</span>\n        <button\n          type=\"button\"\n          onClick={() => {\n            void navigator.clipboard?.writeText(code);\n            setCopied(true);\n            window.setTimeout(() => setCopied(false), 1500);\n          }}\n          className=\"text-[11px] text-(--muted-foreground) transition-colors hover:text-(--foreground)\"\n        >\n          {copied ? \"Copied\" : \"Copy\"}\n        </button>\n      </div>\n      <pre className=\"overflow-x-auto bg-zinc-950 px-3 py-3 text-[13px] leading-5 text-zinc-100 dark:bg-zinc-900\">\n        <code>\n          {code}\n          {caret}\n        </code>\n      </pre>\n    </div>\n  );\n}\n\nfunction isTableSeparator(line: string): boolean {\n  return /^\\s*\\|?\\s*:?-{2,}:?\\s*(\\|\\s*:?-{2,}:?\\s*)*\\|?\\s*$/.test(line);\n}\n\nfunction cells(line: string): string[] {\n  return line\n    .split(\"|\")\n    .map((c) => c.trim())\n    .filter((c, idx, arr) => !(c === \"\" && (idx === 0 || idx === arr.length - 1)));\n}\n\n/* ------------------------------------------------------------------ */\n/* Hold — the ambiguous tail                                           */\n/* ------------------------------------------------------------------ */\n\n/**\n * Should the final line be withheld for now?\n *\n * Every case here is a fragment that could still become more than one thing.\n * Holding costs one token of latency, which no one perceives; guessing costs\n * a visible correction, which everyone does.\n */\nfunction shouldHoldLastLine(lines: string[]): boolean {\n  const line = lines[lines.length - 1];\n  if (line === undefined) return false;\n  const t = line.trim();\n  if (t === \"\") return false;\n\n  /* A run of one or two backticks is either inline code opening or a fence\n     marker still being typed. Those render completely differently. */\n  if (/^`{1,2}$/.test(t)) return true;\n\n  /* Markers with no content yet: `#`, `-`, `>`, `1.` */\n  if (/^#{1,6}$/.test(t)) return true;\n  if (/^[-*+]$/.test(t)) return true;\n  if (/^>$/.test(t)) return true;\n  if (/^\\d+\\.$/.test(t)) return true;\n\n  /* A pipe row is only a table once its separator arrives. Until then it\n     could equally be prose containing a pipe. Rendering it as a paragraph\n     and promoting it to a table later is the single ugliest reflow in\n     streamed markdown, because the whole block changes shape. */\n  if (t.includes(\"|\")) {\n    const prev = lines[lines.length - 2]?.trim() ?? \"\";\n    const established = prev.includes(\"|\") || isTableSeparator(prev);\n    if (!established) return true;\n  }\n\n  /* A separator on its own, with the header above it, is safe to hold too —\n     the table renders the moment there is something to put under it. */\n  if (isTableSeparator(t)) return true;\n\n  return false;\n}\n\n/* ------------------------------------------------------------------ */\n/* Blocks                                                              */\n/* ------------------------------------------------------------------ */\n\nfunction parseBlocks(\n  text: string,\n  speculate: boolean,\n  keyBase: string,\n  caret = false,\n): React.ReactNode[] {\n  let lines = text.split(\"\\n\");\n  if (speculate && shouldHoldLastLine(lines)) lines = lines.slice(0, -1);\n\n  const blocks: React.ReactNode[] = [];\n  let i = 0;\n  let key = 0;\n\n  /* The caret has to sit *inside* the last block, not after it. Rendered as a\n     sibling of the blocks it is a span following a <p>, which is block-level,\n     so it drops onto a line of its own and reads as a stray character. The\n     branches below already know which run of text is last — that is the same\n     condition that decides whether to speculate — so the caret rides along\n     with it. If the last block is a table or a code block there is no\n     sensible inline slot, and it falls through to the trailing push. */\n  let caretPlaced = false;\n  const tip = (last: boolean) => {\n    if (!caret || !last || caretPlaced) return null;\n    caretPlaced = true;\n    return <Caret />;\n  };\n\n  while (i < lines.length) {\n    const line = lines[i];\n    const trimmed = line.trim();\n    const k = `${keyBase}-b${key++}`;\n\n    if (trimmed === \"\") {\n      i++;\n      continue;\n    }\n\n    /* Fenced code. An unclosed fence is rendered as a code block straight\n       away rather than as a paragraph of backticks — the language label is\n       already known, and the block only grows downward from here, so nothing\n       above it moves. */\n    if (trimmed.startsWith(\"```\")) {\n      const lang = trimmed.replace(/^```/, \"\").trim();\n      let j = i + 1;\n      const code: string[] = [];\n      while (j < lines.length && !lines[j].trimStart().startsWith(\"```\")) {\n        code.push(lines[j]);\n        j++;\n      }\n      blocks.push(\n        <CodeBlock key={k} lang={lang} code={code.join(\"\\n\")} caret={tip(j >= lines.length)} />,\n      );\n      i = j + 1;\n      continue;\n    }\n\n    /* Heading */\n    const heading = trimmed.match(/^(#{1,6})\\s+(.*)$/);\n    if (heading) {\n      const level = heading[1].length;\n      const sizes = [\"text-xl\", \"text-lg\", \"text-base\", \"text-sm\", \"text-sm\", \"text-sm\"];\n      blocks.push(\n        <p key={k} className={`mt-4 mb-2 font-semibold first:mt-0 ${sizes[level - 1]}`}>\n          {renderInline(heading[2], speculate, k)}\n          {tip(i === lines.length - 1)}\n        </p>,\n      );\n      i++;\n      continue;\n    }\n\n    /* Table */\n    if (line.includes(\"|\") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {\n      const header = cells(line);\n      let j = i + 2;\n      const body: string[][] = [];\n      while (j < lines.length && lines[j].includes(\"|\") && !isTableSeparator(lines[j])) {\n        body.push(cells(lines[j]));\n        j++;\n      }\n      blocks.push(\n        <div key={k} className=\"my-3 overflow-x-auto\">\n          <table className=\"w-full border-collapse text-[13px] leading-5\">\n            <thead>\n              <tr>\n                {header.map((h, hk) => (\n                  <th key={hk} className=\"border-b border-zinc-200 px-2 py-1.5 text-left font-medium dark:border-zinc-800\">\n                    {renderInline(h, false, `${k}-h${hk}`)}\n                  </th>\n                ))}\n              </tr>\n            </thead>\n            <tbody>\n              {body.map((row, rk) => (\n                <tr key={rk}>\n                  {/* Padded to the header's width: a row that is still\n                      arriving has fewer cells, and letting the column count\n                      change would make the whole table jump. */}\n                  {header.map((_, ck) => (\n                    <td key={ck} className=\"border-b border-zinc-100 px-2 py-1.5 align-top dark:border-zinc-900\">\n                      {renderInline(row[ck] ?? \"\", false, `${k}-r${rk}c${ck}`)}\n                      {/* Last *filled* cell, not the last column: a row that\n                          is still arriving is padded out with empty cells,\n                          and the caret belongs where the text stopped. */}\n                      {tip(\n                        j >= lines.length &&\n                          rk === body.length - 1 &&\n                          ck === Math.min(row.length, header.length) - 1,\n                      )}\n                    </td>\n                  ))}\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>,\n      );\n      i = j;\n      continue;\n    }\n\n    /* Lists */\n    const bullet = trimmed.match(/^[-*+]\\s+(.*)$/);\n    const ordered = trimmed.match(/^(\\d+)\\.\\s+(.*)$/);\n    if (bullet || ordered) {\n      const items: string[] = [];\n      const isOrdered = Boolean(ordered);\n      let j = i;\n      while (j < lines.length) {\n        const t = lines[j].trim();\n        const b = t.match(/^[-*+]\\s+(.*)$/);\n        const o = t.match(/^(\\d+)\\.\\s+(.*)$/);\n        if (isOrdered && o) items.push(o[2]);\n        else if (!isOrdered && b) items.push(b[1]);\n        else break;\n        j++;\n      }\n      const ListTag = isOrdered ? \"ol\" : \"ul\";\n      blocks.push(\n        <ListTag key={k} className={`my-2 space-y-1 pl-5 ${isOrdered ? \"list-decimal\" : \"list-disc\"}`}>\n          {items.map((item, ik) => (\n            <li key={ik}>\n              {renderInline(item, speculate && j === lines.length && ik === items.length - 1, `${k}-i${ik}`)}\n              {tip(j >= lines.length && ik === items.length - 1)}\n            </li>\n          ))}\n        </ListTag>,\n      );\n      i = j;\n      continue;\n    }\n\n    /* Blockquote */\n    const quote = trimmed.match(/^>\\s?(.*)$/);\n    if (quote) {\n      blocks.push(\n        <blockquote key={k} className=\"my-3 border-l-2 border-(--border) pl-3 text-(--muted-foreground)\">\n          {renderInline(quote[1], speculate && i === lines.length - 1, k)}\n          {tip(i === lines.length - 1)}\n        </blockquote>,\n      );\n      i++;\n      continue;\n    }\n\n    /* Paragraph — consecutive non-blank lines that start no other block. */\n    const para: string[] = [];\n    let j = i;\n    while (\n      j < lines.length &&\n      lines[j].trim() !== \"\" &&\n      !lines[j].trimStart().startsWith(\"```\") &&\n      !/^#{1,6}\\s/.test(lines[j].trim()) &&\n      !/^[-*+]\\s/.test(lines[j].trim()) &&\n      !/^\\d+\\.\\s/.test(lines[j].trim()) &&\n      !/^>\\s?/.test(lines[j].trim())\n    ) {\n      para.push(lines[j]);\n      j++;\n    }\n    blocks.push(\n      <p key={k} className=\"my-2 leading-6 first:mt-0 last:mb-0\">\n        {para.map((l, lk) => (\n          <React.Fragment key={lk}>\n            {lk > 0 && \" \"}\n            {renderInline(l, speculate && j === lines.length && lk === para.length - 1, `${k}-l${lk}`)}\n            {tip(j >= lines.length && lk === para.length - 1)}\n          </React.Fragment>\n        ))}\n      </p>,\n    );\n    i = j;\n  }\n\n  if (caret && !caretPlaced) blocks.push(<Caret key={`${keyBase}-caret`} />);\n\n  return blocks;\n}\n\n/**\n * The \"still typing\" tip. `aria-hidden` because a screen reader announcing a\n * blinking rectangle at the end of every partial sentence is noise, not\n * information — the streamed text itself is the signal.\n */\nfunction Caret() {\n  return (\n    <span\n      aria-hidden\n      className=\"ml-0.5 inline-block h-[1em] w-[2px] translate-y-[2px] animate-pulse bg-(--foreground) align-baseline\"\n    />\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Stable / tail split                                                 */\n/* ------------------------------------------------------------------ */\n\n/**\n * Everything before the last block boundary can never change again, so it is\n * parsed once and memoised. Without this, every token re-parses the whole\n * message: quadratic over a stream, and very visible by the time an answer\n * is a few thousand tokens long.\n *\n * The boundary has to sit outside a code fence. A blank line inside one is\n * not a block boundary, and splitting there would parse half a fence as\n * prose — hence the parity check rather than a plain `lastIndexOf`.\n */\nfunction splitStable(text: string): { stable: string; tail: string } {\n  let from = text.length;\n  for (;;) {\n    const idx = text.lastIndexOf(\"\\n\\n\", from);\n    if (idx === -1) return { stable: \"\", tail: text };\n    const before = text.slice(0, idx);\n    const fences = before.match(/^```/gm)?.length ?? 0;\n    if (fences % 2 === 0) return { stable: before, tail: text.slice(idx) };\n    from = idx - 1;\n    if (from < 0) return { stable: \"\", tail: text };\n  }\n}\n\n/* ------------------------------------------------------------------ */\n/* Component                                                           */\n/* ------------------------------------------------------------------ */\n\nexport function StreamingMarkdown({ text, streaming = false, className }: StreamingMarkdownProps) {\n  const { stable, tail } = React.useMemo(() => splitStable(text), [text]);\n\n  /* `stable` only changes when a block boundary is crossed — a few times per\n     message, not a few hundred. This memo is the whole reason long answers\n     stay smooth. */\n  const stableBlocks = React.useMemo(() => parseBlocks(stable, false, \"s\"), [stable]);\n  const tailBlocks = parseBlocks(tail, streaming, \"t\", streaming);\n\n  return (\n    <div className={`text-[15px] leading-6 ${className ?? \"\"}`}>\n      {stableBlocks}\n      {tailBlocks}\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/streaming-markdown.tsx"}]}