Repository navigation
perf(cli): render streaming markdown incrementally - #1549
Conversation
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>
|
Third commit added: 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. |
|
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 A few things make this hard to port as-is:
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 |
Problem
ContentWithMarkdownre-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.renderMarkdowncosts roughly 3 ms per kilobyte, so: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.
AgentMessagedid 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.tskeeps that prefix parsed and re-renders only the tail:\n\nseparator is held at the top level rather than inside the memoized segment, so the final trim behaves exactly like a single render;memo, so React stops re-walking the prefix at all - this is what took the worst flush from 38 ms to 3.8 ms;Both the root message (
ContentWithMarkdown) and streaming subagents (AgentMessage) now use it.Measured (same machine,
bun)Root message stream:
Eight subagents streaming at once, 50 KB each:
Verification
The new test renders both paths through a real terminal (
@opentui/core/testing) and comparescaptureCharFrame()- 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 filesbun run typecheck: exit 0Notes
renderMarkdownNodesis exported from the renderer for this, andcreateRenderStategained a key prefix so segment node keys stay unique without wrapper elements.renderMarkdownitself is unchanged.