1package sidebar
2
3import (
4 "fmt"
5 "os"
6 "strings"
7
8 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/llm/models"
11 "github.com/charmbracelet/crush/internal/logging"
12 "github.com/charmbracelet/crush/internal/lsp"
13 "github.com/charmbracelet/crush/internal/lsp/protocol"
14 "github.com/charmbracelet/crush/internal/pubsub"
15 "github.com/charmbracelet/crush/internal/session"
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/logo"
20 "github.com/charmbracelet/crush/internal/tui/styles"
21 "github.com/charmbracelet/crush/internal/tui/util"
22 "github.com/charmbracelet/crush/internal/version"
23 "github.com/charmbracelet/lipgloss/v2"
24)
25
26const (
27 logoBreakpoint = 65
28)
29
30type Sidebar interface {
31 util.Model
32 layout.Sizeable
33}
34
35type sidebarCmp struct {
36 width, height int
37 session session.Session
38 logo string
39 cwd string
40 lspClients map[string]*lsp.Client
41}
42
43func NewSidebarCmp(lspClients map[string]*lsp.Client) Sidebar {
44 return &sidebarCmp{
45 lspClients: lspClients,
46 }
47}
48
49func (m *sidebarCmp) Init() tea.Cmd {
50 m.logo = m.logoBlock(false)
51 m.cwd = cwd()
52 return nil
53}
54
55func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
56 switch msg := msg.(type) {
57 case chat.SessionSelectedMsg:
58 if msg.ID != m.session.ID {
59 m.session = msg
60 }
61 case chat.SessionClearedMsg:
62 m.session = session.Session{}
63 case pubsub.Event[session.Session]:
64 if msg.Type == pubsub.UpdatedEvent {
65 if m.session.ID == msg.Payload.ID {
66 m.session = msg.Payload
67 }
68 }
69 }
70 return m, nil
71}
72
73func (m *sidebarCmp) View() tea.View {
74 t := styles.CurrentTheme()
75 parts := []string{
76 m.logo,
77 }
78
79 if m.session.ID != "" {
80 parts = append(parts, t.S().Muted.Render(m.session.Title), "")
81 }
82
83 parts = append(parts,
84 m.cwd,
85 "",
86 m.currentModelBlock(),
87 "",
88 m.lspBlock(),
89 "",
90 m.mcpBlock(),
91 )
92
93 return tea.NewView(
94 lipgloss.JoinVertical(lipgloss.Left, parts...),
95 )
96}
97
98func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
99 if width < logoBreakpoint && m.width >= logoBreakpoint {
100 m.logo = m.logoBlock(true)
101 } else if width >= logoBreakpoint && m.width < logoBreakpoint {
102 m.logo = m.logoBlock(false)
103 }
104
105 m.width = width
106 m.height = height
107 return nil
108}
109
110func (m *sidebarCmp) GetSize() (int, int) {
111 return m.width, m.height
112}
113
114func (m *sidebarCmp) logoBlock(compact bool) string {
115 t := styles.CurrentTheme()
116 return logo.Render(version.Version, compact, logo.Opts{
117 FieldColor: t.Primary,
118 TitleColorA: t.Secondary,
119 TitleColorB: t.Primary,
120 CharmColor: t.Secondary,
121 VersionColor: t.Primary,
122 })
123}
124
125func (m *sidebarCmp) lspBlock() string {
126 maxWidth := min(m.width, 58)
127 t := styles.CurrentTheme()
128
129 section := t.S().Muted.Render(
130 core.Section("LSPs", maxWidth),
131 )
132
133 lspList := []string{section, ""}
134
135 lsp := config.Get().LSP
136 if len(lsp) == 0 {
137 return lipgloss.JoinVertical(
138 lipgloss.Left,
139 section,
140 "",
141 t.S().Base.Foreground(t.Border).Render("None"),
142 )
143 }
144
145 for n, l := range lsp {
146 iconColor := t.Success
147 if l.Disabled {
148 iconColor = t.FgMuted
149 }
150 lspErrs := map[protocol.DiagnosticSeverity]int{
151 protocol.SeverityError: 0,
152 protocol.SeverityWarning: 0,
153 protocol.SeverityHint: 0,
154 protocol.SeverityInformation: 0,
155 }
156 if client, ok := m.lspClients[n]; ok {
157 for _, diagnostics := range client.GetDiagnostics() {
158 for _, diagnostic := range diagnostics {
159 if severity, ok := lspErrs[diagnostic.Severity]; ok {
160 lspErrs[diagnostic.Severity] = severity + 1
161 }
162 }
163 }
164 }
165
166 errs := []string{}
167 if lspErrs[protocol.SeverityError] > 0 {
168 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
169 }
170 if lspErrs[protocol.SeverityWarning] > 0 {
171 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
172 }
173 if lspErrs[protocol.SeverityHint] > 0 {
174 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
175 }
176 if lspErrs[protocol.SeverityInformation] > 0 {
177 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
178 }
179
180 logging.Info("LSP Errors", "errors", errs)
181 lspList = append(lspList,
182 core.Status(
183 core.StatusOpts{
184 IconColor: iconColor,
185 Title: n,
186 Description: l.Command,
187 ExtraContent: strings.Join(errs, " "),
188 },
189 m.width,
190 ),
191 )
192 }
193
194 return lipgloss.JoinVertical(
195 lipgloss.Left,
196 lspList...,
197 )
198}
199
200func (m *sidebarCmp) mcpBlock() string {
201 maxWidth := min(m.width, 58)
202 t := styles.CurrentTheme()
203
204 section := t.S().Muted.Render(
205 core.Section("MCPs", maxWidth),
206 )
207
208 mcpList := []string{section, ""}
209
210 mcp := config.Get().MCPServers
211 if len(mcp) == 0 {
212 return lipgloss.JoinVertical(
213 lipgloss.Left,
214 section,
215 "",
216 t.S().Base.Foreground(t.Border).Render("None"),
217 )
218 }
219
220 for n, l := range mcp {
221 iconColor := t.Success
222 mcpList = append(mcpList,
223 core.Status(
224 core.StatusOpts{
225 IconColor: iconColor,
226 Title: n,
227 Description: l.Command,
228 },
229 m.width,
230 ),
231 )
232 }
233
234 return lipgloss.JoinVertical(
235 lipgloss.Left,
236 mcpList...,
237 )
238}
239
240func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
241 t := styles.CurrentTheme()
242 // Format tokens in human-readable format (e.g., 110K, 1.2M)
243 var formattedTokens string
244 switch {
245 case tokens >= 1_000_000:
246 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
247 case tokens >= 1_000:
248 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
249 default:
250 formattedTokens = fmt.Sprintf("%d", tokens)
251 }
252
253 // Remove .0 suffix if present
254 if strings.HasSuffix(formattedTokens, ".0K") {
255 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
256 }
257 if strings.HasSuffix(formattedTokens, ".0M") {
258 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
259 }
260
261 percentage := (float64(tokens) / float64(contextWindow)) * 100
262
263 baseStyle := t.S().Base
264
265 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
266
267 formattedTokens = baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("(%s)", formattedTokens))
268 formattedPercentage := baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("%d%%", int(percentage)))
269 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
270 if percentage > 80 {
271 // add the warning icon
272 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
273 }
274
275 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
276}
277
278func (s *sidebarCmp) currentModelBlock() string {
279 cfg := config.Get()
280 agentCfg := cfg.Agents[config.AgentCoder]
281 selectedModelID := agentCfg.Model
282 model := models.SupportedModels[selectedModelID]
283
284 t := styles.CurrentTheme()
285
286 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
287 modelName := t.S().Text.Render(model.Name)
288 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
289 parts := []string{
290 // section,
291 // "",
292 modelInfo,
293 }
294 if s.session.ID != "" {
295 parts = append(
296 parts,
297 " "+formatTokensAndCost(
298 s.session.CompletionTokens+s.session.PromptTokens,
299 model.ContextWindow,
300 s.session.Cost,
301 ),
302 )
303 }
304 return lipgloss.JoinVertical(
305 lipgloss.Left,
306 parts...,
307 )
308}
309
310func cwd() string {
311 cwd := config.WorkingDirectory()
312 t := styles.CurrentTheme()
313 // replace home directory with ~
314 homeDir, err := os.UserHomeDir()
315 if err == nil {
316 cwd = strings.ReplaceAll(cwd, homeDir, "~")
317 }
318 return t.S().Muted.Render(cwd)
319}