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