on main help sign in
Merge pull request #571 from charmbracelet/status-icons
1504a92Kujtim Hoxha
CRUSH.md+2
@@ -4,6 +4,8 @@
4
4
5- **Build**: `go build .` or `go run .`
5- **Build**: `go build .` or `go run .`
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
7+- **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
8+ - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
7- **Lint**: `task lint-fix`
9- **Lint**: `task lint-fix`
8- **Format**: `task fmt` (gofumpt -w .)
10- **Format**: `task fmt` (gofumpt -w .)
9- **Dev**: `task dev` (runs with profiling enabled)
11- **Dev**: `task dev` (runs with profiling enabled)
internal/tui/components/chat/messages/messages.go−1
@@ -281,7 +281,6 @@ func (m *messageCmp) renderThinkingContent() string {
281 opts := core.StatusOpts{
281 opts := core.StatusOpts{
282 Title: "Thought for",
282 Title: "Thought for",
283 Description: duration.String(),
283 Description: duration.String(),
284− NoIcon: true,
285 }
284 }
286 return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
285 return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
287 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
286 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
internal/tui/components/core/core.go+14−25
@@ -82,41 +82,30 @@ func Title(title string, width int) string {
82}
82}
83
83
84type StatusOpts struct {
84type StatusOpts struct {
85− Icon string
85+ Icon string // if empty no icon will be shown
86− IconColor color.Color
87− NoIcon bool // If true, no icon will be displayed
88 Title string
86 Title string
89 TitleColor color.Color
87 TitleColor color.Color
90 Description string
88 Description string
91 DescriptionColor color.Color
89 DescriptionColor color.Color
92− ExtraContent string // Additional content to append after the description
90+ ExtraContent string // additional content to append after the description
93}
91}
94
92
95−func Status(ops StatusOpts, width int) string {
93+func Status(opts StatusOpts, width int) string {
96 t := styles.CurrentTheme()
94 t := styles.CurrentTheme()
97− icon := "●"
95+ icon := opts.Icon
98− iconColor := t.Success
99− if ops.Icon != "" {
100− icon = ops.Icon
101− } else if ops.NoIcon {
102− icon = ""
103− }
104− if ops.IconColor != nil {
105− iconColor = ops.IconColor
106− }
107− title := ops.Title
96+ title := opts.Title
108 titleColor := t.FgMuted
97 titleColor := t.FgMuted
109− if ops.TitleColor != nil {
98+ if opts.TitleColor != nil {
110− titleColor = ops.TitleColor
99+ titleColor = opts.TitleColor
111 }
100 }
112− description := ops.Description
101+ description := opts.Description
113 descriptionColor := t.FgSubtle
102 descriptionColor := t.FgSubtle
114− if ops.DescriptionColor != nil {
103+ if opts.DescriptionColor != nil {
115− descriptionColor = ops.DescriptionColor
104+ descriptionColor = opts.DescriptionColor
116 }
105 }
117 title = t.S().Base.Foreground(titleColor).Render(title)
106 title = t.S().Base.Foreground(titleColor).Render(title)
118 if description != "" {
107 if description != "" {
119− extraContentWidth := lipgloss.Width(ops.ExtraContent)
108+ extraContentWidth := lipgloss.Width(opts.ExtraContent)
120 if extraContentWidth > 0 {
109 if extraContentWidth > 0 {
121 extraContentWidth += 1
110 extraContentWidth += 1
122 }
111 }
@@ -126,11 +115,11 @@ func Status(ops StatusOpts, width int) string {
126
115
127 content := []string{}
116 content := []string{}
128 if icon != "" {
117 if icon != "" {
129− content = append(content, t.S().Base.Foreground(iconColor).Render(icon))
118+ content = append(content, icon)
130 }
119 }
131 content = append(content, title, description)
120 content = append(content, title, description)
132− if ops.ExtraContent != "" {
121+ if opts.ExtraContent != "" {
133− content = append(content, ops.ExtraContent)
122+ content = append(content, opts.ExtraContent)
134 }
123 }
135
124
136 return strings.Join(content, " ")
125 return strings.Join(content, " ")
internal/tui/components/core/status_test.go−3
@@ -37,7 +37,6 @@ func TestStatus(t *testing.T) {
37 {
37 {
38 name: "NoIcon",
38 name: "NoIcon",
39 opts: core.StatusOpts{
39 opts: core.StatusOpts{
40− NoIcon: true,
41 Title: "Info",
40 Title: "Info",
42 Description: "This status has no icon",
41 Description: "This status has no icon",
43 },
42 },
@@ -47,7 +46,6 @@ func TestStatus(t *testing.T) {
47 name: "WithColors",
46 name: "WithColors",
48 opts: core.StatusOpts{
47 opts: core.StatusOpts{
49 Icon: "⚠",
48 Icon: "⚠",
50− IconColor: color.RGBA{255, 165, 0, 255}, // Orange
51 Title: "Warning",
49 Title: "Warning",
52 TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
50 TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
53 Description: "This is a warning message",
51 Description: "This is a warning message",
@@ -102,7 +100,6 @@ func TestStatus(t *testing.T) {
102 name: "AllFieldsWithExtraContent",
100 name: "AllFieldsWithExtraContent",
103 opts: core.StatusOpts{
101 opts: core.StatusOpts{
104 Icon: "🚀",
102 Icon: "🚀",
105− IconColor: color.RGBA{0, 255, 0, 255}, // Green
106 Title: "Deployment",
103 Title: "Deployment",
107 TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
104 TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
108 Description: "Deploying to production environment",
105 Description: "Deploying to production environment",
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden+1−1
@@ -1 +1 @@
1−🚀 Deployment Deploying to production environment v1.2.3
1+🚀 Deployment Deploying to production environment v1.2.3
internal/tui/components/core/testdata/TestStatus/Default.golden+1−1
@@ -1 +1 @@
1−● Status Everything is working fine
1+Status Everything is working fine
internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden+1−1
@@ -1 +1 @@
1−● Title Only 
1+● Title Only 
internal/tui/components/core/testdata/TestStatus/LongDescription.golden+1−1
@@ -1 +1 @@
1−● Processing This is a very long description that should be…
1+Processing This is a very long description that should be …
internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden+1−1
@@ -1 +1 @@
1−● Status Short message
1+● Status Short message
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden+1−1
@@ -1 +1 @@
1−● Test This will be…
1+● Test This will be…
internal/tui/components/core/testdata/TestStatus/WithColors.golden+1−1
@@ -1 +1 @@
1−⚠ Warning This is a warning message
1+⚠ Warning This is a warning message
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden+1−1
@@ -1 +1 @@
1−✓ Success Operation completed successfully
1+✓ Success Operation completed successfully
internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden+1−1
@@ -1 +1 @@
1−● Build Building project [2/5]
1+Build Building project [2/5]
internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden+1−1
@@ -1 +1 @@
1−● Very Long Title  [extra]
1+● Very Long Title  [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden+1−1
@@ -1 +1 @@
1−● Very Long Title Thi… [extra]
1+● Very Long Title Thi… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden+1−1
@@ -1 +1 @@
1−● Very Long Title This is an ex… [extra]
1+● Very Long Title This is an ex… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden+1−1
@@ -1 +1 @@
1−● Very Long Title This is an extremely lo… [extra]
1+● Very Long Title This is an extremely lo… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden+1−1
@@ -1 +1 @@
1−● Very Long Title This is an extremely long descrip… [extra]
1+● Very Long Title This is an extremely long descrip… [extra]
internal/tui/components/files/files.go−2
@@ -98,8 +98,6 @@ func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
98 fileList = append(fileList,
98 fileList = append(fileList,
99 core.Status(
99 core.Status(
100 core.StatusOpts{
100 core.StatusOpts{
101− IconColor: t.FgMuted,
102− NoIcon: true,
103 Title: filePath,
101 Title: filePath,
104 ExtraContent: extraContent,
102 ExtraContent: extraContent,
105 },
103 },
internal/tui/components/lsp/lsp.go+5−6
@@ -57,22 +57,21 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri
57 }
57 }
58
58
59 // Determine icon color and description based on state
59 // Determine icon color and description based on state
60− iconColor := t.FgMuted
60+ icon := t.ItemOfflineIcon
61 description := l.LSP.Command
61 description := l.LSP.Command
62
62
63 if l.LSP.Disabled {
63 if l.LSP.Disabled {
64− iconColor = t.FgMuted
65 description = t.S().Subtle.Render("disabled")
64 description = t.S().Subtle.Render("disabled")
66 } else if state, exists := lspStates[l.Name]; exists {
65 } else if state, exists := lspStates[l.Name]; exists {
67 switch state.State {
66 switch state.State {
68 case lsp.StateStarting:
67 case lsp.StateStarting:
69− iconColor = t.Yellow
68+ icon = t.ItemBusyIcon
70 description = t.S().Subtle.Render("starting...")
69 description = t.S().Subtle.Render("starting...")
71 case lsp.StateReady:
70 case lsp.StateReady:
72− iconColor = t.Success
71+ icon = t.ItemOnlineIcon
73 description = l.LSP.Command
72 description = l.LSP.Command
74 case lsp.StateError:
73 case lsp.StateError:
75− iconColor = t.Red
74+ icon = t.ItemErrorIcon
76 if state.Error != nil {
75 if state.Error != nil {
77 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
76 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
78 } else {
77 } else {
@@ -119,7 +118,7 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri
119 lspList = append(lspList,
118 lspList = append(lspList,
120 core.Status(
119 core.Status(
121 core.StatusOpts{
120 core.StatusOpts{
122− IconColor: iconColor,
121+ Icon: icon.String(),
123 Title: l.Name,
122 Title: l.Name,
124 Description: description,
123 Description: description,
125 ExtraContent: extraContent,
124 ExtraContent: extraContent,
internal/tui/components/mcp/mcp.go+5−7
@@ -54,25 +54,24 @@ func RenderMCPList(opts RenderOptions) []string {
54 }
54 }
55
55
56 // Determine icon and color based on state
56 // Determine icon and color based on state
57− iconColor := t.FgMuted
57+ icon := t.ItemOfflineIcon
58 description := l.MCP.Command
58 description := l.MCP.Command
59 extraContent := ""
59 extraContent := ""
60
60
61 if state, exists := mcpStates[l.Name]; exists {
61 if state, exists := mcpStates[l.Name]; exists {
62 switch state.State {
62 switch state.State {
63 case agent.MCPStateDisabled:
63 case agent.MCPStateDisabled:
64− iconColor = t.FgMuted
65 description = t.S().Subtle.Render("disabled")
64 description = t.S().Subtle.Render("disabled")
66 case agent.MCPStateStarting:
65 case agent.MCPStateStarting:
67− iconColor = t.Yellow
66+ icon = t.ItemBusyIcon
68 description = t.S().Subtle.Render("starting...")
67 description = t.S().Subtle.Render("starting...")
69 case agent.MCPStateConnected:
68 case agent.MCPStateConnected:
70− iconColor = t.Success
69+ icon = t.ItemOnlineIcon
71 if state.ToolCount > 0 {
70 if state.ToolCount > 0 {
72 extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
71 extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
73 }
72 }
74 case agent.MCPStateError:
73 case agent.MCPStateError:
75− iconColor = t.Red
74+ icon = t.ItemErrorIcon
76 if state.Error != nil {
75 if state.Error != nil {
77 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
76 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
78 } else {
77 } else {
@@ -80,14 +79,13 @@ func RenderMCPList(opts RenderOptions) []string {
80 }
79 }
81 }
80 }
82 } else if l.MCP.Disabled {
81 } else if l.MCP.Disabled {
83− iconColor = t.FgMuted
84 description = t.S().Subtle.Render("disabled")
82 description = t.S().Subtle.Render("disabled")
85 }
83 }
86
84
87 mcpList = append(mcpList,
85 mcpList = append(mcpList,
88 core.Status(
86 core.Status(
89 core.StatusOpts{
87 core.StatusOpts{
90− IconColor: iconColor,
88+ Icon: icon.String(),
91 Title: l.Name,
89 Title: l.Name,
92 Description: description,
90 Description: description,
93 ExtraContent: extraContent,
91 ExtraContent: extraContent,
internal/tui/styles/crush.go+10−1
@@ -1,11 +1,12 @@
1package styles
1package styles
2
2
3import (
3import (
4+ "github.com/charmbracelet/lipgloss/v2"
4 "github.com/charmbracelet/x/exp/charmtone"
5 "github.com/charmbracelet/x/exp/charmtone"
5)
6)
6
7
7func NewCrushTheme() *Theme {
8func NewCrushTheme() *Theme {
8− return &Theme{
9+ t := &Theme{
9 Name: "crush",
10 Name: "crush",
10 IsDark: true,
11 IsDark: true,
11
12
@@ -54,4 +55,12 @@ func NewCrushTheme() *Theme {
54 RedLight: charmtone.Salmon,
55 RedLight: charmtone.Salmon,
55 Cherry: charmtone.Cherry,
56 Cherry: charmtone.Cherry,
56 }
57 }
58+
59+ // LSP and MCP status.
60+ t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
61+ t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
62+ t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
63+ t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
64+
65+ return t
57}
66}
internal/tui/styles/theme.go+6
@@ -74,6 +74,12 @@ type Theme struct {
74 RedLight color.Color
74 RedLight color.Color
75 Cherry color.Color
75 Cherry color.Color
76
76
77+ // LSP and MCP status indicators.
78+ ItemOfflineIcon lipgloss.Style
79+ ItemBusyIcon lipgloss.Style
80+ ItemErrorIcon lipgloss.Style
81+ ItemOnlineIcon lipgloss.Style
82+
77 styles *Styles
83 styles *Styles
78}
84}
79
85