Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod attachments;
pub mod git;
pub mod permissions;
pub mod project;
pub mod provider_colors;
pub mod provider_models;
pub mod provider_status;
pub mod relay;
Expand Down
38 changes: 37 additions & 1 deletion crates/core/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::{
use agent::{ApprovalMode, InteractionMode, OptionSelection, ProviderKind, ResumeCursor};
use serde::{Deserialize, Serialize};

use crate::settings::ProjectSort;
use crate::settings::{ProjectSort, Settings, acp_color_key, provider_key};

/// A project groups sessions (threads) that share a working-directory root.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -140,6 +140,26 @@ pub struct SessionMeta {
}

impl SessionMeta {
/// The key [`Settings::provider_color`] resolves this thread's color from:
/// a user profile id, an ACP agent (`acp:<id>`), or the built-in
/// [`provider_key`]. A user profile is its own provider to the user even
/// when it drives a built-in protocol, so it never inherits the brand color.
pub fn provider_color_key(&self) -> String {
if let Some(profile_id) = self
.profile_id
.as_deref()
.filter(|id| !Settings::is_builtin_profile_id(id))
{
return profile_id.to_string();
}
if self.provider == ProviderKind::Acp
&& let Some(agent_id) = self.acp_agent_id.as_deref()
{
return acp_color_key(agent_id);
}
provider_key(self.provider).to_string()
}

#[cfg(feature = "process")]
pub fn new(provider: ProviderKind, cwd: PathBuf, model: Option<String>) -> Self {
let now = now_secs();
Expand Down Expand Up @@ -504,6 +524,22 @@ mod tests {
meta
}

#[test]
fn provider_color_key_prefers_user_profile_then_acp_agent_then_builtin() {
let mut meta = SessionMeta::new(ProviderKind::ClaudeCode, PathBuf::from("/x"), None);
assert_eq!(meta.provider_color_key(), "claude");
// The built-in profile spelled out explicitly is still the built-in.
meta.profile_id = Some("claude".into());
assert_eq!(meta.provider_color_key(), "claude");
// A third-party endpoint is a different provider to the user.
meta.profile_id = Some("work-claude".into());
assert_eq!(meta.provider_color_key(), "work-claude");

let mut acp = SessionMeta::new(ProviderKind::Acp, PathBuf::from("/x"), None);
acp.acp_agent_id = Some("gemini".into());
assert_eq!(acp.provider_color_key(), "acp:gemini");
}

fn candidates(
sessions: &[SessionMeta],
now: u64,
Expand Down
116 changes: 116 additions & 0 deletions crates/core/src/provider_colors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Per-provider colors for the sidebar thread tint.
//!
//! Built-in providers carry their brand color; everything the user adds (a
//! custom profile, an ACP agent) draws from [`PROVIDER_COLOR_PALETTE`]. The
//! palette slot is derived from the color key alone, so two devices sharing the
//! same settings file agree on every color without persisting an assignment.

use agent::ProviderKind;

/// Hand-picked brand colors (`0xRRGGBB`) for the built-in provider profiles.
/// ACP is not one provider but many, so it has no color of its own: each agent
/// takes a palette slot under its `acp:<id>` key.
pub fn builtin_provider_color(kind: ProviderKind) -> Option<u32> {
match kind {
ProviderKind::ClaudeCode => Some(0xD97757),
ProviderKind::Codex => Some(0x8B5CF6),
ProviderKind::Pi => Some(0x4D9ABF),
ProviderKind::OpenCode => Some(0x22A06B),
ProviderKind::Acp => None,
}
}

/// Hues for user-added providers (`0xRRGGBB`). They stay clear of the four
/// brand colors above so a custom endpoint never masquerades as a built-in.
pub const PROVIDER_COLOR_PALETTE: [u32; 10] = [
0xF59E0B, // amber
0x84CC16, // lime
0x14B8A6, // teal
0x06B6D4, // cyan
0x3B82F6, // blue
0x4F46E5, // indigo
0xD946EF, // fuchsia
0xEC4899, // pink
0xF43F5E, // rose
0x6B7FD7, // slate blue
];

/// 64-bit FNV-1a. `DefaultHasher` is not stable across Rust releases, and a
/// color that changed after an update would look like a different provider.
fn fnv1a(key: &str) -> u64 {
key.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
})
}

fn hashed_slot(key: &str) -> usize {
(fnv1a(key) % PROVIDER_COLOR_PALETTE.len() as u64) as usize
}

/// The palette color for a custom provider key.
///
/// `known` is every currently configured custom provider key, in sorted order.
/// A known key starts at its hashed slot and bumps forward past slots already
/// taken by keys sorted before it, so configured providers never share a color
/// while the palette has room. A key outside `known` (a thread whose profile
/// was deleted) keeps its plain hashed slot.
pub fn palette_color(key: &str, known: &[&str]) -> u32 {
let palette_len = PROVIDER_COLOR_PALETTE.len();
let mut taken = [false; PROVIDER_COLOR_PALETTE.len()];
let mut assigned = 0;
for candidate in known {
let mut slot = hashed_slot(candidate);
if assigned < palette_len {
while taken[slot] {
slot = (slot + 1) % palette_len;
}
taken[slot] = true;
assigned += 1;
}
if *candidate == key {
return PROVIDER_COLOR_PALETTE[slot];
}
}
PROVIDER_COLOR_PALETTE[hashed_slot(key)]
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn known_keys_get_distinct_colors_while_the_palette_has_room() {
let keys: Vec<String> = (0..PROVIDER_COLOR_PALETTE.len())
.map(|i| format!("profile-{i}"))
.collect();
let mut known: Vec<&str> = keys.iter().map(String::as_str).collect();
known.sort_unstable();
let mut colors: Vec<u32> = known.iter().map(|k| palette_color(k, &known)).collect();
colors.sort_unstable();
colors.dedup();
assert_eq!(colors.len(), PROVIDER_COLOR_PALETTE.len());
}

/// Slots are pinned to independently computed FNV-1a values: changing the
/// hash or reordering the palette would recolor every user's threads.
#[test]
fn assignment_is_stable_and_unknown_keys_still_resolve() {
let known = ["acp:gemini", "work-claude"];
assert_eq!(palette_color("acp:gemini", &known), 0x14B8A6);
assert_eq!(palette_color("work-claude", &known), 0x6B7FD7);
// A deleted profile's threads keep their hashed color rather than none.
assert_eq!(palette_color("deleted-profile", &known), 0xF59E0B);
}

#[test]
fn overflowing_the_palette_still_yields_a_color() {
let keys: Vec<String> = (0..PROVIDER_COLOR_PALETTE.len() + 3)
.map(|i| format!("p{i}"))
.collect();
let mut known: Vec<&str> = keys.iter().map(String::as_str).collect();
known.sort_unstable();
for key in &known {
assert!(PROVIDER_COLOR_PALETTE.contains(&palette_color(key, &known)));
}
}
}
Loading