Merge pull request #67236 from NousResearch/bb/tui-incremental-markdown

perf(tui): render streamed markdown incrementally per block
This commit is contained in:
brooklyn! 2026-07-18 23:47:15 -04:00 committed by GitHub
commit 4f4a4f0d43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 637 additions and 143 deletions

View file

@ -0,0 +1,302 @@
// Benchmark: streamed-markdown render strategies for the TUI.
//
// Replays a newline-terminated, block-heavy synthetic stream at width 80
// through a real Ink render (renderSync + rerender per update) and compares:
//
// naive — <Md text={full}/> per update (re-tokenizes everything)
// monolithic — the previous StreamingMd: one memoized stable-prefix <Md>
// plus a tail <Md>. O(total) fence scan per update and a
// full prefix re-parse every time the boundary advances.
// per-block — current StreamingMd: append-only settled block array +
// incremental scanner. Each block parses exactly once.
//
// Each strategy/size pair runs in its OWN child process (orchestrator mode)
// so the parse-tree LRU in markdown.tsx and GC pressure from one strategy
// can't distort another's numbers. Each run also gets a unique text salt and
// a fresh theme object (its own WeakMap cache bucket).
//
// Run: npx tsx scripts/bench-streaming-md.tsx
// Single case: npx tsx scripts/bench-streaming-md.tsx <naive|monolithic|per-block> <blocks>
import { execFileSync } from 'child_process'
import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import React, { memo, useRef } from 'react'
import { Md } from '../src/components/markdown.js'
import { StreamingMd } from '../src/components/streamingMarkdown.js'
import { DEFAULT_THEME } from '../src/theme.js'
// ---- previous implementation (monolithic stable prefix), for comparison ----
const fenceOpenAt = (s: string, end: number) => {
let codeOpen = false
let mathOpen = false
let mathOpener: '$$' | '\\[' | null = null
let i = 0
while (i < end) {
const nl = s.indexOf('\n', i)
const lineEnd = nl < 0 || nl > end ? end : nl
const line = s.slice(i, lineEnd).trim()
if (/^(?:`{3,}|~{3,})/.test(line)) {
codeOpen = !codeOpen
} else if (!codeOpen) {
if (!mathOpen && /^\$\$/.test(line)) {
if (!(line.length >= 4 && /\$\$$/.test(line))) {
mathOpen = true
mathOpener = '$$'
}
} else if (!mathOpen && /^\\\[/.test(line)) {
if (!/\\\]$/.test(line)) {
mathOpen = true
mathOpener = '\\['
}
} else if (mathOpen && mathOpener === '$$' && /\$\$$/.test(line)) {
mathOpen = false
mathOpener = null
} else if (mathOpen && mathOpener === '\\[' && /\\\]$/.test(line)) {
mathOpen = false
mathOpener = null
}
}
if (nl < 0 || nl >= end) {
break
}
i = nl + 1
}
return codeOpen || mathOpen
}
const findStableBoundaryOld = (text: string) => {
let idx = text.length
while (idx > 0) {
const boundary = text.lastIndexOf('\n\n', idx - 1)
if (boundary < 0) {
return -1
}
const splitAt = boundary + 2
if (!fenceOpenAt(text, splitAt)) {
return splitAt
}
idx = boundary
}
return -1
}
const MonolithicStreamingMd = memo(function MonolithicStreamingMd({
t,
text
}: {
t: typeof DEFAULT_THEME
text: string
}) {
const stablePrefixRef = useRef('')
if (!text.startsWith(stablePrefixRef.current)) {
stablePrefixRef.current = ''
}
const boundary = findStableBoundaryOld(text)
if (boundary > stablePrefixRef.current.length) {
stablePrefixRef.current = text.slice(0, boundary)
}
const stablePrefix = stablePrefixRef.current
const unstableSuffix = text.slice(stablePrefix.length)
if (!stablePrefix) {
return <Md t={t} text={unstableSuffix} />
}
if (!unstableSuffix) {
return <Md t={t} text={stablePrefix} />
}
return (
<Box flexDirection="column">
<Md t={t} text={stablePrefix} />
<Md t={t} text={unstableSuffix} />
</Box>
)
})
// ---- synthetic stream ----
const makeBlocks = (count: number, salt: string) => {
const blocks: string[] = []
for (let i = 0; i < count; i++) {
switch (i % 4) {
case 0:
blocks.push(`Paragraph ${salt}-${i} explaining step ${i} with **bold** and \`code\` inline.\n`)
break
case 1:
blocks.push(`- item one ${salt}-${i}\n- item two with _emphasis_\n- item three\n`)
break
case 2:
blocks.push(`\`\`\`ts\nconst v${i} = compute${salt}(${i})\nif (v${i} > 0) {\n emit(v${i})\n}\n\`\`\`\n`)
break
default:
blocks.push(`### Heading ${salt}-${i}\n\nSome follow-up prose for section ${i}.\n`)
}
}
return blocks
}
// Newline-terminated updates: the stream grows one line per rerender.
const makeUpdates = (blocks: string[]) => {
const full = blocks.join('\n')
const updates: string[] = []
let pos = 0
while (pos < full.length) {
const nl = full.indexOf('\n', pos)
pos = nl < 0 ? full.length : nl + 1
updates.push(full.slice(0, pos))
}
return updates
}
const nullStream = () => {
const s = new PassThrough()
Object.assign(s, { columns: 80, isTTY: false, rows: 24 })
s.on('data', () => {})
return s
}
const bench = (
label: string,
updates: string[],
node: (t: typeof DEFAULT_THEME, text: string) => React.ReactNode,
captureSeries = false
) => {
// Fresh theme per run → fresh (collectable) mdCache bucket.
const runTheme = { ...DEFAULT_THEME }
const instance = renderSync(node(runTheme, ''), {
patchConsole: false,
stderr: nullStream() as unknown as NodeJS.WriteStream,
stdin: nullStream() as unknown as NodeJS.ReadStream,
stdout: nullStream() as unknown as NodeJS.WriteStream
})
const times: number[] = []
const start = performance.now()
let n = 0
for (const text of updates) {
const t0 = captureSeries ? performance.now() : 0
instance.rerender(node(runTheme, text))
if (captureSeries) {
times.push(performance.now() - t0)
}
// Something in the render path emits performance measures; unbounded,
// the entry buffer itself becomes a memory leak over thousands of
// rerenders and skews long runs.
if (++n % 64 === 0) {
performance.clearMeasures()
performance.clearMarks()
}
}
const elapsed = performance.now() - start
instance.unmount()
instance.cleanup()
return { elapsed, label, times }
}
const STRATEGIES = {
monolithic: (t: typeof DEFAULT_THEME, text: string) => <MonolithicStreamingMd t={t} text={text} />,
naive: (t: typeof DEFAULT_THEME, text: string) => <Md t={t} text={text} />,
'per-block': (t: typeof DEFAULT_THEME, text: string) => <StreamingMd t={t} text={text} />
} as const
const [strategyArg, sizeArg] = process.argv.slice(2)
if (strategyArg === 'series') {
// Series mode: per-append render times for one strategy/size, as JSON.
// Usage: npx tsx scripts/bench-streaming-md.tsx series <strategy> <blocks>
const [, seriesStrategy, seriesSize] = process.argv.slice(2)
const size = Number(seriesSize)
const updates = makeUpdates(makeBlocks(size, `${seriesStrategy}${size}`))
const { elapsed, times } = bench(
seriesStrategy!,
updates,
STRATEGIES[seriesStrategy as keyof typeof STRATEGIES],
true
)
console.log(JSON.stringify({ appends: updates.length, elapsed, times }))
} else if (strategyArg) {
// Child mode: run one strategy/size and print elapsed ms as JSON.
const size = Number(sizeArg)
const updates = makeUpdates(makeBlocks(size, `${strategyArg}${size}`))
const { elapsed } = bench(strategyArg, updates, STRATEGIES[strategyArg as keyof typeof STRATEGIES])
console.log(JSON.stringify({ appends: updates.length, elapsed }))
} else {
// Orchestrator mode: one child process per strategy/size.
const sizes = [32, 128, 512]
const run = (strategy: string, size: number): { appends: number; elapsed: number } => {
const out = execFileSync('npx', ['tsx', 'scripts/bench-streaming-md.tsx', strategy, String(size)], {
encoding: 'utf8',
env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=8192' },
timeout: 3_600_000
})
return JSON.parse(out.trim().split('\n').at(-1)!)
}
const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : `${ms.toFixed(1)} ms`)
console.log(
'| Blocks | Append calls | naive (full Md) | monolithic prefix | per-block (new) | new vs naive | new vs monolithic |'
)
console.log(
'|--------|--------------|-----------------|-------------------|-----------------|--------------|-------------------|'
)
for (const size of sizes) {
const naive = run('naive', size)
const mono = run('monolithic', size)
const perBlock = run('per-block', size)
console.log(
`| ${size} | ${perBlock.appends} | ${fmt(naive.elapsed)} | ${fmt(mono.elapsed)} | ${fmt(perBlock.elapsed)} | ${(
naive.elapsed / perBlock.elapsed
).toFixed(1)}x | ${(mono.elapsed / perBlock.elapsed).toFixed(1)}x |`
)
}
}

View file

@ -1,19 +1,48 @@
import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { findStableBoundary } from '../components/streamingMarkdown.js'
// We test the pure boundary logic by rendering the component's ref
// behaviour through repeated calls. Since React isn't being rendered here,
// we reach into the module to test findStableBoundary via its exported
// behaviour — but the pure helper isn't exported. So test the component's
// observable output: pass sequential text values and verify the stable
// prefix never retreats.
//
// Strategy: mount StreamingMd in isolation and observe which <Md>
// instances it renders (by text prop). Without a DOM renderer that's
// heavy, so we validate the helper behaviour by directly invoking the
// fence/boundary logic via a re-exported surface.
import { Md } from '../components/markdown.js'
import { advanceScan, createScanState, findStableBoundary } from '../components/streamingMarkdown.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const BEL = String.fromCharCode(7)
const ESC = String.fromCharCode(27)
const CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
const OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
const renderPlain = (node: React.ReactNode) => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(node, {
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
})
instance.unmount()
instance.cleanup()
return output
.replace(OSC_RE, '')
.split('\n')
.map(line => stripAnsi(line).replace(CSI_RE, '').trimEnd())
}
describe('findStableBoundary', () => {
it('returns -1 when no blank line exists yet', () => {
expect(findStableBoundary('partial line with no newline yet')).toBe(-1)
@ -111,11 +140,182 @@ describe('findStableBoundary', () => {
})
})
describe('streaming theme assumption', () => {
it('theme is exportable (component import sanity check)', () => {
// Sanity that the theme we pass doesn't change shape. Component import
// already happens above — this is a smoke test that the module graph
// for streamingMarkdown wires up without cycles.
expect(DEFAULT_THEME.color.accent).toBeTruthy()
// A corpus exercising every construct the boundary scanner must respect:
// paragraphs, fenced code (with blank lines and $$ bait inside), display
// math ($$ and \[), setext headings, tables, lists, quotes, headings.
const CORPUS = [
'Intro paragraph explaining the plan in some detail.\n',
'\nSection Title\n=============\n',
'\nA paragraph before code.\n',
'\n```ts\nconst a = 1\n\nconst b = 2\n// $$ not math $$\n```\n',
'\nBetween-blocks narration.\n',
'\n$$\nE = mc^2\n\n\\sum_i x_i\n$$\n',
'\n- item one\n- item two\n\n1. first\n2. second\n',
'\n| a | b |\n|---|---|\n| 1 | 2 |\n',
'\n> quoted wisdom\n> second line\n',
'\n\\[\nx^2 + y^2 = z^2\n\\]\n',
'\n## Closing heading\n',
'\nFinal paragraph without a trailing newline'
].join('')
const mulberry32 = (seed: number) => () => {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
describe('advanceScan (incremental scanner)', () => {
it('reconstructs the input exactly: blocks + tail === text', () => {
const state = createScanState()
advanceScan(CORPUS, state)
expect(state.blocks.join('')).toHaveLength(state.settledLen)
expect(state.blocks.join('') + CORPUS.slice(state.settledLen)).toBe(CORPUS)
expect(state.blocks.length).toBeGreaterThan(5)
})
it('produces identical blocks fed incrementally at arbitrary cut points', () => {
const oneShot = createScanState()
advanceScan(CORPUS, oneShot)
for (let seed = 1; seed <= 8; seed++) {
const rand = mulberry32(seed)
const state = createScanState()
let pos = 0
while (pos < CORPUS.length) {
pos = Math.min(CORPUS.length, pos + 1 + Math.floor(rand() * 7))
const prevBlocks = state.blocks.length
advanceScan(CORPUS.slice(0, pos), state)
// Append-only: previously committed blocks never change.
expect(state.blocks.length).toBeGreaterThanOrEqual(prevBlocks)
}
expect(state.blocks).toEqual(oneShot.blocks)
expect(state.settledLen).toBe(oneShot.settledLen)
}
})
it('is idempotent when called again with the same text', () => {
const state = createScanState()
advanceScan(CORPUS, state)
const blocks = [...state.blocks]
advanceScan(CORPUS, state)
expect(state.blocks).toEqual(blocks)
})
it('holds a partial trailing line in the tail even if it looks fence-like', () => {
// "``" could grow into "```ts" — the scanner must not judge the line
// until its newline arrives.
const state = createScanState()
advanceScan('para\n\n``', state)
expect(state.blocks).toEqual(['para\n\n'])
expect(state.codeOpen).toBe(false)
advanceScan('para\n\n```ts\ncode\n\nstill code', state)
// The blank line inside the now-open fence must not commit a block.
expect(state.blocks).toEqual(['para\n\n'])
expect(state.codeOpen).toBe(true)
advanceScan('para\n\n```ts\ncode\n\nstill code\n```\n\nafter\n\n', state)
expect(state.blocks).toEqual(['para\n\n', '```ts\ncode\n\nstill code\n```\n\n', 'after\n\n'])
expect(state.codeOpen).toBe(false)
})
it('does not commit whitespace-only blocks on 3+ newline runs', () => {
const state = createScanState()
advanceScan('alpha\n\n\n\nbeta\n\n', state)
expect(state.blocks).toEqual(['alpha\n\n', '\n\nbeta\n\n'])
expect(state.blocks.join('')).toBe('alpha\n\n\n\nbeta\n\n')
})
it('keeps a setext heading contiguous with its paragraph', () => {
// A setext underline attaches to the line above; the only committed
// boundary is the blank line, so the pair can never be torn apart or
// retroactively merged (the desktop splitter needed a fix for this,
// #67176 — blank-line boundaries are immune by construction).
const state = createScanState()
advanceScan('Title\n', state)
advanceScan('Title\n====\n', state)
advanceScan('Title\n====\n\nbody\n\n', state)
expect(state.blocks).toEqual(['Title\n====\n\n', 'body\n\n'])
})
})
describe('StreamingMd rendering equivalence', () => {
it('settled blocks + tail render identically to one combined Md', () => {
const state = createScanState()
advanceScan(CORPUS, state)
const tail = CORPUS.slice(state.settledLen)
const t = DEFAULT_THEME
const split = renderPlain(
React.createElement(
Box,
{ flexDirection: 'column' },
...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })),
tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null
)
)
const combined = renderPlain(React.createElement(Md, { t, text: CORPUS }))
expect(split).toEqual(combined)
})
it('renders split/combined identically at every streamed step', () => {
const rand = mulberry32(42)
const state = createScanState()
const t = DEFAULT_THEME
let pos = 0
while (pos < CORPUS.length) {
pos = Math.min(CORPUS.length, pos + 24 + Math.floor(rand() * 200))
const text = CORPUS.slice(0, pos)
advanceScan(text, state)
const tail = text.slice(state.settledLen)
const split = renderPlain(
React.createElement(
Box,
{ flexDirection: 'column' },
...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })),
tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null
)
)
const combined = renderPlain(React.createElement(Md, { t, text }))
expect(split).toEqual(combined)
}
})
})

View file

@ -1,32 +1,32 @@
// StreamingMd — incremental markdown renderer for in-flight assistant text.
//
// Naive approach (render <Md text={full}/>) re-tokenizes the entire message
// on every stream delta. At 20-char batches over a 3 KB response that's 150
// full re-parses.
// Rendering <Md text={full}/> per delta re-tokenizes the whole message every
// time (O(total) × deltas). The prior stable-prefix split fixed the per-delta
// cost but not the per-block cliff: each advanced boundary re-tokenized the
// entire prefix from scratch — O(blocks²) — plus an O(total) fence rescan.
//
// This splits `text` at the last stable top-level block boundary (blank
// line outside a fenced code span) into:
// stablePrefix — passed to an inner <Md>, memoized on its exact text
// value. During the turn, the prefix only grows monotonically,
// so its memo key matches the previous render and React
// reuses the cached subtree — zero re-tokenization.
// unstableSuffix — the in-flight block(s). A separate <Md> re-parses just
// this tail on every delta (O(unstable length) vs.
// O(total length)).
// This is fully incremental. A forward scanner keeps fence/math state + scan
// position in a ref across deltas, so each delta touches only newly-arrived
// complete lines. Settled top-level blocks (split on "\n\n" outside a fence)
// are frozen into an append-only array, each its own <Md> memoized on text
// that never changes — every block tokenizes exactly once. Only the in-flight
// tail re-parses per delta (O(tail)).
//
// The boundary is stored in a ref so it only advances — idempotent under
// StrictMode double-render. Component unmounts between turns (isStreaming
// flips off → message moves to history and renders via <Md> directly), so
// the ref resets naturally.
// Invariants that keep it correct:
// · Only newline-terminated lines are scanned; a partial trailing line may
// yet become a fence opener, so it stays in the tail.
// · Blank-line boundaries can't be retroactively merged (a setext underline
// only binds the contiguous line above it, never across a committed "\n\n").
// · An unmatched `$$` / `\[` opener is treated as open forever — more
// conservative than markdown.tsx's full-text fallback, because a committed
// block is frozen and can't be un-decided once the closer streams in.
// · State only advances (idempotent under StrictMode). The component
// unmounts between turns; if `text` stops extending `scanned` (turn reuse,
// or boundedLiveRenderText front-trimming a huge reply) the scanner resets
// and <Md>'s LRU absorbs the re-parse.
//
// Layout: the two <Md> subtrees MUST render stacked (column). The parent
// container in messageLine.tsx is a default `flexDirection: 'row'` Box
// (Ink's default), so returning a bare Fragment of two <Md> siblings
// laid them out side-by-side — producing the "two jumbled columns while
// streaming" rendering bug. Wrapping in a flexDirection="column" Box
// here localizes the fix to the streaming path; the non-streaming <Md>
// already returns its own column Box, so its single-child case was never
// affected.
// Layout: the <Md> subtrees MUST stack in a column — the messageLine.tsx
// parent is a default row Box, so bare siblings render side-by-side.
import { Box } from '@hermes/ink'
import { memo, useRef } from 'react'
@ -35,133 +35,125 @@ import type { Theme } from '../theme.js'
import { Md } from './markdown.js'
// Count ``` / ~~~ AND `$$` / `\[…\]` fence toggles in `s` up to `end`. Odd
// = currently inside a fenced block; splitting the prefix there would
// orphan the fence and let the unstable suffix re-render as broken
// markdown. Math fences only toggle when the code fence is closed so
// snippets like ` ```\n$$x$$\n``` ` (math example inside a code block)
// don't double-count. A `$$x$$` line that opens AND closes on its own
// produces zero net toggles; that's `len >= 4` plus `endsDollar`.
//
// NB: this is INTENTIONALLY more conservative than `markdown.tsx`'s
// parser, which falls back to paragraph rendering when an `$$` opener
// has no matching closer. The renderer can do that safely because it
// always sees the full text on every call. The streaming chunker
// cannot — once a chunk is committed to the monotonic stable prefix it
// is frozen, so prematurely deciding "this `$$` is just prose" would
// permanently commit a paragraph rendering that becomes wrong the
// instant the closer streams in. Treating any unmatched `$$` opener
// as still-open keeps the boundary parked behind it until the closer
// arrives (or the stream ends and the non-streaming `<Md>` takes over,
// at which point the renderer's fallback kicks in correctly).
const fenceOpenAt = (s: string, end: number) => {
let codeOpen = false
let mathOpen = false
let mathOpener: '$$' | '\\[' | null = null
let i = 0
export interface StreamScanState {
/** Settled top-level block strings, in order. Append-only. */
blocks: string[]
/** Inside an unclosed ``` / ~~~ fence at the scan position. */
codeOpen: boolean
/** Non-null inside an unclosed display-math block at the scan position. */
mathOpener: '$$' | '\\[' | null
/** Prefix whose complete lines have been scanned (kept as text so the reset
* guard can confirm the scanned region and thus fence state still holds). */
scanned: string
/** Length of the committed prefix (blocks.join('').length). */
settledLen: number
}
while (i < end) {
const nl = s.indexOf('\n', i)
const lineEnd = nl < 0 || nl > end ? end : nl
const line = s.slice(i, lineEnd).trim()
export const createScanState = (): StreamScanState => ({
blocks: [],
codeOpen: false,
mathOpener: null,
scanned: '',
settledLen: 0
})
if (/^(?:`{3,}|~{3,})/.test(line)) {
codeOpen = !codeOpen
} else if (!codeOpen) {
if (!mathOpen && /^\$\$/.test(line)) {
const isSingleLine = line.length >= 4 && /\$\$$/.test(line)
// Fold one complete line into the fence/math state: ``` / ~~~ toggle the code
// fence; `$$` / `\[` open display math unless the line also closes it; closers
// count only against a pending opener; math inside an open code fence is inert.
const applyLine = (state: StreamScanState, line: string) => {
if (/^(?:`{3,}|~{3,})/.test(line)) {
state.codeOpen = !state.codeOpen
if (!isSingleLine) {
mathOpen = true
mathOpener = '$$'
}
} else if (!mathOpen && /^\\\[/.test(line)) {
const isSingleLine = /\\\]$/.test(line)
return
}
if (!isSingleLine) {
mathOpen = true
mathOpener = '\\['
}
} else if (mathOpen && mathOpener === '$$' && /\$\$$/.test(line)) {
mathOpen = false
mathOpener = null
} else if (mathOpen && mathOpener === '\\[' && /\\\]$/.test(line)) {
mathOpen = false
mathOpener = null
}
if (state.codeOpen) {
return
}
if (!state.mathOpener) {
if (/^\$\$/.test(line) && !(line.length >= 4 && /\$\$$/.test(line))) {
state.mathOpener = '$$'
} else if (/^\\\[/.test(line) && !/\\\]$/.test(line)) {
state.mathOpener = '\\['
}
} else if (state.mathOpener === '$$' && /\$\$$/.test(line)) {
state.mathOpener = null
} else if (state.mathOpener === '\\[' && /\\\]$/.test(line)) {
state.mathOpener = null
}
}
// Consume newly-arrived complete lines, committing a settled block at every
// "\n\n" outside a fence. Whitespace-only runs stay with the next block, never
// committed as empty <Md>s. Mutates `state`; re-calling with the same text is
// a no-op (idempotent).
export const advanceScan = (text: string, state: StreamScanState) => {
const start = state.scanned.length
let i = start
while (i < text.length) {
const nl = text.indexOf('\n', i)
if (nl < 0) {
break // partial trailing line — could still open a fence; keep in tail
}
if (nl < 0 || nl >= end) {
break
if (nl === i) {
// Second half of a "\n\n" outside any fence → prior text is a block.
if (i > 0 && !state.codeOpen && !state.mathOpener) {
const block = text.slice(state.settledLen, nl + 1)
if (/\S/.test(block)) {
state.blocks.push(block)
state.settledLen = nl + 1
}
}
} else {
applyLine(state, text.slice(i, nl).trim())
}
i = nl + 1
}
return codeOpen || mathOpen
if (i > start) {
state.scanned += text.slice(start, i)
}
}
// Find the last "\n\n" boundary before `end` that is OUTSIDE a fenced code
// block. Returns the index AFTER the second newline (start of the next
// block), or -1 if no safe boundary exists yet.
// Index just past the last committed boundary, or -1 if nothing has settled.
// Thin wrapper over the scanner for boundary-semantics tests.
export const findStableBoundary = (text: string) => {
let idx = text.length
const state = createScanState()
while (idx > 0) {
const boundary = text.lastIndexOf('\n\n', idx - 1)
advanceScan(text, state)
if (boundary < 0) {
return -1
}
// Boundary candidate: end of stable prefix is boundary + 2 (start of
// next block). Check fence balance up to that point.
const splitAt = boundary + 2
if (!fenceOpenAt(text, splitAt)) {
return splitAt
}
idx = boundary
}
return -1
return state.settledLen > 0 ? state.settledLen : -1
}
export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }: StreamingMdProps) {
const stablePrefixRef = useRef('')
const scanRef = useRef<StreamScanState>(createScanState())
// Reset if the text no longer starts with our recorded prefix (defensive;
// normally the component unmounts between turns so this shouldn't trigger).
if (!text.startsWith(stablePrefixRef.current)) {
stablePrefixRef.current = ''
let state = scanRef.current
// Reset if `text` no longer extends the scanned prefix (turn reuse, or a
// front-trim), which would invalidate the persisted fence state.
if (!text.startsWith(state.scanned)) {
state = scanRef.current = createScanState()
}
const boundary = findStableBoundary(text)
advanceScan(text, state)
// Only advance the prefix — never retreat. The boundary math looks at the
// FULL text each call; if it returns a larger index than before, we grow
// the cached prefix. Monotonic growth makes the memo key stable across
// deltas (identical string → same <Md> subtree → no re-render).
if (boundary > stablePrefixRef.current.length) {
stablePrefixRef.current = text.slice(0, boundary)
}
const stablePrefix = stablePrefixRef.current
const unstableSuffix = text.slice(stablePrefix.length)
if (!stablePrefix) {
return <Md cols={cols} compact={compact} t={t} text={unstableSuffix} />
}
if (!unstableSuffix) {
return <Md cols={cols} compact={compact} t={t} text={stablePrefix} />
}
const tail = text.slice(state.settledLen)
return (
<Box flexDirection="column">
<Md cols={cols} compact={compact} t={t} text={stablePrefix} />
<Md cols={cols} compact={compact} t={t} text={unstableSuffix} />
{state.blocks.map((block, i) => (
<Md cols={cols} compact={compact} key={i} t={t} text={block} />
))}
{tail ? <Md cols={cols} compact={compact} t={t} text={tail} /> : null}
</Box>
)
})