Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 62 additions & 24 deletions pkg/tui/components/markdown/incremental.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
35 changes: 33 additions & 2 deletions pkg/tui/components/markdown/incremental_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading