Skip to content

perf(cli): render streaming markdown incrementally - #1549

Merged
obadakhalili merged 3 commits into
CodebuffAI:mainfrom
Salhi-zh:perf/cli-incremental-markdown
Oct 23, 2026
Merged

obadakhalili merged 3 commits into
CodebuffAI:mainfrom
Salhi-zh:perf/cli-incremental-markdown

Conversation

@Salhi-zh

@Salhi-zh Salhi-zh commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

ContentWithMarkdown re-renders the live message on every flush (~10/s) while it streams, and each render re-parsed the entire accumulated answer with unified + remark-gfm. renderMarkdown costs roughly 3 ms per kilobyte, so:

Answer size Cost of one render
10 KB 35 ms
50 KB 115 ms
200 KB 480 ms
500 KB 1.4 s

That is the stall users feel on long answers: the render thread blocks for hundreds of milliseconds per flush, and the total work for one stream is quadratic. AgentMessage did the same thing per subagent, so a fan-out of 8 agents paid 8 full parses per flush.

Fix

Streaming text is append-only, so the parse of everything before the last complete block boundary is still valid on the next flush. streaming-markdown.ts keeps that prefix parsed and re-renders only the tail:

  • boundaries are blank lines outside fenced code, and never next to a list or blockquote (a loose list or quote would be split, restarting ordered numbering);
  • the trailing \n\n separator is held at the top level rather than inside the memoized segment, so the final trim behaves exactly like a single render;
  • committed segments are wrapped in memo, so React stops re-walking the prefix at all - this is what took the worst flush from 38 ms to 3.8 ms;
  • a width/palette change, or content that is not an extension of what was rendered (chat reload, edit), drops the cache and falls back to a full render.

Both the root message (ContentWithMarkdown) and streaming subagents (AgentMessage) now use it.

Measured (same machine, bun)

Root message stream:

Stream Flushes Total render CPU Per flush Worst flush
200 KB 512 138 s → 0.62 s (223×) 270 ms → 1.2 ms 14 s → 3.8 ms
50 KB 128 6.4 s → 0.14 s (44×) 50 ms → 1.1 ms 109 ms → 3.0 ms
10 KB 25 289 ms → 30 ms (10×) 11.6 ms → 1.2 ms 19 ms → 2.0 ms

Eight subagents streaming at once, 50 KB each:

Before After
Render work per flush 275 ms 8 ms
Worst flush 576 ms 11 ms
Total over 85 flushes 23.4 s 0.68 s (35×)

Verification

The new test renders both paths through a real terminal (@opentui/core/testing) and compares captureCharFrame() - the actual characters on screen - at every line boundary of a document containing headings, paragraphs with emphasis/links/inline code, bullets, an ordered list, a blockquote, a table and a fenced code block. Frames are byte-identical, including inside an unterminated fence and at paragraph→list boundaries.

It also asserts a marker string is present in the frame, so an empty or error frame cannot pass the comparison vacuously (this caught a bug in the first version of the test, where both sides were rendering an error boundary).

  • bun test (full CLI suite): 4202 pass, 0 fail across 279 files
  • bun run typecheck: exit 0

Notes

renderMarkdownNodes is exported from the renderer for this, and createRenderState gained a key prefix so segment node keys stay unique without wrapper elements. renderMarkdown itself is unchanged.

Salhi-zh and others added 3 commits October 23, 2026 12:32
The live message is re-rendered on every flush while it grows, and each
render re-parsed the whole answer: `renderMarkdown` costs ~3ms per
kilobyte, so a 200KB answer spent ~480ms of parsing per render and a
600ms+ worst-case flush on the render thread. Total parsing for one
200KB stream, 512 flushes, was 138 seconds.

Streaming text only ever grows, so everything before the last complete
block boundary is parsed once and kept. Only the tail - the block being
written - is re-rendered per flush, and committed segments are memoized
so React stops re-walking them.

Verified against a real terminal, not just the node tree: the new test
renders both paths with @opentui/core and compares `captureCharFrame()`
at every line boundary of a document containing headings, lists, a
quote, a table and a code fence, including inside an unterminated
fence. Frames are identical.

200KB stream, 512 flushes: 138s -> 0.62s total, worst flush 14s -> 3.8ms.
50KB: 44x. 10KB: 10x.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
AgentMessage parsed its entire body with renderMarkdown on every render
while the agent streamed, and Freebuff usually runs several subagents at
once, so each flush paid several full parses: 8 agents x 50KB cost
~275ms of render work per flush, 576ms worst case.

