From b8f74f33c52759625a1a5cd9f5f1a0b95675a781 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 11 Jul 2025 16:35:27 +0200 Subject: [PATCH 1/3] chore: make the sidebar a bit more responsive --- .../tui/components/chat/sidebar/sidebar.go | 23 ++++++++++++++++++- internal/tui/page/chat/chat.go | 21 +++++++++-------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 3fa08ce021d0fcac1ce7dc9668d46198f6d08055..9bc77db5965c99eb8e365f140562c3b108aae63c 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -33,6 +33,8 @@ type FileHistory struct { latestVersion history.File } +const LogoHeightBreakpoint = 40 + type SessionFile struct { History FileHistory FilePath string @@ -100,8 +102,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 != "" { @@ -504,6 +512,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 diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 33267772e96662f14934a8417149259c7d22541a..49b0ff4ecc49dce5da005a4b9912bf64598dae97 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -52,11 +52,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 @@ -178,7 +179,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) } @@ -421,20 +422,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 From 9416a8f5fb80848dda1a35e0362dadf778a54b77 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 11 Jul 2025 16:45:03 +0200 Subject: [PATCH 2/3] chore: try to make the number of items in the sidebar dynamic --- .../tui/components/chat/sidebar/sidebar.go | 139 +++++++++++++++++- 1 file changed, 137 insertions(+), 2 deletions(-) diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 9bc77db5965c99eb8e365f140562c3b108aae63c..c6b83d978b3b7043842c78e8801436f4bee1a98d 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -35,6 +35,14 @@ type FileHistory struct { 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 @@ -266,6 +274,79 @@ 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 +} + func (m *sidebarCmp) filesBlock() string { t := styles.CurrentTheme() @@ -294,10 +375,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))) @@ -323,6 +413,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( @@ -350,7 +455,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 @@ -398,6 +510,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..., @@ -423,7 +543,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 @@ -440,6 +567,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..., From dc11be243c801b8962305093348d51b02476b7a6 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 11:55:50 +0200 Subject: [PATCH 3/3] chore: small fixe details screen --- CRUSH.md | 2 +- internal/tui/components/chat/header/header.go | 6 + .../tui/components/chat/sidebar/sidebar.go | 276 +++++++++++++++++- internal/tui/page/chat/chat.go | 3 + 4 files changed, 278 insertions(+), 9 deletions(-) diff --git a/CRUSH.md b/CRUSH.md index c308db631e006dd1c3834b6b470a02f4c41ff53b..06c9b99e593596029212b8d4d95024b310a2d003 100644 --- a/CRUSH.md +++ b/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) diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go index 5d27cc14fdf341ea3f201876f80f7edd7f1ce328..4eac0c2444321a59c06d2e83d328fd1ea9e8512c 100644 --- a/internal/tui/components/chat/header/header.go +++ b/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 +} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index c6b83d978b3b7043842c78e8801436f4bee1a98d..cf1fd12dff512475fa77f0cd9fb657646c0cc2fd 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -135,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). @@ -347,6 +358,255 @@ func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) { 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() diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 49b0ff4ecc49dce5da005a4b9912bf64598dae97..14e9b47b65608a04c3d295ae8b88fac1bec69873 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -315,6 +315,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()