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