Skip to content

perf(tui): render streamed markdown incrementally - #315

Merged
liuedcson merged 2 commits into
mainfrom
fix/tui-streaming-incremental-render
Oct 28, 2026
Merged

liuedcson merged 2 commits into
mainfrom
fix/tui-streaming-incremental-render

Conversation

@liuedcson

@liuedcson liuedcson commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

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 cost O(N²) in total. Two smaller contributors sat on top of that — the LaTeX block tokenizer is a Marked block-level extension, so its start() re-scanned the unconsumed remainder at every paragraph boundary (O(paragraphs × remaining length)), and the product path rebuilt the Markdown component 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)

Markdown now 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:

  • the cut sits directly after a top-level space token, which Marked emits only for a real blank line;
  • the following token 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 absorb what follows a blank line and would rewrite the tokens before the cut;
  • document-wide parse facts are carried, not guessed: a block-math opener or a reference definition makes the document unsafe to split at all, and an open reference label or inline <a> / <pre|code|kbd|script> depth stops the prefix where the tail would otherwise parse differently;
  • the existing document-wide no-wrap rule is preserved: 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 Container and 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)

AssistantMessageComponent keeps one Markdown per 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 the renderDirtyStart contract.
  • test/markdown-stream-finality.test.ts — the certification rules themselves, including the cases that must be rejected (1 that 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:

Input Before After
60 KB prose 1 437 ms 18 ms
60 KB mixed with a table 2 095 ms 52 ms
60 KB, 50% code 462 ms 13 ms
146 KB mixed (1 246 frames) 12 340 ms 275 ms
60 KB single unbounded paragraph 403 ms 411 ms (no cut, unchanged)

Known limits (documented in docs/tui-rendering-pipeline.md §4.2)

  • A document that contains block math or a reference definition renders whole on every frame. These are sticky by design: a pending latexBlock can re-clip the paragraph in front of it, and a definition is visible to every block.
  • No certifiable cut means no reuse: one unbounded paragraph, one unbounded code block, or a table that keeps growing. The output stays exact; the cost stays what it was.
  • Assembling the flat 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.
  • Step presentation's wrapper (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 fail
  • apps/cli: assistant-message, step-message, mermaid, acceptance snapshots/interactions — 74 pass, 0 fail
  • pnpm run check subset relevant to this change (Biome, tsgo, ts-imports, layer/ui/workspace/pinned-deps/tui-no-ai/public-boundary/no-observability) — pass
  • git diff --check — clean

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
liuedcson force-pushed the fix/tui-streaming-incremental-render branch from 2ba60b8 to 97f2948 Compare October 28, 2026 07:22
@liuedcson liuedcson changed the title fix(tui): skip the block-math scan when a document has no math opener perf(tui): render streamed markdown incrementally Oct 28, 2026
@liuedcson
liuedcson merged commit 5b7efa3 into main Oct 28, 2026
2 checks passed
@liuedcson
liuedcson deleted the fix/tui-streaming-incremental-render branch October 28, 2026 07:33
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant