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