Route the streaming case through the same cached renderer as the root
message. The palette is memoized so a new object identity does not
invalidate the cache on every render, and both new hooks sit above the
early return for missing agent info.

8 agents x 50KB: 275ms -> 8ms per flush, worst 576ms -> 11ms.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
A frame in the transcript costs the renderer roughly linearly in node
count, regardless of what changed - a static 1,500-node tree measures
~60ms per frame while 129KB in a single node measures ~4ms. The
renderer emitted one span per code line and per styled run, so a 50KB
answer reached 13,542 nodes.

Merge adjacent styled runs with identical props, including runs
separated only by whitespace (the terminal paints the separator in the
same style, verified frame-for-frame). A run that stops at a node it
cannot merge re-emits the whitespace it buffered: dropping it had joined
a paragraph onto the last line of the preceding code block, which the
new tests pin.

Nodes for a 50KB answer: 13,542 -> 11,518. Per-flush render in a
scrollbox with 30 history messages: 67ms -> 60ms. The remaining cost is
structural - one node per line/cell - not something this pass can fix.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@Salhi-zh

Copy link
Copy Markdown
Contributor Author

Third commit added: perf(cli): merge same-styled markdown runs to cut nodes per frame.

Why node count: a frame in the transcript costs the renderer ~linearly in node count regardless of what changed — a static 1,500-node tree measures ~60 ms per frame, while 129 KB in a single node measures ~4 ms. Growing content adds nothing on top. So memoization can't help; only fewer nodes can.

What it does: merges adjacent styled runs with identical props, including runs separated only by whitespace (verified frame-for-frame that the terminal paints the separator in the same style). A run that stops at a non-mergeable node re-emits the whitespace it buffered — dropping it had joined a paragraph onto the last line of the preceding code block; two new tests pin that.

Result: 13,542 → 11,518 nodes for a 50 KB answer; per-flush render in a scrollbox with 30 history messages 67 ms → 60 ms.

Honest limit: this is ~11%, not a fix for the layout wall. The remaining cost is structural — one node per code line, table cell and list marker — and removing it means either virtualizing the transcript scrollbox or giving blocks their own renderables with element-level styling. Both change scrolling/visual behaviour, so they belong in a separate, deliberate change rather than this PR.

@codebuff-team

Copy link
Copy Markdown
Contributor

The problem is real and the measurements are convincing: parsing the entire accumulated answer on every flush is quadratic, and the benchmark table makes the case well. I also like that the equivalence test renders both paths through a real terminal and diffs captureCharFrame() at every line boundary rather than just the final state - that's the right kind of test for this change.

A few things make this hard to port as-is:

  1. State mutation during render. renderStreamingMarkdownCached mutates cache.current (prefix, segments, fenceOpen) as a side effect of a call made in the component body (content-with-markdown.tsx, message-with-agents.tsx). That's fine for a plain useRef-backed memo cache as long as render is only ever invoked once per commit, but it's fragile if the renderer ever double-invokes render (StrictMode-style checks, suspense retries). Worth a comment explaining why that's safe here, or moving the mutation into an effect/useMemo with a stable dependency.

  2. coalesceNodes in markdown-renderer.tsx is intricate (merging same-styled runs across whitespace gaps, buffering separators) and has no direct unit tests of its own - it's only exercised indirectly through the streaming equivalence tests and two regression tests at the bottom of the new test file. Given it also now runs on the non-streaming path (renderMarkdown), a bug here affects every markdown render, not just streaming ones. I'd want dedicated tests for it.

  3. Boundary heuristic coverage. isSafeBoundary/fence tracking handles backtick fences and list/quote lines by regex on the immediately adjacent line, but doesn't appear to handle tilde fences (~~~), fenced blocks nested inside lists/quotes, or four-or-more backtick fences. None of these are tested, and they're exactly the cases where a wrong boundary would silently produce different output under streaming than a full render.

This is a strong profiling job and the direction (cache the parsed prefix, memoize committed segments) is correct, but the diff is large (600 lines across two non-trivial new code paths) for a path this central, and I'd want the edge cases above nailed down with tests before porting. Consider splitting the coalesceNodes change (which affects all rendering) from the streaming-cache change, and adding fence/nesting edge-case tests.

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written labels Oct 23, 2026
@obadakhalili
obadakhalili merged commit c449b6a into CodebuffAI:main Oct 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants