on main help sign in
perf(ui): decouple assistant spinner from cached body render

The assistant message body (thinking/content sections) is stable across 20fps spinner ticks, but Render bypassed its per-line prefix cache entirely while spinning, re-running the O(L) prefix loop on every animation frame — wasteful for long thinking traces on slow/reasoning models. Split the body out of rawRender, cache the prefixed body (its key already excludes the spinner frame), and reconstruct the full frame from the cached body plus a freshly-prefixed spinner. assembleSpinningRender reproduces the old single-pass prefixing byte-for-byte; a table test proves the identity across empty/multiline/blank-line bodies and spinners.

commit699924d232614da34a93d49228bf02fc9cdf170a authoriko <mail@iko.soy> dateWed 8 Jul 2026, 18:59 (3 months ago) parentdc0d982
2 files changed+157−28
internal/ui/chat/assistant.go+79−28 internal/ui/chat/assistant_render_test.go+78
internal/ui/chat/assistant.go+79−28
@@ -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
internal/ui/chat/assistant_render_test.gonew file+78
@@ -0,0 +1,78 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// combineBodySpinner replicates rawRender's spinning combine: an empty body
9+// yields the spinner alone, otherwise the two are joined by a blank line.
10+func combineBodySpinner(body, spinner string) string {
11+ if body != "" {
12+ return body + "\n\n" + spinner
13+ }
14+ return spinner
15+}
16+
17+// TestAssembleSpinningRender_ByteIdentical proves the decoupled Render path
18+// (cache the prefixed body, prefix the spinner separately, reassemble) is
19+// byte-for-byte identical to the old path of prefixing the full
20+// body+spinner string in one pass. If this ever diverges, the spinner
21+// decoupling would visibly corrupt assistant rendering.
22+func TestAssembleSpinningRender_ByteIdentical(t *testing.T) {
23+ t.Parallel()
24+ prefixes := []string{"", "P", "▏ ", "\x1b[38;5;1m│\x1b[0m "}
25+ bodies := []string{
26+ "",
27+ "single line",
28+ "line1\nline2\nline3",
29+ "has\n\ninternal blank",
30+ "trailing\n",
31+ }
32+ spinners := []string{
33+ "spin",
34+ "spin1\nspin2",
35+ "⠋ Thinking",
36+ }
37+ for _, prefix := range prefixes {
38+ for _, body := range bodies {
39+ for _, spinner := range spinners {
40+ want := prefixLines(prefix, combineBodySpinner(body, spinner))
41+ got := assembleSpinningRender(
42+ prefix,
43+ prefixLines(prefix, body),
44+ body == "",
45+ prefixLines(prefix, spinner),
46+ )
47+ if got != want {
48+ t.Errorf("prefix=%q body=%q spinner=%q\n got=%q\nwant=%q",
49+ prefix, body, spinner, got, want)
50+ }
51+ }
52+ }
53+ }
54+}
55+
56+// TestPrefixLines matches the original inline per-line prefix loop.
57+func TestPrefixLines(t *testing.T) {
58+ t.Parallel()
59+ cases := []struct{ prefix, in, want string }{
60+ {"P", "", "P"},
61+ {"P", "a", "Pa"},
62+ {"P", "a\nb", "Pa\nPb"},
63+ {"P", "a\n\nb", "Pa\nP\nPb"},
64+ {"", "a\nb", "a\nb"},
65+ {"P", "trailing\n", "Ptrailing\nP"},
66+ }
67+ for _, c := range cases {
68+ if got := prefixLines(c.prefix, c.in); got != c.want {
69+ t.Errorf("prefixLines(%q,%q)=%q want %q", c.prefix, c.in, got, c.want)
70+ }
71+ }
72+ // Cross-check against strings.Split semantics for a random-ish sample.
73+ s := "x1\nx2\n\nx3"
74+ want := strings.ReplaceAll("Q"+strings.ReplaceAll(s, "\n", "\nQ"), "\r", "")
75+ if got := prefixLines("Q", s); got != want {
76+ t.Errorf("prefixLines cross-check: got %q want %q", got, want)
77+ }
78+}