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 EstimatedUsage: m.session.EstimatedUsage,
51 }
52 }
53 var modelName string
54 if model != nil {
55 modelName = model.CatwalkCfg.Name
56 }
57 return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width, m.hyperCredits)
58}
59
60// getDynamicHeightLimits will give us the num of items to show in each section based on the height
61// some items are more important than others.
62func getDynamicHeightLimits(availableHeight, fileCount, lspCount, mcpCount, skillCount int) (maxFiles, maxLSPs, maxMCPs, maxSkills int) {
63 const (
64 minItemsPerSection = 2
65 // Keep these high so dynamic layout uses available sidebar space
66 // instead of hitting small hard limits.
67 defaultMaxFilesShown = 1000
68 defaultMaxLSPsShown = 1000
69 defaultMaxMCPsShown = 1000
70 defaultMaxSkillsShown = 1000
71 minAvailableHeightLimit = 10
72 )
73
74 if availableHeight < minAvailableHeightLimit {
75 return minItemsPerSection, minItemsPerSection, minItemsPerSection, minItemsPerSection
76 }
77
78 maxFiles = minItemsPerSection
79 maxLSPs = minItemsPerSection
80 maxMCPs = minItemsPerSection
81 maxSkills = minItemsPerSection
82
83 remainingHeight := max(0, availableHeight-(minItemsPerSection*4))
84
85 sectionValues := []*int{&maxFiles, &maxLSPs, &maxMCPs, &maxSkills}
86 sectionCaps := []int{defaultMaxFilesShown, defaultMaxLSPsShown, defaultMaxMCPsShown, defaultMaxSkillsShown}
87 sectionNeeds := []int{max(0, fileCount-maxFiles), max(0, lspCount-maxLSPs), max(0, mcpCount-maxMCPs), max(0, skillCount-maxSkills)}
88
89 for remainingHeight > 0 {
90 allocated := false
91 for i, section := range sectionValues {
92 if remainingHeight == 0 {
93 break
94 }
95 if sectionNeeds[i] == 0 || *section >= sectionCaps[i] {
96 continue
97 }
98 *section = *section + 1
99 sectionNeeds[i]--
100 remainingHeight--
101 allocated = true
102 }
103 if !allocated {
104 break
105 }
106 }
107
108 for remainingHeight > 0 {
109 allocated := false
110 for i, section := range sectionValues {
111 if remainingHeight == 0 {
112 break
113 }
114 if *section >= sectionCaps[i] {
115 continue
116 }
117 *section = *section + 1
118 remainingHeight--
119 allocated = true
120 }
121 if !allocated {
122 break
123 }
124 }
125
126 return maxFiles, maxLSPs, maxMCPs, maxSkills
127}
128
129// sidebar renders the chat sidebar containing session title, working
130// directory, model info, file list, LSP status, and MCP status.
131func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
132 if m.session == nil {
133 return
134 }
135
136 const logoHeightBreakpoint = 30
137
138 t := m.com.Styles
139 width := area.Dx()
140 height := area.Dy()
141
142 title := t.Sidebar.SessionTitle.Width(width).MaxHeight(2).Render(m.session.Title)
143 cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
144 sidebarLogo := m.sidebarLogo
145 if height < logoHeightBreakpoint {
146 sidebarLogo = logo.SmallRender(m.com.Styles, width, logo.Opts{
147 Hyper: m.com.IsHyper(),
148 })
149 }
150 blocks := []string{
151 sidebarLogo,
152 title,
153 "",
154 cwd,
155 "",
156 m.modelInfo(width),
157 "",
158 }
159
160 sidebarHeader := lipgloss.JoinVertical(
161 lipgloss.Left,
162 blocks...,
163 )
164
165 var remainingHeightArea image.Rectangle
166 layout.Vertical(
167 layout.Len(lipgloss.Height(sidebarHeader)),
168 layout.Fill(1),
169 ).Split(m.layout.sidebar).Assign(new(image.Rectangle), &remainingHeightArea)
170 remainingHeight := remainingHeightArea.Dy() - 6
171 filesCount := 0
172 for _, f := range m.sessionFiles {
173 if f.Additions == 0 && f.Deletions == 0 {
174 continue
175 }
176 filesCount++
177 }
178
179 lspsCount := len(m.lspStates)
180
181 mcpsCount := 0
182 for _, mcpCfg := range m.com.Config().MCP.Sorted() {
183 if _, ok := m.mcpStates[mcpCfg.Name]; ok {
184 mcpsCount++
185 }
186 }
187
188 skillsCount := len(m.skillStatusItems())
189
190 maxFiles, maxLSPs, maxMCPs, maxSkills := getDynamicHeightLimits(remainingHeight, filesCount, lspsCount, mcpsCount, skillsCount)
191
192 lspSection := m.lspInfo(width, maxLSPs, true)
193 mcpSection := m.mcpInfo(width, maxMCPs, true)
194 skillsSection := m.skillsInfo(width, maxSkills, true)
195 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
196
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 ),
213 ),
214 ).Draw(scr, area)
215}