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() string {
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	style := t.S().Base.
133		Width(m.width).
134		Height(m.height).
135		Padding(1)
136	if m.compactMode {
137		style = style.PaddingTop(0)
138	}
139	return style.Render(
140		lipgloss.JoinVertical(lipgloss.Left, parts...),
141	)
142}
143
144func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
145	return func() tea.Msg {
146		file := event.Payload
147		found := false
148		m.files.Range(func(key, value any) bool {
149			existing := value.(SessionFile)
150			if existing.FilePath == file.Path {
151				if existing.History.latestVersion.Version < file.Version {
152					existing.History.latestVersion = file
153				} else if file.Version == 0 {
154					existing.History.initialVersion = file
155				} else {
156					// If the version is not greater than the latest, we ignore it
157					return true
158				}
159				before := existing.History.initialVersion.Content
160				after := existing.History.latestVersion.Content
161				path := existing.History.initialVersion.Path
162				cwd := config.Get().WorkingDir()
163				path = strings.TrimPrefix(path, cwd)
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		cwd := config.Get().WorkingDir()
218		path = strings.TrimPrefix(path, cwd)
219		_, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, path)
220		sessionFiles = append(sessionFiles, SessionFile{
221			History:   fh,
222			FilePath:  path,
223			Additions: additions,
224			Deletions: deletions,
225		})
226	}
227
228	return SessionFilesMsg{
229		Files: sessionFiles,
230	}
231}
232
233func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
234	m.logo = m.logoBlock()
235	m.cwd = cwd()
236	m.width = width
237	m.height = height
238	return nil
239}
240
241func (m *sidebarCmp) GetSize() (int, int) {
242	return m.width, m.height
243}
244
245func (m *sidebarCmp) logoBlock() string {
246	t := styles.CurrentTheme()
247	return logo.Render(version.Version, true, logo.Opts{
248		FieldColor:   t.Primary,
249		TitleColorA:  t.Secondary,
250		TitleColorB:  t.Primary,
251		CharmColor:   t.Secondary,
252		VersionColor: t.Primary,
253		Width:        m.width - 2,
254	})
255}
256
257func (m *sidebarCmp) getMaxWidth() int {
258	return min(m.width-2, 58) // -2 for padding
259}
260
261func (m *sidebarCmp) filesBlock() string {
262	t := styles.CurrentTheme()
263
264	section := t.S().Subtle.Render(
265		core.Section("Modified Files", m.getMaxWidth()),
266	)
267
268	files := make([]SessionFile, 0)
269	m.files.Range(func(key, value any) bool {
270		file := value.(SessionFile)
271		files = append(files, file)
272		return true // continue iterating
273	})
274	if len(files) == 0 {
275		return lipgloss.JoinVertical(
276			lipgloss.Left,
277			section,
278			"",
279			t.S().Base.Foreground(t.Border).Render("None"),
280		)
281	}
282
283	fileList := []string{section, ""}
284	// order files by the latest version's created time
285	sort.Slice(files, func(i, j int) bool {
286		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
287	})
288
289	for _, file := range files {
290		if file.Additions == 0 && file.Deletions == 0 {
291			continue // skip files with no changes
292		}
293		var statusParts []string
294		if file.Additions > 0 {
295			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
296		}
297		if file.Deletions > 0 {
298			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
299		}
300
301		extraContent := strings.Join(statusParts, " ")
302		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
303		filePath := file.FilePath
304		filePath = strings.TrimPrefix(filePath, cwd)
305		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
306		filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
307		fileList = append(fileList,
308			core.Status(
309				core.StatusOpts{
310					IconColor:    t.FgMuted,
311					NoIcon:       true,
312					Title:        filePath,
313					ExtraContent: extraContent,
314				},
315				m.getMaxWidth(),
316			),
317		)
318	}
319
320	return lipgloss.JoinVertical(
321		lipgloss.Left,
322		fileList...,
323	)
324}
325
326func (m *sidebarCmp) lspBlock() string {
327	t := styles.CurrentTheme()
328
329	section := t.S().Subtle.Render(
330		core.Section("LSPs", m.getMaxWidth()),
331	)
332
333	lspList := []string{section, ""}
334
335	lsp := config.Get().LSP.Sorted()
336	if len(lsp) == 0 {
337		return lipgloss.JoinVertical(
338			lipgloss.Left,
339			section,
340			"",
341			t.S().Base.Foreground(t.Border).Render("None"),
342		)
343	}
344
345	for _, l := range lsp {
346		iconColor := t.Success
347		if l.LSP.Disabled {
348			iconColor = t.FgMuted
349		}
350		lspErrs := map[protocol.DiagnosticSeverity]int{
351			protocol.SeverityError:       0,
352			protocol.SeverityWarning:     0,
353			protocol.SeverityHint:        0,
354			protocol.SeverityInformation: 0,
355		}
356		if client, ok := m.lspClients[l.Name]; ok {
357			for _, diagnostics := range client.GetDiagnostics() {
358				for _, diagnostic := range diagnostics {
359					if severity, ok := lspErrs[diagnostic.Severity]; ok {
360						lspErrs[diagnostic.Severity] = severity + 1
361					}
362				}
363			}
364		}
365
366		errs := []string{}
367		if lspErrs[protocol.SeverityError] > 0 {
368			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
369		}
370		if lspErrs[protocol.SeverityWarning] > 0 {
371			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
372		}
373		if lspErrs[protocol.SeverityHint] > 0 {
374			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
375		}
376		if lspErrs[protocol.SeverityInformation] > 0 {
377			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
378		}
379
380		lspList = append(lspList,
381			core.Status(
382				core.StatusOpts{
383					IconColor:    iconColor,
384					Title:        l.Name,
385					Description:  l.LSP.Command,
386					ExtraContent: strings.Join(errs, " "),
387				},
388				m.getMaxWidth(),
389			),
390		)
391	}
392
393	return lipgloss.JoinVertical(
394		lipgloss.Left,
395		lspList...,
396	)
397}
398
399func (m *sidebarCmp) mcpBlock() string {
400	t := styles.CurrentTheme()
401
402	section := t.S().Subtle.Render(
403		core.Section("MCPs", m.getMaxWidth()),
404	)
405
406	mcpList := []string{section, ""}
407
408	mcps := config.Get().MCP.Sorted()
409	if len(mcps) == 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 _, l := range mcps {
419		iconColor := t.Success
420		if l.MCP.Disabled {
421			iconColor = t.FgMuted
422		}
423		mcpList = append(mcpList,
424			core.Status(
425				core.StatusOpts{
426					IconColor:   iconColor,
427					Title:       l.Name,
428					Description: l.MCP.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.Model)
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}