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