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