sidebar.go

  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}