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
139 // Check if we should use horizontal layout for sections
140 if m.compactMode && m.width > m.height {
141 // Horizontal layout for compact mode when width > height
142 sectionsContent := m.renderSectionsHorizontal()
143 if sectionsContent != "" {
144 parts = append(parts, "", sectionsContent)
145 }
146 } else {
147 // Vertical layout (default)
148 if m.session.ID != "" {
149 parts = append(parts, "", m.filesBlock())
150 }
151 parts = append(parts,
152 "",
153 m.lspBlock(),
154 "",
155 m.mcpBlock(),
156 )
157 }
158
159 style := t.S().Base.
160 Width(m.width).
161 Height(m.height).
162 Padding(1)
163 if m.compactMode {
164 style = style.PaddingTop(0)
165 }
166 return style.Render(
167 lipgloss.JoinVertical(lipgloss.Left, parts...),
168 )
169}
170
171func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
172 return func() tea.Msg {
173 file := event.Payload
174 found := false
175 m.files.Range(func(key, value any) bool {
176 existing := value.(SessionFile)
177 if existing.FilePath == file.Path {
178 if existing.History.latestVersion.Version < file.Version {
179 existing.History.latestVersion = file
180 } else if file.Version == 0 {
181 existing.History.initialVersion = file
182 } else {
183 // If the version is not greater than the latest, we ignore it
184 return true
185 }
186 before := existing.History.initialVersion.Content
187 after := existing.History.latestVersion.Content
188 path := existing.History.initialVersion.Path
189 cwd := config.Get().WorkingDir()
190 path = strings.TrimPrefix(path, cwd)
191 _, additions, deletions := diff.GenerateDiff(before, after, path)
192 existing.Additions = additions
193 existing.Deletions = deletions
194 m.files.Store(file.Path, existing)
195 found = true
196 return false
197 }
198 return true
199 })
200 if found {
201 return nil
202 }
203 sf := SessionFile{
204 History: FileHistory{
205 initialVersion: file,
206 latestVersion: file,
207 },
208 FilePath: file.Path,
209 Additions: 0,
210 Deletions: 0,
211 }
212 m.files.Store(file.Path, sf)
213 return nil
214 }
215}
216
217func (m *sidebarCmp) loadSessionFiles() tea.Msg {
218 files, err := m.history.ListBySession(context.Background(), m.session.ID)
219 if err != nil {
220 return util.InfoMsg{
221 Type: util.InfoTypeError,
222 Msg: err.Error(),
223 }
224 }
225
226 fileMap := make(map[string]FileHistory)
227
228 for _, file := range files {
229 if existing, ok := fileMap[file.Path]; ok {
230 // Update the latest version
231 existing.latestVersion = file
232 fileMap[file.Path] = existing
233 } else {
234 // Add the initial version
235 fileMap[file.Path] = FileHistory{
236 initialVersion: file,
237 latestVersion: file,
238 }
239 }
240 }
241
242 sessionFiles := make([]SessionFile, 0, len(fileMap))
243 for path, fh := range fileMap {
244 cwd := config.Get().WorkingDir()
245 path = strings.TrimPrefix(path, cwd)
246 _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, path)
247 sessionFiles = append(sessionFiles, SessionFile{
248 History: fh,
249 FilePath: path,
250 Additions: additions,
251 Deletions: deletions,
252 })
253 }
254
255 return SessionFilesMsg{
256 Files: sessionFiles,
257 }
258}
259
260func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
261 m.logo = m.logoBlock()
262 m.cwd = cwd()
263 m.width = width
264 m.height = height
265 return nil
266}
267
268func (m *sidebarCmp) GetSize() (int, int) {
269 return m.width, m.height
270}
271
272func (m *sidebarCmp) logoBlock() string {
273 t := styles.CurrentTheme()
274 return logo.Render(version.Version, true, logo.Opts{
275 FieldColor: t.Primary,
276 TitleColorA: t.Secondary,
277 TitleColorB: t.Primary,
278 CharmColor: t.Secondary,
279 VersionColor: t.Primary,
280 Width: m.width - 2,
281 })
282}
283
284func (m *sidebarCmp) getMaxWidth() int {
285 return min(m.width-2, 58) // -2 for padding
286}
287
288// calculateAvailableHeight estimates how much height is available for dynamic content
289func (m *sidebarCmp) calculateAvailableHeight() int {
290 usedHeight := 0
291
292 if !m.compactMode {
293 if m.height > LogoHeightBreakpoint {
294 usedHeight += 7 // Approximate logo height
295 } else {
296 usedHeight += 2 // Smaller logo height
297 }
298 usedHeight += 1 // Empty line after logo
299 }
300
301 if m.session.ID != "" {
302 usedHeight += 1 // Title line
303 usedHeight += 1 // Empty line after title
304 }
305
306 if !m.compactMode {
307 usedHeight += 1 // CWD line
308 usedHeight += 1 // Empty line after CWD
309 }
310
311 usedHeight += 2 // Model info
312
313 usedHeight += 6 // 3 sections Γ 2 lines each (header + empty line)
314
315 // Base padding
316 usedHeight += 2 // Top and bottom padding
317
318 return max(0, m.height-usedHeight)
319}
320
321// getDynamicLimits calculates how many items to show in each section based on available height
322func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
323 availableHeight := m.calculateAvailableHeight()
324
325 // If we have very little space, use minimum values
326 if availableHeight < 10 {
327 return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
328 }
329
330 // Distribute available height among the three sections
331 // Give priority to files, then LSPs, then MCPs
332 totalSections := 3
333 heightPerSection := availableHeight / totalSections
334
335 // Calculate limits for each section, ensuring minimums
336 maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
337 maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
338 maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
339
340 // If we have extra space, give it to files first
341 remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
342 if remainingHeight > 0 {
343 extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
344 maxFiles += extraForFiles
345 remainingHeight -= extraForFiles
346
347 if remainingHeight > 0 {
348 extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
349 maxLSPs += extraForLSPs
350 remainingHeight -= extraForLSPs
351
352 if remainingHeight > 0 {
353 maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
354 }
355 }
356 }
357
358 return maxFiles, maxLSPs, maxMCPs
359}
360
361// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
362func (m *sidebarCmp) renderSectionsHorizontal() string {
363 // Calculate available width for each section
364 totalWidth := m.width - 4 // Account for padding and spacing
365 sectionWidth := min(50, totalWidth/3)
366
367 // Get the sections content with limited height
368 var filesContent, lspContent, mcpContent string
369
370 filesContent = m.filesBlockCompact(sectionWidth)
371 lspContent = m.lspBlockCompact(sectionWidth)
372 mcpContent = m.mcpBlockCompact(sectionWidth)
373
374 return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
375}
376
377// filesBlockCompact renders the files block with limited width and height for horizontal layout
378func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
379 t := styles.CurrentTheme()
380
381 section := t.S().Subtle.Render("Modified Files")
382
383 files := make([]SessionFile, 0)
384 m.files.Range(func(key, value any) bool {
385 file := value.(SessionFile)
386 files = append(files, file)
387 return true
388 })
389
390 if len(files) == 0 {
391 content := lipgloss.JoinVertical(
392 lipgloss.Left,
393 section,
394 "",
395 t.S().Base.Foreground(t.Border).Render("None"),
396 )
397 return lipgloss.NewStyle().Width(maxWidth).Render(content)
398 }
399
400 fileList := []string{section, ""}
401 sort.Slice(files, func(i, j int) bool {
402 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
403 })
404
405 // Limit items for horizontal layout - use less space
406 maxItems := min(5, len(files))
407 availableHeight := m.height - 8 // Reserve space for header and other content
408 if availableHeight > 0 {
409 maxItems = min(maxItems, availableHeight)
410 }
411
412 filesShown := 0
413 for _, file := range files {
414 if file.Additions == 0 && file.Deletions == 0 {
415 continue
416 }
417 if filesShown >= maxItems {
418 break
419 }
420
421 var statusParts []string
422 if file.Additions > 0 {
423 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
424 }
425 if file.Deletions > 0 {
426 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
427 }
428
429 extraContent := strings.Join(statusParts, " ")
430 cwd := config.Get().WorkingDir() + string(os.PathSeparator)
431 filePath := file.FilePath
432 filePath = strings.TrimPrefix(filePath, cwd)
433 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
434 filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "β¦")
435
436 fileList = append(fileList,
437 core.Status(
438 core.StatusOpts{
439 IconColor: t.FgMuted,
440 NoIcon: true,
441 Title: filePath,
442 ExtraContent: extraContent,
443 },
444 maxWidth,
445 ),
446 )
447 filesShown++
448 }
449
450 // Add "..." indicator if there are more files
451 totalFilesWithChanges := 0
452 for _, file := range files {
453 if file.Additions > 0 || file.Deletions > 0 {
454 totalFilesWithChanges++
455 }
456 }
457 if totalFilesWithChanges > maxItems {
458 fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
459 }
460
461 content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
462 return lipgloss.NewStyle().Width(maxWidth).Render(content)
463}
464
465// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
466func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
467 t := styles.CurrentTheme()
468
469 section := t.S().Subtle.Render("LSPs")
470
471 lspList := []string{section, ""}
472
473 lsp := config.Get().LSP.Sorted()
474 if len(lsp) == 0 {
475 content := lipgloss.JoinVertical(
476 lipgloss.Left,
477 section,
478 "",
479 t.S().Base.Foreground(t.Border).Render("None"),
480 )
481 return lipgloss.NewStyle().Width(maxWidth).Render(content)
482 }
483
484 // Limit items for horizontal layout
485 maxItems := min(5, len(lsp))
486 availableHeight := m.height - 8
487 if availableHeight > 0 {
488 maxItems = min(maxItems, availableHeight)
489 }
490
491 for i, l := range lsp {
492 if i >= maxItems {
493 break
494 }
495
496 iconColor := t.Success
497 if l.LSP.Disabled {
498 iconColor = t.FgMuted
499 }
500
501 lspErrs := map[protocol.DiagnosticSeverity]int{
502 protocol.SeverityError: 0,
503 protocol.SeverityWarning: 0,
504 protocol.SeverityHint: 0,
505 protocol.SeverityInformation: 0,
506 }
507 if client, ok := m.lspClients[l.Name]; ok {
508 for _, diagnostics := range client.GetDiagnostics() {
509 for _, diagnostic := range diagnostics {
510 if severity, ok := lspErrs[diagnostic.Severity]; ok {
511 lspErrs[diagnostic.Severity] = severity + 1
512 }
513 }
514 }
515 }
516
517 errs := []string{}
518 if lspErrs[protocol.SeverityError] > 0 {
519 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
520 }
521 if lspErrs[protocol.SeverityWarning] > 0 {
522 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
523 }
524 if lspErrs[protocol.SeverityHint] > 0 {
525 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
526 }
527 if lspErrs[protocol.SeverityInformation] > 0 {
528 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
529 }
530
531 lspList = append(lspList,
532 core.Status(
533 core.StatusOpts{
534 IconColor: iconColor,
535 Title: l.Name,
536 Description: l.LSP.Command,
537 ExtraContent: strings.Join(errs, " "),
538 },
539 maxWidth,
540 ),
541 )
542 }
543
544 // Add "..." indicator if there are more LSPs
545 if len(lsp) > maxItems {
546 lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
547 }
548
549 content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
550 return lipgloss.NewStyle().Width(maxWidth).Render(content)
551}
552
553// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
554func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
555 t := styles.CurrentTheme()
556
557 section := t.S().Subtle.Render("MCPs")
558
559 mcpList := []string{section, ""}
560
561 mcps := config.Get().MCP.Sorted()
562 if len(mcps) == 0 {
563 content := lipgloss.JoinVertical(
564 lipgloss.Left,
565 section,
566 "",
567 t.S().Base.Foreground(t.Border).Render("None"),
568 )
569 return lipgloss.NewStyle().Width(maxWidth).Render(content)
570 }
571
572 // Limit items for horizontal layout
573 maxItems := min(5, len(mcps))
574 availableHeight := m.height - 8
575 if availableHeight > 0 {
576 maxItems = min(maxItems, availableHeight)
577 }
578
579 for i, l := range mcps {
580 if i >= maxItems {
581 break
582 }
583
584 iconColor := t.Success
585 if l.MCP.Disabled {
586 iconColor = t.FgMuted
587 }
588
589 mcpList = append(mcpList,
590 core.Status(
591 core.StatusOpts{
592 IconColor: iconColor,
593 Title: l.Name,
594 Description: l.MCP.Command,
595 },
596 maxWidth,
597 ),
598 )
599 }
600
601 // Add "..." indicator if there are more MCPs
602 if len(mcps) > maxItems {
603 mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
604 }
605
606 content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
607 return lipgloss.NewStyle().Width(maxWidth).Render(content)
608}
609
610func (m *sidebarCmp) filesBlock() string {
611 t := styles.CurrentTheme()
612
613 section := t.S().Subtle.Render(
614 core.Section("Modified Files", m.getMaxWidth()),
615 )
616
617 files := make([]SessionFile, 0)
618 m.files.Range(func(key, value any) bool {
619 file := value.(SessionFile)
620 files = append(files, file)
621 return true // continue iterating
622 })
623 if len(files) == 0 {
624 return lipgloss.JoinVertical(
625 lipgloss.Left,
626 section,
627 "",
628 t.S().Base.Foreground(t.Border).Render("None"),
629 )
630 }
631
632 fileList := []string{section, ""}
633 // order files by the latest version's created time
634 sort.Slice(files, func(i, j int) bool {
635 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
636 })
637
638 // Limit the number of files shown
639 maxFiles, _, _ := m.getDynamicLimits()
640 maxFiles = min(len(files), maxFiles)
641 filesShown := 0
642
643 for _, file := range files {
644 if file.Additions == 0 && file.Deletions == 0 {
645 continue // skip files with no changes
646 }
647 if filesShown >= maxFiles {
648 break
649 }
650
651 var statusParts []string
652 if file.Additions > 0 {
653 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
654 }
655 if file.Deletions > 0 {
656 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
657 }
658
659 extraContent := strings.Join(statusParts, " ")
660 cwd := config.Get().WorkingDir() + string(os.PathSeparator)
661 filePath := file.FilePath
662 filePath = strings.TrimPrefix(filePath, cwd)
663 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
664 filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "β¦")
665 fileList = append(fileList,
666 core.Status(
667 core.StatusOpts{
668 IconColor: t.FgMuted,
669 NoIcon: true,
670 Title: filePath,
671 ExtraContent: extraContent,
672 },
673 m.getMaxWidth(),
674 ),
675 )
676 filesShown++
677 }
678
679 // Add indicator if there are more files
680 totalFilesWithChanges := 0
681 for _, file := range files {
682 if file.Additions > 0 || file.Deletions > 0 {
683 totalFilesWithChanges++
684 }
685 }
686 if totalFilesWithChanges > maxFiles {
687 remaining := totalFilesWithChanges - maxFiles
688 fileList = append(fileList,
689 t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("β¦ and %d more", remaining)),
690 )
691 }
692
693 return lipgloss.JoinVertical(
694 lipgloss.Left,
695 fileList...,
696 )
697}
698
699func (m *sidebarCmp) lspBlock() string {
700 t := styles.CurrentTheme()
701
702 section := t.S().Subtle.Render(
703 core.Section("LSPs", m.getMaxWidth()),
704 )
705
706 lspList := []string{section, ""}
707
708 lsp := config.Get().LSP.Sorted()
709 if len(lsp) == 0 {
710 return lipgloss.JoinVertical(
711 lipgloss.Left,
712 section,
713 "",
714 t.S().Base.Foreground(t.Border).Render("None"),
715 )
716 }
717
718 // Limit the number of LSPs shown
719 _, maxLSPs, _ := m.getDynamicLimits()
720 maxLSPs = min(len(lsp), maxLSPs)
721 for i, l := range lsp {
722 if i >= maxLSPs {
723 break
724 }
725
726 iconColor := t.Success
727 if l.LSP.Disabled {
728 iconColor = t.FgMuted
729 }
730 lspErrs := map[protocol.DiagnosticSeverity]int{
731 protocol.SeverityError: 0,
732 protocol.SeverityWarning: 0,
733 protocol.SeverityHint: 0,
734 protocol.SeverityInformation: 0,
735 }
736 if client, ok := m.lspClients[l.Name]; ok {
737 for _, diagnostics := range client.GetDiagnostics() {
738 for _, diagnostic := range diagnostics {
739 if severity, ok := lspErrs[diagnostic.Severity]; ok {
740 lspErrs[diagnostic.Severity] = severity + 1
741 }
742 }
743 }
744 }
745
746 errs := []string{}
747 if lspErrs[protocol.SeverityError] > 0 {
748 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
749 }
750 if lspErrs[protocol.SeverityWarning] > 0 {
751 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
752 }
753 if lspErrs[protocol.SeverityHint] > 0 {
754 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
755 }
756 if lspErrs[protocol.SeverityInformation] > 0 {
757 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
758 }
759
760 lspList = append(lspList,
761 core.Status(
762 core.StatusOpts{
763 IconColor: iconColor,
764 Title: l.Name,
765 Description: l.LSP.Command,
766 ExtraContent: strings.Join(errs, " "),
767 },
768 m.getMaxWidth(),
769 ),
770 )
771 }
772
773 // Add indicator if there are more LSPs
774 if len(lsp) > maxLSPs {
775 remaining := len(lsp) - maxLSPs
776 lspList = append(lspList,
777 t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("β¦ and %d more", remaining)),
778 )
779 }
780
781 return lipgloss.JoinVertical(
782 lipgloss.Left,
783 lspList...,
784 )
785}
786
787func (m *sidebarCmp) mcpBlock() string {
788 t := styles.CurrentTheme()
789
790 section := t.S().Subtle.Render(
791 core.Section("MCPs", m.getMaxWidth()),
792 )
793
794 mcpList := []string{section, ""}
795
796 mcps := config.Get().MCP.Sorted()
797 if len(mcps) == 0 {
798 return lipgloss.JoinVertical(
799 lipgloss.Left,
800 section,
801 "",
802 t.S().Base.Foreground(t.Border).Render("None"),
803 )
804 }
805
806 // Limit the number of MCPs shown
807 _, _, maxMCPs := m.getDynamicLimits()
808 maxMCPs = min(len(mcps), maxMCPs)
809 for i, l := range mcps {
810 if i >= maxMCPs {
811 break
812 }
813
814 iconColor := t.Success
815 if l.MCP.Disabled {
816 iconColor = t.FgMuted
817 }
818 mcpList = append(mcpList,
819 core.Status(
820 core.StatusOpts{
821 IconColor: iconColor,
822 Title: l.Name,
823 Description: l.MCP.Command,
824 },
825 m.getMaxWidth(),
826 ),
827 )
828 }
829
830 // Add indicator if there are more MCPs
831 if len(mcps) > maxMCPs {
832 remaining := len(mcps) - maxMCPs
833 mcpList = append(mcpList,
834 t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("β¦ and %d more", remaining)),
835 )
836 }
837
838 return lipgloss.JoinVertical(
839 lipgloss.Left,
840 mcpList...,
841 )
842}
843
844func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
845 t := styles.CurrentTheme()
846 // Format tokens in human-readable format (e.g., 110K, 1.2M)
847 var formattedTokens string
848 switch {
849 case tokens >= 1_000_000:
850 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
851 case tokens >= 1_000:
852 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
853 default:
854 formattedTokens = fmt.Sprintf("%d", tokens)
855 }
856
857 // Remove .0 suffix if present
858 if strings.HasSuffix(formattedTokens, ".0K") {
859 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
860 }
861 if strings.HasSuffix(formattedTokens, ".0M") {
862 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
863 }
864
865 percentage := (float64(tokens) / float64(contextWindow)) * 100
866
867 baseStyle := t.S().Base
868
869 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
870
871 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
872 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
873 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
874 if percentage > 80 {
875 // add the warning icon
876 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
877 }
878
879 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
880}
881
882func (s *sidebarCmp) currentModelBlock() string {
883 agentCfg := config.Get().Agents["coder"]
884 model := config.Get().GetModelByType(agentCfg.Model)
885
886 t := styles.CurrentTheme()
887
888 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
889 modelName := t.S().Text.Render(model.Model)
890 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
891 parts := []string{
892 modelInfo,
893 }
894 if s.session.ID != "" {
895 parts = append(
896 parts,
897 " "+formatTokensAndCost(
898 s.session.CompletionTokens+s.session.PromptTokens,
899 model.ContextWindow,
900 s.session.Cost,
901 ),
902 )
903 }
904 return lipgloss.JoinVertical(
905 lipgloss.Left,
906 parts...,
907 )
908}
909
910func (m *sidebarCmp) smallerScreenLogo() string {
911 t := styles.CurrentTheme()
912 title := t.S().Base.Foreground(t.Secondary).Render("Charmβ’")
913 title += " " + styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)
914 remainingWidth := m.width - lipgloss.Width(title) - 3
915 if remainingWidth > 0 {
916 char := "β±"
917 lines := strings.Repeat(char, remainingWidth)
918 title += " " + t.S().Base.Foreground(t.Primary).Render(lines)
919 }
920 return title
921}
922
923// SetSession implements Sidebar.
924func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
925 m.session = session
926 return m.loadSessionFiles
927}
928
929// SetCompactMode sets the compact mode for the sidebar.
930func (m *sidebarCmp) SetCompactMode(compact bool) {
931 m.compactMode = compact
932}
933
934func cwd() string {
935 cwd := config.Get().WorkingDir()
936 t := styles.CurrentTheme()
937 // Replace home directory with ~, unless we're at the top level of the
938 // home directory).
939 homeDir, err := os.UserHomeDir()
940 if err == nil && cwd != homeDir {
941 cwd = strings.ReplaceAll(cwd, homeDir, "~")
942 }
943 return t.S().Muted.Render(cwd)
944}