sidebar.go

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