@@ -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}