Merge pull request #571 from charmbracelet/status-icons

Kujtim Hoxha created

chore: change LSP/MCP loading icon color + refactor icons + theme prep

Change summary

CRUSH.md                                                                          |  2 
internal/tui/components/chat/messages/messages.go                                 |  1 
internal/tui/components/core/core.go                                              | 39 
internal/tui/components/core/status_test.go                                       |  3 
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden |  2 
internal/tui/components/core/testdata/TestStatus/Default.golden                   |  2 
internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden          |  2 
internal/tui/components/core/testdata/TestStatus/LongDescription.golden           |  2 
internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden               |  2 
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden           |  2 
internal/tui/components/core/testdata/TestStatus/WithColors.golden                |  2 
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden            |  2 
internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden          |  2 
internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden         |  2 
internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden         |  2 
internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden         |  2 
internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden         |  2 
internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden         |  2 
internal/tui/components/files/files.go                                            |  2 
internal/tui/components/lsp/lsp.go                                                | 11 
internal/tui/components/mcp/mcp.go                                                | 12 
internal/tui/styles/crush.go                                                      | 11 
internal/tui/styles/theme.go                                                      |  6 
23 files changed, 56 insertions(+), 59 deletions(-)

Detailed changes

CRUSH.md πŸ”—

@@ -4,6 +4,8 @@
 
 - **Build**: `go build .` or `go run .`
 - **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
+- **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
+  - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
 - **Lint**: `task lint-fix`
 - **Format**: `task fmt` (gofumpt -w .)
 - **Dev**: `task dev` (runs with profiling enabled)

internal/tui/components/chat/messages/messages.go πŸ”—

