1package sidebar
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"slices"
  8	"strings"
  9
 10	tea "github.com/charmbracelet/bubbletea/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/history"
 16	"github.com/charmbracelet/crush/internal/lsp"
 17	"github.com/charmbracelet/crush/internal/pubsub"
 18	"github.com/charmbracelet/crush/internal/session"
 19	"github.com/charmbracelet/crush/internal/tui/components/chat"
 20	"github.com/charmbracelet/crush/internal/tui/components/core"
 21	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 22	"github.com/charmbracelet/crush/internal/tui/components/files"
 23	"github.com/charmbracelet/crush/internal/tui/components/logo"
 24	lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
 25	"github.com/charmbracelet/crush/internal/tui/components/mcp"
 26	"github.com/charmbracelet/crush/internal/tui/styles"
 27	"github.com/charmbracelet/crush/internal/tui/util"
 28	"github.com/charmbracelet/crush/internal/version"
 29	"github.com/charmbracelet/lipgloss/v2"
 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    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 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) (tea.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 := existing.History.initialVersion.Content
194			after := 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		_, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, path)
252		sessionFiles = append(sessionFiles, SessionFile{
253			History:   fh,
254			FilePath:  path,
255			Additions: additions,
256			Deletions: deletions,
257		})
258	}
259
260	return SessionFilesMsg{
261		Files: sessionFiles,
262	}
263}
264
265func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
266	m.logo = m.logoBlock()
267	m.cwd = cwd()
268	m.width = width
269	m.height = height
270	return nil
271}
272
273func (m *sidebarCmp) GetSize() (int, int) {
274	return m.width, m.height
275}
276
277func (m *sidebarCmp) logoBlock() string {
278	t := styles.CurrentTheme()
279	return logo.Render(version.Version, true, logo.Opts{
280		FieldColor:   t.Primary,
281		TitleColorA:  t.Secondary,
282		TitleColorB:  t.Primary,
283		CharmColor:   t.Secondary,
284		VersionColor: t.Primary,
285		Width:        m.width - 2,
286	})
287}
288
289func (m *sidebarCmp) getMaxWidth() int {
290	return min(m.width-2, 58) // -2 for padding
291}
292
293// calculateAvailableHeight estimates how much height is available for dynamic content
294func (m *sidebarCmp) calculateAvailableHeight() int {
295	usedHeight := 0
296
297	if !m.compactMode {
298		if m.height > LogoHeightBreakpoint {
299			usedHeight += 7 // Approximate logo height
300		} else {
301			usedHeight += 2 // Smaller logo height
302		}
303		usedHeight += 1 // Empty line after logo
304	}
305
306	if m.session.ID != "" {
307		usedHeight += 1 // Title line
308		usedHeight += 1 // Empty line after title
309	}
310
311	if !m.compactMode {
312		usedHeight += 1 // CWD line
313		usedHeight += 1 // Empty line after CWD
314	}
315
316	usedHeight += 2 // Model info
317
318	usedHeight += 6 // 3 sections × 2 lines each (header + empty line)
319
320	// Base padding
321	usedHeight += 2 // Top and bottom padding
322
323	return max(0, m.height-usedHeight)
324}
325
326// getDynamicLimits calculates how many items to show in each section based on available height
327func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
328	availableHeight := m.calculateAvailableHeight()
329
330	// If we have very little space, use minimum values
331	if availableHeight < 10 {
332		return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
333	}
334
335	// Distribute available height among the three sections
336	// Give priority to files, then LSPs, then MCPs
337	totalSections := 3
338	heightPerSection := availableHeight / totalSections
339
340	// Calculate limits for each section, ensuring minimums
341	maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
342	maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
343	maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
344
345	// If we have extra space, give it to files first
346	remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
347	if remainingHeight > 0 {
348		extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
349		maxFiles += extraForFiles
350		remainingHeight -= extraForFiles
351
352		if remainingHeight > 0 {
353			extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
354			maxLSPs += extraForLSPs
355			remainingHeight -= extraForLSPs
356
357			if remainingHeight > 0 {
358				maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
359			}
360		}
361	}
362
363	return maxFiles, maxLSPs, maxMCPs
364}
365
366// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
367func (m *sidebarCmp) renderSectionsHorizontal() string {
368	// Calculate available width for each section
369	totalWidth := m.width - 4 // Account for padding and spacing
370	sectionWidth := min(50, totalWidth/3)
371
372	// Get the sections content with limited height
373	var filesContent, lspContent, mcpContent string
374
375	filesContent = m.filesBlockCompact(sectionWidth)
376	lspContent = m.lspBlockCompact(sectionWidth)
377	mcpContent = m.mcpBlockCompact(sectionWidth)
378
379	return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
380}
381
382// filesBlockCompact renders the files block with limited width and height for horizontal layout
383func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
384	// Convert map to slice and handle type conversion
385	sessionFiles := slices.Collect(m.files.Seq())
386	fileSlice := make([]files.SessionFile, len(sessionFiles))
387	for i, sf := range sessionFiles {
388		fileSlice[i] = files.SessionFile{
389			History: files.FileHistory{
390				InitialVersion: sf.History.initialVersion,
391				LatestVersion:  sf.History.latestVersion,
392			},
393			FilePath:  sf.FilePath,
394			Additions: sf.Additions,
395			Deletions: sf.Deletions,
396		}
397	}
398
399	// Limit items for horizontal layout
400	maxItems := min(5, len(fileSlice))
401	availableHeight := m.height - 8 // Reserve space for header and other content
402	if availableHeight > 0 {
403		maxItems = min(maxItems, availableHeight)
404	}
405
406	return files.RenderFileBlock(fileSlice, files.RenderOptions{
407		MaxWidth:    maxWidth,
408		MaxItems:    maxItems,
409		ShowSection: true,
410		SectionName: "Modified Files",
411	}, true)
412}
413
414// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
415func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
416	// Limit items for horizontal layout
417	lspConfigs := config.Get().LSP.Sorted()
418	maxItems := min(5, len(lspConfigs))
419	availableHeight := m.height - 8
420	if availableHeight > 0 {
421		maxItems = min(maxItems, availableHeight)
422	}
423
424	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
425		MaxWidth:    maxWidth,
426		MaxItems:    maxItems,
427		ShowSection: true,
428		SectionName: "LSPs",
429	}, true)
430}
431
432// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
433func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
434	// Limit items for horizontal layout
435	maxItems := min(5, len(config.Get().MCP.Sorted()))
436	availableHeight := m.height - 8
437	if availableHeight > 0 {
438		maxItems = min(maxItems, availableHeight)
439	}
440
441	return mcp.RenderMCPBlock(mcp.RenderOptions{
442		MaxWidth:    maxWidth,
443		MaxItems:    maxItems,
444		ShowSection: true,
445		SectionName: "MCPs",
446	}, true)
447}
448
449func (m *sidebarCmp) filesBlock() string {
450	// Convert map to slice and handle type conversion
451	sessionFiles := slices.Collect(m.files.Seq())
452	fileSlice := make([]files.SessionFile, len(sessionFiles))
453	for i, sf := range sessionFiles {
454		fileSlice[i] = files.SessionFile{
455			History: files.FileHistory{
456				InitialVersion: sf.History.initialVersion,
457				LatestVersion:  sf.History.latestVersion,
458			},
459			FilePath:  sf.FilePath,
460			Additions: sf.Additions,
461			Deletions: sf.Deletions,
462		}
463	}
464
465	// Limit the number of files shown
466	maxFiles, _, _ := m.getDynamicLimits()
467	maxFiles = min(len(fileSlice), maxFiles)
468
469	return files.RenderFileBlock(fileSlice, files.RenderOptions{
470		MaxWidth:    m.getMaxWidth(),
471		MaxItems:    maxFiles,
472		ShowSection: true,
473		SectionName: core.Section("Modified Files", m.getMaxWidth()),
474	}, true)
475}
476
477func (m *sidebarCmp) lspBlock() string {
478	// Limit the number of LSPs shown
479	_, maxLSPs, _ := m.getDynamicLimits()
480	lspConfigs := config.Get().LSP.Sorted()
481	maxLSPs = min(len(lspConfigs), maxLSPs)
482
483	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
484		MaxWidth:    m.getMaxWidth(),
485		MaxItems:    maxLSPs,
486		ShowSection: true,
487		SectionName: core.Section("LSPs", m.getMaxWidth()),
488	}, true)
489}
490
491func (m *sidebarCmp) mcpBlock() string {
492	// Limit the number of MCPs shown
493	_, _, maxMCPs := m.getDynamicLimits()
494	mcps := config.Get().MCP.Sorted()
495	maxMCPs = min(len(mcps), maxMCPs)
496
497	return mcp.RenderMCPBlock(mcp.RenderOptions{
498		MaxWidth:    m.getMaxWidth(),
499		MaxItems:    maxMCPs,
500		ShowSection: true,
501		SectionName: core.Section("MCPs", m.getMaxWidth()),
502	}, true)
503}
504
505func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
506	t := styles.CurrentTheme()
507	// Format tokens in human-readable format (e.g., 110K, 1.2M)
508	var formattedTokens string
509	switch {
510	case tokens >= 1_000_000:
511		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
512	case tokens >= 1_000:
513		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
514	default:
515		formattedTokens = fmt.Sprintf("%d", tokens)
516	}
517
518	// Remove .0 suffix if present
519	if strings.HasSuffix(formattedTokens, ".0K") {
520		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
521	}
522	if strings.HasSuffix(formattedTokens, ".0M") {
523		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
524	}
525
526	percentage := (float64(tokens) / float64(contextWindow)) * 100
527
528	baseStyle := t.S().Base
529
530	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
531
532	formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
533	formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
534	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
535	if percentage > 80 {
536		// add the warning icon
537		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
538	}
539
540	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
541}
542
543func (s *sidebarCmp) currentModelBlock() string {
544	cfg := config.Get()
545	agentCfg := cfg.Agents["coder"]
546
547	selectedModel := cfg.Models[agentCfg.Model]
548
549	model := config.Get().GetModelByType(agentCfg.Model)
550	modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
551
552	t := styles.CurrentTheme()
553
554	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
555	modelName := t.S().Text.Render(model.Name)
556	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
557	parts := []string{
558		modelInfo,
559	}
560	if model.CanReason {
561		reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
562		switch modelProvider.Type {
563		case catwalk.TypeOpenAI:
564			reasoningEffort := model.DefaultReasoningEffort
565			if selectedModel.ReasoningEffort != "" {
566				reasoningEffort = selectedModel.ReasoningEffort
567			}
568			formatter := cases.Title(language.English, cases.NoLower)
569			parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
570		case catwalk.TypeAnthropic:
571			formatter := cases.Title(language.English, cases.NoLower)
572			if selectedModel.Think {
573				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
574			} else {
575				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
576			}
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	// Replace home directory with ~, unless we're at the top level of the
610	// home directory).
611	homeDir, err := os.UserHomeDir()
612	if err == nil && cwd != homeDir {
613		cwd = strings.ReplaceAll(cwd, homeDir, "~")
614	}
615	return t.S().Muted.Render(cwd)
616}