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