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 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 if msg.ID != m.session.ID {
87 m.session = msg
88 }
89 return m, m.loadSessionFiles
90 case SessionFilesMsg:
91 m.files = sync.Map{}
92 for _, file := range msg.Files {
93 m.files.Store(file.FilePath, file)
94 }
95 return m, nil
96
97 case chat.SessionClearedMsg:
98 m.session = session.Session{}
99 case pubsub.Event[history.File]:
100 logging.Info("sidebar", "Received file history event", "file", msg.Payload.Path, "session", msg.Payload.SessionID)
101 return m, m.handleFileHistoryEvent(msg)
102 case pubsub.Event[session.Session]:
103 if msg.Type == pubsub.UpdatedEvent {
104 if m.session.ID == msg.Payload.ID {
105 m.session = msg.Payload
106 }
107 }
108 }
109 return m, nil
110}
111
112func (m *sidebarCmp) View() tea.View {
113 t := styles.CurrentTheme()
114 parts := []string{}
115 if !m.compactMode {
116 parts = append(parts, m.logo)
117 }
118
119 if !m.compactMode && m.session.ID != "" {
120 parts = append(parts, t.S().Muted.Render(m.session.Title), "")
121 } else if m.session.ID != "" {
122 parts = append(parts, t.S().Text.Render(m.session.Title), "")
123 }
124
125 if !m.compactMode {
126 parts = append(parts,
127 m.cwd,
128 "",
129 )
130 }
131 parts = append(parts,
132 m.currentModelBlock(),
133 )
134 if m.session.ID != "" {
135 parts = append(parts, "", m.filesBlock())
136 }
137 parts = append(parts,
138 "",
139 m.lspBlock(),
140 "",
141 m.mcpBlock(),
142 )
143
144 return tea.NewView(
145 lipgloss.JoinVertical(lipgloss.Left, parts...),
146 )
147}
148
149func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
150 return func() tea.Msg {
151 file := event.Payload
152 found := false
153 m.files.Range(func(key, value any) bool {
154 existing := value.(SessionFile)
155 if existing.FilePath == file.Path {
156 if existing.History.latestVersion.Version < file.Version {
157 existing.History.latestVersion = file
158 } else if file.Version == 0 {
159 existing.History.initialVersion = file
160 } else {
161 // If the version is not greater than the latest, we ignore it
162 return true
163 }
164 before := existing.History.initialVersion.Content
165 after := existing.History.latestVersion.Content
166 path := existing.History.initialVersion.Path
167 _, additions, deletions := diff.GenerateDiff(before, after, path)
168 existing.Additions = additions
169 existing.Deletions = deletions
170 m.files.Store(file.Path, existing)
171 found = true
172 return false
173 }
174 return true
175 })
176 if found {
177 return nil
178 }
179 sf := SessionFile{
180 History: FileHistory{
181 initialVersion: file,
182 latestVersion: file,
183 },
184 FilePath: file.Path,
185 Additions: 0,
186 Deletions: 0,
187 }
188 m.files.Store(file.Path, sf)
189 return nil
190 }
191}
192
193func (m *sidebarCmp) loadSessionFiles() tea.Msg {
194 files, err := m.history.ListBySession(context.Background(), m.session.ID)
195 if err != nil {
196 return util.InfoMsg{
197 Type: util.InfoTypeError,
198 Msg: err.Error(),
199 }
200 }
201
202 fileMap := make(map[string]FileHistory)
203
204 for _, file := range files {
205 if existing, ok := fileMap[file.Path]; ok {
206 // Update the latest version
207 existing.latestVersion = file
208 fileMap[file.Path] = existing
209 } else {
210 // Add the initial version
211 fileMap[file.Path] = FileHistory{
212 initialVersion: file,
213 latestVersion: file,
214 }
215 }
216 }
217
218 sessionFiles := make([]SessionFile, 0, len(fileMap))
219 for path, fh := range fileMap {
220 _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
221 sessionFiles = append(sessionFiles, SessionFile{
222 History: fh,
223 FilePath: path,
224 Additions: additions,
225 Deletions: deletions,
226 })
227 }
228
229 return SessionFilesMsg{
230 Files: sessionFiles,
231 }
232}
233
234func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
235 if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
236 m.logo = m.logoBlock(true)
237 } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
238 m.logo = m.logoBlock(false)
239 }
240
241 m.width = width
242 m.height = height
243 return nil
244}
245
246func (m *sidebarCmp) GetSize() (int, int) {
247 return m.width, m.height
248}
249
250func (m *sidebarCmp) logoBlock(compact bool) string {
251 t := styles.CurrentTheme()
252 return logo.Render(version.Version, compact, logo.Opts{
253 FieldColor: t.Primary,
254 TitleColorA: t.Secondary,
255 TitleColorB: t.Primary,
256 CharmColor: t.Secondary,
257 VersionColor: t.Primary,
258 })
259}
260
261func (m *sidebarCmp) filesBlock() string {
262 maxWidth := min(m.width, 58)
263 t := styles.CurrentTheme()
264
265 section := t.S().Subtle.Render(
266 core.Section("Modified Files", maxWidth),
267 )
268
269 files := make([]SessionFile, 0)
270 m.files.Range(func(key, value any) bool {
271 file := value.(SessionFile)
272 files = append(files, file)
273 return true // continue iterating
274 })
275 if len(files) == 0 {
276 return lipgloss.JoinVertical(
277 lipgloss.Left,
278 section,
279 "",
280 t.S().Base.Foreground(t.Border).Render("None"),
281 )
282 }
283
284 fileList := []string{section, ""}
285 // order files by the latest version's created time
286 sort.Slice(files, func(i, j int) bool {
287 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
288 })
289
290 for _, file := range files {
291 if file.Additions == 0 && file.Deletions == 0 {
292 continue // skip files with no changes
293 }
294 var statusParts []string
295 if file.Additions > 0 {
296 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
297 }
298 if file.Deletions > 0 {
299 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
300 }
301
302 extraContent := strings.Join(statusParts, " ")
303 cwd := config.WorkingDirectory() + string(os.PathSeparator)
304 filePath := file.FilePath
305 filePath = strings.TrimPrefix(filePath, cwd)
306 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
307 filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
308 fileList = append(fileList,
309 core.Status(
310 core.StatusOpts{
311 IconColor: t.FgMuted,
312 NoIcon: true,
313 Title: filePath,
314 ExtraContent: extraContent,
315 },
316 m.width,
317 ),
318 )
319 }
320
321 return lipgloss.JoinVertical(
322 lipgloss.Left,
323 fileList...,
324 )
325}
326
327func (m *sidebarCmp) lspBlock() string {
328 maxWidth := min(m.width, 58)
329 t := styles.CurrentTheme()
330
331 section := t.S().Subtle.Render(
332 core.Section("LSPs", maxWidth),
333 )
334
335 lspList := []string{section, ""}
336
337 lsp := config.Get().LSP
338 if len(lsp) == 0 {
339 return lipgloss.JoinVertical(
340 lipgloss.Left,
341 section,
342 "",
343 t.S().Base.Foreground(t.Border).Render("None"),
344 )
345 }
346
347 for n, l := range lsp {
348 iconColor := t.Success
349 if l.Disabled {
350 iconColor = t.FgMuted
351 }
352 lspErrs := map[protocol.DiagnosticSeverity]int{
353 protocol.SeverityError: 0,
354 protocol.SeverityWarning: 0,
355 protocol.SeverityHint: 0,
356 protocol.SeverityInformation: 0,
357 }
358 if client, ok := m.lspClients[n]; ok {
359 for _, diagnostics := range client.GetDiagnostics() {
360 for _, diagnostic := range diagnostics {
361 if severity, ok := lspErrs[diagnostic.Severity]; ok {
362 lspErrs[diagnostic.Severity] = severity + 1
363 }
364 }
365 }
366 }
367
368 errs := []string{}
369 if lspErrs[protocol.SeverityError] > 0 {
370 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
371 }
372 if lspErrs[protocol.SeverityWarning] > 0 {
373 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
374 }
375 if lspErrs[protocol.SeverityHint] > 0 {
376 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
377 }
378 if lspErrs[protocol.SeverityInformation] > 0 {
379 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
380 }
381
382 lspList = append(lspList,
383 core.Status(
384 core.StatusOpts{
385 IconColor: iconColor,
386 Title: n,
387 Description: l.Command,
388 ExtraContent: strings.Join(errs, " "),
389 },
390 m.width,
391 ),
392 )
393 }
394
395 return lipgloss.JoinVertical(
396 lipgloss.Left,
397 lspList...,
398 )
399}
400
401func (m *sidebarCmp) mcpBlock() string {
402 maxWidth := min(m.width, 58)
403 t := styles.CurrentTheme()
404
405 section := t.S().Subtle.Render(
406 core.Section("MCPs", maxWidth),
407 )
408
409 mcpList := []string{section, ""}
410
411 mcp := config.Get().MCPServers
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.width,
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 cfg := config.Get()
481 agentCfg := cfg.Agents[config.AgentCoder]
482 selectedModelID := agentCfg.Model
483 model := models.SupportedModels[selectedModelID]
484
485 t := styles.CurrentTheme()
486
487 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
488 modelName := t.S().Text.Render(model.Name)
489 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
490 parts := []string{
491 modelInfo,
492 }
493 if s.session.ID != "" {
494 parts = append(
495 parts,
496 " "+formatTokensAndCost(
497 s.session.CompletionTokens+s.session.PromptTokens,
498 model.ContextWindow,
499 s.session.Cost,
500 ),
501 )
502 }
503 return lipgloss.JoinVertical(
504 lipgloss.Left,
505 parts...,
506 )
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}