Repository navigation
perf(tui): render streamed markdown incrementally - #315
Merged
Merged
Conversation
liuedcson
requested review from
MelodyVAR,
Uking-xxx and
ZouR-Ma
as code owners
October 27, 2026 14:05
A streamed assistant message re-rendered the whole document on every delta: each frame re-lexed, re-rendered and re-wrapped text that had already ended, so the cumulative cost grew quadratically with the message length. Profiling a reported turn put most of the CPU inside `Markdown.render`, and the cost tracked the length of the message being streamed rather than the session. Keep the rows of blocks that are provably final and re-render only the mutable tail. A cut is certified only directly after a top-level `space` token, which Marked emits only for a real blank line, and only when the following block starts a new top-level block at column 0 with a finished first line. Lists, quotes, tables, fences and indented code are rejected because they can absorb what follows a blank line and would then rewrite the tokens before the cut. Document-wide parse facts are carried instead of guessed. A block-math opener or a reference definition makes the whole document unsafe to split; an open reference label and the inline `<a>`/`<pre|code|kbd|script>` depth stop the prefix at the point where the tail would otherwise be parsed differently. The document-wide no-wrap rule is preserved rather than replaced. Code rows seed the tail, and a later code row that collides with an already wrapped prose row re-renders the frame instead of keeping rows that should have been clipped. `renderDirtyStart` reports the first row that may differ, so the container and the screen diff keep working on the tail, and arrays handed to callers are never mutated. The block-math tokenizer also stops scanning documents without an opener: the tokenizer hint and the streaming hazard scan now share one predicate (`findBlockMathStart`), so the literals and the pattern cannot drift apart. Equivalence is the acceptance bar. Every prefix of 35 adversarial fixtures, sampled repository documents and 200 seeded block combinations renders byte-identically to a freshly constructed instance, and a deterministic work counter shows a 60 KB mixed document now hands the lexer about twice the document length in total instead of roughly half of its square. 146 KB at 120 B/delta: 12 340 ms -> 275 ms over 1 246 frames, measured through the component path. Inputs with no certifiable cut keep rendering whole and are documented as the remaining limit: one unbounded paragraph, one unbounded code block, or a document that contains block math or a reference definition.
`AssistantMessageComponent.updateContent` cleared its container and rebuilt the `Markdown` children for every delta, so the renderer's certified prefix died with the component on every frame and the incremental renderer could never be reached from the product path. Keep one instance per content run, matched by kind (text or thinking), and only re-point it at the new text. Slots are rebuilt when padding changes, because padding is fixed at construction, and a run whose kind changed gets a new instance instead of a stale one. The transform now reads the streaming flag and the transformer list at call time. A reused instance keeps the closure it was constructed with, and the last frame of a reply renders in "not streaming" mode, where Mermaid warnings and final rendering appear. Measured on the real component path, 146 KB at 120 B/delta over 1 246 frames: 12 340 ms -> 275 ms.
liuedcson
force-pushed
the
fix/tui-streaming-incremental-render
branch
from
October 28, 2026 07:22
2ba60b8 to
97f2948
Compare
liuedcson
added a commit
that referenced
this pull request
Oct 28, 2026
…t and source A streamed reply re-renders the same fenced blocks over and over. The markdown renderer treats a theme as a set of pure functions, so it cannot deduplicate the work that happens inside one: every frame that re-renders a finished code block highlights it again from scratch. Measured with the production theme and the real highlight.js pipeline, 40 KB at 120 columns over 120-byte deltas: | document | before | after | | --- | --- | --- | | block math (renders whole every frame) | 1 154 ms | 211 ms | 5.5x | | reference definition (renders whole every frame) | 1 206 ms | 237 ms | 5.1x | | prose containing a fence | 29 ms | 9 ms | 3.2x | | prose with no fence | 10 ms | 9 ms | 1.15x | The last row is the honest bound: a document without a fence never calls the highlighter, so the memo cannot help it and does not claim to. The cache key has three parts, and each one is load-bearing: - **The resolved theme instance.** `theme` is a proxy whose identity never changes, so keying on it would serve the previous theme's colours forever. Every replacement funnels through `setGlobalTheme`, which stores the backing instance under a `Symbol.for` key. - **The grammar set revision.** `highlight.js` resolves embedded sub-languages through one registry, so the same source and language highlight differently before and after the remaining grammars load: a JavaScript template literal only gets HTML colouring once the XML grammar exists. The product loads those grammars after startup (`loadAllHighlightLanguages`), so a block highlighted during the first frames would otherwise keep its pre-load rendering for the rest of the session. The revision is bumped when the loader resolves, which retires the old keys. - **Language and source.** The same text highlights differently as TypeScript and as JSON. Two bounds keep it from becoming the leak it exists to avoid: - **Stored on the second request, not the first.** A fence that is still being written produces a new key on every delta, and each of those prefixes is dead as soon as the next one arrives. Caching them filled the budget with hundreds of intermediate versions of a single block (measured: 33 MiB of heap for one 56 KB fence) and evicted the finished blocks the memo is for. A finished block repeats its key, so it is stored on the second miss. The same stream now retains 1.2 MiB. - **Bounded by payload, not entry count** (4 MiB per theme, FIFO), and sources above 64 Ki UTF-16 code units are never retained, so whole-file callers (read and write tools) cannot pin a file in memory. Every result is a copy: `core/tools/write.ts` keeps the returned array and appends to or overwrites its elements while a file streams, so handing out the cached array would corrupt the cache. Both entry points (`highlightCode` and the markdown theme's `highlightCode`) write into the same memo, so their `catch` fallbacks now agree. Before, an exception left one path with plain text and the other with `mdCodeBlock` colouring, and the result depended on which caller ran first. `packages/coding-agent/test/theme-highlight-cache.test.ts` covers the cases where an entry could be served for the wrong inputs or outlive its budget: a theme switch (labelled and unlabelled fences), the grammar revision, an explicitly passed theme not sharing entries with the global one, a mutating caller not reaching the cache, language in the key, one-off streaming keys not consuming the budget, a repeated block being served from it, eviction by payload, the source limit, and agreement between the two entry points including the failure fallback. Each guard was checked by mutating the implementation: storing every miss fails two tests, dropping the grammar revision fails one, removing the payload budget fails one, keying on the proxy fails two, handing out the cached array fails one, and dropping the `defaultStylePrefix` reset fails two. Rebased onto the tree that already contains #315; the earlier per-token render cache is not part of this change, because after #315 it only paid where that PR falls back to a whole-document render.
liuedcson
added a commit
that referenced
this pull request
Oct 28, 2026
…caches (#319) * fix(tui): reset the derived style prefix and honour the render switch Two caches in the markdown renderer were not tied to the inputs they derive from. `defaultStylePrefix` is the ANSI prefix appended after every inline span that ends inside a style. It is derived from the theme, but `invalidate()` — which is how a theme or settings change reaches a live component — did not clear it, so a switch that stops applying a style spliced the previous theme's escape into the new output. Visible once the new theme drops the style: with bold turned off, `a line with **bold** inside it` renders as `bold\u001b[1m inside it`, with a bold run that never terminates in the theme's own terms. `setIncrementalRenderDisabled(true)` is documented and exported as the seam that gives the render-equivalence harness an unoptimized path, and every other component honours it — `Container`, `Spacer`, `Box`, `Loader`, the screen diff, and the assistant message's zone cache. `Markdown` did not, so the "full render" baseline it produced was still reusing a certified prefix. It now renders the document whole and retains nothing while the switch is set. `packages/tui/test/markdown-invalidate.test.ts` covers both: a theme switch must match a freshly constructed instance (before the fix, one line of the wrapped document keeps the stale escape), and with the switch set the renderer must lex every frame with no prefix reuse while still matching a cold render. Reverting either fix fails its test. The same file's fixture list also gains the block shapes that the per-token cache investigation contributed: a balanced inline anchor followed by an autolink, an ordered list starting at 3, a loose list, a setext `=` heading, a nested blockquote, CJK with emoji and full-width punctuation, inline LaTeX, and a long single paragraph. Each is checked against a cold render at every prefix and three widths, like the rest of the suite. * perf(coding-agent): memoize syntax highlighting per theme, grammar set and source A streamed reply re-renders the same fenced blocks over and over. The markdown renderer treats a theme as a set of pure functions, so it cannot deduplicate the work that happens inside one: every frame that re-renders a finished code block highlights it again from scratch. Measured with the production theme and the real highlight.js pipeline, 40 KB at 120 columns over 120-byte deltas: | document | before | after | | --- | --- | --- | | block math (renders whole every frame) | 1 154 ms | 211 ms | 5.5x | | reference definition (renders whole every frame) | 1 206 ms | 237 ms | 5.1x | | prose containing a fence | 29 ms | 9 ms | 3.2x | | prose with no fence | 10 ms | 9 ms | 1.15x | The last row is the honest bound: a document without a fence never calls the highlighter, so the memo cannot help it and does not claim to. The cache key has three parts, and each one is load-bearing: - **The resolved theme instance.** `theme` is a proxy whose identity never changes, so keying on it would serve the previous theme's colours forever. Every replacement funnels through `setGlobalTheme`, which stores the backing instance under a `Symbol.for` key. - **The grammar set revision.** `highlight.js` resolves embedded sub-languages through one registry, so the same source and language highlight differently before and after the remaining grammars load: a JavaScript template literal only gets HTML colouring once the XML grammar exists. The product loads those grammars after startup (`loadAllHighlightLanguages`), so a block highlighted during the first frames would otherwise keep its pre-load rendering for the rest of the session. The revision is bumped when the loader resolves, which retires the old keys. - **Language and source.** The same text highlights differently as TypeScript and as JSON. Two bounds keep it from becoming the leak it exists to avoid: - **Stored on the second request, not the first.** A fence that is still being written produces a new key on every delta, and each of those prefixes is dead as soon as the next one arrives. Caching them filled the budget with hundreds of intermediate versions of a single block (measured: 33 MiB of heap for one 56 KB fence) and evicted the finished blocks the memo is for. A finished block repeats its key, so it is stored on the second miss. The same stream now retains 1.2 MiB. - **Bounded by payload, not entry count** (4 MiB per theme, FIFO), and sources above 64 Ki UTF-16 code units are never retained, so whole-file callers (read and write tools) cannot pin a file in memory. Every result is a copy: `core/tools/write.ts` keeps the returned array and appends to or overwrites its elements while a file streams, so handing out the cached array would corrupt the cache. Both entry points (`highlightCode` and the markdown theme's `highlightCode`) write into the same memo, so their `catch` fallbacks now agree. Before, an exception left one path with plain text and the other with `mdCodeBlock` colouring, and the result depended on which caller ran first. `packages/coding-agent/test/theme-highlight-cache.test.ts` covers the cases where an entry could be served for the wrong inputs or outlive its budget: a theme switch (labelled and unlabelled fences), the grammar revision, an explicitly passed theme not sharing entries with the global one, a mutating caller not reaching the cache, language in the key, one-off streaming keys not consuming the budget, a repeated block being served from it, eviction by payload, the source limit, and agreement between the two entry points including the failure fallback. Each guard was checked by mutating the implementation: storing every miss fails two tests, dropping the grammar revision fails one, removing the payload budget fails one, keying on the proxy fails two, handing out the cached array fails one, and dropping the `defaultStylePrefix` reset fails two. Rebased onto the tree that already contains #315; the earlier per-token render cache is not part of this change, because after #315 it only paid where that PR falls back to a whole-document render. --------- Co-authored-by: liuedcson <332840681+liuedcson@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
Rendering a long streamed assistant message pinned one CPU core on the CLI. Profiling a reported turn put most of the CPU inside
Markdown.render, and the cost tracked the length of the message being streamed rather than the session size.The cause was not one slow regex but the shape of the work: every delta re-lexed, re-rendered and re-wrapped the whole document, so rendering an
N-character reply costO(N²)in total. Two smaller contributors sat on top of that — the LaTeX block tokenizer is a Marked block-level extension, so itsstart()re-scanned the unconsumed remainder at every paragraph boundary (O(paragraphs × remaining length)), and the product path rebuilt theMarkdowncomponent for every delta, so no instance-level state could ever survive.What changed
1. Certified-prefix incremental rendering (
packages/tui/src/components/markdown.ts,markdown-stream.ts)Markdownnow keeps the rows of blocks that are provably final and re-renders only the mutable tail. A cut is certified only when it can be shown that restarting a fresh Lexer there equals rendering the whole document:spacetoken, which Marked emits only for a real blank line;<a>/<pre|code|kbd|script>depth stops the prefix where the tail would otherwise parse differently;renderDirtyStartreports the first row that may differ, soContainerand the screen diff work on the tail, and arrays already handed to callers are never mutated.2. One predicate for block math (
findBlockMathStart)The tokenizer hint and the streaming hazard scan share a single definition of "a block-math opener starts at a line start after at most three spaces", so the literals and the pattern cannot drift apart. Documents without an opener skip the pattern entirely.
3. Instance reuse in the product path (
apps/cli/.../assistant-message.ts)AssistantMessageComponentkeeps oneMarkdownper content run, matched by kind, and only re-points it at the new text. Padding changes rebuild the slots; the transform reads the streaming flag and the transformer list at call time so the final frame still renders in settled mode.Correctness
Equivalence is the acceptance bar, and it is asserted, not argued:
test/markdown-stream-prefix.test.ts— every prefix of 35 adversarial fixtures at three widths, sampled repository documents, and 200 seeded block combinations, compared byte for byte against a freshly constructed instance. The fixtures are the constructs that break a naive "freeze everything but the last block" cache: setext underlines reaching back across blocks, lists merging across blank lines, ordered lists renumbering, tables gaining a row and widening a column, reference definitions that appear later (including labels spanning blank lines), block HTML, unbalanced inline anchors, indented code continuing across a blank line, partial and unclosed fences, and code rows that collide with an earlier prose row. Plus delivery-shape coverage (1/2/7/120-character deltas), replacement, width change,invalidate(), and therenderDirtyStartcontract.test/markdown-stream-finality.test.ts— the certification rules themselves, including the cases that must be rejected (1that can still become a list marker, an indented witness, a table/fence/quote witness) and the guards (sticky block math, open reference label, unbalanced anchor, escaped bracket, tag text inside a fence, repeated definition that Marked drops).test/markdown-stream-work.test.ts— deterministic work counters instead of wall clock, so a loaded runner cannot fail a real optimization or pass a regression: a 60 KB mixed document hands the lexer about twice the document length in total (a per-frame full lex would be ~250 passes), doubling the document multiplies lex/render/wrap by less than 2.5, and each fallback (block math, reference definition, no certifiable cut, code-row collision) asserts "never reuses a prefix" and still matches a cold render.Results
Measured through the component path, 120 columns, 120 B/delta, cumulative time including the full pipeline:
Known limits (documented in
docs/tui-rendering-pipeline.md§4.2)latexBlockcan re-clip the paragraph in front of it, and a definition is visible to every block.string[]each frame is still a pointer copy proportional to the transcript, and the screen diff is unchanged. Both are accounted separately and do not disappear with this change.renderNativeRun:hideCompleteFences, blank-row collapsing,trimOuterRows) still scans every native row per frame. That cost is independent and needs its own change.Tests
packages/tui:node --test test/*.test.ts— 1046 pass, 0 failapps/cli:assistant-message,step-message,mermaid, acceptance snapshots/interactions — 74 pass, 0 failpnpm run checksubset relevant to this change (Biome, tsgo, ts-imports, layer/ui/workspace/pinned-deps/tui-no-ai/public-boundary/no-observability) — passgit diff --check— clean