From 74cd8c8ef928e38740f6af88fc716031b3f05272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Sun, 15 Feb 2026 23:04:36 +0100 Subject: [PATCH 1/5] feat(#1756): Add dynamic agent color styling system Implement a flexible agent color palette with deterministic color assignment based on agent order. Introduces new functions to generate badge and accent styles for agents dynamically. Changes include: - Create agent color palettes for badges and accents - Add agent order tracking mechanism - Implement color selection based on agent index - Update components to use new dynamic styling functions --- pkg/tui/components/message/message.go | 2 +- pkg/tui/components/sidebar/sidebar.go | 8 +- pkg/tui/components/tool/handoff/handoff.go | 2 +- .../tool/transfertask/transfertask.go | 4 +- pkg/tui/service/sessionstate.go | 7 + pkg/tui/styles/agent_colors.go | 194 ++++++++++++++++++ pkg/tui/styles/agent_colors_test.go | 137 +++++++++++++ pkg/tui/styles/theme.go | 3 + 8 files changed, 349 insertions(+), 8 deletions(-) create mode 100644 pkg/tui/styles/agent_colors.go create mode 100644 pkg/tui/styles/agent_colors_test.go diff --git a/pkg/tui/components/message/message.go b/pkg/tui/components/message/message.go index 533816dc4..39fdf9c89 100644 --- a/pkg/tui/components/message/message.go +++ b/pkg/tui/components/message/message.go @@ -178,7 +178,7 @@ func (mv *messageModel) senderPrefix(sender string) string { if sender == "" { return "" } - return styles.AgentBadgeStyle.MarginLeft(2).Render(sender) + "\n\n" + return styles.AgentBadgeStyleFor(sender).MarginLeft(2).Render(sender) + "\n\n" } // sameAgentAsPrevious returns true if the previous message was from the same agent diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 81ba4e53f..4b3f2bd74 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -1091,17 +1091,17 @@ func (m *model) agentInfo(contentWidth int) string { } func (m *model) renderAgentEntry(content *strings.Builder, agent runtime.AgentDetails, isCurrent bool, index, contentWidth int) { + agentStyle := styles.AgentAccentStyleFor(agent.Name) var prefix string if isCurrent { if m.workingAgent == agent.Name { - // Style the spinner with the same green as the agent name - prefix = styles.TabAccentStyle.Render(m.spinner.View()) + " " + prefix = agentStyle.Render(m.spinner.View()) + " " } else { - prefix = styles.TabAccentStyle.Render("▶") + " " + prefix = agentStyle.Render("▶") + " " } } // Agent name - agentNameText := prefix + styles.TabAccentStyle.Render(agent.Name) + agentNameText := prefix + agentStyle.Render(agent.Name) // Shortcut hint (^1, ^2, etc.) - show for agents 1-9 var shortcutHint string if index >= 0 && index < 9 { diff --git a/pkg/tui/components/tool/handoff/handoff.go b/pkg/tui/components/tool/handoff/handoff.go index d95cc09cc..63f39b496 100644 --- a/pkg/tui/components/tool/handoff/handoff.go +++ b/pkg/tui/components/tool/handoff/handoff.go @@ -22,5 +22,5 @@ func render(msg *types.Message, _ spinner.Spinner, _ service.SessionStateReader, return "" } - return styles.AgentBadgeStyle.MarginLeft(2).Render(msg.Sender) + " ─► " + styles.AgentBadgeStyle.Render(params.Agent) + return styles.AgentBadgeStyleFor(msg.Sender).MarginLeft(2).Render(msg.Sender) + " ─► " + styles.AgentBadgeStyleFor(params.Agent).Render(params.Agent) } diff --git a/pkg/tui/components/tool/transfertask/transfertask.go b/pkg/tui/components/tool/transfertask/transfertask.go index 90e32a382..6eef2a607 100644 --- a/pkg/tui/components/tool/transfertask/transfertask.go +++ b/pkg/tui/components/tool/transfertask/transfertask.go @@ -25,9 +25,9 @@ func render(msg *types.Message, _ spinner.Spinner, _ service.SessionStateReader, return "" } - header := styles.AgentBadgeStyle.MarginLeft(2).Render(msg.Sender) + + header := styles.AgentBadgeStyleFor(msg.Sender).MarginLeft(2).Render(msg.Sender) + " calls " + - styles.AgentBadgeStyle.Render(params.Agent) + styles.AgentBadgeStyleFor(params.Agent).Render(params.Agent) // Calculate the icon with its margin icon := styles.ToolCompletedIcon.Render("✓") diff --git a/pkg/tui/service/sessionstate.go b/pkg/tui/service/sessionstate.go index 3b41f2f1a..694447940 100644 --- a/pkg/tui/service/sessionstate.go +++ b/pkg/tui/service/sessionstate.go @@ -3,6 +3,7 @@ package service import ( "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/tui/styles" "github.com/docker/cagent/pkg/tui/types" "github.com/docker/cagent/pkg/userconfig" ) @@ -116,6 +117,12 @@ func (s *SessionState) AvailableAgents() []runtime.AgentDetails { func (s *SessionState) SetAvailableAgents(availableAgents []runtime.AgentDetails) { s.availableAgents = availableAgents + + names := make([]string, len(availableAgents)) + for i, a := range availableAgents { + names[i] = a.Name + } + styles.SetAgentOrder(names) } func (s *SessionState) GetCurrentAgent() runtime.AgentDetails { diff --git a/pkg/tui/styles/agent_colors.go b/pkg/tui/styles/agent_colors.go new file mode 100644 index 000000000..35c13ce99 --- /dev/null +++ b/pkg/tui/styles/agent_colors.go @@ -0,0 +1,194 @@ +package styles + +import ( + "image/color" + "sync" + + "charm.land/lipgloss/v2" +) + +// agentColorPalette defines distinct background colors for agent badges. +// These are chosen to be visually distinguishable and to provide good +// contrast with white text on dark backgrounds. +var agentColorPalette = []string{ + "#1D63ED", // Blue + "#9B59B6", // Purple + "#1ABC9C", // Teal + "#E67E22", // Orange + "#E74C8B", // Pink + "#27AE60", // Green + "#2980B9", // Steel blue + "#8E44AD", // Deep purple + "#D4AC0D", // Gold + "#C0392B", // Red + "#16A085", // Dark teal + "#D35400", // Burnt orange + "#2C3E99", // Indigo + "#7D3C98", // Plum + "#117864", // Forest green + "#A93226", // Crimson +} + +// agentAccentPalette defines foreground accent colors for agent names in the sidebar. +// These are brighter variants designed to be readable on dark backgrounds without +// a background fill. +var agentAccentPalette = []string{ + "#98C379", // Green + "#C678DD", // Purple + "#56B6C2", // Cyan + "#E5C07B", // Yellow + "#E06C9F", // Pink + "#61AFEF", // Blue + "#D19A66", // Orange + "#BE5046", // Red + "#73C991", // Mint + "#CDA0E0", // Lavender + "#4EC9B0", // Turquoise + "#DCDCAA", // Khaki + "#9CDCFE", // Ice blue + "#CE9178", // Salmon + "#B5CEA8", // Sage + "#D7BA7D", // Tan +} + +// AgentBadgeColors holds the resolved foreground and background colors for an agent badge. +type AgentBadgeColors struct { + Fg color.Color + Bg color.Color +} + +// cachedBadgeStyle holds a precomputed badge style for a palette index. +type cachedBadgeStyle struct { + colors AgentBadgeColors + style lipgloss.Style +} + +// agentRegistry maps agent names to their index in the team list and holds +// precomputed styles for each palette index. +var agentRegistry struct { + sync.RWMutex + indices map[string]int + badgeStyles []cachedBadgeStyle + accentStyles []lipgloss.Style +} + +// SetAgentOrder updates the agent name → index mapping and rebuilds the style cache. +// Call this when the team info changes (e.g., on TeamInfoEvent). +func SetAgentOrder(agentNames []string) { + agentRegistry.Lock() + defer agentRegistry.Unlock() + + agentRegistry.indices = make(map[string]int, len(agentNames)) + for i, name := range agentNames { + agentRegistry.indices[name] = i + } + + rebuildAgentColorCache() +} + +// rebuildAgentColorCache precomputes badge and accent styles for all palette indices. +// Must be called with agentRegistry.Lock held. +func rebuildAgentColorCache() { + theme := CurrentTheme() + + agentRegistry.badgeStyles = make([]cachedBadgeStyle, len(agentColorPalette)) + for i, bgHex := range agentColorPalette { + fgHex := bestForegroundHex( + bgHex, + theme.Colors.TextBright, + theme.Colors.Background, + "#000000", + "#ffffff", + ) + colors := AgentBadgeColors{ + Fg: lipgloss.Color(fgHex), + Bg: lipgloss.Color(bgHex), + } + agentRegistry.badgeStyles[i] = cachedBadgeStyle{ + colors: colors, + style: BaseStyle. + Foreground(colors.Fg). + Background(colors.Bg). + Padding(0, 1), + } + } + + agentRegistry.accentStyles = make([]lipgloss.Style, len(agentAccentPalette)) + for i, hex := range agentAccentPalette { + agentRegistry.accentStyles[i] = BaseStyle.Foreground(lipgloss.Color(hex)) + } +} + +// InvalidateAgentColorCache rebuilds the cached agent styles. +// Call this after a theme change so foreground contrast is recalculated. +func InvalidateAgentColorCache() { + agentRegistry.Lock() + defer agentRegistry.Unlock() + + rebuildAgentColorCache() +} + +// agentIndex returns the palette index for an agent name. +// Uses the registered position if available, wrapping around the palette size. +// Falls back to 0 for unknown agents. +func agentIndex(agentName string) int { + agentRegistry.RLock() + idx, ok := agentRegistry.indices[agentName] + agentRegistry.RUnlock() + + if ok { + return idx % len(agentColorPalette) + } + return 0 +} + +// AgentBadgeColorsFor returns the badge foreground/background colors for a given agent name. +func AgentBadgeColorsFor(agentName string) AgentBadgeColors { + idx := agentIndex(agentName) + + agentRegistry.RLock() + defer agentRegistry.RUnlock() + + if idx < len(agentRegistry.badgeStyles) { + return agentRegistry.badgeStyles[idx].colors + } + + // Fallback if cache is not yet initialized + return AgentBadgeColors{ + Fg: lipgloss.Color("#ffffff"), + Bg: lipgloss.Color(agentColorPalette[idx]), + } +} + +// AgentBadgeStyleFor returns a lipgloss badge style colored for the given agent. +func AgentBadgeStyleFor(agentName string) lipgloss.Style { + idx := agentIndex(agentName) + + agentRegistry.RLock() + defer agentRegistry.RUnlock() + + if idx < len(agentRegistry.badgeStyles) { + return agentRegistry.badgeStyles[idx].style + } + + // Fallback if cache is not yet initialized + return BaseStyle. + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color(agentColorPalette[idx])). + Padding(0, 1) +} + +// AgentAccentStyleFor returns a foreground-only style for agent names (used in sidebar). +func AgentAccentStyleFor(agentName string) lipgloss.Style { + idx := agentIndex(agentName) + + agentRegistry.RLock() + defer agentRegistry.RUnlock() + + if idx < len(agentRegistry.accentStyles) { + return agentRegistry.accentStyles[idx] + } + + // Fallback if cache is not yet initialized + return BaseStyle.Foreground(lipgloss.Color(agentAccentPalette[idx])) +} diff --git a/pkg/tui/styles/agent_colors_test.go b/pkg/tui/styles/agent_colors_test.go new file mode 100644 index 000000000..861ea2f35 --- /dev/null +++ b/pkg/tui/styles/agent_colors_test.go @@ -0,0 +1,137 @@ +package styles + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAgentIndex_UsesRegisteredOrder(t *testing.T) { + SetAgentOrder([]string{"root", "git-agent", "docs-writer"}) + defer SetAgentOrder(nil) + + assert.Equal(t, 0, agentIndex("root")) + assert.Equal(t, 1, agentIndex("git-agent")) + assert.Equal(t, 2, agentIndex("docs-writer")) +} + +func TestAgentIndex_UnknownAgentReturnsFallback(t *testing.T) { + SetAgentOrder([]string{"root", "git-agent"}) + defer SetAgentOrder(nil) + + assert.Equal(t, 0, agentIndex("unknown-agent")) +} + +func TestAgentIndex_WrapsAroundPaletteSize(t *testing.T) { + agents := make([]string, len(agentColorPalette)+3) + for i := range agents { + agents[i] = "agent-" + string(rune('a'+i)) + } + SetAgentOrder(agents) + defer SetAgentOrder(nil) + + last := agents[len(agents)-1] + idx := agentIndex(last) + assert.Less(t, idx, len(agentColorPalette)) + assert.Equal(t, (len(agentColorPalette)+2)%len(agentColorPalette), idx) +} + +func TestAgentIndex_EmptyRegistryReturnsFallback(t *testing.T) { + SetAgentOrder(nil) + defer SetAgentOrder(nil) + + assert.Equal(t, 0, agentIndex("anything")) +} + +func TestSetAgentOrder_UpdatesRegistry(t *testing.T) { + SetAgentOrder([]string{"a", "b", "c"}) + defer SetAgentOrder(nil) + + assert.Equal(t, 0, agentIndex("a")) + assert.Equal(t, 2, agentIndex("c")) + + SetAgentOrder([]string{"c", "b", "a"}) + assert.Equal(t, 2, agentIndex("a")) + assert.Equal(t, 0, agentIndex("c")) +} + +func TestAgentBadgeStyleFor_ProducesDifferentStylesPerIndex(t *testing.T) { + SetAgentOrder([]string{"root", "docs-writer"}) + defer SetAgentOrder(nil) + + rendered1 := AgentBadgeStyleFor("root").Render("root") + rendered2 := AgentBadgeStyleFor("docs-writer").Render("docs-writer") + + require.NotEmpty(t, rendered1) + require.NotEmpty(t, rendered2) + assert.NotEqual(t, rendered1, rendered2) +} + +func TestAgentBadgeStyleFor_Deterministic(t *testing.T) { + SetAgentOrder([]string{"root"}) + defer SetAgentOrder(nil) + + s1 := AgentBadgeStyleFor("root").Render("root") + s2 := AgentBadgeStyleFor("root").Render("root") + assert.Equal(t, s1, s2) +} + +func TestAgentAccentStyleFor_Deterministic(t *testing.T) { + SetAgentOrder([]string{"root"}) + defer SetAgentOrder(nil) + + s1 := AgentAccentStyleFor("root").Render("root") + s2 := AgentAccentStyleFor("root").Render("root") + assert.Equal(t, s1, s2) +} + +func TestAgentBadgeColorsFor_HasFgAndBg(t *testing.T) { + SetAgentOrder([]string{"root"}) + defer SetAgentOrder(nil) + + colors := AgentBadgeColorsFor("root") + assert.NotNil(t, colors.Fg) + assert.NotNil(t, colors.Bg) +} + +func TestPaletteSizes_AreEqual(t *testing.T) { + t.Parallel() + + assert.Len(t, agentAccentPalette, len(agentColorPalette), + "badge and accent palettes must have the same number of entries") + assert.Len(t, agentColorPalette, 16) +} + +func TestSetAgentOrder_PopulatesCache(t *testing.T) { + SetAgentOrder([]string{"root", "docs-writer"}) + defer SetAgentOrder(nil) + + agentRegistry.RLock() + defer agentRegistry.RUnlock() + + assert.Len(t, agentRegistry.badgeStyles, len(agentColorPalette)) + assert.Len(t, agentRegistry.accentStyles, len(agentAccentPalette)) +} + +func TestInvalidateAgentColorCache_RebuildsCachedStyles(t *testing.T) { + SetAgentOrder([]string{"root"}) + defer SetAgentOrder(nil) + + before := AgentBadgeStyleFor("root").Render("root") + InvalidateAgentColorCache() + after := AgentBadgeStyleFor("root").Render("root") + + assert.Equal(t, before, after, "cache rebuild with same theme should produce identical styles") +} + +func TestAgentBadgeStyleFor_UsesCachedStyle(t *testing.T) { + SetAgentOrder([]string{"a", "b"}) + defer SetAgentOrder(nil) + + // Calling AgentBadgeStyleFor repeatedly should return identical styles from cache + for range 100 { + s := AgentBadgeStyleFor("b").Render("b") + require.NotEmpty(t, s) + } +} diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index cd6670258..da5abc78e 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -945,6 +945,9 @@ func ApplyTheme(theme *Theme) { // Rebuild all derived styles rebuildStyles() + // Rebuild cached agent color styles with new theme contrast values + InvalidateAgentColorCache() + // Clear style sequence cache (used by RenderComposite) clearStyleSeqCache() } From 09d9f817530c829fc14e3dccb0ba022b0d6786cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Tue, 17 Feb 2026 17:00:10 +0100 Subject: [PATCH 2/5] refactor(#1756): consolidate color utilities into styles/colorutil.go Extract color math functions from tabbar/tab.go and theme.go into a shared colorutil.go module. Adds HSL conversion, CIELAB perceptual distance, and hue-based palette generation functions. This eliminates duplicate luminance/contrast implementations and provides the foundation for theme-integrated agent color generation. Assisted-By: cagent --- pkg/tui/components/tabbar/tab.go | 125 +------- pkg/tui/components/tabbar/tabbar.go | 6 +- pkg/tui/styles/agent_colors.go | 2 +- pkg/tui/styles/colorutil.go | 422 ++++++++++++++++++++++++++++ pkg/tui/styles/colorutil_test.go | 332 ++++++++++++++++++++++ pkg/tui/styles/theme.go | 90 +----- 6 files changed, 765 insertions(+), 212 deletions(-) create mode 100644 pkg/tui/styles/colorutil.go create mode 100644 pkg/tui/styles/colorutil_test.go diff --git a/pkg/tui/components/tabbar/tab.go b/pkg/tui/components/tabbar/tab.go index 96976343e..2c5fe2b07 100644 --- a/pkg/tui/components/tabbar/tab.go +++ b/pkg/tui/components/tabbar/tab.go @@ -1,9 +1,7 @@ package tabbar import ( - "fmt" "image/color" - "math" "charm.land/lipgloss/v2" @@ -26,17 +24,6 @@ const ( // attentionIndicator is shown before the title when the tab needs attention, // replacing the running indicator to signal that user action is required. attentionIndicator = "! " - // mutedContrastStrength controls how much the muted foreground shifts - // away from the background (0.0 = invisible, 1.0 = full black/white). - mutedContrastStrength = 0.45 - // minIndicatorContrast is the minimum WCAG contrast ratio required for - // semantic indicator colors (running dot, attention bang). If the themed - // color doesn't meet this threshold against the tab background, it is - // automatically boosted while preserving its hue. - minIndicatorContrast = 4.5 - // maxBoostSteps limits the blend iterations in ensureContrast to avoid - // infinite loops on degenerate inputs. - maxBoostSteps = 20 // dragSourceColorBoost controls how much the drag source tab is blended toward // the active tab colors when it is not the active tab. @@ -67,106 +54,6 @@ func (t Tab) Width() int { return t.width } // and the close-button click area begins. func (t Tab) MainZoneEnd() int { return t.mainZoneEnd } -// --- Color helpers --- - -// sRGBLuminance returns the relative luminance of an sRGB color using the -// WCAG 2.x formula (linearized channel values, ITU-R BT.709 coefficients). -func sRGBLuminance(r, g, b float64) float64 { - linearize := func(c float64) float64 { - if c <= 0.03928 { - return c / 12.92 - } - return math.Pow((c+0.055)/1.055, 2.4) - } - return 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b) -} - -// colorToLinear extracts normalized [0,1] sRGB components from a color.Color. -func colorToLinear(c color.Color) (float64, float64, float64) { - r, g, b, _ := c.RGBA() - return float64(r) / 65535, float64(g) / 65535, float64(b) / 65535 -} - -// contrastRatio returns the WCAG 2.x contrast ratio between two colors. -func contrastRatio(fg, bg color.Color) float64 { - r1, g1, b1 := colorToLinear(fg) - r2, g2, b2 := colorToLinear(bg) - l1 := sRGBLuminance(r1, g1, b1) - l2 := sRGBLuminance(r2, g2, b2) - lighter := max(l1, l2) - darker := min(l1, l2) - return (lighter + 0.05) / (darker + 0.05) -} - -// toHexColor formats normalized [0,1] RGB components as a lipgloss color. -func toHexColor(r, g, b float64) color.Color { - clamp := func(v float64) int { - if v < 0 { - return 0 - } - if v > 1 { - return 255 - } - return int(v * 255) - } - return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", clamp(r), clamp(g), clamp(b))) -} - -// mutedContrastFg returns a foreground color that is visible but subtle against -// the given background. It blends the background toward white (for dark bg) or -// black (for light bg) by mutedContrastStrength. -func mutedContrastFg(bg color.Color) color.Color { - rf, gf, bf := colorToLinear(bg) - - // Perceived luminance for direction decision (BT.601 for perceptual balance). - lum := 0.299*rf + 0.587*gf + 0.114*bf - - var tgt float64 - if lum > 0.5 { - tgt = 0.0 - } else { - tgt = 1.0 - } - - s := mutedContrastStrength - return toHexColor(rf+(tgt-rf)*s, gf+(tgt-gf)*s, bf+(tgt-bf)*s) -} - -// ensureContrast returns fg unchanged if it already meets minIndicatorContrast -// against bg. Otherwise it progressively blends fg toward white (on dark bg) or -// black (on light bg) until the threshold is met, preserving the original hue. -func ensureContrast(fg, bg color.Color) color.Color { - if contrastRatio(fg, bg) >= minIndicatorContrast { - return fg - } - - rf, gf, bf := colorToLinear(fg) - bgR, bgG, bgB := colorToLinear(bg) - bgLum := sRGBLuminance(bgR, bgG, bgB) - - // Blend toward white on dark backgrounds, toward black on light ones. - var tR, tG, tB float64 - if bgLum > 0.5 { - tR, tG, tB = 0, 0, 0 - } else { - tR, tG, tB = 1, 1, 1 - } - - for step := 1; step <= maxBoostSteps; step++ { - t := float64(step) / float64(maxBoostSteps) - nr := rf + (tR-rf)*t - ng := gf + (tG-gf)*t - nb := bf + (tB-bf)*t - candidate := toHexColor(nr, ng, nb) - if contrastRatio(candidate, bg) >= minIndicatorContrast { - return candidate - } - } - - // Fallback: full contrast direction (should always meet the threshold). - return toHexColor(tR, tG, tB) -} - // dragRole describes a tab's role during a drag-and-drop operation. type dragRole int @@ -178,9 +65,9 @@ const ( // blendColors mixes two colors by the given ratio (0 = a, 1 = b). func blendColors(a, b color.Color, ratio float64) color.Color { - ar, ag, ab := colorToLinear(a) - br, bg, bb := colorToLinear(b) - return toHexColor( + ar, ag, ab := styles.ColorToRGB(a) + br, bg, bb := styles.ColorToRGB(b) + return styles.RGBToColor( ar+(br-ar)*ratio, ag+(bg-ag)*ratio, ab+(bb-ab)*ratio, @@ -225,7 +112,7 @@ func renderTab(info messages.TabInfo, maxTitleLen, animFrame int, role dragRole) } // Close button color derived from this tab's background. - closeFg := mutedContrastFg(bgColor) + closeFg := styles.MutedContrastFg(bgColor) // Fade all foreground elements when this tab is a bystander during drag. if role == dragRoleBystander { @@ -250,13 +137,13 @@ func renderTab(info messages.TabInfo, maxTitleLen, animFrame int, role dragRole) case info.NeedsAttention: // Attention takes priority over running: replace the streaming dot // with a warning-colored indicator so it's obvious the tab needs action. - attnFg := ensureContrast(styles.Warning, bgColor) + attnFg := styles.EnsureContrast(styles.Warning, bgColor) if role == dragRoleBystander { attnFg = blendColors(attnFg, bgColor, dragBystanderDimAmount) } content += lipgloss.NewStyle().Foreground(attnFg).Background(bgColor).Bold(true).Render(attentionIndicator) case info.IsRunning && !info.IsActive: - runFg := ensureContrast(styles.TabAccentFg, bgColor) + runFg := styles.EnsureContrast(styles.TabAccentFg, bgColor) if role == dragRoleBystander { runFg = blendColors(runFg, bgColor, dragBystanderDimAmount) } diff --git a/pkg/tui/components/tabbar/tabbar.go b/pkg/tui/components/tabbar/tabbar.go index 12a5bbb38..dfa9f5db3 100644 --- a/pkg/tui/components/tabbar/tabbar.go +++ b/pkg/tui/components/tabbar/tabbar.go @@ -408,11 +408,11 @@ func (t *TabBar) View() string { } // Compute "+" and arrow colors dynamically from the terminal background. - chromeFg := mutedContrastFg(styles.Background) + chromeFg := styles.MutedContrastFg(styles.Background) plusStyle := lipgloss.NewStyle().Foreground(chromeFg) arrowStyle := lipgloss.NewStyle().Foreground(chromeFg) // Attention arrow style: warning-colored and bold so off-screen attention tabs are obvious. - attnArrowStyle := lipgloss.NewStyle().Foreground(ensureContrast(styles.Warning, styles.Background)).Bold(true) + attnArrowStyle := lipgloss.NewStyle().Foreground(styles.EnsureContrast(styles.Warning, styles.Background)).Bold(true) var line string var cursor int @@ -436,7 +436,7 @@ func (t *TabBar) View() string { var dropLine string visualDrop := noTab if t.drag.active && !t.drag.isNoOp() { - dropFg := ensureContrast(styles.TabAccentFg, styles.Background) + dropFg := styles.EnsureContrast(styles.TabAccentFg, styles.Background) dropLine = lipgloss.NewStyle().Foreground(dropFg).Render(dropIndicator) visualDrop = t.drag.dropIdx } diff --git a/pkg/tui/styles/agent_colors.go b/pkg/tui/styles/agent_colors.go index 35c13ce99..248259835 100644 --- a/pkg/tui/styles/agent_colors.go +++ b/pkg/tui/styles/agent_colors.go @@ -93,7 +93,7 @@ func rebuildAgentColorCache() { agentRegistry.badgeStyles = make([]cachedBadgeStyle, len(agentColorPalette)) for i, bgHex := range agentColorPalette { - fgHex := bestForegroundHex( + fgHex := BestForegroundHex( bgHex, theme.Colors.TextBright, theme.Colors.Background, diff --git a/pkg/tui/styles/colorutil.go b/pkg/tui/styles/colorutil.go new file mode 100644 index 000000000..790eecfbc --- /dev/null +++ b/pkg/tui/styles/colorutil.go @@ -0,0 +1,422 @@ +package styles + +import ( + "fmt" + "image/color" + "math" + "strconv" + "strings" + + "charm.land/lipgloss/v2" +) + +// --- Hex parsing --- + +// ParseHexRGB parses a hex color string (#RGB or #RRGGBB) into normalized [0,1] sRGB components. +func ParseHexRGB(hex string) (r, g, b float64, ok bool) { + if !strings.HasPrefix(hex, "#") { + return 0, 0, 0, false + } + + h := strings.TrimPrefix(hex, "#") + if len(h) == 3 { + h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) + } + if len(h) != 6 { + return 0, 0, 0, false + } + + r8, err := strconv.ParseUint(h[0:2], 16, 8) + if err != nil { + return 0, 0, 0, false + } + g8, err := strconv.ParseUint(h[2:4], 16, 8) + if err != nil { + return 0, 0, 0, false + } + b8, err := strconv.ParseUint(h[4:6], 16, 8) + if err != nil { + return 0, 0, 0, false + } + + return float64(r8) / 255.0, float64(g8) / 255.0, float64(b8) / 255.0, true +} + +// ColorToRGB extracts normalized [0,1] sRGB components from a color.Color. +func ColorToRGB(c color.Color) (r, g, b float64) { + ri, gi, bi, _ := c.RGBA() + return float64(ri) / 65535, float64(gi) / 65535, float64(bi) / 65535 +} + +// RGBToHex formats normalized [0,1] sRGB components as a hex color string. +func RGBToHex(r, g, b float64) string { + return fmt.Sprintf("#%02x%02x%02x", clamp8(r), clamp8(g), clamp8(b)) +} + +// RGBToColor converts normalized [0,1] sRGB components to a lipgloss color. +func RGBToColor(r, g, b float64) color.Color { + return lipgloss.Color(RGBToHex(r, g, b)) +} + +func clamp8(v float64) int { + if v < 0 { + return 0 + } + if v > 1 { + return 255 + } + return int(v*255 + 0.5) +} + +// --- sRGB linearization --- + +// SRGBToLinear converts an sRGB component [0,1] to linear light. +func SRGBToLinear(c float64) float64 { + if c <= 0.03928 { + return c / 12.92 + } + return math.Pow((c+0.055)/1.055, 2.4) +} + +// LinearToSRGB converts a linear light component [0,1] to sRGB. +func LinearToSRGB(c float64) float64 { + if c <= 0.0031308 { + return c * 12.92 + } + return 1.055*math.Pow(c, 1.0/2.4) - 0.055 +} + +// --- Luminance & contrast --- + +// RelativeLuminance returns the WCAG 2.x relative luminance of an sRGB color. +func RelativeLuminance(r, g, b float64) float64 { + return 0.2126*SRGBToLinear(r) + 0.7152*SRGBToLinear(g) + 0.0722*SRGBToLinear(b) +} + +// RelativeLuminanceHex returns the relative luminance for a hex color string. +func RelativeLuminanceHex(hex string) (float64, bool) { + r, g, b, ok := ParseHexRGB(hex) + if !ok { + return 0, false + } + return RelativeLuminance(r, g, b), true +} + +// RelativeLuminanceColor returns the relative luminance for a color.Color. +func RelativeLuminanceColor(c color.Color) float64 { + r, g, b := ColorToRGB(c) + return RelativeLuminance(r, g, b) +} + +// ContrastRatio returns the WCAG 2.x contrast ratio between two colors. +func ContrastRatio(fg, bg color.Color) float64 { + l1 := RelativeLuminanceColor(fg) + l2 := RelativeLuminanceColor(bg) + lighter := max(l1, l2) + darker := min(l1, l2) + return (lighter + 0.05) / (darker + 0.05) +} + +// ContrastRatioHex returns the WCAG contrast ratio between two hex color strings. +func ContrastRatioHex(fgHex, bgHex string) (float64, bool) { + fgLum, ok := RelativeLuminanceHex(fgHex) + if !ok { + return 0, false + } + bgLum, ok := RelativeLuminanceHex(bgHex) + if !ok { + return 0, false + } + + l1, l2 := fgLum, bgLum + if l2 > l1 { + l1, l2 = l2, l1 + } + return (l1 + 0.05) / (l2 + 0.05), true +} + +// BestForegroundHex picks the candidate hex color with the highest contrast ratio against bgHex. +func BestForegroundHex(bgHex string, candidates ...string) string { + if len(candidates) == 0 { + return "" + } + best := candidates[0] + bestRatio := -1.0 + + for _, cand := range candidates { + ratio, ok := ContrastRatioHex(cand, bgHex) + if !ok { + continue + } + if ratio > bestRatio { + bestRatio = ratio + best = cand + } + } + return best +} + +// --- Dynamic contrast helpers --- + +const ( + // MutedContrastStrength controls how much the muted foreground shifts + // away from the background (0.0 = invisible, 1.0 = full black/white). + MutedContrastStrength = 0.45 + + // MinIndicatorContrast is the minimum WCAG contrast ratio for semantic + // indicator colors (running dot, attention bang). + MinIndicatorContrast = 4.5 + + // maxBoostSteps limits blend iterations in EnsureContrast. + maxBoostSteps = 20 +) + +// MutedContrastFg returns a foreground color that is visible but subtle against +// the given background. It blends the background toward white (for dark bg) or +// black (for light bg) by MutedContrastStrength. +func MutedContrastFg(bg color.Color) color.Color { + rf, gf, bf := ColorToRGB(bg) + lum := 0.299*rf + 0.587*gf + 0.114*bf + + var tgt float64 + if lum > 0.5 { + tgt = 0.0 + } else { + tgt = 1.0 + } + + s := MutedContrastStrength + return RGBToColor(rf+(tgt-rf)*s, gf+(tgt-gf)*s, bf+(tgt-bf)*s) +} + +// EnsureContrast returns fg unchanged if it already meets MinIndicatorContrast +// against bg. Otherwise it progressively blends fg toward white (on dark bg) or +// black (on light bg) until the threshold is met, preserving the original hue direction. +func EnsureContrast(fg, bg color.Color) color.Color { + if ContrastRatio(fg, bg) >= MinIndicatorContrast { + return fg + } + + rf, gf, bf := ColorToRGB(fg) + bgR, bgG, bgB := ColorToRGB(bg) + bgLum := RelativeLuminance(bgR, bgG, bgB) + + var tR, tG, tB float64 + if bgLum > 0.5 { + tR, tG, tB = 0, 0, 0 + } else { + tR, tG, tB = 1, 1, 1 + } + + for step := 1; step <= maxBoostSteps; step++ { + t := float64(step) / float64(maxBoostSteps) + nr := rf + (tR-rf)*t + ng := gf + (tG-gf)*t + nb := bf + (tB-bf)*t + candidate := RGBToColor(nr, ng, nb) + if ContrastRatio(candidate, bg) >= MinIndicatorContrast { + return candidate + } + } + + return RGBToColor(tR, tG, tB) +} + +// --- HSL conversion --- + +// RGBToHSL converts normalized [0,1] sRGB to HSL. +// H is in [0,360), S and L are in [0,1]. +func RGBToHSL(r, g, b float64) (h, s, l float64) { + maxC := max(r, max(g, b)) + minC := min(r, min(g, b)) + l = (maxC + minC) / 2 + + if maxC == minC { + return 0, 0, l + } + + d := maxC - minC + if l > 0.5 { + s = d / (2.0 - maxC - minC) + } else { + s = d / (maxC + minC) + } + + switch maxC { + case r: + h = (g - b) / d + if g < b { + h += 6 + } + case g: + h = (b-r)/d + 2 + case b: + h = (r-g)/d + 4 + } + h *= 60 + + return h, s, l +} + +// HSLToRGB converts HSL to normalized [0,1] sRGB. +// H is in [0,360), S and L are in [0,1]. +func HSLToRGB(h, s, l float64) (r, g, b float64) { + if s == 0 { + return l, l, l + } + + var q float64 + if l < 0.5 { + q = l * (1 + s) + } else { + q = l + s - l*s + } + p := 2*l - q + + h /= 360 + r = hueToRGB(p, q, h+1.0/3.0) + g = hueToRGB(p, q, h) + b = hueToRGB(p, q, h-1.0/3.0) + return r, g, b +} + +func hueToRGB(p, q, t float64) float64 { + if t < 0 { + t++ + } + if t > 1 { + t-- + } + switch { + case t < 1.0/6.0: + return p + (q-p)*6*t + case t < 1.0/2.0: + return q + case t < 2.0/3.0: + return p + (q-p)*(2.0/3.0-t)*6 + default: + return p + } +} + +// --- Palette generation --- + +// DefaultAgentHues provides 16 well-spaced default hue values for agent colors. +var DefaultAgentHues = []float64{ + 220, // Blue + 280, // Purple + 170, // Teal + 30, // Orange + 330, // Pink + 140, // Green + 200, // Steel blue + 265, // Deep purple + 50, // Gold + 0, // Red + 185, // Dark teal + 20, // Burnt orange + 235, // Indigo + 295, // Plum + 155, // Forest green + 350, // Crimson +} + +// GenerateBadgePalette generates badge background colors from hues, adapting +// saturation and lightness based on the theme background. +// Dark backgrounds get lighter, more saturated badges; light backgrounds get darker ones. +func GenerateBadgePalette(hues []float64, bg color.Color) []color.Color { + bgR, bgG, bgB := ColorToRGB(bg) + bgLum := RelativeLuminance(bgR, bgG, bgB) + + isDark := bgLum < 0.5 + + colors := make([]color.Color, len(hues)) + for i, hue := range hues { + var s, l float64 + if isDark { + s = 0.65 + 0.10*math.Sin(float64(i)*0.7) + l = 0.42 + 0.06*math.Cos(float64(i)*0.9) + } else { + s = 0.60 + 0.10*math.Sin(float64(i)*0.7) + l = 0.38 + 0.06*math.Cos(float64(i)*0.9) + } + + r, g, b := HSLToRGB(hue, s, l) + colors[i] = lipgloss.Color(RGBToHex(r, g, b)) + } + return colors +} + +// GenerateAccentPalette generates sidebar accent foreground colors from hues, +// adapting to the theme background for readability. +// Dark backgrounds get brighter accents; light backgrounds get darker ones. +func GenerateAccentPalette(hues []float64, bg color.Color) []color.Color { + bgR, bgG, bgB := ColorToRGB(bg) + bgLum := RelativeLuminance(bgR, bgG, bgB) + + isDark := bgLum < 0.5 + + colors := make([]color.Color, len(hues)) + for i, hue := range hues { + var s, l float64 + if isDark { + s = 0.55 + 0.15*math.Sin(float64(i)*0.5) + l = 0.68 + 0.08*math.Cos(float64(i)*0.7) + } else { + s = 0.65 + 0.15*math.Sin(float64(i)*0.5) + l = 0.35 + 0.08*math.Cos(float64(i)*0.7) + } + + r, g, b := HSLToRGB(hue, s, l) + colors[i] = lipgloss.Color(RGBToHex(r, g, b)) + } + return colors +} + +// --- Perceptual distance --- + +// ColorDistanceCIE76 returns the Euclidean distance between two colors in CIELAB space. +// A value below ~25 means colors may be hard to distinguish at a glance. +func ColorDistanceCIE76(c1, c2 color.Color) float64 { + l1, a1, b1 := colorToLab(c1) + l2, a2, b2 := colorToLab(c2) + dl := l1 - l2 + da := a1 - a2 + db := b1 - b2 + return math.Sqrt(dl*dl + da*da + db*db) +} + +// colorToLab converts a color.Color to CIELAB via XYZ (D65 illuminant). +func colorToLab(c color.Color) (l, a, b float64) { + r, g, bl := ColorToRGB(c) + // sRGB to linear + rl := SRGBToLinear(r) + gl := SRGBToLinear(g) + bll := SRGBToLinear(bl) + + // Linear RGB to XYZ (D65) + x := 0.4124564*rl + 0.3575761*gl + 0.1804375*bll + y := 0.2126729*rl + 0.7151522*gl + 0.0721750*bll + z := 0.0193339*rl + 0.1191920*gl + 0.9503041*bll + + // XYZ to Lab (D65 white point) + x /= 0.95047 + y /= 1.00000 + z /= 1.08883 + + x = labF(x) + y = labF(y) + z = labF(z) + + l = 116*y - 16 + a = 500 * (x - y) + b = 200 * (y - z) + return l, a, b +} + +func labF(t float64) float64 { + if t > 0.008856 { + return math.Cbrt(t) + } + return 7.787*t + 16.0/116.0 +} diff --git a/pkg/tui/styles/colorutil_test.go b/pkg/tui/styles/colorutil_test.go new file mode 100644 index 000000000..c10f20862 --- /dev/null +++ b/pkg/tui/styles/colorutil_test.go @@ -0,0 +1,332 @@ +package styles + +import ( + "math" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Hex parsing --- + +func TestParseHexRGB_Valid6Digit(t *testing.T) { + t.Parallel() + r, g, b, ok := ParseHexRGB("#FF8000") + require.True(t, ok) + assert.InDelta(t, 1.0, r, 0.01) + assert.InDelta(t, 0.502, g, 0.01) + assert.InDelta(t, 0.0, b, 0.01) +} + +func TestParseHexRGB_Valid3Digit(t *testing.T) { + t.Parallel() + r, g, b, ok := ParseHexRGB("#F00") + require.True(t, ok) + assert.InDelta(t, 1.0, r, 0.01) + assert.InDelta(t, 0.0, g, 0.01) + assert.InDelta(t, 0.0, b, 0.01) +} + +func TestParseHexRGB_Invalid(t *testing.T) { + t.Parallel() + for _, input := range []string{"", "FF0000", "#GG0000", "#FF00", "#FF000000"} { + _, _, _, ok := ParseHexRGB(input) + assert.False(t, ok, "expected failure for %q", input) + } +} + +// --- RGB ↔ Hex roundtrip --- + +func TestRGBToHex_Roundtrip(t *testing.T) { + t.Parallel() + hex := RGBToHex(0.2, 0.4, 0.6) + r, g, b, ok := ParseHexRGB(hex) + require.True(t, ok) + assert.InDelta(t, 0.2, r, 0.01) + assert.InDelta(t, 0.4, g, 0.01) + assert.InDelta(t, 0.6, b, 0.01) +} + +func TestRGBToHex_BlackWhite(t *testing.T) { + t.Parallel() + assert.Equal(t, "#000000", RGBToHex(0, 0, 0)) + assert.Equal(t, "#ffffff", RGBToHex(1, 1, 1)) +} + +// --- sRGB linearization roundtrip --- + +func TestLinearization_Roundtrip(t *testing.T) { + t.Parallel() + for _, v := range []float64{0, 0.1, 0.5, 0.9, 1.0} { + result := LinearToSRGB(SRGBToLinear(v)) + assert.InDelta(t, v, result, 0.001, "roundtrip failed for %f", v) + } +} + +// --- Luminance --- + +func TestRelativeLuminance_BlackWhite(t *testing.T) { + t.Parallel() + assert.InDelta(t, 0.0, RelativeLuminance(0, 0, 0), 0.001) + assert.InDelta(t, 1.0, RelativeLuminance(1, 1, 1), 0.001) +} + +func TestRelativeLuminanceHex(t *testing.T) { + t.Parallel() + lum, ok := RelativeLuminanceHex("#ffffff") + require.True(t, ok) + assert.InDelta(t, 1.0, lum, 0.001) + + lum, ok = RelativeLuminanceHex("#000000") + require.True(t, ok) + assert.InDelta(t, 0.0, lum, 0.001) +} + +// --- Contrast ratio --- + +func TestContrastRatio_BlackWhite(t *testing.T) { + t.Parallel() + black := lipgloss.Color("#000000") + white := lipgloss.Color("#ffffff") + ratio := ContrastRatio(black, white) + assert.InDelta(t, 21.0, ratio, 0.1) +} + +func TestContrastRatio_SameColor(t *testing.T) { + t.Parallel() + c := lipgloss.Color("#808080") + ratio := ContrastRatio(c, c) + assert.InDelta(t, 1.0, ratio, 0.001) +} + +func TestContrastRatioHex(t *testing.T) { + t.Parallel() + ratio, ok := ContrastRatioHex("#000000", "#ffffff") + require.True(t, ok) + assert.InDelta(t, 21.0, ratio, 0.1) +} + +func TestBestForegroundHex(t *testing.T) { + t.Parallel() + // On dark background, white should win + best := BestForegroundHex("#000000", "#333333", "#ffffff") + assert.Equal(t, "#ffffff", best) + + // On light background, black should win + best = BestForegroundHex("#ffffff", "#000000", "#cccccc") + assert.Equal(t, "#000000", best) +} + +// --- HSL conversion --- + +func TestRGBToHSL_Red(t *testing.T) { + t.Parallel() + h, s, l := RGBToHSL(1, 0, 0) + assert.InDelta(t, 0, h, 0.1) + assert.InDelta(t, 1.0, s, 0.01) + assert.InDelta(t, 0.5, l, 0.01) +} + +func TestRGBToHSL_Green(t *testing.T) { + t.Parallel() + h, s, l := RGBToHSL(0, 1, 0) + assert.InDelta(t, 120, h, 0.1) + assert.InDelta(t, 1.0, s, 0.01) + assert.InDelta(t, 0.5, l, 0.01) +} + +func TestRGBToHSL_Blue(t *testing.T) { + t.Parallel() + h, s, l := RGBToHSL(0, 0, 1) + assert.InDelta(t, 240, h, 0.1) + assert.InDelta(t, 1.0, s, 0.01) + assert.InDelta(t, 0.5, l, 0.01) +} + +func TestRGBToHSL_Gray(t *testing.T) { + t.Parallel() + h, s, l := RGBToHSL(0.5, 0.5, 0.5) + _ = h // hue is undefined for gray + assert.InDelta(t, 0.0, s, 0.01) + assert.InDelta(t, 0.5, l, 0.01) +} + +func TestHSLToRGB_Roundtrip(t *testing.T) { + t.Parallel() + testCases := []struct { + r, g, b float64 + }{ + {1, 0, 0}, + {0, 1, 0}, + {0, 0, 1}, + {0.5, 0.5, 0.5}, + {0.2, 0.6, 0.8}, + } + for _, tc := range testCases { + h, s, l := RGBToHSL(tc.r, tc.g, tc.b) + r, g, b := HSLToRGB(h, s, l) + assert.InDelta(t, tc.r, r, 0.01, "r mismatch for input %v", tc) + assert.InDelta(t, tc.g, g, 0.01, "g mismatch for input %v", tc) + assert.InDelta(t, tc.b, b, 0.01, "b mismatch for input %v", tc) + } +} + +// --- Dynamic contrast helpers --- + +func TestMutedContrastFg_DarkBg(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#1C1C22") + fg := MutedContrastFg(bg) + // Should produce a lighter color than the background + bgLum := RelativeLuminanceColor(bg) + fgLum := RelativeLuminanceColor(fg) + assert.Greater(t, fgLum, bgLum) +} + +func TestMutedContrastFg_LightBg(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#eff1f5") + fg := MutedContrastFg(bg) + // Should produce a darker color than the background + bgLum := RelativeLuminanceColor(bg) + fgLum := RelativeLuminanceColor(fg) + assert.Less(t, fgLum, bgLum) +} + +func TestEnsureContrast_AlreadySufficient(t *testing.T) { + t.Parallel() + fg := lipgloss.Color("#ffffff") + bg := lipgloss.Color("#000000") + result := EnsureContrast(fg, bg) + // White on black already has 21:1, should be unchanged + r1, g1, b1 := ColorToRGB(fg) + r2, g2, b2 := ColorToRGB(result) + assert.InDelta(t, r1, r2, 0.01) + assert.InDelta(t, g1, g2, 0.01) + assert.InDelta(t, b1, b2, 0.01) +} + +func TestEnsureContrast_BoostsLowContrast(t *testing.T) { + t.Parallel() + fg := lipgloss.Color("#333333") + bg := lipgloss.Color("#222222") + result := EnsureContrast(fg, bg) + ratio := ContrastRatio(result, bg) + assert.GreaterOrEqual(t, ratio, MinIndicatorContrast) +} + +// --- Palette generation --- + +func TestGenerateBadgePalette_CorrectLength(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#1C1C22") + palette := GenerateBadgePalette(DefaultAgentHues, bg) + assert.Len(t, palette, len(DefaultAgentHues)) +} + +func TestGenerateBadgePalette_AllDistinct(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#1C1C22") + palette := GenerateBadgePalette(DefaultAgentHues, bg) + hexSet := make(map[string]bool) + for _, c := range palette { + r, g, b := ColorToRGB(c) + hex := RGBToHex(r, g, b) + hexSet[hex] = true + } + assert.Len(t, hexSet, len(palette), "all generated colors should be distinct") +} + +func TestGenerateAccentPalette_CorrectLength(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#1C1C22") + palette := GenerateAccentPalette(DefaultAgentHues, bg) + assert.Len(t, palette, len(DefaultAgentHues)) +} + +func TestGenerateBadgePalette_DarkVsLight(t *testing.T) { + t.Parallel() + darkBg := lipgloss.Color("#1C1C22") + lightBg := lipgloss.Color("#eff1f5") + darkPalette := GenerateBadgePalette(DefaultAgentHues[:1], darkBg) + lightPalette := GenerateBadgePalette(DefaultAgentHues[:1], lightBg) + + // Same hue should produce different lightness for dark vs light bg + darkLum := RelativeLuminanceColor(darkPalette[0]) + lightLum := RelativeLuminanceColor(lightPalette[0]) + assert.Greater(t, math.Abs(darkLum-lightLum), 0.01, "dark and light themes should produce different badge lightness") +} + +// --- Perceptual distance --- + +func TestColorDistanceCIE76_Identical(t *testing.T) { + t.Parallel() + c := lipgloss.Color("#FF0000") + assert.InDelta(t, 0, ColorDistanceCIE76(c, c), 0.001) +} + +func TestColorDistanceCIE76_BlackWhite(t *testing.T) { + t.Parallel() + black := lipgloss.Color("#000000") + white := lipgloss.Color("#ffffff") + dist := ColorDistanceCIE76(black, white) + assert.Greater(t, dist, 50.0, "black and white should be very far apart in CIELAB") +} + +func TestColorDistanceCIE76_SimilarColors(t *testing.T) { + t.Parallel() + c1 := lipgloss.Color("#FF0000") + c2 := lipgloss.Color("#FF1100") + dist := ColorDistanceCIE76(c1, c2) + assert.Less(t, dist, 10.0, "very similar colors should have small distance") +} + +// --- ColorToRGB --- + +func TestColorToRGB_KnownValues(t *testing.T) { + t.Parallel() + c := lipgloss.Color("#ff0000") + r, g, b := ColorToRGB(c) + assert.InDelta(t, 1.0, r, 0.01) + assert.InDelta(t, 0.0, g, 0.01) + assert.InDelta(t, 0.0, b, 0.01) +} + +// --- DefaultAgentHues --- + +func TestDefaultAgentHues_Length(t *testing.T) { + t.Parallel() + assert.Len(t, DefaultAgentHues, 16) +} + +func TestDefaultAgentHues_InRange(t *testing.T) { + t.Parallel() + for i, h := range DefaultAgentHues { + assert.GreaterOrEqual(t, h, 0.0, "hue %d out of range", i) + assert.Less(t, h, 360.0, "hue %d out of range", i) + } +} + +// --- Helper to verify color.Color interface --- + +func TestRGBToColor_ImplementsInterface(t *testing.T) { + t.Parallel() + c := RGBToColor(0.5, 0.5, 0.5) + r, g, b, a := c.RGBA() + assert.NotZero(t, r) + assert.NotZero(t, g) + assert.NotZero(t, b) + assert.NotZero(t, a) +} + +// --- CIELAB internals --- + +func TestLabF_BelowThreshold(t *testing.T) { + t.Parallel() + // labF should handle very small values + result := labF(0.001) + assert.False(t, math.IsNaN(result)) + assert.False(t, math.IsInf(result, 0)) +} diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index da5abc78e..6ac20c0b6 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -3,11 +3,9 @@ package styles import ( "embed" "fmt" - "math" "os" "path/filepath" "slices" - "strconv" "strings" "sync" "sync/atomic" @@ -919,7 +917,7 @@ func ApplyTheme(theme *Theme) { PlaceholderColor = lipgloss.Color(c.Placeholder) // Badge colors AgentBadgeBg = MobyBlue - AgentBadgeFg = lipgloss.Color(bestForegroundHex( + AgentBadgeFg = lipgloss.Color(BestForegroundHex( c.Brand, c.TextBright, c.Background, @@ -1220,92 +1218,6 @@ func rebuildStyles() { SpinnerTextDimmestStyle = BaseStyle.Foreground(Accent) } -func bestForegroundHex(bgHex string, candidates ...string) string { - if len(candidates) == 0 { - return "" - } - best := candidates[0] - bestRatio := -1.0 - - for _, cand := range candidates { - ratio, ok := contrastRatioHex(cand, bgHex) - if !ok { - continue - } - if ratio > bestRatio { - bestRatio = ratio - best = cand - } - } - - return best -} - -func contrastRatioHex(fgHex, bgHex string) (float64, bool) { - fgLum, ok := relativeLuminanceHex(fgHex) - if !ok { - return 0, false - } - bgLum, ok := relativeLuminanceHex(bgHex) - if !ok { - return 0, false - } - - L1, L2 := fgLum, bgLum - if L2 > L1 { - L1, L2 = L2, L1 - } - - return (L1 + 0.05) / (L2 + 0.05), true -} - -func relativeLuminanceHex(hex string) (float64, bool) { - r, g, b, ok := parseHexRGB01(hex) - if !ok { - return 0, false - } - - // WCAG 2.x relative luminance for sRGB - rl := 0.2126*srgbToLinear(r) + 0.7152*srgbToLinear(g) + 0.0722*srgbToLinear(b) - return rl, true -} - -func srgbToLinear(c float64) float64 { - if c <= 0.03928 { - return c / 12.92 - } - return math.Pow((c+0.055)/1.055, 2.4) -} - -func parseHexRGB01(hex string) (float64, float64, float64, bool) { - if !strings.HasPrefix(hex, "#") { - return 0, 0, 0, false - } - - h := strings.TrimPrefix(hex, "#") - if len(h) == 3 { - h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) - } - if len(h) != 6 { - return 0, 0, 0, false - } - - r8, err := strconv.ParseUint(h[0:2], 16, 8) - if err != nil { - return 0, 0, 0, false - } - g8, err := strconv.ParseUint(h[2:4], 16, 8) - if err != nil { - return 0, 0, 0, false - } - b8, err := strconv.ParseUint(h[4:6], 16, 8) - if err != nil { - return 0, 0, 0, false - } - - return float64(r8) / 255.0, float64(g8) / 255.0, float64(b8) / 255.0, true -} - // init applies the default theme at package initialization time. // This ensures color variables are set before any code uses them, // including tests that don't explicitly call ApplyTheme(). From c2ca1d5c886a2c18a60795f488a79898b986ac4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Tue, 17 Feb 2026 20:17:38 +0100 Subject: [PATCH 3/5] feat(#1756): hue-based agent color generation with theme integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded agent color palettes with dynamic generation from HSL hue values. Each theme can define agent_hues (16 hue values 0-360) in its YAML; colors auto-adapt saturation and lightness based on the theme background (dark vs light). Validation across all 10 built-in themes: - WCAG AA badge contrast (≥4.5:1 fg/bg) — all pass - WCAG AA accent contrast (≥3.0:1 vs background) — all pass - CIE76 pairwise distinctness (ΔE≥10) — all pass - Color audit report available via go test -v Assisted-By: cagent --- pkg/tui/styles/agent_colors.go | 98 ++++------ pkg/tui/styles/agent_colors_test.go | 270 ++++++++++++++++++++++++++-- pkg/tui/styles/colorutil.go | 8 +- pkg/tui/styles/theme.go | 7 + pkg/tui/styles/theme_test.go | 22 ++- pkg/tui/styles/themes/default.yaml | 3 + 6 files changed, 323 insertions(+), 85 deletions(-) diff --git a/pkg/tui/styles/agent_colors.go b/pkg/tui/styles/agent_colors.go index 248259835..8f74018e6 100644 --- a/pkg/tui/styles/agent_colors.go +++ b/pkg/tui/styles/agent_colors.go @@ -7,50 +7,6 @@ import ( "charm.land/lipgloss/v2" ) -// agentColorPalette defines distinct background colors for agent badges. -// These are chosen to be visually distinguishable and to provide good -// contrast with white text on dark backgrounds. -var agentColorPalette = []string{ - "#1D63ED", // Blue - "#9B59B6", // Purple - "#1ABC9C", // Teal - "#E67E22", // Orange - "#E74C8B", // Pink - "#27AE60", // Green - "#2980B9", // Steel blue - "#8E44AD", // Deep purple - "#D4AC0D", // Gold - "#C0392B", // Red - "#16A085", // Dark teal - "#D35400", // Burnt orange - "#2C3E99", // Indigo - "#7D3C98", // Plum - "#117864", // Forest green - "#A93226", // Crimson -} - -// agentAccentPalette defines foreground accent colors for agent names in the sidebar. -// These are brighter variants designed to be readable on dark backgrounds without -// a background fill. -var agentAccentPalette = []string{ - "#98C379", // Green - "#C678DD", // Purple - "#56B6C2", // Cyan - "#E5C07B", // Yellow - "#E06C9F", // Pink - "#61AFEF", // Blue - "#D19A66", // Orange - "#BE5046", // Red - "#73C991", // Mint - "#CDA0E0", // Lavender - "#4EC9B0", // Turquoise - "#DCDCAA", // Khaki - "#9CDCFE", // Ice blue - "#CE9178", // Salmon - "#B5CEA8", // Sage - "#D7BA7D", // Tan -} - // AgentBadgeColors holds the resolved foreground and background colors for an agent badge. type AgentBadgeColors struct { Fg color.Color @@ -64,7 +20,7 @@ type cachedBadgeStyle struct { } // agentRegistry maps agent names to their index in the team list and holds -// precomputed styles for each palette index. +// precomputed styles for each palette entry. var agentRegistry struct { sync.RWMutex indices map[string]int @@ -86,13 +42,24 @@ func SetAgentOrder(agentNames []string) { rebuildAgentColorCache() } -// rebuildAgentColorCache precomputes badge and accent styles for all palette indices. +// rebuildAgentColorCache precomputes badge and accent styles from the current theme's hues. // Must be called with agentRegistry.Lock held. func rebuildAgentColorCache() { theme := CurrentTheme() - agentRegistry.badgeStyles = make([]cachedBadgeStyle, len(agentColorPalette)) - for i, bgHex := range agentColorPalette { + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + badgeColors := GenerateBadgePalette(hues, bg) + accentColors := GenerateAccentPalette(hues, bg) + + agentRegistry.badgeStyles = make([]cachedBadgeStyle, len(badgeColors)) + for i, bgColor := range badgeColors { + r, g, b := ColorToRGB(bgColor) + bgHex := RGBToHex(r, g, b) fgHex := BestForegroundHex( bgHex, theme.Colors.TextBright, @@ -102,7 +69,7 @@ func rebuildAgentColorCache() { ) colors := AgentBadgeColors{ Fg: lipgloss.Color(fgHex), - Bg: lipgloss.Color(bgHex), + Bg: bgColor, } agentRegistry.badgeStyles[i] = cachedBadgeStyle{ colors: colors, @@ -113,14 +80,14 @@ func rebuildAgentColorCache() { } } - agentRegistry.accentStyles = make([]lipgloss.Style, len(agentAccentPalette)) - for i, hex := range agentAccentPalette { - agentRegistry.accentStyles[i] = BaseStyle.Foreground(lipgloss.Color(hex)) + agentRegistry.accentStyles = make([]lipgloss.Style, len(accentColors)) + for i, c := range accentColors { + agentRegistry.accentStyles[i] = BaseStyle.Foreground(c) } } // InvalidateAgentColorCache rebuilds the cached agent styles. -// Call this after a theme change so foreground contrast is recalculated. +// Call this after a theme change so colors are recalculated against the new background. func InvalidateAgentColorCache() { agentRegistry.Lock() defer agentRegistry.Unlock() @@ -128,16 +95,28 @@ func InvalidateAgentColorCache() { rebuildAgentColorCache() } +// paletteSize returns the current number of cached palette entries. +func paletteSize() int { + agentRegistry.RLock() + defer agentRegistry.RUnlock() + + return len(agentRegistry.badgeStyles) +} + // agentIndex returns the palette index for an agent name. // Uses the registered position if available, wrapping around the palette size. // Falls back to 0 for unknown agents. func agentIndex(agentName string) int { agentRegistry.RLock() idx, ok := agentRegistry.indices[agentName] + size := len(agentRegistry.badgeStyles) agentRegistry.RUnlock() - if ok { - return idx % len(agentColorPalette) + if !ok { + return 0 + } + if size > 0 { + return idx % size } return 0 } @@ -153,10 +132,9 @@ func AgentBadgeColorsFor(agentName string) AgentBadgeColors { return agentRegistry.badgeStyles[idx].colors } - // Fallback if cache is not yet initialized return AgentBadgeColors{ Fg: lipgloss.Color("#ffffff"), - Bg: lipgloss.Color(agentColorPalette[idx]), + Bg: lipgloss.Color("#1D63ED"), } } @@ -171,10 +149,9 @@ func AgentBadgeStyleFor(agentName string) lipgloss.Style { return agentRegistry.badgeStyles[idx].style } - // Fallback if cache is not yet initialized return BaseStyle. Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color(agentColorPalette[idx])). + Background(lipgloss.Color("#1D63ED")). Padding(0, 1) } @@ -189,6 +166,5 @@ func AgentAccentStyleFor(agentName string) lipgloss.Style { return agentRegistry.accentStyles[idx] } - // Fallback if cache is not yet initialized - return BaseStyle.Foreground(lipgloss.Color(agentAccentPalette[idx])) + return BaseStyle.Foreground(lipgloss.Color("#98C379")) } diff --git a/pkg/tui/styles/agent_colors_test.go b/pkg/tui/styles/agent_colors_test.go index 861ea2f35..61daa489e 100644 --- a/pkg/tui/styles/agent_colors_test.go +++ b/pkg/tui/styles/agent_colors_test.go @@ -1,12 +1,16 @@ package styles import ( + "fmt" "testing" + "charm.land/lipgloss/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// --- Agent index and registry tests --- + func TestAgentIndex_UsesRegisteredOrder(t *testing.T) { SetAgentOrder([]string{"root", "git-agent", "docs-writer"}) defer SetAgentOrder(nil) @@ -24,17 +28,20 @@ func TestAgentIndex_UnknownAgentReturnsFallback(t *testing.T) { } func TestAgentIndex_WrapsAroundPaletteSize(t *testing.T) { - agents := make([]string, len(agentColorPalette)+3) + size := paletteSize() + require.Positive(t, size) + + agents := make([]string, size+3) for i := range agents { - agents[i] = "agent-" + string(rune('a'+i)) + agents[i] = fmt.Sprintf("agent-%d", i) } SetAgentOrder(agents) defer SetAgentOrder(nil) last := agents[len(agents)-1] idx := agentIndex(last) - assert.Less(t, idx, len(agentColorPalette)) - assert.Equal(t, (len(agentColorPalette)+2)%len(agentColorPalette), idx) + assert.Less(t, idx, size) + assert.Equal(t, (size+2)%size, idx) } func TestAgentIndex_EmptyRegistryReturnsFallback(t *testing.T) { @@ -56,6 +63,8 @@ func TestSetAgentOrder_UpdatesRegistry(t *testing.T) { assert.Equal(t, 0, agentIndex("c")) } +// --- Style rendering tests --- + func TestAgentBadgeStyleFor_ProducesDifferentStylesPerIndex(t *testing.T) { SetAgentOrder([]string{"root", "docs-writer"}) defer SetAgentOrder(nil) @@ -95,13 +104,7 @@ func TestAgentBadgeColorsFor_HasFgAndBg(t *testing.T) { assert.NotNil(t, colors.Bg) } -func TestPaletteSizes_AreEqual(t *testing.T) { - t.Parallel() - - assert.Len(t, agentAccentPalette, len(agentColorPalette), - "badge and accent palettes must have the same number of entries") - assert.Len(t, agentColorPalette, 16) -} +// --- Cache tests --- func TestSetAgentOrder_PopulatesCache(t *testing.T) { SetAgentOrder([]string{"root", "docs-writer"}) @@ -110,8 +113,9 @@ func TestSetAgentOrder_PopulatesCache(t *testing.T) { agentRegistry.RLock() defer agentRegistry.RUnlock() - assert.Len(t, agentRegistry.badgeStyles, len(agentColorPalette)) - assert.Len(t, agentRegistry.accentStyles, len(agentAccentPalette)) + assert.NotEmpty(t, agentRegistry.badgeStyles) + assert.NotEmpty(t, agentRegistry.accentStyles) + assert.Len(t, agentRegistry.accentStyles, len(agentRegistry.badgeStyles)) } func TestInvalidateAgentColorCache_RebuildsCachedStyles(t *testing.T) { @@ -129,9 +133,247 @@ func TestAgentBadgeStyleFor_UsesCachedStyle(t *testing.T) { SetAgentOrder([]string{"a", "b"}) defer SetAgentOrder(nil) - // Calling AgentBadgeStyleFor repeatedly should return identical styles from cache for range 100 { s := AgentBadgeStyleFor("b").Render("b") require.NotEmpty(t, s) } } + +// --- Layer 1: WCAG contrast validation across all themes --- + +const ( + // minBadgeContrast is the WCAG AA minimum for normal text. + minBadgeContrast = 4.5 + // minAccentContrast is the WCAG AA minimum for large/bold text. + minAccentContrast = 3.0 +) + +func TestAllBuiltinThemes_AgentBadgeContrast(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + require.NotEmpty(t, refs) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + badgeColors := GenerateBadgePalette(hues, bg) + + for i, badgeBg := range badgeColors { + r, g, b := ColorToRGB(badgeBg) + bgHex := RGBToHex(r, g, b) + fgHex := BestForegroundHex( + bgHex, + theme.Colors.TextBright, + theme.Colors.Background, + "#000000", + "#ffffff", + ) + fg := lipgloss.Color(fgHex) + + ratio := ContrastRatio(fg, badgeBg) + assert.GreaterOrEqual(t, ratio, minBadgeContrast, + "badge %d (bg=%s, fg=%s) contrast %.2f < %.1f in theme %s", + i, bgHex, fgHex, ratio, minBadgeContrast, ref) + } + }) + } +} + +func TestAllBuiltinThemes_AgentAccentContrast(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + require.NotEmpty(t, refs) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + accentColors := GenerateAccentPalette(hues, bg) + + for i, accent := range accentColors { + ratio := ContrastRatio(accent, bg) + r, g, b := ColorToRGB(accent) + hex := RGBToHex(r, g, b) + assert.GreaterOrEqual(t, ratio, minAccentContrast, + "accent %d (%s) contrast %.2f < %.1f against bg %s in theme %s", + i, hex, ratio, minAccentContrast, theme.Colors.Background, ref) + } + }) + } +} + +// --- Layer 1: Pairwise color distinctness across all themes --- + +const ( + // minColorDistance is the minimum CIE76 ΔE between adjacent palette entries. + // Below ~15 colors become hard to distinguish at a glance. + minColorDistance = 10.0 +) + +func TestAllBuiltinThemes_AgentBadgeDistinctness(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + require.NotEmpty(t, refs) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + palette := GenerateBadgePalette(hues, bg) + + for i := range palette { + for j := i + 1; j < len(palette); j++ { + dist := ColorDistanceCIE76(palette[i], palette[j]) + assert.GreaterOrEqual(t, dist, minColorDistance, + "badge colors %d and %d are too similar (ΔE=%.1f) in theme %s", + i, j, dist, ref) + } + } + }) + } +} + +func TestAllBuiltinThemes_AgentAccentDistinctness(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + require.NotEmpty(t, refs) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + palette := GenerateAccentPalette(hues, bg) + + for i := range palette { + for j := i + 1; j < len(palette); j++ { + dist := ColorDistanceCIE76(palette[i], palette[j]) + assert.GreaterOrEqual(t, dist, minColorDistance, + "accent colors %d and %d are too similar (ΔE=%.1f) in theme %s", + i, j, dist, ref) + } + } + }) + } +} + +// --- Layer 2: Color audit report (run with -v) --- + +func TestAgentColorAuditReport(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + badges := GenerateBadgePalette(hues, bg) + accents := GenerateAccentPalette(hues, bg) + + t.Logf("\n=== Agent Color Audit: %s (bg: %s) ===", theme.Name, theme.Colors.Background) + t.Logf("%-5s %-10s %-10s %-10s %-12s %-10s %-10s", + "Idx", "Hue", "Badge", "Badge FG", "Badge CR", "Accent", "Accent CR") + t.Logf("%-5s %-10s %-10s %-10s %-12s %-10s %-10s", + "---", "---", "---", "---", "---", "---", "---") + + for i := range hues { + br, bg2, bb := ColorToRGB(badges[i]) + badgeHex := RGBToHex(br, bg2, bb) + + fgHex := BestForegroundHex(badgeHex, + theme.Colors.TextBright, theme.Colors.Background, + "#000000", "#ffffff") + fg := lipgloss.Color(fgHex) + badgeCR := ContrastRatio(fg, badges[i]) + + ar, ag, ab := ColorToRGB(accents[i]) + accentHex := RGBToHex(ar, ag, ab) + accentCR := ContrastRatio(accents[i], bg) + + badgeStatus := "✓" + if badgeCR < minBadgeContrast { + badgeStatus = "✗" + } + accentStatus := "✓" + if accentCR < minAccentContrast { + accentStatus = "✗" + } + + t.Logf("%-5d %-10.0f %-10s %-10s %s %-9.2f %-10s %s %.2f", + i, hues[i], badgeHex, fgHex, badgeStatus, badgeCR, + accentHex, accentStatus, accentCR) + } + + // Log minimum pairwise distances + minBadgeDist := 999.0 + minAccentDist := 999.0 + for i := range badges { + for j := i + 1; j < len(badges); j++ { + if d := ColorDistanceCIE76(badges[i], badges[j]); d < minBadgeDist { + minBadgeDist = d + } + if d := ColorDistanceCIE76(accents[i], accents[j]); d < minAccentDist { + minAccentDist = d + } + } + } + t.Logf("\nMin badge pairwise ΔE: %.1f (threshold: %.1f)", minBadgeDist, minColorDistance) + t.Logf("Min accent pairwise ΔE: %.1f (threshold: %.1f)", minAccentDist, minColorDistance) + }) + } +} diff --git a/pkg/tui/styles/colorutil.go b/pkg/tui/styles/colorutil.go index 790eecfbc..3349d7b31 100644 --- a/pkg/tui/styles/colorutil.go +++ b/pkg/tui/styles/colorutil.go @@ -310,7 +310,7 @@ var DefaultAgentHues = []float64{ 330, // Pink 140, // Green 200, // Steel blue - 265, // Deep purple + 260, // Deep purple 50, // Gold 0, // Red 185, // Dark teal @@ -318,7 +318,7 @@ var DefaultAgentHues = []float64{ 235, // Indigo 295, // Plum 155, // Forest green - 350, // Crimson + 340, // Crimson } // GenerateBadgePalette generates badge background colors from hues, adapting @@ -363,8 +363,8 @@ func GenerateAccentPalette(hues []float64, bg color.Color) []color.Color { s = 0.55 + 0.15*math.Sin(float64(i)*0.5) l = 0.68 + 0.08*math.Cos(float64(i)*0.7) } else { - s = 0.65 + 0.15*math.Sin(float64(i)*0.5) - l = 0.35 + 0.08*math.Cos(float64(i)*0.7) + s = 0.70 + 0.15*math.Sin(float64(i)*0.5) + l = 0.30 + 0.06*math.Cos(float64(i)*0.7) } r, g, b := HSLToRGB(hue, s, l) diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index 6ac20c0b6..9796ba52e 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -133,6 +133,9 @@ type ThemeColors struct { BadgeAccent string `yaml:"badge_accent,omitempty"` // Accent badge (e.g., purple highlights) BadgeInfo string `yaml:"badge_info,omitempty"` // Info badge (e.g., cyan) BadgeSuccess string `yaml:"badge_success,omitempty"` // Success badge (e.g., green) + + // Agent colors + AgentHues []float64 `yaml:"agent_hues,omitempty"` // Hue values (0-360) for agent color generation } // ChromaColors contains syntax highlighting colors (for code blocks). @@ -756,6 +759,10 @@ func mergeColors(base, override ThemeColors) ThemeColors { if override.BadgeSuccess != "" { result.BadgeSuccess = override.BadgeSuccess } + // Agent colors + if len(override.AgentHues) > 0 { + result.AgentHues = override.AgentHues + } return result } diff --git a/pkg/tui/styles/theme_test.go b/pkg/tui/styles/theme_test.go index a66b989ff..7fa40c745 100644 --- a/pkg/tui/styles/theme_test.go +++ b/pkg/tui/styles/theme_test.go @@ -281,28 +281,38 @@ func TestDefaultTheme_AllColorsPopulated(t *testing.T) { func TestMergeColors_HandlesAllFields(t *testing.T) { t.Parallel() - // Create a base with all fields set to "BASE" + // Create a base with all string fields set to "BASE" base := ThemeColors{} baseVal := reflect.ValueOf(&base).Elem() for _, field := range baseVal.Fields() { - field.SetString("BASE") + if field.Kind() == reflect.String { + field.SetString("BASE") + } } + base.AgentHues = []float64{10, 20} - // Create an override with all fields set to "OVERRIDE" + // Create an override with all string fields set to "OVERRIDE" override := ThemeColors{} overrideVal := reflect.ValueOf(&override).Elem() for _, field := range overrideVal.Fields() { - field.SetString("OVERRIDE") + if field.Kind() == reflect.String { + field.SetString("OVERRIDE") + } } + override.AgentHues = []float64{30, 40, 50} // Merge should replace all base values with override values merged := mergeColors(base, override) mergedVal := reflect.ValueOf(merged) for field, value := range mergedVal.Fields() { - assert.Equal(t, "OVERRIDE", value.String(), - "mergeColors() doesn't handle ThemeColors.%s - add merge logic in mergeColors()", field.Name) + if value.Kind() == reflect.String { + assert.Equal(t, "OVERRIDE", value.String(), + "mergeColors() doesn't handle ThemeColors.%s - add merge logic in mergeColors()", field.Name) + } } + assert.Equal(t, []float64{30, 40, 50}, merged.AgentHues, + "mergeColors() doesn't handle ThemeColors.AgentHues") } // TestMergeChromaColors_HandlesAllFields ensures mergeChromaColors handles every ChromaColors field. diff --git a/pkg/tui/styles/themes/default.yaml b/pkg/tui/styles/themes/default.yaml index 3e2c72a18..566bf9535 100644 --- a/pkg/tui/styles/themes/default.yaml +++ b/pkg/tui/styles/themes/default.yaml @@ -62,6 +62,9 @@ colors: badge_info: "#7DCFFF" badge_success: "#9ECE6A" + # Agent colors (hue values 0-360 for dynamic palette generation) + agent_hues: [220, 280, 170, 30, 330, 140, 200, 260, 50, 0, 185, 20, 235, 295, 155, 340] + chroma: # Syntax highlighting colors (Monokai-inspired) error_fg: "#F1F1F1" From 6726b2a41145a89a95eb0c687c9bc67b108caae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Wed, 18 Feb 2026 22:57:26 +0100 Subject: [PATCH 4/5] fix(#1756): race condition in agent color style lookups Hold a single RLock across both the index lookup and the cached style array access in AgentBadgeColorsFor, AgentBadgeStyleFor, and AgentAccentStyleFor. Previously, agentIndex() acquired and released a separate lock before the caller re-locked to read the style arrays, allowing SetAgentOrder() to rebuild the registry in between and causing stale indices to reference wrong colors or fall through to defaults. Assisted-By: cagent --- pkg/tui/styles/agent_colors.go | 64 +++++++++++++---------------- pkg/tui/styles/agent_colors_test.go | 51 ++++++++++++++--------- 2 files changed, 60 insertions(+), 55 deletions(-) diff --git a/pkg/tui/styles/agent_colors.go b/pkg/tui/styles/agent_colors.go index 8f74018e6..29d38391b 100644 --- a/pkg/tui/styles/agent_colors.go +++ b/pkg/tui/styles/agent_colors.go @@ -95,41 +95,22 @@ func InvalidateAgentColorCache() { rebuildAgentColorCache() } -// paletteSize returns the current number of cached palette entries. -func paletteSize() int { +// AgentBadgeColorsFor returns the badge foreground/background colors for a given agent name. +func AgentBadgeColorsFor(agentName string) AgentBadgeColors { agentRegistry.RLock() defer agentRegistry.RUnlock() - return len(agentRegistry.badgeStyles) -} - -// agentIndex returns the palette index for an agent name. -// Uses the registered position if available, wrapping around the palette size. -// Falls back to 0 for unknown agents. -func agentIndex(agentName string) int { - agentRegistry.RLock() idx, ok := agentRegistry.indices[agentName] - size := len(agentRegistry.badgeStyles) - agentRegistry.RUnlock() - if !ok { - return 0 - } - if size > 0 { - return idx % size + return AgentBadgeColors{ + Fg: lipgloss.Color("#ffffff"), + Bg: lipgloss.Color("#1D63ED"), + } } - return 0 -} - -// AgentBadgeColorsFor returns the badge foreground/background colors for a given agent name. -func AgentBadgeColorsFor(agentName string) AgentBadgeColors { - idx := agentIndex(agentName) - - agentRegistry.RLock() - defer agentRegistry.RUnlock() - if idx < len(agentRegistry.badgeStyles) { - return agentRegistry.badgeStyles[idx].colors + size := len(agentRegistry.badgeStyles) + if size > 0 { + return agentRegistry.badgeStyles[idx%size].colors } return AgentBadgeColors{ @@ -140,13 +121,20 @@ func AgentBadgeColorsFor(agentName string) AgentBadgeColors { // AgentBadgeStyleFor returns a lipgloss badge style colored for the given agent. func AgentBadgeStyleFor(agentName string) lipgloss.Style { - idx := agentIndex(agentName) - agentRegistry.RLock() defer agentRegistry.RUnlock() - if idx < len(agentRegistry.badgeStyles) { - return agentRegistry.badgeStyles[idx].style + idx, ok := agentRegistry.indices[agentName] + if !ok { + return BaseStyle. + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#1D63ED")). + Padding(0, 1) + } + + size := len(agentRegistry.badgeStyles) + if size > 0 { + return agentRegistry.badgeStyles[idx%size].style } return BaseStyle. @@ -157,13 +145,17 @@ func AgentBadgeStyleFor(agentName string) lipgloss.Style { // AgentAccentStyleFor returns a foreground-only style for agent names (used in sidebar). func AgentAccentStyleFor(agentName string) lipgloss.Style { - idx := agentIndex(agentName) - agentRegistry.RLock() defer agentRegistry.RUnlock() - if idx < len(agentRegistry.accentStyles) { - return agentRegistry.accentStyles[idx] + idx, ok := agentRegistry.indices[agentName] + if !ok { + return BaseStyle.Foreground(lipgloss.Color("#98C379")) + } + + size := len(agentRegistry.accentStyles) + if size > 0 { + return agentRegistry.accentStyles[idx%size] } return BaseStyle.Foreground(lipgloss.Color("#98C379")) diff --git a/pkg/tui/styles/agent_colors_test.go b/pkg/tui/styles/agent_colors_test.go index 61daa489e..d95e36492 100644 --- a/pkg/tui/styles/agent_colors_test.go +++ b/pkg/tui/styles/agent_colors_test.go @@ -9,26 +9,34 @@ import ( "github.com/stretchr/testify/require" ) -// --- Agent index and registry tests --- +// --- Agent registry and color assignment tests --- -func TestAgentIndex_UsesRegisteredOrder(t *testing.T) { +func TestAgentBadgeStyleFor_UsesRegisteredOrder(t *testing.T) { SetAgentOrder([]string{"root", "git-agent", "docs-writer"}) defer SetAgentOrder(nil) - assert.Equal(t, 0, agentIndex("root")) - assert.Equal(t, 1, agentIndex("git-agent")) - assert.Equal(t, 2, agentIndex("docs-writer")) + // Each agent should get a distinct style based on its position. + r1 := AgentBadgeStyleFor("root").Render("x") + r2 := AgentBadgeStyleFor("git-agent").Render("x") + r3 := AgentBadgeStyleFor("docs-writer").Render("x") + assert.NotEqual(t, r1, r2) + assert.NotEqual(t, r2, r3) + assert.NotEqual(t, r1, r3) } -func TestAgentIndex_UnknownAgentReturnsFallback(t *testing.T) { +func TestAgentBadgeStyleFor_UnknownAgentReturnsFallback(t *testing.T) { SetAgentOrder([]string{"root", "git-agent"}) defer SetAgentOrder(nil) - assert.Equal(t, 0, agentIndex("unknown-agent")) + // Unknown agent should get the fallback style, same as calling with no registration. + s := AgentBadgeStyleFor("unknown-agent").Render("x") + require.NotEmpty(t, s) } -func TestAgentIndex_WrapsAroundPaletteSize(t *testing.T) { - size := paletteSize() +func TestAgentBadgeStyleFor_WrapsAroundPaletteSize(t *testing.T) { + agentRegistry.RLock() + size := len(agentRegistry.badgeStyles) + agentRegistry.RUnlock() require.Positive(t, size) agents := make([]string, size+3) @@ -38,29 +46,34 @@ func TestAgentIndex_WrapsAroundPaletteSize(t *testing.T) { SetAgentOrder(agents) defer SetAgentOrder(nil) - last := agents[len(agents)-1] - idx := agentIndex(last) - assert.Less(t, idx, size) - assert.Equal(t, (size+2)%size, idx) + // The last agent wraps around, so it should match the style at (size+2)%size. + last := AgentBadgeStyleFor(agents[len(agents)-1]).Render("x") + wrapped := AgentBadgeStyleFor(agents[(size+2)%size]).Render("x") + assert.Equal(t, last, wrapped) } -func TestAgentIndex_EmptyRegistryReturnsFallback(t *testing.T) { +func TestAgentBadgeStyleFor_EmptyRegistryReturnsFallback(t *testing.T) { SetAgentOrder(nil) defer SetAgentOrder(nil) - assert.Equal(t, 0, agentIndex("anything")) + s := AgentBadgeStyleFor("anything").Render("x") + require.NotEmpty(t, s) } func TestSetAgentOrder_UpdatesRegistry(t *testing.T) { SetAgentOrder([]string{"a", "b", "c"}) defer SetAgentOrder(nil) - assert.Equal(t, 0, agentIndex("a")) - assert.Equal(t, 2, agentIndex("c")) + styleA1 := AgentBadgeStyleFor("a").Render("x") + styleC1 := AgentBadgeStyleFor("c").Render("x") + assert.NotEqual(t, styleA1, styleC1) + // Swap order: a and c should exchange styles. SetAgentOrder([]string{"c", "b", "a"}) - assert.Equal(t, 2, agentIndex("a")) - assert.Equal(t, 0, agentIndex("c")) + styleA2 := AgentBadgeStyleFor("a").Render("x") + styleC2 := AgentBadgeStyleFor("c").Render("x") + assert.Equal(t, styleA1, styleC2, "c at index 0 should match a's previous index-0 style") + assert.Equal(t, styleC1, styleA2, "a at index 2 should match c's previous index-2 style") } // --- Style rendering tests --- From 9a344f069ea9345f1ac6a1f92b024ab22421eb76 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Tue, 24 Feb 2026 17:15:16 +0100 Subject: [PATCH 5/5] styles: unexport internal color helpers and deduplicate fallbacks Reduce the public API surface of the styles package by unexporting color utility functions (parseHexRGB, contrastRatio, hslToRGB, etc.) and palette generators that are only used within the package. Consolidate repeated fallback badge/accent colors and styles into package-level variables and extract a lookupAgentIndex helper to remove duplicated lookup logic in AgentBadgeColorsFor, AgentBadgeStyleFor, and AgentAccentStyleFor. Assisted-By: cagent --- pkg/tui/styles/agent_colors.go | 76 +++++++++---------- pkg/tui/styles/agent_colors_test.go | 42 +++++------ pkg/tui/styles/colorutil.go | 110 ++++++++++++++-------------- pkg/tui/styles/colorutil_test.go | 82 ++++++++++----------- pkg/tui/styles/theme.go | 2 +- 5 files changed, 151 insertions(+), 161 deletions(-) diff --git a/pkg/tui/styles/agent_colors.go b/pkg/tui/styles/agent_colors.go index 29d38391b..2f3fe6f8d 100644 --- a/pkg/tui/styles/agent_colors.go +++ b/pkg/tui/styles/agent_colors.go @@ -13,6 +13,16 @@ type AgentBadgeColors struct { Bg color.Color } +// Fallback colors used when an agent is not in the registry or the cache is empty. +var ( + fallbackBadgeColors = AgentBadgeColors{ + Fg: lipgloss.Color("#ffffff"), + Bg: lipgloss.Color("#1D63ED"), + } + fallbackBadgeStyle = BaseStyle.Foreground(fallbackBadgeColors.Fg).Background(fallbackBadgeColors.Bg).Padding(0, 1) + fallbackAccentStyle = BaseStyle.Foreground(lipgloss.Color("#98C379")) +) + // cachedBadgeStyle holds a precomputed badge style for a palette index. type cachedBadgeStyle struct { colors AgentBadgeColors @@ -49,18 +59,18 @@ func rebuildAgentColorCache() { hues := theme.Colors.AgentHues if len(hues) == 0 { - hues = DefaultAgentHues + hues = defaultAgentHues } bg := lipgloss.Color(theme.Colors.Background) - badgeColors := GenerateBadgePalette(hues, bg) - accentColors := GenerateAccentPalette(hues, bg) + badgeColors := generateBadgePalette(hues, bg) + accentColors := generateAccentPalette(hues, bg) agentRegistry.badgeStyles = make([]cachedBadgeStyle, len(badgeColors)) for i, bgColor := range badgeColors { r, g, b := ColorToRGB(bgColor) bgHex := RGBToHex(r, g, b) - fgHex := BestForegroundHex( + fgHex := bestForegroundHex( bgHex, theme.Colors.TextBright, theme.Colors.Background, @@ -95,28 +105,24 @@ func InvalidateAgentColorCache() { rebuildAgentColorCache() } +// lookupAgentIndex returns the palette index for the given agent name +// and whether the agent was found. Must be called with agentRegistry.RLock held. +func lookupAgentIndex(agentName string) (int, bool) { + idx, ok := agentRegistry.indices[agentName] + return idx, ok +} + // AgentBadgeColorsFor returns the badge foreground/background colors for a given agent name. func AgentBadgeColorsFor(agentName string) AgentBadgeColors { agentRegistry.RLock() defer agentRegistry.RUnlock() - idx, ok := agentRegistry.indices[agentName] - if !ok { - return AgentBadgeColors{ - Fg: lipgloss.Color("#ffffff"), - Bg: lipgloss.Color("#1D63ED"), - } + idx, ok := lookupAgentIndex(agentName) + if !ok || len(agentRegistry.badgeStyles) == 0 { + return fallbackBadgeColors } - size := len(agentRegistry.badgeStyles) - if size > 0 { - return agentRegistry.badgeStyles[idx%size].colors - } - - return AgentBadgeColors{ - Fg: lipgloss.Color("#ffffff"), - Bg: lipgloss.Color("#1D63ED"), - } + return agentRegistry.badgeStyles[idx%len(agentRegistry.badgeStyles)].colors } // AgentBadgeStyleFor returns a lipgloss badge style colored for the given agent. @@ -124,23 +130,12 @@ func AgentBadgeStyleFor(agentName string) lipgloss.Style { agentRegistry.RLock() defer agentRegistry.RUnlock() - idx, ok := agentRegistry.indices[agentName] - if !ok { - return BaseStyle. - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#1D63ED")). - Padding(0, 1) - } - - size := len(agentRegistry.badgeStyles) - if size > 0 { - return agentRegistry.badgeStyles[idx%size].style + idx, ok := lookupAgentIndex(agentName) + if !ok || len(agentRegistry.badgeStyles) == 0 { + return fallbackBadgeStyle } - return BaseStyle. - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#1D63ED")). - Padding(0, 1) + return agentRegistry.badgeStyles[idx%len(agentRegistry.badgeStyles)].style } // AgentAccentStyleFor returns a foreground-only style for agent names (used in sidebar). @@ -148,15 +143,10 @@ func AgentAccentStyleFor(agentName string) lipgloss.Style { agentRegistry.RLock() defer agentRegistry.RUnlock() - idx, ok := agentRegistry.indices[agentName] - if !ok { - return BaseStyle.Foreground(lipgloss.Color("#98C379")) - } - - size := len(agentRegistry.accentStyles) - if size > 0 { - return agentRegistry.accentStyles[idx%size] + idx, ok := lookupAgentIndex(agentName) + if !ok || len(agentRegistry.accentStyles) == 0 { + return fallbackAccentStyle } - return BaseStyle.Foreground(lipgloss.Color("#98C379")) + return agentRegistry.accentStyles[idx%len(agentRegistry.accentStyles)] } diff --git a/pkg/tui/styles/agent_colors_test.go b/pkg/tui/styles/agent_colors_test.go index d95e36492..c6aa5997b 100644 --- a/pkg/tui/styles/agent_colors_test.go +++ b/pkg/tui/styles/agent_colors_test.go @@ -177,16 +177,16 @@ func TestAllBuiltinThemes_AgentBadgeContrast(t *testing.T) { hues := theme.Colors.AgentHues if len(hues) == 0 { - hues = DefaultAgentHues + hues = defaultAgentHues } bg := lipgloss.Color(theme.Colors.Background) - badgeColors := GenerateBadgePalette(hues, bg) + badgeColors := generateBadgePalette(hues, bg) for i, badgeBg := range badgeColors { r, g, b := ColorToRGB(badgeBg) bgHex := RGBToHex(r, g, b) - fgHex := BestForegroundHex( + fgHex := bestForegroundHex( bgHex, theme.Colors.TextBright, theme.Colors.Background, @@ -195,7 +195,7 @@ func TestAllBuiltinThemes_AgentBadgeContrast(t *testing.T) { ) fg := lipgloss.Color(fgHex) - ratio := ContrastRatio(fg, badgeBg) + ratio := contrastRatio(fg, badgeBg) assert.GreaterOrEqual(t, ratio, minBadgeContrast, "badge %d (bg=%s, fg=%s) contrast %.2f < %.1f in theme %s", i, bgHex, fgHex, ratio, minBadgeContrast, ref) @@ -220,14 +220,14 @@ func TestAllBuiltinThemes_AgentAccentContrast(t *testing.T) { hues := theme.Colors.AgentHues if len(hues) == 0 { - hues = DefaultAgentHues + hues = defaultAgentHues } bg := lipgloss.Color(theme.Colors.Background) - accentColors := GenerateAccentPalette(hues, bg) + accentColors := generateAccentPalette(hues, bg) for i, accent := range accentColors { - ratio := ContrastRatio(accent, bg) + ratio := contrastRatio(accent, bg) r, g, b := ColorToRGB(accent) hex := RGBToHex(r, g, b) assert.GreaterOrEqual(t, ratio, minAccentContrast, @@ -262,15 +262,15 @@ func TestAllBuiltinThemes_AgentBadgeDistinctness(t *testing.T) { hues := theme.Colors.AgentHues if len(hues) == 0 { - hues = DefaultAgentHues + hues = defaultAgentHues } bg := lipgloss.Color(theme.Colors.Background) - palette := GenerateBadgePalette(hues, bg) + palette := generateBadgePalette(hues, bg) for i := range palette { for j := i + 1; j < len(palette); j++ { - dist := ColorDistanceCIE76(palette[i], palette[j]) + dist := colorDistanceCIE76(palette[i], palette[j]) assert.GreaterOrEqual(t, dist, minColorDistance, "badge colors %d and %d are too similar (ΔE=%.1f) in theme %s", i, j, dist, ref) @@ -296,15 +296,15 @@ func TestAllBuiltinThemes_AgentAccentDistinctness(t *testing.T) { hues := theme.Colors.AgentHues if len(hues) == 0 { - hues = DefaultAgentHues + hues = defaultAgentHues } bg := lipgloss.Color(theme.Colors.Background) - palette := GenerateAccentPalette(hues, bg) + palette := generateAccentPalette(hues, bg) for i := range palette { for j := i + 1; j < len(palette); j++ { - dist := ColorDistanceCIE76(palette[i], palette[j]) + dist := colorDistanceCIE76(palette[i], palette[j]) assert.GreaterOrEqual(t, dist, minColorDistance, "accent colors %d and %d are too similar (ΔE=%.1f) in theme %s", i, j, dist, ref) @@ -331,12 +331,12 @@ func TestAgentColorAuditReport(t *testing.T) { hues := theme.Colors.AgentHues if len(hues) == 0 { - hues = DefaultAgentHues + hues = defaultAgentHues } bg := lipgloss.Color(theme.Colors.Background) - badges := GenerateBadgePalette(hues, bg) - accents := GenerateAccentPalette(hues, bg) + badges := generateBadgePalette(hues, bg) + accents := generateAccentPalette(hues, bg) t.Logf("\n=== Agent Color Audit: %s (bg: %s) ===", theme.Name, theme.Colors.Background) t.Logf("%-5s %-10s %-10s %-10s %-12s %-10s %-10s", @@ -348,15 +348,15 @@ func TestAgentColorAuditReport(t *testing.T) { br, bg2, bb := ColorToRGB(badges[i]) badgeHex := RGBToHex(br, bg2, bb) - fgHex := BestForegroundHex(badgeHex, + fgHex := bestForegroundHex(badgeHex, theme.Colors.TextBright, theme.Colors.Background, "#000000", "#ffffff") fg := lipgloss.Color(fgHex) - badgeCR := ContrastRatio(fg, badges[i]) + badgeCR := contrastRatio(fg, badges[i]) ar, ag, ab := ColorToRGB(accents[i]) accentHex := RGBToHex(ar, ag, ab) - accentCR := ContrastRatio(accents[i], bg) + accentCR := contrastRatio(accents[i], bg) badgeStatus := "✓" if badgeCR < minBadgeContrast { @@ -377,10 +377,10 @@ func TestAgentColorAuditReport(t *testing.T) { minAccentDist := 999.0 for i := range badges { for j := i + 1; j < len(badges); j++ { - if d := ColorDistanceCIE76(badges[i], badges[j]); d < minBadgeDist { + if d := colorDistanceCIE76(badges[i], badges[j]); d < minBadgeDist { minBadgeDist = d } - if d := ColorDistanceCIE76(accents[i], accents[j]); d < minAccentDist { + if d := colorDistanceCIE76(accents[i], accents[j]); d < minAccentDist { minAccentDist = d } } diff --git a/pkg/tui/styles/colorutil.go b/pkg/tui/styles/colorutil.go index 3349d7b31..fe0951b89 100644 --- a/pkg/tui/styles/colorutil.go +++ b/pkg/tui/styles/colorutil.go @@ -12,8 +12,8 @@ import ( // --- Hex parsing --- -// ParseHexRGB parses a hex color string (#RGB or #RRGGBB) into normalized [0,1] sRGB components. -func ParseHexRGB(hex string) (r, g, b float64, ok bool) { +// parseHexRGB parses a hex color string (#RGB or #RRGGBB) into normalized [0,1] sRGB components. +func parseHexRGB(hex string) (r, g, b float64, ok bool) { if !strings.HasPrefix(hex, "#") { return 0, 0, 0, false } @@ -70,16 +70,16 @@ func clamp8(v float64) int { // --- sRGB linearization --- -// SRGBToLinear converts an sRGB component [0,1] to linear light. -func SRGBToLinear(c float64) float64 { +// sRGBToLinear converts an sRGB component [0,1] to linear light. +func sRGBToLinear(c float64) float64 { if c <= 0.03928 { return c / 12.92 } return math.Pow((c+0.055)/1.055, 2.4) } -// LinearToSRGB converts a linear light component [0,1] to sRGB. -func LinearToSRGB(c float64) float64 { +// linearToSRGB converts a linear light component [0,1] to sRGB. +func linearToSRGB(c float64) float64 { if c <= 0.0031308 { return c * 12.92 } @@ -88,42 +88,42 @@ func LinearToSRGB(c float64) float64 { // --- Luminance & contrast --- -// RelativeLuminance returns the WCAG 2.x relative luminance of an sRGB color. -func RelativeLuminance(r, g, b float64) float64 { - return 0.2126*SRGBToLinear(r) + 0.7152*SRGBToLinear(g) + 0.0722*SRGBToLinear(b) +// relativeLuminance returns the WCAG 2.x relative luminance of an sRGB color. +func relativeLuminance(r, g, b float64) float64 { + return 0.2126*sRGBToLinear(r) + 0.7152*sRGBToLinear(g) + 0.0722*sRGBToLinear(b) } -// RelativeLuminanceHex returns the relative luminance for a hex color string. -func RelativeLuminanceHex(hex string) (float64, bool) { - r, g, b, ok := ParseHexRGB(hex) +// relativeLuminanceHex returns the relative luminance for a hex color string. +func relativeLuminanceHex(hex string) (float64, bool) { + r, g, b, ok := parseHexRGB(hex) if !ok { return 0, false } - return RelativeLuminance(r, g, b), true + return relativeLuminance(r, g, b), true } -// RelativeLuminanceColor returns the relative luminance for a color.Color. -func RelativeLuminanceColor(c color.Color) float64 { +// relativeLuminanceColor returns the relative luminance for a color.Color. +func relativeLuminanceColor(c color.Color) float64 { r, g, b := ColorToRGB(c) - return RelativeLuminance(r, g, b) + return relativeLuminance(r, g, b) } -// ContrastRatio returns the WCAG 2.x contrast ratio between two colors. -func ContrastRatio(fg, bg color.Color) float64 { - l1 := RelativeLuminanceColor(fg) - l2 := RelativeLuminanceColor(bg) +// contrastRatio returns the WCAG 2.x contrast ratio between two colors. +func contrastRatio(fg, bg color.Color) float64 { + l1 := relativeLuminanceColor(fg) + l2 := relativeLuminanceColor(bg) lighter := max(l1, l2) darker := min(l1, l2) return (lighter + 0.05) / (darker + 0.05) } -// ContrastRatioHex returns the WCAG contrast ratio between two hex color strings. -func ContrastRatioHex(fgHex, bgHex string) (float64, bool) { - fgLum, ok := RelativeLuminanceHex(fgHex) +// contrastRatioHex returns the WCAG contrast ratio between two hex color strings. +func contrastRatioHex(fgHex, bgHex string) (float64, bool) { + fgLum, ok := relativeLuminanceHex(fgHex) if !ok { return 0, false } - bgLum, ok := RelativeLuminanceHex(bgHex) + bgLum, ok := relativeLuminanceHex(bgHex) if !ok { return 0, false } @@ -135,8 +135,8 @@ func ContrastRatioHex(fgHex, bgHex string) (float64, bool) { return (l1 + 0.05) / (l2 + 0.05), true } -// BestForegroundHex picks the candidate hex color with the highest contrast ratio against bgHex. -func BestForegroundHex(bgHex string, candidates ...string) string { +// bestForegroundHex picks the candidate hex color with the highest contrast ratio against bgHex. +func bestForegroundHex(bgHex string, candidates ...string) string { if len(candidates) == 0 { return "" } @@ -144,7 +144,7 @@ func BestForegroundHex(bgHex string, candidates ...string) string { bestRatio := -1.0 for _, cand := range candidates { - ratio, ok := ContrastRatioHex(cand, bgHex) + ratio, ok := contrastRatioHex(cand, bgHex) if !ok { continue } @@ -159,21 +159,21 @@ func BestForegroundHex(bgHex string, candidates ...string) string { // --- Dynamic contrast helpers --- const ( - // MutedContrastStrength controls how much the muted foreground shifts + // mutedContrastStrength controls how much the muted foreground shifts // away from the background (0.0 = invisible, 1.0 = full black/white). - MutedContrastStrength = 0.45 + mutedContrastStrength = 0.45 - // MinIndicatorContrast is the minimum WCAG contrast ratio for semantic + // minIndicatorContrast is the minimum WCAG contrast ratio for semantic // indicator colors (running dot, attention bang). - MinIndicatorContrast = 4.5 + minIndicatorContrast = 4.5 - // maxBoostSteps limits blend iterations in EnsureContrast. + // maxBoostSteps limits blend iterations in ensureContrast. maxBoostSteps = 20 ) // MutedContrastFg returns a foreground color that is visible but subtle against // the given background. It blends the background toward white (for dark bg) or -// black (for light bg) by MutedContrastStrength. +// black (for light bg) by mutedContrastStrength. func MutedContrastFg(bg color.Color) color.Color { rf, gf, bf := ColorToRGB(bg) lum := 0.299*rf + 0.587*gf + 0.114*bf @@ -185,21 +185,21 @@ func MutedContrastFg(bg color.Color) color.Color { tgt = 1.0 } - s := MutedContrastStrength + s := mutedContrastStrength return RGBToColor(rf+(tgt-rf)*s, gf+(tgt-gf)*s, bf+(tgt-bf)*s) } -// EnsureContrast returns fg unchanged if it already meets MinIndicatorContrast +// EnsureContrast returns fg unchanged if it already meets minIndicatorContrast // against bg. Otherwise it progressively blends fg toward white (on dark bg) or // black (on light bg) until the threshold is met, preserving the original hue direction. func EnsureContrast(fg, bg color.Color) color.Color { - if ContrastRatio(fg, bg) >= MinIndicatorContrast { + if contrastRatio(fg, bg) >= minIndicatorContrast { return fg } rf, gf, bf := ColorToRGB(fg) bgR, bgG, bgB := ColorToRGB(bg) - bgLum := RelativeLuminance(bgR, bgG, bgB) + bgLum := relativeLuminance(bgR, bgG, bgB) var tR, tG, tB float64 if bgLum > 0.5 { @@ -214,7 +214,7 @@ func EnsureContrast(fg, bg color.Color) color.Color { ng := gf + (tG-gf)*t nb := bf + (tB-bf)*t candidate := RGBToColor(nr, ng, nb) - if ContrastRatio(candidate, bg) >= MinIndicatorContrast { + if contrastRatio(candidate, bg) >= minIndicatorContrast { return candidate } } @@ -224,9 +224,9 @@ func EnsureContrast(fg, bg color.Color) color.Color { // --- HSL conversion --- -// RGBToHSL converts normalized [0,1] sRGB to HSL. +// rgbToHSL converts normalized [0,1] sRGB to HSL. // H is in [0,360), S and L are in [0,1]. -func RGBToHSL(r, g, b float64) (h, s, l float64) { +func rgbToHSL(r, g, b float64) (h, s, l float64) { maxC := max(r, max(g, b)) minC := min(r, min(g, b)) l = (maxC + minC) / 2 @@ -258,9 +258,9 @@ func RGBToHSL(r, g, b float64) (h, s, l float64) { return h, s, l } -// HSLToRGB converts HSL to normalized [0,1] sRGB. +// hslToRGB converts HSL to normalized [0,1] sRGB. // H is in [0,360), S and L are in [0,1]. -func HSLToRGB(h, s, l float64) (r, g, b float64) { +func hslToRGB(h, s, l float64) (r, g, b float64) { if s == 0 { return l, l, l } @@ -301,8 +301,8 @@ func hueToRGB(p, q, t float64) float64 { // --- Palette generation --- -// DefaultAgentHues provides 16 well-spaced default hue values for agent colors. -var DefaultAgentHues = []float64{ +// defaultAgentHues provides 16 well-spaced default hue values for agent colors. +var defaultAgentHues = []float64{ 220, // Blue 280, // Purple 170, // Teal @@ -324,9 +324,9 @@ var DefaultAgentHues = []float64{ // GenerateBadgePalette generates badge background colors from hues, adapting // saturation and lightness based on the theme background. // Dark backgrounds get lighter, more saturated badges; light backgrounds get darker ones. -func GenerateBadgePalette(hues []float64, bg color.Color) []color.Color { +func generateBadgePalette(hues []float64, bg color.Color) []color.Color { bgR, bgG, bgB := ColorToRGB(bg) - bgLum := RelativeLuminance(bgR, bgG, bgB) + bgLum := relativeLuminance(bgR, bgG, bgB) isDark := bgLum < 0.5 @@ -341,7 +341,7 @@ func GenerateBadgePalette(hues []float64, bg color.Color) []color.Color { l = 0.38 + 0.06*math.Cos(float64(i)*0.9) } - r, g, b := HSLToRGB(hue, s, l) + r, g, b := hslToRGB(hue, s, l) colors[i] = lipgloss.Color(RGBToHex(r, g, b)) } return colors @@ -350,9 +350,9 @@ func GenerateBadgePalette(hues []float64, bg color.Color) []color.Color { // GenerateAccentPalette generates sidebar accent foreground colors from hues, // adapting to the theme background for readability. // Dark backgrounds get brighter accents; light backgrounds get darker ones. -func GenerateAccentPalette(hues []float64, bg color.Color) []color.Color { +func generateAccentPalette(hues []float64, bg color.Color) []color.Color { bgR, bgG, bgB := ColorToRGB(bg) - bgLum := RelativeLuminance(bgR, bgG, bgB) + bgLum := relativeLuminance(bgR, bgG, bgB) isDark := bgLum < 0.5 @@ -367,7 +367,7 @@ func GenerateAccentPalette(hues []float64, bg color.Color) []color.Color { l = 0.30 + 0.06*math.Cos(float64(i)*0.7) } - r, g, b := HSLToRGB(hue, s, l) + r, g, b := hslToRGB(hue, s, l) colors[i] = lipgloss.Color(RGBToHex(r, g, b)) } return colors @@ -375,9 +375,9 @@ func GenerateAccentPalette(hues []float64, bg color.Color) []color.Color { // --- Perceptual distance --- -// ColorDistanceCIE76 returns the Euclidean distance between two colors in CIELAB space. +// colorDistanceCIE76 returns the Euclidean distance between two colors in CIELAB space. // A value below ~25 means colors may be hard to distinguish at a glance. -func ColorDistanceCIE76(c1, c2 color.Color) float64 { +func colorDistanceCIE76(c1, c2 color.Color) float64 { l1, a1, b1 := colorToLab(c1) l2, a2, b2 := colorToLab(c2) dl := l1 - l2 @@ -390,9 +390,9 @@ func ColorDistanceCIE76(c1, c2 color.Color) float64 { func colorToLab(c color.Color) (l, a, b float64) { r, g, bl := ColorToRGB(c) // sRGB to linear - rl := SRGBToLinear(r) - gl := SRGBToLinear(g) - bll := SRGBToLinear(bl) + rl := sRGBToLinear(r) + gl := sRGBToLinear(g) + bll := sRGBToLinear(bl) // Linear RGB to XYZ (D65) x := 0.4124564*rl + 0.3575761*gl + 0.1804375*bll diff --git a/pkg/tui/styles/colorutil_test.go b/pkg/tui/styles/colorutil_test.go index c10f20862..be305895f 100644 --- a/pkg/tui/styles/colorutil_test.go +++ b/pkg/tui/styles/colorutil_test.go @@ -13,7 +13,7 @@ import ( func TestParseHexRGB_Valid6Digit(t *testing.T) { t.Parallel() - r, g, b, ok := ParseHexRGB("#FF8000") + r, g, b, ok := parseHexRGB("#FF8000") require.True(t, ok) assert.InDelta(t, 1.0, r, 0.01) assert.InDelta(t, 0.502, g, 0.01) @@ -22,7 +22,7 @@ func TestParseHexRGB_Valid6Digit(t *testing.T) { func TestParseHexRGB_Valid3Digit(t *testing.T) { t.Parallel() - r, g, b, ok := ParseHexRGB("#F00") + r, g, b, ok := parseHexRGB("#F00") require.True(t, ok) assert.InDelta(t, 1.0, r, 0.01) assert.InDelta(t, 0.0, g, 0.01) @@ -32,7 +32,7 @@ func TestParseHexRGB_Valid3Digit(t *testing.T) { func TestParseHexRGB_Invalid(t *testing.T) { t.Parallel() for _, input := range []string{"", "FF0000", "#GG0000", "#FF00", "#FF000000"} { - _, _, _, ok := ParseHexRGB(input) + _, _, _, ok := parseHexRGB(input) assert.False(t, ok, "expected failure for %q", input) } } @@ -42,7 +42,7 @@ func TestParseHexRGB_Invalid(t *testing.T) { func TestRGBToHex_Roundtrip(t *testing.T) { t.Parallel() hex := RGBToHex(0.2, 0.4, 0.6) - r, g, b, ok := ParseHexRGB(hex) + r, g, b, ok := parseHexRGB(hex) require.True(t, ok) assert.InDelta(t, 0.2, r, 0.01) assert.InDelta(t, 0.4, g, 0.01) @@ -60,7 +60,7 @@ func TestRGBToHex_BlackWhite(t *testing.T) { func TestLinearization_Roundtrip(t *testing.T) { t.Parallel() for _, v := range []float64{0, 0.1, 0.5, 0.9, 1.0} { - result := LinearToSRGB(SRGBToLinear(v)) + result := linearToSRGB(sRGBToLinear(v)) assert.InDelta(t, v, result, 0.001, "roundtrip failed for %f", v) } } @@ -69,17 +69,17 @@ func TestLinearization_Roundtrip(t *testing.T) { func TestRelativeLuminance_BlackWhite(t *testing.T) { t.Parallel() - assert.InDelta(t, 0.0, RelativeLuminance(0, 0, 0), 0.001) - assert.InDelta(t, 1.0, RelativeLuminance(1, 1, 1), 0.001) + assert.InDelta(t, 0.0, relativeLuminance(0, 0, 0), 0.001) + assert.InDelta(t, 1.0, relativeLuminance(1, 1, 1), 0.001) } func TestRelativeLuminanceHex(t *testing.T) { t.Parallel() - lum, ok := RelativeLuminanceHex("#ffffff") + lum, ok := relativeLuminanceHex("#ffffff") require.True(t, ok) assert.InDelta(t, 1.0, lum, 0.001) - lum, ok = RelativeLuminanceHex("#000000") + lum, ok = relativeLuminanceHex("#000000") require.True(t, ok) assert.InDelta(t, 0.0, lum, 0.001) } @@ -90,20 +90,20 @@ func TestContrastRatio_BlackWhite(t *testing.T) { t.Parallel() black := lipgloss.Color("#000000") white := lipgloss.Color("#ffffff") - ratio := ContrastRatio(black, white) + ratio := contrastRatio(black, white) assert.InDelta(t, 21.0, ratio, 0.1) } func TestContrastRatio_SameColor(t *testing.T) { t.Parallel() c := lipgloss.Color("#808080") - ratio := ContrastRatio(c, c) + ratio := contrastRatio(c, c) assert.InDelta(t, 1.0, ratio, 0.001) } func TestContrastRatioHex(t *testing.T) { t.Parallel() - ratio, ok := ContrastRatioHex("#000000", "#ffffff") + ratio, ok := contrastRatioHex("#000000", "#ffffff") require.True(t, ok) assert.InDelta(t, 21.0, ratio, 0.1) } @@ -111,11 +111,11 @@ func TestContrastRatioHex(t *testing.T) { func TestBestForegroundHex(t *testing.T) { t.Parallel() // On dark background, white should win - best := BestForegroundHex("#000000", "#333333", "#ffffff") + best := bestForegroundHex("#000000", "#333333", "#ffffff") assert.Equal(t, "#ffffff", best) // On light background, black should win - best = BestForegroundHex("#ffffff", "#000000", "#cccccc") + best = bestForegroundHex("#ffffff", "#000000", "#cccccc") assert.Equal(t, "#000000", best) } @@ -123,7 +123,7 @@ func TestBestForegroundHex(t *testing.T) { func TestRGBToHSL_Red(t *testing.T) { t.Parallel() - h, s, l := RGBToHSL(1, 0, 0) + h, s, l := rgbToHSL(1, 0, 0) assert.InDelta(t, 0, h, 0.1) assert.InDelta(t, 1.0, s, 0.01) assert.InDelta(t, 0.5, l, 0.01) @@ -131,7 +131,7 @@ func TestRGBToHSL_Red(t *testing.T) { func TestRGBToHSL_Green(t *testing.T) { t.Parallel() - h, s, l := RGBToHSL(0, 1, 0) + h, s, l := rgbToHSL(0, 1, 0) assert.InDelta(t, 120, h, 0.1) assert.InDelta(t, 1.0, s, 0.01) assert.InDelta(t, 0.5, l, 0.01) @@ -139,7 +139,7 @@ func TestRGBToHSL_Green(t *testing.T) { func TestRGBToHSL_Blue(t *testing.T) { t.Parallel() - h, s, l := RGBToHSL(0, 0, 1) + h, s, l := rgbToHSL(0, 0, 1) assert.InDelta(t, 240, h, 0.1) assert.InDelta(t, 1.0, s, 0.01) assert.InDelta(t, 0.5, l, 0.01) @@ -147,7 +147,7 @@ func TestRGBToHSL_Blue(t *testing.T) { func TestRGBToHSL_Gray(t *testing.T) { t.Parallel() - h, s, l := RGBToHSL(0.5, 0.5, 0.5) + h, s, l := rgbToHSL(0.5, 0.5, 0.5) _ = h // hue is undefined for gray assert.InDelta(t, 0.0, s, 0.01) assert.InDelta(t, 0.5, l, 0.01) @@ -165,8 +165,8 @@ func TestHSLToRGB_Roundtrip(t *testing.T) { {0.2, 0.6, 0.8}, } for _, tc := range testCases { - h, s, l := RGBToHSL(tc.r, tc.g, tc.b) - r, g, b := HSLToRGB(h, s, l) + h, s, l := rgbToHSL(tc.r, tc.g, tc.b) + r, g, b := hslToRGB(h, s, l) assert.InDelta(t, tc.r, r, 0.01, "r mismatch for input %v", tc) assert.InDelta(t, tc.g, g, 0.01, "g mismatch for input %v", tc) assert.InDelta(t, tc.b, b, 0.01, "b mismatch for input %v", tc) @@ -180,8 +180,8 @@ func TestMutedContrastFg_DarkBg(t *testing.T) { bg := lipgloss.Color("#1C1C22") fg := MutedContrastFg(bg) // Should produce a lighter color than the background - bgLum := RelativeLuminanceColor(bg) - fgLum := RelativeLuminanceColor(fg) + bgLum := relativeLuminanceColor(bg) + fgLum := relativeLuminanceColor(fg) assert.Greater(t, fgLum, bgLum) } @@ -190,8 +190,8 @@ func TestMutedContrastFg_LightBg(t *testing.T) { bg := lipgloss.Color("#eff1f5") fg := MutedContrastFg(bg) // Should produce a darker color than the background - bgLum := RelativeLuminanceColor(bg) - fgLum := RelativeLuminanceColor(fg) + bgLum := relativeLuminanceColor(bg) + fgLum := relativeLuminanceColor(fg) assert.Less(t, fgLum, bgLum) } @@ -213,8 +213,8 @@ func TestEnsureContrast_BoostsLowContrast(t *testing.T) { fg := lipgloss.Color("#333333") bg := lipgloss.Color("#222222") result := EnsureContrast(fg, bg) - ratio := ContrastRatio(result, bg) - assert.GreaterOrEqual(t, ratio, MinIndicatorContrast) + ratio := contrastRatio(result, bg) + assert.GreaterOrEqual(t, ratio, minIndicatorContrast) } // --- Palette generation --- @@ -222,14 +222,14 @@ func TestEnsureContrast_BoostsLowContrast(t *testing.T) { func TestGenerateBadgePalette_CorrectLength(t *testing.T) { t.Parallel() bg := lipgloss.Color("#1C1C22") - palette := GenerateBadgePalette(DefaultAgentHues, bg) - assert.Len(t, palette, len(DefaultAgentHues)) + palette := generateBadgePalette(defaultAgentHues, bg) + assert.Len(t, palette, len(defaultAgentHues)) } func TestGenerateBadgePalette_AllDistinct(t *testing.T) { t.Parallel() bg := lipgloss.Color("#1C1C22") - palette := GenerateBadgePalette(DefaultAgentHues, bg) + palette := generateBadgePalette(defaultAgentHues, bg) hexSet := make(map[string]bool) for _, c := range palette { r, g, b := ColorToRGB(c) @@ -242,20 +242,20 @@ func TestGenerateBadgePalette_AllDistinct(t *testing.T) { func TestGenerateAccentPalette_CorrectLength(t *testing.T) { t.Parallel() bg := lipgloss.Color("#1C1C22") - palette := GenerateAccentPalette(DefaultAgentHues, bg) - assert.Len(t, palette, len(DefaultAgentHues)) + palette := generateAccentPalette(defaultAgentHues, bg) + assert.Len(t, palette, len(defaultAgentHues)) } func TestGenerateBadgePalette_DarkVsLight(t *testing.T) { t.Parallel() darkBg := lipgloss.Color("#1C1C22") lightBg := lipgloss.Color("#eff1f5") - darkPalette := GenerateBadgePalette(DefaultAgentHues[:1], darkBg) - lightPalette := GenerateBadgePalette(DefaultAgentHues[:1], lightBg) + darkPalette := generateBadgePalette(defaultAgentHues[:1], darkBg) + lightPalette := generateBadgePalette(defaultAgentHues[:1], lightBg) // Same hue should produce different lightness for dark vs light bg - darkLum := RelativeLuminanceColor(darkPalette[0]) - lightLum := RelativeLuminanceColor(lightPalette[0]) + darkLum := relativeLuminanceColor(darkPalette[0]) + lightLum := relativeLuminanceColor(lightPalette[0]) assert.Greater(t, math.Abs(darkLum-lightLum), 0.01, "dark and light themes should produce different badge lightness") } @@ -264,14 +264,14 @@ func TestGenerateBadgePalette_DarkVsLight(t *testing.T) { func TestColorDistanceCIE76_Identical(t *testing.T) { t.Parallel() c := lipgloss.Color("#FF0000") - assert.InDelta(t, 0, ColorDistanceCIE76(c, c), 0.001) + assert.InDelta(t, 0, colorDistanceCIE76(c, c), 0.001) } func TestColorDistanceCIE76_BlackWhite(t *testing.T) { t.Parallel() black := lipgloss.Color("#000000") white := lipgloss.Color("#ffffff") - dist := ColorDistanceCIE76(black, white) + dist := colorDistanceCIE76(black, white) assert.Greater(t, dist, 50.0, "black and white should be very far apart in CIELAB") } @@ -279,7 +279,7 @@ func TestColorDistanceCIE76_SimilarColors(t *testing.T) { t.Parallel() c1 := lipgloss.Color("#FF0000") c2 := lipgloss.Color("#FF1100") - dist := ColorDistanceCIE76(c1, c2) + dist := colorDistanceCIE76(c1, c2) assert.Less(t, dist, 10.0, "very similar colors should have small distance") } @@ -294,16 +294,16 @@ func TestColorToRGB_KnownValues(t *testing.T) { assert.InDelta(t, 0.0, b, 0.01) } -// --- DefaultAgentHues --- +// --- defaultAgentHues --- func TestDefaultAgentHues_Length(t *testing.T) { t.Parallel() - assert.Len(t, DefaultAgentHues, 16) + assert.Len(t, defaultAgentHues, 16) } func TestDefaultAgentHues_InRange(t *testing.T) { t.Parallel() - for i, h := range DefaultAgentHues { + for i, h := range defaultAgentHues { assert.GreaterOrEqual(t, h, 0.0, "hue %d out of range", i) assert.Less(t, h, 360.0, "hue %d out of range", i) } diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index 9796ba52e..a90eec3a9 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -924,7 +924,7 @@ func ApplyTheme(theme *Theme) { PlaceholderColor = lipgloss.Color(c.Placeholder) // Badge colors AgentBadgeBg = MobyBlue - AgentBadgeFg = lipgloss.Color(BestForegroundHex( + AgentBadgeFg = lipgloss.Color(bestForegroundHex( c.Brand, c.TextBright, c.Background,