on main help sign in
chore: add chat sidebar (#1510)
commit26edcdc405cea27dac32208f060a36d565930c0a authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 26 Nov 2025, 16:02 (1 year ago) parentdb5c3eb
9 files changed+502−123
internal/ui/common/elements.go+26−1 internal/ui/model/files.go+211 internal/ui/model/landing.go+7−28 internal/ui/model/lsp.go+16−7 internal/ui/model/mcp.go+18−13 internal/ui/model/onboarding.go+5 internal/ui/model/sidebar.go+126−43 internal/ui/model/ui.go+72−25 internal/ui/styles/styles.go+21−6
internal/ui/common/elements.go+26−1
@@ -12,17 +12,22 @@ import (
1212 "github.com/charmbracelet/x/ansi"
1313)
1414
15+// PrettyPath formats a file path with home directory shortening and applies
16+// muted styling.
1517func PrettyPath(t *styles.Styles, path string, width int) string {
1618 formatted := home.Short(path)
1719 return t.Muted.Width(width).Render(formatted)
1820}
1921
22+// ModelContextInfo contains token usage and cost information for a model.
2023type ModelContextInfo struct {
2124 ContextUsed int64
2225 ModelContext int64
2326 Cost float64
2427}
2528
29+// ModelInfo renders model information including name, reasoning settings, and
30+// optional context usage/cost.
2631func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string {
2732 modelIcon := t.Subtle.Render(styles.ModelIcon)
2833 modelName = t.Base.Render(modelName)
@@ -37,7 +42,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context
3742 }
3843
3944 if context != nil {
40− parts = append(parts, formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost))
45+ formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)
46+ parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
4147 }
4248
4349 return lipgloss.NewStyle().Width(width).Render(
@@ -45,6 +51,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context
4551 )
4652}
4753
54+// formatTokensAndCost formats token usage and cost with appropriate units
55+// (K/M) and percentage of context window.
4856func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
4957 var formattedTokens string
5058 switch {
@@ -77,6 +85,8 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
7785 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
7886}
7987
88+// StatusOpts defines options for rendering a status line with icon, title,
89+// description, and optional extra content.
8090type StatusOpts struct {
8191 Icon string // if empty no icon will be shown
8292 Title string
@@ -86,6 +96,8 @@ type StatusOpts struct {
8696 ExtraContent string // additional content to append after the description
8797}
8898
99+// Status renders a status line with icon, title, description, and extra
100+// content. The description is truncated if it exceeds the available width.
89101func Status(t *styles.Styles, opts StatusOpts, width int) string {
90102 icon := opts.Icon
91103 title := opts.Title
@@ -119,3 +131,16 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string {
119131
120132 return strings.Join(content, " ")
121133}
134+
135+// Section renders a section header with a title and a horizontal line filling
136+// the remaining width.
137+func Section(t *styles.Styles, text string, width int) string {
138+ char := styles.SectionSeparator
139+ length := lipgloss.Width(text) + 1
140+ remainingWidth := width - length
141+ text = t.Section.Title.Render(text)
142+ if remainingWidth > 0 {
143+ text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth))
144+ }
145+ return text
146+}
internal/ui/model/files.gonew file+211
@@ -0,0 +1,211 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "slices"
8+ "strings"
9+
10+ tea "charm.land/bubbletea/v2"
11+ "charm.land/lipgloss/v2"
12+ "github.com/charmbracelet/crush/internal/diff"
13+ "github.com/charmbracelet/crush/internal/fsext"
14+ "github.com/charmbracelet/crush/internal/history"
15+ "github.com/charmbracelet/crush/internal/ui/common"
16+ "github.com/charmbracelet/crush/internal/ui/styles"
17+ "github.com/charmbracelet/x/ansi"
18+)
19+
20+// SessionFile tracks the first and latest versions of a file in a session,
21+// along with the total additions and deletions.
22+type SessionFile struct {
23+ FirstVersion history.File
24+ LatestVersion history.File
25+ Additions int
26+ Deletions int
27+}
28+
29+// loadSessionFiles loads all files modified during a session and calculates
30+// their diff statistics.
31+func (m *UI) loadSessionFiles(sessionID string) tea.Cmd {
32+ return func() tea.Msg {
33+ files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
34+ if err != nil {
35+ return err
36+ }
37+ filesByPath := make(map[string][]history.File)
38+ for _, f := range files {
39+ filesByPath[f.Path] = append(filesByPath[f.Path], f)
40+ }
41+
42+ sessionFiles := make([]SessionFile, 0, len(filesByPath))
43+ for _, versions := range filesByPath {
44+ if len(versions) == 0 {
45+ continue
46+ }
47+
48+ first := versions[0]
49+ last := versions[0]
50+ for _, v := range versions {
51+ if v.Version < first.Version {
52+ first = v
53+ }
54+ if v.Version > last.Version {
55+ last = v
56+ }
57+ }
58+
59+ _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
60+
61+ sessionFiles = append(sessionFiles, SessionFile{
62+ FirstVersion: first,
63+ LatestVersion: last,
64+ Additions: additions,
65+ Deletions: deletions,
66+ })
67+ }
68+
69+ slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
70+ if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
71+ return -1
72+ }
73+ if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
74+ return 1
75+ }
76+ return 0
77+ })
78+
79+ return sessionFilesLoadedMsg{
80+ files: sessionFiles,
81+ }
82+ }
83+}
84+
85+// handleFileEvent processes file change events and updates the session file
86+// list with new or updated file information.
87+func (m *UI) handleFileEvent(file history.File) tea.Cmd {
88+ if m.session == nil || file.SessionID != m.session.ID {
89+ return nil
90+ }
91+
92+ return func() tea.Msg {
93+ existingIdx := -1
94+ for i, sf := range m.sessionFiles {
95+ if sf.FirstVersion.Path == file.Path {
96+ existingIdx = i
97+ break
98+ }
99+ }
100+
101+ if existingIdx == -1 {
102+ newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1)
103+ newFiles = append(newFiles, SessionFile{
104+ FirstVersion: file,
105+ LatestVersion: file,
106+ Additions: 0,
107+ Deletions: 0,
108+ })
109+ newFiles = append(newFiles, m.sessionFiles...)
110+
111+ return sessionFilesLoadedMsg{files: newFiles}
112+ }
113+
114+ updated := m.sessionFiles[existingIdx]
115+
116+ if file.Version < updated.FirstVersion.Version {
117+ updated.FirstVersion = file
118+ }
119+
120+ if file.Version > updated.LatestVersion.Version {
121+ updated.LatestVersion = file
122+ }
123+
124+ _, additions, deletions := diff.GenerateDiff(
125+ updated.FirstVersion.Content,
126+ updated.LatestVersion.Content,
127+ updated.FirstVersion.Path,
128+ )
129+ updated.Additions = additions
130+ updated.Deletions = deletions
131+
132+ newFiles := make([]SessionFile, 0, len(m.sessionFiles))
133+ newFiles = append(newFiles, updated)
134+ for i, sf := range m.sessionFiles {
135+ if i != existingIdx {
136+ newFiles = append(newFiles, sf)
137+ }
138+ }
139+
140+ return sessionFilesLoadedMsg{files: newFiles}
141+ }
142+}
143+
144+// filesInfo renders the modified files section for the sidebar, showing files
145+// with their addition/deletion counts.
146+func (m *UI) filesInfo(cwd string, width, maxItems int) string {
147+ t := m.com.Styles
148+ title := common.Section(t, "Modified Files", width)
149+ list := t.Subtle.Render("None")
150+
151+ if len(m.sessionFiles) > 0 {
152+ list = fileList(t, cwd, m.sessionFiles, width, maxItems)
153+ }
154+
155+ return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
156+}
157+
158+// fileList renders a list of files with their diff statistics, truncating to
159+// maxItems and showing a "...and N more" message if needed.
160+func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
161+ var renderedFiles []string
162+ filesShown := 0
163+
164+ var filesWithChanges []SessionFile
165+ for _, f := range files {
166+ if f.Additions == 0 && f.Deletions == 0 {
167+ continue
168+ }
169+ filesWithChanges = append(filesWithChanges, f)
170+ }
171+
172+ for _, f := range filesWithChanges {
173+ // Skip files with no changes
174+ if filesShown >= maxItems {
175+ break
176+ }
177+
178+ // Build stats string with colors
179+ var statusParts []string
180+ if f.Additions > 0 {
181+ statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions)))
182+ }
183+ if f.Deletions > 0 {
184+ statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions)))
185+ }
186+ extraContent := strings.Join(statusParts, " ")
187+
188+ // Format file path
189+ filePath := f.FirstVersion.Path
190+ if rel, err := filepath.Rel(cwd, filePath); err == nil {
191+ filePath = rel
192+ }
193+ filePath = fsext.DirTrim(filePath, 2)
194+ filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "…")
195+
196+ line := t.Files.Path.Render(filePath)
197+ if extraContent != "" {
198+ line = fmt.Sprintf("%s %s", line, extraContent)
199+ }
200+
201+ renderedFiles = append(renderedFiles, line)
202+ filesShown++
203+ }
204+
205+ if len(filesWithChanges) > maxItems {
206+ remaining := len(filesWithChanges) - maxItems
207+ renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
208+ }
209+
210+ return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
211+}
internal/ui/model/landing.go+7−28
@@ -1,18 +1,14 @@
11package model
22
33import (
4− "cmp"
5− "fmt"
6−
74 "charm.land/lipgloss/v2"
8− "github.com/charmbracelet/catwalk/pkg/catwalk"
95 "github.com/charmbracelet/crush/internal/agent"
106 "github.com/charmbracelet/crush/internal/ui/common"
117 uv "github.com/charmbracelet/ultraviolet"
12− "golang.org/x/text/cases"
13− "golang.org/x/text/language"
148)
159
10+// selectedLargeModel returns the currently selected large language model from
11+// the agent coordinator, if one exists.
1612func (m *UI) selectedLargeModel() *agent.Model {
1713 if m.com.App.AgentCoordinator != nil {
1814 model := m.com.App.AgentCoordinator.Model()
@@ -21,6 +17,8 @@ func (m *UI) selectedLargeModel() *agent.Model {
2117 return nil
2218}
2319
20+// landingView renders the landing page view showing the current working
21+// directory, model information, and LSP/MCP status in a two-column layout.
2422func (m *UI) landingView() string {
2523 t := m.com.Styles
2624 width := m.layout.main.Dx()
@@ -30,34 +28,15 @@ func (m *UI) landingView() string {
3028 cwd,
3129 }
3230
33− model := m.selectedLargeModel()
34− if model != nil && model.CatwalkCfg.CanReason {
35− reasoningInfo := ""
36− providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
37− if ok {
38− switch providerConfig.Type {
39− case catwalk.TypeAnthropic:
40− if model.ModelCfg.Think {
41− reasoningInfo = "Thinking On"
42− } else {
43− reasoningInfo = "Thinking Off"
44− }
45− default:
46− formatter := cases.Title(language.English, cases.NoLower)
47− reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
48− reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
49− }
50− parts = append(parts, "", common.ModelInfo(t, model.CatwalkCfg.Name, reasoningInfo, nil, width))
51− }
52− }
31+ parts = append(parts, "", m.modelInfo(width))
5332 infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
5433
5534 _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1))
5635
5736 mcpLspSectionWidth := min(30, (width-1)/2)
5837
59− lspSection := m.lspInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy())
60− mcpSection := m.mcpInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy())
38+ lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
39+ mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
6140
6241 content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection)
6342
internal/ui/model/lsp.go+16−7
@@ -12,13 +12,17 @@ import (
1212 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
1313)
1414
15+// LSPInfo wraps LSP client information with diagnostic counts by severity.
1516type LSPInfo struct {
1617 app.LSPClientInfo
1718 Diagnostics map[protocol.DiagnosticSeverity]int
1819}
1920
20−func (m *UI) lspInfo(t *styles.Styles, width, height int) string {
21+// lspInfo renders the LSP status section showing active LSP clients and their
22+// diagnostic counts.
23+func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
2124 var lsps []LSPInfo
25+ t := m.com.Styles
2226
2327 for _, state := range m.lspStates {
2428 client, ok := m.com.App.LSPClients.Get(state.Name)
@@ -43,15 +47,18 @@ func (m *UI) lspInfo(t *styles.Styles, width, height int) string {
4347 lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
4448 }
4549 title := t.Subtle.Render("LSPs")
50+ if isSection {
51+ title = common.Section(t, title, width)
52+ }
4653 list := t.Subtle.Render("None")
4754 if len(lsps) > 0 {
48− height = max(0, height-2) // remove title and space
49− list = lspList(t, lsps, width, height)
55+ list = lspList(t, lsps, width, maxItems)
5056 }
5157
5258 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
5359}
5460
61+// lspDiagnostics formats diagnostic counts with appropriate icons and colors.
5562func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string {
5663 errs := []string{}
5764 if diagnostics[protocol.SeverityError] > 0 {
@@ -69,7 +76,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit
6976 return strings.Join(errs, " ")
7077}
7178
72−func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string {
79+// lspList renders a list of LSP clients with their status and diagnostics,
80+// truncating to maxItems if needed.
81+func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
7382 var renderedLsps []string
7483 for _, l := range lsps {
7584 var icon string
@@ -103,9 +112,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string {
103112 }, width))
104113 }
105114
106− if len(renderedLsps) > height {
107− visibleItems := renderedLsps[:height-1]
108− remaining := len(renderedLsps) - (height - 1)
115+ if len(renderedLsps) > maxItems {
116+ visibleItems := renderedLsps[:maxItems-1]
117+ remaining := len(renderedLsps) - maxItems
109118 visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
110119 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
111120 }
internal/ui/model/mcp.go+18−13
@@ -10,27 +10,29 @@ import (
1010 "github.com/charmbracelet/crush/internal/ui/styles"
1111)
1212
13−type MCPInfo struct {
14− mcp.ClientInfo
15−}
16−
17−func (m *UI) mcpInfo(t *styles.Styles, width, height int) string {
18− var mcps []MCPInfo
13+// mcpInfo renders the MCP status section showing active MCP clients and their
14+// tool/prompt counts.
15+func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
16+ var mcps []mcp.ClientInfo
17+ t := m.com.Styles
1918
2019 for _, state := range m.mcpStates {
21− mcps = append(mcps, MCPInfo{ClientInfo: state})
20+ mcps = append(mcps, state)
2221 }
2322
2423 title := t.Subtle.Render("MCPs")
24+ if isSection {
25+ title = common.Section(t, title, width)
26+ }
2527 list := t.Subtle.Render("None")
2628 if len(mcps) > 0 {
27− height = max(0, height-2) // remove title and space
28− list = mcpList(t, mcps, width, height)
29+ list = mcpList(t, mcps, width, maxItems)
2930 }
3031
3132 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
3233}
3334
35+// mcpCounts formats tool and prompt counts for display.
3436func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
3537 parts := []string{}
3638 if counts.Tools > 0 {
@@ -42,8 +44,11 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
4244 return strings.Join(parts, " ")
4345}
4446
45−func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string {
47+// mcpList renders a list of MCP clients with their status and counts,
48+// truncating to maxItems if needed.
49+func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
4650 var renderedMcps []string
51+
4752 for _, m := range mcps {
4853 var icon string
4954 title := m.Name
@@ -78,9 +83,9 @@ func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string {
7883 }, width))
7984 }
8085
81− if len(renderedMcps) > height {
82− visibleItems := renderedMcps[:height-1]
83− remaining := len(renderedMcps) - (height - 1)
86+ if len(renderedMcps) > maxItems {
87+ visibleItems := renderedMcps[:maxItems-1]
88+ remaining := len(renderedMcps) - maxItems
8489 visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
8590 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
8691 }
internal/ui/model/onboarding.go+5
@@ -13,6 +13,7 @@ import (
1313 "github.com/charmbracelet/crush/internal/ui/common"
1414)
1515
16+// markProjectInitialized marks the current project as initialized in the config.
1617func (m *UI) markProjectInitialized() tea.Msg {
1718 // TODO: handle error so we show it in the tui footer
1819 err := config.MarkProjectInitialized()
@@ -22,6 +23,7 @@ func (m *UI) markProjectInitialized() tea.Msg {
2223 return nil
2324}
2425
26+// updateInitializeView handles keyboard input for the project initialization prompt.
2527func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
2628 switch {
2729 case key.Matches(msg, m.keyMap.Initialize.Enter):
@@ -40,6 +42,7 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
4042 return cmds
4143}
4244
45+// initializeProject starts project initialization and transitions to the landing view.
4346func (m *UI) initializeProject() tea.Cmd {
4447 // TODO: initialize the project
4548 // for now we just go to the landing page
@@ -49,6 +52,7 @@ func (m *UI) initializeProject() tea.Cmd {
4952 return m.markProjectInitialized
5053}
5154
55+// skipInitializeProject skips project initialization and transitions to the landing view.
5256func (m *UI) skipInitializeProject() tea.Cmd {
5357 // TODO: initialize the project
5458 m.state = uiLanding
@@ -57,6 +61,7 @@ func (m *UI) skipInitializeProject() tea.Cmd {
5761 return m.markProjectInitialized
5862}
5963
64+// initializeView renders the project initialization prompt with Yes/No buttons.
6065func (m *UI) initializeView() string {
6166 cfg := m.com.Config()
6267 s := m.com.Styles.Initialize
internal/ui/model/sidebar.go+126−43
@@ -1,67 +1,150 @@
11package model
22
33import (
4− tea "charm.land/bubbletea/v2"
4+ "cmp"
5+ "fmt"
6+
57 "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
69 "github.com/charmbracelet/crush/internal/ui/common"
10+ "github.com/charmbracelet/crush/internal/ui/logo"
11+ uv "github.com/charmbracelet/ultraviolet"
12+ "golang.org/x/text/cases"
13+ "golang.org/x/text/language"
714)
815
9−// SidebarModel is the model for the sidebar UI component.
10−type SidebarModel struct {
11− com *common.Common
16+// modelInfo renders the current model information including reasoning
17+// settings and context usage/cost for the sidebar.
18+func (m *UI) modelInfo(width int) string {
19+ model := m.selectedLargeModel()
20+ reasoningInfo := ""
21+ if model != nil && model.CatwalkCfg.CanReason {
22+ providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
23+ if ok {
24+ switch providerConfig.Type {
25+ case catwalk.TypeAnthropic:
26+ if model.ModelCfg.Think {
27+ reasoningInfo = "Thinking On"
28+ } else {
29+ reasoningInfo = "Thinking Off"
30+ }
31+ default:
32+ formatter := cases.Title(language.English, cases.NoLower)
33+ reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
34+ reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
35+ }
36+ }
37+ }
38+ var modelContext *common.ModelContextInfo
39+ if m.session != nil {
40+ modelContext = &common.ModelContextInfo{
41+ ContextUsed: m.session.CompletionTokens + m.session.PromptTokens,
42+ Cost: m.session.Cost,
43+ ModelContext: model.CatwalkCfg.ContextWindow,
44+ }
45+ }
46+ return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, reasoningInfo, modelContext, width)
47+}
48+
49+// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
50+// some items are more important than others.
51+func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) {
52+ const (
53+ minItemsPerSection = 2
54+ defaultMaxFilesShown = 10
55+ defaultMaxLSPsShown = 8
56+ defaultMaxMCPsShown = 8
57+ minAvailableHeightLimit = 10
58+ )
59+
60+ // If we have very little space, use minimum values
61+ if availableHeight < minAvailableHeightLimit {
62+ return minItemsPerSection, minItemsPerSection, minItemsPerSection
63+ }
1264
13− // width of the sidebar.
14− width int
65+ // Distribute available height among the three sections
66+ // Give priority to files, then LSPs, then MCPs
67+ totalSections := 3
68+ heightPerSection := availableHeight / totalSections
1569
16− // Cached rendered logo string.
17− logo string
18− // Cached cwd string.
19− cwd string
70+ // Calculate limits for each section, ensuring minimums
71+ maxFiles = max(minItemsPerSection, min(defaultMaxFilesShown, heightPerSection))
72+ maxLSPs = max(minItemsPerSection, min(defaultMaxLSPsShown, heightPerSection))
73+ maxMCPs = max(minItemsPerSection, min(defaultMaxMCPsShown, heightPerSection))
2074
21− // TODO: lsp, files, session
75+ // If we have extra space, give it to files first
76+ remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
77+ if remainingHeight > 0 {
78+ extraForFiles := min(remainingHeight, defaultMaxFilesShown-maxFiles)
79+ maxFiles += extraForFiles
80+ remainingHeight -= extraForFiles
2281
23− // Whether to render the sidebar in compact mode.
24− compact bool
25−}
82+ if remainingHeight > 0 {
83+ extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs)
84+ maxLSPs += extraForLSPs
85+ remainingHeight -= extraForLSPs
2686
27−// NewSidebarModel creates a new SidebarModel instance.
28−func NewSidebarModel(com *common.Common) *SidebarModel {
29− return &SidebarModel{
30− com: com,
31− compact: true,
32− cwd: com.Config().WorkingDir(),
87+ if remainingHeight > 0 {
88+ maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs)
89+ }
90+ }
3391 }
34−}
3592
36−// Init initializes the sidebar model.
37−func (m *SidebarModel) Init() tea.Cmd {
38− return nil
93+ return maxFiles, maxLSPs, maxMCPs
3994}
4095
41−// Update updates the sidebar model based on incoming messages.
42−func (m *SidebarModel) Update(msg tea.Msg) (*SidebarModel, tea.Cmd) {
43− return m, nil
44−}
96+// sidebar renders the chat sidebar containing session title, working
97+// directory, model info, file list, LSP status, and MCP status.
98+func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
99+ const logoHeightBreakpoint = 30
45100
46−// View renders the sidebar model as a string.
47−func (m *SidebarModel) View() string {
48− s := m.com.Styles.SidebarFull
49− if m.compact {
50− s = m.com.Styles.SidebarCompact
51− }
101+ t := m.com.Styles
102+ width := area.Dx()
103+ height := area.Dy()
52104
105+ title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
106+ cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
107+ sidebarLogo := m.sidebarLogo
108+ if height < logoHeightBreakpoint {
109+ sidebarLogo = logo.SmallRender(width)
110+ }
53111 blocks := []string{
54− m.logo,
112+ sidebarLogo,
113+ title,
114+ "",
115+ cwd,
116+ "",
117+ m.modelInfo(width),
118+ "",
55119 }
56120
57− return s.Render(lipgloss.JoinVertical(
58− lipgloss.Top,
121+ sidebarHeader := lipgloss.JoinVertical(
122+ lipgloss.Left,
59123 blocks...,
60− ))
61−}
124+ )
125+
126+ _, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader)))
127+ remainingHeight := remainingHeightArea.Dy() - 10
128+ maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight)
129+
130+ lspSection := m.lspInfo(width, maxLSPs, true)
131+ mcpSection := m.mcpInfo(width, maxMCPs, true)
132+ filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles)
62133
63−// SetWidth sets the width of the sidebar and updates the logo accordingly.
64−func (m *SidebarModel) SetWidth(width int) {
65− m.logo = renderLogo(m.com.Styles, true, width)
66− m.width = width
134+ uv.NewStyledString(
135+ lipgloss.NewStyle().
136+ MaxWidth(width).
137+ MaxHeight(height).
138+ Render(
139+ lipgloss.JoinVertical(
140+ lipgloss.Left,
141+ sidebarHeader,
142+ filesSection,
143+ "",
144+ lspSection,
145+ "",
146+ mcpSection,
147+ ),
148+ ),
149+ ).Draw(scr, area)
67150}
internal/ui/model/ui.go+72−25
@@ -1,6 +1,7 @@
11package model
22
33import (
4+ "context"
45 "image"
56 "math/rand"
67 "os"
@@ -15,6 +16,7 @@ import (
1516 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1617 "github.com/charmbracelet/crush/internal/app"
1718 "github.com/charmbracelet/crush/internal/config"
19+ "github.com/charmbracelet/crush/internal/history"
1820 "github.com/charmbracelet/crush/internal/pubsub"
1921 "github.com/charmbracelet/crush/internal/session"
2022 "github.com/charmbracelet/crush/internal/ui/common"
@@ -48,10 +50,19 @@ const (
4850 uiChatCompact
4951)
5052
53+type sessionLoadedMsg struct {
54+ sess session.Session
55+}
56+
57+type sessionFilesLoadedMsg struct {
58+ files []SessionFile
59+}
60+
5161// UI represents the main user interface model.
5262type UI struct {
53− com *common.Common
54− sess *session.Session
63+ com *common.Common
64+ session *session.Session
65+ sessionFiles []SessionFile
5566
5667 // The width and height of the terminal in cells.
5768 width int
@@ -65,7 +76,6 @@ type UI struct {
6576 keyenh tea.KeyboardEnhancementsMsg
6677
6778 chat *ChatModel
68− side *SidebarModel
6979 dialog *dialog.Overlay
7080 help help.Model
7181
@@ -98,6 +108,9 @@ type UI struct {
98108
99109 // mcp
100110 mcpStates map[string]mcp.ClientInfo
111+
112+ // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
113+ sidebarLogo string
101114}
102115
103116// New creates a new instance of the [UI] model.
@@ -114,7 +127,6 @@ func New(com *common.Common) *UI {
114127 com: com,
115128 dialog: dialog.NewOverlay(),
116129 keyMap: DefaultKeyMap(),
117− side: NewSidebarModel(com),
118130 help: help.New(),
119131 focus: uiFocusNone,
120132 state: uiConfigure,
@@ -161,6 +173,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
161173 if !m.sendProgressBar {
162174 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
163175 }
176+ case sessionLoadedMsg:
177+ m.state = uiChat
178+ m.session = &msg.sess
179+ case sessionFilesLoadedMsg:
180+ m.sessionFiles = msg.files
181+ case pubsub.Event[history.File]:
182+ cmds = append(cmds, m.handleFileEvent(msg.Payload))
164183 case pubsub.Event[app.LSPEvent]:
165184 m.lspStates = app.GetLSPStates()
166185 case pubsub.Event[mcp.Event]:
@@ -203,6 +222,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203222 return m, tea.Batch(cmds...)
204223}
205224
225+func (m *UI) loadSession(sessionID string) tea.Cmd {
226+ return func() tea.Msg {
227+ // TODO: handle error
228+ session, _ := m.com.App.Sessions.Get(context.Background(), sessionID)
229+ return sessionLoadedMsg{session}
230+ }
231+}
232+
206233func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
207234 if m.dialog.HasDialogs() {
208235 return m.updateDialogs(msg)
@@ -249,6 +276,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
249276func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
250277 layout := generateLayout(m, area.Dx(), area.Dy())
251278
279+ // Update cached layout and component sizes if needed.
280+ if m.layout != layout {
281+ m.layout = layout
282+ m.updateSize()
283+ }
284+
252285 // Clear the screen first
253286 screen.Clear(scr)
254287
@@ -283,13 +316,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
283316 case uiChat:
284317 header := uv.NewStyledString(m.header)
285318 header.Draw(scr, layout.header)
286−
287− side := uv.NewStyledString(m.side.View())
288− side.Draw(scr, layout.sidebar)
289−
319+ m.drawSidebar(scr, layout.sidebar)
290320 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
291321 Height(layout.main.Dy()).
292− Background(lipgloss.ANSIColor(rand.Intn(256))).
293322 Render(" Chat Messages ")
294323 main := uv.NewStyledString(mainView)
295324 main.Draw(scr, layout.main)
@@ -385,16 +414,16 @@ func (m *UI) ShortHelp() []key.Binding {
385414 binds = append(binds, k.Quit)
386415 default:
387416 // TODO: other states
388− if m.sess == nil {
389− // no session selected
390− binds = append(binds,
391− k.Commands,
392− k.Models,
393− k.Editor.Newline,
394− k.Quit,
395− k.Help,
396− )
397− }
417+ // if m.session == nil {
418+ // no session selected
419+ binds = append(binds,
420+ k.Commands,
421+ k.Models,
422+ k.Editor.Newline,
423+ k.Quit,
424+ k.Help,
425+ )
426+ // }
398427 // else {
399428 // we have a session
400429 // }
@@ -436,7 +465,7 @@ func (m *UI) FullHelp() [][]key.Binding {
436465 k.Quit,
437466 })
438467 default:
439− if m.sess == nil {
468+ if m.session == nil {
440469 // no session selected
441470 binds = append(binds,
442471 []key.Binding{
@@ -535,8 +564,7 @@ func (m *UI) updateSize() {
535564 m.textarea.SetHeight(m.layout.editor.Dy())
536565
537566 case uiChat:
538− // TODO: set the width and heigh of the chat component
539− m.side.SetWidth(m.layout.sidebar.Dx())
567+ m.renderSidebarLogo(m.layout.sidebar.Dx())
540568 m.textarea.SetWidth(m.layout.editor.Dx())
541569 m.textarea.SetHeight(m.layout.editor.Dy())
542570
@@ -548,7 +576,8 @@ func (m *UI) updateSize() {
548576 }
549577}
550578
551−// generateLayout generates a [layout] for the given rectangle.
579+// generateLayout calculates the layout rectangles for all UI components based
580+// on the current UI state and terminal dimensions.
552581func generateLayout(m *UI, w, h int) layout {
553582 // The screen area we're working with
554583 area := image.Rect(0, 0, w, h)
@@ -558,7 +587,7 @@ func generateLayout(m *UI, w, h int) layout {
558587 // The editor height
559588 editorHeight := 5
560589 // The sidebar width
561− sidebarWidth := 40
590+ sidebarWidth := 30
562591 // The header height
563592 // TODO: handle compact
564593 headerHeight := 4
@@ -635,6 +664,8 @@ func generateLayout(m *UI, w, h int) layout {
635664 // help
636665
637666 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
667+ // Add padding left
668+ sideRect.Min.X += 1
638669 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
639670 layout.sidebar = sideRect
640671 layout.main = mainRect
@@ -690,6 +721,8 @@ type layout struct {
690721 help uv.Rectangle
691722}
692723
724+// setEditorPrompt configures the textarea prompt function based on whether
725+// yolo mode is enabled.
693726func (m *UI) setEditorPrompt() {
694727 if m.com.App.Permissions.SkipRequests() {
695728 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
@@ -698,6 +731,8 @@ func (m *UI) setEditorPrompt() {
698731 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
699732}
700733
734+// normalPromptFunc returns the normal editor prompt style (" > " on first
735+// line, "::: " on subsequent lines).
701736func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
702737 t := m.com.Styles
703738 if info.LineNumber == 0 {
@@ -709,6 +744,8 @@ func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
709744 return t.EditorPromptNormalBlurred.Render()
710745}
711746
747+// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
748+// and colored dots.
712749func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
713750 t := m.com.Styles
714751 if info.LineNumber == 0 {
@@ -740,16 +777,26 @@ var workingPlaceholders = [...]string{
740777 "Thinking...",
741778}
742779
780+// randomizePlaceholders selects random placeholder text for the textarea's
781+// ready and working states.
743782func (m *UI) randomizePlaceholders() {
744783 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
745784 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
746785}
747786
787+// renderHeader renders and caches the header logo at the specified width.
748788func (m *UI) renderHeader(compact bool, width int) {
749789 // TODO: handle the compact case differently
750790 m.header = renderLogo(m.com.Styles, compact, width)
751791}
752792
793+// renderSidebarLogo renders and caches the sidebar logo at the specified
794+// width.
795+func (m *UI) renderSidebarLogo(width int) {
796+ m.sidebarLogo = renderLogo(m.com.Styles, true, width)
797+}
798+
799+// renderLogo renders the Crush logo with the given styles and dimensions.
753800func renderLogo(t *styles.Styles, compact bool, width int) string {
754801 return logo.Render(version.Version, compact, logo.Opts{
755802 FieldColor: t.LogoFieldColor,
@@ -757,6 +804,6 @@ func renderLogo(t *styles.Styles, compact bool, width int) string {
757804 TitleColorB: t.LogoTitleColorB,
758805 CharmColor: t.LogoCharmColor,
759806 VersionColor: t.LogoVersionColor,
760− Width: max(0, width-2),
807+ Width: width,
761808 })
762809}
internal/ui/styles/styles.go+21−6
@@ -31,6 +31,8 @@ const (
3131
3232 BorderThin string = "│"
3333 BorderThick string = "▌"
34+
35+ SectionSeparator string = "─"
3436)
3537
3638const (
@@ -122,9 +124,11 @@ type Styles struct {
122124 LogoCharmColor color.Color
123125 LogoVersionColor color.Color
124126
125− // Sidebar
126− SidebarFull lipgloss.Style
127− SidebarCompact lipgloss.Style
127+ // Section Title
128+ Section struct {
129+ Title lipgloss.Style
130+ Line lipgloss.Style
131+ }
128132
129133 // Initialize
130134 Initialize struct {
@@ -140,6 +144,13 @@ type Styles struct {
140144 HintDiagnostic lipgloss.Style
141145 InfoDiagnostic lipgloss.Style
142146 }
147+
148+ // Files
149+ Files struct {
150+ Path lipgloss.Style
151+ Additions lipgloss.Style
152+ Deletions lipgloss.Style
153+ }
143154}
144155
145156func DefaultStyles() Styles {
@@ -575,9 +586,9 @@ func DefaultStyles() Styles {
575586 s.LogoCharmColor = secondary
576587 s.LogoVersionColor = primary
577588
578− // Sidebar
579− s.SidebarFull = lipgloss.NewStyle().Padding(1, 1)
580− s.SidebarCompact = s.SidebarFull.PaddingTop(0)
589+ // Section
590+ s.Section.Title = s.Subtle
591+ s.Section.Line = s.Base.Foreground(charmtone.Charcoal)
581592
582593 // Initialize
583594 s.Initialize.Header = s.Base
@@ -595,6 +606,10 @@ func DefaultStyles() Styles {
595606 s.LSP.WarningDiagnostic = s.Base.Foreground(warning)
596607 s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
597608 s.LSP.InfoDiagnostic = s.Base.Foreground(info)
609+
610+ s.Files.Path = s.Muted
611+ s.Files.Additions = s.Base.Foreground(greenDark)
612+ s.Files.Deletions = s.Base.Foreground(redDark)
598613 return s
599614}
600615