fix(ui): cache glamour renderers

Christian Rocha created

The theme prep overhaul in 755f6fa made the main markdown renderer and
thinking block markdown renderer heavier, which exasperated a gap in
perf. Basically every token chunk would clear would clear the per-item
render cache and force a re-render.

This update memoizes renderers with markdown and should generally
give us a nice increase in perf compared to what we had before the theme
rendering.

Change summary

internal/ui/common/markdown.go | 36 ++++++++++++++++++++++++++++++++++--
internal/ui/model/ui.go        |  1 +
2 files changed, 35 insertions(+), 2 deletions(-)

Detailed changes

internal/ui/common/markdown.go 🔗

@@ -2,6 +2,7 @@ package common
 
 import (
 	"image/color"
+	"sync"
 
 	"charm.land/glamour/v2"
 	"github.com/alecthomas/chroma/v2/formatters"
@@ -18,24 +19,55 @@ func init() {
 	formatters.Register(formatterName, xchroma.Formatter(zero, nil))
 }
 
+var (
+	mdCacheMu    sync.Mutex
+	mdCache      = map[int]*glamour.TermRenderer{}
+	quietMDCache = map[int]*glamour.TermRenderer{}
+)
+
 // MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
-// the given styles and width.
+// the given styles and width. Renderers are memoized per width and shared
+// across callers; call InvalidateMarkdownRendererCache when the active
+// styles change.
 func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
+	mdCacheMu.Lock()
+	defer mdCacheMu.Unlock()
+	if r, ok := mdCache[width]; ok {
+		return r
+	}
 	r, _ := glamour.NewTermRenderer(
 		glamour.WithStyles(sty.Markdown),
 		glamour.WithWordWrap(width),
 		glamour.WithChromaFormatter(formatterName),
 	)
+	mdCache[width] = r
 	return r
 }
 
 // QuietMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
-// (plain text with structure) and the given width.
+// (plain text with structure) and the given width. Renderers are memoized per
+// width and shared across callers.
 func QuietMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
+	mdCacheMu.Lock()
+	defer mdCacheMu.Unlock()
+	if r, ok := quietMDCache[width]; ok {
+		return r
+	}
 	r, _ := glamour.NewTermRenderer(
 		glamour.WithStyles(sty.QuietMarkdown),
 		glamour.WithWordWrap(width),
 		glamour.WithChromaFormatter(formatterName),
 	)
+	quietMDCache[width] = r
 	return r
 }
+
+// InvalidateMarkdownRendererCache drops every cached renderer. Call this
+// whenever the active styles change so subsequent renderers pick up the new
+// ansi.StyleConfig.
+func InvalidateMarkdownRendererCache() {
+	mdCacheMu.Lock()
+	defer mdCacheMu.Unlock()
+	mdCache = map[int]*glamour.TermRenderer{}
+	quietMDCache = map[int]*glamour.TermRenderer{}
+}

internal/ui/model/ui.go 🔗

@@ -3116,6 +3116,7 @@ func (m *UI) refreshStyles() {
 	)
 	m.todoSpinner.Style = t.Pills.TodoSpinner
 	m.status.help.Styles = t.Help
+	common.InvalidateMarkdownRendererCache()
 	m.chat.InvalidateRenderCaches()
 }