agentmux_srv/backend/agent_color.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-agent color assignment — palette, deterministic pick, validation.
5//!
6//! Agents carry a display color in the per-agent content store
7//! (`agent_content`, `content_type = "ui:color"`, value `#rrggbb`). It is
8//! assigned at creation (`createagent` handler), backfilled for existing
9//! agents by migration `m0020_agent_color_backfill`, and seeded into each
10//! new agent block's `frame:bordercolor` meta at `agent.open` so the
11//! existing pane-frame rendering displays it with no frontend changes.
12//! See `docs/specs/SPEC_AGENT_COLOR_2026_08_08.md`.
13
14/// The 14-hue palette — hex values mirror the frontend's
15/// `AGENT_COLOR_PALETTE` (`frontend/app/view/agent/agent-color.ts`).
16/// Duplicated deliberately: assigned colors are STORED, not derived, so
17/// palette drift between the two lists cannot recolor existing agents.
18/// This is a distinct, deliberately-vivid array from the frontend's tab
19/// strip colors (`frontend/app/tab/tab.tsx`'s `TAB_COLORS`) — see
20/// docs/specs/SPEC_TAB_COLOR_DESATURATION_2026_08_13.md.
21pub const AGENT_COLOR_PALETTE: [&str; 14] = [
22 "#ef4444", // Red
23 "#f97316", // Orange
24 "#f59e0b", // Amber
25 "#eab308", // Yellow
26 "#84cc16", // Lime
27 "#22c55e", // Green
28 "#14b8a6", // Teal
29 "#06b6d4", // Cyan
30 "#3b82f6", // Blue
31 "#6366f1", // Indigo
32 "#8b5cf6", // Violet
33 "#d946ef", // Fuchsia
34 "#ec4899", // Pink
35 "#f43f5e", // Rose
36];
37
38/// Pick a palette color for an agent — deterministic FNV-1a hash of the
39/// agent id mapped onto the palette. Effectively random across agents,
40/// stable across re-runs and machines, and dependency-free (no `rand`).
41pub fn pick_agent_color(agent_id: &str) -> &'static str {
42 let mut hash: u64 = 0xcbf29ce484222325;
43 for b in agent_id.as_bytes() {
44 hash ^= u64::from(*b);
45 hash = hash.wrapping_mul(0x100000001b3);
46 }
47 AGENT_COLOR_PALETTE[(hash % AGENT_COLOR_PALETTE.len() as u64) as usize]
48}
49
50/// Strict `#rrggbb` shape check — the stored value flows into block meta
51/// that the frontend applies as a CSS border-color, so a corrupt or
52/// hand-edited row must not be able to inject arbitrary CSS.
53pub fn is_valid_agent_color(value: &str) -> bool {
54 let bytes = value.as_bytes();
55 bytes.len() == 7 && bytes[0] == b'#' && bytes[1..].iter().all(u8::is_ascii_hexdigit)
56}
57
58/// Dimmed variant for the UNFOCUSED pane border (`frame:bordercolor`),
59/// keeping the full-strength color for the focused border
60/// (`frame:activebordercolor`) — the agent's color stays visible either
61/// way, and focus stays distinguishable by brightness. Input must already
62/// have passed [`is_valid_agent_color`]; returns the input unchanged if it
63/// somehow hasn't.
64pub fn dim_agent_color(hex: &str) -> String {
65 if !is_valid_agent_color(hex) {
66 return hex.to_string();
67 }
68 let channel = |s: &str| u8::from_str_radix(s, 16).unwrap_or(0);
69 let r = (f32::from(channel(&hex[1..3])) * 0.55) as u8;
70 let g = (f32::from(channel(&hex[3..5])) * 0.55) as u8;
71 let b = (f32::from(channel(&hex[5..7])) * 0.55) as u8;
72 format!("#{r:02x}{g:02x}{b:02x}")
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78
79 #[test]
80 fn pick_is_deterministic() {
81 assert_eq!(pick_agent_color("abc-123"), pick_agent_color("abc-123"));
82 }
83
84 #[test]
85 fn pick_returns_palette_member() {
86 for id in ["a", "b", "c", "0d5b45f1-9b2c-4a7e-8f3d-1234567890ab", ""] {
87 assert!(AGENT_COLOR_PALETTE.contains(&pick_agent_color(id)));
88 }
89 }
90
91 #[test]
92 fn distinct_ids_spread_over_palette() {
93 // Not a randomness proof — just guards against a degenerate hash
94 // that maps everything to one bucket.
95 let picked: std::collections::HashSet<_> =
96 (0..100).map(|i| pick_agent_color(&format!("agent-{i}"))).collect();
97 assert!(picked.len() > 5, "expected spread, got {}", picked.len());
98 }
99
100 #[test]
101 fn validation_accepts_palette_and_rejects_junk() {
102 for c in AGENT_COLOR_PALETTE {
103 assert!(is_valid_agent_color(c));
104 }
105 for bad in ["", "#fff", "red", "#gggggg", "#3b82f6; }", "3b82f6", "#3B82F66"] {
106 assert!(!is_valid_agent_color(bad), "{bad} should be invalid");
107 }
108 // Uppercase hex is fine.
109 assert!(is_valid_agent_color("#3B82F6"));
110 }
111
112 #[test]
113 fn dim_scales_channels_and_stays_valid() {
114 assert_eq!(dim_agent_color("#ffffff"), "#8c8c8c");
115 assert_eq!(dim_agent_color("#000000"), "#000000");
116 for c in AGENT_COLOR_PALETTE {
117 let dimmed = dim_agent_color(c);
118 assert!(is_valid_agent_color(&dimmed), "{dimmed}");
119 assert_ne!(dimmed, *c);
120 }
121 // Invalid input passes through untouched (defensive contract).
122 assert_eq!(dim_agent_color("junk"), "junk");
123 }
124}