1package model
2
3import (
4 "cmp"
5 "fmt"
6 "image"
7
8 "charm.land/lipgloss/v2"
9 "github.com/charmbracelet/crush/internal/ui/common"
10 "github.com/charmbracelet/crush/internal/ui/logo"
11 uv "github.com/charmbracelet/ultraviolet"
12 "github.com/charmbracelet/ultraviolet/layout"
13)
14
15// modelInfo renders the current model information including reasoning
16// settings and context usage/cost for the sidebar.
17func (m *UI) modelInfo(width int) string {
18 model := m.selectedLargeModel()
19 reasoningInfo := ""
20 providerName := ""
21
22 if model != nil {
23 // Get provider name first
24 providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
25 if ok {
26 providerName = providerConfig.Name
27
28 // Only check reasoning if model can reason
29 if model.CatwalkCfg.CanReason {
30 if len(model.CatwalkCfg.ReasoningLevels) == 0 {
31 if model.ModelCfg.Think {
32 reasoningInfo = "Thinking On"
33 } else {
34 reasoningInfo = "Thinking Off"
35 }
36 } else {
37 reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
38 reasoningInfo = fmt.Sprintf("Reasoning %s", common.FormatReasoningEffort(reasoningEffort))
39 }
40 }
41 }
42 }
43
44 var modelContext *common.ModelContextInfo
45 if model != nil && m.session != nil {
46 modelContext = &common.ModelContextInfo{
47 ContextUsed: m.session.CompletionTokens + m.session.PromptTokens,
48 Cost: m.session.Cost,
49 ModelContext: model.CatwalkCfg.ContextWindow,
50 }
51 }
52 var modelName string
53 if model != nil {
54 modelName = model.CatwalkCfg.Name
55 }
56 return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width)
57}
58
59// getDynamicHeightLimits will give us the num of items to show in each section based on the height
60// some items are more important than others.
61func getDynamicHeightLimits(availableHeight, fileCount, lspCount, mcpCount, skillCount int) (maxFiles, maxLSPs, maxMCPs, maxSkills int) {
62 const (
63 minItemsPerSection = 2
64 // Keep these high so dynamic layout uses available sidebar space
65 // instead of hitting small hard limits.
66 defaultMaxFilesShown = 1000
67 defaultMaxLSPsShown = 1000
68 defaultMaxMCPsShown = 1000
69 defaultMaxSkillsShown = 1000
70 minAvailableHeightLimit = 10
71 )
72
73 if availableHeight < minAvailableHeightLimit {
74 return minItemsPerSection, minItemsPerSection, minItemsPerSection, minItemsPerSection
75 }
76
77 maxFiles = minItemsPerSection
78 maxLSPs = minItemsPerSection
79 maxMCPs = minItemsPerSection
80 maxSkills = minItemsPerSection
81
82 remainingHeight := max(0, availableHeight-(minItemsPerSection*4))
83
84 sectionValues := []*int{&maxFiles, &maxLSPs, &maxMCPs, &maxSkills}
85 sectionCaps := []int{defaultMaxFilesShown, defaultMaxLSPsShown, defaultMaxMCPsShown, defaultMaxSkillsShown}
86 sectionNeeds := []int{max(0, fileCount-maxFiles), max(0, lspCount-maxLSPs), max(0, mcpCount-maxMCPs), max(0, skillCount-maxSkills)}
87
88 for remainingHeight > 0 {
89 allocated := false
90 for i, section := range sectionValues {
91 if remainingHeight == 0 {
92 break
93 }
94 if sectionNeeds[i] == 0 || *section >= sectionCaps[i] {
95 continue
96 }
97 *section = *section + 1
98 sectionNeeds[i]--
99 remainingHeight--
100 allocated = true
101 }
102 if !allocated {
103 break
104 }
105 }
106
107 for remainingHeight > 0 {
108 allocated := false
109 for i, section := range sectionValues {
110 if remainingHeight == 0 {
111 break
112 }
113 if *section >= sectionCaps[i] {
114 continue
115 }
116 *section = *section + 1
117 remainingHeight--
118 allocated = true
119 }
120 if !allocated {
121 break
122 }
123 }
124
125 return maxFiles, maxLSPs, maxMCPs, maxSkills
126}
127
128// sidebar renders the chat sidebar containing session title, working
129// directory, model info, file list, LSP status, and MCP status.
130func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
131 if m.session == nil {
132 return
133 }
134
135 const logoHeightBreakpoint = 30
136
137 t := m.com.Styles
138 width := area.Dx()
139 height := area.Dy()
140
141 title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
142 cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
143 sidebarLogo := m.sidebarLogo
144 if height < logoHeightBreakpoint {
145 sidebarLogo = logo.SmallRender(m.com.Styles, width)
146 }
147 blocks := []string{
148 sidebarLogo,
149 title,
150 "",
151 cwd,
152 "",
153 m.modelInfo(width),
154 "",
155 }
156
157 sidebarHeader := lipgloss.JoinVertical(
158 lipgloss.Left,
159 blocks...,
160 )
161
162 var remainingHeightArea image.Rectangle
163 layout.Vertical(
164 layout.Len(lipgloss.Height(sidebarHeader)),
165 layout.Fill(1),
166 ).Split(m.layout.sidebar).Assign(new(image.Rectangle), &remainingHeightArea)
167 remainingHeight := remainingHeightArea.Dy() - 6
168 filesCount := 0
169 for _, f := range m.sessionFiles {
170 if f.Additions == 0 && f.Deletions == 0 {
171 continue
172 }
173 filesCount++
174 }
175
176 lspsCount := len(m.lspStates)
177
178 mcpsCount := 0
179 for _, mcpCfg := range m.com.Config().MCP.Sorted() {
180 if _, ok := m.mcpStates[mcpCfg.Name]; ok {
181 mcpsCount++
182 }
183 }
184
185 skillsCount := len(m.skillStatusItems())
186
187 maxFiles, maxLSPs, maxMCPs, maxSkills := getDynamicHeightLimits(remainingHeight, filesCount, lspsCount, mcpsCount, skillsCount)
188
189 lspSection := m.lspInfo(width, maxLSPs, true)
190 mcpSection := m.mcpInfo(width, maxMCPs, true)
191 skillsSection := m.skillsInfo(width, maxSkills, true)
192 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
193
194 uv.NewStyledString(
195 lipgloss.NewStyle().
196 MaxWidth(width).
197 MaxHeight(height).
198 Render(
199 lipgloss.JoinVertical(
200 lipgloss.Left,
201 sidebarHeader,
202 filesSection,
203 "",
204 lspSection,
205 "",
206 mcpSection,
207 "",
208 skillsSection,
209 ),
210 ),
211 ).Draw(scr, area)
212}