on main help sign in
chore: add chat sidebar (#1510)
26edcdcKujtim Hoxha
internal/ui/common/elements.go+26−1
@@ -12,17 +12,22 @@ import (
12 "github.com/charmbracelet/x/ansi"
12 "github.com/charmbracelet/x/ansi"
13)
13)
14
14
15+// PrettyPath formats a file path with home directory shortening and applies
16+// muted styling.
15func PrettyPath(t *styles.Styles, path string, width int) string {
17func PrettyPath(t *styles.Styles, path string, width int) string {
16 formatted := home.Short(path)
18 formatted := home.Short(path)
17 return t.Muted.Width(width).Render(formatted)
19 return t.Muted.Width(width).Render(formatted)
18}
20}
19
21
22+// ModelContextInfo contains token usage and cost information for a model.
20type ModelContextInfo struct {
23type ModelContextInfo struct {
21 ContextUsed int64
24 ContextUsed int64
22 ModelContext int64
25 ModelContext int64
23 Cost float64
26 Cost float64
24}
27}
25
28
29+// ModelInfo renders model information including name, reasoning settings, and
30+// optional context usage/cost.
26func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string {
31func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string {
27 modelIcon := t.Subtle.Render(styles.ModelIcon)
32 modelIcon := t.Subtle.Render(styles.ModelIcon)
28 modelName = t.Base.Render(modelName)
33 modelName = t.Base.Render(modelName)
@@ -37,7 +42,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context
37 }
42 }
38
43
39 if context != nil {
44 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))
41 }
47 }
42
48
43 return lipgloss.NewStyle().Width(width).Render(
49 return lipgloss.NewStyle().Width(width).Render(
@@ -45,6 +51,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context
45 )
51 )
46}
52}
47
53
54+// formatTokensAndCost formats token usage and cost with appropriate units
55+// (K/M) and percentage of context window.
48func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
56func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
49 var formattedTokens string
57 var formattedTokens string
50 switch {
58 switch {
@@ -77,6 +85,8 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
77 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
85 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
78}
86}
79
87
88+// StatusOpts defines options for rendering a status line with icon, title,
89+// description, and optional extra content.
80type StatusOpts struct {
90type StatusOpts struct {
81 Icon string // if empty no icon will be shown
91 Icon string // if empty no icon will be shown
82 Title string
92 Title string
@@ -86,6 +96,8 @@ type StatusOpts struct {
86 ExtraContent string // additional content to append after the description
96 ExtraContent string // additional content to append after the description
87}
97}
88
98
99+// Status renders a status line with icon, title, description, and extra
100+// content. The description is truncated if it exceeds the available width.
89func Status(t *styles.Styles, opts StatusOpts, width int) string {
101func Status(t *styles.Styles, opts StatusOpts, width int) string {
90 icon := opts.Icon
102 icon := opts.Icon
91 title := opts.Title
103 title := opts.Title
@@ -119,3 +131,16 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string {
119
131
120 return strings.Join(content, " ")
132 return strings.Join(content, " ")
121}
133}
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 @@
1package model
1package model
2
2
3import (
3import (
4− "cmp"
5− "fmt"
6−
7 "charm.land/lipgloss/v2"
4 "charm.land/lipgloss/v2"
8− "github.com/charmbracelet/catwalk/pkg/catwalk"
9 "github.com/charmbracelet/crush/internal/agent"
5 "github.com/charmbracelet/crush/internal/agent"
10 "github.com/charmbracelet/crush/internal/ui/common"
6 "github.com/charmbracelet/crush/internal/ui/common"
11 uv "github.com/charmbracelet/ultraviolet"
7 uv "github.com/charmbracelet/ultraviolet"
12− "golang.org/x/text/cases"
13− "golang.org/x/text/language"
14)
8)
15
9
10+// selectedLargeModel returns the currently selected large language model from
11+// the agent coordinator, if one exists.
16func (m *UI) selectedLargeModel() *agent.Model {
12func (m *UI) selectedLargeModel() *agent.Model {
17 if m.com.App.AgentCoordinator != nil {
13 if m.com.App.AgentCoordinator != nil {
18 model := m.com.App.AgentCoordinator.Model()
14 model := m.com.App.AgentCoordinator.Model()
@@ -21,6 +17,8 @@ func (m *UI) selectedLargeModel() *agent.Model {
21 return nil
17 return nil
22}
18}
23
19
20+// landingView renders the landing page view showing the current working
21+// directory, model information, and LSP/MCP status in a two-column layout.
24func (m *UI) landingView() string {
22func (m *UI) landingView() string {
25 t := m.com.Styles
23 t := m.com.Styles
26 width := m.layout.main.Dx()
24 width := m.layout.main.Dx()
@@ -30,34 +28,15 @@ func (m *UI) landingView() string {
30 cwd,
28 cwd,
31 }
29 }
32
30
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))
31+ parts = append(parts, "", m.modelInfo(width))
51− }
52− }
53 infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
32 infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
54
33
55 _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1))
34 _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1))
56
35
57 mcpLspSectionWidth := min(30, (width-1)/2)
36 mcpLspSectionWidth := min(30, (width-1)/2)
58
37
59− lspSection := m.lspInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy())
38+ lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
60− mcpSection := m.mcpInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy())
39+ mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
61
40
62 content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection)
41 content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection)
63
42
internal/ui/model/lsp.go+16−7
@@ -12,13 +12,17 @@ import (
12 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
12 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
13)
13)
14
14
15+// LSPInfo wraps LSP client information with diagnostic counts by severity.
15type LSPInfo struct {
16type LSPInfo struct {
16 app.LSPClientInfo
17 app.LSPClientInfo
17 Diagnostics map[protocol.DiagnosticSeverity]int
18 Diagnostics map[protocol.DiagnosticSeverity]int
18}
19}
19
20
21+// lspInfo renders the LSP status section showing active LSP clients and their
22+// diagnostic counts.
20−func (m *UI) lspInfo(t *styles.Styles, width, height int) string {
23+func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
21 var lsps []LSPInfo
24 var lsps []LSPInfo
25+ t := m.com.Styles
22
26
23 for _, state := range m.lspStates {
27 for _, state := range m.lspStates {
24 client, ok := m.com.App.LSPClients.Get(state.Name)
28 client, ok := m.com.App.LSPClients.Get(state.Name)
@@ -43,15 +47,18 @@ func (m *UI) lspInfo(t *styles.Styles, width, height int) string {
43 lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
47 lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
44 }
48 }
45 title := t.Subtle.Render("LSPs")
49 title := t.Subtle.Render("LSPs")
50+ if isSection {
51+ title = common.Section(t, title, width)
52+ }
46 list := t.Subtle.Render("None")
53 list := t.Subtle.Render("None")
47 if len(lsps) > 0 {
54 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)
50 }
56 }
51
57
52 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
58 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
53}
59}
54
60
61+// lspDiagnostics formats diagnostic counts with appropriate icons and colors.
55func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string {
62func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string {
56 errs := []string{}
63 errs := []string{}
57 if diagnostics[protocol.SeverityError] > 0 {
64 if diagnostics[protocol.SeverityError] > 0 {
@@ -69,7 +76,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit
69 return strings.Join(errs, " ")
76 return strings.Join(errs, " ")
70}
77}
71
78
79+// lspList renders a list of LSP clients with their status and diagnostics,
80+// truncating to maxItems if needed.
72−func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string {
81+func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
73 var renderedLsps []string
82 var renderedLsps []string
74 for _, l := range lsps {
83 for _, l := range lsps {
75 var icon string
84 var icon string
@@ -103,9 +112,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string {
103 }, width))
112 }, width))
104 }
113 }
105
114
106− if len(renderedLsps) > height {
115+ if len(renderedLsps) > maxItems {
107− visibleItems := renderedLsps[:height-1]
116+ visibleItems := renderedLsps[:maxItems-1]
108− remaining := len(renderedLsps) - (height - 1)
117+ remaining := len(renderedLsps) - maxItems
109 visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
118 visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
110 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
119 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
111 }
120 }
internal/ui/model/mcp.go+18−13
@@ -10,27 +10,29 @@ import (
10 "github.com/charmbracelet/crush/internal/ui/styles"
10 "github.com/charmbracelet/crush/internal/ui/styles"
11)
11)
12
12
13−type MCPInfo struct {
13+// mcpInfo renders the MCP status section showing active MCP clients and their
14− mcp.ClientInfo
14+// tool/prompt counts.
15−}
16−
17−func (m *UI) mcpInfo(t *styles.Styles, width, height int) string {
15+func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
18− var mcps []MCPInfo
16+ var mcps []mcp.ClientInfo
17+ t := m.com.Styles
19
18
20 for _, state := range m.mcpStates {
19 for _, state := range m.mcpStates {
21− mcps = append(mcps, MCPInfo{ClientInfo: state})
20+ mcps = append(mcps, state)
22 }
21 }
23
22
24 title := t.Subtle.Render("MCPs")
23 title := t.Subtle.Render("MCPs")
24+ if isSection {
25+ title = common.Section(t, title, width)
26+ }
25 list := t.Subtle.Render("None")
27 list := t.Subtle.Render("None")
26 if len(mcps) > 0 {
28 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)
29 }
30 }
30
31
31 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
32 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
32}
33}
33
34
35+// mcpCounts formats tool and prompt counts for display.
34func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
36func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
35 parts := []string{}
37 parts := []string{}
36 if counts.Tools > 0 {
38 if counts.Tools > 0 {
@@ -42,8 +44,11 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
42 return strings.Join(parts, " ")
44 return strings.Join(parts, " ")
43}
45}
44
46
47+// mcpList renders a list of MCP clients with their status and counts,
48+// truncating to maxItems if needed.
45−func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string {
49+func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
46 var renderedMcps []string
50 var renderedMcps []string
51+
47 for _, m := range mcps {
52 for _, m := range mcps {
48 var icon string
53 var icon string
49 title := m.Name
54 title := m.Name
@@ -78,9 +83,9 @@ func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string {
78 }, width))
83 }, width))
79 }
84 }
80
85
81− if len(renderedMcps) > height {
86+ if len(renderedMcps) > maxItems {
82− visibleItems := renderedMcps[:height-1]
87+ visibleItems := renderedMcps[:maxItems-1]
83− remaining := len(renderedMcps) - (height - 1)
88+ remaining := len(renderedMcps) - maxItems
84 visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
89 visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
85 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
90 return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
86 }
91 }
internal/ui/model/onboarding.go+5
@@ -13,6 +13,7 @@ import (
13 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/common"
14)
14)
15
15
16+// markProjectInitialized marks the current project as initialized in the config.
16func (m *UI) markProjectInitialized() tea.Msg {
17func (m *UI) markProjectInitialized() tea.Msg {
17 // TODO: handle error so we show it in the tui footer
18 // TODO: handle error so we show it in the tui footer
18 err := config.MarkProjectInitialized()
19 err := config.MarkProjectInitialized()
@@ -22,6 +23,7 @@ func (m *UI) markProjectInitialized() tea.Msg {
22 return nil
23 return nil
23}
24}
24
25
26+// updateInitializeView handles keyboard input for the project initialization prompt.
25func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
27func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
26 switch {
28 switch {
27 case key.Matches(msg, m.keyMap.Initialize.Enter):
29 case key.Matches(msg, m.keyMap.Initialize.Enter):
@@ -40,6 +42,7 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
40 return cmds
42 return cmds
41}
43}
42
44
45+// initializeProject starts project initialization and transitions to the landing view.
43func (m *UI) initializeProject() tea.Cmd {
46func (m *UI) initializeProject() tea.Cmd {
44 // TODO: initialize the project
47 // TODO: initialize the project
45 // for now we just go to the landing page
48 // for now we just go to the landing page
@@ -49,6 +52,7 @@ func (m *UI) initializeProject() tea.Cmd {
49 return m.markProjectInitialized
52 return m.markProjectInitialized
50}
53}
51
54
55+// skipInitializeProject skips project initialization and transitions to the landing view.
52func (m *UI) skipInitializeProject() tea.Cmd {
56func (m *UI) skipInitializeProject() tea.Cmd {
53 // TODO: initialize the project
57 // TODO: initialize the project
54 m.state = uiLanding
58 m.state = uiLanding
@@ -57,6 +61,7 @@ func (m *UI) skipInitializeProject() tea.Cmd {
57 return m.markProjectInitialized
61 return m.markProjectInitialized
58}
62}
59
63
64+// initializeView renders the project initialization prompt with Yes/No buttons.
60func (m *UI) initializeView() string {
65func (m *UI) initializeView() string {
61 cfg := m.com.Config()
66 cfg := m.com.Config()
62 s := m.com.Styles.Initialize
67 s := m.com.Styles.Initialize
internal/ui/model/sidebar.go+126−43
@@ -1,67 +1,150 @@
1package model
1package model
2
2
3import (
3import (
4− tea "charm.land/bubbletea/v2"
4+ "cmp"
5+ "fmt"
6+
5 "charm.land/lipgloss/v2"
7 "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
6 "github.com/charmbracelet/crush/internal/ui/common"
9 "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"
7)
14)
8
15
9−// SidebarModel is the model for the sidebar UI component.
16+// modelInfo renders the current model information including reasoning
10−type SidebarModel struct {
17+// settings and context usage/cost for the sidebar.
11− com *common.Common
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+ }
12
64
13− // width of the sidebar.
65+ // Distribute available height among the three sections
14− width int
66+ // Give priority to files, then LSPs, then MCPs
67+ totalSections := 3
68+ heightPerSection := availableHeight / totalSections
15
69
16− // Cached rendered logo string.
70+ // Calculate limits for each section, ensuring minimums
17− logo string
71+ maxFiles = max(minItemsPerSection, min(defaultMaxFilesShown, heightPerSection))
18− // Cached cwd string.
72+ maxLSPs = max(minItemsPerSection, min(defaultMaxLSPsShown, heightPerSection))
19− cwd string
73+ maxMCPs = max(minItemsPerSection, min(defaultMaxMCPsShown, heightPerSection))
20
74
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
22
81
23− // Whether to render the sidebar in compact mode.
82+ if remainingHeight > 0 {
24− compact bool
83+ extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs)
25−}
84+ maxLSPs += extraForLSPs
85+ remainingHeight -= extraForLSPs
26
86
27−// NewSidebarModel creates a new SidebarModel instance.
87+ if remainingHeight > 0 {
28−func NewSidebarModel(com *common.Common) *SidebarModel {
88+ maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs)
29− return &SidebarModel{
89+ }
30− com: com,
90+ }
31− compact: true,
32− cwd: com.Config().WorkingDir(),
33 }
91 }
34−}
35
92
36−// Init initializes the sidebar model.
93+ return maxFiles, maxLSPs, maxMCPs
37−func (m *SidebarModel) Init() tea.Cmd {
38− return nil
39}
94}
40
95
41−// Update updates the sidebar model based on incoming messages.
96+// sidebar renders the chat sidebar containing session title, working
42−func (m *SidebarModel) Update(msg tea.Msg) (*SidebarModel, tea.Cmd) {
97+// directory, model info, file list, LSP status, and MCP status.
43− return m, nil
98+func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
44−}
99+ const logoHeightBreakpoint = 30
45
100
46−// View renders the sidebar model as a string.
47−func (m *SidebarModel) View() string {
48− s := m.com.Styles.SidebarFull
101+ t := m.com.Styles
49− if m.compact {
102+ width := area.Dx()
50− s = m.com.Styles.SidebarCompact
103+ height := area.Dy()
51− }
52
104
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+ }
53 blocks := []string{
111 blocks := []string{
54− m.logo,
112+ sidebarLogo,
113+ title,
114+ "",
115+ cwd,
116+ "",
117+ m.modelInfo(width),
118+ "",
55 }
119 }
56
120
57− return s.Render(lipgloss.JoinVertical(
121+ sidebarHeader := lipgloss.JoinVertical(
58− lipgloss.Top,
122+ lipgloss.Left,
59 blocks...,
123 blocks...,
60− ))
124+ )
61−}
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)
62
133
63−// SetWidth sets the width of the sidebar and updates the logo accordingly.
134+ uv.NewStyledString(
64−func (m *SidebarModel) SetWidth(width int) {
135+ lipgloss.NewStyle().
65− m.logo = renderLogo(m.com.Styles, true, width)
136+ MaxWidth(width).
66− m.width = 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)
67}
150}
internal/ui/model/ui.go+72−25
@@ -1,6 +1,7 @@
1package model
1package model
2
2
3import (
3import (
4+ "context"
4 "image"
5 "image"
5 "math/rand"
6 "math/rand"
6 "os"
7 "os"
@@ -15,6 +16,7 @@ import (
15 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
16 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
16 "github.com/charmbracelet/crush/internal/app"
17 "github.com/charmbracelet/crush/internal/app"
17 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/config"
19+ "github.com/charmbracelet/crush/internal/history"
18 "github.com/charmbracelet/crush/internal/pubsub"
20 "github.com/charmbracelet/crush/internal/pubsub"
19 "github.com/charmbracelet/crush/internal/session"
21 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/ui/common"
22 "github.com/charmbracelet/crush/internal/ui/common"
@@ -48,10 +50,19 @@ const (
48 uiChatCompact
50 uiChatCompact
49)
51)
50
52
53+type sessionLoadedMsg struct {
54+ sess session.Session
55+}
56+
57+type sessionFilesLoadedMsg struct {
58+ files []SessionFile
59+}
60+
51// UI represents the main user interface model.
61// UI represents the main user interface model.
52type UI struct {
62type UI struct {
53− com *common.Common
63+ com *common.Common
54− sess *session.Session
64+ session *session.Session
65+ sessionFiles []SessionFile
55
66
56 // The width and height of the terminal in cells.
67 // The width and height of the terminal in cells.
57 width int
68 width int
@@ -65,7 +76,6 @@ type UI struct {
65 keyenh tea.KeyboardEnhancementsMsg
76 keyenh tea.KeyboardEnhancementsMsg
66
77
67 chat *ChatModel
78 chat *ChatModel
68− side *SidebarModel
69 dialog *dialog.Overlay
79 dialog *dialog.Overlay
70 help help.Model
80 help help.Model
71
81
@@ -98,6 +108,9 @@ type UI struct {
98
108
99 // mcp
109 // mcp
100 mcpStates map[string]mcp.ClientInfo
110 mcpStates map[string]mcp.ClientInfo
111+
112+ // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
113+ sidebarLogo string
101}
114}
102
115
103// New creates a new instance of the [UI] model.
116// New creates a new instance of the [UI] model.
@@ -114,7 +127,6 @@ func New(com *common.Common) *UI {
114 com: com,
127 com: com,
115 dialog: dialog.NewOverlay(),
128 dialog: dialog.NewOverlay(),
116 keyMap: DefaultKeyMap(),
129 keyMap: DefaultKeyMap(),
117− side: NewSidebarModel(com),
118 help: help.New(),
130 help: help.New(),
119 focus: uiFocusNone,
131 focus: uiFocusNone,
120 state: uiConfigure,
132 state: uiConfigure,
@@ -161,6 +173,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
161 if !m.sendProgressBar {
173 if !m.sendProgressBar {
162 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
174 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
163 }
175 }
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))
164 case pubsub.Event[app.LSPEvent]:
183 case pubsub.Event[app.LSPEvent]:
165 m.lspStates = app.GetLSPStates()
184 m.lspStates = app.GetLSPStates()
166 case pubsub.Event[mcp.Event]:
185 case pubsub.Event[mcp.Event]:
@@ -203,6 +222,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203 return m, tea.Batch(cmds...)
222 return m, tea.Batch(cmds...)
204}
223}
205
224
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+
206func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
233func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
207 if m.dialog.HasDialogs() {
234 if m.dialog.HasDialogs() {
208 return m.updateDialogs(msg)
235 return m.updateDialogs(msg)
@@ -249,6 +276,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
249func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
276func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
250 layout := generateLayout(m, area.Dx(), area.Dy())
277 layout := generateLayout(m, area.Dx(), area.Dy())
251
278
279+ // Update cached layout and component sizes if needed.
280+ if m.layout != layout {
281+ m.layout = layout
282+ m.updateSize()
283+ }
284+
252 // Clear the screen first
285 // Clear the screen first
253 screen.Clear(scr)
286 screen.Clear(scr)
254
287
@@ -283,13 +316,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
283 case uiChat:
316 case uiChat:
284 header := uv.NewStyledString(m.header)
317 header := uv.NewStyledString(m.header)
285 header.Draw(scr, layout.header)
318 header.Draw(scr, layout.header)
286−
287− side := uv.NewStyledString(m.side.View())
288− side.Draw(scr, layout.sidebar)
319+ m.drawSidebar(scr, layout.sidebar)
289−
290 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
320 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
291 Height(layout.main.Dy()).
321 Height(layout.main.Dy()).
292− Background(lipgloss.ANSIColor(rand.Intn(256))).
293 Render(" Chat Messages ")
322 Render(" Chat Messages ")
294 main := uv.NewStyledString(mainView)
323 main := uv.NewStyledString(mainView)
295 main.Draw(scr, layout.main)
324 main.Draw(scr, layout.main)
@@ -385,16 +414,16 @@ func (m *UI) ShortHelp() []key.Binding {
385 binds = append(binds, k.Quit)
414 binds = append(binds, k.Quit)
386 default:
415 default:
387 // TODO: other states
416 // TODO: other states
388− if m.sess == nil {
417+ // if m.session == nil {
389− // no session selected
418+ // no session selected
390− binds = append(binds,
419+ binds = append(binds,
391− k.Commands,
420+ k.Commands,
392− k.Models,
421+ k.Models,
393− k.Editor.Newline,
422+ k.Editor.Newline,
394− k.Quit,
423+ k.Quit,
395− k.Help,
424+ k.Help,
396− )
425+ )
397− }
426+ // }
398 // else {
427 // else {
399 // we have a session
428 // we have a session
400 // }
429 // }
@@ -436,7 +465,7 @@ func (m *UI) FullHelp() [][]key.Binding {
436 k.Quit,
465 k.Quit,
437 })
466 })
438 default:
467 default:
439− if m.sess == nil {
468+ if m.session == nil {
440 // no session selected
469 // no session selected
441 binds = append(binds,
470 binds = append(binds,
442 []key.Binding{
471 []key.Binding{
@@ -535,8 +564,7 @@ func (m *UI) updateSize() {
535 m.textarea.SetHeight(m.layout.editor.Dy())
564 m.textarea.SetHeight(m.layout.editor.Dy())
536
565
537 case uiChat:
566 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())
540 m.textarea.SetWidth(m.layout.editor.Dx())
568 m.textarea.SetWidth(m.layout.editor.Dx())
541 m.textarea.SetHeight(m.layout.editor.Dy())
569 m.textarea.SetHeight(m.layout.editor.Dy())
542
570
@@ -548,7 +576,8 @@ func (m *UI) updateSize() {
548 }
576 }
549}
577}
550
578
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.
552func generateLayout(m *UI, w, h int) layout {
581func generateLayout(m *UI, w, h int) layout {
553 // The screen area we're working with
582 // The screen area we're working with
554 area := image.Rect(0, 0, w, h)
583 area := image.Rect(0, 0, w, h)
@@ -558,7 +587,7 @@ func generateLayout(m *UI, w, h int) layout {
558 // The editor height
587 // The editor height
559 editorHeight := 5
588 editorHeight := 5
560 // The sidebar width
589 // The sidebar width
561− sidebarWidth := 40
590+ sidebarWidth := 30
562 // The header height
591 // The header height
563 // TODO: handle compact
592 // TODO: handle compact
564 headerHeight := 4
593 headerHeight := 4
@@ -635,6 +664,8 @@ func generateLayout(m *UI, w, h int) layout {
635 // help
664 // help
636
665
637 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
666 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
667+ // Add padding left
668+ sideRect.Min.X += 1
638 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
669 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
639 layout.sidebar = sideRect
670 layout.sidebar = sideRect
640 layout.main = mainRect
671 layout.main = mainRect
@@ -690,6 +721,8 @@ type layout struct {
690 help uv.Rectangle
721 help uv.Rectangle
691}
722}
692
723
724+// setEditorPrompt configures the textarea prompt function based on whether
725+// yolo mode is enabled.
693func (m *UI) setEditorPrompt() {
726func (m *UI) setEditorPrompt() {
694 if m.com.App.Permissions.SkipRequests() {
727 if m.com.App.Permissions.SkipRequests() {
695 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
728 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
@@ -698,6 +731,8 @@ func (m *UI) setEditorPrompt() {
698 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
731 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
699}
732}
700
733
734+// normalPromptFunc returns the normal editor prompt style (" > " on first
735+// line, "::: " on subsequent lines).
701func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
736func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
702 t := m.com.Styles
737 t := m.com.Styles
703 if info.LineNumber == 0 {
738 if info.LineNumber == 0 {
@@ -709,6 +744,8 @@ func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
709 return t.EditorPromptNormalBlurred.Render()
744 return t.EditorPromptNormalBlurred.Render()
710}
745}
711
746
747+// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
748+// and colored dots.
712func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
749func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
713 t := m.com.Styles
750 t := m.com.Styles
714 if info.LineNumber == 0 {
751 if info.LineNumber == 0 {
@@ -740,16 +777,26 @@ var workingPlaceholders = [...]string{
740 "Thinking...",
777 "Thinking...",
741}
778}
742
779
780+// randomizePlaceholders selects random placeholder text for the textarea's
781+// ready and working states.
743func (m *UI) randomizePlaceholders() {
782func (m *UI) randomizePlaceholders() {
744 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
783 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
745 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
784 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
746}
785}
747
786
787+// renderHeader renders and caches the header logo at the specified width.
748func (m *UI) renderHeader(compact bool, width int) {
788func (m *UI) renderHeader(compact bool, width int) {
749 // TODO: handle the compact case differently
789 // TODO: handle the compact case differently
750 m.header = renderLogo(m.com.Styles, compact, width)
790 m.header = renderLogo(m.com.Styles, compact, width)
751}
791}
752
792
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.
753func renderLogo(t *styles.Styles, compact bool, width int) string {
800func renderLogo(t *styles.Styles, compact bool, width int) string {
754 return logo.Render(version.Version, compact, logo.Opts{
801 return logo.Render(version.Version, compact, logo.Opts{
755 FieldColor: t.LogoFieldColor,
802 FieldColor: t.LogoFieldColor,
@@ -757,6 +804,6 @@ func renderLogo(t *styles.Styles, compact bool, width int) string {
757 TitleColorB: t.LogoTitleColorB,
804 TitleColorB: t.LogoTitleColorB,
758 CharmColor: t.LogoCharmColor,
805 CharmColor: t.LogoCharmColor,
759 VersionColor: t.LogoVersionColor,
806 VersionColor: t.LogoVersionColor,
760− Width: max(0, width-2),
807+ Width: width,
761 })
808 })
762}
809}
internal/ui/styles/styles.go+21−6
@@ -31,6 +31,8 @@ const (
31
31
32 BorderThin string = "│"
32 BorderThin string = "│"
33 BorderThick string = "▌"
33 BorderThick string = "▌"
34+
35+ SectionSeparator string = "─"
34)
36)
35
37
36const (
38const (
@@ -122,9 +124,11 @@ type Styles struct {
122 LogoCharmColor color.Color
124 LogoCharmColor color.Color
123 LogoVersionColor color.Color
125 LogoVersionColor color.Color
124
126
125− // Sidebar
127+ // Section Title
128+ Section struct {
126− SidebarFull lipgloss.Style
129+ Title lipgloss.Style
127− SidebarCompact lipgloss.Style
130+ Line lipgloss.Style
131+ }
128
132
129 // Initialize
133 // Initialize
130 Initialize struct {
134 Initialize struct {
@@ -140,6 +144,13 @@ type Styles struct {
140 HintDiagnostic lipgloss.Style
144 HintDiagnostic lipgloss.Style
141 InfoDiagnostic lipgloss.Style
145 InfoDiagnostic lipgloss.Style
142 }
146 }
147+
148+ // Files
149+ Files struct {
150+ Path lipgloss.Style
151+ Additions lipgloss.Style
152+ Deletions lipgloss.Style
153+ }
143}
154}
144
155
145func DefaultStyles() Styles {
156func DefaultStyles() Styles {
@@ -575,9 +586,9 @@ func DefaultStyles() Styles {
575 s.LogoCharmColor = secondary
586 s.LogoCharmColor = secondary
576 s.LogoVersionColor = primary
587 s.LogoVersionColor = primary
577
588
578− // Sidebar
589+ // Section
579− s.SidebarFull = lipgloss.NewStyle().Padding(1, 1)
590+ s.Section.Title = s.Subtle
580− s.SidebarCompact = s.SidebarFull.PaddingTop(0)
591+ s.Section.Line = s.Base.Foreground(charmtone.Charcoal)
581
592
582 // Initialize
593 // Initialize
583 s.Initialize.Header = s.Base
594 s.Initialize.Header = s.Base
@@ -595,6 +606,10 @@ func DefaultStyles() Styles {
595 s.LSP.WarningDiagnostic = s.Base.Foreground(warning)
606 s.LSP.WarningDiagnostic = s.Base.Foreground(warning)
596 s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
607 s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
597 s.LSP.InfoDiagnostic = s.Base.Foreground(info)
608 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)
598 return s
613 return s
599}
614}
600
615