on main help sign in
perf(chat): cache the prefixed render of chat messages
771117cChristian Rocha
internal/ui/chat/assistant.go+21−1
@@ -114,6 +114,22 @@ func (a *AssistantMessageItem) Render(width int) string {
114 // it's wrapping logic.
114 // it's wrapping logic.
115 // We already know that the content is wrapped to the correct width in
115 // We already know that the content is wrapped to the correct width in
116 // RawRender, so we can just apply the styles directly to each line.
116 // 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+ }
117 focused := a.sty.Messages.AssistantFocused.Render()
133 focused := a.sty.Messages.AssistantFocused.Render()
118 blurred := a.sty.Messages.AssistantBlurred.Render()
134 blurred := a.sty.Messages.AssistantBlurred.Render()
119 rendered := a.RawRender(width)
135 rendered := a.RawRender(width)
@@ -125,7 +141,11 @@ func (a *AssistantMessageItem) Render(width int) string {
125 lines[i] = blurred + line
141 lines[i] = blurred + line
126 }
142 }
127 }
143 }
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
129}
149}
130
150
131// renderMessageContent renders the message content including thinking, main content, and finish reason.
151// 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 {
156 // width and height are the dimensions of the cached render
156 // width and height are the dimensions of the cached render
157 width int
157 width int
158 height int
158 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
159}
173}
160
174
161// getCachedRender returns the cached render if it exists for the given width.
175// 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)
173 c.height = height
187 c.height = height
174}
188}
175
189
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+
176// clearCache clears the cached render.
207// clearCache clears the cached render.
177func (c *cachedMessageItem) clearCache() {
208func (c *cachedMessageItem) clearCache() {
178 c.rendered = ""
209 c.rendered = ""
179 c.width = 0
210 c.width = 0
180 c.height = 0
211 c.height = 0
212+ c.prefixedRendered = ""
213+ c.prefixedWidth = 0
214+ c.prefixedKey = 0
181}
215}
182
216
183// focusableMessageItem is a base struct for message items that can be focused.
217// focusableMessageItem is a base struct for message items that can be focused.
@@ -237,12 +271,20 @@ func (a *AssistantInfoItem) RawRender(width int) string {
237
271
238// Render implements MessageItem.
272// Render implements MessageItem.
239func (a *AssistantInfoItem) Render(width int) string {
273func (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+ }
240 prefix := a.sty.Messages.SectionHeader.Render()
280 prefix := a.sty.Messages.SectionHeader.Render()
241 lines := strings.Split(a.RawRender(width), "\n")
281 lines := strings.Split(a.RawRender(width), "\n")
242 for i, line := range lines {
282 for i, line := range lines {
243 lines[i] = prefix + line
283 lines[i] = prefix + line
244 }
284 }
245− return strings.Join(lines, "\n")
285+ out := strings.Join(lines, "\n")
286+ a.setCachedPrefixedRender(out, width, 0)
287+ return out
246}
288}
247
289
248func (a *AssistantInfoItem) renderContent(width int) string {
290func (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 {
332
332
333// Render renders the tool message item at the given width.
333// Render renders the tool message item at the given width.
334func (t *baseToolMessageItem) Render(width int) string {
334func (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+ }
335 var prefix string
353 var prefix string
336 if t.isCompact {
354 if t.isCompact {
337 prefix = t.sty.Messages.ToolCallCompact.Render()
355 prefix = t.sty.Messages.ToolCallCompact.Render()
@@ -344,7 +362,11 @@ func (t *baseToolMessageItem) Render(width int) string {
344 for i, ln := range lines {
362 for i, ln := range lines {
345 lines[i] = prefix + ln
363 lines[i] = prefix + ln
346 }
364 }
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
348}
370}
349
371
350// ToolCall returns the tool call associated with this message item.
372// 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 {
70
70
71// Render implements MessageItem.
71// Render implements MessageItem.
72func (m *UserMessageItem) Render(width int) string {
72func (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+ }
73 var prefix string
87 var prefix string
74 if m.focused {
88 if m.focused {
75 prefix = m.sty.Messages.UserFocused.Render()
89 prefix = m.sty.Messages.UserFocused.Render()
@@ -80,7 +94,11 @@ func (m *UserMessageItem) Render(width int) string {
80 for i, line := range lines {
94 for i, line := range lines {
81 lines[i] = prefix + line
95 lines[i] = prefix + line
82 }
96 }
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
84}
102}
85
103
86// ID implements MessageItem.
104// ID implements MessageItem.