sidebar.go

  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}