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		}
205		if found {
206			return nil
207		}
208		sf := SessionFile{
209			History: FileHistory{
210				initialVersion: file,
211				latestVersion:  file,
212			},
213			FilePath:  file.Path,
214			Additions: 0,
215			Deletions: 0,
216		}
217		m.files.Set(file.Path, sf)
218		return nil
219	}
220}
221
222func (m *sidebarCmp) loadSessionFiles() tea.Msg {
223	files, err := m.history.ListBySession(context.Background(), m.session.ID)
224	if err != nil {
225		return util.InfoMsg{
226			Type: util.InfoTypeError,
227			Msg:  err.Error(),
228		}
229	}
230
231	fileMap := make(map[string]FileHistory)
232
233	for _, file := range files {
234		if existing, ok := fileMap[file.Path]; ok {
235			// Update the latest version
236			existing.latestVersion = file
237			fileMap[file.Path] = existing
238		} else {
239			// Add the initial version
240			fileMap[file.Path] = FileHistory{
241				initialVersion: file,
242				latestVersion:  file,
243			}
244		}
245	}
246
247	sessionFiles := make([]SessionFile, 0, len(fileMap))
248	for path, fh := range fileMap {
249		cwd := config.Get().WorkingDir()
250		path = strings.TrimPrefix(path, cwd)
251		_, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, path)
252		sessionFiles = append(sessionFiles, SessionFile{
253			History:   fh,
254			FilePath:  path,
255			Additions: additions,
256			Deletions: deletions,
257		})
258	}
259
260	return SessionFilesMsg{
261		Files: sessionFiles,
262	}
263}
264
265func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
266	m.logo = m.logoBlock()
267	m.cwd = cwd()
268	m.width = width
269	m.height = height
270	return nil
271}
272
273func (m *sidebarCmp) GetSize() (int, int) {
274	return m.width, m.height
275}
276
277func (m *sidebarCmp) logoBlock() string {
278	t := styles.CurrentTheme()
279	return logo.Render(version.Version, true, logo.Opts{
280		FieldColor:   t.Primary,
281		TitleColorA:  t.Secondary,
282		TitleColorB:  t.Primary,
283		CharmColor:   t.Secondary,
284		VersionColor: t.Primary,
285		Width:        m.width - 2,
286	})
287}
288
289func (m *sidebarCmp) getMaxWidth() int {
290	return min(m.width-2, 58) // -2 for padding
291}
292
293// calculateAvailableHeight estimates how much height is available for dynamic content
294func (m *sidebarCmp) calculateAvailableHeight() int {
295	usedHeight := 0
296
297	if !m.compactMode {
298		if m.height > LogoHeightBreakpoint {
299			usedHeight += 7 // Approximate logo height
300		} else {
301			usedHeight += 2 // Smaller logo height
302		}
303		usedHeight += 1 // Empty line after logo
304	}
305
306	if m.session.ID != "" {
307		usedHeight += 1 // Title line
308		usedHeight += 1 // Empty line after title
309	}
310
311	if !m.compactMode {
312		usedHeight += 1 // CWD line
313		usedHeight += 1 // Empty line after CWD
314	}
315
316	usedHeight += 2 // Model info
317
318	usedHeight += 6 // 3 sections Γ— 2 lines each (header + empty line)
319
320	// Base padding
321	usedHeight += 2 // Top and bottom padding
322
323	return max(0, m.height-usedHeight)
324}
325
326// getDynamicLimits calculates how many items to show in each section based on available height
327func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
328	availableHeight := m.calculateAvailableHeight()
329
330	// If we have very little space, use minimum values
331	if availableHeight < 10 {
332		return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
333	}
334
335	// Distribute available height among the three sections
336	// Give priority to files, then LSPs, then MCPs
337	totalSections := 3
338	heightPerSection := availableHeight / totalSections
339
340	// Calculate limits for each section, ensuring minimums
341	maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
342	maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
343	maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
344
345	// If we have extra space, give it to files first
346	remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
347	if remainingHeight > 0 {
348		extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
349		maxFiles += extraForFiles
350		remainingHeight -= extraForFiles
351
352		if remainingHeight > 0 {
353			extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
354			maxLSPs += extraForLSPs
355			remainingHeight -= extraForLSPs
356
357			if remainingHeight > 0 {
358				maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
359			}
360		}
361	}
362
363	return maxFiles, maxLSPs, maxMCPs
364}
365
366// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
367func (m *sidebarCmp) renderSectionsHorizontal() string {
368	// Calculate available width for each section
369	totalWidth := m.width - 4 // Account for padding and spacing
370	sectionWidth := min(50, totalWidth/3)
371
372	// Get the sections content with limited height
373	var filesContent, lspContent, mcpContent string
374
375	filesContent = m.filesBlockCompact(sectionWidth)
376	lspContent = m.lspBlockCompact(sectionWidth)
377	mcpContent = m.mcpBlockCompact(sectionWidth)
378
379	return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
380}
381
382// filesBlockCompact renders the files block with limited width and height for horizontal layout
383func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
384	t := styles.CurrentTheme()
385
386	section := t.S().Subtle.Render("Modified Files")
387
388	files := slices.Collect(m.files.Seq())
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 := slices.Collect(m.files.Seq())
618	if len(files) == 0 {
619		return lipgloss.JoinVertical(
620			lipgloss.Left,
621			section,
622			"",
623			t.S().Base.Foreground(t.Border).Render("None"),
624		)
625	}
626
627	fileList := []string{section, ""}
628	// order files by the latest version's created time
629	sort.Slice(files, func(i, j int) bool {
630		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
631	})
632
633	// Limit the number of files shown
634	maxFiles, _, _ := m.getDynamicLimits()
635	maxFiles = min(len(files), maxFiles)
636	filesShown := 0
637
638	for _, file := range files {
639		if file.Additions == 0 && file.Deletions == 0 {
640			continue // skip files with no changes
641		}
642		if filesShown >= maxFiles {
643			break
644		}
645
646		var statusParts []string
647		if file.Additions > 0 {
648			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
649		}
650		if file.Deletions > 0 {
651			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
652		}
653
654		extraContent := strings.Join(statusParts, " ")
655		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
656		filePath := file.FilePath
657		filePath = strings.TrimPrefix(filePath, cwd)
658		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
659		filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
660		fileList = append(fileList,
661			core.Status(
662				core.StatusOpts{
663					IconColor:    t.FgMuted,
664					NoIcon:       true,
665					Title:        filePath,
666					ExtraContent: extraContent,
667				},
668				m.getMaxWidth(),
669			),
670		)
671		filesShown++
672	}
673
674	// Add indicator if there are more files
675	totalFilesWithChanges := 0
676	for _, file := range files {
677		if file.Additions > 0 || file.Deletions > 0 {
678			totalFilesWithChanges++
679		}
680	}
681	if totalFilesWithChanges > maxFiles {
682		remaining := totalFilesWithChanges - maxFiles
683		fileList = append(fileList,
684			t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
685		)
686	}
687
688	return lipgloss.JoinVertical(
689		lipgloss.Left,
690		fileList...,
691	)
692}
693
694func (m *sidebarCmp) lspBlock() string {
695	t := styles.CurrentTheme()
696
697	section := t.S().Subtle.Render(
698		core.Section("LSPs", m.getMaxWidth()),
699	)
700
701	lspList := []string{section, ""}
702
703	lsp := config.Get().LSP.Sorted()
704	if len(lsp) == 0 {
705		return lipgloss.JoinVertical(
706			lipgloss.Left,
707			section,
708			"",
709			t.S().Base.Foreground(t.Border).Render("None"),
710		)
711	}
712
713	// Limit the number of LSPs shown
714	_, maxLSPs, _ := m.getDynamicLimits()
715	maxLSPs = min(len(lsp), maxLSPs)
716	for i, l := range lsp {
717		if i >= maxLSPs {
718			break
719		}
720
721		iconColor := t.Success
722		if l.LSP.Disabled {
723			iconColor = t.FgMuted
724		}
725		lspErrs := map[protocol.DiagnosticSeverity]int{
726			protocol.SeverityError:       0,
727			protocol.SeverityWarning:     0,
728			protocol.SeverityHint:        0,
729			protocol.SeverityInformation: 0,
730		}
731		if client, ok := m.lspClients[l.Name]; ok {
732			for _, diagnostics := range client.GetDiagnostics() {
733				for _, diagnostic := range diagnostics {
734					if severity, ok := lspErrs[diagnostic.Severity]; ok {
735						lspErrs[diagnostic.Severity] = severity + 1
736					}
737				}
738			}
739		}
740
741		errs := []string{}
742		if lspErrs[protocol.SeverityError] > 0 {
743			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
744		}
745		if lspErrs[protocol.SeverityWarning] > 0 {
746			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
747		}
748		if lspErrs[protocol.SeverityHint] > 0 {
749			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
750		}
751		if lspErrs[protocol.SeverityInformation] > 0 {
752			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
753		}
754
755		lspList = append(lspList,
756			core.Status(
757				core.StatusOpts{
758					IconColor:    iconColor,
759					Title:        l.Name,
760					Description:  l.LSP.Command,
761					ExtraContent: strings.Join(errs, " "),
762				},
763				m.getMaxWidth(),
764			),
765		)
766	}
767
768	// Add indicator if there are more LSPs
769	if len(lsp) > maxLSPs {
770		remaining := len(lsp) - maxLSPs
771		lspList = append(lspList,
772			t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
773		)
774	}
775
776	return lipgloss.JoinVertical(
777		lipgloss.Left,
778		lspList...,
779	)
780}
781
782func (m *sidebarCmp) mcpBlock() string {
783	t := styles.CurrentTheme()
784
785	section := t.S().Subtle.Render(
786		core.Section("MCPs", m.getMaxWidth()),
787	)
788
789	mcpList := []string{section, ""}
790
791	mcps := config.Get().MCP.Sorted()
792	if len(mcps) == 0 {
793		return lipgloss.JoinVertical(
794			lipgloss.Left,
795			section,
796			"",
797			t.S().Base.Foreground(t.Border).Render("None"),
798		)
799	}
800
801	// Limit the number of MCPs shown
802	_, _, maxMCPs := m.getDynamicLimits()
803	maxMCPs = min(len(mcps), maxMCPs)
804	for i, l := range mcps {
805		if i >= maxMCPs {
806			break
807		}
808
809		iconColor := t.Success
810		if l.MCP.Disabled {
811			iconColor = t.FgMuted
812		}
813		mcpList = append(mcpList,
814			core.Status(
815				core.StatusOpts{
816					IconColor:   iconColor,
817					Title:       l.Name,
818					Description: l.MCP.Command,
819				},
820				m.getMaxWidth(),
821			),
822		)
823	}
824
825	// Add indicator if there are more MCPs
826	if len(mcps) > maxMCPs {
827		remaining := len(mcps) - maxMCPs
828		mcpList = append(mcpList,
829			t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
830		)
831	}
832
833	return lipgloss.JoinVertical(
834		lipgloss.Left,
835		mcpList...,
836	)
837}
838
839func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
840	t := styles.CurrentTheme()
841	// Format tokens in human-readable format (e.g., 110K, 1.2M)
842	var formattedTokens string
843	switch {
844	case tokens >= 1_000_000:
845		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
846	case tokens >= 1_000:
847		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
848	default:
849		formattedTokens = fmt.Sprintf("%d", tokens)
850	}
851
852	// Remove .0 suffix if present
853	if strings.HasSuffix(formattedTokens, ".0K") {
854		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
855	}
856	if strings.HasSuffix(formattedTokens, ".0M") {
857		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
858	}
859
860	percentage := (float64(tokens) / float64(contextWindow)) * 100
861
862	baseStyle := t.S().Base
863
864	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
865
866	formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
867	formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
868	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
869	if percentage > 80 {
870		// add the warning icon
871		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
872	}
873
874	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
875}
876
877func (s *sidebarCmp) currentModelBlock() string {
878	cfg := config.Get()
879	agentCfg := cfg.Agents["coder"]
880
881	selectedModel := cfg.Models[agentCfg.Model]
882
883	model := config.Get().GetModelByType(agentCfg.Model)
884	modelProvider := config.Get().GetProviderForModel(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.Name)
890	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
891	parts := []string{
892		modelInfo,
893	}
894	if model.CanReason {
895		reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
896		switch modelProvider.Type {
897		case catwalk.TypeOpenAI:
898			reasoningEffort := model.DefaultReasoningEffort
899			if selectedModel.ReasoningEffort != "" {
900				reasoningEffort = selectedModel.ReasoningEffort
901			}
902			formatter := cases.Title(language.English, cases.NoLower)
903			parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
904		case catwalk.TypeAnthropic:
905			formatter := cases.Title(language.English, cases.NoLower)
906			if selectedModel.Think {
907				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
908			} else {
909				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
910			}
911		}
912	}
913	if s.session.ID != "" {
914		parts = append(
915			parts,
916			"  "+formatTokensAndCost(
917				s.session.CompletionTokens+s.session.PromptTokens,
918				model.ContextWindow,
919				s.session.Cost,
920			),
921		)
922	}
923	return lipgloss.JoinVertical(
924		lipgloss.Left,
925		parts...,
926	)
927}
928
929// SetSession implements Sidebar.
930func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
931	m.session = session
932	return m.loadSessionFiles
933}
934
935// SetCompactMode sets the compact mode for the sidebar.
936func (m *sidebarCmp) SetCompactMode(compact bool) {
937	m.compactMode = compact
938}
939
940func cwd() string {
941	cwd := config.Get().WorkingDir()
942	t := styles.CurrentTheme()
943	// Replace home directory with ~, unless we're at the top level of the
944	// home directory).
945	homeDir, err := os.UserHomeDir()
946	if err == nil && cwd != homeDir {
947		cwd = strings.ReplaceAll(cwd, homeDir, "~")
948	}
949	return t.S().Muted.Render(cwd)
950}