1package sidebar
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "strings"
8
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/diff"
12 "github.com/charmbracelet/crush/internal/fsext"
13 "github.com/charmbracelet/crush/internal/history"
14 "github.com/charmbracelet/crush/internal/llm/models"
15 "github.com/charmbracelet/crush/internal/logging"
16 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/lsp/protocol"
18 "github.com/charmbracelet/crush/internal/pubsub"
19 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/tui/components/chat"
21 "github.com/charmbracelet/crush/internal/tui/components/core"
22 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
23 "github.com/charmbracelet/crush/internal/tui/components/logo"
24 "github.com/charmbracelet/crush/internal/tui/styles"
25 "github.com/charmbracelet/crush/internal/tui/util"
26 "github.com/charmbracelet/crush/internal/version"
27 "github.com/charmbracelet/lipgloss/v2"
28 "github.com/charmbracelet/x/ansi"
29)
30
31const (
32 logoBreakpoint = 65
33)
34
35type SessionFile struct {
36 FilePath string
37 Additions int
38 Deletions int
39}
40type SessionFilesMsg struct {
41 Files []SessionFile
42}
43
44type Sidebar interface {
45 util.Model
46 layout.Sizeable
47}
48
49type sidebarCmp struct {
50 width, height int
51 session session.Session
52 logo string
53 cwd string
54 lspClients map[string]*lsp.Client
55 history history.Service
56 files []SessionFile
57}
58
59func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client) Sidebar {
60 return &sidebarCmp{
61 lspClients: lspClients,
62 history: history,
63 }
64}
65
66func (m *sidebarCmp) Init() tea.Cmd {
67 m.logo = m.logoBlock(false)
68 m.cwd = cwd()
69 return nil
70}
71
72func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73 switch msg := msg.(type) {
74 case chat.SessionSelectedMsg:
75 if msg.ID != m.session.ID {
76 m.session = msg
77 }
78 return m, m.loadSessionFiles
79 case SessionFilesMsg:
80 m.files = msg.Files
81 logging.Info("Loaded session files", "count", len(m.files))
82 return m, nil
83
84 case chat.SessionClearedMsg:
85 m.session = session.Session{}
86 case pubsub.Event[session.Session]:
87 if msg.Type == pubsub.UpdatedEvent {
88 if m.session.ID == msg.Payload.ID {
89 m.session = msg.Payload
90 }
91 }
92 }
93 return m, nil
94}
95
96func (m *sidebarCmp) View() tea.View {
97 t := styles.CurrentTheme()
98 parts := []string{
99 m.logo,
100 }
101
102 if m.session.ID != "" {
103 parts = append(parts, t.S().Muted.Render(m.session.Title), "")
104 }
105
106 parts = append(parts,
107 m.cwd,
108 "",
109 m.currentModelBlock(),
110 )
111 if m.session.ID != "" {
112 parts = append(parts, "", m.filesBlock())
113 }
114 parts = append(parts,
115 "",
116 m.lspBlock(),
117 "",
118 m.mcpBlock(),
119 )
120
121 return tea.NewView(
122 lipgloss.JoinVertical(lipgloss.Left, parts...),
123 )
124}
125
126func (m *sidebarCmp) loadSessionFiles() tea.Msg {
127 files, err := m.history.ListBySession(context.Background(), m.session.ID)
128 if err != nil {
129 return util.InfoMsg{
130 Type: util.InfoTypeError,
131 Msg: err.Error(),
132 }
133 }
134
135 type fileHistory struct {
136 initialVersion history.File
137 latestVersion history.File
138 }
139
140 fileMap := make(map[string]fileHistory)
141
142 for _, file := range files {
143 if existing, ok := fileMap[file.Path]; ok {
144 // Update the latest version
145 if existing.latestVersion.CreatedAt < file.CreatedAt {
146 existing.latestVersion = file
147 }
148 if file.Version == history.InitialVersion {
149 existing.initialVersion = file
150 }
151 fileMap[file.Path] = existing
152 } else {
153 // Add the initial version
154 fileMap[file.Path] = fileHistory{
155 initialVersion: file,
156 latestVersion: file,
157 }
158 }
159 }
160
161 sessionFiles := make([]SessionFile, 0, len(fileMap))
162 for path, fh := range fileMap {
163 if fh.initialVersion.Version == history.InitialVersion {
164 _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
165 sessionFiles = append(sessionFiles, SessionFile{
166 FilePath: path,
167 Additions: additions,
168 Deletions: deletions,
169 })
170 }
171 }
172
173 return SessionFilesMsg{
174 Files: sessionFiles,
175 }
176}
177
178func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
179 if width < logoBreakpoint && m.width >= logoBreakpoint {
180 m.logo = m.logoBlock(true)
181 } else if width >= logoBreakpoint && m.width < logoBreakpoint {
182 m.logo = m.logoBlock(false)
183 }
184
185 m.width = width
186 m.height = height
187 return nil
188}
189
190func (m *sidebarCmp) GetSize() (int, int) {
191 return m.width, m.height
192}
193
194func (m *sidebarCmp) logoBlock(compact bool) string {
195 t := styles.CurrentTheme()
196 return logo.Render(version.Version, compact, logo.Opts{
197 FieldColor: t.Primary,
198 TitleColorA: t.Secondary,
199 TitleColorB: t.Primary,
200 CharmColor: t.Secondary,
201 VersionColor: t.Primary,
202 })
203}
204
205func (m *sidebarCmp) filesBlock() string {
206 maxWidth := min(m.width, 58)
207 t := styles.CurrentTheme()
208
209 section := t.S().Subtle.Render(
210 core.Section("Modified Files", maxWidth),
211 )
212
213 if len(m.files) == 0 {
214 return lipgloss.JoinVertical(
215 lipgloss.Left,
216 section,
217 "",
218 t.S().Base.Foreground(t.Border).Render("None"),
219 )
220 }
221
222 fileList := []string{section, ""}
223
224 for _, file := range m.files {
225 // Extract just the filename from the path
226
227 // Create status indicators for additions/deletions
228 var statusParts []string
229 if file.Additions > 0 {
230 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
231 }
232 if file.Deletions > 0 {
233 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
234 }
235
236 extraContent := strings.Join(statusParts, " ")
237 filePath := fsext.DirTrim(fsext.PrettyPath(file.FilePath), 2)
238 filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
239 fileList = append(fileList,
240 core.Status(
241 core.StatusOpts{
242 IconColor: t.FgMuted,
243 NoIcon: true,
244 Title: filePath,
245 ExtraContent: extraContent,
246 },
247 m.width,
248 ),
249 )
250 }
251
252 return lipgloss.JoinVertical(
253 lipgloss.Left,
254 fileList...,
255 )
256}
257
258func (m *sidebarCmp) lspBlock() string {
259 maxWidth := min(m.width, 58)
260 t := styles.CurrentTheme()
261
262 section := t.S().Subtle.Render(
263 core.Section("LSPs", maxWidth),
264 )
265
266 lspList := []string{section, ""}
267
268 lsp := config.Get().LSP
269 if len(lsp) == 0 {
270 return lipgloss.JoinVertical(
271 lipgloss.Left,
272 section,
273 "",
274 t.S().Base.Foreground(t.Border).Render("None"),
275 )
276 }
277
278 for n, l := range lsp {
279 iconColor := t.Success
280 if l.Disabled {
281 iconColor = t.FgMuted
282 }
283 lspErrs := map[protocol.DiagnosticSeverity]int{
284 protocol.SeverityError: 0,
285 protocol.SeverityWarning: 0,
286 protocol.SeverityHint: 0,
287 protocol.SeverityInformation: 0,
288 }
289 if client, ok := m.lspClients[n]; ok {
290 for _, diagnostics := range client.GetDiagnostics() {
291 for _, diagnostic := range diagnostics {
292 if severity, ok := lspErrs[diagnostic.Severity]; ok {
293 lspErrs[diagnostic.Severity] = severity + 1
294 }
295 }
296 }
297 }
298
299 errs := []string{}
300 if lspErrs[protocol.SeverityError] > 0 {
301 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
302 }
303 if lspErrs[protocol.SeverityWarning] > 0 {
304 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
305 }
306 if lspErrs[protocol.SeverityHint] > 0 {
307 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
308 }
309 if lspErrs[protocol.SeverityInformation] > 0 {
310 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
311 }
312
313 lspList = append(lspList,
314 core.Status(
315 core.StatusOpts{
316 IconColor: iconColor,
317 Title: n,
318 Description: l.Command,
319 ExtraContent: strings.Join(errs, " "),
320 },
321 m.width,
322 ),
323 )
324 }
325
326 return lipgloss.JoinVertical(
327 lipgloss.Left,
328 lspList...,
329 )
330}
331
332func (m *sidebarCmp) mcpBlock() string {
333 maxWidth := min(m.width, 58)
334 t := styles.CurrentTheme()
335
336 section := t.S().Subtle.Render(
337 core.Section("MCPs", maxWidth),
338 )
339
340 mcpList := []string{section, ""}
341
342 mcp := config.Get().MCPServers
343 if len(mcp) == 0 {
344 return lipgloss.JoinVertical(
345 lipgloss.Left,
346 section,
347 "",
348 t.S().Base.Foreground(t.Border).Render("None"),
349 )
350 }
351
352 for n, l := range mcp {
353 iconColor := t.Success
354 mcpList = append(mcpList,
355 core.Status(
356 core.StatusOpts{
357 IconColor: iconColor,
358 Title: n,
359 Description: l.Command,
360 },
361 m.width,
362 ),
363 )
364 }
365
366 return lipgloss.JoinVertical(
367 lipgloss.Left,
368 mcpList...,
369 )
370}
371
372func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
373 t := styles.CurrentTheme()
374 // Format tokens in human-readable format (e.g., 110K, 1.2M)
375 var formattedTokens string
376 switch {
377 case tokens >= 1_000_000:
378 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
379 case tokens >= 1_000:
380 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
381 default:
382 formattedTokens = fmt.Sprintf("%d", tokens)
383 }
384
385 // Remove .0 suffix if present
386 if strings.HasSuffix(formattedTokens, ".0K") {
387 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
388 }
389 if strings.HasSuffix(formattedTokens, ".0M") {
390 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
391 }
392
393 percentage := (float64(tokens) / float64(contextWindow)) * 100
394
395 baseStyle := t.S().Base
396
397 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
398
399 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
400 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
401 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
402 if percentage > 80 {
403 // add the warning icon
404 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
405 }
406
407 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
408}
409
410func (s *sidebarCmp) currentModelBlock() string {
411 cfg := config.Get()
412 agentCfg := cfg.Agents[config.AgentCoder]
413 selectedModelID := agentCfg.Model
414 model := models.SupportedModels[selectedModelID]
415
416 t := styles.CurrentTheme()
417
418 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
419 modelName := t.S().Text.Render(model.Name)
420 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
421 parts := []string{
422 modelInfo,
423 }
424 if s.session.ID != "" {
425 parts = append(
426 parts,
427 " "+formatTokensAndCost(
428 s.session.CompletionTokens+s.session.PromptTokens,
429 model.ContextWindow,
430 s.session.Cost,
431 ),
432 )
433 }
434 return lipgloss.JoinVertical(
435 lipgloss.Left,
436 parts...,
437 )
438}
439
440func cwd() string {
441 cwd := config.WorkingDirectory()
442 t := styles.CurrentTheme()
443 // replace home directory with ~
444 homeDir, err := os.UserHomeDir()
445 if err == nil {
446 cwd = strings.ReplaceAll(cwd, homeDir, "~")
447 }
448 return t.S().Muted.Render(cwd)
449}