on main help sign in
perf(chat): skip re-parsing the rendered chat when nothing has changed
65f30a1Christian Rocha
internal/ui/model/chat.go+112−1
@@ -63,6 +63,32 @@ type Chat struct {
63 // follow is a flag to indicate whether the view should auto-scroll to
63 // follow is a flag to indicate whether the view should auto-scroll to
64 // bottom on new messages.
64 // bottom on new messages.
65 follow bool
65 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
66}
92}
67
93
68// NewChat creates a new instance of [Chat] that handles chat interactions and
94// NewChat creates a new instance of [Chat] that handles chat interactions and
@@ -89,8 +115,93 @@ func (m *Chat) Height() int {
89}
115}
90
116
91// Draw renders the chat UI component to the screen and the given area.
117// 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.
92func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
124func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
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+ }
93− uv.NewStyledString(m.list.Render()).Draw(scr, area)
204+ buf.Draw(scr, area)
94}
205}
95
206
96// SetSize sets the size of the chat view port.
207// 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{}