on main help sign in
perf(ui): cache render-hot-path work

- Memoize the sidebar and pills renders (content fingerprints of every output-affecting input) instead of fully re-rendering them every frame. - Cache the built chroma style per theme+bg and precompute a per-token style palette per Format call in the code formatter. - Hoist per-line width styles out of tool-output loops; render scrollbar thumb/track once; use a strings.Builder for prefixed line joins; hoist looksLikeMarkdown patterns to package scope. - Fix grad.go bold: assign the immutable style returned by Bold(true) so bold is actually applied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commit2c77d01475cb930a710401a1ebebb482c1f29584 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 03:18 (3 months ago) parent5dd90d8
10 files changed+296−75
internal/ui/chat/assistant.go+10−5 internal/ui/chat/tool_result_content.go+13−12 internal/ui/chat/tools.go+13−7 internal/ui/common/highlight.go+58−11 internal/ui/common/scrollbar.go+4−2 internal/ui/model/pills.go+43 internal/ui/model/sidebar.go+103−17 internal/ui/model/ui.go+13 internal/ui/styles/grad.go+2−2 internal/ui/xchroma/chroma.go+37−19
internal/ui/chat/assistant.go+10−5
@@ -253,16 +253,21 @@ func (a *AssistantMessageItem) Render(width int) string {
253253 }
254254 focused := a.sty.Messages.AssistantFocused.Render()
255255 blurred := a.sty.Messages.AssistantBlurred.Render()
256+ prefix := blurred
257+ if a.focused {
258+ prefix = focused
259+ }
256260 rendered := a.RawRender(width)
257261 lines := strings.Split(rendered, "\n")
262+ var sb strings.Builder
258263 for i, line := range lines {
259− if a.focused {
260− lines[i] = focused + line
261− } else {
262− lines[i] = blurred + line
264+ if i > 0 {
265+ sb.WriteString("\n")
263266 }
267+ sb.WriteString(prefix)
268+ sb.WriteString(line)
264269 }
265− out := strings.Join(lines, "\n")
270+ out := sb.String()
266271 if useCache {
267272 a.setCachedPrefixedRender(out, width, key)
268273 }
internal/ui/chat/tool_result_content.go+13−12
@@ -20,19 +20,20 @@ func humanizedToolName(name string) string {
2020 return stringext.Capitalize(name)
2121}
2222
23+var markdownPatterns = []string{
24+ "# ",
25+ "## ",
26+ "**",
27+ "```",
28+ "- ",
29+ "1. ",
30+ "> ",
31+ "---",
32+ "***",
33+}
34+
2335func looksLikeMarkdown(content string) bool {
24− patterns := []string{
25− "# ",
26− "## ",
27− "**",
28− "```",
29− "- ",
30− "1. ",
31− "> ",
32− "---",
33− "***",
34− }
35− for _, p := range patterns {
36+ for _, p := range markdownPatterns {
3637 if strings.Contains(content, p) {
3738 return true
3839 }
internal/ui/chat/tools.go+13−7
@@ -379,10 +379,15 @@ func (t *baseToolMessageItem) Render(width int) string {
379379 prefix = t.sty.Messages.ToolCallBlurred.Render()
380380 }
381381 lines := strings.Split(t.RawRender(width), "\n")
382+ var sb strings.Builder
382383 for i, ln := range lines {
383− lines[i] = prefix + ln
384+ if i > 0 {
385+ sb.WriteString("\n")
386+ }
387+ sb.WriteString(prefix)
388+ sb.WriteString(ln)
384389 }
385− out := strings.Join(lines, "\n")
390+ out := sb.String()
386391 if useCache {
387392 t.setCachedPrefixedRender(out, width, key)
388393 }
@@ -644,6 +649,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
644649 maxLines = len(lines) // Show all
645650 }
646651
652+ contentLineStyle := sty.Tool.ContentLine.Width(width)
647653 var out []string
648654 for i, ln := range lines {
649655 if i >= maxLines {
@@ -653,7 +659,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
653659 if lipgloss.Width(ln) > width {
654660 ln = ansi.Truncate(ln, width, "…")
655661 }
656− out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
662+ out = append(out, contentLineStyle.Render(ln))
657663 }
658664
659665 wasTruncated := len(lines) > responseContextHeight
@@ -695,16 +701,16 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
695701 bodyWidth := width - toolBodyLeftPaddingTotal
696702 codeWidth := bodyWidth - maxDigits
697703
704+ codeLineStyle := sty.Tool.ContentCodeLine.Width(codeWidth)
705+ codeLinePadding := sty.Tool.ContentCodeLine.GetHorizontalPadding()
698706 var out []string
699707 for i, ln := range highlightedLines {
700708 lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
701709
702710 // Truncate accounting for padding that will be added.
703− ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
711+ ln = ansi.Truncate(ln, codeWidth-codeLinePadding, "…")
704712
705− codeLine := sty.Tool.ContentCodeLine.
706− Width(codeWidth).
707− Render(ln)
713+ codeLine := codeLineStyle.Render(ln)
708714
709715 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
710716 }
internal/ui/common/highlight.go+58−11
@@ -2,7 +2,11 @@ package common
22
33import (
44 "bytes"
5+ "hash/fnv"
56 "image/color"
7+ "sort"
8+ "strconv"
9+ "sync"
610
711 "github.com/alecthomas/chroma/v2"
812 "github.com/alecthomas/chroma/v2/formatters"
@@ -11,6 +15,39 @@ import (
1115 "github.com/charmbracelet/crush/internal/ui/styles"
1216)
1317
18+// highlightStyleCache memoizes the *chroma.Style built for a given theme +
19+// background combination. Building the style (MustNewStyle + Builder.Transform.
20+// Build) parses colors for every entry and was previously done on every
21+// SyntaxHighlight call. The Styles struct is mutated in place on theme change,
22+// so we key on the theme's content fingerprint (plus bg) rather than its
23+// pointer identity.
24+var highlightStyleCache sync.Map // map[uint64]*chroma.Style
25+
26+// chromaStyleKey derives a stable cache key from the theme entries and bg.
27+func chromaStyleKey(theme chroma.StyleEntries, bg color.Color) uint64 {
28+ h := fnv.New64a()
29+ // Sort token types for a deterministic fingerprint (map order is random).
30+ types := make([]int, 0, len(theme))
31+ for tt := range theme {
32+ types = append(types, int(tt))
33+ }
34+ sort.Ints(types)
35+ for _, tt := range types {
36+ h.Write([]byte(strconv.Itoa(tt)))
37+ h.Write([]byte{0})
38+ h.Write([]byte(theme[chroma.TokenType(tt)]))
39+ h.Write([]byte{0})
40+ }
41+ r, g, b, a := bg.RGBA()
42+ var buf [8]byte
43+ buf[0] = byte(r >> 8)
44+ buf[1] = byte(g >> 8)
45+ buf[2] = byte(b >> 8)
46+ buf[3] = byte(a >> 8)
47+ h.Write(buf[:4])
48+ return h.Sum64()
49+}
50+
1451// SyntaxHighlight applies syntax highlighting to the given source code based
1552// on the file name and background color. It returns the highlighted code as a
1653// string.
@@ -31,18 +68,28 @@ func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color)
3168 f = formatters.Fallback
3269 }
3370
34− style := chroma.MustNewStyle("crush", st.ChromaTheme())
71+ // Build (or reuse a cached) chroma style for this theme + background.
72+ theme := st.ChromaTheme()
73+ key := chromaStyleKey(theme, bg)
74+ var s *chroma.Style
75+ if cached, ok := highlightStyleCache.Load(key); ok {
76+ s = cached.(*chroma.Style)
77+ } else {
78+ style := chroma.MustNewStyle("crush", theme)
3579
36− // Modify the style to use the provided background
37− s, err := style.Builder().Transform(
38− func(t chroma.StyleEntry) chroma.StyleEntry {
39− r, g, b, _ := bg.RGBA()
40− t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
41− return t
42− },
43− ).Build()
44− if err != nil {
45− s = chromastyles.Fallback
80+ // Modify the style to use the provided background
81+ built, err := style.Builder().Transform(
82+ func(t chroma.StyleEntry) chroma.StyleEntry {
83+ r, g, b, _ := bg.RGBA()
84+ t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
85+ return t
86+ },
87+ ).Build()
88+ if err != nil {
89+ built = chromastyles.Fallback
90+ }
91+ s = built
92+ highlightStyleCache.Store(key, s)
4693 }
4794
4895 // Tokenize and format
internal/ui/common/scrollbar.go+4−2
@@ -30,15 +30,17 @@ func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int)
3030 }
3131
3232 // Build the scrollbar.
33+ thumb := s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb)
34+ track := s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack)
3335 var sb strings.Builder
3436 for i := range height {
3537 if i > 0 {
3638 sb.WriteString("\n")
3739 }
3840 if i >= thumbPos && i < thumbPos+thumbSize {
39− sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb))
41+ sb.WriteString(thumb)
4042 } else {
41− sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack))
43+ sb.WriteString(track)
4244 }
4345 }
4446
internal/ui/model/pills.go+43
@@ -245,8 +245,51 @@ func (m *UI) pillsAreaHeight() int {
245245 return pillsAreaHeight
246246}
247247
248+// pillsCacheState builds a fingerprint of every input that affects the
249+// rendered pills. Returned alongside the width for reuse by renderPills.
250+func (m *UI) pillsCacheState() string {
251+ if !m.hasSession() {
252+ return "no-session"
253+ }
254+ width := m.layout.pills.Dx()
255+ hasQueue := m.promptQueue > 0
256+
257+ var kb strings.Builder
258+ fmt.Fprintf(&kb, "w=%d;theme=%s;q=%d;exp=%t;sec=%d;",
259+ width, m.themeKey, m.promptQueue, m.pillsExpanded, m.focusedPillSection)
260+ // The in-progress spinner icon changes every animation frame, so fold
261+ // its current view in only while spinning.
262+ if m.todoIsSpinning {
263+ kb.WriteString("spin=")
264+ kb.WriteString(m.todoSpinner.View())
265+ kb.WriteByte(';')
266+ }
267+ for i := range m.session.Todos {
268+ td := &m.session.Todos[i]
269+ fmt.Fprintf(&kb, "t%d=%v|%s|%s;", i, td.Status, td.Content, td.ActiveForm)
270+ }
271+ // The expanded queue list content only affects output when the queue
272+ // section is expanded and focused.
273+ if m.pillsExpanded && m.focusedPillSection == pillSectionQueue && hasQueue {
274+ if m.com != nil && m.com.Workspace != nil && m.com.Workspace.AgentIsReady() {
275+ for _, it := range m.com.Workspace.AgentQueuedPromptsList(m.session.ID) {
276+ kb.WriteString(it)
277+ kb.WriteByte(';')
278+ }
279+ }
280+ }
281+ return kb.String()
282+}
283+
248284// renderPills renders the pills panel and stores it in m.pillsView.
249285func (m *UI) renderPills() {
286+ // Skip re-rendering when nothing that affects the output changed.
287+ key := m.pillsCacheState()
288+ if key == m.pillsCacheKey {
289+ return
290+ }
291+ m.pillsCacheKey = key
292+
250293 m.pillsView = ""
251294 if !m.hasSession() {
252295 return
internal/ui/model/sidebar.go+103−17
@@ -3,7 +3,9 @@ package model
33import (
44 "cmp"
55 "fmt"
6+ "hash/fnv"
67 "image"
8+ "sort"
79
810 "charm.land/lipgloss/v2"
911 "github.com/charmbracelet/crush/internal/ui/common"
@@ -126,6 +128,80 @@ func getDynamicHeightLimits(availableHeight, fileCount, lspCount, mcpCount, skil
126128 return maxFiles, maxLSPs, maxMCPs, maxSkills
127129}
128130
131+// sidebarFingerprint computes a hash of every input that affects the
132+// rendered sidebar, so drawSidebar can reuse a cached render when nothing
133+// changed.
134+func (m *UI) sidebarFingerprint(area uv.Rectangle) uint64 {
135+ h := fnv.New64a()
136+ fmt.Fprintf(h, "w=%d;h=%d;theme=%s;", area.Dx(), area.Dy(), m.themeKey)
137+
138+ if m.session != nil {
139+ fmt.Fprintf(h, "sid=%s;title=%s;ct=%d;pt=%d;cost=%f;eu=%t;",
140+ m.session.ID, m.session.Title,
141+ m.session.CompletionTokens, m.session.PromptTokens,
142+ m.session.Cost, m.session.EstimatedUsage)
143+ }
144+
145+ fmt.Fprintf(h, "cwd=%s;hyper=%t;", m.com.Workspace.WorkingDir(), m.com.IsHyper())
146+ if m.hyperCredits != nil {
147+ fmt.Fprintf(h, "hc=%d;", *m.hyperCredits)
148+ }
149+ // The logo string is already cached; fold it in so any change to it (or
150+ // the small-render breakpoint) invalidates the sidebar.
151+ h.Write([]byte("logo="))
152+ h.Write([]byte(m.sidebarLogo))
153+ h.Write([]byte{';'})
154+
155+ if model := m.selectedLargeModel(); model != nil {
156+ fmt.Fprintf(h, "model=%s|%s|reason=%t|lvls=%d|def=%s|think=%t|eff=%s|ctx=%d;",
157+ model.ModelCfg.Provider, model.CatwalkCfg.Name,
158+ model.CatwalkCfg.CanReason, len(model.CatwalkCfg.ReasoningLevels),
159+ model.CatwalkCfg.DefaultReasoningEffort, model.ModelCfg.Think,
160+ model.ModelCfg.ReasoningEffort, model.CatwalkCfg.ContextWindow)
161+ } else {
162+ h.Write([]byte("model=nil;"))
163+ }
164+
165+ // Files.
166+ for i := range m.sessionFiles {
167+ f := &m.sessionFiles[i]
168+ fmt.Fprintf(h, "f%d=%s|%d|%d|%d;", i,
169+ f.LatestVersion.Path, f.Additions, f.Deletions, f.LatestVersion.UpdatedAt)
170+ }
171+
172+ // LSPs (sorted by name for a stable fingerprint), with diagnostic counts.
173+ lspNames := make([]string, 0, len(m.lspStates))
174+ for name := range m.lspStates {
175+ lspNames = append(lspNames, name)
176+ }
177+ sort.Strings(lspNames)
178+ for _, name := range lspNames {
179+ st := m.lspStates[name]
180+ counts := m.com.Workspace.LSPGetDiagnosticCounts(name)
181+ fmt.Fprintf(h, "l=%s|%v|err=%t|%d/%d/%d/%d;",
182+ st.Name, st.State, st.Error != nil,
183+ counts.Error, counts.Warning, counts.Hint, counts.Information)
184+ }
185+
186+ // MCPs (config order, matching the render).
187+ for _, mcpCfg := range m.com.Config().MCP.Sorted() {
188+ state, ok := m.mcpStates[mcpCfg.Name]
189+ if !ok {
190+ continue
191+ }
192+ fmt.Fprintf(h, "m=%s|%v|err=%t|%d/%d/%d;",
193+ state.Name, state.State, state.Error != nil,
194+ state.Counts.Tools, state.Counts.Prompts, state.Counts.Resources)
195+ }
196+
197+ // Skills.
198+ for _, it := range m.skillStatusItems() {
199+ fmt.Fprintf(h, "s=%s|%s|%s;", it.icon, it.name, it.title)
200+ }
201+
202+ return h.Sum64()
203+}
204+
129205// sidebar renders the chat sidebar containing session title, working
130206// directory, model info, file list, LSP status, and MCP status.
131207func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
@@ -133,6 +209,18 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
133209 return
134210 }
135211
212+ key := m.sidebarFingerprint(area)
213+ if !m.hasSidebarCache || key != m.sidebarCacheKey {
214+ m.sidebarView = m.buildSidebar(area)
215+ m.sidebarCacheKey = key
216+ m.hasSidebarCache = true
217+ }
218+
219+ uv.NewStyledString(m.sidebarView).Draw(scr, area)
220+}
221+
222+// buildSidebar renders the sidebar content block as a string.
223+func (m *UI) buildSidebar(area uv.Rectangle) string {
136224 const logoHeightBreakpoint = 30
137225
138226 t := m.com.Styles
@@ -194,22 +282,20 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
194282 skillsSection := m.skillsInfo(width, maxSkills, true)
195283 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
196284
197− uv.NewStyledString(
198− lipgloss.NewStyle().
199− MaxWidth(width).
200− MaxHeight(height).
201− Render(
202− lipgloss.JoinVertical(
203− lipgloss.Left,
204− sidebarHeader,
205− filesSection,
206− "",
207− lspSection,
208− "",
209− mcpSection,
210− "",
211− skillsSection,
212− ),
285+ return lipgloss.NewStyle().
286+ MaxWidth(width).
287+ MaxHeight(height).
288+ Render(
289+ lipgloss.JoinVertical(
290+ lipgloss.Left,
291+ sidebarHeader,
292+ filesSection,
293+ "",
294+ lspSection,
295+ "",
296+ mcpSection,
297+ "",
298+ skillsSection,
213299 ),
214− ).Draw(scr, area)
300+ )
215301}
internal/ui/model/ui.go+13
@@ -275,6 +275,13 @@ type UI struct {
275275 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
276276 sidebarLogo string
277277
278+ // sidebar render cache: drawSidebar fully re-renders every frame, so we
279+ // memoize the rendered block keyed by a fingerprint of every input that
280+ // affects its output.
281+ sidebarView string
282+ sidebarCacheKey uint64
283+ hasSidebarCache bool
284+
278285 // Notification state
279286 notifyBackend notification.Backend
280287 notifyWindowFocused bool
@@ -298,6 +305,10 @@ type UI struct {
298305 focusedPillSection pillSection
299306 promptQueue int
300307 pillsView string
308+ // pillsCacheKey fingerprints the inputs used to build pillsView so
309+ // renderPills can skip re-rendering (and recomputing the queue
310+ // gradient) when nothing that affects the output has changed.
311+ pillsCacheKey string
301312
302313 // Todo spinner
303314 todoSpinner spinner.Model
@@ -3952,6 +3963,8 @@ func (m *UI) newSession() tea.Cmd {
39523963 m.pillsAutoExpanded = false
39533964 m.promptQueue = 0
39543965 m.pillsView = ""
3966+ m.pillsCacheKey = ""
3967+ m.hasSidebarCache = false
39553968 m.historyReset()
39563969 agenttools.ResetCache()
39573970 return tea.Batch(
internal/ui/styles/grad.go+2−2
@@ -20,7 +20,7 @@ func ForegroundGrad(base lipgloss.Style, input string, bold bool, color1, color2
2020 if len(input) == 1 {
2121 style := base.Foreground(color1)
2222 if bold {
23− style.Bold(true)
23+ style = style.Bold(true)
2424 }
2525 return []string{style.Render(input)}
2626 }
@@ -34,7 +34,7 @@ func ForegroundGrad(base lipgloss.Style, input string, bold bool, color1, color2
3434 for i, c := range ramp {
3535 style := base.Foreground(c)
3636 if bold {
37− style.Bold(true)
37+ style = style.Bold(true)
3838 }
3939 clusters[i] = style.Render(clusters[i])
4040 }
internal/ui/xchroma/chroma.go+37−19
@@ -13,37 +13,55 @@ import (
1313// Lip Gloss for foreground styling, while keeping a forced background color.
1414func Formatter(bgColor color.Color, processValue func(string) string) chroma.Formatter {
1515 return chroma.FormatterFunc(func(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
16+ // Precompute the base background style once, then memoize the
17+ // resolved lipgloss.Style per token type so the per-token work is
18+ // just a map lookup + one Render.
19+ baseStyle := lipgloss.NewStyle().Background(bgColor)
20+
21+ type tokenEntry struct {
22+ style lipgloss.Style
23+ zero bool
24+ }
25+ palette := make(map[chroma.TokenType]tokenEntry)
26+
1627 for token := it(); token != chroma.EOF; token = it() {
1728 value := token.Value
1829 if processValue != nil {
1930 value = processValue(value)
2031 }
2132
22− entry := style.Get(token.Type)
23− if entry.IsZero() {
33+ te, ok := palette[token.Type]
34+ if !ok {
35+ entry := style.Get(token.Type)
36+ if entry.IsZero() {
37+ te = tokenEntry{zero: true}
38+ } else {
39+ s := baseStyle
40+ if entry.Bold == chroma.Yes {
41+ s = s.Bold(true)
42+ }
43+ if entry.Underline == chroma.Yes {
44+ s = s.Underline(true)
45+ }
46+ if entry.Italic == chroma.Yes {
47+ s = s.Italic(true)
48+ }
49+ if entry.Colour.IsSet() {
50+ s = s.Foreground(lipgloss.Color(entry.Colour.String()))
51+ }
52+ te = tokenEntry{style: s}
53+ }
54+ palette[token.Type] = te
55+ }
56+
57+ if te.zero {
2458 if _, err := fmt.Fprint(w, value); err != nil {
2559 return err
2660 }
2761 continue
2862 }
2963
30− s := lipgloss.NewStyle().
31− Background(bgColor)
32−
33− if entry.Bold == chroma.Yes {
34− s = s.Bold(true)
35− }
36− if entry.Underline == chroma.Yes {
37− s = s.Underline(true)
38− }
39− if entry.Italic == chroma.Yes {
40− s = s.Italic(true)
41− }
42− if entry.Colour.IsSet() {
43− s = s.Foreground(lipgloss.Color(entry.Colour.String()))
44− }
45−
46− if _, err := fmt.Fprint(w, s.Render(value)); err != nil {
64+ if _, err := fmt.Fprint(w, te.style.Render(value)); err != nil {
4765 return err
4866 }
4967 }