on main help sign in
fix: cache streaming thinking renders to avoid CPU burn during long reasoning traces
3295a08Kieran Klukas
internal/ui/chat/assistant.go+15−7
@@ -144,6 +144,13 @@ type AssistantMessageItem struct {
144 // docs/notes/2026-05-12-chat-rendering-perf.md. See
144 // docs/notes/2026-05-12-chat-rendering-perf.md. See
145 // streaming_markdown.go for the full algorithm.
145 // streaming_markdown.go for the full algorithm.
146 streamingContent streamingMarkdown
146 streamingContent streamingMarkdown
147+
148+ // streamingThinking applies the same stable-prefix caching to
149+ // the thinking/reasoning section. Without this, every streaming
150+ // delta forces a full glamour re-render of the entire accumulated
151+ // thinking text, which burns CPU and starves the terminal emulator
152+ // during long reasoning traces.
153+ streamingThinking streamingMarkdown
147}
154}
148
155
149var _ Expandable = (*AssistantMessageItem)(nil)
156var _ Expandable = (*AssistantMessageItem)(nil)
@@ -447,13 +454,7 @@ func (a *AssistantMessageItem) cachedError(width int) string {
447// lines so the visual box matches what the user sees today.
454// lines so the visual box matches what the user sees today.
448func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
455func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
449 renderer := common.QuietMarkdownRenderer(a.sty, width)
456 renderer := common.QuietMarkdownRenderer(a.sty, width)
450− mu := common.LockMarkdownRenderer(renderer)
457+ rendered := a.streamingThinking.Render(thinking, width, renderer)
451− mu.Lock()
452− rendered, err := renderer.Render(thinking)
453− mu.Unlock()
454− if err != nil {
455− rendered = thinking
456− }
457 rendered = strings.TrimSpace(rendered)
458 rendered = strings.TrimSpace(rendered)
458
459
459 lines := strings.Split(rendered, "\n")
460 lines := strings.Split(rendered, "\n")
@@ -587,6 +588,7 @@ func (a *AssistantMessageItem) clearCache() {
587 a.contentSec.reset()
588 a.contentSec.reset()
588 a.errorSec.reset()
589 a.errorSec.reset()
589 a.streamingContent.Reset()
590 a.streamingContent.Reset()
591+ a.streamingThinking.Reset()
590}
592}
591
593
592// ToggleExpanded advances the F5 thinking view-mode cycle and returns
594// ToggleExpanded advances the F5 thinking view-mode cycle and returns
@@ -617,6 +619,12 @@ func (a *AssistantMessageItem) ToggleExpanded() bool {
617 case thinkingFullExpanded:
619 case thinkingFullExpanded:
618 a.thinkingViewMode = thinkingCollapsed
620 a.thinkingViewMode = thinkingCollapsed
619 }
621 }
622+ // View-mode changes alter the windowing slice applied after
623+ // glamour render. The streaming prefix cache may have been
624+ // seeded under a different slice regime, and glued renders are
625+ // not byte-identical to monolithic ones. Drop the prefix cache
626+ // so the next render is clean.
627+ a.streamingThinking.Reset()
620 a.Bump()
628 a.Bump()
621 return a.thinkingViewMode != thinkingCollapsed
629 return a.thinkingViewMode != thinkingCollapsed
622}
630}