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 = 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	t := styles.CurrentTheme()
618
619	section := t.S().Subtle.Render(
620		core.Section("Modified Files", m.getMaxWidth()),
621	)
622
623	files := make([]SessionFile, 0)
624	m.files.Range(func(key, value any) bool {
625		file := value.(SessionFile)
626		files = append(files, file)
627		return true // continue iterating
628	})
629	if len(files) == 0 {
630		return lipgloss.JoinVertical(
631			lipgloss.Left,
632			section,
633			"",
634			t.S().Base.Foreground(t.Border).Render("None"),
635		)
636	}
637
638	fileList := []string{section, ""}
639	// order files by the latest version's created time
640	sort.Slice(files, func(i, j int) bool {
641		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
642	})
643
644	// Limit the number of files shown
645	maxFiles, _, _ := m.getDynamicLimits()
646	maxFiles = min(len(files), maxFiles)
647	filesShown := 0
648
649	for _, file := range files {
650		if file.Additions == 0 && file.Deletions == 0 {
651			continue // skip files with no changes
652		}
653		if filesShown >= maxFiles {
654			break
655		}
656
657		var statusParts []string
658		if file.Additions > 0 {
659			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
660		}
661		if file.Deletions > 0 {
662			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
663		}
664
665		extraContent := strings.Join(statusParts, " ")
666		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
667		filePath := file.FilePath
668		filePath = strings.TrimPrefix(filePath, cwd)
669		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
670		filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
671		fileList = append(fileList,
672			core.Status(
673				core.StatusOpts{
674					IconColor:    t.FgMuted,
675					NoIcon:       true,
676					Title:        filePath,
677					ExtraContent: extraContent,
678				},
679				m.getMaxWidth(),
680			),
681		)
682		filesShown++
683	}
684
685	// Add indicator if there are more files
686	totalFilesWithChanges := 0
687	for _, file := range files {
688		if file.Additions > 0 || file.Deletions > 0 {
689			totalFilesWithChanges++
690		}
691	}
692	if totalFilesWithChanges > maxFiles {
693		remaining := totalFilesWithChanges - maxFiles
694		fileList = append(fileList,
695			t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
696		)
697	}
698
699	return lipgloss.JoinVertical(
700		lipgloss.Left,
701		fileList...,
702	)
703}
704
705func (m *sidebarCmp) lspBlock() string {
706	t := styles.CurrentTheme()
707
708	section := t.S().Subtle.Render(
709		core.Section("LSPs", m.getMaxWidth()),
710	)
711
712	lspList := []string{section, ""}
713
714	lsp := config.Get().LSP.Sorted()
715	if len(lsp) == 0 {
716		return lipgloss.JoinVertical(
717			lipgloss.Left,
718			section,
719			"",
720			t.S().Base.Foreground(t.Border).Render("None"),
721		)
722	}
723
724	// Limit the number of LSPs shown
725	_, maxLSPs, _ := m.getDynamicLimits()
726	maxLSPs = min(len(lsp), maxLSPs)
727	for i, l := range lsp {
728		if i >= maxLSPs {
729			break
730		}
731
732		iconColor := t.Success
733		if l.LSP.Disabled {
734			iconColor = t.FgMuted
735		}
736		lspErrs := map[protocol.DiagnosticSeverity]int{
737			protocol.SeverityError:       0,
738			protocol.SeverityWarning:     0,
739			protocol.SeverityHint:        0,
740			protocol.SeverityInformation: 0,
741		}
742		if client, ok := m.lspClients[l.Name]; ok {
743			for _, diagnostics := range client.GetDiagnostics() {
744				for _, diagnostic := range diagnostics {
745					if severity, ok := lspErrs[diagnostic.Severity]; ok {
746						lspErrs[diagnostic.Severity] = severity + 1
747					}
748				}
749			}
750		}
751
752		errs := []string{}
753		if lspErrs[protocol.SeverityError] > 0 {
754			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
755		}
756		if lspErrs[protocol.SeverityWarning] > 0 {
757			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
758		}
759		if lspErrs[protocol.SeverityHint] > 0 {
760			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
761		}
762		if lspErrs[protocol.SeverityInformation] > 0 {
763			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
764		}
765
766		lspList = append(lspList,
767			core.Status(
768				core.StatusOpts{
769					IconColor:    iconColor,
770					Title:        l.Name,
771					Description:  l.LSP.Command,
772					ExtraContent: strings.Join(errs, " "),
773				},
774				m.getMaxWidth(),
775			),
776		)
777	}
778
779	// Add indicator if there are more LSPs
780	if len(lsp) > maxLSPs {
781		remaining := len(lsp) - maxLSPs
782		lspList = append(lspList,
783			t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
784		)
785	}
786
787	return lipgloss.JoinVertical(
788		lipgloss.Left,
789		lspList...,
790	)
791}
792
793func (m *sidebarCmp) mcpBlock() string {
794	t := styles.CurrentTheme()
795
796	section := t.S().Subtle.Render(
797		core.Section("MCPs", m.getMaxWidth()),
798	)
799
800	mcpList := []string{section, ""}
801
802	mcps := config.Get().MCP.Sorted()
803	if len(mcps) == 0 {
804		return lipgloss.JoinVertical(
805			lipgloss.Left,
806			section,
807			"",
808			t.S().Base.Foreground(t.Border).Render("None"),
809		)
810	}
811
812	// Limit the number of MCPs shown
813	_, _, maxMCPs := m.getDynamicLimits()
814	maxMCPs = min(len(mcps), maxMCPs)
815	for i, l := range mcps {
816		if i >= maxMCPs {
817			break
818		}
819
820		iconColor := t.Success
821		if l.MCP.Disabled {
822			iconColor = t.FgMuted
823		}
824		mcpList = append(mcpList,
825			core.Status(
826				core.StatusOpts{
827					IconColor:   iconColor,
828					Title:       l.Name,
829					Description: l.MCP.Command,
830				},
831				m.getMaxWidth(),
832			),
833		)
834	}
835
836	// Add indicator if there are more MCPs
837	if len(mcps) > maxMCPs {
838		remaining := len(mcps) - maxMCPs
839		mcpList = append(mcpList,
840			t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
841		)
842	}
843
844	return lipgloss.JoinVertical(
845		lipgloss.Left,
846		mcpList...,
847	)
848}
849
850func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
851	t := styles.CurrentTheme()
852	// Format tokens in human-readable format (e.g., 110K, 1.2M)
853	var formattedTokens string
854	switch {
855	case tokens >= 1_000_000:
856		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
857	case tokens >= 1_000:
858		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
859	default:
860		formattedTokens = fmt.Sprintf("%d", tokens)
861	}
862
863	// Remove .0 suffix if present
864	if strings.HasSuffix(formattedTokens, ".0K") {
865		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
866	}
867	if strings.HasSuffix(formattedTokens, ".0M") {
868		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
869	}
870
871	percentage := (float64(tokens) / float64(contextWindow)) * 100
872
873	baseStyle := t.S().Base
874
875	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
876
877	formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
878	formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
879	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
880	if percentage > 80 {
881		// add the warning icon
882		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
883	}
884
885	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
886}
887
888func (s *sidebarCmp) currentModelBlock() string {
889	cfg := config.Get()
890	agentCfg := cfg.Agents["coder"]
891
892	selectedModel := cfg.Models[agentCfg.Model]
893
894	model := config.Get().GetModelByType(agentCfg.Model)
895	modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
896
897	t := styles.CurrentTheme()
898
899	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
900	modelName := t.S().Text.Render(model.Model)
901	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
902	parts := []string{
903		modelInfo,
904	}
905	if model.CanReason {
906		reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
907		switch modelProvider.Type {
908		case provider.TypeOpenAI:
909			reasoningEffort := model.DefaultReasoningEffort
910			if selectedModel.ReasoningEffort != "" {
911				reasoningEffort = selectedModel.ReasoningEffort
912			}
913			formatter := cases.Title(language.English, cases.NoLower)
914			parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
915		case provider.TypeAnthropic:
916			formatter := cases.Title(language.English, cases.NoLower)
917			if selectedModel.Think {
918				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
919			} else {
920				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
921			}
922		}
923	}
924	if s.session.ID != "" {
925		parts = append(
926			parts,
927			"  "+formatTokensAndCost(
928				s.session.CompletionTokens+s.session.PromptTokens,
929				model.ContextWindow,
930				s.session.Cost,
931			),
932		)
933	}
934	return lipgloss.JoinVertical(
935		lipgloss.Left,
936		parts...,
937	)
938}
939
940// SetSession implements Sidebar.
941func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
942	m.session = session
943	return m.loadSessionFiles
944}
945
946// SetCompactMode sets the compact mode for the sidebar.
947func (m *sidebarCmp) SetCompactMode(compact bool) {
948	m.compactMode = compact
949}
950
951func cwd() string {
952	cwd := config.Get().WorkingDir()
953	t := styles.CurrentTheme()
954	// Replace home directory with ~, unless we're at the top level of the
955	// home directory).
956	homeDir, err := os.UserHomeDir()
957	if err == nil && cwd != homeDir {
958		cwd = strings.ReplaceAll(cwd, homeDir, "~")
959	}
960	return t.S().Muted.Render(cwd)
961}