@@ -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.
@@ -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{}