Merge pull request #144 from charmbracelet/fix-sidebar-height

Kujtim Hoxha created

Fix sidebar height

Change summary

CRUSH.md                                        |   2 
internal/tui/components/chat/header/header.go   |   6 
internal/tui/components/chat/sidebar/sidebar.go | 438 ++++++++++++++++++
internal/tui/page/chat/chat.go                  |  24 
4 files changed, 448 insertions(+), 22 deletions(-)

Detailed changes

CRUSH.md πŸ”—

@@ -4,7 +4,7 @@
 
 - **Build**: `go build .` or `go run .`
 - **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
-- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
+- **Lint**: `task lint-fix`
 - **Format**: `task fmt` (gofumpt -w .)
 - **Dev**: `task dev` (runs with profiling enabled)
 

internal/tui/components/chat/header/header.go πŸ”—

@@ -21,6 +21,7 @@ type Header interface {
 	SetSession(session session.Session) tea.Cmd
 	SetWidth(width int) tea.Cmd
 	SetDetailsOpen(open bool)
+	ShowingDetails() bool
 }
 
 type header struct {
@@ -137,3 +138,8 @@ func (h *header) SetWidth(width int) tea.Cmd {
 	h.width = width
 	return nil
 }
+
+// ShowingDetails implements Header.
+func (h *header) ShowingDetails() bool {
+	return h.detailsOpen
+}

internal/tui/components/chat/sidebar/sidebar.go πŸ”—

@@ -33,6 +33,16 @@ type FileHistory struct {
 	latestVersion  history.File
 }
 
+const LogoHeightBreakpoint = 40
+
+// Default maximum number of items to show in each section
+const (
+	DefaultMaxFilesShown = 10
+	DefaultMaxLSPsShown  = 8
+	DefaultMaxMCPsShown  = 8
+	MinItemsPerSection   = 2 // Minimum items to show per section
+)
+
 type SessionFile struct {
 	History   FileHistory
 	FilePath  string
@@ -100,8 +110,14 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (m *sidebarCmp) View() string {
 	t := styles.CurrentTheme()
 	parts := []string{}
+
 	if !m.compactMode {
-		parts = append(parts, m.logo)
+		if m.height > LogoHeightBreakpoint {
+			parts = append(parts, m.logo)
+		} else {
+			// Use a smaller logo for smaller screens
+			parts = append(parts, m.smallerScreenLogo(), "")
+		}
 	}
 
 	if !m.compactMode && m.session.ID != "" {
@@ -119,15 +135,26 @@ func (m *sidebarCmp) View() string {
 	parts = append(parts,
 		m.currentModelBlock(),
 	)
-	if m.session.ID != "" {
-		parts = append(parts, "", m.filesBlock())
+
+	// Check if we should use horizontal layout for sections
+	if m.compactMode && m.width > m.height {
+		// Horizontal layout for compact mode when width > height
+		sectionsContent := m.renderSectionsHorizontal()
+		if sectionsContent != "" {
+			parts = append(parts, "", sectionsContent)
+		}
+	} else {
+		// Vertical layout (default)
+		if m.session.ID != "" {
+			parts = append(parts, "", m.filesBlock())
+		}
+		parts = append(parts,
+			"",
+			m.lspBlock(),
+			"",
+			m.mcpBlock(),
+		)
 	}
-	parts = append(parts,
-		"",
-		m.lspBlock(),
-		"",
-		m.mcpBlock(),
-	)
 
 	style := t.S().Base.
 		Width(m.width).
@@ -258,6 +285,328 @@ func (m *sidebarCmp) getMaxWidth() int {
 	return min(m.width-2, 58) // -2 for padding
 }
 
+// calculateAvailableHeight estimates how much height is available for dynamic content
+func (m *sidebarCmp) calculateAvailableHeight() int {
+	usedHeight := 0
+
+	if !m.compactMode {
+		if m.height > LogoHeightBreakpoint {
+			usedHeight += 7 // Approximate logo height
+		} else {
+			usedHeight += 2 // Smaller logo height
+		}
+		usedHeight += 1 // Empty line after logo
+	}
+
+	if m.session.ID != "" {
+		usedHeight += 1 // Title line
+		usedHeight += 1 // Empty line after title
+	}
+
+	if !m.compactMode {
+		usedHeight += 1 // CWD line
+		usedHeight += 1 // Empty line after CWD
+	}
+
+	usedHeight += 2 // Model info
+
+	usedHeight += 6 // 3 sections Γ— 2 lines each (header + empty line)
+
+	// Base padding
+	usedHeight += 2 // Top and bottom padding
+
+	return max(0, m.height-usedHeight)
+}
+
+// getDynamicLimits calculates how many items to show in each section based on available height
+func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
+	availableHeight := m.calculateAvailableHeight()
+
+	// If we have very little space, use minimum values
+	if availableHeight < 10 {
+		return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
+	}
+
+	// Distribute available height among the three sections
+	// Give priority to files, then LSPs, then MCPs
+	totalSections := 3
+	heightPerSection := availableHeight / totalSections
+
+	// Calculate limits for each section, ensuring minimums
+	maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
+	maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
+	maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
+
+	// If we have extra space, give it to files first
+	remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
+	if remainingHeight > 0 {
+		extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
+		maxFiles += extraForFiles
+		remainingHeight -= extraForFiles
+
+		if remainingHeight > 0 {
+			extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
+			maxLSPs += extraForLSPs
+			remainingHeight -= extraForLSPs
+
+			if remainingHeight > 0 {
+				maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
+			}
+		}
+	}
+
+	return maxFiles, maxLSPs, maxMCPs
+}
+
+// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
+func (m *sidebarCmp) renderSectionsHorizontal() string {
+	// Calculate available width for each section
+	totalWidth := m.width - 4 // Account for padding and spacing
+	sectionWidth := min(50, totalWidth/3)
+
+	// Get the sections content with limited height
+	var filesContent, lspContent, mcpContent string
+
+	filesContent = m.filesBlockCompact(sectionWidth)
+	lspContent = m.lspBlockCompact(sectionWidth)
+	mcpContent = m.mcpBlockCompact(sectionWidth)
+
+	return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
+}
+
+// filesBlockCompact renders the files block with limited width and height for horizontal layout
+func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
+	t := styles.CurrentTheme()
+
+	section := t.S().Subtle.Render("Modified Files")
+
+	files := make([]SessionFile, 0)
+	m.files.Range(func(key, value any) bool {
+		file := value.(SessionFile)
+		files = append(files, file)
+		return true
+	})
+
+	if len(files) == 0 {
+		content := lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Base.Foreground(t.Border).Render("None"),
+		)
+		return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	}
+
+	fileList := []string{section, ""}
+	sort.Slice(files, func(i, j int) bool {
+		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
+	})
+
+	// Limit items for horizontal layout - use less space
+	maxItems := min(5, len(files))
+	availableHeight := m.height - 8 // Reserve space for header and other content
+	if availableHeight > 0 {
+		maxItems = min(maxItems, availableHeight)
+	}
+
+	filesShown := 0
+	for _, file := range files {
+		if file.Additions == 0 && file.Deletions == 0 {
+			continue
+		}
+		if filesShown >= maxItems {
+			break
+		}
+
+		var statusParts []string
+		if file.Additions > 0 {
+			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
+		}
+		if file.Deletions > 0 {
+			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
+		}
+
+		extraContent := strings.Join(statusParts, " ")
+		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
+		filePath := file.FilePath
+		filePath = strings.TrimPrefix(filePath, cwd)
+		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
+		filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
+
+		fileList = append(fileList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:    t.FgMuted,
+					NoIcon:       true,
+					Title:        filePath,
+					ExtraContent: extraContent,
+				},
+				maxWidth,
+			),
+		)
+		filesShown++
+	}
+
+	// Add "..." indicator if there are more files
+	totalFilesWithChanges := 0
+	for _, file := range files {
+		if file.Additions > 0 || file.Deletions > 0 {
+			totalFilesWithChanges++
+		}
+	}
+	if totalFilesWithChanges > maxItems {
+		fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
+	}
+
+	content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
+	return lipgloss.NewStyle().Width(maxWidth).Render(content)
+}
+
+// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
+func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
+	t := styles.CurrentTheme()
+
+	section := t.S().Subtle.Render("LSPs")
+
+	lspList := []string{section, ""}
+
+	lsp := config.Get().LSP.Sorted()
+	if len(lsp) == 0 {
+		content := lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Base.Foreground(t.Border).Render("None"),
+		)
+		return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	}
+
+	// Limit items for horizontal layout
+	maxItems := min(5, len(lsp))
+	availableHeight := m.height - 8
+	if availableHeight > 0 {
+		maxItems = min(maxItems, availableHeight)
+	}
+
+	for i, l := range lsp {
+		if i >= maxItems {
+			break
+		}
+
+		iconColor := t.Success
+		if l.LSP.Disabled {
+			iconColor = t.FgMuted
+		}
+
+		lspErrs := map[protocol.DiagnosticSeverity]int{
+			protocol.SeverityError:       0,
+			protocol.SeverityWarning:     0,
+			protocol.SeverityHint:        0,
+			protocol.SeverityInformation: 0,
+		}
+		if client, ok := m.lspClients[l.Name]; ok {
+			for _, diagnostics := range client.GetDiagnostics() {
+				for _, diagnostic := range diagnostics {
+					if severity, ok := lspErrs[diagnostic.Severity]; ok {
+						lspErrs[diagnostic.Severity] = severity + 1
+					}
+				}
+			}
+		}
+
+		errs := []string{}
+		if lspErrs[protocol.SeverityError] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
+		}
+		if lspErrs[protocol.SeverityWarning] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
+		}
+		if lspErrs[protocol.SeverityHint] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
+		}
+		if lspErrs[protocol.SeverityInformation] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
+		}
+
+		lspList = append(lspList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:    iconColor,
+					Title:        l.Name,
+					Description:  l.LSP.Command,
+					ExtraContent: strings.Join(errs, " "),
+				},
+				maxWidth,
+			),
+		)
+	}
+
+	// Add "..." indicator if there are more LSPs
+	if len(lsp) > maxItems {
+		lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
+	}
+
+	content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
+	return lipgloss.NewStyle().Width(maxWidth).Render(content)
+}
+
+// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
+func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
+	t := styles.CurrentTheme()
+
+	section := t.S().Subtle.Render("MCPs")
+
+	mcpList := []string{section, ""}
+
+	mcps := config.Get().MCP.Sorted()
+	if len(mcps) == 0 {
+		content := lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Base.Foreground(t.Border).Render("None"),
+		)
+		return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	}
+
+	// Limit items for horizontal layout
+	maxItems := min(5, len(mcps))
+	availableHeight := m.height - 8
+	if availableHeight > 0 {
+		maxItems = min(maxItems, availableHeight)
+	}
+
+	for i, l := range mcps {
+		if i >= maxItems {
+			break
+		}
+
+		iconColor := t.Success
+		if l.MCP.Disabled {
+			iconColor = t.FgMuted
+		}
+
+		mcpList = append(mcpList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:   iconColor,
+					Title:       l.Name,
+					Description: l.MCP.Command,
+				},
+				maxWidth,
+			),
+		)
+	}
+
+	// Add "..." indicator if there are more MCPs
+	if len(mcps) > maxItems {
+		mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
+	}
+
+	content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
+	return lipgloss.NewStyle().Width(maxWidth).Render(content)
+}
+
 func (m *sidebarCmp) filesBlock() string {
 	t := styles.CurrentTheme()
 
@@ -286,10 +635,19 @@ func (m *sidebarCmp) filesBlock() string {
 		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
 	})
 
