1package sidebar
2
3import (
4 "context"
5 "fmt"
6 "slices"
7 "strings"
8
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/catwalk/pkg/catwalk"
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/csync"
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/home"
17 "github.com/charmbracelet/crush/internal/lsp"
18 "github.com/charmbracelet/crush/internal/pubsub"
19 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/tui/components/chat"
21 "github.com/charmbracelet/crush/internal/tui/components/core"
22 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
23 "github.com/charmbracelet/crush/internal/tui/components/files"
24 "github.com/charmbracelet/crush/internal/tui/components/logo"
25 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
26 "github.com/charmbracelet/crush/internal/tui/components/mcp"
27 "github.com/charmbracelet/crush/internal/tui/styles"
28 "github.com/charmbracelet/crush/internal/tui/util"
29 "github.com/charmbracelet/crush/internal/version"
30 "github.com/charmbracelet/lipgloss/v2"
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 found := false
182 for existing := range m.files.Seq() {
183 if existing.FilePath != file.Path {
184 continue
185 }
186 if existing.History.latestVersion.Version < file.Version {
187 existing.History.latestVersion = file
188 } else if file.Version == 0 {
189 existing.History.initialVersion = file
190 } else {
191 // If the version is not greater than the latest, we ignore it
192 continue
193 }
194 before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content)
195 after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content)
196 path := existing.History.initialVersion.Path
197 cwd := config.Get().WorkingDir()
198 path = strings.TrimPrefix(path, cwd)
199 _, additions, deletions := diff.GenerateDiff(before, after, path)
200 existing.Additions = additions
201 existing.Deletions = deletions
202 m.files.Set(file.Path, existing)
203 found = true
204 break
205 }
206 if found {
207 return nil
208 }
209 sf := SessionFile{
210 History: FileHistory{
211 initialVersion: file,
212 latestVersion: file,
213 },
214 FilePath: file.Path,
215 Additions: 0,
216 Deletions: 0,
217 }
218 m.files.Set(file.Path, sf)
219 return nil
220 }
221}
222
223func (m *sidebarCmp) loadSessionFiles() tea.Msg {
224 files, err := m.history.ListBySession(context.Background(), m.session.ID)
225 if err != nil {
226 return util.InfoMsg{
227 Type: util.InfoTypeError,
228 Msg: err.Error(),
229 }
230 }
231
232 fileMap := make(map[string]FileHistory)
233
234 for _, file := range files {
235 if existing, ok := fileMap[file.Path]; ok {
236 // Update the latest version
237 existing.latestVersion = file
238 fileMap[file.Path] = existing
239 } else {
240 // Add the initial version
241 fileMap[file.Path] = FileHistory{
242 initialVersion: file,
243 latestVersion: file,
244 }
245 }
246 }
247
248 sessionFiles := make([]SessionFile, 0, len(fileMap))
249 for path, fh := range fileMap {
250 cwd := config.Get().WorkingDir()
251 path = strings.TrimPrefix(path, cwd)
252 before, _ := fsext.ToUnixLineEndings(fh.initialVersion.Content)
253 after, _ := fsext.ToUnixLineEndings(fh.latestVersion.Content)
254 _, additions, deletions := diff.GenerateDiff(before, after, path)
255 sessionFiles = append(sessionFiles, SessionFile{
256 History: fh,
257 FilePath: path,
258 Additions: additions,
259 Deletions: deletions,
260 })
261 }
262
263 return SessionFilesMsg{
264 Files: sessionFiles,
265 }
266}
267
268func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
269 m.logo = m.logoBlock()
270 m.cwd = cwd()
271 m.width = width
272 m.height = height
273 return nil
274}
275
276func (m *sidebarCmp) GetSize() (int, int) {
277 return m.width, m.height
278}
279
280func (m *sidebarCmp) logoBlock() string {
281 t := styles.CurrentTheme()
282 return logo.Render(version.Version, true, logo.Opts{
283 FieldColor: t.Primary,
284 TitleColorA: t.Secondary,
285 TitleColorB: t.Primary,
286 CharmColor: t.Secondary,
287 VersionColor: t.Primary,
288 Width: m.width - 2,
289 })
290}
291
292func (m *sidebarCmp) getMaxWidth() int {
293 return min(m.width-2, 58) // -2 for padding
294}
295
296// calculateAvailableHeight estimates how much height is available for dynamic content
297func (m *sidebarCmp) calculateAvailableHeight() int {
298 usedHeight := 0
299
300 if !m.compactMode {
301 if m.height > LogoHeightBreakpoint {
302 usedHeight += 7 // Approximate logo height
303 } else {
304 usedHeight += 2 // Smaller logo height
305 }
306 usedHeight += 1 // Empty line after logo
307 }
308
309 if m.session.ID != "" {
310 usedHeight += 1 // Title line
311 usedHeight += 1 // Empty line after title
312 }
313
314 if !m.compactMode {
315 usedHeight += 1 // CWD line
316 usedHeight += 1 // Empty line after CWD
317 }
318
319 usedHeight += 2 // Model info
320
321 usedHeight += 6 // 3 sections × 2 lines each (header + empty line)
322
323 // Base padding
324 usedHeight += 2 // Top and bottom padding
325
326 return max(0, m.height-usedHeight)
327}
328
329// getDynamicLimits calculates how many items to show in each section based on available height
330func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
331 availableHeight := m.calculateAvailableHeight()
332
333 // If we have very little space, use minimum values
334 if availableHeight < 10 {
335 return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
336 }
337
338 // Distribute available height among the three sections
339 // Give priority to files, then LSPs, then MCPs
340 totalSections := 3
341 heightPerSection := availableHeight / totalSections
342
343 // Calculate limits for each section, ensuring minimums
344 maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
345 maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
346 maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
347
348 // If we have extra space, give it to files first
349 remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
350 if remainingHeight > 0 {
351 extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
352 maxFiles += extraForFiles
353 remainingHeight -= extraForFiles
354
355 if remainingHeight > 0 {
356 extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
357 maxLSPs += extraForLSPs
358 remainingHeight -= extraForLSPs
359
360 if remainingHeight > 0 {
361 maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
362 }
363 }
364 }
365
366 return maxFiles, maxLSPs, maxMCPs
367}
368
369// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
370func (m *sidebarCmp) renderSectionsHorizontal() string {
371 // Calculate available width for each section
372 totalWidth := m.width - 4 // Account for padding and spacing
373 sectionWidth := min(50, totalWidth/3)
374
375 // Get the sections content with limited height
376 var filesContent, lspContent, mcpContent string
377
378 filesContent = m.filesBlockCompact(sectionWidth)
379 lspContent = m.lspBlockCompact(sectionWidth)
380 mcpContent = m.mcpBlockCompact(sectionWidth)
381
382 return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
383}
384
385// filesBlockCompact renders the files block with limited width and height for horizontal layout
386func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
387 // Convert map to slice and handle type conversion
388 sessionFiles := slices.Collect(m.files.Seq())
389 fileSlice := make([]files.SessionFile, len(sessionFiles))
390 for i, sf := range sessionFiles {
391 fileSlice[i] = files.SessionFile{
392 History: files.FileHistory{
393 InitialVersion: sf.History.initialVersion,
394 LatestVersion: sf.History.latestVersion,
395 },
396 FilePath: sf.FilePath,
397 Additions: sf.Additions,
398 Deletions: sf.Deletions,
399 }
400 }
401
402 // Limit items for horizontal layout
403 maxItems := min(5, len(fileSlice))
404 availableHeight := m.height - 8 // Reserve space for header and other content
405 if availableHeight > 0 {
406 maxItems = min(maxItems, availableHeight)
407 }
408
409 return files.RenderFileBlock(fileSlice, files.RenderOptions{
410 MaxWidth: maxWidth,
411 MaxItems: maxItems,
412 ShowSection: true,
413 SectionName: "Modified Files",
414 }, true)
415}
416
417// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
418func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
419 // Limit items for horizontal layout
420 lspConfigs := config.Get().LSP.Sorted()
421 maxItems := min(5, len(lspConfigs))
422 availableHeight := m.height - 8
423 if availableHeight > 0 {
424 maxItems = min(maxItems, availableHeight)
425 }
426
427 return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
428 MaxWidth: maxWidth,
429 MaxItems: maxItems,
430 ShowSection: true,
431 SectionName: "LSPs",
432 }, true)
433}
434
435// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
436func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
437 // Limit items for horizontal layout
438 maxItems := min(5, len(config.Get().MCP.Sorted()))
439 availableHeight := m.height - 8
440 if availableHeight > 0 {
441 maxItems = min(maxItems, availableHeight)
442 }
443
444 return mcp.RenderMCPBlock(mcp.RenderOptions{
445 MaxWidth: maxWidth,
446 MaxItems: maxItems,
447 ShowSection: true,
448 SectionName: "MCPs",
449 }, true)
450}
451
452func (m *sidebarCmp) filesBlock() string {
453 // Convert map to slice and handle type conversion
454 sessionFiles := slices.Collect(m.files.Seq())
455 fileSlice := make([]files.SessionFile, len(sessionFiles))
456 for i, sf := range sessionFiles {
457 fileSlice[i] = files.SessionFile{
458 History: files.FileHistory{
459 InitialVersion: sf.History.initialVersion,
460 LatestVersion: sf.History.latestVersion,
461 },
462 FilePath: sf.FilePath,
463 Additions: sf.Additions,
464 Deletions: sf.Deletions,
465 }
466 }
467
468 // Limit the number of files shown
469 maxFiles, _, _ := m.getDynamicLimits()
470 maxFiles = min(len(fileSlice), maxFiles)
471
472 return files.RenderFileBlock(fileSlice, files.RenderOptions{
473 MaxWidth: m.getMaxWidth(),
474 MaxItems: maxFiles,
475 ShowSection: true,
476 SectionName: core.Section("Modified Files", m.getMaxWidth()),
477 }, true)
478}
479
480func (m *sidebarCmp) lspBlock() string {
481 // Limit the number of LSPs shown
482 _, maxLSPs, _ := m.getDynamicLimits()
483 lspConfigs := config.Get().LSP.Sorted()
484 maxLSPs = min(len(lspConfigs), maxLSPs)
485
486 return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
487 MaxWidth: m.getMaxWidth(),
488 MaxItems: maxLSPs,
489 ShowSection: true,
490 SectionName: core.Section("LSPs", m.getMaxWidth()),
491 }, true)
492}
493
494func (m *sidebarCmp) mcpBlock() string {
495 // Limit the number of MCPs shown
496 _, _, maxMCPs := m.getDynamicLimits()
497 mcps := config.Get().MCP.Sorted()
498 maxMCPs = min(len(mcps), maxMCPs)
499
500 return mcp.RenderMCPBlock(mcp.RenderOptions{
501 MaxWidth: m.getMaxWidth(),
502 MaxItems: maxMCPs,
503 ShowSection: true,
504 SectionName: core.Section("MCPs", m.getMaxWidth()),
505 }, true)
506}
507
508func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
509 t := styles.CurrentTheme()
510 // Format tokens in human-readable format (e.g., 110K, 1.2M)
511 var formattedTokens string
512 switch {
513 case tokens >= 1_000_000:
514 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
515 case tokens >= 1_000:
516 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
517 default:
518 formattedTokens = fmt.Sprintf("%d", tokens)
519 }
520
521 // Remove .0 suffix if present
522 if strings.HasSuffix(formattedTokens, ".0K") {
523 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
524 }
525 if strings.HasSuffix(formattedTokens, ".0M") {
526 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
527 }
528
529 percentage := (float64(tokens) / float64(contextWindow)) * 100
530
531 baseStyle := t.S().Base
532
533 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
534
535 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
536 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
537 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
538 if percentage > 80 {
539 // add the warning icon
540 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
541 }
542
543 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
544}
545
546func (s *sidebarCmp) currentModelBlock() string {
547 cfg := config.Get()
548 agentCfg := cfg.Agents["coder"]
549
550 selectedModel := cfg.Models[agentCfg.Model]
551
552 model := config.Get().GetModelByType(agentCfg.Model)
553 modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
554
555 t := styles.CurrentTheme()
556
557 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
558 modelName := t.S().Text.Render(model.Name)
559 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
560 parts := []string{
561 modelInfo,
562 }
563 if model.CanReason {
564 reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
565 switch modelProvider.Type {
566 case catwalk.TypeOpenAI:
567 reasoningEffort := model.DefaultReasoningEffort
568 if selectedModel.ReasoningEffort != "" {
569 reasoningEffort = selectedModel.ReasoningEffort
570 }
571 formatter := cases.Title(language.English, cases.NoLower)
572 parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
573 case catwalk.TypeAnthropic:
574 formatter := cases.Title(language.English, cases.NoLower)
575 if selectedModel.Think {
576 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
577 } else {
578 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
579 }
580 }
581 }
582 if s.session.ID != "" {
583 parts = append(
584 parts,
585 " "+formatTokensAndCost(
586 s.session.CompletionTokens+s.session.PromptTokens,
587 model.ContextWindow,
588 s.session.Cost,
589 ),
590 )
591 }
592 return lipgloss.JoinVertical(
593 lipgloss.Left,
594 parts...,
595 )
596}
597
598// SetSession implements Sidebar.
599func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
600 m.session = session
601 return m.loadSessionFiles
602}
603
604// SetCompactMode sets the compact mode for the sidebar.
605func (m *sidebarCmp) SetCompactMode(compact bool) {
606 m.compactMode = compact
607}
608
609func cwd() string {
610 cwd := config.Get().WorkingDir()
611 t := styles.CurrentTheme()
612 return t.S().Muted.Render(home.Short(cwd))
613}