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