on main help sign in
fix: cache streaming thinking renders to avoid CPU burn during long reasoning traces

renderThinking was doing a full glamour re-render on every 33ms streaming delta, unlike renderMarkdown which already used stable-prefix caching. For long thinking traces this burned CPU and starved the terminal emulator. Wire streamingThinking through the same streamingMarkdown prefix cache that content already uses. Reset on view-mode toggle since glued renders aren't byte-identical to monolithic ones.

commit3295a085cfb405c450ae3ac1fd89ab8989793e99 authorKieran Klukas <kieran@dunkirk.sh> dateThu 25 Jun 2026, 15:43 (4 months ago) parentf645040
1 file changed+15−7
internal/ui/chat/assistant.go+15−7
internal/ui/chat/assistant.go+15−7
@@ -144,6 +144,13 @@ type AssistantMessageItem struct {
144144 // docs/notes/2026-05-12-chat-rendering-perf.md. See
145145 // streaming_markdown.go for the full algorithm.
146146 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
147154}
148155
149156var _ Expandable = (*AssistantMessageItem)(nil)
@@ -447,13 +454,7 @@ func (a *AssistantMessageItem) cachedError(width int) string {
447454// lines so the visual box matches what the user sees today.
448455func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
449456 renderer := common.QuietMarkdownRenderer(a.sty, width)
450− mu := common.LockMarkdownRenderer(renderer)
451− mu.Lock()
452− rendered, err := renderer.Render(thinking)
453− mu.Unlock()
454− if err != nil {
455− rendered = thinking
456− }
457+ rendered := a.streamingThinking.Render(thinking, width, renderer)
457458 rendered = strings.TrimSpace(rendered)
458459
459460 lines := strings.Split(rendered, "\n")
@@ -587,6 +588,7 @@ func (a *AssistantMessageItem) clearCache() {
587588 a.contentSec.reset()
588589 a.errorSec.reset()
589590 a.streamingContent.Reset()
591+ a.streamingThinking.Reset()
590592}
591593
592594// ToggleExpanded advances the F5 thinking view-mode cycle and returns
@@ -617,6 +619,12 @@ func (a *AssistantMessageItem) ToggleExpanded() bool {
617619 case thinkingFullExpanded:
618620 a.thinkingViewMode = thinkingCollapsed
619621 }
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()
620628 a.Bump()
621629 return a.thinkingViewMode != thinkingCollapsed
622630}