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