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
 38type SessionFile struct {
 39	History   FileHistory
 40	FilePath  string
 41	Additions int
 42	Deletions int
 43}
 44type SessionFilesMsg struct {
 45	Files []SessionFile
 46}
 47
 48type Sidebar interface {
 49	util.Model
 50	layout.Sizeable
 51	SetSession(session session.Session) tea.Cmd
 52	SetCompactMode(bool)
 53}
 54
 55type sidebarCmp struct {
 56	width, height int
 57	session       session.Session
 58	logo          string
 59	cwd           string
 60	lspClients    map[string]*lsp.Client
 61	compactMode   bool
 62	history       history.Service
 63	// Using a sync map here because we might receive file history events concurrently
 64	files sync.Map
 65}
 66
 67func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
 68	return &sidebarCmp{
 69		lspClients:  lspClients,
 70		history:     history,
 71		compactMode: compact,
 72	}
 73}
 74
 75func (m *sidebarCmp) Init() tea.Cmd {
 76	return nil
 77}
 78
 79func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 80	switch msg := msg.(type) {
 81	case SessionFilesMsg:
 82		m.files = sync.Map{}
 83		for _, file := range msg.Files {
 84			m.files.Store(file.FilePath, file)
 85		}
 86		return m, nil
 87
 88	case chat.SessionClearedMsg:
 89		m.session = session.Session{}
 90	case pubsub.Event[history.File]:
 91		return m, m.handleFileHistoryEvent(msg)
 92	case pubsub.Event[session.Session]:
 93		if msg.Type == pubsub.UpdatedEvent {
 94			if m.session.ID == msg.Payload.ID {
 95				m.session = msg.Payload
 96			}
 97		}
 98	}
 99	return m, nil
100}
101
102func (m *sidebarCmp) View() string {
103	t := styles.CurrentTheme()
104	parts := []string{}
105
106	if !m.compactMode {
107		if m.height > LogoHeightBreakpoint {
108			parts = append(parts, m.logo)
109		} else {
110			// Use a smaller logo for smaller screens
111			parts = append(parts, m.smallerScreenLogo(), "")
112		}
113	}
114
115	if !m.compactMode && m.session.ID != "" {
116		parts = append(parts, t.S().Muted.Render(m.session.Title), "")
117	} else if m.session.ID != "" {
118		parts = append(parts, t.S().Text.Render(m.session.Title), "")
119	}
120
121	if !m.compactMode {
122		parts = append(parts,
123			m.cwd,
124			"",
125		)
126	}
127	parts = append(parts,
128		m.currentModelBlock(),
129	)
130	if m.session.ID != "" {
131		parts = append(parts, "", m.filesBlock())
132	}
133	parts = append(parts,
134		"",
135		m.lspBlock(),
136		"",
137		m.mcpBlock(),
138	)
139
140	style := t.S().Base.
141		Width(m.width).
142		Height(m.height).
143		Padding(1)
144	if m.compactMode {
145		style = style.PaddingTop(0)
146	}
147	return style.Render(
148		lipgloss.JoinVertical(lipgloss.Left, parts...),
149	)
150}
151
152func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
153	return func() tea.Msg {
154		file := event.Payload
155		found := false
156		m.files.Range(func(key, value any) bool {
157			existing := value.(SessionFile)
158			if existing.FilePath == file.Path {
159				if existing.History.latestVersion.Version < file.Version {
160					existing.History.latestVersion = file
161				} else if file.Version == 0 {
162					existing.History.initialVersion = file
163				} else {
164					// If the version is not greater than the latest, we ignore it
165					return true
166				}
167				before := existing.History.initialVersion.Content
168				after := existing.History.latestVersion.Content
169				path := existing.History.initialVersion.Path
170				cwd := config.Get().WorkingDir()
171				path = strings.TrimPrefix(path, cwd)
172				_, additions, deletions := diff.GenerateDiff(before, after, path)
173				existing.Additions = additions
174				existing.Deletions = deletions
175				m.files.Store(file.Path, existing)
176				found = true
177				return false
178			}
179			return true
180		})
181		if found {
182			return nil
183		}
184		sf := SessionFile{
185			History: FileHistory{
186				initialVersion: file,
187				latestVersion:  file,
188			},
189			FilePath:  file.Path,
190			Additions: 0,
191			Deletions: 0,
192		}
193		m.files.Store(file.Path, sf)
194		return nil
195	}
196}
197
198func (m *sidebarCmp) loadSessionFiles() tea.Msg {
199	files, err := m.history.ListBySession(context.Background(), m.session.ID)
200	if err != nil {
201		return util.InfoMsg{
202			Type: util.InfoTypeError,
203			Msg:  err.Error(),
204		}
205	}
206
207	fileMap := make(map[string]FileHistory)
208
209	for _, file := range files {
210		if existing, ok := fileMap[file.Path]; ok {
211			// Update the latest version
212			existing.latestVersion = file
213			fileMap[file.Path] = existing
214		} else {
215			// Add the initial version
216			fileMap[file.Path] = FileHistory{
217				initialVersion: file,
218				latestVersion:  file,
219			}
220		}
221	}
222
223	sessionFiles := make([]SessionFile, 0, len(fileMap))
224	for path, fh := range fileMap {
225		cwd := config.Get().WorkingDir()
226		path = strings.TrimPrefix(path, cwd)
227		_, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, path)
228		sessionFiles = append(sessionFiles, SessionFile{
229			History:   fh,
230			FilePath:  path,
231			Additions: additions,
232			Deletions: deletions,
233		})
234	}
235
236	return SessionFilesMsg{
237		Files: sessionFiles,
238	}
239}
240
241func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
242	m.logo = m.logoBlock()
243	m.cwd = cwd()
244	m.width = width
245	m.height = height
246	return nil
247}
248
249func (m *sidebarCmp) GetSize() (int, int) {
250	return m.width, m.height
251}
252
253func (m *sidebarCmp) logoBlock() string {
254	t := styles.CurrentTheme()
255	return logo.Render(version.Version, true, logo.Opts{
256		FieldColor:   t.Primary,
257		TitleColorA:  t.Secondary,
258		TitleColorB:  t.Primary,
259		CharmColor:   t.Secondary,
260		VersionColor: t.Primary,
261		Width:        m.width - 2,
262	})
263}
264
265func (m *sidebarCmp) getMaxWidth() int {
266	return min(m.width-2, 58) // -2 for padding
267}
268
269func (m *sidebarCmp) filesBlock() string {
270	t := styles.CurrentTheme()
271
272	section := t.S().Subtle.Render(
273		core.Section("Modified Files", m.getMaxWidth()),
274	)
275
276	files := make([]SessionFile, 0)
277	m.files.Range(func(key, value any) bool {
278		file := value.(SessionFile)
279		files = append(files, file)
280		return true // continue iterating
281	})
282	if len(files) == 0 {
283		return lipgloss.JoinVertical(
284			lipgloss.Left,
285			section,
286			"",
287			t.S().Base.Foreground(t.Border).Render("None"),
288		)
289	}
290
291	fileList := []string{section, ""}
292	// order files by the latest version's created time
293	sort.Slice(files, func(i, j int) bool {
294		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
295	})
296
297	for _, file := range files {
298		if file.Additions == 0 && file.Deletions == 0 {
299			continue // skip files with no changes
300		}
301		var statusParts []string
302		if file.Additions > 0 {
303			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
304		}
305		if file.Deletions > 0 {
306			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
307		}
308
309		extraContent := strings.Join(statusParts, " ")
310		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
311		filePath := file.FilePath
312		filePath = strings.TrimPrefix(filePath, cwd)
313		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
314		filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
315		fileList = append(fileList,
316			core.Status(
317				core.StatusOpts{
318					IconColor:    t.FgMuted,
319					NoIcon:       true,
320					Title:        filePath,
321					ExtraContent: extraContent,
322				},
323				m.getMaxWidth(),
324			),
325		)
326	}
327
328	return lipgloss.JoinVertical(
329		lipgloss.Left,
330		fileList...,
331	)
332}
333
334func (m *sidebarCmp) lspBlock() string {
335	t := styles.CurrentTheme()
336
337	section := t.S().Subtle.Render(
338		core.Section("LSPs", m.getMaxWidth()),
339	)
340
341	lspList := []string{section, ""}
342
343	lsp := config.Get().LSP.Sorted()
344	if len(lsp) == 0 {
345		return lipgloss.JoinVertical(
346			lipgloss.Left,
347			section,
348			"",
349			t.S().Base.Foreground(t.Border).Render("None"),
350		)
351	}
352
353	for _, l := range lsp {
354		iconColor := t.Success
355		if l.LSP.Disabled {
356			iconColor = t.FgMuted
357		}
358		lspErrs := map[protocol.DiagnosticSeverity]int{
359			protocol.SeverityError:       0,
360			protocol.SeverityWarning:     0,
361			protocol.SeverityHint:        0,
362			protocol.SeverityInformation: 0,
363		}
364		if client, ok := m.lspClients[l.Name]; ok {
365			for _, diagnostics := range client.GetDiagnostics() {
366				for _, diagnostic := range diagnostics {
367					if severity, ok := lspErrs[diagnostic.Severity]; ok {
368						lspErrs[diagnostic.Severity] = severity + 1
369					}
370				}
371			}
372		}
373
374		errs := []string{}
375		if lspErrs[protocol.SeverityError] > 0 {
376			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
377		}
378		if lspErrs[protocol.SeverityWarning] > 0 {
379			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
380		}
381		if lspErrs[protocol.SeverityHint] > 0 {
382			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
383		}
384		if lspErrs[protocol.SeverityInformation] > 0 {
385			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
386		}
387
388		lspList = append(lspList,
389			core.Status(
390				core.StatusOpts{
391					IconColor:    iconColor,
392					Title:        l.Name,
393					Description:  l.LSP.Command,
394					ExtraContent: strings.Join(errs, " "),
395				},
396				m.getMaxWidth(),
397			),
398		)
399	}
400
401	return lipgloss.JoinVertical(
402		lipgloss.Left,
403		lspList...,
404	)
405}
406
407func (m *sidebarCmp) mcpBlock() string {
408	t := styles.CurrentTheme()
409
410	section := t.S().Subtle.Render(
411		core.Section("MCPs", m.getMaxWidth()),
412	)
413
414	mcpList := []string{section, ""}
415
416	mcps := config.Get().MCP.Sorted()
417	if len(mcps) == 0 {
418		return lipgloss.JoinVertical(
419			lipgloss.Left,
420			section,
421			"",
422			t.S().Base.Foreground(t.Border).Render("None"),
423		)
424	}
425
426	for _, l := range mcps {
427		iconColor := t.Success
428		if l.MCP.Disabled {
429			iconColor = t.FgMuted
430		}
431		mcpList = append(mcpList,
432			core.Status(
433				core.StatusOpts{
434					IconColor:   iconColor,
435					Title:       l.Name,
436					Description: l.MCP.Command,
437				},
438				m.getMaxWidth(),
439			),
440		)
441	}
442
443	return lipgloss.JoinVertical(
444		lipgloss.Left,
445		mcpList...,
446	)
447}
448
449func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
450	t := styles.CurrentTheme()
451	// Format tokens in human-readable format (e.g., 110K, 1.2M)
452	var formattedTokens string
453	switch {
454	case tokens >= 1_000_000:
455		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
456	case tokens >= 1_000:
457		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
458	default:
459		formattedTokens = fmt.Sprintf("%d", tokens)
460	}
461
462	// Remove .0 suffix if present
463	if strings.HasSuffix(formattedTokens, ".0K") {
464		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
465	}
466	if strings.HasSuffix(formattedTokens, ".0M") {
467		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
468	}
469
470	percentage := (float64(tokens) / float64(contextWindow)) * 100
471
472	baseStyle := t.S().Base
473
474	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
475
476	formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
477	formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
478	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
479	if percentage > 80 {
480		// add the warning icon
481		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
482	}
483
484	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
485}
486
487func (s *sidebarCmp) currentModelBlock() string {
488	agentCfg := config.Get().Agents["coder"]
489	model := config.Get().GetModelByType(agentCfg.Model)
490
491	t := styles.CurrentTheme()
492
493	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
494	modelName := t.S().Text.Render(model.Model)
495	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
496	parts := []string{
497		modelInfo,
498	}
499	if s.session.ID != "" {
500		parts = append(
501			parts,
502			"  "+formatTokensAndCost(
503				s.session.CompletionTokens+s.session.PromptTokens,
504				model.ContextWindow,
505				s.session.Cost,
506			),
507		)
508	}
509	return lipgloss.JoinVertical(
510		lipgloss.Left,
511		parts...,
512	)
513}
514
515func (m *sidebarCmp) smallerScreenLogo() string {
516	t := styles.CurrentTheme()
517	title := t.S().Base.Foreground(t.Secondary).Render("Charmβ„’")
518	title += " " + styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)
519	remainingWidth := m.width - lipgloss.Width(title) - 3
520	if remainingWidth > 0 {
521		char := "β•±"
522		lines := strings.Repeat(char, remainingWidth)
523		title += " " + t.S().Base.Foreground(t.Primary).Render(lines)
524	}
525	return title
526}
527
528// SetSession implements Sidebar.
529func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
530	m.session = session
531	return m.loadSessionFiles
532}
533
534// SetCompactMode sets the compact mode for the sidebar.
535func (m *sidebarCmp) SetCompactMode(compact bool) {
536	m.compactMode = compact
537}
538
539func cwd() string {
540	cwd := config.Get().WorkingDir()
541	t := styles.CurrentTheme()
542	// Replace home directory with ~, unless we're at the top level of the
543	// home directory).
544	homeDir, err := os.UserHomeDir()
545	if err == nil && cwd != homeDir {
546		cwd = strings.ReplaceAll(cwd, homeDir, "~")
547	}
548	return t.S().Muted.Render(cwd)
549}