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 })
254}
255
256func (m *sidebarCmp) getMaxWidth() int {
257 return min(m.width-2, 58) // -2 for padding
258}
259
260func (m *sidebarCmp) filesBlock() string {
261 t := styles.CurrentTheme()
262
263 section := t.S().Subtle.Render(
264 core.Section("Modified Files", m.getMaxWidth()),
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.Get().WorkingDir() + 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, m.getMaxWidth()-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.getMaxWidth(),
315 ),
316 )
317 }
318
319 return lipgloss.JoinVertical(
320 lipgloss.Left,
321 fileList...,
322 )
323}
324
325func (m *sidebarCmp) lspBlock() string {
326 t := styles.CurrentTheme()
327
328 section := t.S().Subtle.Render(
329 core.Section("LSPs", m.getMaxWidth()),
330 )
331
332 lspList := []string{section, ""}
333
334 lsp := config.Get().LSP.Sorted()
335 if len(lsp) == 0 {
336 return lipgloss.JoinVertical(
337 lipgloss.Left,
338 section,
339 "",
340 t.S().Base.Foreground(t.Border).Render("None"),
341 )
342 }
343
344 for _, l := range lsp {
345 iconColor := t.Success
346 if l.LSP.Disabled {
347 iconColor = t.FgMuted
348 }
349 lspErrs := map[protocol.DiagnosticSeverity]int{
350 protocol.SeverityError: 0,
351 protocol.SeverityWarning: 0,
352 protocol.SeverityHint: 0,
353 protocol.SeverityInformation: 0,
354 }
355 if client, ok := m.lspClients[l.Name]; ok {
356 for _, diagnostics := range client.GetDiagnostics() {
357 for _, diagnostic := range diagnostics {
358 if severity, ok := lspErrs[diagnostic.Severity]; ok {
359 lspErrs[diagnostic.Severity] = severity + 1
360 }
361 }
362 }
363 }
364
365 errs := []string{}
366 if lspErrs[protocol.SeverityError] > 0 {
367 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
368 }
369 if lspErrs[protocol.SeverityWarning] > 0 {
370 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
371 }
372 if lspErrs[protocol.SeverityHint] > 0 {
373 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
374 }
375 if lspErrs[protocol.SeverityInformation] > 0 {
376 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
377 }
378
379 lspList = append(lspList,
380 core.Status(
381 core.StatusOpts{
382 IconColor: iconColor,
383 Title: l.Name,
384 Description: l.LSP.Command,
385 ExtraContent: strings.Join(errs, " "),
386 },
387 m.getMaxWidth(),
388 ),
389 )
390 }
391
392 return lipgloss.JoinVertical(
393 lipgloss.Left,
394 lspList...,
395 )
396}
397
398func (m *sidebarCmp) mcpBlock() string {
399 t := styles.CurrentTheme()
400
401 section := t.S().Subtle.Render(
402 core.Section("MCPs", m.getMaxWidth()),
403 )
404
405 mcpList := []string{section, ""}
406
407 mcps := config.Get().MCP.Sorted()
408 if len(mcps) == 0 {
409 return lipgloss.JoinVertical(
410 lipgloss.Left,
411 section,
412 "",
413 t.S().Base.Foreground(t.Border).Render("None"),
414 )
415 }
416
417 for _, l := range mcps {
418 iconColor := t.Success
419 if l.MCP.Disabled {
420 iconColor = t.FgMuted
421 }
422 mcpList = append(mcpList,
423 core.Status(
424 core.StatusOpts{
425 IconColor: iconColor,
426 Title: l.Name,
427 Description: l.MCP.Command,
428 },
429 m.getMaxWidth(),
430 ),
431 )
432 }
433
434 return lipgloss.JoinVertical(
435 lipgloss.Left,
436 mcpList...,
437 )
438}
439
440func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
441 t := styles.CurrentTheme()
442 // Format tokens in human-readable format (e.g., 110K, 1.2M)
443 var formattedTokens string
444 switch {
445 case tokens >= 1_000_000:
446 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
447 case tokens >= 1_000:
448 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
449 default:
450 formattedTokens = fmt.Sprintf("%d", tokens)
451 }
452
453 // Remove .0 suffix if present
454 if strings.HasSuffix(formattedTokens, ".0K") {
455 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
456 }
457 if strings.HasSuffix(formattedTokens, ".0M") {
458 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
459 }
460
461 percentage := (float64(tokens) / float64(contextWindow)) * 100
462
463 baseStyle := t.S().Base
464
465 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
466
467 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
468 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
469 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
470 if percentage > 80 {
471 // add the warning icon
472 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
473 }
474
475 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
476}
477
478func (s *sidebarCmp) currentModelBlock() string {
479 agentCfg := config.Get().Agents["coder"]
480 model := config.Get().GetModelByType(agentCfg.Model)
481
482 t := styles.CurrentTheme()
483
484 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
485 modelName := t.S().Text.Render(model.Model)
486 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
487 parts := []string{
488 modelInfo,
489 }
490 if s.session.ID != "" {
491 parts = append(
492 parts,
493 " "+formatTokensAndCost(
494 s.session.CompletionTokens+s.session.PromptTokens,
495 model.ContextWindow,
496 s.session.Cost,
497 ),
498 )
499 }
500 return lipgloss.JoinVertical(
501 lipgloss.Left,
502 parts...,
503 )
504}
505
506// SetSession implements Sidebar.
507func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
508 m.session = session
509 return m.loadSessionFiles
510}
511
512// SetCompactMode sets the compact mode for the sidebar.
513func (m *sidebarCmp) SetCompactMode(compact bool) {
514 m.compactMode = compact
515}
516
517func cwd() string {
518 cwd := config.Get().WorkingDir()
519 t := styles.CurrentTheme()
520 // Replace home directory with ~, unless we're at the top level of the
521 // home directory).
522 homeDir, err := os.UserHomeDir()
523 if err == nil && cwd != homeDir {
524 cwd = strings.ReplaceAll(cwd, homeDir, "~")
525 }
526 return t.S().Muted.Render(cwd)
527}