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