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