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