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	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		if msg.ID != m.session.ID {
 87			m.session = msg
 88		}
 89		return m, m.loadSessionFiles
 90	case SessionFilesMsg:
 91		m.files = sync.Map{}
 92		for _, file := range msg.Files {
 93			m.files.Store(file.FilePath, file)
 94		}
 95		return m, nil
 96
 97	case chat.SessionClearedMsg:
 98		m.session = session.Session{}
 99	case pubsub.Event[history.File]:
100		logging.Info("sidebar", "Received file history event", "file", msg.Payload.Path, "session", msg.Payload.SessionID)
101		return m, m.handleFileHistoryEvent(msg)
102	case pubsub.Event[session.Session]:
103		if msg.Type == pubsub.UpdatedEvent {
104			if m.session.ID == msg.Payload.ID {
105				m.session = msg.Payload
106			}
107		}
108	}
109	return m, nil
110}
111
112func (m *sidebarCmp) View() tea.View {
113	t := styles.CurrentTheme()
114	parts := []string{}
115	if !m.compactMode {
116		parts = append(parts, m.logo)
117	}
118
119	if !m.compactMode && m.session.ID != "" {
120		parts = append(parts, t.S().Muted.Render(m.session.Title), "")
121	} else if m.session.ID != "" {
122		parts = append(parts, t.S().Text.Render(m.session.Title), "")
123	}
124
125	if !m.compactMode {
126		parts = append(parts,
127			m.cwd,
128			"",
129		)
130	}
131	parts = append(parts,
132		m.currentModelBlock(),
133	)
134	if m.session.ID != "" {
135		parts = append(parts, "", m.filesBlock())
136	}
137	parts = append(parts,
138		"",
139		m.lspBlock(),
140		"",
141		m.mcpBlock(),
142	)
143
144	return tea.NewView(
145		lipgloss.JoinVertical(lipgloss.Left, parts...),
146	)
147}
148
149func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
150	return func() tea.Msg {
151		file := event.Payload
152		found := false
153		m.files.Range(func(key, value any) bool {
154			existing := value.(SessionFile)
155			if existing.FilePath == file.Path {
156				if existing.History.latestVersion.Version < file.Version {
157					existing.History.latestVersion = file
158				} else if file.Version == 0 {
159					existing.History.initialVersion = file
160				} else {
161					// If the version is not greater than the latest, we ignore it
162					return true
163				}
164				before := existing.History.initialVersion.Content
165				after := existing.History.latestVersion.Content
166				path := existing.History.initialVersion.Path
167				_, additions, deletions := diff.GenerateDiff(before, after, path)
168				existing.Additions = additions
169				existing.Deletions = deletions
170				m.files.Store(file.Path, existing)
171				found = true
172				return false
173			}
174			return true
175		})
176		if found {
177			return nil
178		}
179		sf := SessionFile{
180			History: FileHistory{
181				initialVersion: file,
182				latestVersion:  file,
183			},
184			FilePath:  file.Path,
185			Additions: 0,
186			Deletions: 0,
187		}
188		m.files.Store(file.Path, sf)
189		return nil
190	}
191}
192
193func (m *sidebarCmp) loadSessionFiles() tea.Msg {
194	files, err := m.history.ListBySession(context.Background(), m.session.ID)
195	if err != nil {
196		return util.InfoMsg{
197			Type: util.InfoTypeError,
198			Msg:  err.Error(),
199		}
200	}
201
202	fileMap := make(map[string]FileHistory)
203
204	for _, file := range files {
205		if existing, ok := fileMap[file.Path]; ok {
206			// Update the latest version
207			existing.latestVersion = file
208			fileMap[file.Path] = existing
209		} else {
210			// Add the initial version
211			fileMap[file.Path] = FileHistory{
212				initialVersion: file,
213				latestVersion:  file,
214			}
215		}
216	}
217
218	sessionFiles := make([]SessionFile, 0, len(fileMap))
219	for path, fh := range fileMap {
220		_, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
221		sessionFiles = append(sessionFiles, SessionFile{
222			History:   fh,
223			FilePath:  path,
224			Additions: additions,
225			Deletions: deletions,
226		})
227	}
228
229	return SessionFilesMsg{
230		Files: sessionFiles,
231	}
232}
233
234func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
235	if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
236		m.logo = m.logoBlock(true)
237	} else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
238		m.logo = m.logoBlock(false)
239	}
240
241	m.width = width
242	m.height = height
243	return nil
244}
245
246func (m *sidebarCmp) GetSize() (int, int) {
247	return m.width, m.height
248}
249
250func (m *sidebarCmp) logoBlock(compact bool) string {
251	t := styles.CurrentTheme()
252	return logo.Render(version.Version, compact, logo.Opts{
253		FieldColor:   t.Primary,
254		TitleColorA:  t.Secondary,
255		TitleColorB:  t.Primary,
256		CharmColor:   t.Secondary,
257		VersionColor: t.Primary,
258	})
259}
260
261func (m *sidebarCmp) filesBlock() string {
262	maxWidth := min(m.width, 58)
263	t := styles.CurrentTheme()
264
265	section := t.S().Subtle.Render(
266		core.Section("Modified Files", maxWidth),
267	)
268
269	files := make([]SessionFile, 0)
270	m.files.Range(func(key, value any) bool {
271		file := value.(SessionFile)
272		files = append(files, file)
273		return true // continue iterating
274	})
275	if len(files) == 0 {
276		return lipgloss.JoinVertical(
277			lipgloss.Left,
278			section,
279			"",
280			t.S().Base.Foreground(t.Border).Render("None"),
281		)
282	}
283
284	fileList := []string{section, ""}
285	// order files by the latest version's created time
286	sort.Slice(files, func(i, j int) bool {
287		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
288	})
289
290	for _, file := range files {
291		if file.Additions == 0 && file.Deletions == 0 {
292			continue // skip files with no changes
293		}
294		var statusParts []string
295		if file.Additions > 0 {
296			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
297		}
298		if file.Deletions > 0 {
299			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
300		}
301
302		extraContent := strings.Join(statusParts, " ")
303		cwd := config.WorkingDirectory() + string(os.PathSeparator)
304		filePath := file.FilePath
305		filePath = strings.TrimPrefix(filePath, cwd)
306		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
307		filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
308		fileList = append(fileList,
309			core.Status(
310				core.StatusOpts{
311					IconColor:    t.FgMuted,
312					NoIcon:       true,
313					Title:        filePath,
314					ExtraContent: extraContent,
315				},
316				m.width,
317			),
318		)
319	}
320
321	return lipgloss.JoinVertical(
322		lipgloss.Left,
323		fileList...,
324	)
325}
326
327func (m *sidebarCmp) lspBlock() string {
328	maxWidth := min(m.width, 58)
329	t := styles.CurrentTheme()
330
331	section := t.S().Subtle.Render(
332		core.Section("LSPs", maxWidth),
333	)
334
335	lspList := []string{section, ""}
336
337	lsp := config.Get().LSP
338	if len(lsp) == 0 {
339		return lipgloss.JoinVertical(
340			lipgloss.Left,
341			section,
342			"",
343			t.S().Base.Foreground(t.Border).Render("None"),
344		)
345	}
346
347	for n, l := range lsp {
348		iconColor := t.Success
349		if l.Disabled {
350			iconColor = t.FgMuted
351		}
352		lspErrs := map[protocol.DiagnosticSeverity]int{
353			protocol.SeverityError:       0,
354			protocol.SeverityWarning:     0,
355			protocol.SeverityHint:        0,
356			protocol.SeverityInformation: 0,
357		}
358		if client, ok := m.lspClients[n]; ok {
359			for _, diagnostics := range client.GetDiagnostics() {
360				for _, diagnostic := range diagnostics {
361					if severity, ok := lspErrs[diagnostic.Severity]; ok {
362						lspErrs[diagnostic.Severity] = severity + 1
363					}
364				}
365			}
366		}
367
368		errs := []string{}
369		if lspErrs[protocol.SeverityError] > 0 {
370			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
371		}
372		if lspErrs[protocol.SeverityWarning] > 0 {
373			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
374		}
375		if lspErrs[protocol.SeverityHint] > 0 {
376			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
377		}
378		if lspErrs[protocol.SeverityInformation] > 0 {
379			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
380		}
381
382		lspList = append(lspList,
383			core.Status(
384				core.StatusOpts{
385					IconColor:    iconColor,
386					Title:        n,
387					Description:  l.Command,
388					ExtraContent: strings.Join(errs, " "),
389				},
390				m.width,
391			),
392		)
393	}
394
395	return lipgloss.JoinVertical(
396		lipgloss.Left,
397		lspList...,
398	)
399}
400
401func (m *sidebarCmp) mcpBlock() string {
402	maxWidth := min(m.width, 58)
403	t := styles.CurrentTheme()
404
405	section := t.S().Subtle.Render(
406		core.Section("MCPs", maxWidth),
407	)
408
409	mcpList := []string{section, ""}
410
411	mcp := config.Get().MCPServers
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.width,
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	cfg := config.Get()
481	agentCfg := cfg.Agents[config.AgentCoder]
482	selectedModelID := agentCfg.Model
483	model := models.SupportedModels[selectedModelID]
484
485	t := styles.CurrentTheme()
486
487	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
488	modelName := t.S().Text.Render(model.Name)
489	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
490	parts := []string{
491		modelInfo,
492	}
493	if s.session.ID != "" {
494		parts = append(
495			parts,
496			"  "+formatTokensAndCost(
497				s.session.CompletionTokens+s.session.PromptTokens,
498				model.ContextWindow,
499				s.session.Cost,
500			),
501		)
502	}
503	return lipgloss.JoinVertical(
504		lipgloss.Left,
505		parts...,
506	)
507}
508
509func cwd() string {
510	cwd := config.WorkingDirectory()
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}