1package sidebar
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "slices"
8 "strings"
9
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/diff"
15 "github.com/charmbracelet/crush/internal/history"
16 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/pubsub"
18 "github.com/charmbracelet/crush/internal/session"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
20 "github.com/charmbracelet/crush/internal/tui/components/core"
21 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
22 "github.com/charmbracelet/crush/internal/tui/components/files"
23 "github.com/charmbracelet/crush/internal/tui/components/logo"
24 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
25 "github.com/charmbracelet/crush/internal/tui/components/mcp"
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 "golang.org/x/text/cases"
31 "golang.org/x/text/language"
32)
33
34type FileHistory struct {
35 initialVersion history.File
36 latestVersion history.File
37}
38
39const LogoHeightBreakpoint = 30
40
41// Default maximum number of items to show in each section
42const (
43 DefaultMaxFilesShown = 10
44 DefaultMaxLSPsShown = 8
45 DefaultMaxMCPsShown = 8
46 MinItemsPerSection = 2 // Minimum items to show per section
47)
48
49type SessionFile struct {
50 History FileHistory
51 FilePath string
52 Additions int
53 Deletions int
54}
55type SessionFilesMsg struct {
56 Files []SessionFile
57}
58
59type Sidebar interface {
60 util.Model
61 layout.Sizeable
62 SetSession(session session.Session) tea.Cmd
63 SetCompactMode(bool)
64}
65
66type sidebarCmp struct {
67 width, height int
68 session session.Session
69 logo string
70 cwd string
71 lspClients map[string]*lsp.Client
72 compactMode bool
73 history history.Service
74 files *csync.Map[string, SessionFile]
75}
76
77func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
78 return &sidebarCmp{
79 lspClients: lspClients,
80 history: history,
81 compactMode: compact,
82 files: csync.NewMap[string, SessionFile](),
83 }
84}
85
86func (m *sidebarCmp) Init() tea.Cmd {
87 return nil
88}
89
90func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91 switch msg := msg.(type) {
92 case SessionFilesMsg:
93 m.files = csync.NewMap[string, SessionFile]()
94 for _, file := range msg.Files {
95 m.files.Set(file.FilePath, file)
96 }
97 return m, nil
98
99 case chat.SessionClearedMsg:
100 m.session = session.Session{}
101 case pubsub.Event[history.File]:
102 return m, m.handleFileHistoryEvent(msg)
103 case pubsub.Event[session.Session]:
104 if msg.Type == pubsub.UpdatedEvent {
105 if m.session.ID == msg.Payload.ID {
106 m.session = msg.Payload
107 }
108 }
109 }
110 return m, nil
111}
112
113func (m *sidebarCmp) View() string {
114 t := styles.CurrentTheme()
115 parts := []string{}
116
117 style := t.S().Base.
118 Width(m.width).
119 Height(m.height).
120 Padding(1)
121 if m.compactMode {
122 style = style.PaddingTop(0)
123 }
124
125 if !m.compactMode {
126 if m.height > LogoHeightBreakpoint {
127 parts = append(parts, m.logo)
128 } else {
129 // Use a smaller logo for smaller screens
130 parts = append(parts,
131 logo.SmallRender(m.width-style.GetHorizontalFrameSize()),
132 "")
133 }
134 }
135
136 if !m.compactMode && m.session.ID != "" {
137 parts = append(parts, t.S().Muted.Render(m.session.Title), "")
138 } else if m.session.ID != "" {
139 parts = append(parts, t.S().Text.Render(m.session.Title), "")
140 }
141
142 if !m.compactMode {
143 parts = append(parts,
144 m.cwd,
145 "",
146 )
147 }
148 parts = append(parts,
149 m.currentModelBlock(),
150 )
151
152 // Check if we should use horizontal layout for sections
153 if m.compactMode && m.width > m.height {
154 // Horizontal layout for compact mode when width > height
155 sectionsContent := m.renderSectionsHorizontal()
156 if sectionsContent != "" {
157 parts = append(parts, "", sectionsContent)
158 }
159 } else {
160 // Vertical layout (default)
161 if m.session.ID != "" {
162 parts = append(parts, "", m.filesBlock())
163 }
164 parts = append(parts,
165 "",
166 m.lspBlock(),
167 "",
168 m.mcpBlock(),
169 )
170 }
171
172 return style.Render(
173 lipgloss.JoinVertical(lipgloss.Left, parts...),
174 )
175}
176
177func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
178 return func() tea.Msg {
179 file := event.Payload
180 found := false
181 for existing := range m.files.Seq() {
182 if existing.FilePath != file.Path {
183 continue
184 }
185 if existing.History.latestVersion.Version < file.Version {
186 existing.History.latestVersion = file
187 } else if file.Version == 0 {
188 existing.History.initialVersion = file
189 } else {
190 // If the version is not greater than the latest, we ignore it
191 continue
192 }
193 before := existing.History.initialVersion.Content
194 after := existing.History.latestVersion.Content
195 path := existing.History.initialVersion.Path
196 cwd := config.Get().WorkingDir()
197 path = strings.TrimPrefix(path, cwd)
198 _, additions, deletions := diff.GenerateDiff(before, after, path)
199 existing.Additions = additions
200 existing.Deletions = deletions
201 m.files.Set(file.Path, existing)
202 found = true
203 break
204 }
205 if found {
206 return nil
207 }
208 sf := SessionFile{
209 History: FileHistory{
210 initialVersion: file,
211 latestVersion: file,
212 },
213 FilePath: file.Path,
214 Additions: 0,
215 Deletions: 0,
216 }
217 m.files.Set(file.Path, sf)
218 return nil
219 }
220}
221
222func (m *sidebarCmp) loadSessionFiles() tea.Msg {
223 files, err := m.history.ListBySession(context.Background(), m.session.ID)
224 if err != nil {
225 return util.InfoMsg{
226 Type: util.InfoTypeError,
227 Msg: err.Error(),
228 }
229 }
230
231 fileMap := make(map[string]FileHistory)
232
233 for _, file := range files {
234 if existing, ok := fileMap[file.Path]; ok {
235 // Update the latest version
236 existing.latestVersion = file
237 fileMap[file.Path] = existing
238 } else {
239 // Add the initial version
240 fileMap[file.Path] = FileHistory{
241 initialVersion: file,
242 latestVersion: file,
243 }
244 }
245 }
246
247 sessionFiles := make([]SessionFile, 0, len(fileMap))
248 for path, fh := range fileMap {
249 cwd := config.Get().WorkingDir()
250 path = strings.TrimPrefix(path, cwd)
251 _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, path)
252 sessionFiles = append(sessionFiles, SessionFile{
253 History: fh,
254 FilePath: path,
255 Additions: additions,
256 Deletions: deletions,
257 })
258 }
259
260 return SessionFilesMsg{
261 Files: sessionFiles,
262 }
263}
264
265func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
266 m.logo = m.logoBlock()
267 m.cwd = cwd()
268 m.width = width
269 m.height = height
270 return nil
271}
272
273func (m *sidebarCmp) GetSize() (int, int) {
274 return m.width, m.height
275}
276
277func (m *sidebarCmp) logoBlock() string {
278 t := styles.CurrentTheme()
279 return logo.Render(version.Version, true, logo.Opts{
280 FieldColor: t.Primary,
281 TitleColorA: t.Secondary,
282 TitleColorB: t.Primary,
283 CharmColor: t.Secondary,
284 VersionColor: t.Primary,
285 Width: m.width - 2,
286 })
287}
288
289func (m *sidebarCmp) getMaxWidth() int {
290 return min(m.width-2, 58) // -2 for padding
291}
292
293// calculateAvailableHeight estimates how much height is available for dynamic content
294func (m *sidebarCmp) calculateAvailableHeight() int {
295 usedHeight := 0
296
297 if !m.compactMode {
298 if m.height > LogoHeightBreakpoint {
299 usedHeight += 7 // Approximate logo height
300 } else {
301 usedHeight += 2 // Smaller logo height
302 }
303 usedHeight += 1 // Empty line after logo
304 }
305
306 if m.session.ID != "" {
307 usedHeight += 1 // Title line
308 usedHeight += 1 // Empty line after title
309 }
310
311 if !m.compactMode {
312 usedHeight += 1 // CWD line
313 usedHeight += 1 // Empty line after CWD
314 }
315
316 usedHeight += 2 // Model info
317
318 usedHeight += 6 // 3 sections × 2 lines each (header + empty line)
319
320 // Base padding
321 usedHeight += 2 // Top and bottom padding
322
323 return max(0, m.height-usedHeight)
324}
325
326// getDynamicLimits calculates how many items to show in each section based on available height
327func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
328 availableHeight := m.calculateAvailableHeight()
329
330 // If we have very little space, use minimum values
331 if availableHeight < 10 {
332 return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
333 }
334
335 // Distribute available height among the three sections
336 // Give priority to files, then LSPs, then MCPs
337 totalSections := 3
338 heightPerSection := availableHeight / totalSections
339
340 // Calculate limits for each section, ensuring minimums
341 maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
342 maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
343 maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
344
345 // If we have extra space, give it to files first
346 remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
347 if remainingHeight > 0 {
348 extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
349 maxFiles += extraForFiles
350 remainingHeight -= extraForFiles
351
352 if remainingHeight > 0 {
353 extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
354 maxLSPs += extraForLSPs
355 remainingHeight -= extraForLSPs
356
357 if remainingHeight > 0 {
358 maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
359 }
360 }
361 }
362
363 return maxFiles, maxLSPs, maxMCPs
364}
365
366// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
367func (m *sidebarCmp) renderSectionsHorizontal() string {
368 // Calculate available width for each section
369 totalWidth := m.width - 4 // Account for padding and spacing
370 sectionWidth := min(50, totalWidth/3)
371
372 // Get the sections content with limited height
373 var filesContent, lspContent, mcpContent string
374
375 filesContent = m.filesBlockCompact(sectionWidth)
376 lspContent = m.lspBlockCompact(sectionWidth)
377 mcpContent = m.mcpBlockCompact(sectionWidth)
378
379 return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
380}
381
382// filesBlockCompact renders the files block with limited width and height for horizontal layout
383func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
384 // Convert map to slice and handle type conversion
385 sessionFiles := slices.Collect(m.files.Seq())
386 fileSlice := make([]files.SessionFile, len(sessionFiles))
387 for i, sf := range sessionFiles {
388 fileSlice[i] = files.SessionFile{
389 History: files.FileHistory{
390 InitialVersion: sf.History.initialVersion,
391 LatestVersion: sf.History.latestVersion,
392 },
393 FilePath: sf.FilePath,
394 Additions: sf.Additions,
395 Deletions: sf.Deletions,
396 }
397 }
398
399 // Limit items for horizontal layout
400 maxItems := min(5, len(fileSlice))
401 availableHeight := m.height - 8 // Reserve space for header and other content
402 if availableHeight > 0 {
403 maxItems = min(maxItems, availableHeight)
404 }
405
406 return files.RenderFileBlock(fileSlice, files.RenderOptions{
407 MaxWidth: maxWidth,
408 MaxItems: maxItems,
409 ShowSection: true,
410 SectionName: "Modified Files",
411 }, true)
412}
413
414// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
415func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
416 // Limit items for horizontal layout
417 lspConfigs := config.Get().LSP.Sorted()
418 maxItems := min(5, len(lspConfigs))
419 availableHeight := m.height - 8
420 if availableHeight > 0 {
421 maxItems = min(maxItems, availableHeight)
422 }
423
424 return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
425 MaxWidth: maxWidth,
426 MaxItems: maxItems,
427 ShowSection: true,
428 SectionName: "LSPs",
429 }, true)
430}
431
432// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
433func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
434 // Limit items for horizontal layout
435 maxItems := min(5, len(config.Get().MCP.Sorted()))
436 availableHeight := m.height - 8
437 if availableHeight > 0 {
438 maxItems = min(maxItems, availableHeight)
439 }
440
441 return mcp.RenderMCPBlock(mcp.RenderOptions{
442 MaxWidth: maxWidth,
443 MaxItems: maxItems,
444 ShowSection: true,
445 SectionName: "MCPs",
446 }, true)
447}
448
449func (m *sidebarCmp) filesBlock() string {
450 // Convert map to slice and handle type conversion
451 sessionFiles := slices.Collect(m.files.Seq())
452 fileSlice := make([]files.SessionFile, len(sessionFiles))
453 for i, sf := range sessionFiles {
454 fileSlice[i] = files.SessionFile{
455 History: files.FileHistory{
456 InitialVersion: sf.History.initialVersion,
457 LatestVersion: sf.History.latestVersion,
458 },
459 FilePath: sf.FilePath,
460 Additions: sf.Additions,
461 Deletions: sf.Deletions,
462 }
463 }
464
465 // Limit the number of files shown
466 maxFiles, _, _ := m.getDynamicLimits()
467 maxFiles = min(len(fileSlice), maxFiles)
468
469 return files.RenderFileBlock(fileSlice, files.RenderOptions{
470 MaxWidth: m.getMaxWidth(),
471 MaxItems: maxFiles,
472 ShowSection: true,
473 SectionName: core.Section("Modified Files", m.getMaxWidth()),
474 }, true)
475}
476
477func (m *sidebarCmp) lspBlock() string {
478 // Limit the number of LSPs shown
479 _, maxLSPs, _ := m.getDynamicLimits()
480 lspConfigs := config.Get().LSP.Sorted()
481 maxLSPs = min(len(lspConfigs), maxLSPs)
482
483 return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
484 MaxWidth: m.getMaxWidth(),
485 MaxItems: maxLSPs,
486 ShowSection: true,
487 SectionName: core.Section("LSPs", m.getMaxWidth()),
488 }, true)
489}
490
491func (m *sidebarCmp) mcpBlock() string {
492 // Limit the number of MCPs shown
493 _, _, maxMCPs := m.getDynamicLimits()
494 mcps := config.Get().MCP.Sorted()
495 maxMCPs = min(len(mcps), maxMCPs)
496
497 return mcp.RenderMCPBlock(mcp.RenderOptions{
498 MaxWidth: m.getMaxWidth(),
499 MaxItems: maxMCPs,
500 ShowSection: true,
501 SectionName: core.Section("MCPs", m.getMaxWidth()),
502 }, true)
503}
504
505func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
506 t := styles.CurrentTheme()
507 // Format tokens in human-readable format (e.g., 110K, 1.2M)
508 var formattedTokens string
509 switch {
510 case tokens >= 1_000_000:
511 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
512 case tokens >= 1_000:
513 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
514 default:
515 formattedTokens = fmt.Sprintf("%d", tokens)
516 }
517
518 // Remove .0 suffix if present
519 if strings.HasSuffix(formattedTokens, ".0K") {
520 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
521 }
522 if strings.HasSuffix(formattedTokens, ".0M") {
523 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
524 }
525
526 percentage := (float64(tokens) / float64(contextWindow)) * 100
527
528 baseStyle := t.S().Base
529
530 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
531
532 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
533 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
534 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
535 if percentage > 80 {
536 // add the warning icon
537 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
538 }
539
540 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
541}
542
543func (s *sidebarCmp) currentModelBlock() string {
544 cfg := config.Get()
545 agentCfg := cfg.Agents["coder"]
546
547 selectedModel := cfg.Models[agentCfg.Model]
548
549 model := config.Get().GetModelByType(agentCfg.Model)
550 modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
551
552 t := styles.CurrentTheme()
553
554 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
555 modelName := t.S().Text.Render(model.Name)
556 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
557 parts := []string{
558 modelInfo,
559 }
560 if model.CanReason {
561 reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
562 switch modelProvider.Type {
563 case catwalk.TypeOpenAI:
564 reasoningEffort := model.DefaultReasoningEffort
565 if selectedModel.ReasoningEffort != "" {
566 reasoningEffort = selectedModel.ReasoningEffort
567 }
568 formatter := cases.Title(language.English, cases.NoLower)
569 parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
570 case catwalk.TypeAnthropic:
571 formatter := cases.Title(language.English, cases.NoLower)
572 if selectedModel.Think {
573 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
574 } else {
575 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
576 }
577 }
578 }
579 if s.session.ID != "" {
580 parts = append(
581 parts,
582 " "+formatTokensAndCost(
583 s.session.CompletionTokens+s.session.PromptTokens,
584 model.ContextWindow,
585 s.session.Cost,
586 ),
587 )
588 }
589 return lipgloss.JoinVertical(
590 lipgloss.Left,
591 parts...,
592 )
593}
594
595// SetSession implements Sidebar.
596func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
597 m.session = session
598 return m.loadSessionFiles
599}
600
601// SetCompactMode sets the compact mode for the sidebar.
602func (m *sidebarCmp) SetCompactMode(compact bool) {
603 m.compactMode = compact
604}
605
606func cwd() string {
607 cwd := config.Get().WorkingDir()
608 t := styles.CurrentTheme()
609 // Replace home directory with ~, unless we're at the top level of the
610 // home directory).
611 homeDir, err := os.UserHomeDir()
612 if err == nil && cwd != homeDir {
613 cwd = strings.ReplaceAll(cwd, homeDir, "~")
614 }
615 return t.S().Muted.Render(cwd)
616}