sidebar.go

  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}