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