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 SetSession(session session.Session) tea.Cmd
56}
57
58type sidebarCmp struct {
59 width, height int
60 session session.Session
61 logo string
62 cwd string
63 lspClients map[string]*lsp.Client
64 compactMode bool
65 history history.Service
66 // Using a sync map here because we might receive file history events concurrently
67 files sync.Map
68}
69
70func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
71 return &sidebarCmp{
72 lspClients: lspClients,
73 history: history,
74 compactMode: compact,
75 }
76}
77
78func (m *sidebarCmp) Init() tea.Cmd {
79 m.logo = m.logoBlock(false)
80 m.cwd = cwd()
81 return nil
82}
83
84func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85 switch msg := msg.(type) {
86 case chat.SessionSelectedMsg:
87 return m, m.SetSession(msg)
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 if !m.compactMode {
114 parts = append(parts, m.logo)
115 }
116
117 if !m.compactMode && m.session.ID != "" {
118 parts = append(parts, t.S().Muted.Render(m.session.Title), "")
119 } else if m.session.ID != "" {
120 parts = append(parts, t.S().Text.Render(m.session.Title), "")
121 }
122
123 if !m.compactMode {
124 parts = append(parts,
125 m.cwd,
126 "",
127 )
128 }
129 parts = append(parts,
130 m.currentModelBlock(),
131 )
132 if m.session.ID != "" {
133 parts = append(parts, "", m.filesBlock())
134 }
135 parts = append(parts,
136 "",
137 m.lspBlock(),
138 "",
139 m.mcpBlock(),
140 )
141
142 return tea.NewView(
143 lipgloss.JoinVertical(lipgloss.Left, parts...),
144 )
145}
146
147func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
148 return func() tea.Msg {
149 file := event.Payload
150 found := false
151 m.files.Range(func(key, value any) bool {
152 existing := value.(SessionFile)
153 if existing.FilePath == file.Path {
154 if existing.History.latestVersion.Version < file.Version {
155 existing.History.latestVersion = file
156 } else if file.Version == 0 {
157 existing.History.initialVersion = file
158 } else {
159 // If the version is not greater than the latest, we ignore it
160 return true
161 }
162 before := existing.History.initialVersion.Content
163 after := existing.History.latestVersion.Content
164 path := existing.History.initialVersion.Path
165 _, additions, deletions := diff.GenerateDiff(before, after, path)
166 existing.Additions = additions
167 existing.Deletions = deletions
168 m.files.Store(file.Path, existing)
169 found = true
170 return false
171 }
172 return true
173 })
174 if found {
175 return nil
176 }
177 sf := SessionFile{
178 History: FileHistory{
179 initialVersion: file,
180 latestVersion: file,
181 },
182 FilePath: file.Path,
183 Additions: 0,
184 Deletions: 0,
185 }
186 m.files.Store(file.Path, sf)
187 return nil
188 }
189}
190
191func (m *sidebarCmp) loadSessionFiles() tea.Msg {
192 files, err := m.history.ListBySession(context.Background(), m.session.ID)
193 if err != nil {
194 return util.InfoMsg{
195 Type: util.InfoTypeError,
196 Msg: err.Error(),
197 }
198 }
199
200 fileMap := make(map[string]FileHistory)
201
202 for _, file := range files {
203 if existing, ok := fileMap[file.Path]; ok {
204 // Update the latest version
205 existing.latestVersion = file
206 fileMap[file.Path] = existing
207 } else {
208 // Add the initial version
209 fileMap[file.Path] = FileHistory{
210 initialVersion: file,
211 latestVersion: file,
212 }
213 }
214 }
215
216 sessionFiles := make([]SessionFile, 0, len(fileMap))
217 for path, fh := range fileMap {
218 _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
219 sessionFiles = append(sessionFiles, SessionFile{
220 History: fh,
221 FilePath: path,
222 Additions: additions,
223 Deletions: deletions,
224 })
225 }
226
227 return SessionFilesMsg{
228 Files: sessionFiles,
229 }
230}
231
232func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
233 if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
234 m.logo = m.logoBlock(true)
235 } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
236 m.logo = m.logoBlock(false)
237 }
238
239 m.width = width
240 m.height = height
241 return nil
242}
243
244func (m *sidebarCmp) GetSize() (int, int) {
245 return m.width, m.height
246}
247
248func (m *sidebarCmp) logoBlock(compact bool) string {
249 t := styles.CurrentTheme()
250 return logo.Render(version.Version, compact, logo.Opts{
251 FieldColor: t.Primary,
252 TitleColorA: t.Secondary,
253 TitleColorB: t.Primary,
254 CharmColor: t.Secondary,
255 VersionColor: t.Primary,
256 })
257}
258
259func (m *sidebarCmp) filesBlock() string {
260 maxWidth := min(m.width, 58)
261 t := styles.CurrentTheme()
262
263 section := t.S().Subtle.Render(
264 core.Section("Modified Files", maxWidth),
265 )
266
267 files := make([]SessionFile, 0)
268 m.files.Range(func(key, value any) bool {
269 file := value.(SessionFile)
270 files = append(files, file)
271 return true // continue iterating
272 })
273 if len(files) == 0 {
274 return lipgloss.JoinVertical(
275 lipgloss.Left,
276 section,
277 "",
278 t.S().Base.Foreground(t.Border).Render("None"),
279 )
280 }
281
282 fileList := []string{section, ""}
283 // order files by the latest version's created time
284 sort.Slice(files, func(i, j int) bool {
285 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
286 })
287
288 for _, file := range files {
289 if file.Additions == 0 && file.Deletions == 0 {
290 continue // skip files with no changes
291 }
292 var statusParts []string
293 if file.Additions > 0 {
294 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
295 }
296 if file.Deletions > 0 {
297 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
298 }
299
300 extraContent := strings.Join(statusParts, " ")
301 cwd := config.WorkingDirectory() + string(os.PathSeparator)
302 filePath := file.FilePath
303 filePath = strings.TrimPrefix(filePath, cwd)
304 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
305 filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
306 fileList = append(fileList,
307 core.Status(
308 core.StatusOpts{
309 IconColor: t.FgMuted,
310 NoIcon: true,
311 Title: filePath,
312 ExtraContent: extraContent,
313 },
314 m.width,
315 ),
316 )
317 }
318
319 return lipgloss.JoinVertical(
320 lipgloss.Left,
321 fileList...,
322 )
323}
324
325func (m *sidebarCmp) lspBlock() string {
326 maxWidth := min(m.width, 58)
327 t := styles.CurrentTheme()
328
329 section := t.S().Subtle.Render(
330 core.Section("LSPs", maxWidth),
331 )
332
333 lspList := []string{section, ""}
334
335 lsp := config.Get().LSP
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 n, l := range lsp {
346 iconColor := t.Success
347 if l.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[n]; 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: n,
385 Description: l.Command,
386 ExtraContent: strings.Join(errs, " "),
387 },
388 m.width,
389 ),
390 )
391 }
392
393 return lipgloss.JoinVertical(
394 lipgloss.Left,
395 lspList...,
396 )
397}
398
399func (m *sidebarCmp) mcpBlock() string {
400 maxWidth := min(m.width, 58)
401 t := styles.CurrentTheme()
402
403 section := t.S().Subtle.Render(
404 core.Section("MCPs", maxWidth),
405 )
406
407 mcpList := []string{section, ""}
408
409 mcp := config.Get().MCPServers
410 if len(mcp) == 0 {
411 return lipgloss.JoinVertical(
412 lipgloss.Left,
413 section,
414 "",
415 t.S().Base.Foreground(t.Border).Render("None"),
416 )
417 }
418
419 for n, l := range mcp {
420 iconColor := t.Success
421 mcpList = append(mcpList,
422 core.Status(
423 core.StatusOpts{
424 IconColor: iconColor,
425 Title: n,
426 Description: l.Command,
427 },
428 m.width,
429 ),
430 )
431 }
432
433 return lipgloss.JoinVertical(
434 lipgloss.Left,
435 mcpList...,
436 )
437}
438
439func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
440 t := styles.CurrentTheme()
441 // Format tokens in human-readable format (e.g., 110K, 1.2M)
442 var formattedTokens string
443 switch {
444 case tokens >= 1_000_000:
445 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
446 case tokens >= 1_000:
447 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
448 default:
449 formattedTokens = fmt.Sprintf("%d", tokens)
450 }
451
452 // Remove .0 suffix if present
453 if strings.HasSuffix(formattedTokens, ".0K") {
454 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
455 }
456 if strings.HasSuffix(formattedTokens, ".0M") {
457 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
458 }
459
460 percentage := (float64(tokens) / float64(contextWindow)) * 100
461
462 baseStyle := t.S().Base
463
464 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
465
466 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
467 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
468 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
469 if percentage > 80 {
470 // add the warning icon
471 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
472 }
473
474 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
475}
476
477func (s *sidebarCmp) currentModelBlock() string {
478 cfg := config.Get()
479 agentCfg := cfg.Agents[config.AgentCoder]
480 selectedModelID := agentCfg.Model
481 model := models.SupportedModels[selectedModelID]
482
483 t := styles.CurrentTheme()
484
485 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
486 modelName := t.S().Text.Render(model.Name)
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
513func cwd() string {
514 cwd := config.WorkingDirectory()
515 t := styles.CurrentTheme()
516 // Replace home directory with ~, unless we're at the top level of the
517 // home directory).
518 homeDir, err := os.UserHomeDir()
519 if err == nil && cwd != homeDir {
520 cwd = strings.ReplaceAll(cwd, homeDir, "~")
521 }
522 return t.S().Muted.Render(cwd)
523}