perf(chat): skip re-parsing the rendered chat when nothing has changed

Christian Rocha and Charm Crush created

Each frame the chat list is turned into a string with ANSI color codes
that we then have to parse back into cells before drawing. When the
chat list output is the same as the previous frame, we now reuse the
cells we already produced and skip the parsing step.

Co-Authored-By: Charm Crush <crush@charm.land>

Change summary

internal/ui/model/chat.go                 | 113 ++++++++
internal/ui/model/chat_draw_cache_test.go | 334 +++++++++++++++++++++++++
2 files changed, 446 insertions(+), 1 deletion(-)

Detailed changes

internal/ui/model/chat.go ๐Ÿ”—

@@ -63,6 +63,32 @@ type Chat struct {
 	// follow is a flag to indicate whether the view should auto-scroll to
 	// bottom on new messages.
 	follow bool
+
+	// drawCache memoizes the decoded form of the last list.Render output so
+	// repeat frames with byte-identical content skip the per-cell ANSI
+	// reparse that uv.StyledString.Draw performs every call. See F9
+	// (docs/notes/2026-05-12-chat-rendering-perf.md ยง4.8). Bounded to one
+	// entry; invalidated implicitly by string inequality on the next Draw.
+	drawCache *chatDrawCache
+}
+
+// chatDrawCache holds the pre-decoded form of the last list.Render output.
+// The cache is keyed by the rendered string and the screen's width method
+// (graphemes vs wcwidth pick different decoders inside ultraviolet's
+// printString, so a cached buffer is only valid for the method it was
+// decoded with). The cached buffer is independent of the draw area, so
+// resize / scroll changes that produce the same string still hit. We cannot
+// use uv.StyledString.Lines because it bottoms out at the first iteration
+// against a zero-bounds rectangle (see ultraviolet styled.go line 45 โ€” the
+// shared printString loop's `y >= bounds.Max.Y` exit applies to the
+// line-building branch too). A Buffer of the rendered text's natural
+// dimensions is the cheapest correct shape: StyledString.Draw runs once on
+// miss to populate it, and Buffer.Draw is an O(cells) cell copy with no
+// ANSI re-parse on hit.
+type chatDrawCache struct {
+	rendered string
+	method   ansi.Method
+	buf      uv.ScreenBuffer
 }
 
 // NewChat creates a new instance of [Chat] that handles chat interactions and
@@ -89,8 +115,93 @@ func (m *Chat) Height() int {
 }
 
 // Draw renders the chat UI component to the screen and the given area.
+//
+// The list's rendered output is cached in decoded form (see chatDrawCache) so
+// that frames with byte-identical content skip the ANSI reparse that
+// uv.StyledString.Draw performs on every call. The cache is keyed by the
+// rendered string and the screen's width method; area / scroll changes do not
+// invalidate it.
 func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
