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, m.hyperCredits)
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.Sidebar.SessionTitle.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, logo.Opts{
146 Hyper: m.com.IsHyper(),
147 })
148 }
149 blocks := []string{
150 sidebarLogo,
151 title,
152 "",
153 cwd,
154 "",
155 m.modelInfo(width),
156 "",
157 }
158
159 sidebarHeader := lipgloss.JoinVertical(
160 lipgloss.Left,
161 blocks...,
162 )
163
164 var remainingHeightArea image.Rectangle
165 layout.Vertical(
166 layout.Len(lipgloss.Height(sidebarHeader)),
167 layout.Fill(1),
168 ).Split(m.layout.sidebar).Assign(new(image.Rectangle), &remainingHeightArea)
169 remainingHeight := remainingHeightArea.Dy() - 6
170 filesCount := 0
171 for _, f := range m.sessionFiles {
172 if f.Additions == 0 && f.Deletions == 0 {
173 continue
174 }
175 filesCount++
176 }
177
178 lspsCount := len(m.lspStates)
179
180 mcpsCount := 0
181 for _, mcpCfg := range m.com.Config().MCP.Sorted() {
182 if _, ok := m.mcpStates[mcpCfg.Name]; ok {
183 mcpsCount++
184 }
185 }
186
187 skillsCount := len(m.skillStatusItems())
188
189 maxFiles, maxLSPs, maxMCPs, maxSkills := getDynamicHeightLimits(remainingHeight, filesCount, lspsCount, mcpsCount, skillsCount)
190
191 lspSection := m.lspInfo(width, maxLSPs, true)
192 mcpSection := m.mcpInfo(width, maxMCPs, true)
193 skillsSection := m.skillsInfo(width, maxSkills, true)
194 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
195
196 uv.NewStyledString(
197 lipgloss.NewStyle().
198 MaxWidth(width).
199 MaxHeight(height).
200 Render(
201 lipgloss.JoinVertical(
202 lipgloss.Left,
203 sidebarHeader,
204 filesSection,
205 "",
206 lspSection,
207 "",
208 mcpSection,
209 "",
210 skillsSection,
211 ),
212 ),
213 ).Draw(scr, area)
214}