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