-	uv.NewStyledString(m.list.Render()).Draw(scr, area)
+	rendered := m.list.Render()
+	method, ok := scr.WidthMethod().(ansi.Method)
+	if !ok {
+		// Width method isn't an ansi.Method (unlikely in practice โ€” both
+		// TerminalScreen and ScreenBuffer store ansi.Method). Fall back
+		// to the uncached path so behavior matches upstream exactly.
+		uv.NewStyledString(rendered).Draw(scr, area)
+		return
+	}
+	if m.drawCache == nil ||
+		m.drawCache.rendered != rendered ||
+		m.drawCache.method != method {
+		m.drawCache = newChatDrawCache(rendered, method)
+	}
+	drawCachedBuffer(scr, area, m.drawCache.buf)
+}
+
+// newChatDrawCache builds a chatDrawCache for the given rendered string by
+// running uv.StyledString.Draw into a fresh buffer sized to the text's
+// natural bounds under the active width method. This is the only place
+// ANSI decoding happens for cached frames โ€” subsequent draws reuse buf
+// via drawCachedBuffer.
+//
+// We can't use uv.StyledString.Bounds() here: it is hard-coded to
+// ansi.GraphemeWidth, while StyledString.Draw lays cells using the
+// destination buffer's WidthMethod (which we capture in `method`). For
+// strings where graphemes and wcwidth disagree (emoji ZWJ sequences,
+// some CJK, certain combining marks) the two answers diverge, leaving
+// the cached buffer either too small (trailing cells dropped on hit) or
+// too large (dead cells past the live content). Computing dimensions
+// with `method.StringWidth` per line matches what printString tallies
+// cell-by-cell, since both decode ANSI sequences and use the same width
+// method.
+func newChatDrawCache(rendered string, method ansi.Method) *chatDrawCache {
+	w, h := renderedBounds(rendered, method)
+	if w <= 0 {
+		w = 1
+	}
+	if h <= 0 {
+		h = 1
+	}
+	buf := uv.NewScreenBuffer(w, h)
+	buf.Method = method
+	uv.NewStyledString(rendered).Draw(buf, buf.Bounds())
+	return &chatDrawCache{
+		rendered: rendered,
+		method:   method,
+		buf:      buf,
+	}
+}
+
+// renderedBounds returns the (width, height) cell extent of rendered
+// when laid out by method. Width is the widest line's StringWidth (which
+// strips ANSI sequences and tallies cells via method, exactly like
+// printString); height is the line count. Both match what
+// uv.StyledString.Draw will write into a buffer whose WidthMethod is
+// method, so the cache buffer is always sized to fit the live content.
+func renderedBounds(rendered string, method ansi.Method) (w, h int) {
+	for line := range strings.SplitSeq(rendered, "\n") {
+		w = max(w, method.StringWidth(line))
+		h++
+	}
+	return w, h
+}
+
+// drawCachedBuffer blits a previously-decoded buffer into scr at area,
+// mirroring uv.StyledString.Draw's screen-mode behavior for the default
+// Wrap=false, Tail="" case. The clear loop matches StyledString.Draw line
+// 51-56; the buf.Draw call replaces the per-cell ANSI decode that
+// printString does on every uncached frame with a pure cell copy.
+func drawCachedBuffer(scr uv.Screen, area uv.Rectangle, buf uv.ScreenBuffer) {
+	// Clear the area first to match StyledString.Draw โ€” leftover cells
+	// from a previous frame outside the new content must be zeroed,
+	// because Buffer.Draw skips empty cells (it doesn't clear).
+	for y := area.Min.Y; y < area.Max.Y; y++ {
+		for x := area.Min.X; x < area.Max.X; x++ {
+			scr.SetCell(x, y, nil)
+		}
+	}
+	buf.Draw(scr, area)
 }
 
 // SetSize sets the size of the chat view port.

internal/ui/model/chat_draw_cache_test.go ๐Ÿ”—

