sidebar.go

  1package sidebar
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"strings"
  8
  9	tea "github.com/charmbracelet/bubbletea/v2"
 10	"github.com/charmbracelet/crush/internal/config"
 11	"github.com/charmbracelet/crush/internal/diff"
 12	"github.com/charmbracelet/crush/internal/fsext"
 13	"github.com/charmbracelet/crush/internal/history"
 14	"github.com/charmbracelet/crush/internal/llm/models"
 15	"github.com/charmbracelet/crush/internal/logging"
 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
 31const (
 32	logoBreakpoint = 65
 33)
 34
 35type SessionFile struct {
 36	FilePath  string
 37	Additions int
 38	Deletions int
 39}
 40type SessionFilesMsg struct {
 41	Files []SessionFile
 42}
 43
 44type Sidebar interface {
 45	util.Model
 46	layout.Sizeable
 47}
 48
 49type sidebarCmp struct {
 50	width, height int
 51	session       session.Session
 52	logo          string
 53	cwd           string
 54	lspClients    map[string]*lsp.Client
 55	history       history.Service
 56	files         []SessionFile
 57}
 58
 59func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client) Sidebar {
 60	return &sidebarCmp{
 61		lspClients: lspClients,
 62		history:    history,
 63	}
 64}
 65
 66func (m *sidebarCmp) Init() tea.Cmd {
 67	m.logo = m.logoBlock(false)
 68	m.cwd = cwd()
 69	return nil
 70}
 71
 72func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 73	switch msg := msg.(type) {
 74	case chat.SessionSelectedMsg:
 75		if msg.ID != m.session.ID {
 76			m.session = msg
 77		}
 78		return m, m.loadSessionFiles
 79	case SessionFilesMsg:
 80		m.files = msg.Files
 81		logging.Info("Loaded session files", "count", len(m.files))
 82		return m, nil
 83
 84	case chat.SessionClearedMsg:
 85		m.session = session.Session{}
 86	case pubsub.Event[session.Session]:
 87		if msg.Type == pubsub.UpdatedEvent {
 88			if m.session.ID == msg.Payload.ID {
 89				m.session = msg.Payload
 90			}
 91		}
 92	}
 93	return m, nil
 94}
 95
 96func (m *sidebarCmp) View() tea.View {
 97	t := styles.CurrentTheme()
 98	parts := []string{
 99		m.logo,
100	}
101
102	if m.session.ID != "" {
103		parts = append(parts, t.S().Muted.Render(m.session.Title), "")
104	}
105
106	parts = append(parts,
107		m.cwd,
108		"",
109		m.currentModelBlock(),
110	)
111	if m.session.ID != "" {
112		parts = append(parts, "", m.filesBlock())
113	}
114	parts = append(parts,
115		"",
116		m.lspBlock(),
117		"",
118		m.mcpBlock(),
119	)
120
121	return tea.NewView(
122		lipgloss.JoinVertical(lipgloss.Left, parts...),
123	)
124}
125
126func (m *sidebarCmp) loadSessionFiles() tea.Msg {
127	files, err := m.history.ListBySession(context.Background(), m.session.ID)
128	if err != nil {
129		return util.InfoMsg{
130			Type: util.InfoTypeError,
131			Msg:  err.Error(),
132		}
133	}
134
135	type fileHistory struct {
136		initialVersion history.File
137		latestVersion  history.File
138	}
139
140	fileMap := make(map[string]fileHistory)
141
142	for _, file := range files {
143		if existing, ok := fileMap[file.Path]; ok {
144			// Update the latest version
145			if existing.latestVersion.CreatedAt < file.CreatedAt {
146				existing.latestVersion = file
147			}
148			if file.Version == history.InitialVersion {
149				existing.initialVersion = file
150			}
151			fileMap[file.Path] = existing
152		} else {
153			// Add the initial version
154			fileMap[file.Path] = fileHistory{
155				initialVersion: file,
156				latestVersion:  file,
157			}
158		}
159	}
160
161	sessionFiles := make([]SessionFile, 0, len(fileMap))
162	for path, fh := range fileMap {
163		if fh.initialVersion.Version == history.InitialVersion {
164			_, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
165			sessionFiles = append(sessionFiles, SessionFile{
166				FilePath:  path,
167				Additions: additions,
168				Deletions: deletions,
169			})
170		}
171	}
172
173	return SessionFilesMsg{
174		Files: sessionFiles,
175	}
176}
177
178func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
179	if width < logoBreakpoint && m.width >= logoBreakpoint {
180		m.logo = m.logoBlock(true)
181	} else if width >= logoBreakpoint && m.width < logoBreakpoint {
182		m.logo = m.logoBlock(false)
183	}
184
185	m.width = width
186	m.height = height
187	return nil
188}
189
190func (m *sidebarCmp) GetSize() (int, int) {
191	return m.width, m.height
192}
193
194func (m *sidebarCmp) logoBlock(compact bool) string {
195	t := styles.CurrentTheme()
196	return logo.Render(version.Version, compact, logo.Opts{
197		FieldColor:   t.Primary,
198		TitleColorA:  t.Secondary,
199		TitleColorB:  t.Primary,
200		CharmColor:   t.Secondary,
201		VersionColor: t.Primary,
202	})
203}
204
205func (m *sidebarCmp) filesBlock() string {
206	maxWidth := min(m.width, 58)
207	t := styles.CurrentTheme()
208
209	section := t.S().Subtle.Render(
210		core.Section("Modified Files", maxWidth),
211	)
212
213	if len(m.files) == 0 {
214		return lipgloss.JoinVertical(
215			lipgloss.Left,
216			section,
217			"",
218			t.S().Base.Foreground(t.Border).Render("None"),
219		)
220	}
221
222	fileList := []string{section, ""}
223
224	for _, file := range m.files {
225		// Extract just the filename from the path
226
227		// Create status indicators for additions/deletions
228		var statusParts []string
229		if file.Additions > 0 {
230			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
231		}
232		if file.Deletions > 0 {
233			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
234		}
235
236		extraContent := strings.Join(statusParts, " ")
237		filePath := fsext.DirTrim(fsext.PrettyPath(file.FilePath), 2)
238		filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
239		fileList = append(fileList,
240			core.Status(
241				core.StatusOpts{
242					IconColor:    t.FgMuted,
243					NoIcon:       true,
244					Title:        filePath,
245					ExtraContent: extraContent,
246				},
247				m.width,
248			),
249		)
250	}
251
252	return lipgloss.JoinVertical(
253		lipgloss.Left,
254		fileList...,
255	)
256}
257
258func (m *sidebarCmp) lspBlock() string {
259	maxWidth := min(m.width, 58)
260	t := styles.CurrentTheme()
261
262	section := t.S().Subtle.Render(
263		core.Section("LSPs", maxWidth),
264	)
265
266	lspList := []string{section, ""}
267
268	lsp := config.Get().LSP
269	if len(lsp) == 0 {
270		return lipgloss.JoinVertical(
271			lipgloss.Left,
272			section,
273			"",
274			t.S().Base.Foreground(t.Border).Render("None"),
275		)
276	}
277
278	for n, l := range lsp {
279		iconColor := t.Success
280		if l.Disabled {
281			iconColor = t.FgMuted
282		}
283		lspErrs := map[protocol.DiagnosticSeverity]int{
284			protocol.SeverityError:       0,
285			protocol.SeverityWarning:     0,
286			protocol.SeverityHint:        0,
287			protocol.SeverityInformation: 0,
288		}
289		if client, ok := m.lspClients[n]; ok {
290			for _, diagnostics := range client.GetDiagnostics() {
291				for _, diagnostic := range diagnostics {
292					if severity, ok := lspErrs[diagnostic.Severity]; ok {
293						lspErrs[diagnostic.Severity] = severity + 1
294					}
295				}
296			}
297		}
298
299		errs := []string{}
300		if lspErrs[protocol.SeverityError] > 0 {
301			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
302		}
303		if lspErrs[protocol.SeverityWarning] > 0 {
304			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
305		}
306		if lspErrs[protocol.SeverityHint] > 0 {
307			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
308		}
309		if lspErrs[protocol.SeverityInformation] > 0 {
310			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
311		}
312
313		lspList = append(lspList,
314			core.Status(
315				core.StatusOpts{
316					IconColor:    iconColor,
317					Title:        n,
318					Description:  l.Command,
319					ExtraContent: strings.Join(errs, " "),
320				},
321				m.width,
322			),
323		)
324	}
325
326	return lipgloss.JoinVertical(
327		lipgloss.Left,
328		lspList...,
329	)
330}
331
332func (m *sidebarCmp) mcpBlock() string {
333	maxWidth := min(m.width, 58)
334	t := styles.CurrentTheme()
335
336	section := t.S().Subtle.Render(
337		core.Section("MCPs", maxWidth),
338	)
339
340	mcpList := []string{section, ""}
341
342	mcp := config.Get().MCPServers
343	if len(mcp) == 0 {
344		return lipgloss.JoinVertical(
345			lipgloss.Left,
346			section,
347			"",
348			t.S().Base.Foreground(t.Border).Render("None"),
349		)
350	}
351
352	for n, l := range mcp {
353		iconColor := t.Success
354		mcpList = append(mcpList,
355			core.Status(
356				core.StatusOpts{
357					IconColor:   iconColor,
358					Title:       n,
359					Description: l.Command,
360				},
361				m.width,
362			),
363		)
364	}
365
366	return lipgloss.JoinVertical(
367		lipgloss.Left,
368		mcpList...,
369	)
370}
371
372func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
373	t := styles.CurrentTheme()
374	// Format tokens in human-readable format (e.g., 110K, 1.2M)
375	var formattedTokens string
376	switch {
377	case tokens >= 1_000_000:
378		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
379	case tokens >= 1_000:
380		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
381	default:
382		formattedTokens = fmt.Sprintf("%d", tokens)
383	}
384
385	// Remove .0 suffix if present
386	if strings.HasSuffix(formattedTokens, ".0K") {
387		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
388	}
389	if strings.HasSuffix(formattedTokens, ".0M") {
390		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
391	}
392
393	percentage := (float64(tokens) / float64(contextWindow)) * 100
394
395	baseStyle := t.S().Base
396
397	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
398
399	formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
400	formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
401	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
402	if percentage > 80 {
403		// add the warning icon
404		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
405	}
406
407	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
408}
409
410func (s *sidebarCmp) currentModelBlock() string {
411	cfg := config.Get()
412	agentCfg := cfg.Agents[config.AgentCoder]
413	selectedModelID := agentCfg.Model
414	model := models.SupportedModels[selectedModelID]
415
416	t := styles.CurrentTheme()
417
418	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
419	modelName := t.S().Text.Render(model.Name)
420	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
421	parts := []string{
422		modelInfo,
423	}
424	if s.session.ID != "" {
425		parts = append(
426			parts,
427			"  "+formatTokensAndCost(
428				s.session.CompletionTokens+s.session.PromptTokens,
429				model.ContextWindow,
430				s.session.Cost,
431			),
432		)
433	}
434	return lipgloss.JoinVertical(
435		lipgloss.Left,
436		parts...,
437	)
438}
439
440func cwd() string {
441	cwd := config.WorkingDirectory()
442	t := styles.CurrentTheme()
443	// replace home directory with ~
444	homeDir, err := os.UserHomeDir()
445	if err == nil {
446		cwd = strings.ReplaceAll(cwd, homeDir, "~")
447	}
448	return t.S().Muted.Render(cwd)
449}