1package sidebar
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "sort"
8 "strings"
9 "sync"
10
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/history"
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
31type FileHistory struct {
32 initialVersion history.File
33 latestVersion history.File
34}
35
36const LogoHeightBreakpoint = 40
37
38// Default maximum number of items to show in each section
39const (
40 DefaultMaxFilesShown = 10
41 DefaultMaxLSPsShown = 8
42 DefaultMaxMCPsShown = 8
43 MinItemsPerSection = 2 // Minimum items to show per section
44)
45
46type SessionFile struct {
47 History FileHistory
48 FilePath string
49 Additions int
50 Deletions int
51}
52type SessionFilesMsg struct {
53 Files []SessionFile
54}
55
56type Sidebar interface {
57 util.Model
58 layout.Sizeable
59 SetSession(session session.Session) tea.Cmd
60 SetCompactMode(bool)
61}
62
63type sidebarCmp struct {
64 width, height int
65 session session.Session
66 logo string
67 cwd string
68 lspClients map[string]*lsp.Client
69 compactMode bool
70 history history.Service
71 // Using a sync map here because we might receive file history events concurrently
72 files sync.Map
73}
74
75func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
76 return &sidebarCmp{
77 lspClients: lspClients,
78 history: history,
79 compactMode: compact,
80 }
81}
82
83func (m *sidebarCmp) Init() tea.Cmd {
84 return nil
85}
86
87func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88 switch msg := msg.(type) {
89 case SessionFilesMsg:
90 m.files = sync.Map{}
91 for _, file := range msg.Files {
92 m.files.Store(file.FilePath, file)
93 }
94 return m, nil
95
96 case chat.SessionClearedMsg:
97 m.session = session.Session{}
98 case pubsub.Event[history.File]:
99 return m, m.handleFileHistoryEvent(msg)
100 case pubsub.Event[session.Session]:
101 if msg.Type == pubsub.UpdatedEvent {
102 if m.session.ID == msg.Payload.ID {
103 m.session = msg.Payload
104 }
105 }
106 }
107 return m, nil
108}
109
110func (m *sidebarCmp) View() string {
111 t := styles.CurrentTheme()
112 parts := []string{}
113
114 if !m.compactMode {
115 if m.height > LogoHeightBreakpoint {
116 parts = append(parts, m.logo)
117 } else {
118 // Use a smaller logo for smaller screens
119 parts = append(parts, m.smallerScreenLogo(), "")
120 }
121 }
122
123 if !m.compactMode && m.session.ID != "" {
124 parts = append(parts, t.S().Muted.Render(m.session.Title), "")
125 } else if m.session.ID != "" {
126 parts = append(parts, t.S().Text.Render(m.session.Title), "")
127 }
128
129 if !m.compactMode {
130 parts = append(parts,
131 m.cwd,
132 "",
133 )
134 }
135 parts = append(parts,
136 m.currentModelBlock(),
137 )
138 if m.session.ID != "" {
139 parts = append(parts, "", m.filesBlock())
140 }
141 parts = append(parts,
142 "",
143 m.lspBlock(),
144 "",
145 m.mcpBlock(),
146 )
147
148 style := t.S().Base.
149 Width(m.width).
150 Height(m.height).
151 Padding(1)
152 if m.compactMode {
153 style = style.PaddingTop(0)
154 }
155 return style.Render(
156 lipgloss.JoinVertical(lipgloss.Left, parts...),
157 )
158}
159
160func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
161 return func() tea.Msg {
162 file := event.Payload
163 found := false
164 m.files.Range(func(key, value any) bool {
165 existing := value.(SessionFile)
166 if existing.FilePath == file.Path {
167 if existing.History.latestVersion.Version < file.Version {
168 existing.History.latestVersion = file
169 } else if file.Version == 0 {
170 existing.History.initialVersion = file
171 } else {
172 // If the version is not greater than the latest, we ignore it
173 return true
174 }
175 before := existing.History.initialVersion.Content
176 after := existing.History.latestVersion.Content
177 path := existing.History.initialVersion.Path
178 cwd := config.Get().WorkingDir()
179 path = strings.TrimPrefix(path, cwd)
180 _, additions, deletions := diff.GenerateDiff(before, after, path)
181 existing.Additions = additions
182 existing.Deletions = deletions
183 m.files.Store(file.Path, existing)
184 found = true
185 return false
186 }
187 return true
188 })
189 if found {
190 return nil
191 }
192 sf := SessionFile{
193 History: FileHistory{
194 initialVersion: file,
195 latestVersion: file,
196 },
197 FilePath: file.Path,
198 Additions: 0,
199 Deletions: 0,
200 }
201 m.files.Store(file.Path, sf)
202 return nil
203 }
204}
205
206func (m *sidebarCmp) loadSessionFiles() tea.Msg {
207 files, err := m.history.ListBySession(context.Background(), m.session.ID)
208 if err != nil {
209 return util.InfoMsg{
210 Type: util.InfoTypeError,
211 Msg: err.Error(),
212 }
213 }
214
215 fileMap := make(map[string]FileHistory)
216
217 for _, file := range files {
218 if existing, ok := fileMap[file.Path]; ok {
219 // Update the latest version
220 existing.latestVersion = file
221 fileMap[file.Path] = existing
222 } else {
223 // Add the initial version
224 fileMap[file.Path] = FileHistory{
225 initialVersion: file,
226 latestVersion: file,
227 }
228 }
229 }
230
231 sessionFiles := make([]SessionFile, 0, len(fileMap))
232 for path, fh := range fileMap {
233 cwd := config.Get().WorkingDir()
234 path = strings.TrimPrefix(path, cwd)
235 _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, path)
236 sessionFiles = append(sessionFiles, SessionFile{
237 History: fh,
238 FilePath: path,
239 Additions: additions,
240 Deletions: deletions,
241 })
242 }
243
244 return SessionFilesMsg{
245 Files: sessionFiles,
246 }
247}
248
249func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
250 m.logo = m.logoBlock()
251 m.cwd = cwd()
252 m.width = width
253 m.height = height
254 return nil
255}
256
257func (m *sidebarCmp) GetSize() (int, int) {
258 return m.width, m.height
259}
260
261func (m *sidebarCmp) logoBlock() string {
262 t := styles.CurrentTheme()
263 return logo.Render(version.Version, true, logo.Opts{
264 FieldColor: t.Primary,
265 TitleColorA: t.Secondary,
266 TitleColorB: t.Primary,
267 CharmColor: t.Secondary,
268 VersionColor: t.Primary,
269 Width: m.width - 2,
270 })
271}
272
273func (m *sidebarCmp) getMaxWidth() int {
274 return min(m.width-2, 58) // -2 for padding
275}
276
277// calculateAvailableHeight estimates how much height is available for dynamic content
278func (m *sidebarCmp) calculateAvailableHeight() int {
279 usedHeight := 0
280
281 if !m.compactMode {
282 if m.height > LogoHeightBreakpoint {
283 usedHeight += 7 // Approximate logo height
284 } else {
285 usedHeight += 2 // Smaller logo height
286 }
287 usedHeight += 1 // Empty line after logo
288 }
289
290 if m.session.ID != "" {
291 usedHeight += 1 // Title line
292 usedHeight += 1 // Empty line after title
293 }
294
295 if !m.compactMode {
296 usedHeight += 1 // CWD line
297 usedHeight += 1 // Empty line after CWD
298 }
299
300 usedHeight += 2 // Model info
301
302 usedHeight += 6 // 3 sections Γ 2 lines each (header + empty line)
303
304 // Base padding
305 usedHeight += 2 // Top and bottom padding
306
307 return max(0, m.height-usedHeight)
308}
309
310// getDynamicLimits calculates how many items to show in each section based on available height
311func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
312 availableHeight := m.calculateAvailableHeight()
313
314 // If we have very little space, use minimum values
315 if availableHeight < 10 {
316 return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
317 }
318
319 // Distribute available height among the three sections
320 // Give priority to files, then LSPs, then MCPs
321 totalSections := 3
322 heightPerSection := availableHeight / totalSections
323
324 // Calculate limits for each section, ensuring minimums
325 maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
326 maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
327 maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
328
329 // If we have extra space, give it to files first
330 remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
331 if remainingHeight > 0 {
332 extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
333 maxFiles += extraForFiles
334 remainingHeight -= extraForFiles
335
336 if remainingHeight > 0 {
337 extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
338 maxLSPs += extraForLSPs
339 remainingHeight -= extraForLSPs
340
341 if remainingHeight > 0 {
342 maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
343 }
344 }
345 }
346
347 return maxFiles, maxLSPs, maxMCPs
348}
349
350func (m *sidebarCmp) filesBlock() string {
351 t := styles.CurrentTheme()
352
353 section := t.S().Subtle.Render(
354 core.Section("Modified Files", m.getMaxWidth()),
355 )
356
357 files := make([]SessionFile, 0)
358 m.files.Range(func(key, value any) bool {
359 file := value.(SessionFile)
360 files = append(files, file)
361 return true // continue iterating
362 })
363 if len(files) == 0 {
364 return lipgloss.JoinVertical(
365 lipgloss.Left,
366 section,
367 "",
368 t.S().Base.Foreground(t.Border).Render("None"),
369 )
370 }
371
372 fileList := []string{section, ""}
373 // order files by the latest version's created time
374 sort.Slice(files, func(i, j int) bool {
375 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
376 })
377
378 // Limit the number of files shown
379 maxFiles, _, _ := m.getDynamicLimits()
380 maxFiles = min(len(files), maxFiles)
381 filesShown := 0
382
383 for _, file := range files {
384 if file.Additions == 0 && file.Deletions == 0 {
385 continue // skip files with no changes
386 }
387 if filesShown >= maxFiles {
388 break
389 }
390
391 var statusParts []string
392 if file.Additions > 0 {
393 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
394 }
395 if file.Deletions > 0 {
396 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
397 }
398
399 extraContent := strings.Join(statusParts, " ")
400 cwd := config.Get().WorkingDir() + string(os.PathSeparator)
401 filePath := file.FilePath
402 filePath = strings.TrimPrefix(filePath, cwd)
403 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
404 filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "β¦")
405 fileList = append(fileList,
406 core.Status(
407 core.StatusOpts{
408 IconColor: t.FgMuted,
409 NoIcon: true,
410 Title: filePath,
411 ExtraContent: extraContent,
412 },
413 m.getMaxWidth(),
414 ),
415 )
416 filesShown++
417 }
418
419 // Add indicator if there are more files
420 totalFilesWithChanges := 0
421 for _, file := range files {
422 if file.Additions > 0 || file.Deletions > 0 {
423 totalFilesWithChanges++
424 }
425 }
426 if totalFilesWithChanges > maxFiles {
427 remaining := totalFilesWithChanges - maxFiles
428 fileList = append(fileList,
429 t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("β¦ and %d more", remaining)),
430 )
431 }
432
433 return lipgloss.JoinVertical(
434 lipgloss.Left,
435 fileList...,
436 )
437}
438
439func (m *sidebarCmp) lspBlock() string {
440 t := styles.CurrentTheme()
441
442 section := t.S().Subtle.Render(
443 core.Section("LSPs", m.getMaxWidth()),
444 )
445
446 lspList := []string{section, ""}
447
448 lsp := config.Get().LSP.Sorted()
449 if len(lsp) == 0 {
450 return lipgloss.JoinVertical(
451 lipgloss.Left,
452 section,
453 "",
454 t.S().Base.Foreground(t.Border).Render("None"),
455 )
456 }
457
458 // Limit the number of LSPs shown
459 _, maxLSPs, _ := m.getDynamicLimits()
460 maxLSPs = min(len(lsp), maxLSPs)
461 for i, l := range lsp {
462 if i >= maxLSPs {
463 break
464 }
465
466 iconColor := t.Success
467 if l.LSP.Disabled {
468 iconColor = t.FgMuted
469 }
470 lspErrs := map[protocol.DiagnosticSeverity]int{
471 protocol.SeverityError: 0,
472 protocol.SeverityWarning: 0,
473 protocol.SeverityHint: 0,
474 protocol.SeverityInformation: 0,
475 }
476 if client, ok := m.lspClients[l.Name]; ok {
477 for _, diagnostics := range client.GetDiagnostics() {
478 for _, diagnostic := range diagnostics {
479 if severity, ok := lspErrs[diagnostic.Severity]; ok {
480 lspErrs[diagnostic.Severity] = severity + 1
481 }
482 }
483 }
484 }
485
486 errs := []string{}
487 if lspErrs[protocol.SeverityError] > 0 {
488 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
489 }
490 if lspErrs[protocol.SeverityWarning] > 0 {
491 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
492 }
493 if lspErrs[protocol.SeverityHint] > 0 {
494 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
495 }
496 if lspErrs[protocol.SeverityInformation] > 0 {
497 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
498 }
499
500 lspList = append(lspList,
501 core.Status(
502 core.StatusOpts{
503 IconColor: iconColor,
504 Title: l.Name,
505 Description: l.LSP.Command,
506 ExtraContent: strings.Join(errs, " "),
507 },
508 m.getMaxWidth(),
509 ),
510 )
511 }
512
513 // Add indicator if there are more LSPs
514 if len(lsp) > maxLSPs {
515 remaining := len(lsp) - maxLSPs
516 lspList = append(lspList,
517 t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("β¦ and %d more", remaining)),
518 )
519 }
520
521 return lipgloss.JoinVertical(
522 lipgloss.Left,
523 lspList...,
524 )
525}
526
527func (m *sidebarCmp) mcpBlock() string {
528 t := styles.CurrentTheme()
529
530 section := t.S().Subtle.Render(
531 core.Section("MCPs", m.getMaxWidth()),
532 )
533
534 mcpList := []string{section, ""}
535
536 mcps := config.Get().MCP.Sorted()
537 if len(mcps) == 0 {
538 return lipgloss.JoinVertical(
539 lipgloss.Left,
540 section,
541 "",
542 t.S().Base.Foreground(t.Border).Render("None"),
543 )
544 }
545
546 // Limit the number of MCPs shown
547 _, _, maxMCPs := m.getDynamicLimits()
548 maxMCPs = min(len(mcps), maxMCPs)
549 for i, l := range mcps {
550 if i >= maxMCPs {
551 break
552 }
553
554 iconColor := t.Success
555 if l.MCP.Disabled {
556 iconColor = t.FgMuted
557 }
558 mcpList = append(mcpList,
559 core.Status(
560 core.StatusOpts{
561 IconColor: iconColor,
562 Title: l.Name,
563 Description: l.MCP.Command,
564 },
565 m.getMaxWidth(),
566 ),
567 )
568 }
569
570 // Add indicator if there are more MCPs
571 if len(mcps) > maxMCPs {
572 remaining := len(mcps) - maxMCPs
573 mcpList = append(mcpList,
574 t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("β¦ and %d more", remaining)),
575 )
576 }
577
578 return lipgloss.JoinVertical(
579 lipgloss.Left,
580 mcpList...,
581 )
582}
583
584func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
585 t := styles.CurrentTheme()
586 // Format tokens in human-readable format (e.g., 110K, 1.2M)
587 var formattedTokens string
588 switch {
589 case tokens >= 1_000_000:
590 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
591 case tokens >= 1_000:
592 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
593 default:
594 formattedTokens = fmt.Sprintf("%d", tokens)
595 }
596
597 // Remove .0 suffix if present
598 if strings.HasSuffix(formattedTokens, ".0K") {
599 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
600 }
601 if strings.HasSuffix(formattedTokens, ".0M") {
602 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
603 }
604
605 percentage := (float64(tokens) / float64(contextWindow)) * 100
606
607 baseStyle := t.S().Base
608
609 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
610
611 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
612 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
613 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
614 if percentage > 80 {
615 // add the warning icon
616 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
617 }
618
619 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
620}
621
622func (s *sidebarCmp) currentModelBlock() string {
623 agentCfg := config.Get().Agents["coder"]
624 model := config.Get().GetModelByType(agentCfg.Model)
625
626 t := styles.CurrentTheme()
627
628 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
629 modelName := t.S().Text.Render(model.Model)
630 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
631 parts := []string{
632 modelInfo,
633 }
634 if s.session.ID != "" {
635 parts = append(
636 parts,
637 " "+formatTokensAndCost(
638 s.session.CompletionTokens+s.session.PromptTokens,
639 model.ContextWindow,
640 s.session.Cost,
641 ),
642 )
643 }
644 return lipgloss.JoinVertical(
645 lipgloss.Left,
646 parts...,
647 )
648}
649
650func (m *sidebarCmp) smallerScreenLogo() string {
651 t := styles.CurrentTheme()
652 title := t.S().Base.Foreground(t.Secondary).Render("Charmβ’")
653 title += " " + styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)
654 remainingWidth := m.width - lipgloss.Width(title) - 3
655 if remainingWidth > 0 {
656 char := "β±"
657 lines := strings.Repeat(char, remainingWidth)
658 title += " " + t.S().Base.Foreground(t.Primary).Render(lines)
659 }
660 return title
661}
662
663// SetSession implements Sidebar.
664func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
665 m.session = session
666 return m.loadSessionFiles
667}
668
669// SetCompactMode sets the compact mode for the sidebar.
670func (m *sidebarCmp) SetCompactMode(compact bool) {
671 m.compactMode = compact
672}
673
674func cwd() string {
675 cwd := config.Get().WorkingDir()
676 t := styles.CurrentTheme()
677 // Replace home directory with ~, unless we're at the top level of the
678 // home directory).
679 homeDir, err := os.UserHomeDir()
680 if err == nil && cwd != homeDir {
681 cwd = strings.ReplaceAll(cwd, homeDir, "~")
682 }
683 return t.S().Muted.Render(cwd)
684}