Detailed changes
@@ -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
+}
@@ -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...)
+}
@@ -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)
@@ -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...)
}
@@ -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...)
}
@@ -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
@@ -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)
}
@@ -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,
})
}
@@ -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
}