1package sidebar
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "slices"
8 "sort"
9 "strings"
10
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/fsext"
17 "github.com/charmbracelet/crush/internal/history"
18 "github.com/charmbracelet/crush/internal/lsp"
19 "github.com/charmbracelet/crush/internal/lsp/protocol"
20 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/session"
22 "github.com/charmbracelet/crush/internal/tui/components/chat"
23 "github.com/charmbracelet/crush/internal/tui/components/core"
24 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
25 "github.com/charmbracelet/crush/internal/tui/components/logo"
26 "github.com/charmbracelet/crush/internal/tui/styles"
27 "github.com/charmbracelet/crush/internal/tui/util"
28 "github.com/charmbracelet/crush/internal/version"
29 "github.com/charmbracelet/lipgloss/v2"
30 "github.com/charmbracelet/x/ansi"
31 "golang.org/x/text/cases"
32 "golang.org/x/text/language"
33)
34
35type FileHistory struct {
36 initialVersion history.File
37 latestVersion history.File
38}
39
40const LogoHeightBreakpoint = 30
41
42// Default maximum number of items to show in each section
43const (
44 DefaultMaxFilesShown = 10
45 DefaultMaxLSPsShown = 8
46 DefaultMaxMCPsShown = 8
47 MinItemsPerSection = 2 // Minimum items to show per section
48)
49
50type SessionFile struct {
51 History FileHistory
52 FilePath string
53 Additions int
54 Deletions int
55}
56type SessionFilesMsg struct {
57 Files []SessionFile
58}
59
60type Sidebar interface {
61 util.Model
62 layout.Sizeable
63 SetSession(session session.Session) tea.Cmd
64 SetCompactMode(bool)
65}
66
67type sidebarCmp struct {
68 width, height int
69 session session.Session
70 logo string
71 cwd string
72 lspClients map[string]*lsp.Client
73 compactMode bool
74 history history.Service
75 files *csync.Map[string, SessionFile]
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 files: csync.NewMap[string, SessionFile](),
84 }
85}
86
87func (m *sidebarCmp) Init() tea.Cmd {
88 return nil
89}
90
91func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
92 switch msg := msg.(type) {
93 case SessionFilesMsg:
94 m.files = csync.NewMap[string, SessionFile]()
95 for _, file := range msg.Files {
96 m.files.Set(file.FilePath, file)
97 }
98 return m, nil
99
100 case chat.SessionClearedMsg:
101 m.session = session.Session{}
102 case pubsub.Event[history.File]:
103 return m, m.handleFileHistoryEvent(msg)
104 case pubsub.Event[session.Session]:
105 if msg.Type == pubsub.UpdatedEvent {
106 if m.session.ID == msg.Payload.ID {
107 m.session = msg.Payload
108 }
109 }
110 }
111 return m, nil
112}
113
114func (m *sidebarCmp) View() string {
115 t := styles.CurrentTheme()
116 parts := []string{}
117
118 style := t.S().Base.
119 Width(m.width).
120 Height(m.height).
121 Padding(1)
122 if m.compactMode {
123 style = style.PaddingTop(0)
124 }
125
126 if !m.compactMode {
127 if m.height > LogoHeightBreakpoint {
128 parts = append(parts, m.logo)
129 } else {
130 // Use a smaller logo for smaller screens
131 parts = append(parts,
132 logo.SmallRender(m.width-style.GetHorizontalFrameSize()),
133 "")
134 }
135 }
136
137 if !m.compactMode && m.session.ID != "" {
138 parts = append(parts, t.S().Muted.Render(m.session.Title), "")
139 } else if m.session.ID != "" {
140 parts = append(parts, t.S().Text.Render(m.session.Title), "")
141 }
142
143 if !m.compactMode {
144 parts = append(parts,
145 m.cwd,
146 "",
147 )
148 }
149 parts = append(parts,
150 m.currentModelBlock(),
151 )
152
153 // Check if we should use horizontal layout for sections
154 if m.compactMode && m.width > m.height {
155 // Horizontal layout for compact mode when width > height
156 sectionsContent := m.renderSectionsHorizontal()
157 if sectionsContent != "" {
158 parts = append(parts, "", sectionsContent)
159 }
160 } else {
161 // Vertical layout (default)
162 if m.session.ID != "" {
163 parts = append(parts, "", m.filesBlock())
164 }
165 parts = append(parts,
166 "",
167 m.lspBlock(),
168 "",
169 m.mcpBlock(),
170 )
171 }
172
173 return style.Render(
174 lipgloss.JoinVertical(lipgloss.Left, parts...),
175 )
176}
177
178func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
179 return func() tea.Msg {
180 file := event.Payload
181 found := false
182 for existing := range m.files.Seq() {
183 if existing.FilePath != file.Path {
184 continue
185 }
186 if existing.History.latestVersion.Version < file.Version {
187 existing.History.latestVersion = file
188 } else if file.Version == 0 {
189 existing.History.initialVersion = file
190 } else {
191 // If the version is not greater than the latest, we ignore it
192 continue
193 }
194 before := existing.History.initialVersion.Content
195 after := existing.History.latestVersion.Content
196 path := existing.History.initialVersion.Path
197 cwd := config.Get().WorkingDir()
198 path = strings.TrimPrefix(path, cwd)
199 _, additions, deletions := diff.GenerateDiff(before, after, path)
200 existing.Additions = additions
201 existing.Deletions = deletions
202 m.files.Set(file.Path, existing)
203 found = true
204 break
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.Set(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 := slices.Collect(m.files.Seq())
390
391 if len(files) == 0 {
392 content := lipgloss.JoinVertical(
393 lipgloss.Left,
394 section,
395 "",
396 t.S().Base.Foreground(t.Border).Render("None"),
397 )
398 return lipgloss.NewStyle().Width(maxWidth).Render(content)
399 }
400
401 fileList := []string{section, ""}
402 sort.Slice(files, func(i, j int) bool {
403 if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt {
404 return files[i].FilePath < files[j].FilePath
405 }
406 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
407 })
408
409 // Limit items for horizontal layout - use less space
410 maxItems := min(5, len(files))
411 availableHeight := m.height - 8 // Reserve space for header and other content
412 if availableHeight > 0 {
413 maxItems = min(maxItems, availableHeight)
414 }
415
416 filesShown := 0
417 for _, file := range files {
418 if file.Additions == 0 && file.Deletions == 0 {
419 continue
420 }
421 if filesShown >= maxItems {
422 break
423 }
424
425 var statusParts []string
426 if file.Additions > 0 {
427 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
428 }
429 if file.Deletions > 0 {
430 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
431 }
432
433 extraContent := strings.Join(statusParts, " ")
434 cwd := config.Get().WorkingDir() + string(os.PathSeparator)
435 filePath := file.FilePath
436 filePath = strings.TrimPrefix(filePath, cwd)
437 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
438 filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "β¦")
439
440 fileList = append(fileList,
441 core.Status(
442 core.StatusOpts{
443 IconColor: t.FgMuted,
444 NoIcon: true,
445 Title: filePath,
446 ExtraContent: extraContent,
447 },
448 maxWidth,
449 ),
450 )
451 filesShown++
452 }
453
454 // Add "..." indicator if there are more files
455 totalFilesWithChanges := 0
456 for _, file := range files {
457 if file.Additions > 0 || file.Deletions > 0 {
458 totalFilesWithChanges++
459 }
460 }
461 if totalFilesWithChanges > maxItems {
462 fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
463 }
464
465 content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
466 return lipgloss.NewStyle().Width(maxWidth).Render(content)
467}
468
469// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
470func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
471 t := styles.CurrentTheme()
472
473 section := t.S().Subtle.Render("LSPs")
474
475 lspList := []string{section, ""}
476
477 lsp := config.Get().LSP.Sorted()
478 if len(lsp) == 0 {
479 content := lipgloss.JoinVertical(
480 lipgloss.Left,
481 section,
482 "",
483 t.S().Base.Foreground(t.Border).Render("None"),
484 )
485 return lipgloss.NewStyle().Width(maxWidth).Render(content)
486 }
487
488 // Limit items for horizontal layout
489 maxItems := min(5, len(lsp))
490 availableHeight := m.height - 8
491 if availableHeight > 0 {
492 maxItems = min(maxItems, availableHeight)
493 }
494
495 for i, l := range lsp {
496 if i >= maxItems {
497 break
498 }
499
500 iconColor := t.Success
501 if l.LSP.Disabled {
502 iconColor = t.FgMuted
503 }
504
505 lspErrs := map[protocol.DiagnosticSeverity]int{
506 protocol.SeverityError: 0,
507 protocol.SeverityWarning: 0,
508 protocol.SeverityHint: 0,
509 protocol.SeverityInformation: 0,
510 }
511 if client, ok := m.lspClients[l.Name]; ok {
512 for _, diagnostics := range client.GetDiagnostics() {
513 for _, diagnostic := range diagnostics {
514 if severity, ok := lspErrs[diagnostic.Severity]; ok {
515 lspErrs[diagnostic.Severity] = severity + 1
516 }
517 }
518 }
519 }
520
521 errs := []string{}
522 if lspErrs[protocol.SeverityError] > 0 {
523 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
524 }
525 if lspErrs[protocol.SeverityWarning] > 0 {
526 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
527 }
528 if lspErrs[protocol.SeverityHint] > 0 {
529 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
530 }
531 if lspErrs[protocol.SeverityInformation] > 0 {
532 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
533 }
534
535 lspList = append(lspList,
536 core.Status(
537 core.StatusOpts{
538 IconColor: iconColor,
539 Title: l.Name,
540 Description: l.LSP.Command,
541 ExtraContent: strings.Join(errs, " "),
542 },
543 maxWidth,
544 ),
545 )
546 }
547
548 // Add "..." indicator if there are more LSPs
549 if len(lsp) > maxItems {
550 lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
551 }
552
553 content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
554 return lipgloss.NewStyle().Width(maxWidth).Render(content)
555}
556
557// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
558func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
559 t := styles.CurrentTheme()
560
561 section := t.S().Subtle.Render("MCPs")
562
563 mcpList := []string{section, ""}
564
565 mcps := config.Get().MCP.Sorted()
566 if len(mcps) == 0 {
567 content := lipgloss.JoinVertical(
568 lipgloss.Left,
569 section,
570 "",
571 t.S().Base.Foreground(t.Border).Render("None"),
572 )
573 return lipgloss.NewStyle().Width(maxWidth).Render(content)
574 }
575
576 // Limit items for horizontal layout
577 maxItems := min(5, len(mcps))
578 availableHeight := m.height - 8
579 if availableHeight > 0 {
580 maxItems = min(maxItems, availableHeight)
581 }
582
583 for i, l := range mcps {
584 if i >= maxItems {
585 break
586 }
587
588 iconColor := t.Success
589 if l.MCP.Disabled {
590 iconColor = t.FgMuted
591 }
592
593 mcpList = append(mcpList,
594 core.Status(
595 core.StatusOpts{
596 IconColor: iconColor,
597 Title: l.Name,
598 Description: l.MCP.Command,
599 },
600 maxWidth,
601 ),
602 )
603 }
604
605 // Add "..." indicator if there are more MCPs
606 if len(mcps) > maxItems {
607 mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
608 }
609
610 content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
611 return lipgloss.NewStyle().Width(maxWidth).Render(content)
612}
613
614func (m *sidebarCmp) filesBlock() string {
615 t := styles.CurrentTheme()
616
617 section := t.S().Subtle.Render(
618 core.Section("Modified Files", m.getMaxWidth()),
619 )
620
621 files := slices.Collect(m.files.Seq())
622 if len(files) == 0 {
623 return lipgloss.JoinVertical(
624 lipgloss.Left,
625 section,
626 "",
627 t.S().Base.Foreground(t.Border).Render("None"),
628 )
629 }
630
631 fileList := []string{section, ""}
632 // order files by the latest version's created time, then by path for stability
633 sort.Slice(files, func(i, j int) bool {
634 if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt {
635 return files[i].FilePath < files[j].FilePath
636 }
637 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
638 })
639
640 // Limit the number of files shown
641 maxFiles, _, _ := m.getDynamicLimits()
642 maxFiles = min(len(files), maxFiles)
643 filesShown := 0
644
645 for _, file := range files {
646 if file.Additions == 0 && file.Deletions == 0 {
647 continue // skip files with no changes
648 }
649 if filesShown >= maxFiles {
650 break
651 }
652
653 var statusParts []string
654 if file.Additions > 0 {
655 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
656 }
657 if file.Deletions > 0 {
658 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
659 }
660
661 extraContent := strings.Join(statusParts, " ")
662 cwd := config.Get().WorkingDir() + string(os.PathSeparator)
663 filePath := file.FilePath
664 filePath = strings.TrimPrefix(filePath, cwd)
665 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
666 filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "β¦")
667 fileList = append(fileList,
668 core.Status(
669 core.StatusOpts{
670 IconColor: t.FgMuted,
671 NoIcon: true,
672 Title: filePath,
673 ExtraContent: extraContent,
674 },
675 m.getMaxWidth(),
676 ),
677 )
678 filesShown++
679 }
680
681 // Add indicator if there are more files
682 totalFilesWithChanges := 0
683 for _, file := range files {
684 if file.Additions > 0 || file.Deletions > 0 {
685 totalFilesWithChanges++
686 }
687 }
688 if totalFilesWithChanges > maxFiles {
689 remaining := totalFilesWithChanges - maxFiles
690 fileList = append(fileList,
691 t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("β¦and %d more", remaining)),
692 )
693 }
694
695 return lipgloss.JoinVertical(
696 lipgloss.Left,
697 fileList...,
698 )
699}
700
701func (m *sidebarCmp) lspBlock() string {
702 t := styles.CurrentTheme()
703
704 section := t.S().Subtle.Render(
705 core.Section("LSPs", m.getMaxWidth()),
706 )
707
708 lspList := []string{section, ""}
709
710 lsp := config.Get().LSP.Sorted()
711 if len(lsp) == 0 {
712 return lipgloss.JoinVertical(
713 lipgloss.Left,
714 section,
715 "",
716 t.S().Base.Foreground(t.Border).Render("None"),
717 )
718 }
719
720 // Limit the number of LSPs shown
721 _, maxLSPs, _ := m.getDynamicLimits()
722 maxLSPs = min(len(lsp), maxLSPs)
723 for i, l := range lsp {
724 if i >= maxLSPs {
725 break
726 }
727
728 iconColor := t.Success
729 if l.LSP.Disabled {
730 iconColor = t.FgMuted
731 }
732 lspErrs := map[protocol.DiagnosticSeverity]int{
733 protocol.SeverityError: 0,
734 protocol.SeverityWarning: 0,
735 protocol.SeverityHint: 0,
736 protocol.SeverityInformation: 0,
737 }
738 if client, ok := m.lspClients[l.Name]; ok {
739 for _, diagnostics := range client.GetDiagnostics() {
740 for _, diagnostic := range diagnostics {
741 if severity, ok := lspErrs[diagnostic.Severity]; ok {
742 lspErrs[diagnostic.Severity] = severity + 1
743 }
744 }
745 }
746 }
747
748 errs := []string{}
749 if lspErrs[protocol.SeverityError] > 0 {
750 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
751 }
752 if lspErrs[protocol.SeverityWarning] > 0 {
753 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
754 }
755 if lspErrs[protocol.SeverityHint] > 0 {
756 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
757 }
758 if lspErrs[protocol.SeverityInformation] > 0 {
759 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
760 }
761
762 lspList = append(lspList,
763 core.Status(
764 core.StatusOpts{
765 IconColor: iconColor,
766 Title: l.Name,
767 Description: l.LSP.Command,
768 ExtraContent: strings.Join(errs, " "),
769 },
770 m.getMaxWidth(),
771 ),
772 )
773 }
774
775 // Add indicator if there are more LSPs
776 if len(lsp) > maxLSPs {
777 remaining := len(lsp) - maxLSPs
778 lspList = append(lspList,
779 t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("β¦and %d more", remaining)),
780 )
781 }
782
783 return lipgloss.JoinVertical(
784 lipgloss.Left,
785 lspList...,
786 )
787}
788
789func (m *sidebarCmp) mcpBlock() string {
790 t := styles.CurrentTheme()
791
792 section := t.S().Subtle.Render(
793 core.Section("MCPs", m.getMaxWidth()),
794 )
795
796 mcpList := []string{section, ""}
797
798 mcps := config.Get().MCP.Sorted()
799 if len(mcps) == 0 {
800 return lipgloss.JoinVertical(
801 lipgloss.Left,
802 section,
803 "",
804 t.S().Base.Foreground(t.Border).Render("None"),
805 )
806 }
807
808 // Limit the number of MCPs shown
809 _, _, maxMCPs := m.getDynamicLimits()
810 maxMCPs = min(len(mcps), maxMCPs)
811 for i, l := range mcps {
812 if i >= maxMCPs {
813 break
814 }
815
816 iconColor := t.Success
817 if l.MCP.Disabled {
818 iconColor = t.FgMuted
819 }
820 mcpList = append(mcpList,
821 core.Status(
822 core.StatusOpts{
823 IconColor: iconColor,
824 Title: l.Name,
825 Description: l.MCP.Command,
826 },
827 m.getMaxWidth(),
828 ),
829 )
830 }
831
832 // Add indicator if there are more MCPs
833 if len(mcps) > maxMCPs {
834 remaining := len(mcps) - maxMCPs
835 mcpList = append(mcpList,
836 t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("β¦and %d more", remaining)),
837 )
838 }
839
840 return lipgloss.JoinVertical(
841 lipgloss.Left,
842 mcpList...,
843 )
844}
845
846func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
847 t := styles.CurrentTheme()
848 // Format tokens in human-readable format (e.g., 110K, 1.2M)
849 var formattedTokens string
850 switch {
851 case tokens >= 1_000_000:
852 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
853 case tokens >= 1_000:
854 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
855 default:
856 formattedTokens = fmt.Sprintf("%d", tokens)
857 }
858
859 // Remove .0 suffix if present
860 if strings.HasSuffix(formattedTokens, ".0K") {
861 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
862 }
863 if strings.HasSuffix(formattedTokens, ".0M") {
864 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
865 }
866
867 percentage := (float64(tokens) / float64(contextWindow)) * 100
868
869 baseStyle := t.S().Base
870
871 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
872
873 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
874 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
875 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
876 if percentage > 80 {
877 // add the warning icon
878 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
879 }
880
881 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
882}
883
884func (s *sidebarCmp) currentModelBlock() string {
885 cfg := config.Get()
886 agentCfg := cfg.Agents["coder"]
887
888 selectedModel := cfg.Models[agentCfg.Model]
889
890 model := config.Get().GetModelByType(agentCfg.Model)
891 modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
892
893 t := styles.CurrentTheme()
894
895 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
896 modelName := t.S().Text.Render(model.Name)
897 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
898 parts := []string{
899 modelInfo,
900 }
901 if model.CanReason {
902 reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
903 switch modelProvider.Type {
904 case catwalk.TypeOpenAI:
905 reasoningEffort := model.DefaultReasoningEffort
906 if selectedModel.ReasoningEffort != "" {
907 reasoningEffort = selectedModel.ReasoningEffort
908 }
909 formatter := cases.Title(language.English, cases.NoLower)
910 parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
911 case catwalk.TypeAnthropic:
912 formatter := cases.Title(language.English, cases.NoLower)
913 if selectedModel.Think {
914 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
915 } else {
916 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
917 }
918 }
919 }
920 if s.session.ID != "" {
921 parts = append(
922 parts,
923 " "+formatTokensAndCost(
924 s.session.CompletionTokens+s.session.PromptTokens,
925 model.ContextWindow,
926 s.session.Cost,
927 ),
928 )
929 }
930 return lipgloss.JoinVertical(
931 lipgloss.Left,
932 parts...,
933 )
934}
935
936// SetSession implements Sidebar.
937func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
938 m.session = session
939 return m.loadSessionFiles
940}
941
942// SetCompactMode sets the compact mode for the sidebar.
943func (m *sidebarCmp) SetCompactMode(compact bool) {
944 m.compactMode = compact
945}
946
947func cwd() string {
948 cwd := config.Get().WorkingDir()
949 t := styles.CurrentTheme()
950 // Replace home directory with ~, unless we're at the top level of the
951 // home directory).
952 homeDir, err := os.UserHomeDir()
953 if err == nil && cwd != homeDir {
954 cwd = strings.ReplaceAll(cwd, homeDir, "~")
955 }
956 return t.S().Muted.Render(cwd)
957}