@@ -1,9 +1,7 @@
11package chat
22
33import (
4− "encoding/binary"
54 "fmt"
6− "hash/fnv"
75 "strings"
86
97 tea "charm.land/bubbletea/v2"
@@ -93,11 +91,47 @@ func (s *assistantSection) reset() {
9391 *s = assistantSection{}
9492}
9593
96−// fnv64 hashes a single string with FNV-64.
94+// FNV-64a constants. We fold bytes into a plain uint64 accumulator
95+// rather than allocating a fnv.New64a per call: the returned
96+// hash.Hash64 escapes to the heap, which is wasteful on the per-render
97+// hashing hot path. The algorithm (offset basis, prime, xor-then-multiply)
98+// is identical to hash/fnv, so hashes are byte-for-byte the same.
99+const (
100+ fnvOffset64 = 14695981039346656037
101+ fnvPrime64 = 1099511628211
102+)
103+
104+// fnvAddString folds s into an in-progress FNV-64a hash.
105+func fnvAddString(h uint64, s string) uint64 {
106+ for i := 0; i < len(s); i++ {
107+ h ^= uint64(s[i])
108+ h *= fnvPrime64
109+ }
110+ return h
111+}
112+
113+// fnvAddBytes folds b into an in-progress FNV-64a hash.
114+func fnvAddBytes(h uint64, b []byte) uint64 {
115+ for _, c := range b {
116+ h ^= uint64(c)
117+ h *= fnvPrime64
118+ }
119+ return h
120+}
121+
122+// fnvAddU64 folds v's 8 little-endian bytes into an in-progress hash,
123+// matching binary.LittleEndian.PutUint64 followed by a Write.
124+func fnvAddU64(h, v uint64) uint64 {
125+ for i := 0; i < 8; i++ {
126+ h ^= uint64(byte(v >> (8 * i)))
127+ h *= fnvPrime64
128+ }
129+ return h
130+}
131+
132+// fnv64 hashes a single string with FNV-64a.
97133func fnv64(s string) uint64 {
98− h := fnv.New64a()
99− _, _ = h.Write([]byte(s))
100− return h.Sum64()
134+ return fnvAddString(fnvOffset64, s)
101135}
102136
103137// fnvFields hashes a list of byte fields with length-prefix framing
@@ -106,14 +140,41 @@ func fnv64(s string) uint64 {
106140// boundary between two fields). Each field is preceded by its
107141// length encoded as 8 bytes little-endian.
108142func fnvFields(fields ...[]byte) uint64 {
109− h := fnv.New64a()
110− var lenBuf [8]byte
143+ h := uint64(fnvOffset64)
111144 for _, f := range fields {
112− binary.LittleEndian.PutUint64(lenBuf[:], uint64(len(f)))
113− _, _ = h.Write(lenBuf[:])
114− _, _ = h.Write(f)
145+ h = fnvAddU64(h, uint64(len(f)))
146+ h = fnvAddBytes(h, f)
147+ }
148+ return h
149+}
150+
151+// assistantRenderKeys bundles the per-render cache keys and tool-call
152+// count computed once at the top of Render/RawRender and threaded
153+// through the render path, so each section's source text is hashed only
154+// once and ToolCalls() is materialized only once per render.
155+type assistantRenderKeys struct {
156+ thinkSrc, thinkExtra uint64
157+ contentSrc, contentExtra uint64
158+ errSrc, errExtra uint64
159+ toolCallCount int
160+}
161+
162+// renderKeys computes the per-render keys once. It calls ToolCalls()
163+// exactly once and reuses its length everywhere the render path needs it.
164+func (a *AssistantMessageItem) renderKeys() assistantRenderKeys {
165+ toolCallCount := len(a.message.ToolCalls())
166+ thinkSrc, thinkExtra := a.thinkingKey(toolCallCount)
167+ contentSrc, contentExtra := a.contentKey()
168+ errSrc, errExtra := a.errorKey()
169+ return assistantRenderKeys{
170+ thinkSrc: thinkSrc,
171+ thinkExtra: thinkExtra,
172+ contentSrc: contentSrc,
173+ contentExtra: contentExtra,
174+ errSrc: errSrc,
175+ errExtra: errExtra,
176+ toolCallCount: toolCallCount,
115177 }
116− return h.Sum64()
117178}
118179
119180// AssistantMessageItem represents an assistant message in the chat UI.
@@ -208,14 +269,20 @@ func (a *AssistantMessageItem) ID() string {
208269
209270// RawRender implements [MessageItem].
210271func (a *AssistantMessageItem) RawRender(width int) string {
272+ return a.rawRender(width, a.renderKeys())
273+}
274+
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 {
211278 cappedWidth := cappedMessageWidth(width)
212279
213280 var spinner string
214− if a.isSpinning() {
281+ if a.isSpinningWithToolCalls(keys.toolCallCount) {
215282 spinner = a.renderSpinning()
216283 }
217284
218− content, height := a.renderMessageContent(cappedWidth)
285+ content, height := a.renderMessageContent(cappedWidth, keys)
219286 highlightedContent := a.renderHighlighted(content, cappedWidth, height)
220287 if spinner != "" {
221288 if highlightedContent != "" {
@@ -243,21 +310,24 @@ func (a *AssistantMessageItem) Render(width int) string {
243310 // drop. Bypass the cache while spinning (RawRender's spinner
244311 // suffix changes every animation frame) or while a highlight
245312 // range is active (selection drag).
246− useCache := !a.isSpinning() && !a.isHighlighted()
313+ keys := a.renderKeys()
314+ useCache := !a.isSpinningWithToolCalls(keys.toolCallCount) && !a.isHighlighted()
247315 cappedWidth := cappedMessageWidth(width)
248− key := a.prefixCacheKey(cappedWidth)
316+ key := a.prefixCacheKey(cappedWidth, keys)
249317 if useCache {
250318 if cached, ok := a.getCachedPrefixedRender(width, key); ok {
251319 return cached
252320 }
253321 }
254− focused := a.sty.Messages.AssistantFocused.Render()
255− blurred := a.sty.Messages.AssistantBlurred.Render()
256− prefix := blurred
322+ // Only render the style actually used; rendering both wastes work
323+ // (lipgloss.Render is not free) when one is discarded.
324+ var prefix string
257325 if a.focused {
258− prefix = focused
326+ prefix = a.sty.Messages.AssistantFocused.Render()
327+ } else {
328+ prefix = a.sty.Messages.AssistantBlurred.Render()
259329 }
260− rendered := a.RawRender(width)
330+ rendered := a.rawRender(width, keys)
261331 lines := strings.Split(rendered, "\n")
262332 var sb strings.Builder
263333 for i, line := range lines {
@@ -283,27 +353,17 @@ func (a *AssistantMessageItem) Render(width int) string {
283353// folded in too because it controls the composition of
284354// renderMessageContent (e.g. appending the constant "Canceled"
285355// string) — that decision lives outside any section's own hash.
286−func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int) uint64 {
287− thinkSrc, thinkExtra := a.thinkingKey()
288− contentSrc, contentExtra := a.contentKey()
289− errSrc, errExtra := a.errorKey()
290− h := fnv.New64a()
291− var buf [8]byte
292− writeU64 := func(v uint64) {
293− for i := range 8 {
294− buf[i] = byte(v >> (8 * i))
295− }
296− _, _ = h.Write(buf[:])
297− }
298− writeU64(uint64(cappedWidth))
299− writeU64(thinkSrc)
300− writeU64(thinkExtra)
301− writeU64(contentSrc)
302− writeU64(contentExtra)
303− writeU64(errSrc)
304− writeU64(errExtra)
305− writeU64(a.compositionKey())
306− fingerprint := h.Sum64()
356+func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int, keys assistantRenderKeys) uint64 {
357+ h := uint64(fnvOffset64)
358+ h = fnvAddU64(h, uint64(cappedWidth))
359+ h = fnvAddU64(h, keys.thinkSrc)
360+ h = fnvAddU64(h, keys.thinkExtra)
361+ h = fnvAddU64(h, keys.contentSrc)
362+ h = fnvAddU64(h, keys.contentExtra)
363+ h = fnvAddU64(h, keys.errSrc)
364+ h = fnvAddU64(h, keys.errExtra)
365+ h = fnvAddU64(h, a.compositionKey())
366+ fingerprint := h
307367 var focusBit uint64
308368 if a.focused {
309369 focusBit = 1
@@ -332,20 +392,20 @@ func (a *AssistantMessageItem) compositionKey() uint64 {
332392// content, and finish reason. Each section is served from its own cache;
333393// only the section whose source text or extras changed since the last
334394// render is recomputed.
335−func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
395+func (a *AssistantMessageItem) renderMessageContent(width int, keys assistantRenderKeys) (string, int) {
336396 var messageParts []string
337397 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
338398 content := strings.TrimSpace(a.message.Content().Text)
339399
340400 if thinking != "" {
341− messageParts = append(messageParts, a.cachedThinking(width))
401+ messageParts = append(messageParts, a.cachedThinking(width, keys))
342402 }
343403
344404 if content != "" {
345405 if thinking != "" {
346406 messageParts = append(messageParts, "")
347407 }
348− messageParts = append(messageParts, a.cachedContent(width))
408+ messageParts = append(messageParts, a.cachedContent(width, keys))
349409 }
350410
351411 if a.message.IsFinished() {
@@ -353,7 +413,7 @@ func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
353413 case message.FinishReasonCanceled:
354414 messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
355415 case message.FinishReasonError:
356− messageParts = append(messageParts, a.cachedError(width))
416+ messageParts = append(messageParts, a.cachedError(width, keys))
357417 }
358418 }
359419
@@ -366,11 +426,11 @@ func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
366426// thinking text that affects the rendered output: the view mode
367427// (collapsed / tail-window / full) and the footer state (which
368428// depends on IsThinking, ToolCalls, and ThinkingDuration).
369−func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
429+func (a *AssistantMessageItem) thinkingKey(toolCallCount int) (uint64, uint64) {
370430 thinking := a.message.ReasoningContent().Thinking
371431 srcHash := fnv64(thinking)
372432
373− showFooter := !a.message.IsThinking() || len(a.message.ToolCalls()) > 0
433+ showFooter := !a.message.IsThinking() || toolCallCount > 0
374434 var durationStr string
375435 if showFooter {
376436 duration := a.message.ThinkingDuration()
@@ -417,20 +477,20 @@ func (a *AssistantMessageItem) errorKey() (uint64, uint64) {
417477// caching it on miss. The thinking-box height (used for click target
418478// detection) is preserved across hits via assistantSection.aux so the
419479// cached path never desyncs click detection.
420−func (a *AssistantMessageItem) cachedThinking(width int) string {
421− srcHash, extra := a.thinkingKey()
480+func (a *AssistantMessageItem) cachedThinking(width int, keys assistantRenderKeys) string {
481+ srcHash, extra := keys.thinkSrc, keys.thinkExtra
422482 if a.thinkingSec.hit(width, srcHash, extra) {
423483 a.thinkingBoxHeight = a.thinkingSec.aux
424484 return a.thinkingSec.out
425485 }
426− out := a.renderThinking(a.message.ReasoningContent().Thinking, width)
486+ out := a.renderThinking(a.message.ReasoningContent().Thinking, width, keys.toolCallCount)
427487 a.thinkingSec.store(width, srcHash, extra, out, a.thinkingBoxHeight)
428488 return out
429489}
430490
431491// cachedContent returns the rendered content section.
432−func (a *AssistantMessageItem) cachedContent(width int) string {
433− srcHash, extra := a.contentKey()
492+func (a *AssistantMessageItem) cachedContent(width int, keys assistantRenderKeys) string {
493+ srcHash, extra := keys.contentSrc, keys.contentExtra
434494 if a.contentSec.hit(width, srcHash, extra) {
435495 return a.contentSec.out
436496 }
@@ -440,8 +500,8 @@ func (a *AssistantMessageItem) cachedContent(width int) string {
440500}
441501
442502// cachedError returns the rendered error section.
443−func (a *AssistantMessageItem) cachedError(width int) string {
444− srcHash, extra := a.errorKey()
503+func (a *AssistantMessageItem) cachedError(width int, keys assistantRenderKeys) string {
504+ srcHash, extra := keys.errSrc, keys.errExtra
445505 if a.errorSec.hit(width, srcHash, extra) {
446506 return a.errorSec.out
447507 }
@@ -457,7 +517,7 @@ func (a *AssistantMessageItem) cachedError(width int) string {
457517// boundary problem §4.4 of the design note flags. The bordered
458518// ThinkingBox style is applied on top of the (already-windowed)
459519// lines so the visual box matches what the user sees today.
460−func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
520+func (a *AssistantMessageItem) renderThinking(thinking string, width int, toolCallCount int) string {
461521 renderer := common.QuietMarkdownRenderer(a.sty, width)
462522
463523 // Cap the raw text handed to glamour while the block is windowed.
@@ -509,7 +569,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
509569
510570 var footer string
511571 // if thinking is done add the thought for footer
512− if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
572+ if !a.message.IsThinking() || toolCallCount > 0 {
513573 duration := a.message.ThinkingDuration()
514574 if duration.String() != "0s" {
515575 footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
@@ -647,11 +707,16 @@ func (a *AssistantMessageItem) renderError(width int) string {
647707
648708// isSpinning returns true if the assistant message is still generating.
649709func (a *AssistantMessageItem) isSpinning() bool {
710+ return a.isSpinningWithToolCalls(len(a.message.ToolCalls()))
711+}
712+
713+// isSpinningWithToolCalls is isSpinning given an already-computed
714+// tool-call count, so the render path avoids re-materializing ToolCalls().
715+func (a *AssistantMessageItem) isSpinningWithToolCalls(toolCallCount int) bool {
650716 isThinking := a.message.IsThinking()
651717 isFinished := a.message.IsFinished()
652718 hasContent := strings.TrimSpace(a.message.Content().Text) != ""
653− hasToolCalls := len(a.message.ToolCalls()) > 0
654− return (isThinking || !isFinished) && !hasContent && !hasToolCalls
719+ return (isThinking || !isFinished) && !hasContent && toolCallCount == 0
655720}
656721
657722// SetMessage is used to update the underlying message. Only the