@@ -0,0 +1,334 @@
+package model
+
+import (
+	"strconv"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/ui/chat"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/stretchr/testify/require"
+)
+
+// drawTestArea is a fixed area used by the cache tests; it must be at least
+// as large as the test items so we can compare every cell.
+func drawTestArea(width, height int) uv.Rectangle {
+	return uv.Rect(0, 0, width, height)
+}
+
+// renderToBuffer mirrors what Chat.Draw does at the screen layer so tests
+// can assert byte-equivalence with a fresh uv.NewStyledString render.
+func renderToBuffer(t *testing.T, c *Chat, w, h int) string {
+	t.Helper()
+	scr := uv.NewScreenBuffer(w, h)
+	c.Draw(scr, drawTestArea(w, h))
+	return scr.Render()
+}
+
+// TestChatDrawCache_HitOnIdenticalRender asserts that two consecutive Draws
+// against the same list output reuse the same chatDrawCache rather than
+// allocating a fresh one. Both the wrapper pointer and the embedded
+// RenderBuffer pointer must be stable across the second Draw โ€” that's what
+// proves no re-decoding happened.
+func TestChatDrawCache_HitOnIdenticalRender(t *testing.T) {
+	t.Parallel()
+
+	u := newTestUI()
+	u.chat.SetMessages(
+		testMessageItem{id: "a", text: "alpha"},
+		testMessageItem{id: "b", text: "beta"},
+	)
+	u.updateLayoutAndSize()
+
+	w, h := 80, 20
+	_ = renderToBuffer(t, u.chat, w, h)
+	require.NotNil(t, u.chat.drawCache, "first draw should populate cache")
+	firstCache := u.chat.drawCache
+	firstBuf := u.chat.drawCache.buf.RenderBuffer
+
+	_ = renderToBuffer(t, u.chat, w, h)
+	require.Same(t, firstCache, u.chat.drawCache,
+		"identical rendered string must reuse the same cache pointer")
+	require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
+		"identical rendered string must reuse the same RenderBuffer pointer")
+}
+
+// TestChatDrawCache_MissOnDifferentRender asserts that when the list output
+// changes, the cache is rebuilt and the new draw output matches a fresh
+// uv.NewStyledString render byte-for-byte.
+func TestChatDrawCache_MissOnDifferentRender(t *testing.T) {
+	t.Parallel()
+
+	u := newTestUI()
+	u.chat.SetMessages(
+		testMessageItem{id: "a", text: "alpha"},
+	)
+	u.updateLayoutAndSize()
+
+	w, h := 80, 20
+	_ = renderToBuffer(t, u.chat, w, h)
+	require.NotNil(t, u.chat.drawCache)
+	firstCache := u.chat.drawCache
+
+	// Replace the items so the rendered string differs.
+	u.chat.SetMessages(
+		testMessageItem{id: "c", text: "gamma delta"},
+	)
+	u.updateLayoutAndSize()
+
+	got := renderToBuffer(t, u.chat, w, h)
+	require.NotSame(t, firstCache, u.chat.drawCache,
+		"changed rendered string must replace the cache entry")
+
+	// Output must match a fresh uv.NewStyledString render of the current
+	// list output for the same area. This is the byte-equivalence guard
+	// that protects against blit drift from StyledString.Draw.
+	want := freshStyledRender(u.chat.list.Render(), w, h)
+	require.Equal(t, want, got)
+}
+
+// TestChatDrawCache_ReusedAcrossDifferentArea asserts that the cached
+// decoded lines do not depend on the screen / area: the same string drawn
+// into a smaller area must hit the cache, and the output must match a fresh
+// uv.NewStyledString render against the new area.
+func TestChatDrawCache_ReusedAcrossDifferentArea(t *testing.T) {
+	t.Parallel()
+
+	u := newTestUI()
+	u.chat.SetMessages(
+		testMessageItem{id: "a", text: "alpha"},
+		testMessageItem{id: "b", text: "beta"},
+	)
+	u.updateLayoutAndSize()
+
+	w, h := 80, 20
+	_ = renderToBuffer(t, u.chat, w, h)
+	require.NotNil(t, u.chat.drawCache)
+	firstCache := u.chat.drawCache
+	firstBuf := u.chat.drawCache.buf.RenderBuffer
+
+	// Draw the same content into a smaller buffer. The list output is
+	// width-sensitive in production (list.Render depends on width), so we
+	// hold the list width fixed via updateLayoutAndSize and only shrink
+	// the destination area. The cache key is the rendered string + width
+	// method, both unchanged here.
+	smallScr := uv.NewScreenBuffer(40, 10)
+	u.chat.Draw(smallScr, uv.Rect(0, 0, 40, 10))
+
+	require.Same(t, firstCache, u.chat.drawCache,
+		"smaller area must still hit the cache")
+	require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
+		"cached RenderBuffer must be reused across area changes")
+
+	want := freshStyledRender(u.chat.list.Render(), 40, 10)
+	require.Equal(t, want, smallScr.Render())
+}
+
+// TestChatDrawCache_BoundedSize asserts the draw cache holds at most one
+// entry. This is structural โ€” any future change that introduces an LRU or
+// ring would have to update this test.
+func TestChatDrawCache_BoundedSize(t *testing.T) {
+	t.Parallel()
+
+	u := newTestUI()
+	w, h := 80, 20
+
+	// Cycle through several distinct list outputs and confirm that
+	// drawCache is still a single *chatDrawCache pointing at the most
+	// recent rendered string each time.
+	for i := range 5 {
+		u.chat.SetMessages(
+			testMessageItem{id: "x", text: "tick " + strconv.Itoa(i)},
+		)
+		u.updateLayoutAndSize()
+		_ = renderToBuffer(t, u.chat, w, h)
+		require.NotNil(t, u.chat.drawCache)
+		require.Equal(t, u.chat.list.Render(), u.chat.drawCache.rendered,
+			"cache.rendered must always match the most recent list output")
+	}
+
+	// Sanity: the type of drawCache is a single pointer, not a slice/map.
+	// This is enforced at compile time but checking it explicitly here
+	// keeps the bounded-size invariant visible to future readers.
+	require.IsType(t, (*chatDrawCache)(nil), u.chat.drawCache)
+}
+
+// freshStyledRender renders the same string through uv.NewStyledString into
+// a fresh ScreenBuffer of the given size. It's the reference implementation
+// used by the cache tests to detect blit drift.
+func freshStyledRender(s string, w, h int) string {
+	scr := uv.NewScreenBuffer(w, h)
+	uv.NewStyledString(s).Draw(scr, uv.Rect(0, 0, w, h))
+	return scr.Render()
+}
+
+// TestChatDrawCache_InvalidatedByWidthMethodSwap asserts the cache rebuilds
+// when the destination screen's width method changes between frames.
+// GraphemeWidth and WcWidth disagree on emoji ZWJ sequences and other
+// modern unicode, so the decoded buffer is only valid for the method it
+// was decoded with. Reusing across methods would corrupt cell widths.
+func TestChatDrawCache_InvalidatedByWidthMethodSwap(t *testing.T) {
+	t.Parallel()
+
+	u := newTestUI()
+	// Use content where the two methods disagree so any accidental
+	// reuse would surface as a different rendered cell layout. The
+	// woman-technologist ZWJ sequence is one cell under GraphemeWidth
+	// but two under WcWidth (the components combine).
+	u.chat.SetMessages(
+		testMessageItem{id: "a", text: "hi ๐Ÿ‘ฉ\u200d๐Ÿ’ป there"},
+	)
+	u.updateLayoutAndSize()
+
+	w, h := 80, 5
+
+	// First draw under GraphemeWidth.
+	scrA := uv.ScreenBuffer{
+		RenderBuffer: uv.NewRenderBuffer(w, h),
+		Method:       ansi.GraphemeWidth,
+	}
+	u.chat.Draw(scrA, uv.Rect(0, 0, w, h))
+	require.NotNil(t, u.chat.drawCache)
+	require.Equal(t, ansi.GraphemeWidth, u.chat.drawCache.method)
+	firstCache := u.chat.drawCache
+
+	// Same string, swap the method. The cache key includes method, so
+	// this must rebuild even though `rendered` is byte-identical.
+	scrB := uv.ScreenBuffer{
+		RenderBuffer: uv.NewRenderBuffer(w, h),
+		Method:       ansi.WcWidth,
+	}
+	u.chat.Draw(scrB, uv.Rect(0, 0, w, h))
+	require.NotSame(t, firstCache, u.chat.drawCache,
+		"width method change must invalidate the cache")
+	require.Equal(t, ansi.WcWidth, u.chat.drawCache.method)
+}
+
+// fixedMethodScreen wraps a uv.ScreenBuffer but reports a custom (non
+// ansi.Method) WidthMethod implementation. Chat.Draw is expected to
+// detect the type-assertion miss and fall through to the uncached path.
+type fixedMethodScreen struct {
+	uv.ScreenBuffer
+	method uv.WidthMethod
+}
+
+func (s fixedMethodScreen) WidthMethod() uv.WidthMethod { return s.method }
+
+// customWidth is a WidthMethod whose concrete type is intentionally NOT
+// ansi.Method, so the type assertion in Chat.Draw fails. The actual
+// width math just delegates to ansi.GraphemeWidth so the rendered
+// output is well-defined and comparable.
+type customWidth struct{}
+
+func (customWidth) StringWidth(s string) int {
+	return ansi.GraphemeWidth.StringWidth(s)
+}
+
+// TestChatDrawCache_FallbackOnNonAnsiMethod asserts that when the screen
+// reports a WidthMethod whose concrete type is not ansi.Method, Chat.Draw
+// skips the cache (it has no comparable key for an arbitrary interface)
+// and falls through to a direct uv.NewStyledString.Draw, producing the
+// same output as the upstream uncached path.
+func TestChatDrawCache_FallbackOnNonAnsiMethod(t *testing.T) {
+	t.Parallel()
+
+	u := newTestUI()
+	u.chat.SetMessages(
+		testMessageItem{id: "a", text: "alpha"},
+		testMessageItem{id: "b", text: "beta"},
+	)
+	u.updateLayoutAndSize()
+
+	w, h := 40, 10
+	scr := fixedMethodScreen{
+		ScreenBuffer: uv.ScreenBuffer{
+			RenderBuffer: uv.NewRenderBuffer(w, h),
+			Method:       ansi.GraphemeWidth,
+		},
+		method: customWidth{},
+	}
+	// Sanity: the wrapper actually returns a non-ansi.Method type so
+	// the fallback path is exercised end-to-end.
+	_, isAnsiMethod := scr.WidthMethod().(ansi.Method)
+	require.False(t, isAnsiMethod,
+		"test setup must hand Draw a non-ansi.Method WidthMethod")
+
+	u.chat.Draw(scr, uv.Rect(0, 0, w, h))
+
+	// The fallback path uses uv.NewStyledString(rendered).Draw โ€” same
+	// thing freshStyledRender does โ€” so output must match the
+	// reference render byte-for-byte.
+	want := freshStyledRender(u.chat.list.Render(), w, h)
+	require.Equal(t, want, scr.Render())
+
+	// And no cache was populated (or if it was, it wasn't used for
+	// this draw). Either way: drawing through the fallback must never
+	// leave a stale cache that future draws would reuse incorrectly.
+	require.Nil(t, u.chat.drawCache,
+		"fallback path must not populate the cache")
+}
+
+// TestRenderedBounds_MatchesPrintStringTallyForZWJ pins the invariant
+// that renderedBounds (used to size the cache buffer) returns the same
+// width as the cell tally StyledString.Draw will write into a buffer
+// using the same WidthMethod. We compare the computed width to a
+// reference render: lay the string into an oversized buffer, then count
+// non-empty cells per row. A divergent-width sample (woman-technologist
+// ZWJ sequence) is the canonical case that would expose a Bounds()
+// vs printString mismatch โ€” under GraphemeWidth the sequence is one
+// cell, under WcWidth it's two โ€” so the same string must produce two
+// different widths under the two methods, both matching the tally.
+func TestRenderedBounds_MatchesPrintStringTallyForZWJ(t *testing.T) {
+	t.Parallel()
+
+	const sample = "x ๐Ÿ‘ฉ\u200d๐Ÿ’ป y"
+
+	for _, m := range []ansi.Method{ansi.GraphemeWidth, ansi.WcWidth} {
+		w, h := renderedBounds(sample, m)
+		// Tally what printString would actually write.
+		// Use an oversized buffer so nothing gets clipped.
+		scr := uv.ScreenBuffer{
+			RenderBuffer: uv.NewRenderBuffer(64, 4),
+			Method:       m,
+		}
+		uv.NewStyledString(sample).Draw(scr, uv.Rect(0, 0, 64, 4))
+
+		// Tally width: rightmost non-empty cell + its width, per row.
+		gotW := 0
+		gotH := 0
+		for y := 0; y < 4; y++ {
+			rowW := 0
+			rowHasContent := false
+			for x := 0; x < 64; x++ {
+				cell := scr.CellAt(x, y)
+				if cell == nil || cell.IsZero() ||
+					cell.Content == " " && cell.Width == 1 &&
+						cell.Style == (uv.Style{}) {
+					continue
+				}
+				rowHasContent = true
+				if x+cell.Width > rowW {
+					rowW = x + cell.Width
+				}
+			}
+			if rowHasContent {
+				gotH = y + 1
+				if rowW > gotW {
+					gotW = rowW
+				}
+			}
+		}
+
+		require.Equal(t, gotW, w,
+			"renderedBounds width must match printString cell tally for method %v", m)
+		// Height is just '\n' count + 1 โ€” no special-casing needed
+		// here, but pin it so future regressions on multi-line
+		// inputs don't sneak by.
+		require.Equal(t, gotH, h,
+			"renderedBounds height must match printString row tally for method %v", m)
+	}
+}
+
+// Compile-time guard: testMessageItem is reused from layout_test.go and
+// satisfies chat.MessageItem there. We rely on the same shape here.
+var _ chat.MessageItem = testMessageItem{}