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