on main help sign in
perf(chat): cache the prefixed render of chat messages

Avoid rebuilding the focus and selection prefix for every line of every message on every frame. Each item now keeps the prefixed output cached and only rebuilds it when something actually changes. Co-Authored-By: Charm Crush <crush@charm.land>

commit771117cafeedffc8a9b5859503fc055bcd525d3e authorChristian Rocha <christian@rocha.is> dateTue 12 May 2026, 09:39 (5 months ago) parentbf5dd26
5 files changed+302−4
internal/ui/chat/assistant.go+21−1 internal/ui/chat/messages.go+43−1 internal/ui/chat/prefix_cache_test.go+196 internal/ui/chat/tools.go+23−1 internal/ui/chat/user.go+19−1
internal/ui/chat/assistant.go+21−1
@@ -114,6 +114,22 @@ func (a *AssistantMessageItem) Render(width int) string {
114114 // it's wrapping logic.
115115 // We already know that the content is wrapped to the correct width in
116116 // RawRender, so we can just apply the styles directly to each line.
117+ //
118+ // The split + per-line prefix loop is O(L); cache the result keyed
119+ // by (width, focused) so steady-state Render becomes a pointer
120+ // return. Bypass the cache while spinning (RawRender's spinner
121+ // suffix changes every animation frame) or while a highlight range
122+ // is active (selection drag).
123+ useCache := !a.isSpinning() && !a.isHighlighted()
124+ var key uint64
125+ if a.focused {
126+ key = 1
127+ }
128+ if useCache {
129+ if cached, ok := a.getCachedPrefixedRender(width, key); ok {
130+ return cached
131+ }
132+ }
117133 focused := a.sty.Messages.AssistantFocused.Render()
118134 blurred := a.sty.Messages.AssistantBlurred.Render()
119135 rendered := a.RawRender(width)
@@ -125,7 +141,11 @@ func (a *AssistantMessageItem) Render(width int) string {
125141 lines[i] = blurred + line
126142 }
127143 }
128− return strings.Join(lines, "\n")
144+ out := strings.Join(lines, "\n")
145+ if useCache {
146+ a.setCachedPrefixedRender(out, width, key)
147+ }
148+ return out
129149}
130150
131151// renderMessageContent renders the message content including thinking, main content, and finish reason.
internal/ui/chat/messages.go+43−1
@@ -156,6 +156,20 @@ type cachedMessageItem struct {
156156 // width and height are the dimensions of the cached render
157157 width int
158158 height int
159+
160+ // prefixedRendered caches the per-line-prefixed Render output (the
161+ // result of splitting RawRender by newlines and prepending a focus
162+ // or selection prefix to every line). Items rebuild this every
163+ // frame today; caching it keyed by (prefixedWidth, prefixedKey)
164+ // turns Render into a pointer return when item state is stable.
165+ //
166+ // Invalidation lives in clearCache; callers must additionally
167+ // bypass this cache whenever the prefixed output would not be
168+ // stable (spinner ticks, active highlight ranges) by not calling
169+ // setCachedPrefixedRender for those frames.
170+ prefixedRendered string
171+ prefixedWidth int
172+ prefixedKey uint64
159173}
160174
161175// getCachedRender returns the cached render if it exists for the given width.
@@ -173,11 +187,31 @@ func (c *cachedMessageItem) setCachedRender(rendered string, width, height int)
173187 c.height = height
174188}
175189
190+// getCachedPrefixedRender returns the cached prefixed render if it exists
191+// for the given (width, key). The key encodes any state that changes the
192+// per-line prefix (focused/blurred, compact, ...).
193+func (c *cachedMessageItem) getCachedPrefixedRender(width int, key uint64) (string, bool) {
194+ if c.prefixedRendered != "" && c.prefixedWidth == width && c.prefixedKey == key {
195+ return c.prefixedRendered, true
196+ }
197+ return "", false
198+}
199+
200+// setCachedPrefixedRender stores the cached prefixed render.
201+func (c *cachedMessageItem) setCachedPrefixedRender(rendered string, width int, key uint64) {
202+ c.prefixedRendered = rendered
203+ c.prefixedWidth = width
204+ c.prefixedKey = key
205+}
206+
176207// clearCache clears the cached render.
177208func (c *cachedMessageItem) clearCache() {
178209 c.rendered = ""
179210 c.width = 0
180211 c.height = 0
212+ c.prefixedRendered = ""
213+ c.prefixedWidth = 0
214+ c.prefixedKey = 0
181215}
182216
183217// focusableMessageItem is a base struct for message items that can be focused.
@@ -237,12 +271,20 @@ func (a *AssistantInfoItem) RawRender(width int) string {
237271
238272// Render implements MessageItem.
239273func (a *AssistantInfoItem) Render(width int) string {
274+ // AssistantInfoItem uses a single, state-independent prefix; key 0
275+ // is sufficient. The cache is invalidated whenever the underlying
276+ // cachedMessageItem render is cleared.
277+ if cached, ok := a.getCachedPrefixedRender(width, 0); ok {
278+ return cached
279+ }
240280 prefix := a.sty.Messages.SectionHeader.Render()
241281 lines := strings.Split(a.RawRender(width), "\n")
242282 for i, line := range lines {
243283 lines[i] = prefix + line
244284 }
245− return strings.Join(lines, "\n")
285+ out := strings.Join(lines, "\n")
286+ a.setCachedPrefixedRender(out, width, 0)
287+ return out
246288}
247289
248290func (a *AssistantInfoItem) renderContent(width int) string {
internal/ui/chat/prefix_cache_test.gonew file+196
@@ -0,0 +1,196 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/attachments"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// finishedAssistantMessage builds an assistant message with text content and a
15+// finish part so AssistantMessageItem.isSpinning returns false and the
16+// prefix cache is exercised.
17+func finishedAssistantMessage(id, text string) *message.Message {
18+ return &message.Message{
19+ ID: id,
20+ Role: message.Assistant,
21+ Parts: []message.ContentPart{
22+ message.TextContent{Text: text},
23+ message.Finish{Reason: message.FinishReasonEndTurn, Time: time.Now().Unix()},
24+ },
25+ }
26+}
27+
28+// TestAssistantMessageItemRender_PrefixCacheFocusBlur covers the F3 invariant
29+// that focus → blur → focus produces the correct prefix every time and never
30+// leaks the previous focus state out of the cache.
31+func TestAssistantMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
32+ t.Parallel()
33+
34+ sty := styles.CharmtonePantera()
35+ msg := finishedAssistantMessage("m1", "Hello world from the cache test.")
36+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
37+
38+ const width = 60
39+
40+ item.SetFocused(true)
41+ focused1 := item.Render(width)
42+ focused2 := item.Render(width)
43+ require.Equal(t, focused1, focused2, "second render must hit the cache and match the first")
44+
45+ item.SetFocused(false)
46+ blurred1 := item.Render(width)
47+ require.NotEqual(t, focused1, blurred1, "blur must produce a different prefixed render than focus")
48+
49+ item.SetFocused(true)
50+ focused3 := item.Render(width)
51+ require.Equal(t, focused1, focused3, "re-focus must produce identical output to the original focused render")
52+}
53+
54+// TestAssistantMessageItemRender_PrefixCacheWidthInvalidates asserts that a
55+// width change does not return the previous width's cached output.
56+func TestAssistantMessageItemRender_PrefixCacheWidthInvalidates(t *testing.T) {
57+ t.Parallel()
58+
59+ sty := styles.CharmtonePantera()
60+ msg := finishedAssistantMessage("m2", "Some content that wraps differently at different widths so the rendered output diverges.")
61+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
62+ item.SetFocused(true)
63+
64+ narrow := item.Render(40)
65+ wide := item.Render(100)
66+ require.NotEqual(t, narrow, wide, "different widths must produce different rendered output")
67+
68+ narrowAgain := item.Render(40)
69+ require.Equal(t, narrow, narrowAgain, "returning to the original width must hit (or repopulate) the cache with the same output")
70+}
71+
72+// TestAssistantMessageItemRender_PrefixCacheHighlightOnTop guarantees that
73+// activating a highlight range bypasses the prefix cache so selection drags
74+// reflect immediately, and that clearing the highlight returns to the cached
75+// prefixed output unchanged.
76+func TestAssistantMessageItemRender_PrefixCacheHighlightOnTop(t *testing.T) {
77+ t.Parallel()
78+
79+ sty := styles.CharmtonePantera()
80+ msg := finishedAssistantMessage("m3", "Hello world from the highlight test.")
81+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
82+ item.SetFocused(true)
83+
84+ const width = 60
85+ plain := item.Render(width)
86+
87+ // Activating a highlight must change the rendered output (selection
88+ // painted on top) without poisoning the cache for the un-highlighted
89+ // state that follows.
90+ item.SetHighlight(0, 0, 0, 5)
91+ highlighted := item.Render(width)
92+ require.NotEqual(t, plain, highlighted, "active highlight must change Render output")
93+
94+ // Clear the highlight; the cached un-highlighted prefix render must
95+ // be returned unchanged.
96+ item.SetHighlight(-1, -1, -1, -1)
97+ plainAfter := item.Render(width)
98+ require.Equal(t, plain, plainAfter, "clearing the highlight must restore the cached prefixed output exactly")
99+}
100+
101+// TestUserMessageItemRender_PrefixCacheFocusBlur is the user-message
102+// counterpart of the assistant focus/blur cache test.
103+func TestUserMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
104+ t.Parallel()
105+
106+ sty := styles.CharmtonePantera()
107+ msg := &message.Message{
108+ ID: "u1",
109+ Role: message.User,
110+ Parts: []message.ContentPart{
111+ message.TextContent{Text: "Hello from the user."},
112+ },
113+ }
114+ r := attachments.NewRenderer(
115+ sty.Attachments.Normal,
116+ sty.Attachments.Deleting,
117+ sty.Attachments.Image,
118+ sty.Attachments.Text,
119+ )
120+ item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
121+
122+ const width = 60
123+
124+ item.SetFocused(true)
125+ focused1 := item.Render(width)
126+ focused2 := item.Render(width)
127+ require.Equal(t, focused1, focused2)
128+
129+ item.SetFocused(false)
130+ blurred := item.Render(width)
131+ require.NotEqual(t, focused1, blurred)
132+
133+ item.SetFocused(true)
134+ focused3 := item.Render(width)
135+ require.Equal(t, focused1, focused3)
136+}
137+
138+// TestCachedMessageItem_PrefixCacheSemantics covers the constant-prefix
139+// path used by AssistantInfoItem and the (width, key) keying used by every
140+// item, against the underlying cachedMessageItem helper directly. This
141+// avoids constructing a full *config.Config with an initialized provider
142+// map just to exercise cache plumbing that is identical for all callers.
143+func TestCachedMessageItem_PrefixCacheSemantics(t *testing.T) {
144+ t.Parallel()
145+
146+ c := &cachedMessageItem{}
147+
148+ // Empty cache: miss.
149+ _, ok := c.getCachedPrefixedRender(80, 0)
150+ require.False(t, ok)
151+
152+ // Set then hit at the same (width, key).
153+ c.setCachedPrefixedRender("hello", 80, 0)
154+ got, ok := c.getCachedPrefixedRender(80, 0)
155+ require.True(t, ok)
156+ require.Equal(t, "hello", got)
157+
158+ // Different width: miss.
159+ _, ok = c.getCachedPrefixedRender(120, 0)
160+ require.False(t, ok)
161+
162+ // Different key (focused vs blurred): miss.
163+ _, ok = c.getCachedPrefixedRender(80, 1)
164+ require.False(t, ok)
165+
166+ // clearCache drops the prefixed cache too.
167+ c.setCachedRender("raw", 80, 1)
168+ c.setCachedPrefixedRender("hello", 80, 0)
169+ c.clearCache()
170+ _, ok = c.getCachedPrefixedRender(80, 0)
171+ require.False(t, ok, "clearCache must drop the prefixed render cache")
172+ _, _, ok = c.getCachedRender(80)
173+ require.False(t, ok, "clearCache must also drop the raw render cache")
174+}
175+
176+// TestAssistantMessageItemRender_PrefixCacheNoCacheLeak guards against a
177+// regression where the cache returned the prefixed output of the previous
178+// width. We verify that the cached output for width=W contains the W-sized
179+// prefix and not a stale wider one by checking that line lengths are
180+// consistent on cache hit.
181+func TestAssistantMessageItemRender_PrefixCacheNoCacheLeak(t *testing.T) {
182+ t.Parallel()
183+
184+ sty := styles.CharmtonePantera()
185+ msg := finishedAssistantMessage("m4", strings.Repeat("word ", 40))
186+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
187+ item.SetFocused(true)
188+
189+ out80 := item.Render(80)
190+ out120 := item.Render(120)
191+ require.NotEqual(t, out80, out120)
192+
193+ // Hit each cached entry again and confirm stability.
194+ require.Equal(t, out80, item.Render(80))
195+ require.Equal(t, out120, item.Render(120))
196+}
internal/ui/chat/tools.go+23−1
@@ -332,6 +332,24 @@ func (t *baseToolMessageItem) RawRender(width int) string {
332332
333333// Render renders the tool message item at the given width.
334334func (t *baseToolMessageItem) Render(width int) string {
335+ // Cache the prefixed output keyed by (width, prefix variant).
336+ // Bypass the cache while spinning (RawRender output is
337+ // frame-dependent) or while a highlight range is active.
338+ useCache := !t.isSpinning() && !t.isHighlighted()
339+ var key uint64
340+ switch {
341+ case t.isCompact:
342+ key = 2
343+ case t.focused:
344+ key = 1
345+ default:
346+ key = 0
347+ }
348+ if useCache {
349+ if cached, ok := t.getCachedPrefixedRender(width, key); ok {
350+ return cached
351+ }
352+ }
335353 var prefix string
336354 if t.isCompact {
337355 prefix = t.sty.Messages.ToolCallCompact.Render()
@@ -344,7 +362,11 @@ func (t *baseToolMessageItem) Render(width int) string {
344362 for i, ln := range lines {
345363 lines[i] = prefix + ln
346364 }
347− return strings.Join(lines, "\n")
365+ out := strings.Join(lines, "\n")
366+ if useCache {
367+ t.setCachedPrefixedRender(out, width, key)
368+ }
369+ return out
348370}
349371
350372// ToolCall returns the tool call associated with this message item.
internal/ui/chat/user.go+19−1
@@ -70,6 +70,20 @@ func (m *UserMessageItem) RawRender(width int) string {
7070
7171// Render implements MessageItem.
7272func (m *UserMessageItem) Render(width int) string {
73+ // Bypass the prefix cache while a highlight range is active so
74+ // selection drags reflect immediately without invalidating the
75+ // cache. Highlight changes are intentionally applied "above" the
76+ // prefix cache.
77+ useCache := !m.isHighlighted()
78+ var key uint64
79+ if m.focused {
80+ key = 1
81+ }
82+ if useCache {
83+ if cached, ok := m.getCachedPrefixedRender(width, key); ok {
84+ return cached
85+ }
86+ }
7387 var prefix string
7488 if m.focused {
7589 prefix = m.sty.Messages.UserFocused.Render()
@@ -80,7 +94,11 @@ func (m *UserMessageItem) Render(width int) string {
8094 for i, line := range lines {
8195 lines[i] = prefix + line
8296 }
83− return strings.Join(lines, "\n")
97+ out := strings.Join(lines, "\n")
98+ if useCache {
99+ m.setCachedPrefixedRender(out, width, key)
100+ }
101+ return out
84102}
85103
86104// ID implements MessageItem.