@@ -281,7 +281,6 @@ func (m *messageCmp) renderThinkingContent() string {
 			opts := core.StatusOpts{
 				Title:       "Thought for",
 				Description: duration.String(),
-				NoIcon:      true,
 			}
 			return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
 		} else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {

internal/tui/components/core/core.go πŸ”—

@@ -82,41 +82,30 @@ func Title(title string, width int) string {
 }
 
 type StatusOpts struct {
-	Icon             string
-	IconColor        color.Color
-	NoIcon           bool // If true, no icon will be displayed
+	Icon             string // if empty no icon will be shown
 	Title            string
 	TitleColor       color.Color
 	Description      string
 	DescriptionColor color.Color
-	ExtraContent     string // Additional content to append after the description
+	ExtraContent     string // additional content to append after the description
 }
 
-func Status(ops StatusOpts, width int) string {
+func Status(opts StatusOpts, width int) string {
 	t := styles.CurrentTheme()
-	icon := "●"
-	iconColor := t.Success
-	if ops.Icon != "" {
-		icon = ops.Icon
-	} else if ops.NoIcon {
-		icon = ""
-	}
-	if ops.IconColor != nil {
-		iconColor = ops.IconColor
-	}
-	title := ops.Title
+	icon := opts.Icon
+	title := opts.Title
 	titleColor := t.FgMuted
-	if ops.TitleColor != nil {
-		titleColor = ops.TitleColor
+	if opts.TitleColor != nil {
+		titleColor = opts.TitleColor
 	}
-	description := ops.Description
+	description := opts.Description
 	descriptionColor := t.FgSubtle
-	if ops.DescriptionColor != nil {
-		descriptionColor = ops.DescriptionColor
+	if opts.DescriptionColor != nil {
+		descriptionColor = opts.DescriptionColor
 	}
 	title = t.S().Base.Foreground(titleColor).Render(title)
 	if description != "" {
-		extraContentWidth := lipgloss.Width(ops.ExtraContent)
+		extraContentWidth := lipgloss.Width(opts.ExtraContent)
 		if extraContentWidth > 0 {
 			extraContentWidth += 1
 		}
@@ -126,11 +115,11 @@ func Status(ops StatusOpts, width int) string {
 
 	content := []string{}
 	if icon != "" {
-		content = append(content, t.S().Base.Foreground(iconColor).Render(icon))
+		content = append(content, icon)
 	}
 	content = append(content, title, description)
-	if ops.ExtraContent != "" {
-		content = append(content, ops.ExtraContent)
+	if opts.ExtraContent != "" {
+		content = append(content, opts.ExtraContent)
 	}
 
 	return strings.Join(content, " ")

internal/tui/components/core/status_test.go πŸ”—

@@ -37,7 +37,6 @@ func TestStatus(t *testing.T) {
 		{
 			name: "NoIcon",
 			opts: core.StatusOpts{
-				NoIcon:      true,
 				Title:       "Info",
 				Description: "This status has no icon",
 			},
@@ -47,7 +46,6 @@ func TestStatus(t *testing.T) {
 			name: "WithColors",
 			opts: core.StatusOpts{
 				Icon:             "⚠",
-				IconColor:        color.RGBA{255, 165, 0, 255}, // Orange
 				Title:            "Warning",
 				TitleColor:       color.RGBA{255, 255, 0, 255}, // Yellow
 				Description:      "This is a warning message",
@@ -102,7 +100,6 @@ func TestStatus(t *testing.T) {
 			name: "AllFieldsWithExtraContent",
 			opts: core.StatusOpts{
 				Icon:             "πŸš€",
-				IconColor:        color.RGBA{0, 255, 0, 255}, // Green
 				Title:            "Deployment",
 				TitleColor:       color.RGBA{0, 0, 255, 255}, // Blue
 				Description:      "Deploying to production environment",

internal/tui/components/files/files.go πŸ”—

@@ -98,8 +98,6 @@ func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
 		fileList = append(fileList,
 			core.Status(
 				core.StatusOpts{
-					IconColor:    t.FgMuted,
-					NoIcon:       true,
 					Title:        filePath,
 					ExtraContent: extraContent,
 				},

internal/tui/components/lsp/lsp.go πŸ”—

@@ -57,22 +57,21 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri
 		}
 
 		// Determine icon color and description based on state
-		iconColor := t.FgMuted
+		icon := t.ItemOfflineIcon
 		description := l.LSP.Command
 
 		if l.LSP.Disabled {
-			iconColor = t.FgMuted
 			description = t.S().Subtle.Render("disabled")
 		} else if state, exists := lspStates[l.Name]; exists {
 			switch state.State {
 			case lsp.StateStarting:
-				iconColor = t.Yellow
+				icon = t.ItemBusyIcon
 				description = t.S().Subtle.Render("starting...")
 			case lsp.StateReady:
-				iconColor = t.Success
+				icon = t.ItemOnlineIcon
 				description = l.LSP.Command
 			case lsp.StateError:
-				iconColor = t.Red
+				icon = t.ItemErrorIcon
 				if state.Error != nil {
 					description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
 				} else {
@@ -119,7 +118,7 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri
 		lspList = append(lspList,
 			core.Status(
 				core.StatusOpts{
-					IconColor:    iconColor,
+					Icon:         icon.String(),
 					Title:        l.Name,
 					Description:  description,
 					ExtraContent: extraContent,

internal/tui/components/mcp/mcp.go πŸ”—

@@ -54,25 +54,24 @@ func RenderMCPList(opts RenderOptions) []string {
 		}
 
 		// Determine icon and color based on state
-		iconColor := t.FgMuted
+		icon := t.ItemOfflineIcon
 		description := l.MCP.Command
 		extraContent := ""
 
 		if state, exists := mcpStates[l.Name]; exists {
 			switch state.State {
 			case agent.MCPStateDisabled:
-				iconColor = t.FgMuted
 				description = t.S().Subtle.Render("disabled")
 			case agent.MCPStateStarting:
-				iconColor = t.Yellow
+				icon = t.ItemBusyIcon
 				description = t.S().Subtle.Render("starting...")
 			case agent.MCPStateConnected:
-				iconColor = t.Success
+				icon = t.ItemOnlineIcon
 				if state.ToolCount > 0 {
 					extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
 				}
 			case agent.MCPStateError:
-				iconColor = t.Red
+				icon = t.ItemErrorIcon
 				if state.Error != nil {
 					description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
 				} else {
@@ -80,14 +79,13 @@ func RenderMCPList(opts RenderOptions) []string {
 				}
 			}
 		} else if l.MCP.Disabled {
-			iconColor = t.FgMuted
 			description = t.S().Subtle.Render("disabled")
 		}
 
 		mcpList = append(mcpList,
 			core.Status(
 				core.StatusOpts{
-					IconColor:    iconColor,
+					Icon:         icon.String(),
 					Title:        l.Name,
 					Description:  description,
 					ExtraContent: extraContent,

internal/tui/styles/crush.go πŸ”—

@@ -1,11 +1,12 @@
 package styles
 
 import (
+	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/exp/charmtone"
 )
 
 func NewCrushTheme() *Theme {
-	return &Theme{
+	t := &Theme{
 		Name:   "crush",
 		IsDark: true,
 
@@ -54,4 +55,12 @@ func NewCrushTheme() *Theme {
 		RedLight: charmtone.Salmon,
 		Cherry:   charmtone.Cherry,
 	}
+
+	// LSP and MCP status.
+	t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
+	t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
+	t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
+	t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
+
+	return t
 }

internal/tui/styles/theme.go πŸ”—

@@ -74,6 +74,12 @@ type Theme struct {
 	RedLight color.Color
 	Cherry   color.Color
 
+	// LSP and MCP status indicators.
+	ItemOfflineIcon lipgloss.Style
+	ItemBusyIcon    lipgloss.Style
+	ItemErrorIcon   lipgloss.Style
+	ItemOnlineIcon  lipgloss.Style
+
 	styles *Styles
 }