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