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