@@ -6,6 +6,7 @@ import (
66 "hash/fnv"
77 "image"
88 "sort"
9+ "strconv"
910
1011 "charm.land/lipgloss/v2"
1112 "github.com/charmbracelet/crush/internal/config"
@@ -134,41 +135,67 @@ func getDynamicHeightLimits(availableHeight, fileCount, lspCount, mcpCount, skil
134135// changed. skillItems and mcpSorted are computed once per render by
135136// drawSidebar and reused by buildSidebar to avoid recomputing them.
136137func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem, mcpSorted []config.MCP) uint64 {
137− h := fnv.New64a()
138− fmt.Fprintf(h, "w=%d;h=%d;theme=%s;", area.Dx(), area.Dy(), m.themeKey)
138+ // Build the fingerprint into a reused scratch buffer using strconv
139+ // appends instead of fmt.Fprintf. This runs on every frame (before the
140+ // sidebar render cache is consulted), so the reflection-based arg boxing
141+ // fmt.Fprintf performs was pure per-frame allocation churn. Appending to
142+ // a retained buffer and hashing it once is allocation-free after warmup.
143+ b := m.sidebarFPBuf[:0]
144+ putInt := func(k string, v int64) { b = append(b, k...); b = strconv.AppendInt(b, v, 10) }
145+ putStr := func(k, v string) { b = append(b, k...); b = append(b, v...) }
146+ putBool := func(k string, v bool) { b = append(b, k...); b = strconv.AppendBool(b, v) }
147+
148+ putInt("w=", int64(area.Dx()))
149+ putInt(";h=", int64(area.Dy()))
150+ putStr(";theme=", m.themeKey)
151+ b = append(b, ';')
139152
140153 if m.session != nil {
141− fmt.Fprintf(h, "sid=%s;title=%s;ct=%d;pt=%d;cost=%f;eu=%t;",
142− m.session.ID, m.session.Title,
143− m.session.CompletionTokens, m.session.PromptTokens,
144− m.session.Cost, m.session.EstimatedUsage)
154+ putStr("sid=", m.session.ID)
155+ putStr(";title=", m.session.Title)
156+ putInt(";ct=", m.session.CompletionTokens)
157+ putInt(";pt=", m.session.PromptTokens)
158+ b = append(b, ";cost="...)
159+ b = strconv.AppendFloat(b, m.session.Cost, 'f', -1, 64)
160+ putBool(";eu=", m.session.EstimatedUsage)
161+ b = append(b, ';')
145162 }
146163
147− fmt.Fprintf(h, "cwd=%s;hyper=%t;", m.com.Workspace.WorkingDir(), m.com.IsHyper())
164+ putStr("cwd=", m.com.Workspace.WorkingDir())
165+ putBool(";hyper=", m.com.IsHyper())
166+ b = append(b, ';')
148167 if m.hyperCredits != nil {
149− fmt.Fprintf(h, "hc=%d;", *m.hyperCredits)
168+ putInt("hc=", int64(*m.hyperCredits))
169+ b = append(b, ';')
150170 }
151171 // The logo string is already cached; fold it in so any change to it (or
152172 // the small-render breakpoint) invalidates the sidebar.
153− h.Write([]byte("logo="))
154− h.Write([]byte(m.sidebarLogo))
155− h.Write([]byte{';'})
173+ putStr("logo=", m.sidebarLogo)
174+ b = append(b, ';')
156175
157176 if model := m.selectedLargeModel(); model != nil {
158− fmt.Fprintf(h, "model=%s|%s|reason=%t|lvls=%d|def=%s|think=%t|eff=%s|ctx=%d;",
159− model.ModelCfg.Provider, model.CatwalkCfg.Name,
160− model.CatwalkCfg.CanReason, len(model.CatwalkCfg.ReasoningLevels),
161− model.CatwalkCfg.DefaultReasoningEffort, model.ModelCfg.Think,
162− model.ModelCfg.ReasoningEffort, model.CatwalkCfg.ContextWindow)
177+ putStr("model=", model.ModelCfg.Provider)
178+ putStr("|", model.CatwalkCfg.Name)
179+ putBool("|reason=", model.CatwalkCfg.CanReason)
180+ putInt("|lvls=", int64(len(model.CatwalkCfg.ReasoningLevels)))
181+ putStr("|def=", model.CatwalkCfg.DefaultReasoningEffort)
182+ putBool("|think=", model.ModelCfg.Think)
183+ putStr("|eff=", model.ModelCfg.ReasoningEffort)
184+ putInt("|ctx=", int64(model.CatwalkCfg.ContextWindow))
185+ b = append(b, ';')
163186 } else {
164− h.Write([]byte("model=nil;"))
187+ b = append(b, "model=nil;"...)
165188 }
166189
167190 // Files.
168191 for i := range m.sessionFiles {
169192 f := &m.sessionFiles[i]
170− fmt.Fprintf(h, "f%d=%s|%d|%d|%d;", i,
171− f.LatestVersion.Path, f.Additions, f.Deletions, f.LatestVersion.UpdatedAt)
193+ putInt("f", int64(i))
194+ putStr("=", f.LatestVersion.Path)
195+ putInt("|", int64(f.Additions))
196+ putInt("|", int64(f.Deletions))
197+ putInt("|", f.LatestVersion.UpdatedAt)
198+ b = append(b, ';')
172199 }
173200
174201 // LSPs (sorted by name for a stable fingerprint), with diagnostic counts.
@@ -188,9 +215,14 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem,
188215 // Hash the error text, not just its presence: the render prints
189216 // the message, so a changed message within the same state must
190217 // invalidate the cache.
191− fmt.Fprintf(h, "l=%s|%v|err=%s|%d/%d/%d/%d;",
192− st.Name, st.State, errText(st.Error),
193− counts.Error, counts.Warning, counts.Hint, counts.Information)
218+ putStr("l=", st.Name)
219+ putInt("|", int64(st.State))
220+ putStr("|err=", errText(st.Error))
221+ putInt("|", int64(counts.Error))
222+ putInt("/", int64(counts.Warning))
223+ putInt("/", int64(counts.Hint))
224+ putInt("/", int64(counts.Information))
225+ b = append(b, ';')
194226 }
195227
196228 // MCPs (config order, matching the render).
@@ -199,16 +231,26 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem,
199231 if !ok {
200232 continue
201233 }
202− fmt.Fprintf(h, "m=%s|%v|err=%s|%d/%d/%d;",
203− state.Name, state.State, errText(state.Error),
204− state.Counts.Tools, state.Counts.Prompts, state.Counts.Resources)
234+ putStr("m=", state.Name)
235+ putInt("|", int64(state.State))
236+ putStr("|err=", errText(state.Error))
237+ putInt("|", int64(state.Counts.Tools))
238+ putInt("/", int64(state.Counts.Prompts))
239+ putInt("/", int64(state.Counts.Resources))
240+ b = append(b, ';')
205241 }
206242
207243 // Skills.
208244 for _, it := range skillItems {
209− fmt.Fprintf(h, "s=%s|%s|%s;", it.icon, it.name, it.title)
245+ putStr("s=", it.icon)
246+ putStr("|", it.name)
247+ putStr("|", it.title)
248+ b = append(b, ';')
210249 }
211250
251+ m.sidebarFPBuf = b // retain the grown buffer for reuse next frame
252+ h := fnv.New64a()
253+ h.Write(b)
212254 return h.Sum64()
213255}
214256