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		cwd := config.WorkingDirectory() + string(os.PathSeparator)
295		filePath := file.FilePath
296		filePath = strings.TrimPrefix(filePath, cwd)
297		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
298		filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
299		fileList = append(fileList,
300			core.Status(
301				core.StatusOpts{
302					IconColor:    t.FgMuted,
303					NoIcon:       true,
304					Title:        filePath,
305					ExtraContent: extraContent,
306				},
307				m.width,
308			),
309		)
310	}
311
312	return lipgloss.JoinVertical(
313		lipgloss.Left,
314		fileList...,
315	)
316}
317
318func (m *sidebarCmp) lspBlock() string {
319	maxWidth := min(m.width, 58)
320	t := styles.CurrentTheme()
321
322	section := t.S().Subtle.Render(
323		core.Section("LSPs", maxWidth),
324	)
325
326	lspList := []string{section, ""}
327
328	lsp := config.Get().LSP
329	if len(lsp) == 0 {
330		return lipgloss.JoinVertical(
331			lipgloss.Left,
332			section,
333			"",
334			t.S().Base.Foreground(t.Border).Render("None"),
335		)
336	}
337
338	for n, l := range lsp {
339		iconColor := t.Success
340		if l.Disabled {
341			iconColor = t.FgMuted
342		}
343		lspErrs := map[protocol.DiagnosticSeverity]int{
344			protocol.SeverityError:       0,
345			protocol.SeverityWarning:     0,
346			protocol.SeverityHint:        0,
347			protocol.SeverityInformation: 0,
348		}
349		if client, ok := m.lspClients[n]; ok {
350			for _, diagnostics := range client.GetDiagnostics() {
351				for _, diagnostic := range diagnostics {
352					if severity, ok := lspErrs[diagnostic.Severity]; ok {
353						lspErrs[diagnostic.Severity] = severity + 1
354					}
355				}
356			}
357		}
358
359		errs := []string{}
360		if lspErrs[protocol.SeverityError] > 0 {
361			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
362		}
363		if lspErrs[protocol.SeverityWarning] > 0 {
364			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
365		}
366		if lspErrs[protocol.SeverityHint] > 0 {
367			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
368		}
369		if lspErrs[protocol.SeverityInformation] > 0 {
370			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
371		}
372
373		lspList = append(lspList,
374			core.Status(
375				core.StatusOpts{
376					IconColor:    iconColor,
377					Title:        n,
378					Description:  l.Command,
379					ExtraContent: strings.Join(errs, " "),
380				},
381				m.width,
382			),
383		)
384	}
385
386	return lipgloss.JoinVertical(
387		lipgloss.Left,
388		lspList...,
389	)
390}
391
392func (m *sidebarCmp) mcpBlock() string {
393	maxWidth := min(m.width, 58)
394	t := styles.CurrentTheme()
395
396	section := t.S().Subtle.Render(
397		core.Section("MCPs", maxWidth),
398	)
399
400	mcpList := []string{section, ""}
401
402	mcp := config.Get().MCPServers
403	if len(mcp) == 0 {
404		return lipgloss.JoinVertical(
405			lipgloss.Left,
406			section,
407			"",
408			t.S().Base.Foreground(t.Border).Render("None"),
409		)
410	}
411
412	for n, l := range mcp {
413		iconColor := t.Success
414		mcpList = append(mcpList,
415			core.Status(
416				core.StatusOpts{
417					IconColor:   iconColor,
418					Title:       n,
419					Description: l.Command,
420				},
421				m.width,
422			),
423		)
424	}
425
426	return lipgloss.JoinVertical(
427		lipgloss.Left,
428		mcpList...,
429	)
430}
431
432func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
433	t := styles.CurrentTheme()
434	// Format tokens in human-readable format (e.g., 110K, 1.2M)
435	var formattedTokens string
436	switch {
437	case tokens >= 1_000_000:
438		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
439	case tokens >= 1_000:
440		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
441	default:
442		formattedTokens = fmt.Sprintf("%d", tokens)
443	}
444
445	// Remove .0 suffix if present
446	if strings.HasSuffix(formattedTokens, ".0K") {
447		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
448	}
449	if strings.HasSuffix(formattedTokens, ".0M") {
450		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
451	}
452
453	percentage := (float64(tokens) / float64(contextWindow)) * 100
454
455	baseStyle := t.S().Base
456
457	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
458
459	formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
460	formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
461	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
462	if percentage > 80 {
463		// add the warning icon
464		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
465	}
466
467	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
468}
469
470func (s *sidebarCmp) currentModelBlock() string {
471	cfg := config.Get()
472	agentCfg := cfg.Agents[config.AgentCoder]
473	selectedModelID := agentCfg.Model
474	model := models.SupportedModels[selectedModelID]
475
476	t := styles.CurrentTheme()
477
478	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
479	modelName := t.S().Text.Render(model.Name)
480	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
481	parts := []string{
482		modelInfo,
483	}
484	if s.session.ID != "" {
485		parts = append(
486			parts,
487			"  "+formatTokensAndCost(
488				s.session.CompletionTokens+s.session.PromptTokens,
489				model.ContextWindow,
490				s.session.Cost,
491			),
492		)
493	}
494	return lipgloss.JoinVertical(
495		lipgloss.Left,
496		parts...,
497	)
498}
499
500func cwd() string {
501	cwd := config.WorkingDirectory()
502	t := styles.CurrentTheme()
503	// replace home directory with ~
504	homeDir, err := os.UserHomeDir()
505	if err == nil {
506		cwd = strings.ReplaceAll(cwd, homeDir, "~")
507	}
508	return t.S().Muted.Render(cwd)
509}