on main help sign in
perf(chat): cache the parts of an assistant message separately

Each assistant message has up to three sections — thinking, response, and errors — and previously a new streamed token re-rendered all of them. Now each section is cached on its own, so streaming a response no longer re-renders the (often long) thinking block above it. Co-Authored-By: Charm Crush <crush@charm.land>

commit8370edb6910e3044f0e7e375685431110a3eac9c authorChristian Rocha <christian@rocha.is> dateTue 12 May 2026, 11:44 (5 months ago) parent6938ded
2 files changed+728−32
internal/ui/chat/assistant.go+264−32 internal/ui/chat/assistant_section_cache_test.go+464
internal/ui/chat/assistant.go+264−32
@@ -1,7 +1,9 @@
11package chat
22
33import (
4+ "encoding/binary"
45 "fmt"
6+ "hash/fnv"
57 "strings"
68
79 tea "charm.land/bubbletea/v2"
@@ -20,6 +22,70 @@ const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to
2022// maxCollapsedThinkingHeight defines the maximum height of the thinking
2123const maxCollapsedThinkingHeight = 10
2224
25+// assistantSection is a per-section render cache for AssistantMessageItem.
26+// Each section (thinking, content, error) carries its own keys so that
27+// streaming a section does not invalidate a different — often more
28+// expensive — section's cached render. srcHash is an FNV-64 of the
29+// section's source text; extra captures any other state that changes
30+// the rendered output (e.g. thinkingExpanded, the thinking footer
31+// inputs). valid disambiguates a real cache hit from the zero value
32+// when both source text and extras hash to zero. aux carries any
33+// per-section side data that the caller needs to recover on a hit
34+// (e.g. the thinking box height for click detection).
35+type assistantSection struct {
36+ width int
37+ srcHash uint64
38+ extra uint64
39+ out string
40+ h int
41+ aux int
42+ valid bool
43+}
44+
45+// hit reports whether the cache entry matches the requested key.
46+func (s *assistantSection) hit(width int, srcHash, extra uint64) bool {
47+ return s.valid && s.width == width && s.srcHash == srcHash && s.extra == extra
48+}
49+
50+// store records the rendered output under the given key.
51+func (s *assistantSection) store(width int, srcHash, extra uint64, out string, aux int) {
52+ s.width = width
53+ s.srcHash = srcHash
54+ s.extra = extra
55+ s.out = out
56+ s.h = lipgloss.Height(out)
57+ s.aux = aux
58+ s.valid = true
59+}
60+
61+// reset drops the cached output.
62+func (s *assistantSection) reset() {
63+ *s = assistantSection{}
64+}
65+
66+// fnv64 hashes a single string with FNV-64.
67+func fnv64(s string) uint64 {
68+ h := fnv.New64a()
69+ _, _ = h.Write([]byte(s))
70+ return h.Sum64()
71+}
72+
73+// fnvFields hashes a list of byte fields with length-prefix framing
74+// so that no concatenation collision can occur between distinct
75+// field tuples (a NUL inside one field cannot impersonate a
76+// boundary between two fields). Each field is preceded by its
77+// length encoded as 8 bytes little-endian.
78+func fnvFields(fields ...[]byte) uint64 {
79+ h := fnv.New64a()
80+ var lenBuf [8]byte
81+ for _, f := range fields {
82+ binary.LittleEndian.PutUint64(lenBuf[:], uint64(len(f)))
83+ _, _ = h.Write(lenBuf[:])
84+ _, _ = h.Write(f)
85+ }
86+ return h.Sum64()
87+}
88+
2389// AssistantMessageItem represents an assistant message in the chat UI.
2490//
2591// This item includes thinking, and the content but does not include the tool calls.
@@ -33,6 +99,13 @@ type AssistantMessageItem struct {
3399 anim *anim.Anim
34100 thinkingExpanded bool
35101 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
102+
103+ // Per-section render caches. Splitting these out means content
104+ // streaming does not invalidate the (often expensive) thinking
105+ // render, and vice versa.
106+ thinkingSec assistantSection
107+ contentSec assistantSection
108+ errorSec assistantSection
36109}
37110
38111var _ Expandable = (*AssistantMessageItem)(nil)
@@ -88,14 +161,7 @@ func (a *AssistantMessageItem) RawRender(width int) string {
88161 spinner = a.renderSpinning()
89162 }
90163
91− content, height, ok := a.getCachedRender(cappedWidth)
92− if !ok {
93− content = a.renderMessageContent(cappedWidth)
94− height = lipgloss.Height(content)
95− // cache the rendered content
96− a.setCachedRender(content, cappedWidth, height)
97− }
98−
164+ content, height := a.renderMessageContent(cappedWidth)
99165 highlightedContent := a.renderHighlighted(content, cappedWidth, height)
100166 if spinner != "" {
101167 if highlightedContent != "" {
@@ -116,15 +182,16 @@ func (a *AssistantMessageItem) Render(width int) string {
116182 // RawRender, so we can just apply the styles directly to each line.
117183 //
118184 // 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).
185+ // by (width, focused, sectionsFingerprint) so steady-state Render
186+ // becomes a pointer return. The sectionsFingerprint folds in the
187+ // per-section srcHash/extra so that any sub-cache change
188+ // invalidates this prefix cache without requiring an explicit
189+ // drop. Bypass the cache while spinning (RawRender's spinner
190+ // suffix changes every animation frame) or while a highlight
191+ // range is active (selection drag).
123192 useCache := !a.isSpinning() && !a.isHighlighted()
124− var key uint64
125− if a.focused {
126− key = 1
127− }
193+ cappedWidth := cappedMessageWidth(width)
194+ key := a.prefixCacheKey(cappedWidth)
128195 if useCache {
129196 if cached, ok := a.getCachedPrefixedRender(width, key); ok {
130197 return cached
@@ -148,36 +215,182 @@ func (a *AssistantMessageItem) Render(width int) string {
148215 return out
149216}
150217
151−// renderMessageContent renders the message content including thinking, main content, and finish reason.
152−func (a *AssistantMessageItem) renderMessageContent(width int) string {
218+// prefixCacheKey builds the F3 prefixed-render cache key. We pack the
219+// focus bit into bit 0 and a fingerprint of the section caches into
220+// the upper bits, so any change to a sub-section's source text or
221+// extras forces the prefix cache to miss without needing an explicit
222+// drop. cappedWidth is included so a cached prefix never survives a
223+// section-cache miss caused by a width change. The finish reason is
224+// folded in too because it controls the composition of
225+// renderMessageContent (e.g. appending the constant "Canceled"
226+// string) — that decision lives outside any section's own hash.
227+func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int) uint64 {
228+ thinkSrc, thinkExtra := a.thinkingKey()
229+ contentSrc, contentExtra := a.contentKey()
230+ errSrc, errExtra := a.errorKey()
231+ h := fnv.New64a()
232+ var buf [8]byte
233+ writeU64 := func(v uint64) {
234+ for i := range 8 {
235+ buf[i] = byte(v >> (8 * i))
236+ }
237+ _, _ = h.Write(buf[:])
238+ }
239+ writeU64(uint64(cappedWidth))
240+ writeU64(thinkSrc)
241+ writeU64(thinkExtra)
242+ writeU64(contentSrc)
243+ writeU64(contentExtra)
244+ writeU64(errSrc)
245+ writeU64(errExtra)
246+ writeU64(a.compositionKey())
247+ fingerprint := h.Sum64()
248+ var focusBit uint64
249+ if a.focused {
250+ focusBit = 1
251+ }
252+ return (fingerprint &^ 1) | focusBit
253+}
254+
255+// compositionKey hashes the inputs to renderMessageContent's structural
256+// decisions (which sections to include, whether to append the
257+// constant "Canceled" footer) so that flipping IsFinished or the
258+// finish reason invalidates the prefix cache even when no section's
259+// own source text changed.
260+func (a *AssistantMessageItem) compositionKey() uint64 {
261+ var finishedFlag byte
262+ var reason string
263+ if a.message.IsFinished() {
264+ finishedFlag = 1
265+ reason = string(a.message.FinishReason())
266+ }
267+ // Length-prefixed framing keeps the finished flag and the reason
268+ // string from blending into one another.
269+ return fnvFields([]byte{finishedFlag}, []byte(reason))
270+}
271+
272+// renderMessageContent renders the message content including thinking, main
273+// content, and finish reason. Each section is served from its own cache;
274+// only the section whose source text or extras changed since the last
275+// render is recomputed.
276+func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
153277 var messageParts []string
154278 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
155279 content := strings.TrimSpace(a.message.Content().Text)
156− // if the massage has reasoning content add that first
280+
157281 if thinking != "" {
158− messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
282+ messageParts = append(messageParts, a.cachedThinking(width))
159283 }
160284
161− // then add the main content
162285 if content != "" {
163− // add a spacer between thinking and content
164286 if thinking != "" {
165287 messageParts = append(messageParts, "")
166288 }
167− messageParts = append(messageParts, a.renderMarkdown(content, width))
289+ messageParts = append(messageParts, a.cachedContent(width))
168290 }
169291
170− // finally add any finish reason info
171292 if a.message.IsFinished() {
172293 switch a.message.FinishReason() {
173294 case message.FinishReasonCanceled:
174295 messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
175296 case message.FinishReasonError:
176− messageParts = append(messageParts, a.renderError(width))
297+ messageParts = append(messageParts, a.cachedError(width))
177298 }
178299 }
179300
180− return strings.Join(messageParts, "\n")
301+ out := strings.Join(messageParts, "\n")
302+ return out, lipgloss.Height(out)
303+}
304+
305+// thinkingKey returns the (srcHash, extra) cache key components for the
306+// thinking section. extra folds in everything other than the raw
307+// thinking text that affects the rendered output: the expanded flag
308+// and the footer state (which depends on IsThinking, ToolCalls, and
309+// ThinkingDuration).
310+func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
311+ thinking := a.message.ReasoningContent().Thinking
312+ srcHash := fnv64(thinking)
313+
314+ showFooter := !a.message.IsThinking() || len(a.message.ToolCalls()) > 0
315+ var durationStr string
316+ if showFooter {
317+ duration := a.message.ThinkingDuration()
318+ if duration.String() != "0s" {
319+ durationStr = duration.String()
320+ }
321+ }
322+ var expanded byte
323+ if a.thinkingExpanded {
324+ expanded = 1
325+ }
326+ var footer byte
327+ if showFooter {
328+ footer = 1
329+ }
330+ // Length-prefixed framing avoids any delimiter collision between
331+ // the flag bytes and the duration string.
332+ extra := fnvFields([]byte{expanded, footer}, []byte(durationStr))
333+ return srcHash, extra
334+}
335+
336+// contentKey returns the (srcHash, extra) cache key components for the
337+// main content section.
338+func (a *AssistantMessageItem) contentKey() (uint64, uint64) {
339+ return fnv64(a.message.Content().Text), 0
340+}
341+
342+// errorKey returns the (srcHash, extra) cache key components for the
343+// error section. Returns (0, 0) when no error is present so the cache
344+// stays a no-op for non-error messages.
345+func (a *AssistantMessageItem) errorKey() (uint64, uint64) {
346+ if !a.message.IsFinished() || a.message.FinishReason() != message.FinishReasonError {
347+ return 0, 0
348+ }
349+ finishPart := a.message.FinishPart()
350+ if finishPart == nil {
351+ return 0, 0
352+ }
353+ // Length-prefixed framing prevents Message+Details collisions
354+ // between distinct (Message, Details) tuples that would
355+ // otherwise concatenate to the same byte sequence.
356+ return fnvFields([]byte(finishPart.Message), []byte(finishPart.Details)), 0
357+}
358+
359+// cachedThinking returns the rendered thinking section, computing and
360+// caching it on miss. The thinking-box height (used for click target
361+// detection) is preserved across hits via assistantSection.aux so the
362+// cached path never desyncs click detection.
363+func (a *AssistantMessageItem) cachedThinking(width int) string {
364+ srcHash, extra := a.thinkingKey()
365+ if a.thinkingSec.hit(width, srcHash, extra) {
366+ a.thinkingBoxHeight = a.thinkingSec.aux
367+ return a.thinkingSec.out
368+ }
369+ out := a.renderThinking(a.message.ReasoningContent().Thinking, width)
370+ a.thinkingSec.store(width, srcHash, extra, out, a.thinkingBoxHeight)
371+ return out
372+}
373+
374+// cachedContent returns the rendered content section.
375+func (a *AssistantMessageItem) cachedContent(width int) string {
376+ srcHash, extra := a.contentKey()
377+ if a.contentSec.hit(width, srcHash, extra) {
378+ return a.contentSec.out
379+ }
380+ out := a.renderMarkdown(a.message.Content().Text, width)
381+ a.contentSec.store(width, srcHash, extra, out, 0)
382+ return out
383+}
384+
385+// cachedError returns the rendered error section.
386+func (a *AssistantMessageItem) cachedError(width int) string {
387+ srcHash, extra := a.errorKey()
388+ if a.errorSec.hit(width, srcHash, extra) {
389+ return a.errorSec.out
390+ }
391+ out := a.renderError(width)
392+ a.errorSec.store(width, srcHash, extra, out, 0)
393+ return out
181394}
182395
183396// renderThinking renders the thinking/reasoning content with footer.
@@ -260,22 +473,41 @@ func (a *AssistantMessageItem) isSpinning() bool {
260473 return (isThinking || !isFinished) && !hasContent && !hasToolCalls
261474}
262475
263−// SetMessage is used to update the underlying message.
264−func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
476+// SetMessage is used to update the underlying message. Only the
477+// sub-section caches whose source text or extras changed are
478+// invalidated; the others survive and serve cache hits on the next
479+// RawRender.
480+func (a *AssistantMessageItem) SetMessage(msg *message.Message) tea.Cmd {
265481 wasSpinning := a.isSpinning()
266− a.message = message
267− a.clearCache()
482+ a.message = msg
483+ // The prefix cache is keyed by a fingerprint that includes every
484+ // section's source hash, so an unchanged section keeps its prefix
485+ // cache valid while a changed section forces a miss naturally.
486+ // Section caches themselves are content-keyed, so they do not
487+ // need an explicit drop here either.
268488 if !wasSpinning && a.isSpinning() {
269489 return a.StartAnimation()
270490 }
271491 return nil
272492}
273493
494+// clearCache drops every cached render for this item, including the
495+// per-section caches. Shadows the embedded cachedMessageItem.clearCache
496+// so ClearItemCaches (style change) wipes the section caches too.
497+func (a *AssistantMessageItem) clearCache() {
498+ a.cachedMessageItem.clearCache()
499+ a.thinkingSec.reset()
500+ a.contentSec.reset()
501+ a.errorSec.reset()
502+}
503+
274504// ToggleExpanded toggles the expanded state of the thinking box and returns
275−// whether the item is now expanded.
505+// whether the item is now expanded. Both the thinking section cache and
506+// the F3 prefix cache key fold in thinkingExpanded (via the section's
507+// extra hash and the prefix cache fingerprint respectively), so no
508+// explicit invalidation is required.
276509func (a *AssistantMessageItem) ToggleExpanded() bool {
277510 a.thinkingExpanded = !a.thinkingExpanded
278− a.clearCache()
279511 return a.thinkingExpanded
280512}
281513
internal/ui/chat/assistant_section_cache_test.gonew file+464
@@ -0,0 +1,464 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// Fixed Unix timestamps for deterministic cache-equality tests. The
13+// thinking section's `extra` hash folds in ThinkingDuration, which
14+// in turn depends on (FinishedAt - StartedAt). Anchoring both
15+// timestamps removes any wall-clock dependency from the cache key
16+// so two builds across a second boundary still hit the cache.
17+const (
18+ testStartedAt int64 = 1_700_000_000
19+ testFinishedAt int64 = 1_700_000_005
20+ testFinishTime int64 = 1_700_000_006
21+)
22+
23+// thinkingMessage builds an assistant message with a fixed reasoning
24+// content and an optional text content. When text is empty the
25+// message represents a still-thinking turn (matches IsThinking()).
26+// Both reasoning timestamps are anchored to fixed Unix seconds so
27+// ThinkingDuration is deterministic and cache-equality assertions
28+// don't depend on wall-clock time.
29+func thinkingMessage(id, thinking, text string) *message.Message {
30+ parts := []message.ContentPart{
31+ message.ReasoningContent{
32+ Thinking: thinking,
33+ StartedAt: testStartedAt,
34+ FinishedAt: testFinishedAt,
35+ },
36+ }
37+ if text != "" {
38+ parts = append(parts, message.TextContent{Text: text})
39+ }
40+ return &message.Message{
41+ ID: id,
42+ Role: message.Assistant,
43+ Parts: parts,
44+ }
45+}
46+
47+// errorMessage builds a finished assistant message whose finish part
48+// carries an error reason plus a custom message and details.
49+func errorMessage(id, errMsg, errDetails string) *message.Message {
50+ return &message.Message{
51+ ID: id,
52+ Role: message.Assistant,
53+ Parts: []message.ContentPart{
54+ message.TextContent{Text: "partial output"},
55+ message.Finish{
56+ Reason: message.FinishReasonError,
57+ Message: errMsg,
58+ Details: errDetails,
59+ Time: testFinishTime,
60+ },
61+ },
62+ }
63+}
64+
65+// renderTwoSetMessages drives a SetMessage cycle and returns the
66+// section-cache identity (out string pointers via direct comparison
67+// of the cached fields). The test compares `out` strings; identical
68+// output across cycles is the cache-hit indicator we rely on.
69+type sectionSnapshot struct {
70+ thinking string
71+ content string
72+ errSec string
73+}
74+
75+func snapshot(a *AssistantMessageItem) sectionSnapshot {
76+ return sectionSnapshot{
77+ thinking: a.thinkingSec.out,
78+ content: a.contentSec.out,
79+ errSec: a.errorSec.out,
80+ }
81+}
82+
83+// TestAssistantSectionCache_ContentChangeDoesNotInvalidateThinking covers
84+// the central F4 invariant: streaming the main content through SetMessage
85+// must keep the cached thinking render intact, provided the inputs to
86+// the thinking section render (text, expanded flag, footer state) are
87+// unchanged. We seed an already-non-empty content so that IsThinking()
88+// is false on both renders — that's the steady streaming state where
89+// the thinking block has finished and content keeps growing.
90+func TestAssistantSectionCache_ContentChangeDoesNotInvalidateThinking(t *testing.T) {
91+ sty := styles.CharmtonePantera()
92+ thinking := "Step 1\nStep 2\nStep 3"
93+ msg := thinkingMessage("a1", thinking, "Initial answer.")
94+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
95+
96+ const width = 71
97+
98+ _ = item.RawRender(width)
99+ first := snapshot(item)
100+ require.NotEmpty(t, first.thinking, "thinking section must be populated after first render")
101+
102+ // Stream more content into the existing turn. Thinking text and
103+ // footer state are byte-identical between the two renders.
104+ updated := thinkingMessage("a1", thinking, "Initial answer. More streamed text.")
105+ item.SetMessage(updated)
106+ _ = item.RawRender(width)
107+ second := snapshot(item)
108+
109+ require.Equal(t, first.thinking, second.thinking,
110+ "content streaming must not invalidate the thinking section render")
111+ require.NotEqual(t, first.content, second.content,
112+ "content section must have been re-rendered")
113+}
114+
115+// TestAssistantSectionCache_ThinkingChangeDoesNotInvalidateContent is the
116+// mirror of the previous test: extending thinking text must not force a
117+// re-render of the content section.
118+func TestAssistantSectionCache_ThinkingChangeDoesNotInvalidateContent(t *testing.T) {
119+ sty := styles.CharmtonePantera()
120+ content := "Final answer goes here."
121+ msg := thinkingMessage("a2", "Step 1", content)
122+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
123+
124+ const width = 73
125+
126+ _ = item.RawRender(width)
127+ first := snapshot(item)
128+ require.NotEmpty(t, first.content)
129+
130+ updated := thinkingMessage("a2", "Step 1\nStep 2", content)
131+ item.SetMessage(updated)
132+ _ = item.RawRender(width)
133+ second := snapshot(item)
134+
135+ require.Equal(t, first.content, second.content,
136+ "thinking streaming must not invalidate the content section render")
137+ require.NotEqual(t, first.thinking, second.thinking,
138+ "thinking text changed; thinking section must have re-rendered")
139+}
140+
141+// TestAssistantSectionCache_HashKeyDiscrimination asserts that two
142+// messages with different source text hash to different per-section
143+// keys, and that messages with identical source text hit the cache.
144+func TestAssistantSectionCache_HashKeyDiscrimination(t *testing.T) {
145+ sty := styles.CharmtonePantera()
146+ msgA := thinkingMessage("a3", "thinking A", "content A")
147+ msgB := thinkingMessage("a3", "thinking B", "content B")
148+
149+ itemA := NewAssistantMessageItem(&sty, msgA).(*AssistantMessageItem)
150+ itemB := NewAssistantMessageItem(&sty, msgB).(*AssistantMessageItem)
151+
152+ thinkSrcA, _ := itemA.thinkingKey()
153+ thinkSrcB, _ := itemB.thinkingKey()
154+ require.NotEqual(t, thinkSrcA, thinkSrcB,
155+ "distinct thinking text must produce distinct FNV-64 source hashes")
156+
157+ contentSrcA, _ := itemA.contentKey()
158+ contentSrcB, _ := itemB.contentKey()
159+ require.NotEqual(t, contentSrcA, contentSrcB,
160+ "distinct content text must produce distinct FNV-64 source hashes")
161+
162+ // Identical source text on a fresh item must produce the same
163+ // hashes — keying invariant for cache hits.
164+ itemAClone := NewAssistantMessageItem(&sty, thinkingMessage("a3", "thinking A", "content A")).(*AssistantMessageItem)
165+ thinkSrcAClone, _ := itemAClone.thinkingKey()
166+ contentSrcAClone, _ := itemAClone.contentKey()
167+ require.Equal(t, thinkSrcA, thinkSrcAClone)
168+ require.Equal(t, contentSrcA, contentSrcAClone)
169+}
170+
171+// TestAssistantSectionCache_CloneRoundTrip guards the contract that
172+// message.Clone() does not invalidate any section cache: re-keying off
173+// the cloned message must produce identical hashes and the section
174+// caches must serve byte-identical renders.
175+func TestAssistantSectionCache_CloneRoundTrip(t *testing.T) {
176+ sty := styles.CharmtonePantera()
177+ orig := thinkingMessage("a4", "Reasoning step.", "Answer text.")
178+ item := NewAssistantMessageItem(&sty, orig).(*AssistantMessageItem)
179+
180+ const width = 75
181+ _ = item.RawRender(width)
182+ first := snapshot(item)
183+
184+ cloned := orig.Clone()
185+ item.SetMessage(&cloned)
186+ _ = item.RawRender(width)
187+ second := snapshot(item)
188+
189+ require.Equal(t, first.thinking, second.thinking, "clone must hit the thinking cache")
190+ require.Equal(t, first.content, second.content, "clone must hit the content cache")
191+}
192+
193+// TestAssistantSectionCache_ResizeInvalidatesAll asserts that a width
194+// change forces a re-render of every section.
195+func TestAssistantSectionCache_ResizeInvalidatesAll(t *testing.T) {
196+ sty := styles.CharmtonePantera()
197+ msg := errorMessage("a5", "boom", strings.Repeat("detail line\n", 5))
198+ // errorMessage returns FinishReasonError; combine with thinking
199+ // content so all three sections are exercised.
200+ msg.Parts = append([]message.ContentPart{
201+ message.ReasoningContent{
202+ Thinking: "Considering options.",
203+ StartedAt: testStartedAt,
204+ FinishedAt: testFinishedAt,
205+ },
206+ }, msg.Parts...)
207+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
208+
209+ _ = item.RawRender(77)
210+ first := snapshot(item)
211+ require.NotEmpty(t, first.thinking)
212+ require.NotEmpty(t, first.content)
213+ require.NotEmpty(t, first.errSec)
214+
215+ _ = item.RawRender(117)
216+ second := snapshot(item)
217+
218+ require.NotEqual(t, first.thinking, second.thinking, "resize must re-render the thinking section")
219+ require.NotEqual(t, first.content, second.content, "resize must re-render the content section")
220+ require.NotEqual(t, first.errSec, second.errSec, "resize must re-render the error section")
221+}
222+
223+// TestAssistantSectionCache_ErrorIndependentOfThinkingAndContent guards
224+// that the error section caches independently. Editing the error
225+// message must not invalidate the other two sections, and editing the
226+// content must not invalidate the error section.
227+func TestAssistantSectionCache_ErrorIndependentOfThinkingAndContent(t *testing.T) {
228+ sty := styles.CharmtonePantera()
229+ build := func(thinking, content, errMsg, errDetails string) *message.Message {
230+ return &message.Message{
231+ ID: "a6",
232+ Role: message.Assistant,
233+ Parts: []message.ContentPart{
234+ message.ReasoningContent{
235+ Thinking: thinking,
236+ StartedAt: testStartedAt,
237+ FinishedAt: testFinishedAt,
238+ },
239+ message.TextContent{Text: content},
240+ message.Finish{
241+ Reason: message.FinishReasonError,
242+ Message: errMsg,
243+ Details: errDetails,
244+ Time: testFinishTime,
245+ },
246+ },
247+ }
248+ }
249+
250+ item := NewAssistantMessageItem(&sty, build("think", "content", "boom", "details")).(*AssistantMessageItem)
251+ _ = item.RawRender(79)
252+ first := snapshot(item)
253+
254+ // Change only the error text. Thinking and content caches must
255+ // survive; error cache must miss and re-render.
256+ item.SetMessage(build("think", "content", "different boom", "different details"))
257+ _ = item.RawRender(79)
258+ second := snapshot(item)
259+
260+ require.Equal(t, first.thinking, second.thinking, "error change must not invalidate thinking")
261+ require.Equal(t, first.content, second.content, "error change must not invalidate content")
262+ require.NotEqual(t, first.errSec, second.errSec, "error change must re-render the error section")
263+
264+ // Now change only the content; error cache must survive.
265+ item.SetMessage(build("think", "different content", "different boom", "different details"))
266+ _ = item.RawRender(79)
267+ third := snapshot(item)
268+
269+ require.Equal(t, second.thinking, third.thinking)
270+ require.NotEqual(t, second.content, third.content)
271+ require.Equal(t, second.errSec, third.errSec, "content change must not invalidate the error section")
272+}
273+
274+// TestAssistantSectionCache_PrefixCacheRespectsSectionChanges guards
275+// the F3/F4 boundary: the prefix cache must invalidate when any
276+// underlying section changes. We verify by comparing the F3-cached
277+// Render output across SetMessage cycles.
278+func TestAssistantSectionCache_PrefixCacheRespectsSectionChanges(t *testing.T) {
279+ sty := styles.CharmtonePantera()
280+ build := func(content string) *message.Message {
281+ return &message.Message{
282+ ID: "a7",
283+ Role: message.Assistant,
284+ Parts: []message.ContentPart{
285+ message.TextContent{Text: content},
286+ message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
287+ },
288+ }
289+ }
290+
291+ item := NewAssistantMessageItem(&sty, build("first content")).(*AssistantMessageItem)
292+ item.SetFocused(true)
293+
294+ const width = 81
295+ first := item.Render(width)
296+
297+ item.SetMessage(build("second content"))
298+ second := item.Render(width)
299+ require.NotEqual(t, first, second,
300+ "prefix cache must invalidate when the content section changes")
301+
302+ // Re-set to the original content; the prefix cache should
303+ // produce identical output again.
304+ item.SetMessage(build("first content"))
305+ third := item.Render(width)
306+ require.Equal(t, first, third)
307+}
308+
309+// TestAssistantSectionCache_ByteIdenticalToFreshRender asserts that the
310+// F4 cached path produces the same bytes as a fresh-instance render of
311+// the equivalent message — i.e. caching is invisible from the outside.
312+// Drives a sequence of mutations (thinking change, content change,
313+// finish) and compares every step against an independent item rendered
314+// from scratch.
315+func TestAssistantSectionCache_ByteIdenticalToFreshRender(t *testing.T) {
316+ sty := styles.CharmtonePantera()
317+ const width = 83
318+
319+ type step struct {
320+ name string
321+ msg *message.Message
322+ }
323+ startedAt := testStartedAt
324+ finishedAt := testFinishedAt
325+ finishTime := testFinishTime
326+ steps := []step{
327+ {
328+ name: "thinking-only",
329+ msg: &message.Message{
330+ ID: "iso", Role: message.Assistant,
331+ Parts: []message.ContentPart{
332+ message.ReasoningContent{Thinking: "first reasoning", StartedAt: startedAt},
333+ },
334+ },
335+ },
336+ {
337+ name: "thinking-grew",
338+ msg: &message.Message{
339+ ID: "iso", Role: message.Assistant,
340+ Parts: []message.ContentPart{
341+ message.ReasoningContent{Thinking: "first reasoning more", StartedAt: startedAt},
342+ },
343+ },
344+ },
345+ {
346+ name: "content-arrived",
347+ msg: &message.Message{
348+ ID: "iso", Role: message.Assistant,
349+ Parts: []message.ContentPart{
350+ message.ReasoningContent{Thinking: "first reasoning more", StartedAt: startedAt, FinishedAt: finishedAt},
351+ message.TextContent{Text: "the answer"},
352+ },
353+ },
354+ },
355+ {
356+ name: "finished-end-turn",
357+ msg: &message.Message{
358+ ID: "iso", Role: message.Assistant,
359+ Parts: []message.ContentPart{
360+ message.ReasoningContent{Thinking: "first reasoning more", StartedAt: startedAt, FinishedAt: finishedAt},
361+ message.TextContent{Text: "the answer"},
362+ message.Finish{Reason: message.FinishReasonEndTurn, Time: finishTime},
363+ },
364+ },
365+ },
366+ }
367+
368+ first := steps[0].msg.Clone()
369+ cached := NewAssistantMessageItem(&sty, &first).(*AssistantMessageItem)
370+ for _, s := range steps {
371+ cached.SetMessage(s.msg)
372+ freshMsg := s.msg.Clone()
373+ fresh := NewAssistantMessageItem(&sty, &freshMsg).(*AssistantMessageItem)
374+ require.Equal(t, fresh.RawRender(width), cached.RawRender(width),
375+ "step %q: cached path must match fresh render byte-for-byte", s.name)
376+ }
377+}
378+
379+// TestAssistantSectionCache_PrefixCacheInvalidatesOnCompositionOnlyChange
380+// guards the F3 prefix cache against composition-only changes:
381+// flipping the finish reason from EndTurn to Canceled appends a
382+// constant "Canceled" line via renderMessageContent, but no
383+// section's own source text changes. The prefix cache must observe
384+// the difference (compositionKey is folded into prefixCacheKey) and
385+// the resulting bytes must differ. As a second guarantee, a fresh
386+// item built with the same final state must produce byte-equal
387+// output to the cached item — caching must never produce stale or
388+// divergent renders.
389+func TestAssistantSectionCache_PrefixCacheInvalidatesOnCompositionOnlyChange(t *testing.T) {
390+ sty := styles.CharmtonePantera()
391+ const width = 87
392+
393+ build := func(reason message.FinishReason) *message.Message {
394+ return &message.Message{
395+ ID: "comp",
396+ Role: message.Assistant,
397+ Parts: []message.ContentPart{
398+ message.TextContent{Text: "hi"},
399+ message.Finish{Reason: reason, Time: testFinishTime},
400+ },
401+ }
402+ }
403+
404+ item := NewAssistantMessageItem(&sty, build(message.FinishReasonEndTurn)).(*AssistantMessageItem)
405+ endTurnOut := item.Render(width)
406+
407+ // Flip only the finish reason. Thinking is empty and content
408+ // text is unchanged, so no section's source hash moves; only
409+ // compositionKey shifts. The prefix cache must miss.
410+ item.SetMessage(build(message.FinishReasonCanceled))
411+ canceledOut := item.Render(width)
412+ require.NotEqual(t, endTurnOut, canceledOut,
413+ "prefix cache must invalidate on composition-only change (finish reason)")
414+
415+ // A fresh item built with the same final state must match the
416+ // cached item byte-for-byte — caching is invisible from the
417+ // outside and never serves stale output.
418+ fresh := NewAssistantMessageItem(&sty, build(message.FinishReasonCanceled)).(*AssistantMessageItem)
419+ require.Equal(t, fresh.Render(width), canceledOut,
420+ "cached output must equal a fresh render of the same final state")
421+}
422+
423+// TestAssistantSectionCache_ThinkingBoxHeightSurvivesCacheHit guards
424+// click-detection geometry across thinking-section cache hits. The
425+// thinking box height feeds HandleMouseClick; it is recomputed
426+// inside renderThinking and must be restored from
427+// assistantSection.aux when the thinking cache hits. We render once
428+// to capture the original height, trigger a content-only change so
429+// thinkingKey stays identical (thinking text, expanded flag, and
430+// footer state all unchanged), render again, and assert the
431+// thinkingBoxHeight field is preserved.
432+func TestAssistantSectionCache_ThinkingBoxHeightSurvivesCacheHit(t *testing.T) {
433+ sty := styles.CharmtonePantera()
434+ const width = 71
435+
436+ thinking := strings.Join([]string{
437+ "Considering the request.",
438+ "Looking at the relevant files.",
439+ "Drafting a plan.",
440+ "Verifying constraints.",
441+ }, "\n")
442+ msg := thinkingMessage("hbox", thinking, "initial answer")
443+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
444+ item.thinkingExpanded = true
445+
446+ _ = item.RawRender(width)
447+ originalHeight := item.thinkingBoxHeight
448+ require.Greater(t, originalHeight, 0,
449+ "thinking box height must be populated after first render")
450+
451+ // Stomp the field so a stale read (cache hit that fails to
452+ // restore aux) is detectable. Then trigger a content-only
453+ // change: thinkingKey is byte-identical between renders, so
454+ // the thinking section cache must hit and restore the
455+ // preserved height via assistantSection.aux.
456+ item.thinkingBoxHeight = -1
457+ updated := thinkingMessage("hbox", thinking, "initial answer with more streamed text")
458+ item.SetMessage(updated)
459+ _ = item.RawRender(width)
460+
461+ require.Equal(t, originalHeight, item.thinkingBoxHeight,
462+ "thinkingBoxHeight must be preserved across thinking section cache hits "+
463+ "so HandleMouseClick keeps targeting the right rows")
464+}