on main help sign in
perf(chat): skip re-parsing the rendered chat when nothing has changed

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>

commit65f30a13430a93ef3f271adb3cee4b535ade3de6 authorChristian Rocha <christian@rocha.is> dateTue 12 May 2026, 16:37 (5 months ago) parent1c7ccfd
2 files changed+446−1
internal/ui/model/chat.go+112−1 internal/ui/model/chat_draw_cache_test.go+334
internal/ui/model/chat.go+112−1
@@ -63,6 +63,32 @@ type Chat struct {
6363 // follow is a flag to indicate whether the view should auto-scroll to
6464 // bottom on new messages.
6565 follow bool
66+
67+ // drawCache memoizes the decoded form of the last list.Render output so
68+ // repeat frames with byte-identical content skip the per-cell ANSI
69+ // reparse that uv.StyledString.Draw performs every call. See F9
70+ // (docs/notes/2026-05-12-chat-rendering-perf.md §4.8). Bounded to one
71+ // entry; invalidated implicitly by string inequality on the next Draw.
72+ drawCache *chatDrawCache
73+}
74+
75+// chatDrawCache holds the pre-decoded form of the last list.Render output.
76+// The cache is keyed by the rendered string and the screen's width method
77+// (graphemes vs wcwidth pick different decoders inside ultraviolet's
78+// printString, so a cached buffer is only valid for the method it was
79+// decoded with). The cached buffer is independent of the draw area, so
80+// resize / scroll changes that produce the same string still hit. We cannot
81+// use uv.StyledString.Lines because it bottoms out at the first iteration
82+// against a zero-bounds rectangle (see ultraviolet styled.go line 45 — the
83+// shared printString loop's `y >= bounds.Max.Y` exit applies to the
84+// line-building branch too). A Buffer of the rendered text's natural
85+// dimensions is the cheapest correct shape: StyledString.Draw runs once on
86+// miss to populate it, and Buffer.Draw is an O(cells) cell copy with no
87+// ANSI re-parse on hit.
88+type chatDrawCache struct {
89+ rendered string
90+ method ansi.Method
91+ buf uv.ScreenBuffer
6692}
6793
6894// NewChat creates a new instance of [Chat] that handles chat interactions and
@@ -89,8 +115,93 @@ func (m *Chat) Height() int {
89115}
90116
91117// Draw renders the chat UI component to the screen and the given area.
118+//
119+// The list's rendered output is cached in decoded form (see chatDrawCache) so
120+// that frames with byte-identical content skip the ANSI reparse that
121+// uv.StyledString.Draw performs on every call. The cache is keyed by the
122+// rendered string and the screen's width method; area / scroll changes do not
123+// invalidate it.
92124func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
93− uv.NewStyledString(m.list.Render()).Draw(scr, area)
125+ rendered := m.list.Render()
126+ method, ok := scr.WidthMethod().(ansi.Method)
127+ if !ok {
128+ // Width method isn't an ansi.Method (unlikely in practice — both
129+ // TerminalScreen and ScreenBuffer store ansi.Method). Fall back
130+ // to the uncached path so behavior matches upstream exactly.
131+ uv.NewStyledString(rendered).Draw(scr, area)
132+ return
133+ }
134+ if m.drawCache == nil ||
135+ m.drawCache.rendered != rendered ||
136+ m.drawCache.method != method {
137+ m.drawCache = newChatDrawCache(rendered, method)
138+ }
139+ drawCachedBuffer(scr, area, m.drawCache.buf)
140+}
141+
142+// newChatDrawCache builds a chatDrawCache for the given rendered string by
143+// running uv.StyledString.Draw into a fresh buffer sized to the text's
144+// natural bounds under the active width method. This is the only place
145+// ANSI decoding happens for cached frames — subsequent draws reuse buf
146+// via drawCachedBuffer.
147+//
148+// We can't use uv.StyledString.Bounds() here: it is hard-coded to
149+// ansi.GraphemeWidth, while StyledString.Draw lays cells using the
150+// destination buffer's WidthMethod (which we capture in `method`). For
151+// strings where graphemes and wcwidth disagree (emoji ZWJ sequences,
152+// some CJK, certain combining marks) the two answers diverge, leaving
153+// the cached buffer either too small (trailing cells dropped on hit) or
154+// too large (dead cells past the live content). Computing dimensions
155+// with `method.StringWidth` per line matches what printString tallies
156+// cell-by-cell, since both decode ANSI sequences and use the same width
157+// method.
158+func newChatDrawCache(rendered string, method ansi.Method) *chatDrawCache {
159+ w, h := renderedBounds(rendered, method)
160+ if w <= 0 {
161+ w = 1
162+ }
163+ if h <= 0 {
164+ h = 1
165+ }
166+ buf := uv.NewScreenBuffer(w, h)
167+ buf.Method = method
168+ uv.NewStyledString(rendered).Draw(buf, buf.Bounds())
169+ return &chatDrawCache{
170+ rendered: rendered,
171+ method: method,
172+ buf: buf,
173+ }
174+}
175+
176+// renderedBounds returns the (width, height) cell extent of rendered
177+// when laid out by method. Width is the widest line's StringWidth (which
178+// strips ANSI sequences and tallies cells via method, exactly like
179+// printString); height is the line count. Both match what
180+// uv.StyledString.Draw will write into a buffer whose WidthMethod is
181+// method, so the cache buffer is always sized to fit the live content.
182+func renderedBounds(rendered string, method ansi.Method) (w, h int) {
183+ for line := range strings.SplitSeq(rendered, "\n") {
184+ w = max(w, method.StringWidth(line))
185+ h++
186+ }
187+ return w, h
188+}
189+
190+// drawCachedBuffer blits a previously-decoded buffer into scr at area,
191+// mirroring uv.StyledString.Draw's screen-mode behavior for the default
192+// Wrap=false, Tail="" case. The clear loop matches StyledString.Draw line
193+// 51-56; the buf.Draw call replaces the per-cell ANSI decode that
194+// printString does on every uncached frame with a pure cell copy.
195+func drawCachedBuffer(scr uv.Screen, area uv.Rectangle, buf uv.ScreenBuffer) {
196+ // Clear the area first to match StyledString.Draw — leftover cells
197+ // from a previous frame outside the new content must be zeroed,
198+ // because Buffer.Draw skips empty cells (it doesn't clear).
199+ for y := area.Min.Y; y < area.Max.Y; y++ {
200+ for x := area.Min.X; x < area.Max.X; x++ {
201+ scr.SetCell(x, y, nil)
202+ }
203+ }
204+ buf.Draw(scr, area)
94205}
95206
96207// SetSize sets the size of the chat view port.
internal/ui/model/chat_draw_cache_test.gonew file+334
@@ -0,0 +1,334 @@
1+package model
2+
3+import (
4+ "strconv"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/ui/chat"
8+ uv "github.com/charmbracelet/ultraviolet"
9+ "github.com/charmbracelet/x/ansi"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// drawTestArea is a fixed area used by the cache tests; it must be at least
14+// as large as the test items so we can compare every cell.
15+func drawTestArea(width, height int) uv.Rectangle {
16+ return uv.Rect(0, 0, width, height)
17+}
18+
19+// renderToBuffer mirrors what Chat.Draw does at the screen layer so tests
20+// can assert byte-equivalence with a fresh uv.NewStyledString render.
21+func renderToBuffer(t *testing.T, c *Chat, w, h int) string {
22+ t.Helper()
23+ scr := uv.NewScreenBuffer(w, h)
24+ c.Draw(scr, drawTestArea(w, h))
25+ return scr.Render()
26+}
27+
28+// TestChatDrawCache_HitOnIdenticalRender asserts that two consecutive Draws
29+// against the same list output reuse the same chatDrawCache rather than
30+// allocating a fresh one. Both the wrapper pointer and the embedded
31+// RenderBuffer pointer must be stable across the second Draw — that's what
32+// proves no re-decoding happened.
33+func TestChatDrawCache_HitOnIdenticalRender(t *testing.T) {
34+ t.Parallel()
35+
36+ u := newTestUI()
37+ u.chat.SetMessages(
38+ testMessageItem{id: "a", text: "alpha"},
39+ testMessageItem{id: "b", text: "beta"},
40+ )
41+ u.updateLayoutAndSize()
42+
43+ w, h := 80, 20
44+ _ = renderToBuffer(t, u.chat, w, h)
45+ require.NotNil(t, u.chat.drawCache, "first draw should populate cache")
46+ firstCache := u.chat.drawCache
47+ firstBuf := u.chat.drawCache.buf.RenderBuffer
48+
49+ _ = renderToBuffer(t, u.chat, w, h)
50+ require.Same(t, firstCache, u.chat.drawCache,
51+ "identical rendered string must reuse the same cache pointer")
52+ require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
53+ "identical rendered string must reuse the same RenderBuffer pointer")
54+}
55+
56+// TestChatDrawCache_MissOnDifferentRender asserts that when the list output
57+// changes, the cache is rebuilt and the new draw output matches a fresh
58+// uv.NewStyledString render byte-for-byte.
59+func TestChatDrawCache_MissOnDifferentRender(t *testing.T) {
60+ t.Parallel()
61+
62+ u := newTestUI()
63+ u.chat.SetMessages(
64+ testMessageItem{id: "a", text: "alpha"},
65+ )
66+ u.updateLayoutAndSize()
67+
68+ w, h := 80, 20
69+ _ = renderToBuffer(t, u.chat, w, h)
70+ require.NotNil(t, u.chat.drawCache)
71+ firstCache := u.chat.drawCache
72+
73+ // Replace the items so the rendered string differs.
74+ u.chat.SetMessages(
75+ testMessageItem{id: "c", text: "gamma delta"},
76+ )
77+ u.updateLayoutAndSize()
78+
79+ got := renderToBuffer(t, u.chat, w, h)
80+ require.NotSame(t, firstCache, u.chat.drawCache,
81+ "changed rendered string must replace the cache entry")
82+
83+ // Output must match a fresh uv.NewStyledString render of the current
84+ // list output for the same area. This is the byte-equivalence guard
85+ // that protects against blit drift from StyledString.Draw.
86+ want := freshStyledRender(u.chat.list.Render(), w, h)
87+ require.Equal(t, want, got)
88+}
89+
90+// TestChatDrawCache_ReusedAcrossDifferentArea asserts that the cached
91+// decoded lines do not depend on the screen / area: the same string drawn
92+// into a smaller area must hit the cache, and the output must match a fresh
93+// uv.NewStyledString render against the new area.
94+func TestChatDrawCache_ReusedAcrossDifferentArea(t *testing.T) {
95+ t.Parallel()
96+
97+ u := newTestUI()
98+ u.chat.SetMessages(
99+ testMessageItem{id: "a", text: "alpha"},
100+ testMessageItem{id: "b", text: "beta"},
101+ )
102+ u.updateLayoutAndSize()
103+
104+ w, h := 80, 20
105+ _ = renderToBuffer(t, u.chat, w, h)
106+ require.NotNil(t, u.chat.drawCache)
107+ firstCache := u.chat.drawCache
108+ firstBuf := u.chat.drawCache.buf.RenderBuffer
109+
110+ // Draw the same content into a smaller buffer. The list output is
111+ // width-sensitive in production (list.Render depends on width), so we
112+ // hold the list width fixed via updateLayoutAndSize and only shrink
113+ // the destination area. The cache key is the rendered string + width
114+ // method, both unchanged here.
115+ smallScr := uv.NewScreenBuffer(40, 10)
116+ u.chat.Draw(smallScr, uv.Rect(0, 0, 40, 10))
117+
118+ require.Same(t, firstCache, u.chat.drawCache,
119+ "smaller area must still hit the cache")
120+ require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
121+ "cached RenderBuffer must be reused across area changes")
122+
123+ want := freshStyledRender(u.chat.list.Render(), 40, 10)
124+ require.Equal(t, want, smallScr.Render())
125+}
126+
127+// TestChatDrawCache_BoundedSize asserts the draw cache holds at most one
128+// entry. This is structural — any future change that introduces an LRU or
129+// ring would have to update this test.
130+func TestChatDrawCache_BoundedSize(t *testing.T) {
131+ t.Parallel()
132+
133+ u := newTestUI()
134+ w, h := 80, 20
135+
136+ // Cycle through several distinct list outputs and confirm that
137+ // drawCache is still a single *chatDrawCache pointing at the most
138+ // recent rendered string each time.
139+ for i := range 5 {
140+ u.chat.SetMessages(
141+ testMessageItem{id: "x", text: "tick " + strconv.Itoa(i)},
142+ )
143+ u.updateLayoutAndSize()
144+ _ = renderToBuffer(t, u.chat, w, h)
145+ require.NotNil(t, u.chat.drawCache)
146+ require.Equal(t, u.chat.list.Render(), u.chat.drawCache.rendered,
147+ "cache.rendered must always match the most recent list output")
148+ }
149+
150+ // Sanity: the type of drawCache is a single pointer, not a slice/map.
151+ // This is enforced at compile time but checking it explicitly here
152+ // keeps the bounded-size invariant visible to future readers.
153+ require.IsType(t, (*chatDrawCache)(nil), u.chat.drawCache)
154+}
155+
156+// freshStyledRender renders the same string through uv.NewStyledString into
157+// a fresh ScreenBuffer of the given size. It's the reference implementation
158+// used by the cache tests to detect blit drift.
159+func freshStyledRender(s string, w, h int) string {
160+ scr := uv.NewScreenBuffer(w, h)
161+ uv.NewStyledString(s).Draw(scr, uv.Rect(0, 0, w, h))
162+ return scr.Render()
163+}
164+
165+// TestChatDrawCache_InvalidatedByWidthMethodSwap asserts the cache rebuilds
166+// when the destination screen's width method changes between frames.
167+// GraphemeWidth and WcWidth disagree on emoji ZWJ sequences and other
168+// modern unicode, so the decoded buffer is only valid for the method it
169+// was decoded with. Reusing across methods would corrupt cell widths.
170+func TestChatDrawCache_InvalidatedByWidthMethodSwap(t *testing.T) {
171+ t.Parallel()
172+
173+ u := newTestUI()
174+ // Use content where the two methods disagree so any accidental
175+ // reuse would surface as a different rendered cell layout. The
176+ // woman-technologist ZWJ sequence is one cell under GraphemeWidth
177+ // but two under WcWidth (the components combine).
178+ u.chat.SetMessages(
179+ testMessageItem{id: "a", text: "hi 👩\u200d💻 there"},
180+ )
181+ u.updateLayoutAndSize()
182+
183+ w, h := 80, 5
184+
185+ // First draw under GraphemeWidth.
186+ scrA := uv.ScreenBuffer{
187+ RenderBuffer: uv.NewRenderBuffer(w, h),
188+ Method: ansi.GraphemeWidth,
189+ }
190+ u.chat.Draw(scrA, uv.Rect(0, 0, w, h))
191+ require.NotNil(t, u.chat.drawCache)
192+ require.Equal(t, ansi.GraphemeWidth, u.chat.drawCache.method)
193+ firstCache := u.chat.drawCache
194+
195+ // Same string, swap the method. The cache key includes method, so
196+ // this must rebuild even though `rendered` is byte-identical.
197+ scrB := uv.ScreenBuffer{
198+ RenderBuffer: uv.NewRenderBuffer(w, h),
199+ Method: ansi.WcWidth,
200+ }
201+ u.chat.Draw(scrB, uv.Rect(0, 0, w, h))
202+ require.NotSame(t, firstCache, u.chat.drawCache,
203+ "width method change must invalidate the cache")
204+ require.Equal(t, ansi.WcWidth, u.chat.drawCache.method)
205+}
206+
207+// fixedMethodScreen wraps a uv.ScreenBuffer but reports a custom (non
208+// ansi.Method) WidthMethod implementation. Chat.Draw is expected to
209+// detect the type-assertion miss and fall through to the uncached path.
210+type fixedMethodScreen struct {
211+ uv.ScreenBuffer
212+ method uv.WidthMethod
213+}
214+
215+func (s fixedMethodScreen) WidthMethod() uv.WidthMethod { return s.method }
216+
217+// customWidth is a WidthMethod whose concrete type is intentionally NOT
218+// ansi.Method, so the type assertion in Chat.Draw fails. The actual
219+// width math just delegates to ansi.GraphemeWidth so the rendered
220+// output is well-defined and comparable.
221+type customWidth struct{}
222+
223+func (customWidth) StringWidth(s string) int {
224+ return ansi.GraphemeWidth.StringWidth(s)
225+}
226+
227+// TestChatDrawCache_FallbackOnNonAnsiMethod asserts that when the screen
228+// reports a WidthMethod whose concrete type is not ansi.Method, Chat.Draw
229+// skips the cache (it has no comparable key for an arbitrary interface)
230+// and falls through to a direct uv.NewStyledString.Draw, producing the
231+// same output as the upstream uncached path.
232+func TestChatDrawCache_FallbackOnNonAnsiMethod(t *testing.T) {
233+ t.Parallel()
234+
235+ u := newTestUI()
236+ u.chat.SetMessages(
237+ testMessageItem{id: "a", text: "alpha"},
238+ testMessageItem{id: "b", text: "beta"},
239+ )
240+ u.updateLayoutAndSize()
241+
242+ w, h := 40, 10
243+ scr := fixedMethodScreen{
244+ ScreenBuffer: uv.ScreenBuffer{
245+ RenderBuffer: uv.NewRenderBuffer(w, h),
246+ Method: ansi.GraphemeWidth,
247+ },
248+ method: customWidth{},
249+ }
250+ // Sanity: the wrapper actually returns a non-ansi.Method type so
251+ // the fallback path is exercised end-to-end.
252+ _, isAnsiMethod := scr.WidthMethod().(ansi.Method)
253+ require.False(t, isAnsiMethod,
254+ "test setup must hand Draw a non-ansi.Method WidthMethod")
255+
256+ u.chat.Draw(scr, uv.Rect(0, 0, w, h))
257+
258+ // The fallback path uses uv.NewStyledString(rendered).Draw — same
259+ // thing freshStyledRender does — so output must match the
260+ // reference render byte-for-byte.
261+ want := freshStyledRender(u.chat.list.Render(), w, h)
262+ require.Equal(t, want, scr.Render())
263+
264+ // And no cache was populated (or if it was, it wasn't used for
265+ // this draw). Either way: drawing through the fallback must never
266+ // leave a stale cache that future draws would reuse incorrectly.
267+ require.Nil(t, u.chat.drawCache,
268+ "fallback path must not populate the cache")
269+}
270+
271+// TestRenderedBounds_MatchesPrintStringTallyForZWJ pins the invariant
272+// that renderedBounds (used to size the cache buffer) returns the same
273+// width as the cell tally StyledString.Draw will write into a buffer
274+// using the same WidthMethod. We compare the computed width to a
275+// reference render: lay the string into an oversized buffer, then count
276+// non-empty cells per row. A divergent-width sample (woman-technologist
277+// ZWJ sequence) is the canonical case that would expose a Bounds()
278+// vs printString mismatch — under GraphemeWidth the sequence is one
279+// cell, under WcWidth it's two — so the same string must produce two
280+// different widths under the two methods, both matching the tally.
281+func TestRenderedBounds_MatchesPrintStringTallyForZWJ(t *testing.T) {
282+ t.Parallel()
283+
284+ const sample = "x 👩\u200d💻 y"
285+
286+ for _, m := range []ansi.Method{ansi.GraphemeWidth, ansi.WcWidth} {
287+ w, h := renderedBounds(sample, m)
288+ // Tally what printString would actually write.
289+ // Use an oversized buffer so nothing gets clipped.
290+ scr := uv.ScreenBuffer{
291+ RenderBuffer: uv.NewRenderBuffer(64, 4),
292+ Method: m,
293+ }
294+ uv.NewStyledString(sample).Draw(scr, uv.Rect(0, 0, 64, 4))
295+
296+ // Tally width: rightmost non-empty cell + its width, per row.
297+ gotW := 0
298+ gotH := 0
299+ for y := 0; y < 4; y++ {
300+ rowW := 0
301+ rowHasContent := false
302+ for x := 0; x < 64; x++ {
303+ cell := scr.CellAt(x, y)
304+ if cell == nil || cell.IsZero() ||
305+ cell.Content == " " && cell.Width == 1 &&
306+ cell.Style == (uv.Style{}) {
307+ continue
308+ }
309+ rowHasContent = true
310+ if x+cell.Width > rowW {
311+ rowW = x + cell.Width
312+ }
313+ }
314+ if rowHasContent {
315+ gotH = y + 1
316+ if rowW > gotW {
317+ gotW = rowW
318+ }
319+ }
320+ }
321+
322+ require.Equal(t, gotW, w,
323+ "renderedBounds width must match printString cell tally for method %v", m)
324+ // Height is just '\n' count + 1 — no special-casing needed
325+ // here, but pin it so future regressions on multi-line
326+ // inputs don't sneak by.
327+ require.Equal(t, gotH, h,
328+ "renderedBounds height must match printString row tally for method %v", m)
329+ }
330+}
331+
332+// Compile-time guard: testMessageItem is reused from layout_test.go and
333+// satisfies chat.MessageItem there. We rely on the same shape here.
334+var _ chat.MessageItem = testMessageItem{}