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