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