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() tea.View {
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 // TODO: CHECK out why we need to set the background here weird issue
133 style := t.S().Base.
134 Background(t.BgBase).
135 Width(m.width).
136 Height(m.height).
137 Padding(1)
138 if m.compactMode {
139 style = style.PaddingTop(0)
140 }
141 return tea.NewView(
142 style.Render(
143 lipgloss.JoinVertical(lipgloss.Left, parts...),
144 ),
145 )
146}
147
148func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
149 return func() tea.Msg {
150 file := event.Payload
151 found := false
152 m.files.Range(func(key, value any) bool {
153 existing := value.(SessionFile)
154 if existing.FilePath == file.Path {
155 if existing.History.latestVersion.Version < file.Version {
156 existing.History.latestVersion = file
157 } else if file.Version == 0 {
158 existing.History.initialVersion = file
159 } else {
160 // If the version is not greater than the latest, we ignore it
161 return true
162 }
163 before := existing.History.initialVersion.Content
164 after := existing.History.latestVersion.Content
165 path := existing.History.initialVersion.Path
166 cwd := config.Get().WorkingDir()
167 path = strings.TrimPrefix(path, cwd)
168 _, additions, deletions := diff.GenerateDiff(before, after, path)
169 existing.Additions = additions
170 existing.Deletions = deletions
171 m.files.Store(file.Path, existing)
172 found = true
173 return false
174 }
175 return true
176 })
177 if found {
178 return nil
179 }
180 sf := SessionFile{
181 History: FileHistory{
182 initialVersion: file,
183 latestVersion: file,
184 },
185 FilePath: file.Path,
186 Additions: 0,
187 Deletions: 0,
188 }
189 m.files.Store(file.Path, sf)
190 return nil
191 }
192}
193
194func (m *sidebarCmp) loadSessionFiles() tea.Msg {
195 files, err := m.history.ListBySession(context.Background(), m.session.ID)
196 if err != nil {
197 return util.InfoMsg{
198 Type: util.InfoTypeError,
199 Msg: err.Error(),
200 }
201 }
202
203 fileMap := make(map[string]FileHistory)
204
205 for _, file := range files {
206 if existing, ok := fileMap[file.Path]; ok {
207 // Update the latest version
208 existing.latestVersion = file
209 fileMap[file.Path] = existing
210 } else {
211 // Add the initial version
212 fileMap[file.Path] = FileHistory{
213 initialVersion: file,
214 latestVersion: file,
215 }
216 }
217 }
218
219 sessionFiles := make([]SessionFile, 0, len(fileMap))
220 for path, fh := range fileMap {
221 cwd := config.Get().WorkingDir()
222 path = strings.TrimPrefix(path, cwd)
223 _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, path)
224 sessionFiles = append(sessionFiles, SessionFile{
225 History: fh,
226 FilePath: path,
227 Additions: additions,
228 Deletions: deletions,
229 })
230 }
231
232 return SessionFilesMsg{
233 Files: sessionFiles,
234 }
235}
236
237func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
238 m.logo = m.logoBlock()
239 m.cwd = cwd()
240 m.width = width
241 m.height = height
242 return nil
243}
244
245func (m *sidebarCmp) GetSize() (int, int) {
246 return m.width, m.height
247}
248
249func (m *sidebarCmp) logoBlock() string {
250 t := styles.CurrentTheme()
251 return logo.Render(version.Version, true, logo.Opts{
252 FieldColor: t.Primary,
253 TitleColorA: t.Secondary,
254 TitleColorB: t.Primary,
255 CharmColor: t.Secondary,
256 VersionColor: t.Primary,
257 })
258}
259
260func (m *sidebarCmp) getMaxWidth() int {
261 return min(m.width-2, 58) // -2 for padding
262}
263
264func (m *sidebarCmp) filesBlock() string {
265 t := styles.CurrentTheme()
266
267 section := t.S().Subtle.Render(
268 core.Section("Modified Files", m.getMaxWidth()),
269 )
270
271 files := make([]SessionFile, 0)
272 m.files.Range(func(key, value any) bool {
273 file := value.(SessionFile)
274 files = append(files, file)
275 return true // continue iterating
276 })
277 if len(files) == 0 {
278 return lipgloss.JoinVertical(
279 lipgloss.Left,
280 section,
281 "",
282 t.S().Base.Foreground(t.Border).Render("None"),
283 )
284 }
285
286 fileList := []string{section, ""}
287 // order files by the latest version's created time
288 sort.Slice(files, func(i, j int) bool {
289 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
290 })
291
292 for _, file := range files {
293 if file.Additions == 0 && file.Deletions == 0 {
294 continue // skip files with no changes
295 }
296 var statusParts []string
297 if file.Additions > 0 {
298 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
299 }
300 if file.Deletions > 0 {
301 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
302 }
303
304 extraContent := strings.Join(statusParts, " ")
305 cwd := config.Get().WorkingDir() + string(os.PathSeparator)
306 filePath := file.FilePath
307 filePath = strings.TrimPrefix(filePath, cwd)
308 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
309 filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
310 fileList = append(fileList,
311 core.Status(
312 core.StatusOpts{
313 IconColor: t.FgMuted,
314 NoIcon: true,
315 Title: filePath,
316 ExtraContent: extraContent,
317 },
318 m.getMaxWidth(),
319 ),
320 )
321 }
322
323 return lipgloss.JoinVertical(
324 lipgloss.Left,
325 fileList...,
326 )
327}
328
329func (m *sidebarCmp) lspBlock() string {
330 t := styles.CurrentTheme()
331
332 section := t.S().Subtle.Render(
333 core.Section("LSPs", m.getMaxWidth()),
334 )
335
336 lspList := []string{section, ""}
337
338 lsp := config.Get().LSP
339 if len(lsp) == 0 {
340 return lipgloss.JoinVertical(
341 lipgloss.Left,
342 section,
343 "",
344 t.S().Base.Foreground(t.Border).Render("None"),
345 )
346 }
347
348 for n, l := range lsp {
349 iconColor := t.Success
350 if l.Disabled {
351 iconColor = t.FgMuted
352 }
353 lspErrs := map[protocol.DiagnosticSeverity]int{
354 protocol.SeverityError: 0,
355 protocol.SeverityWarning: 0,
356 protocol.SeverityHint: 0,
357 protocol.SeverityInformation: 0,
358 }
359 if client, ok := m.lspClients[n]; ok {
360 for _, diagnostics := range client.GetDiagnostics() {
361 for _, diagnostic := range diagnostics {
362 if severity, ok := lspErrs[diagnostic.Severity]; ok {
363 lspErrs[diagnostic.Severity] = severity + 1
364 }
365 }
366 }
367 }
368
369 errs := []string{}
370 if lspErrs[protocol.SeverityError] > 0 {
371 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
372 }
373 if lspErrs[protocol.SeverityWarning] > 0 {
374 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
375 }
376 if lspErrs[protocol.SeverityHint] > 0 {
377 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
378 }
379 if lspErrs[protocol.SeverityInformation] > 0 {
380 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
381 }
382
383 lspList = append(lspList,
384 core.Status(
385 core.StatusOpts{
386 IconColor: iconColor,
387 Title: n,
388 Description: l.Command,
389 ExtraContent: strings.Join(errs, " "),
390 },
391 m.getMaxWidth(),
392 ),
393 )
394 }
395
396 return lipgloss.JoinVertical(
397 lipgloss.Left,
398 lspList...,
399 )
400}
401
402func (m *sidebarCmp) mcpBlock() string {
403 t := styles.CurrentTheme()
404
405 section := t.S().Subtle.Render(
406 core.Section("MCPs", m.getMaxWidth()),
407 )
408
409 mcpList := []string{section, ""}
410
411 mcp := config.Get().MCP
412 if len(mcp) == 0 {
413 return lipgloss.JoinVertical(
414 lipgloss.Left,
415 section,
416 "",
417 t.S().Base.Foreground(t.Border).Render("None"),
418 )
419 }
420
421 for n, l := range mcp {
422 iconColor := t.Success
423 mcpList = append(mcpList,
424 core.Status(
425 core.StatusOpts{
426 IconColor: iconColor,
427 Title: n,
428 Description: l.Command,
429 },
430 m.getMaxWidth(),
431 ),
432 )
433 }
434
435 return lipgloss.JoinVertical(
436 lipgloss.Left,
437 mcpList...,
438 )
439}
440
441func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
442 t := styles.CurrentTheme()
443 // Format tokens in human-readable format (e.g., 110K, 1.2M)
444 var formattedTokens string
445 switch {
446 case tokens >= 1_000_000:
447 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
448 case tokens >= 1_000:
449 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
450 default:
451 formattedTokens = fmt.Sprintf("%d", tokens)
452 }
453
454 // Remove .0 suffix if present
455 if strings.HasSuffix(formattedTokens, ".0K") {
456 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
457 }
458 if strings.HasSuffix(formattedTokens, ".0M") {
459 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
460 }
461
462 percentage := (float64(tokens) / float64(contextWindow)) * 100
463
464 baseStyle := t.S().Base
465
466 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
467
468 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
469 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
470 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
471 if percentage > 80 {
472 // add the warning icon
473 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
474 }
475
476 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
477}
478
479func (s *sidebarCmp) currentModelBlock() string {
480 agentCfg := config.Get().Agents["coder"]
481 model := config.Get().GetModelByType(agentCfg.Model)
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
513// SetCompactMode sets the compact mode for the sidebar.
514func (m *sidebarCmp) SetCompactMode(compact bool) {
515 m.compactMode = compact
516}
517
518func cwd() string {
519 cwd := config.Get().WorkingDir()
520 t := styles.CurrentTheme()
521 // Replace home directory with ~, unless we're at the top level of the
522 // home directory).
523 homeDir, err := os.UserHomeDir()
524 if err == nil && cwd != homeDir {
525 cwd = strings.ReplaceAll(cwd, homeDir, "~")
526 }
527 return t.S().Muted.Render(cwd)
528}