+	// Limit the number of files shown
+	maxFiles, _, _ := m.getDynamicLimits()
+	maxFiles = min(len(files), maxFiles)
+	filesShown := 0
+
 	for _, file := range files {
 		if file.Additions == 0 && file.Deletions == 0 {
 			continue // skip files with no changes
 		}
+		if filesShown >= maxFiles {
+			break
+		}
+
 		var statusParts []string
 		if file.Additions > 0 {
 			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
@@ -315,6 +673,21 @@ func (m *sidebarCmp) filesBlock() string {
 				m.getMaxWidth(),
 			),
 		)
+		filesShown++
+	}
+
+	// Add indicator if there are more files
+	totalFilesWithChanges := 0
+	for _, file := range files {
+		if file.Additions > 0 || file.Deletions > 0 {
+			totalFilesWithChanges++
+		}
+	}
+	if totalFilesWithChanges > maxFiles {
+		remaining := totalFilesWithChanges - maxFiles
+		fileList = append(fileList,
+			t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
+		)
 	}
 
 	return lipgloss.JoinVertical(
@@ -342,7 +715,14 @@ func (m *sidebarCmp) lspBlock() string {
 		)
 	}
 
-	for _, l := range lsp {
+	// Limit the number of LSPs shown
+	_, maxLSPs, _ := m.getDynamicLimits()
+	maxLSPs = min(len(lsp), maxLSPs)
+	for i, l := range lsp {
+		if i >= maxLSPs {
+			break
+		}
+
 		iconColor := t.Success
 		if l.LSP.Disabled {
 			iconColor = t.FgMuted
@@ -390,6 +770,14 @@ func (m *sidebarCmp) lspBlock() string {
 		)
 	}
 
+	// Add indicator if there are more LSPs
+	if len(lsp) > maxLSPs {
+		remaining := len(lsp) - maxLSPs
+		lspList = append(lspList,
+			t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
+		)
+	}
+
 	return lipgloss.JoinVertical(
 		lipgloss.Left,
 		lspList...,
@@ -415,7 +803,14 @@ func (m *sidebarCmp) mcpBlock() string {
 		)
 	}
 
-	for _, l := range mcps {
+	// Limit the number of MCPs shown
+	_, _, maxMCPs := m.getDynamicLimits()
+	maxMCPs = min(len(mcps), maxMCPs)
+	for i, l := range mcps {
+		if i >= maxMCPs {
+			break
+		}
+
 		iconColor := t.Success
 		if l.MCP.Disabled {
 			iconColor = t.FgMuted
@@ -432,6 +827,14 @@ func (m *sidebarCmp) mcpBlock() string {
 		)
 	}
 
+	// Add indicator if there are more MCPs
+	if len(mcps) > maxMCPs {
+		remaining := len(mcps) - maxMCPs
+		mcpList = append(mcpList,
+			t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
+		)
+	}
+
 	return lipgloss.JoinVertical(
 		lipgloss.Left,
 		mcpList...,
@@ -504,6 +907,19 @@ func (s *sidebarCmp) currentModelBlock() string {
 	)
 }
 
+func (m *sidebarCmp) smallerScreenLogo() string {
+	t := styles.CurrentTheme()
+	title := t.S().Base.Foreground(t.Secondary).Render("Charmβ„’")
+	title += " " + styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)
+	remainingWidth := m.width - lipgloss.Width(title) - 3
+	if remainingWidth > 0 {
+		char := "β•±"
+		lines := strings.Repeat(char, remainingWidth)
+		title += " " + t.S().Base.Foreground(t.Primary).Render(lines)
+	}
+	return title
+}
+
 // SetSession implements Sidebar.
 func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
 	m.session = session

internal/tui/page/chat/chat.go πŸ”—

@@ -51,11 +51,12 @@ const (
 )
 
 const (
-	CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
-	EditorHeight          = 5   // Height of the editor input area including padding
-	SideBarWidth          = 31  // Width of the sidebar
-	SideBarDetailsPadding = 1   // Padding for the sidebar details section
-	HeaderHeight          = 1   // Height of the header
+	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
+	CompactModeHeightBreakpoint = 30  // Height at which the chat page switches to compact mode
+	EditorHeight                = 5   // Height of the editor input area including padding
+	SideBarWidth                = 31  // Width of the sidebar
+	SideBarDetailsPadding       = 1   // Padding for the sidebar details section
+	HeaderHeight                = 1   // Height of the header
 
 	// Layout constants for borders and padding
 	BorderWidth        = 1 // Width of component borders
@@ -177,7 +178,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if p.forceCompact {
 			p.setCompactMode(true)
 			cmd = p.updateCompactConfig(true)
-		} else if p.width >= CompactModeBreakpoint {
+		} else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
 			p.setCompactMode(false)
 			cmd = p.updateCompactConfig(false)
 		}
@@ -313,6 +314,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (p *chatPage) Cursor() *tea.Cursor {
+	if p.header.ShowingDetails() {
+		return nil
+	}
 	switch p.focusedPane {
 	case PanelTypeEditor:
 		return p.editor.Cursor()
@@ -420,20 +424,20 @@ func (p *chatPage) setCompactMode(compact bool) {
 	}
 }
 
-func (p *chatPage) handleCompactMode(newWidth int) {
+func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 	if p.forceCompact {
 		return
 	}
-	if newWidth < CompactModeBreakpoint && !p.compact {
+	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 		p.setCompactMode(true)
 	}
-	if newWidth >= CompactModeBreakpoint && p.compact {
+	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 		p.setCompactMode(false)
 	}
 }
 
 func (p *chatPage) SetSize(width, height int) tea.Cmd {
-	p.handleCompactMode(width)
+	p.handleCompactMode(width, height)
 	p.width = width
 	p.height = height
 	var cmds []tea.Cmd