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/history"
 16	"github.com/charmbracelet/crush/internal/lsp"
 17	"github.com/charmbracelet/crush/internal/lsp/protocol"
 18	"github.com/charmbracelet/crush/internal/pubsub"
 19	"github.com/charmbracelet/crush/internal/session"
 20	"github.com/charmbracelet/crush/internal/tui/components/chat"
 21	"github.com/charmbracelet/crush/internal/tui/components/core"
 22	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 23	"github.com/charmbracelet/crush/internal/tui/components/logo"
 24	"github.com/charmbracelet/crush/internal/tui/styles"
 25	"github.com/charmbracelet/crush/internal/tui/util"
 26	"github.com/charmbracelet/crush/internal/version"
 27	"github.com/charmbracelet/lipgloss/v2"
 28	"github.com/charmbracelet/x/ansi"
 29)
 30
 31type FileHistory struct {
 32	initialVersion history.File
 33	latestVersion  history.File
 34}
 35
 36const LogoHeightBreakpoint = 40
 37
 38// Default maximum number of items to show in each section
 39const (
 40	DefaultMaxFilesShown = 10
 41	DefaultMaxLSPsShown  = 8
 42	DefaultMaxMCPsShown  = 8
 43	MinItemsPerSection   = 2 // Minimum items to show per section
 44)
 45
 46type SessionFile struct {
 47	History   FileHistory
 48	FilePath  string
 49	Additions int
 50	Deletions int
 51}
 52type SessionFilesMsg struct {
 53	Files []SessionFile
 54}
 55
 56type Sidebar interface {
 57	util.Model
 58	layout.Sizeable
 59	SetSession(session session.Session) tea.Cmd
 60	SetCompactMode(bool)
 61}
 62
 63type sidebarCmp struct {
 64	width, height int
 65	session       session.Session
 66	logo          string
 67	cwd           string
 68	lspClients    map[string]*lsp.Client
 69	compactMode   bool
 70	history       history.Service
 71	// Using a sync map here because we might receive file history events concurrently
 72	files sync.Map
 73}
 74
 75func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
 76	return &sidebarCmp{
 77		lspClients:  lspClients,
 78		history:     history,
 79		compactMode: compact,
 80	}
 81}
 82
 83func (m *sidebarCmp) Init() tea.Cmd {
 84	return nil
 85}
 86
 87func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 88	switch msg := msg.(type) {
 89	case SessionFilesMsg:
 90		m.files = sync.Map{}
 91		for _, file := range msg.Files {
 92			m.files.Store(file.FilePath, file)
 93		}
 94		return m, nil
 95
 96	case chat.SessionClearedMsg:
 97		m.session = session.Session{}
 98	case pubsub.Event[history.File]:
 99		return m, m.handleFileHistoryEvent(msg)
100	case pubsub.Event[session.Session]:
101		if msg.Type == pubsub.UpdatedEvent {
102			if m.session.ID == msg.Payload.ID {
103				m.session = msg.Payload
104			}
105		}
106	}
107	return m, nil
108}
109
110func (m *sidebarCmp) View() string {
111	t := styles.CurrentTheme()
112	parts := []string{}
113
114	if !m.compactMode {
115		if m.height > LogoHeightBreakpoint {
116			parts = append(parts, m.logo)
117		} else {
118			// Use a smaller logo for smaller screens
119			parts = append(parts, m.smallerScreenLogo(), "")
120		}
121	}
122
123	if !m.compactMode && m.session.ID != "" {
124		parts = append(parts, t.S().Muted.Render(m.session.Title), "")
125	} else if m.session.ID != "" {
126		parts = append(parts, t.S().Text.Render(m.session.Title), "")
127	}
128
129	if !m.compactMode {
130		parts = append(parts,
131			m.cwd,
132			"",
133		)
134	}
135	parts = append(parts,
136		m.currentModelBlock(),
137	)
138	if m.session.ID != "" {
139		parts = append(parts, "", m.filesBlock())
140	}
141	parts = append(parts,
142		"",
143		m.lspBlock(),
144		"",
145		m.mcpBlock(),
146	)
147
148	style := t.S().Base.
149		Width(m.width).
150		Height(m.height).
151		Padding(1)
152	if m.compactMode {
153		style = style.PaddingTop(0)
154	}
155	return style.Render(
156		lipgloss.JoinVertical(lipgloss.Left, parts...),
157	)
158}
159
160func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
161	return func() tea.Msg {
162		file := event.Payload
163		found := false
164		m.files.Range(func(key, value any) bool {
165			existing := value.(SessionFile)
166			if existing.FilePath == file.Path {
167				if existing.History.latestVersion.Version < file.Version {
168					existing.History.latestVersion = file
169				} else if file.Version == 0 {
170					existing.History.initialVersion = file
171				} else {
172					// If the version is not greater than the latest, we ignore it
173					return true
174				}
175				before := existing.History.initialVersion.Content
176				after := existing.History.latestVersion.Content
177				path := existing.History.initialVersion.Path
178				cwd := config.Get().WorkingDir()
179				path = strings.TrimPrefix(path, cwd)
180				_, additions, deletions := diff.GenerateDiff(before, after, path)
181				existing.Additions = additions
182				existing.Deletions = deletions
183				m.files.Store(file.Path, existing)
184				found = true
185				return false
186			}
187			return true
188		})
189		if found {
190			return nil
191		}
192		sf := SessionFile{
193			History: FileHistory{
194				initialVersion: file,
195				latestVersion:  file,
196			},
197			FilePath:  file.Path,
198			Additions: 0,
199			Deletions: 0,
200		}
201		m.files.Store(file.Path, sf)
202		return nil
203	}
204}
205
206func (m *sidebarCmp) loadSessionFiles() tea.Msg {
207	files, err := m.history.ListBySession(context.Background(), m.session.ID)
208	if err != nil {
209		return util.InfoMsg{
210			Type: util.InfoTypeError,
211			Msg:  err.Error(),
212		}
213	}
214
215	fileMap := make(map[string]FileHistory)
216
217	for _, file := range files {
218		if existing, ok := fileMap[file.Path]; ok {
219			// Update the latest version
220			existing.latestVersion = file
221			fileMap[file.Path] = existing
222		} else {
223			// Add the initial version
224			fileMap[file.Path] = FileHistory{
225				initialVersion: file,
226				latestVersion:  file,
227			}
228		}
229	}
230
231	sessionFiles := make([]SessionFile, 0, len(fileMap))
232	for path, fh := range fileMap {
233		cwd := config.Get().WorkingDir()
234		path = strings.TrimPrefix(path, cwd)
235		_, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, path)
236		sessionFiles = append(sessionFiles, SessionFile{
237			History:   fh,
238			FilePath:  path,
239			Additions: additions,
240			Deletions: deletions,
241		})
242	}
243
244	return SessionFilesMsg{
245		Files: sessionFiles,
246	}
247}
248
249func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
250	m.logo = m.logoBlock()
251	m.cwd = cwd()
252	m.width = width
253	m.height = height
254	return nil
255}
256
257func (m *sidebarCmp) GetSize() (int, int) {
258	return m.width, m.height
259}
260
261func (m *sidebarCmp) logoBlock() string {
262	t := styles.CurrentTheme()
263	return logo.Render(version.Version, true, logo.Opts{
264		FieldColor:   t.Primary,
265		TitleColorA:  t.Secondary,
266		TitleColorB:  t.Primary,
267		CharmColor:   t.Secondary,
268		VersionColor: t.Primary,
269		Width:        m.width - 2,
270	})
271}
272
273func (m *sidebarCmp) getMaxWidth() int {
274	return min(m.width-2, 58) // -2 for padding
275}
276
277// calculateAvailableHeight estimates how much height is available for dynamic content
278func (m *sidebarCmp) calculateAvailableHeight() int {
279	usedHeight := 0
280
281	if !m.compactMode {
282		if m.height > LogoHeightBreakpoint {
283			usedHeight += 7 // Approximate logo height
284		} else {
285			usedHeight += 2 // Smaller logo height
286		}
287		usedHeight += 1 // Empty line after logo
288	}
289
290	if m.session.ID != "" {
291		usedHeight += 1 // Title line
292		usedHeight += 1 // Empty line after title
293	}
294
295	if !m.compactMode {
296		usedHeight += 1 // CWD line
297		usedHeight += 1 // Empty line after CWD
298	}
299
300	usedHeight += 2 // Model info
301
302	usedHeight += 6 // 3 sections Γ— 2 lines each (header + empty line)
303
304	// Base padding
305	usedHeight += 2 // Top and bottom padding
306
307	return max(0, m.height-usedHeight)
308}
309
310// getDynamicLimits calculates how many items to show in each section based on available height
311func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
312	availableHeight := m.calculateAvailableHeight()
313
314	// If we have very little space, use minimum values
315	if availableHeight < 10 {
316		return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
317	}
318
319	// Distribute available height among the three sections
320	// Give priority to files, then LSPs, then MCPs
321	totalSections := 3
322	heightPerSection := availableHeight / totalSections
323
324	// Calculate limits for each section, ensuring minimums
325	maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
326	maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
327	maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
328
329	// If we have extra space, give it to files first
330	remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
331	if remainingHeight > 0 {
332		extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
333		maxFiles += extraForFiles
334		remainingHeight -= extraForFiles
335
336		if remainingHeight > 0 {
337			extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
338			maxLSPs += extraForLSPs
339			remainingHeight -= extraForLSPs
340
341			if remainingHeight > 0 {
342				maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
343			}
344		}
345	}
346
347	return maxFiles, maxLSPs, maxMCPs
348}
349
350func (m *sidebarCmp) filesBlock() string {
351	t := styles.CurrentTheme()
352
353	section := t.S().Subtle.Render(
354		core.Section("Modified Files", m.getMaxWidth()),
355	)
356
357	files := make([]SessionFile, 0)
358	m.files.Range(func(key, value any) bool {
359		file := value.(SessionFile)
360		files = append(files, file)
361		return true // continue iterating
362	})
363	if len(files) == 0 {
364		return lipgloss.JoinVertical(
365			lipgloss.Left,
366			section,
367			"",
368			t.S().Base.Foreground(t.Border).Render("None"),
369		)
370	}
371
372	fileList := []string{section, ""}
373	// order files by the latest version's created time
374	sort.Slice(files, func(i, j int) bool {
375		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
376	})
377
378	// Limit the number of files shown
379	maxFiles, _, _ := m.getDynamicLimits()
380	maxFiles = min(len(files), maxFiles)
381	filesShown := 0
382
383	for _, file := range files {
384		if file.Additions == 0 && file.Deletions == 0 {
385			continue // skip files with no changes
386		}
387		if filesShown >= maxFiles {
388			break
389		}
390
391		var statusParts []string
392		if file.Additions > 0 {
393			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
394		}
395		if file.Deletions > 0 {
396			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
397		}
398
399		extraContent := strings.Join(statusParts, " ")
400		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
401		filePath := file.FilePath
402		filePath = strings.TrimPrefix(filePath, cwd)
403		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
404		filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
405		fileList = append(fileList,
406			core.Status(
407				core.StatusOpts{
408					IconColor:    t.FgMuted,
409					NoIcon:       true,
410					Title:        filePath,
411					ExtraContent: extraContent,
412				},
413				m.getMaxWidth(),
414			),
415		)
416		filesShown++
417	}
418
419	// Add indicator if there are more files
420	totalFilesWithChanges := 0
421	for _, file := range files {
422		if file.Additions > 0 || file.Deletions > 0 {
423			totalFilesWithChanges++
424		}
425	}
426	if totalFilesWithChanges > maxFiles {
427		remaining := totalFilesWithChanges - maxFiles
428		fileList = append(fileList,
429			t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
430		)
431	}
432
433	return lipgloss.JoinVertical(
434		lipgloss.Left,
435		fileList...,
436	)
437}
438
439func (m *sidebarCmp) lspBlock() string {
440	t := styles.CurrentTheme()
441
442	section := t.S().Subtle.Render(
443		core.Section("LSPs", m.getMaxWidth()),
444	)
445
446	lspList := []string{section, ""}
447
448	lsp := config.Get().LSP.Sorted()
449	if len(lsp) == 0 {
450		return lipgloss.JoinVertical(
451			lipgloss.Left,
452			section,
453			"",
454			t.S().Base.Foreground(t.Border).Render("None"),
455		)
456	}
457
458	// Limit the number of LSPs shown
459	_, maxLSPs, _ := m.getDynamicLimits()
460	maxLSPs = min(len(lsp), maxLSPs)
461	for i, l := range lsp {
462		if i >= maxLSPs {
463			break
464		}
465
466		iconColor := t.Success
467		if l.LSP.Disabled {
468			iconColor = t.FgMuted
469		}
470		lspErrs := map[protocol.DiagnosticSeverity]int{
471			protocol.SeverityError:       0,
472			protocol.SeverityWarning:     0,
473			protocol.SeverityHint:        0,
474			protocol.SeverityInformation: 0,
475		}
476		if client, ok := m.lspClients[l.Name]; ok {
477			for _, diagnostics := range client.GetDiagnostics() {
478				for _, diagnostic := range diagnostics {
479					if severity, ok := lspErrs[diagnostic.Severity]; ok {
480						lspErrs[diagnostic.Severity] = severity + 1
481					}
482				}
483			}
484		}
485
486		errs := []string{}
487		if lspErrs[protocol.SeverityError] > 0 {
488			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
489		}
490		if lspErrs[protocol.SeverityWarning] > 0 {
491			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
492		}
493		if lspErrs[protocol.SeverityHint] > 0 {
494			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
495		}
496		if lspErrs[protocol.SeverityInformation] > 0 {
497			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
498		}
499
500		lspList = append(lspList,
501			core.Status(
502				core.StatusOpts{
503					IconColor:    iconColor,
504					Title:        l.Name,
505					Description:  l.LSP.Command,
506					ExtraContent: strings.Join(errs, " "),
507				},
508				m.getMaxWidth(),
509			),
510		)
511	}
512
513	// Add indicator if there are more LSPs
514	if len(lsp) > maxLSPs {
515		remaining := len(lsp) - maxLSPs
516		lspList = append(lspList,
517			t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
518		)
519	}
520
521	return lipgloss.JoinVertical(
522		lipgloss.Left,
523		lspList...,
524	)
525}
526
527func (m *sidebarCmp) mcpBlock() string {
528	t := styles.CurrentTheme()
529
530	section := t.S().Subtle.Render(
531		core.Section("MCPs", m.getMaxWidth()),
532	)
533
534	mcpList := []string{section, ""}
535
536	mcps := config.Get().MCP.Sorted()
537	if len(mcps) == 0 {
538		return lipgloss.JoinVertical(
539			lipgloss.Left,
540			section,
541			"",
542			t.S().Base.Foreground(t.Border).Render("None"),
543		)
544	}
545
546	// Limit the number of MCPs shown
547	_, _, maxMCPs := m.getDynamicLimits()
548	maxMCPs = min(len(mcps), maxMCPs)
549	for i, l := range mcps {
550		if i >= maxMCPs {
551			break
552		}
553
554		iconColor := t.Success
555		if l.MCP.Disabled {
556			iconColor = t.FgMuted
557		}
558		mcpList = append(mcpList,
559			core.Status(
560				core.StatusOpts{
561					IconColor:   iconColor,
562					Title:       l.Name,
563					Description: l.MCP.Command,
564				},
565				m.getMaxWidth(),
566			),
567		)
568	}
569
570	// Add indicator if there are more MCPs
571	if len(mcps) > maxMCPs {
572		remaining := len(mcps) - maxMCPs
573		mcpList = append(mcpList,
574			t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
575		)
576	}
577
578	return lipgloss.JoinVertical(
579		lipgloss.Left,
580		mcpList...,
581	)
582}
583
584func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
585	t := styles.CurrentTheme()
586	// Format tokens in human-readable format (e.g., 110K, 1.2M)
587	var formattedTokens string
588	switch {
589	case tokens >= 1_000_000:
590		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
591	case tokens >= 1_000:
592		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
593	default:
594		formattedTokens = fmt.Sprintf("%d", tokens)
595	}
596
597	// Remove .0 suffix if present
598	if strings.HasSuffix(formattedTokens, ".0K") {
599		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
600	}
601	if strings.HasSuffix(formattedTokens, ".0M") {
602		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
603	}
604
605	percentage := (float64(tokens) / float64(contextWindow)) * 100
606
607	baseStyle := t.S().Base
608
609	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
610
611	formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
612	formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
613	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
614	if percentage > 80 {
615		// add the warning icon
616		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
617	}
618
619	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
620}
621
622func (s *sidebarCmp) currentModelBlock() string {
623	agentCfg := config.Get().Agents["coder"]
624	model := config.Get().GetModelByType(agentCfg.Model)
625
626	t := styles.CurrentTheme()
627
628	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
629	modelName := t.S().Text.Render(model.Model)
630	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
631	parts := []string{
632		modelInfo,
633	}
634	if s.session.ID != "" {
635		parts = append(
636			parts,
637			"  "+formatTokensAndCost(
638				s.session.CompletionTokens+s.session.PromptTokens,
639				model.ContextWindow,
640				s.session.Cost,
641			),
642		)
643	}
644	return lipgloss.JoinVertical(
645		lipgloss.Left,
646		parts...,
647	)
648}
649
650func (m *sidebarCmp) smallerScreenLogo() string {
651	t := styles.CurrentTheme()
652	title := t.S().Base.Foreground(t.Secondary).Render("Charmβ„’")
653	title += " " + styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)
654	remainingWidth := m.width - lipgloss.Width(title) - 3
655	if remainingWidth > 0 {
656		char := "β•±"
657		lines := strings.Repeat(char, remainingWidth)
658		title += " " + t.S().Base.Foreground(t.Primary).Render(lines)
659	}
660	return title
661}
662
663// SetSession implements Sidebar.
664func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
665	m.session = session
666	return m.loadSessionFiles
667}
668
669// SetCompactMode sets the compact mode for the sidebar.
670func (m *sidebarCmp) SetCompactMode(compact bool) {
671	m.compactMode = compact
672}
673
674func cwd() string {
675	cwd := config.Get().WorkingDir()
676	t := styles.CurrentTheme()
677	// Replace home directory with ~, unless we're at the top level of the
678	// home directory).
679	homeDir, err := os.UserHomeDir()
680	if err == nil && cwd != homeDir {
681		cwd = strings.ReplaceAll(cwd, homeDir, "~")
682	}
683	return t.S().Muted.Render(cwd)
684}