@@ -272,26 +272,33 @@ func (a *AssistantMessageItem) RawRender(width int) string {
272272 return a.rawRender(width, a.renderKeys())
273273}
274274
275−// rawRender renders the item using per-render keys computed once by the
276−// caller, so the hashing/tool-call work is shared with Render.
277−func (a *AssistantMessageItem) rawRender(width int, keys assistantRenderKeys) string {
275+// rawRenderBody renders the assistant message body (thinking / content /
276+// error sections) WITHOUT the spinner suffix. The body is stable across
277+// animation ticks — only the spinner changes — so Render caches its
278+// prefixed form and reuses it while re-rendering just the spinner.
279+func (a *AssistantMessageItem) rawRenderBody(width int, keys assistantRenderKeys) string {
278280 cappedWidth := cappedMessageWidth(width)
281+ content, height := a.renderMessageContent(cappedWidth, keys)
282+ return a.renderHighlighted(content, cappedWidth, height)
283+}
279284
280− var spinner string
281− if a.isSpinningWithToolCalls(keys.toolCallCount) {
282− spinner = a.renderSpinning()
285+// rawRender renders the item body plus, when spinning, the spinner suffix.
286+// It is the reference full render; Render reproduces its output byte-for-byte
287+// via a cached body + freshly-prefixed spinner (validated in
288+// assistant_render_test.go).
289+func (a *AssistantMessageItem) rawRender(width int, keys assistantRenderKeys) string {
290+ body := a.rawRenderBody(width, keys)
291+ if !a.isSpinningWithToolCalls(keys.toolCallCount) {
292+ return body
283293 }
284−
285− content, height := a.renderMessageContent(cappedWidth, keys)
286− highlightedContent := a.renderHighlighted(content, cappedWidth, height)
287− if spinner != "" {
288− if highlightedContent != "" {
289− highlightedContent += "\n\n"
290− }
291− return highlightedContent + spinner
294+ spinner := a.renderSpinning()
295+ if spinner == "" {
296+ return body
292297 }
293−
294− return highlightedContent
298+ if body != "" {
299+ return body + "\n\n" + spinner
300+ }
301+ return spinner
295302}
296303
297304// Render implements MessageItem.
@@ -311,14 +318,10 @@ func (a *AssistantMessageItem) Render(width int) string {
311318 // suffix changes every animation frame) or while a highlight
312319 // range is active (selection drag).
313320 keys := a.renderKeys()
314− useCache := !a.isSpinningWithToolCalls(keys.toolCallCount) && !a.isHighlighted()
321+ spinning := a.isSpinningWithToolCalls(keys.toolCallCount)
315322 cappedWidth := cappedMessageWidth(width)
316323 key := a.prefixCacheKey(cappedWidth, keys)
317− if useCache {
318− if cached, ok := a.getCachedPrefixedRender(width, key); ok {
319− return cached
320− }
321− }
324+
322325 // Only render the style actually used; rendering both wastes work
323326 // (lipgloss.Render is not free) when one is discarded.
324327 var prefix string
@@ -327,8 +330,47 @@ func (a *AssistantMessageItem) Render(width int) string {
327330 } else {
328331 prefix = a.sty.Messages.AssistantBlurred.Render()
329332 }
330− rendered := a.rawRender(width, keys)
331− lines := strings.Split(rendered, "\n")
333+
334+ // Resolve the prefixed body. Its cache key excludes the spinner frame,
335+ // so it stays valid across animation ticks — the whole point of the
336+ // decoupling: prefix the (potentially long) thinking body once per
337+ // content change instead of on every 20fps spinner tick. Highlighting
338+ // mutates the body itself, so it bypasses the cache.
339+ useCache := !a.isHighlighted()
340+ prefixedBody, ok := "", false
341+ if useCache {
342+ prefixedBody, ok = a.getCachedPrefixedRender(width, key)
343+ }
344+ bodyEmpty := false
345+ if !ok {
346+ body := a.rawRenderBody(width, keys)
347+ bodyEmpty = body == ""
348+ prefixedBody = prefixLines(prefix, body)
349+ // Cache only a non-empty body: an empty body prefixes to the bare
350+ // prefix and is cheap to recompute, and the cache treats "" as a
351+ // miss, so caching it would be indistinguishable from no entry.
352+ if useCache && !bodyEmpty {
353+ a.setCachedPrefixedRender(prefixedBody, width, key)
354+ }
355+ }
356+ // A cache hit implies a non-empty body (empty bodies are never cached),
357+ // so bodyEmpty stays false on the hit path.
358+
359+ if !spinning {
360+ return prefixedBody
361+ }
362+ spinner := a.renderSpinning()
363+ if spinner == "" {
364+ return prefixedBody
365+ }
366+ return assembleSpinningRender(prefix, prefixedBody, bodyEmpty, prefixLines(prefix, spinner))
367+}
368+
369+// prefixLines prepends prefix to every line of s, matching the per-line
370+// focus/selection prefix that Render applies (done manually rather than via
371+// lipgloss.Render, whose wrapping logic is costly for long messages).
372+func prefixLines(prefix, s string) string {
373+ lines := strings.Split(s, "\n")
332374 var sb strings.Builder
333375 for i, line := range lines {
334376 if i > 0 {
@@ -337,11 +379,20 @@ func (a *AssistantMessageItem) Render(width int) string {
337379 sb.WriteString(prefix)
338380 sb.WriteString(line)
339381 }
340− out := sb.String()
341− if useCache {
342− a.setCachedPrefixedRender(out, width, key)
382+ return sb.String()
383+}
384+
385+// assembleSpinningRender reconstructs the prefixed body+spinner output
386+// byte-for-byte identically to prefixLines(prefix, body+"\n\n"+spinner),
387+// but from the separately-prefixed (and body-cached) parts. When the body is
388+// empty the reference render is the spinner alone; otherwise the "\n\n"
389+// separator becomes one prefixed empty line between body and spinner. This
390+// exact identity is validated in assistant_render_test.go.
391+func assembleSpinningRender(prefix, prefixedBody string, bodyEmpty bool, prefixedSpinner string) string {
392+ if bodyEmpty {
393+ return prefixedSpinner
343394 }
344− return out
395+ return prefixedBody + "\n" + prefix + "\n" + prefixedSpinner
345396}
346397
347398// prefixCacheKey builds the F3 prefixed-render cache key. We pack the