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/logging"
17 "github.com/charmbracelet/crush/internal/lsp"
18 "github.com/charmbracelet/crush/internal/lsp/protocol"
19 "github.com/charmbracelet/crush/internal/pubsub"
20 "github.com/charmbracelet/crush/internal/session"
21 "github.com/charmbracelet/crush/internal/tui/components/chat"
22 "github.com/charmbracelet/crush/internal/tui/components/core"
23 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
24 "github.com/charmbracelet/crush/internal/tui/components/logo"
25 "github.com/charmbracelet/crush/internal/tui/styles"
26 "github.com/charmbracelet/crush/internal/tui/util"
27 "github.com/charmbracelet/crush/internal/version"
28 "github.com/charmbracelet/lipgloss/v2"
29 "github.com/charmbracelet/x/ansi"
30)
31
32const (
33 logoBreakpoint = 65
34)
35
36type FileHistory struct {
37 initialVersion history.File
38 latestVersion history.File
39}
40
41type SessionFile struct {
42 History FileHistory
43 FilePath string
44 Additions int
45 Deletions int
46}
47type SessionFilesMsg struct {
48 Files []SessionFile
49}
50
51type Sidebar interface {
52 util.Model
53 layout.Sizeable
54 SetSession(session session.Session) tea.Cmd
55}
56
57type sidebarCmp struct {
58 width, height int
59 session session.Session
60 logo string
61 cwd string
62 lspClients map[string]*lsp.Client
63 compactMode bool
64 history history.Service
65 // Using a sync map here because we might receive file history events concurrently
66 files sync.Map
67}
68
69func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
70 return &sidebarCmp{
71 lspClients: lspClients,
72 history: history,
73 compactMode: compact,
74 }
75}
76
77func (m *sidebarCmp) Init() tea.Cmd {
78 m.logo = m.logoBlock()
79 m.cwd = cwd()
80 return nil
81}
82
83func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84 switch msg := msg.(type) {
85 case chat.SessionSelectedMsg:
86 return m, m.SetSession(msg)
87 case SessionFilesMsg:
88 m.files = sync.Map{}
89 for _, file := range msg.Files {
90 m.files.Store(file.FilePath, file)
91 }
92 return m, nil
93
94 case chat.SessionClearedMsg:
95 m.session = session.Session{}
96 case pubsub.Event[history.File]:
97 logging.Info("sidebar", "Received file history event", "file", msg.Payload.Path, "session", msg.Payload.SessionID)
98 return m, m.handleFileHistoryEvent(msg)
99 case pubsub.Event[session.Session]:
100 if msg.Type == pubsub.UpdatedEvent {
101 if m.session.ID == msg.Payload.ID {
102 m.session = msg.Payload
103 }
104 }
105 }
106 return m, nil
107}
108
109func (m *sidebarCmp) View() tea.View {
110 t := styles.CurrentTheme()
111 parts := []string{}
112 if !m.compactMode {
113 parts = append(parts, m.logo)
114 }
115
116 if !m.compactMode && m.session.ID != "" {
117 parts = append(parts, t.S().Muted.Render(m.session.Title), "")
118 } else if m.session.ID != "" {
119 parts = append(parts, t.S().Text.Render(m.session.Title), "")
120 }
121
122 if !m.compactMode {
123 parts = append(parts,
124 m.cwd,
125 "",
126 )
127 }
128 parts = append(parts,
129 m.currentModelBlock(),
130 )
131 if m.session.ID != "" {
132 parts = append(parts, "", m.filesBlock())
133 }
134 parts = append(parts,
135 "",
136 m.lspBlock(),
137 "",
138 m.mcpBlock(),
139 )
140
141 return tea.NewView(
142 lipgloss.JoinVertical(lipgloss.Left, parts...),
143 )
144}
145
146func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
147 return func() tea.Msg {
148 file := event.Payload
149 found := false
150 m.files.Range(func(key, value any) bool {
151 existing := value.(SessionFile)
152 if existing.FilePath == file.Path {
153 if existing.History.latestVersion.Version < file.Version {
154 existing.History.latestVersion = file
155 } else if file.Version == 0 {
156 existing.History.initialVersion = file
157 } else {
158 // If the version is not greater than the latest, we ignore it
159 return true
160 }
161 before := existing.History.initialVersion.Content
162 after := existing.History.latestVersion.Content
163 path := existing.History.initialVersion.Path
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 _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
218 sessionFiles = append(sessionFiles, SessionFile{
219 History: fh,
220 FilePath: path,
221 Additions: additions,
222 Deletions: deletions,
223 })
224 }
225
226 return SessionFilesMsg{
227 Files: sessionFiles,
228 }
229}
230
231func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
232 if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
233 m.logo = m.logoBlock()
234 } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
235 m.logo = m.logoBlock()
236 }
237
238 m.width = width
239 m.height = height
240 return nil
241}
242
243func (m *sidebarCmp) GetSize() (int, int) {
244 return m.width, m.height
245}
246
247func (m *sidebarCmp) logoBlock() string {
248 t := styles.CurrentTheme()
249 return logo.Render(version.Version, true, logo.Opts{
250 FieldColor: t.Primary,
251 TitleColorA: t.Secondary,
252 TitleColorB: t.Primary,
253 CharmColor: t.Secondary,
254 VersionColor: t.Primary,
255 })
256}
257
258func (m *sidebarCmp) filesBlock() string {
259 maxWidth := min(m.width, 58)
260 t := styles.CurrentTheme()
261
262 section := t.S().Subtle.Render(
263 core.Section("Modified Files", maxWidth),
264 )
265
266 files := make([]SessionFile, 0)
267 m.files.Range(func(key, value any) bool {
268 file := value.(SessionFile)
269 files = append(files, file)
270 return true // continue iterating
271 })
272 if len(files) == 0 {
273 return lipgloss.JoinVertical(
274 lipgloss.Left,
275 section,
276 "",
277 t.S().Base.Foreground(t.Border).Render("None"),
278 )
279 }
280
281 fileList := []string{section, ""}
282 // order files by the latest version's created time
283 sort.Slice(files, func(i, j int) bool {
284 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
285 })
286
287 for _, file := range files {
288 if file.Additions == 0 && file.Deletions == 0 {
289 continue // skip files with no changes
290 }
291 var statusParts []string
292 if file.Additions > 0 {
293 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
294 }
295 if file.Deletions > 0 {
296 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
297 }
298
299 extraContent := strings.Join(statusParts, " ")
300 cwd := config.WorkingDirectory() + string(os.PathSeparator)
301 filePath := file.FilePath
302 filePath = strings.TrimPrefix(filePath, cwd)
303 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
304 filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
305 fileList = append(fileList,
306 core.Status(
307 core.StatusOpts{
308 IconColor: t.FgMuted,
309 NoIcon: true,
310 Title: filePath,
311 ExtraContent: extraContent,
312 },
313 m.width,
314 ),
315 )
316 }
317
318 return lipgloss.JoinVertical(
319 lipgloss.Left,
320 fileList...,
321 )
322}
323
324func (m *sidebarCmp) lspBlock() string {
325 maxWidth := min(m.width, 58)
326 t := styles.CurrentTheme()
327
328 section := t.S().Subtle.Render(
329 core.Section("LSPs", maxWidth),
330 )
331
332 lspList := []string{section, ""}
333
334 lsp := config.Get().LSP
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 n, l := range lsp {
345 iconColor := t.Success
346 if l.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[n]; 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: n,
384 Description: l.Command,
385 ExtraContent: strings.Join(errs, " "),
386 },
387 m.width,
388 ),
389 )
390 }
391
392 return lipgloss.JoinVertical(
393 lipgloss.Left,
394 lspList...,
395 )
396}
397
398func (m *sidebarCmp) mcpBlock() string {
399 maxWidth := min(m.width, 58)
400 t := styles.CurrentTheme()
401
402 section := t.S().Subtle.Render(
403 core.Section("MCPs", maxWidth),
404 )
405
406 mcpList := []string{section, ""}
407
408 mcp := config.Get().MCP
409 if len(mcp) == 0 {
410 return lipgloss.JoinVertical(
411 lipgloss.Left,
412 section,
413 "",
414 t.S().Base.Foreground(t.Border).Render("None"),
415 )
416 }
417
418 for n, l := range mcp {
419 iconColor := t.Success
420 mcpList = append(mcpList,
421 core.Status(
422 core.StatusOpts{
423 IconColor: iconColor,
424 Title: n,
425 Description: l.Command,
426 },
427 m.width,
428 ),
429 )
430 }
431
432 return lipgloss.JoinVertical(
433 lipgloss.Left,
434 mcpList...,
435 )
436}
437
438func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
439 t := styles.CurrentTheme()
440 // Format tokens in human-readable format (e.g., 110K, 1.2M)
441 var formattedTokens string
442 switch {
443 case tokens >= 1_000_000:
444 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
445 case tokens >= 1_000:
446 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
447 default:
448 formattedTokens = fmt.Sprintf("%d", tokens)
449 }
450
451 // Remove .0 suffix if present
452 if strings.HasSuffix(formattedTokens, ".0K") {
453 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
454 }
455 if strings.HasSuffix(formattedTokens, ".0M") {
456 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
457 }
458
459 percentage := (float64(tokens) / float64(contextWindow)) * 100
460
461 baseStyle := t.S().Base
462
463 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
464
465 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
466 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
467 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
468 if percentage > 80 {
469 // add the warning icon
470 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
471 }
472
473 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
474}
475
476func (s *sidebarCmp) currentModelBlock() string {
477 model := config.GetAgentModel(config.AgentCoder)
478
479 t := styles.CurrentTheme()
480
481 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
482 modelName := t.S().Text.Render(model.Name)
483 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
484 parts := []string{
485 modelInfo,
486 }
487 if s.session.ID != "" {
488 parts = append(
489 parts,
490 " "+formatTokensAndCost(
491 s.session.CompletionTokens+s.session.PromptTokens,
492 model.ContextWindow,
493 s.session.Cost,
494 ),
495 )
496 }
497 return lipgloss.JoinVertical(
498 lipgloss.Left,
499 parts...,
500 )
501}
502
503// SetSession implements Sidebar.
504func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
505 m.session = session
506 return m.loadSessionFiles
507}
508
509func cwd() string {
510 cwd := config.WorkingDirectory()
511 t := styles.CurrentTheme()
512 // Replace home directory with ~, unless we're at the top level of the
513 // home directory).
514 homeDir, err := os.UserHomeDir()
515 if err == nil && cwd != homeDir {
516 cwd = strings.ReplaceAll(cwd, homeDir, "~")
517 }
518 return t.S().Muted.Render(cwd)
519}