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