Skip to main content

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}