From 26edcdc405cea27dac32208f060a36d565930c0a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 26 Nov 2025 16:02:18 +0100 Subject: [PATCH] chore: add chat sidebar (#1510) --- internal/ui/common/elements.go | 27 +++- internal/ui/model/files.go | 211 ++++++++++++++++++++++++++++++++ internal/ui/model/landing.go | 35 ++---- internal/ui/model/lsp.go | 23 ++-- internal/ui/model/mcp.go | 31 +++-- internal/ui/model/onboarding.go | 5 + internal/ui/model/sidebar.go | 169 ++++++++++++++++++------- internal/ui/model/ui.go | 97 +++++++++++---- internal/ui/styles/styles.go | 27 +++- 9 files changed, 502 insertions(+), 123 deletions(-) create mode 100644 internal/ui/model/files.go diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index 762fac8e12ebad622c4df4e16c4ebd12ad0f6613..246543078028f5ab616c88c5b1b75103489e1d1a 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -12,17 +12,22 @@ import ( "github.com/charmbracelet/x/ansi" ) +// PrettyPath formats a file path with home directory shortening and applies +// muted styling. func PrettyPath(t *styles.Styles, path string, width int) string { formatted := home.Short(path) return t.Muted.Width(width).Render(formatted) } +// ModelContextInfo contains token usage and cost information for a model. type ModelContextInfo struct { ContextUsed int64 ModelContext int64 Cost float64 } +// ModelInfo renders model information including name, reasoning settings, and +// optional context usage/cost. func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string { modelIcon := t.Subtle.Render(styles.ModelIcon) modelName = t.Base.Render(modelName) @@ -37,7 +42,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context } if context != nil { - parts = append(parts, formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)) + formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost) + parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo)) } return lipgloss.NewStyle().Width(width).Render( @@ -45,6 +51,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context ) } +// formatTokensAndCost formats token usage and cost with appropriate units +// (K/M) and percentage of context window. func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string { var formattedTokens string switch { @@ -77,6 +85,8 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo return fmt.Sprintf("%s %s", formattedTokens, formattedCost) } +// StatusOpts defines options for rendering a status line with icon, title, +// description, and optional extra content. type StatusOpts struct { Icon string // if empty no icon will be shown Title string @@ -86,6 +96,8 @@ type StatusOpts struct { ExtraContent string // additional content to append after the description } +// Status renders a status line with icon, title, description, and extra +// content. The description is truncated if it exceeds the available width. func Status(t *styles.Styles, opts StatusOpts, width int) string { icon := opts.Icon title := opts.Title @@ -119,3 +131,16 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string { return strings.Join(content, " ") } + +// Section renders a section header with a title and a horizontal line filling +// the remaining width. +func Section(t *styles.Styles, text string, width int) string { + char := styles.SectionSeparator + length := lipgloss.Width(text) + 1 + remainingWidth := width - length + text = t.Section.Title.Render(text) + if remainingWidth > 0 { + text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + } + return text +} diff --git a/internal/ui/model/files.go b/internal/ui/model/files.go new file mode 100644 index 0000000000000000000000000000000000000000..7526ee8473f591b298e1a89096c5c8db5225bb70 --- /dev/null +++ b/internal/ui/model/files.go @@ -0,0 +1,211 @@ +package model + +import ( + "context" + "fmt" + "path/filepath" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// SessionFile tracks the first and latest versions of a file in a session, +// along with the total additions and deletions. +type SessionFile struct { + FirstVersion history.File + LatestVersion history.File + Additions int + Deletions int +} + +// loadSessionFiles loads all files modified during a session and calculates +// their diff statistics. +func (m *UI) loadSessionFiles(sessionID string) tea.Cmd { + return func() tea.Msg { + files, err := m.com.App.History.ListBySession(context.Background(), sessionID) + if err != nil { + return err + } + filesByPath := make(map[string][]history.File) + for _, f := range files { + filesByPath[f.Path] = append(filesByPath[f.Path], f) + } + + sessionFiles := make([]SessionFile, 0, len(filesByPath)) + for _, versions := range filesByPath { + if len(versions) == 0 { + continue + } + + first := versions[0] + last := versions[0] + for _, v := range versions { + if v.Version < first.Version { + first = v + } + if v.Version > last.Version { + last = v + } + } + + _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path) + + sessionFiles = append(sessionFiles, SessionFile{ + FirstVersion: first, + LatestVersion: last, + Additions: additions, + Deletions: deletions, + }) + } + + slices.SortFunc(sessionFiles, func(a, b SessionFile) int { + if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt { + return -1 + } + if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt { + return 1 + } + return 0 + }) + + return sessionFilesLoadedMsg{ + files: sessionFiles, + } + } +} + +// handleFileEvent processes file change events and updates the session file +// list with new or updated file information. +func (m *UI) handleFileEvent(file history.File) tea.Cmd { + if m.session == nil || file.SessionID != m.session.ID { + return nil + } + + return func() tea.Msg { + existingIdx := -1 + for i, sf := range m.sessionFiles { + if sf.FirstVersion.Path == file.Path { + existingIdx = i + break + } + } + + if existingIdx == -1 { + newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1) + newFiles = append(newFiles, SessionFile{ + FirstVersion: file, + LatestVersion: file, + Additions: 0, + Deletions: 0, + }) + newFiles = append(newFiles, m.sessionFiles...) + + return sessionFilesLoadedMsg{files: newFiles} + } + + updated := m.sessionFiles[existingIdx] + + if file.Version < updated.FirstVersion.Version { + updated.FirstVersion = file + } + + if file.Version > updated.LatestVersion.Version { + updated.LatestVersion = file + } + + _, additions, deletions := diff.GenerateDiff( + updated.FirstVersion.Content, + updated.LatestVersion.Content, + updated.FirstVersion.Path, + ) + updated.Additions = additions + updated.Deletions = deletions + + newFiles := make([]SessionFile, 0, len(m.sessionFiles)) + newFiles = append(newFiles, updated) + for i, sf := range m.sessionFiles { + if i != existingIdx { + newFiles = append(newFiles, sf) + } + } + + return sessionFilesLoadedMsg{files: newFiles} + } +} + +// filesInfo renders the modified files section for the sidebar, showing files +// with their addition/deletion counts. +func (m *UI) filesInfo(cwd string, width, maxItems int) string { + t := m.com.Styles + title := common.Section(t, "Modified Files", width) + list := t.Subtle.Render("None") + + if len(m.sessionFiles) > 0 { + list = fileList(t, cwd, m.sessionFiles, width, maxItems) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +// fileList renders a list of files with their diff statistics, truncating to +// maxItems and showing a "...and N more" message if needed. +func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string { + var renderedFiles []string + filesShown := 0 + + var filesWithChanges []SessionFile + for _, f := range files { + if f.Additions == 0 && f.Deletions == 0 { + continue + } + filesWithChanges = append(filesWithChanges, f) + } + + for _, f := range filesWithChanges { + // Skip files with no changes + if filesShown >= maxItems { + break + } + + // Build stats string with colors + var statusParts []string + if f.Additions > 0 { + statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions))) + } + if f.Deletions > 0 { + statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions))) + } + extraContent := strings.Join(statusParts, " ") + + // Format file path + filePath := f.FirstVersion.Path + if rel, err := filepath.Rel(cwd, filePath); err == nil { + filePath = rel + } + filePath = fsext.DirTrim(filePath, 2) + filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "…") + + line := t.Files.Path.Render(filePath) + if extraContent != "" { + line = fmt.Sprintf("%s %s", line, extraContent) + } + + renderedFiles = append(renderedFiles, line) + filesShown++ + } + + if len(filesWithChanges) > maxItems { + remaining := len(filesWithChanges) - maxItems + renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + } + + return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...) +} diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go index dda843250db29a19626cd7dae5e67990bd85c1ad..a90ef76fdaf779e61477f5a05fd92a68d2e8a257 100644 --- a/internal/ui/model/landing.go +++ b/internal/ui/model/landing.go @@ -1,18 +1,14 @@ package model import ( - "cmp" - "fmt" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/ui/common" uv "github.com/charmbracelet/ultraviolet" - "golang.org/x/text/cases" - "golang.org/x/text/language" ) +// selectedLargeModel returns the currently selected large language model from +// the agent coordinator, if one exists. func (m *UI) selectedLargeModel() *agent.Model { if m.com.App.AgentCoordinator != nil { model := m.com.App.AgentCoordinator.Model() @@ -21,6 +17,8 @@ func (m *UI) selectedLargeModel() *agent.Model { return nil } +// landingView renders the landing page view showing the current working +// directory, model information, and LSP/MCP status in a two-column layout. func (m *UI) landingView() string { t := m.com.Styles width := m.layout.main.Dx() @@ -30,34 +28,15 @@ func (m *UI) landingView() string { cwd, } - model := m.selectedLargeModel() - if model != nil && model.CatwalkCfg.CanReason { - reasoningInfo := "" - providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider) - if ok { - switch providerConfig.Type { - case catwalk.TypeAnthropic: - if model.ModelCfg.Think { - reasoningInfo = "Thinking On" - } else { - reasoningInfo = "Thinking Off" - } - default: - formatter := cases.Title(language.English, cases.NoLower) - reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) - reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) - } - parts = append(parts, "", common.ModelInfo(t, model.CatwalkCfg.Name, reasoningInfo, nil, width)) - } - } + parts = append(parts, "", m.modelInfo(width)) infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...) _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1)) mcpLspSectionWidth := min(30, (width-1)/2) - lspSection := m.lspInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy()) - mcpSection := m.mcpInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy()) + lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false) + mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false) content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection) diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index 220133b793da374920c7bd0fc2ee9e0aecd3b67b..b1a3b8ebb223ce20687a0885f21a65a7ed1bf88a 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -12,13 +12,17 @@ import ( "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) +// LSPInfo wraps LSP client information with diagnostic counts by severity. type LSPInfo struct { app.LSPClientInfo Diagnostics map[protocol.DiagnosticSeverity]int } -func (m *UI) lspInfo(t *styles.Styles, width, height int) string { +// lspInfo renders the LSP status section showing active LSP clients and their +// diagnostic counts. +func (m *UI) lspInfo(width, maxItems int, isSection bool) string { var lsps []LSPInfo + t := m.com.Styles for _, state := range m.lspStates { client, ok := m.com.App.LSPClients.Get(state.Name) @@ -43,15 +47,18 @@ func (m *UI) lspInfo(t *styles.Styles, width, height int) string { lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs}) } title := t.Subtle.Render("LSPs") + if isSection { + title = common.Section(t, title, width) + } list := t.Subtle.Render("None") if len(lsps) > 0 { - height = max(0, height-2) // remove title and space - list = lspList(t, lsps, width, height) + list = lspList(t, lsps, width, maxItems) } return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) } +// lspDiagnostics formats diagnostic counts with appropriate icons and colors. func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string { errs := []string{} if diagnostics[protocol.SeverityError] > 0 { @@ -69,7 +76,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit return strings.Join(errs, " ") } -func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string { +// lspList renders a list of LSP clients with their status and diagnostics, +// truncating to maxItems if needed. +func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { var renderedLsps []string for _, l := range lsps { var icon string @@ -103,9 +112,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string { }, width)) } - if len(renderedLsps) > height { - visibleItems := renderedLsps[:height-1] - remaining := len(renderedLsps) - (height - 1) + if len(renderedLsps) > maxItems { + visibleItems := renderedLsps[:maxItems-1] + remaining := len(renderedLsps) - maxItems visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) } diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index f4278a1e45afac2a7f8d12b495e1eb0be7cecfa5..2a58e15ac10175f29d6180aa7e98d954a644b34b 100644 --- a/internal/ui/model/mcp.go +++ b/internal/ui/model/mcp.go @@ -10,27 +10,29 @@ import ( "github.com/charmbracelet/crush/internal/ui/styles" ) -type MCPInfo struct { - mcp.ClientInfo -} - -func (m *UI) mcpInfo(t *styles.Styles, width, height int) string { - var mcps []MCPInfo +// mcpInfo renders the MCP status section showing active MCP clients and their +// tool/prompt counts. +func (m *UI) mcpInfo(width, maxItems int, isSection bool) string { + var mcps []mcp.ClientInfo + t := m.com.Styles for _, state := range m.mcpStates { - mcps = append(mcps, MCPInfo{ClientInfo: state}) + mcps = append(mcps, state) } title := t.Subtle.Render("MCPs") + if isSection { + title = common.Section(t, title, width) + } list := t.Subtle.Render("None") if len(mcps) > 0 { - height = max(0, height-2) // remove title and space - list = mcpList(t, mcps, width, height) + list = mcpList(t, mcps, width, maxItems) } return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) } +// mcpCounts formats tool and prompt counts for display. func mcpCounts(t *styles.Styles, counts mcp.Counts) string { parts := []string{} if counts.Tools > 0 { @@ -42,8 +44,11 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string { return strings.Join(parts, " ") } -func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string { +// mcpList renders a list of MCP clients with their status and counts, +// truncating to maxItems if needed. +func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string { var renderedMcps []string + for _, m := range mcps { var icon string title := m.Name @@ -78,9 +83,9 @@ func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string { }, width)) } - if len(renderedMcps) > height { - visibleItems := renderedMcps[:height-1] - remaining := len(renderedMcps) - (height - 1) + if len(renderedMcps) > maxItems { + visibleItems := renderedMcps[:maxItems-1] + remaining := len(renderedMcps) - maxItems visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) } diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 2f50ae85d1086dd00674e87560b44a4ae2aad202..1b922282ae78bf0a89004abfff6098ec3240ff94 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" ) +// markProjectInitialized marks the current project as initialized in the config. func (m *UI) markProjectInitialized() tea.Msg { // TODO: handle error so we show it in the tui footer err := config.MarkProjectInitialized() @@ -22,6 +23,7 @@ func (m *UI) markProjectInitialized() tea.Msg { return nil } +// updateInitializeView handles keyboard input for the project initialization prompt. func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { switch { case key.Matches(msg, m.keyMap.Initialize.Enter): @@ -40,6 +42,7 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { return cmds } +// initializeProject starts project initialization and transitions to the landing view. func (m *UI) initializeProject() tea.Cmd { // TODO: initialize the project // for now we just go to the landing page @@ -49,6 +52,7 @@ func (m *UI) initializeProject() tea.Cmd { return m.markProjectInitialized } +// skipInitializeProject skips project initialization and transitions to the landing view. func (m *UI) skipInitializeProject() tea.Cmd { // TODO: initialize the project m.state = uiLanding @@ -57,6 +61,7 @@ func (m *UI) skipInitializeProject() tea.Cmd { return m.markProjectInitialized } +// initializeView renders the project initialization prompt with Yes/No buttons. func (m *UI) initializeView() string { cfg := m.com.Config() s := m.com.Styles.Initialize diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 2f84565c98428126df612020ac0af35d88f46c78..1f63f020d228b8b975306fead7cb75d7167a717e 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -1,67 +1,150 @@ package model import ( - tea "charm.land/bubbletea/v2" + "cmp" + "fmt" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/logo" + uv "github.com/charmbracelet/ultraviolet" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) -// SidebarModel is the model for the sidebar UI component. -type SidebarModel struct { - com *common.Common +// modelInfo renders the current model information including reasoning +// settings and context usage/cost for the sidebar. +func (m *UI) modelInfo(width int) string { + model := m.selectedLargeModel() + reasoningInfo := "" + if model != nil && model.CatwalkCfg.CanReason { + providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider) + if ok { + switch providerConfig.Type { + case catwalk.TypeAnthropic: + if model.ModelCfg.Think { + reasoningInfo = "Thinking On" + } else { + reasoningInfo = "Thinking Off" + } + default: + formatter := cases.Title(language.English, cases.NoLower) + reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) + reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) + } + } + } + var modelContext *common.ModelContextInfo + if m.session != nil { + modelContext = &common.ModelContextInfo{ + ContextUsed: m.session.CompletionTokens + m.session.PromptTokens, + Cost: m.session.Cost, + ModelContext: model.CatwalkCfg.ContextWindow, + } + } + return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, reasoningInfo, modelContext, width) +} + +// getDynamicHeightLimits will give us the num of items to show in each section based on the hight +// some items are more important than others. +func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) { + const ( + minItemsPerSection = 2 + defaultMaxFilesShown = 10 + defaultMaxLSPsShown = 8 + defaultMaxMCPsShown = 8 + minAvailableHeightLimit = 10 + ) + + // If we have very little space, use minimum values + if availableHeight < minAvailableHeightLimit { + return minItemsPerSection, minItemsPerSection, minItemsPerSection + } - // width of the sidebar. - width int + // Distribute available height among the three sections + // Give priority to files, then LSPs, then MCPs + totalSections := 3 + heightPerSection := availableHeight / totalSections - // Cached rendered logo string. - logo string - // Cached cwd string. - cwd string + // 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)) - // TODO: lsp, files, session + // 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 - // Whether to render the sidebar in compact mode. - compact bool -} + if remainingHeight > 0 { + extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs) + maxLSPs += extraForLSPs + remainingHeight -= extraForLSPs -// NewSidebarModel creates a new SidebarModel instance. -func NewSidebarModel(com *common.Common) *SidebarModel { - return &SidebarModel{ - com: com, - compact: true, - cwd: com.Config().WorkingDir(), + if remainingHeight > 0 { + maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs) + } + } } -} -// Init initializes the sidebar model. -func (m *SidebarModel) Init() tea.Cmd { - return nil + return maxFiles, maxLSPs, maxMCPs } -// Update updates the sidebar model based on incoming messages. -func (m *SidebarModel) Update(msg tea.Msg) (*SidebarModel, tea.Cmd) { - return m, nil -} +// sidebar renders the chat sidebar containing session title, working +// directory, model info, file list, LSP status, and MCP status. +func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { + const logoHeightBreakpoint = 30 -// View renders the sidebar model as a string. -func (m *SidebarModel) View() string { - s := m.com.Styles.SidebarFull - if m.compact { - s = m.com.Styles.SidebarCompact - } + t := m.com.Styles + width := area.Dx() + height := area.Dy() + title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title) + cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + sidebarLogo := m.sidebarLogo + if height < logoHeightBreakpoint { + sidebarLogo = logo.SmallRender(width) + } blocks := []string{ - m.logo, + sidebarLogo, + title, + "", + cwd, + "", + m.modelInfo(width), + "", } - return s.Render(lipgloss.JoinVertical( - lipgloss.Top, + sidebarHeader := lipgloss.JoinVertical( + lipgloss.Left, blocks..., - )) -} + ) + + _, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader))) + remainingHeight := remainingHeightArea.Dy() - 10 + maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight) + + lspSection := m.lspInfo(width, maxLSPs, true) + mcpSection := m.mcpInfo(width, maxMCPs, true) + filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles) -// SetWidth sets the width of the sidebar and updates the logo accordingly. -func (m *SidebarModel) SetWidth(width int) { - m.logo = renderLogo(m.com.Styles, true, width) - m.width = width + uv.NewStyledString( + lipgloss.NewStyle(). + MaxWidth(width). + MaxHeight(height). + Render( + lipgloss.JoinVertical( + lipgloss.Left, + sidebarHeader, + filesSection, + "", + lspSection, + "", + mcpSection, + ), + ), + ).Draw(scr, area) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cfa699b87f7c8d38d07410ff06b45e7958a3ac07..d81c02ee0d8dc7ca233476d5a789c2b25f9f0305 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1,6 +1,7 @@ package model import ( + "context" "image" "math/rand" "os" @@ -15,6 +16,7 @@ import ( "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" @@ -48,10 +50,19 @@ const ( uiChatCompact ) +type sessionLoadedMsg struct { + sess session.Session +} + +type sessionFilesLoadedMsg struct { + files []SessionFile +} + // UI represents the main user interface model. type UI struct { - com *common.Common - sess *session.Session + com *common.Common + session *session.Session + sessionFiles []SessionFile // The width and height of the terminal in cells. width int @@ -65,7 +76,6 @@ type UI struct { keyenh tea.KeyboardEnhancementsMsg chat *ChatModel - side *SidebarModel dialog *dialog.Overlay help help.Model @@ -98,6 +108,9 @@ type UI struct { // mcp mcpStates map[string]mcp.ClientInfo + + // sidebarLogo keeps a cached version of the sidebar sidebarLogo. + sidebarLogo string } // New creates a new instance of the [UI] model. @@ -114,7 +127,6 @@ func New(com *common.Common) *UI { com: com, dialog: dialog.NewOverlay(), keyMap: DefaultKeyMap(), - side: NewSidebarModel(com), help: help.New(), focus: uiFocusNone, state: uiConfigure, @@ -161,6 +173,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } + case sessionLoadedMsg: + m.state = uiChat + m.session = &msg.sess + case sessionFilesLoadedMsg: + m.sessionFiles = msg.files + case pubsub.Event[history.File]: + cmds = append(cmds, m.handleFileEvent(msg.Payload)) case pubsub.Event[app.LSPEvent]: m.lspStates = app.GetLSPStates() case pubsub.Event[mcp.Event]: @@ -203,6 +222,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *UI) loadSession(sessionID string) tea.Cmd { + return func() tea.Msg { + // TODO: handle error + session, _ := m.com.App.Sessions.Get(context.Background(), sessionID) + return sessionLoadedMsg{session} + } +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { if m.dialog.HasDialogs() { return m.updateDialogs(msg) @@ -249,6 +276,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { layout := generateLayout(m, area.Dx(), area.Dy()) + // Update cached layout and component sizes if needed. + if m.layout != layout { + m.layout = layout + m.updateSize() + } + // Clear the screen first screen.Clear(scr) @@ -283,13 +316,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { case uiChat: header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) - - side := uv.NewStyledString(m.side.View()) - side.Draw(scr, layout.sidebar) - + m.drawSidebar(scr, layout.sidebar) mainView := lipgloss.NewStyle().Width(layout.main.Dx()). Height(layout.main.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Chat Messages ") main := uv.NewStyledString(mainView) main.Draw(scr, layout.main) @@ -385,16 +414,16 @@ func (m *UI) ShortHelp() []key.Binding { binds = append(binds, k.Quit) default: // TODO: other states - if m.sess == nil { - // no session selected - binds = append(binds, - k.Commands, - k.Models, - k.Editor.Newline, - k.Quit, - k.Help, - ) - } + // if m.session == nil { + // no session selected + binds = append(binds, + k.Commands, + k.Models, + k.Editor.Newline, + k.Quit, + k.Help, + ) + // } // else { // we have a session // } @@ -436,7 +465,7 @@ func (m *UI) FullHelp() [][]key.Binding { k.Quit, }) default: - if m.sess == nil { + if m.session == nil { // no session selected binds = append(binds, []key.Binding{ @@ -535,8 +564,7 @@ func (m *UI) updateSize() { m.textarea.SetHeight(m.layout.editor.Dy()) case uiChat: - // TODO: set the width and heigh of the chat component - m.side.SetWidth(m.layout.sidebar.Dx()) + m.renderSidebarLogo(m.layout.sidebar.Dx()) m.textarea.SetWidth(m.layout.editor.Dx()) m.textarea.SetHeight(m.layout.editor.Dy()) @@ -548,7 +576,8 @@ func (m *UI) updateSize() { } } -// generateLayout generates a [layout] for the given rectangle. +// generateLayout calculates the layout rectangles for all UI components based +// on the current UI state and terminal dimensions. func generateLayout(m *UI, w, h int) layout { // The screen area we're working with area := image.Rect(0, 0, w, h) @@ -558,7 +587,7 @@ func generateLayout(m *UI, w, h int) layout { // The editor height editorHeight := 5 // The sidebar width - sidebarWidth := 40 + sidebarWidth := 30 // The header height // TODO: handle compact headerHeight := 4 @@ -635,6 +664,8 @@ func generateLayout(m *UI, w, h int) layout { // help mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) + // Add padding left + sideRect.Min.X += 1 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) layout.sidebar = sideRect layout.main = mainRect @@ -690,6 +721,8 @@ type layout struct { help uv.Rectangle } +// setEditorPrompt configures the textarea prompt function based on whether +// yolo mode is enabled. func (m *UI) setEditorPrompt() { if m.com.App.Permissions.SkipRequests() { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) @@ -698,6 +731,8 @@ func (m *UI) setEditorPrompt() { m.textarea.SetPromptFunc(4, m.normalPromptFunc) } +// normalPromptFunc returns the normal editor prompt style (" > " on first +// line, "::: " on subsequent lines). func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { t := m.com.Styles if info.LineNumber == 0 { @@ -709,6 +744,8 @@ func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { return t.EditorPromptNormalBlurred.Render() } +// yoloPromptFunc returns the yolo mode editor prompt style with warning icon +// and colored dots. func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { t := m.com.Styles if info.LineNumber == 0 { @@ -740,16 +777,26 @@ var workingPlaceholders = [...]string{ "Thinking...", } +// randomizePlaceholders selects random placeholder text for the textarea's +// ready and working states. func (m *UI) randomizePlaceholders() { m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] } +// renderHeader renders and caches the header logo at the specified width. func (m *UI) renderHeader(compact bool, width int) { // TODO: handle the compact case differently m.header = renderLogo(m.com.Styles, compact, width) } +// renderSidebarLogo renders and caches the sidebar logo at the specified +// width. +func (m *UI) renderSidebarLogo(width int) { + m.sidebarLogo = renderLogo(m.com.Styles, true, width) +} + +// renderLogo renders the Crush logo with the given styles and dimensions. func renderLogo(t *styles.Styles, compact bool, width int) string { return logo.Render(version.Version, compact, logo.Opts{ FieldColor: t.LogoFieldColor, @@ -757,6 +804,6 @@ func renderLogo(t *styles.Styles, compact bool, width int) string { TitleColorB: t.LogoTitleColorB, CharmColor: t.LogoCharmColor, VersionColor: t.LogoVersionColor, - Width: max(0, width-2), + Width: width, }) } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 0b005f0b83690b53ead7951c0a0979cf83c7a073..049652225920098622e0988c8d073d5e95527d50 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -31,6 +31,8 @@ const ( BorderThin string = "│" BorderThick string = "▌" + + SectionSeparator string = "─" ) const ( @@ -122,9 +124,11 @@ type Styles struct { LogoCharmColor color.Color LogoVersionColor color.Color - // Sidebar - SidebarFull lipgloss.Style - SidebarCompact lipgloss.Style + // Section Title + Section struct { + Title lipgloss.Style + Line lipgloss.Style + } // Initialize Initialize struct { @@ -140,6 +144,13 @@ type Styles struct { HintDiagnostic lipgloss.Style InfoDiagnostic lipgloss.Style } + + // Files + Files struct { + Path lipgloss.Style + Additions lipgloss.Style + Deletions lipgloss.Style + } } func DefaultStyles() Styles { @@ -575,9 +586,9 @@ func DefaultStyles() Styles { s.LogoCharmColor = secondary s.LogoVersionColor = primary - // Sidebar - s.SidebarFull = lipgloss.NewStyle().Padding(1, 1) - s.SidebarCompact = s.SidebarFull.PaddingTop(0) + // Section + s.Section.Title = s.Subtle + s.Section.Line = s.Base.Foreground(charmtone.Charcoal) // Initialize s.Initialize.Header = s.Base @@ -595,6 +606,10 @@ func DefaultStyles() Styles { s.LSP.WarningDiagnostic = s.Base.Foreground(warning) s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted) s.LSP.InfoDiagnostic = s.Base.Foreground(info) + + s.Files.Path = s.Muted + s.Files.Additions = s.Base.Foreground(greenDark) + s.Files.Deletions = s.Base.Foreground(redDark) return s }