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/4] 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/4] 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/4] 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/4] 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 ---