sidebar.go

  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}