From 6bec609a7c85ef1e95350e0fe351d5c4cd5deb13 Mon Sep 17 00:00:00 2001 From: Christopher Petito Date: Sat, 25 Jul 2026 14:44:10 +0200 Subject: [PATCH] perf(tui): bound progressive Markdown rendering --- pkg/tui/components/markdown/incremental.go | 86 +++++++++++++------ .../components/markdown/incremental_test.go | 35 +++++++- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/pkg/tui/components/markdown/incremental.go b/pkg/tui/components/markdown/incremental.go index e12108c74c..3b82677530 100644 --- a/pkg/tui/components/markdown/incremental.go +++ b/pkg/tui/components/markdown/incremental.go @@ -37,8 +37,20 @@ type IncrementalRenderer struct { // fallback is used for the actual rendering work; it is reused across calls // so its parser pool (and chroma caches) stay warm. fallback *FastRenderer + + stats IncrementalStats +} + +// IncrementalStats reports deterministic parser work for scaling tests. +type IncrementalStats struct { + Calls uint64 + FullRenders uint64 + ParsedBytes uint64 } +// Stats returns cumulative work counters. +func (r *IncrementalRenderer) Stats() IncrementalStats { return r.stats } + // NewIncrementalRenderer creates a new incremental renderer with the given // terminal width. func NewIncrementalRenderer(width int) *IncrementalRenderer { @@ -56,22 +68,49 @@ func (r *IncrementalRenderer) Render(input string) (string, error) { return out, err } +// RenderedParts exposes the reusable rendered block prefix separately from the +// still-mutable trailing block. Consumers that retain lines can splice only +// MutableTail while StablePrefix grows monotonically during streaming. +type RenderedParts struct { + StablePrefix string + MutableTail string + CodeBlocks []CodeBlock +} + +// RenderParts renders input without concatenating the stable rendered prefix +// to the mutable tail. This avoids rescanning/copying an ever-growing ANSI +// string in viewport-oriented consumers. +func (r *IncrementalRenderer) RenderParts(input string) (RenderedParts, error) { + stable, tail, blocks, err := r.renderParts(input) + return RenderedParts{StablePrefix: stable, MutableTail: tail, CodeBlocks: blocks}, err +} + // RenderWithCodeBlocks behaves like Render but additionally returns the list // of fenced code blocks in the rendered output. Each entry's Line is the // 0-indexed line within the returned string where the block's copy label is // drawn. func (r *IncrementalRenderer) RenderWithCodeBlocks(input string) (string, []CodeBlock, error) { + stable, tail, blocks, err := r.renderParts(input) + if err != nil { + return "", nil, err + } + return r.joinPrefixAndTail(stable, tail), blocks, nil +} + +func (r *IncrementalRenderer) renderParts(input string) (string, string, []CodeBlock, error) { + r.stats.Calls++ if input == "" { r.inputPrefix = "" r.outputPrefix = "" r.codeBlocksPrefix = nil - return "", nil, nil + return "", "", nil, nil } // If the new input no longer starts with our cached prefix, the user (or a // retry) replaced earlier content; fall back to a full render. if r.inputPrefix == "" || !strings.HasPrefix(input, r.inputPrefix) { - return r.fullRender(input) + stable, tail, blocks, err := r.fullRenderParts(input) + return stable, tail, blocks, err } // Locate a fresh stable boundary anywhere up to the end of the current @@ -86,10 +125,11 @@ func (r *IncrementalRenderer) RenderWithCodeBlocks(input string) (string, []Code // concatenate. Cached prefix is unchanged. renderedTail, tailBlocks, err := r.fallback.RenderWithCodeBlocks(tail) if err != nil { - return r.fullRender(input) + stable, tail, blocks, fallbackErr := r.fullRenderParts(input) + return stable, tail, blocks, fallbackErr } - out := r.joinPrefixAndTail(r.outputPrefix, renderedTail) - return out, r.mergeCodeBlocks(r.outputPrefix, r.codeBlocksPrefix, tailBlocks), nil + r.stats.ParsedBytes += uint64(len(tail)) + return r.outputPrefix, renderedTail, r.mergeCodeBlocks(r.outputPrefix, r.codeBlocksPrefix, tailBlocks), nil } // We have a new boundary inside the tail. Render the new stable region @@ -98,7 +138,8 @@ func (r *IncrementalRenderer) RenderWithCodeBlocks(input string) (string, []Code newStableTail := tail[:boundary] renderedStableTail, stableBlocks, err := r.fallback.RenderWithCodeBlocks(newStableTail) if err != nil { - return r.fullRender(input) + stable, tail, blocks, fallbackErr := r.fullRenderParts(input) + return stable, tail, blocks, fallbackErr } newBlocks := r.mergeCodeBlocks(r.outputPrefix, r.codeBlocksPrefix, stableBlocks) r.inputPrefix += newStableTail @@ -107,14 +148,16 @@ func (r *IncrementalRenderer) RenderWithCodeBlocks(input string) (string, []Code rest := tail[boundary:] if rest == "" { - return r.outputPrefix, cloneCodeBlocks(r.codeBlocksPrefix), nil + r.stats.ParsedBytes += uint64(len(tail)) + return r.outputPrefix, "", cloneCodeBlocks(r.codeBlocksPrefix), nil } renderedRest, restBlocks, err := r.fallback.RenderWithCodeBlocks(rest) if err != nil { - return r.fullRender(input) + stable, tail, blocks, fallbackErr := r.fullRenderParts(input) + return stable, tail, blocks, fallbackErr } - out := r.joinPrefixAndTail(r.outputPrefix, renderedRest) - return out, r.mergeCodeBlocks(r.outputPrefix, r.codeBlocksPrefix, restBlocks), nil + r.stats.ParsedBytes += uint64(len(tail)) + return r.outputPrefix, renderedRest, r.mergeCodeBlocks(r.outputPrefix, r.codeBlocksPrefix, restBlocks), nil } // SetWidth updates the renderer width. Width changes invalidate the cache @@ -138,46 +181,41 @@ func (r *IncrementalRenderer) Reset() { r.codeBlocksPrefix = nil } -// fullRender renders input from scratch, refreshes the cache, and returns the -// result. To avoid rendering input twice (once whole, once for the cached -// prefix), we split input at its longest stable boundary and render the two -// pieces separately, then join. The two render calls on smaller inputs are -// faster than one big render plus a separate prefix render, and the prefix -// piece can be reused as outputPrefix. -func (r *IncrementalRenderer) fullRender(input string) (string, []CodeBlock, error) { +func (r *IncrementalRenderer) fullRenderParts(input string) (string, string, []CodeBlock, error) { + r.stats.FullRenders++ + r.stats.ParsedBytes += uint64(len(input)) boundary := stableBoundary(input) if boundary <= 0 { out, blocks, err := r.fallback.RenderWithCodeBlocks(input) if err != nil { - return "", nil, err + return "", "", nil, err } r.inputPrefix = "" r.outputPrefix = "" r.codeBlocksPrefix = nil - return out, blocks, nil + return "", out, blocks, nil } prefix := input[:boundary] rest := input[boundary:] renderedPrefix, prefixBlocks, err := r.fallback.RenderWithCodeBlocks(prefix) if err != nil { - return "", nil, err + return "", "", nil, err } if rest == "" { r.inputPrefix = prefix r.outputPrefix = renderedPrefix r.codeBlocksPrefix = prefixBlocks - return renderedPrefix, cloneCodeBlocks(prefixBlocks), nil + return renderedPrefix, "", cloneCodeBlocks(prefixBlocks), nil } renderedRest, restBlocks, err := r.fallback.RenderWithCodeBlocks(rest) if err != nil { - return "", nil, err + return "", "", nil, err } r.inputPrefix = prefix r.outputPrefix = renderedPrefix r.codeBlocksPrefix = prefixBlocks - out := r.joinPrefixAndTail(renderedPrefix, renderedRest) - return out, r.mergeCodeBlocks(renderedPrefix, prefixBlocks, restBlocks), nil + return renderedPrefix, renderedRest, r.mergeCodeBlocks(renderedPrefix, prefixBlocks, restBlocks), nil } // joinPrefixAndTail concatenates a previously rendered prefix and a freshly diff --git a/pkg/tui/components/markdown/incremental_test.go b/pkg/tui/components/markdown/incremental_test.go index 0cb1c49a52..caf6d906d2 100644 --- a/pkg/tui/components/markdown/incremental_test.go +++ b/pkg/tui/components/markdown/incremental_test.go @@ -5,13 +5,44 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestIncrementalRendererMatchesFullRender feeds a representative streaming -// document chunk by chunk through the incremental renderer and checks that the +func TestIncrementalRenderedPartsMatchJoinedOutputAtEveryStep(t *testing.T) { + t.Parallel() + chunks := []string{"unfinished *em", "phasis* and [li", "nk](https://example.com)\n\n", "```go\nfmt.Print(\"λ界\")", "\n```\n\n- one", "\n- two\n\nfinal"} + inc := NewIncrementalRenderer(64) + var input strings.Builder + for i, chunk := range chunks { + input.WriteString(chunk) + parts, err := inc.RenderParts(input.String()) + require.NoError(t, err) + got := inc.joinPrefixAndTail(parts.StablePrefix, parts.MutableTail) + expected, err := NewFastRenderer(64).Render(input.String()) + require.NoError(t, err) + require.Equal(t, expected, got, "step %d", i) + } +} + // final output matches a single full render. This is the correctness contract // the optimization must preserve. +func TestIncrementalRendererScalingBound(t *testing.T) { + const chunk = "Paragraph with **markdown**, Unicode λ界, and `code`.\n\n" + for _, target := range []int{1 << 10, 10 << 10, 50 << 10, 100 << 10, 200 << 10} { + r := NewIncrementalRenderer(100) + var input strings.Builder + for input.Len() < target { + input.WriteString(chunk) + _, err := r.Render(input.String()) + require.NoError(t, err) + } + stats := r.Stats() + assert.LessOrEqual(t, float64(stats.ParsedBytes)/float64(input.Len()), 3.5) + assert.LessOrEqual(t, stats.FullRenders, uint64(2)) + } +} + func TestIncrementalRendererMatchesFullRender(t *testing.T) { t.Parallel()