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