/** * Progressive Markdown display for a reply that is still streaming. * * The UI commits COMPLETE LINES instead of waiting for complete * blank-line-delimited blocks, while buffering or sanitizing incomplete * Markdown structures so raw syntax is never exposed to the reader. * * Two ideas do the work together — the line-level split is not sufficient on * its own: * * 1. Split at the last newline, so every finished line can render and only * the partially-typed final line waits. (Splitting at the last blank line * instead would freeze a whole table — which has no blank lines — as raw * text for the entire stream.) * * 2. Buffer the multi-line structures that a prefix of complete lines can * still leave half-open: * - an unterminated ``` code fence (`withoutOpenCodeFence`) * - a table with its header row but no `|---|` separator yet * (`withoutNascentTable`) * and sanitize the single in-progress line (`cleanStreamTail`). * * This module is TEXT-LEVEL and renderer-agnostic: it decides WHAT markdown is * safe to parse right now, not how to turn it into HTML. The actual rendering * is react-markdown (see Message.jsx), so the split survives any renderer * upgrade. Pure (no React) so it unit-tests directly — see * streamRender.test.mjs. */ /** * Split the stream buffer into what may render now vs. what must wait. * * Returns: * { thinking: true } — buffer is still empty * { thinking: false, committed, tail } — `committed` is markdown that is * safe to hand to the parser; `tail` is the sanitized plain-text remnant * of the line still being typed ("" when there is nothing showable). */ export function splitStreaming(content) { const text = content || ""; if (!text.trim()) { return { thinking: true, committed: "", tail: "", liveCode: null }; } const cut = text.lastIndexOf("\n"); const committedRaw = cut === -1 ? "" : text.slice(0, cut); const tailRaw = cut === -1 ? text : text.slice(cut + 1); const liveCode = extractOpenCodeFence(committedRaw, tailRaw); const committedSource = liveCode ? liveCode.before : committedRaw; // Among the complete lines, hold back any structure that is not yet in a // renderable state, so the parser never receives half-open syntax. const committed = commitStableMarkdown(committedSource); // If the buffer ends inside an open code fence, the in-progress line is code, // not prose — hide it along with the fence instead of showing it as text. const startsFence = /^\s*```/.test(tailRaw); const tail = liveCode || startsFence ? "" : cleanStreamTail(tailRaw); return { thinking: false, committed, tail, liveCode: liveCode ? { language: liveCode.language, code: liveCode.code, } : null, }; } /** * Trim the committed prefix down to the largest span that renders cleanly: * no open code fence, no header-only table. Order matters — remove an open * fence first, since fenced content can contain `|` lines that would * otherwise look like a nascent table. */ export function commitStableMarkdown(committed) { return withoutNascentTable( repairMarkdownTables( withoutOpenMathBlock( withoutOpenCodeFence(committed), ), ), ); } export function extractOpenCodeFence(committed, tail = "") { if (!committed || committed.indexOf("```") === -1) return null; const lines = committed.split("\n"); let open = false; let lastOpenIdx = -1; let language = ""; for (let i = 0; i < lines.length; i++) { const fence = lines[i].match(/^\s*```(.*)$/); if (!fence) continue; if (!open) { open = true; lastOpenIdx = i; language = (fence[1] || "").trim().split(/\s+/)[0] || ""; } else { open = false; lastOpenIdx = -1; language = ""; } } if (!open || lastOpenIdx < 0) return null; const codeLines = lines.slice(lastOpenIdx + 1); const code = tail ? [...codeLines, tail].join("\n") : codeLines.join("\n"); return { before: lines.slice(0, lastOpenIdx).join("\n"), language, code, }; } /** * Hold back an unterminated ``` code fence. * * A fence renders as a code block only once BOTH its opening and closing ``` * lines are present. While the fence is still open, the committed prefix ends * with `` ```lang `` and code lines that would render as loose text. Cut the * prefix at the last unmatched opening fence so the code block stays hidden * until it closes, then renders whole. */ export function withoutOpenCodeFence(committed) { const open = extractOpenCodeFence(committed); return open ? open.before : committed; } export function withoutOpenMathBlock(committed) { if (!committed || committed.indexOf("$$") === -1) return committed; const lines = committed.split("\n"); let open = false; let lastOpenIdx = -1; for (let i = 0; i < lines.length; i++) { if (/^\s*\$\$\s*$/.test(lines[i])) { if (!open) { open = true; lastOpenIdx = i; } else { open = false; } } } return open ? lines.slice(0, lastOpenIdx).join("\n") : committed; } export function repairMarkdownTables(markdown) { if (!markdown || markdown.indexOf("|") === -1) return markdown; const lines = markdown.split("\n"); const out = []; let i = 0; let inFence = false; while (i < lines.length) { if (/^\s*```/.test(lines[i])) { inFence = !inFence; out.push(lines[i++]); continue; } if (inFence || !isPipeTableLine(lines[i])) { out.push(lines[i++]); continue; } const start = i; while (i < lines.length && isPipeTableLine(lines[i])) i++; const block = lines.slice(start, i); const hasSeparator = block.some(isTableSeparatorLine); if (!hasSeparator && canRepairTableBlock(block)) { out.push(block[0], separatorForTableLine(block[0]), ...block.slice(1)); } else { out.push(...block); } } return out.join("\n"); } export function isPipeTableLine(line) { const s = (line || "").trim(); return s.startsWith("|") && s.endsWith("|") && splitTableCells(s).length >= 2; } function canRepairTableBlock(block) { if (block.length < 2) return false; const count = splitTableCells(block[0]).length; return count >= 2 && block.every((line) => splitTableCells(line).length === count); } function separatorForTableLine(line) { const count = splitTableCells(line).length; return `|${Array.from({ length: count }, () => "---").join("|")}|`; } function splitTableCells(line) { const s = (line || "").trim().replace(/^\|/, "").replace(/\|$/, ""); return s.split("|").map((cell) => cell.trim()); } /** * Hold back a table that has its header row but not its `|---|` separator yet. * * A markdown table needs both a header row and a separator row to render. * While streaming, the header line completes one frame before the separator, * so for that window the committed text ends in table rows with no separator — * which would show as raw pipes. Find the trailing run of `|`-lines and, if * none of them is a separator, hide the whole run until the separator arrives. * Once it lands, the block renders as a real table. */ export function withoutNascentTable(committed) { if (!committed || committed.indexOf("|") === -1) return committed; const lines = committed.split("\n"); let i = lines.length; while (i > 0 && isPipeTableLine(lines[i - 1])) i--; const trailing = lines.slice(i); if (trailing.length === 0) return committed; // no trailing table if (trailing.some(isTableSeparatorLine)) return committed; // already a table return lines.slice(0, i).join("\n"); // header-only → hide } export function isTableSeparatorLine(line) { const s = (line || "").trim(); if (!(s.startsWith("|") && s.endsWith("|"))) return false; return /-/.test(s) && /^\|[\s:|-]+\|$/.test(s); } /** * Sanitize the single in-progress line before showing it. * * That line is raw markdown, so printing it verbatim leaks syntax: a half-typed * separator shows as `|---|`, an unclosed bold shows its `**`. A table row * mid-build is pure scaffolding, so it is hidden outright; ordinary prose keeps * typing through smoothly with only its (possibly unclosed) inline markers * dropped. */ export function cleanStreamTail(tail) { const t = tail || ""; if (!t.trim()) return ""; if (/^\s*\|/.test(t)) return ""; // table row / separator — hide return t .replace(/^\s*#{1,6}\s*/, "") // heading marker being typed .replace(/^\s*>\s?/, "") // blockquote marker being typed .replace(/\$\$/g, "") // display math delimiter .replace(/(^|[^\\])\$(?=\S)(?!\d)/g, "$1") // inline math opener, not money .replace(/\*\*/g, "") // unclosed bold .replace(/(^|[^*])\*(?!\*)/g, "$1") // unclosed single-star italic .replace(/~~/g, "") // unclosed strikethrough .replace(/`/g, ""); // unclosed code span / fence }