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/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}