use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::detect::Agent; const MAX_SIDEBAR_ROWS: usize = 16; const MAX_SIDEBAR_TOKENS_PER_ROW: usize = 16; fn deserialize_sidebar_rows<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, T: Deserialize<'de>, { let rows = Vec::>::deserialize(deserializer)?; validate_sidebar_rows(&rows).map_err(serde::de::Error::custom)?; Ok(rows) } fn validate_sidebar_rows(rows: &[Vec]) -> Result<(), String> { if rows.len() > MAX_SIDEBAR_ROWS { return Err(format!( "sidebar layouts may contain at most {MAX_SIDEBAR_ROWS} rows" )); } if rows .iter() .any(|row| row.len() > MAX_SIDEBAR_TOKENS_PER_ROW) { return Err(format!( "sidebar rows may contain at most {MAX_SIDEBAR_TOKENS_PER_ROW} tokens" )); } Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] pub enum AgentSidebarToken { StateIcon, StateText, Workspace, Tab, Pane, Agent, TerminalTitle, TerminalTitleStripped, Custom(String), } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SpaceSidebarToken { StateIcon, StateText, Workspace, Branch, GitStatus, Custom(String), } fn parse_sidebar_token<'de, D, T>(deserializer: D, builtins: &[(&str, T)]) -> Result where D: serde::Deserializer<'de>, T: Clone + From, { let value = String::deserialize(deserializer)?; if let Some((_, token)) = builtins.iter().find(|(name, _)| *name == value) { return Ok(token.clone()); } let Some(name) = value.strip_prefix('$') else { return Err(serde::de::Error::custom(format!( "unknown sidebar token `{value}`; custom tokens must start with `$`" ))); }; if name.is_empty() || name.len() > 32 || !name .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) { return Err(serde::de::Error::custom(format!( "invalid custom sidebar token `{value}`" ))); } Ok(T::from(name.to_string())) } impl Serialize for AgentSidebarToken { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { match self { Self::StateIcon => serializer.serialize_str("state_icon"), Self::StateText => serializer.serialize_str("state_text"), Self::Workspace => serializer.serialize_str("workspace"), Self::Tab => serializer.serialize_str("tab"), Self::Pane => serializer.serialize_str("pane"), Self::Agent => serializer.serialize_str("agent"), Self::TerminalTitle => serializer.serialize_str("terminal_title"), Self::TerminalTitleStripped => serializer.serialize_str("terminal_title_stripped"), Self::Custom(name) => serializer.serialize_str(&format!("${name}")), } } } impl From for AgentSidebarToken { fn from(value: String) -> Self { Self::Custom(value) } } impl<'de> Deserialize<'de> for AgentSidebarToken { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { parse_sidebar_token( deserializer, &[ ("state_icon", Self::StateIcon), ("state_text", Self::StateText), ("workspace", Self::Workspace), ("tab", Self::Tab), ("pane", Self::Pane), ("agent", Self::Agent), ("terminal_title", Self::TerminalTitle), ("terminal_title_stripped", Self::TerminalTitleStripped), ], ) } } impl Serialize for SpaceSidebarToken { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { match self { Self::StateIcon => serializer.serialize_str("state_icon"), Self::StateText => serializer.serialize_str("state_text"), Self::Workspace => serializer.serialize_str("workspace"), Self::Branch => serializer.serialize_str("branch"), Self::GitStatus => serializer.serialize_str("git_status"), Self::Custom(name) => serializer.serialize_str(&format!("${name}")), } } } impl From for SpaceSidebarToken { fn from(value: String) -> Self { Self::Custom(value) } } impl<'de> Deserialize<'de> for SpaceSidebarToken { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { parse_sidebar_token( deserializer, &[ ("state_icon", Self::StateIcon), ("state_text", Self::StateText), ("workspace", Self::Workspace), ("branch", Self::Branch), ("git_status", Self::GitStatus), ], ) } } type AgentSidebarRows = Vec>; type SpaceSidebarRows = Vec>; fn deserialize_rows_by_agent<'de, D>( deserializer: D, ) -> Result, D::Error> where D: serde::Deserializer<'de>, { let rows_by_agent = BTreeMap::::deserialize(deserializer)?; for (id, rows) in &rows_by_agent { if crate::detect::parse_canonical_agent_label(id).is_none() { return Err(serde::de::Error::custom(format!( "unknown canonical agent id `{id}` in sidebar rows_by_agent" ))); } validate_sidebar_rows(rows).map_err(serde::de::Error::custom)?; } Ok(rows_by_agent) } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct AgentsSidebarConfig { #[serde(deserialize_with = "deserialize_sidebar_rows")] pub rows: AgentSidebarRows, #[serde(default, deserialize_with = "deserialize_rows_by_agent")] pub rows_by_agent: BTreeMap, } impl AgentsSidebarConfig { pub(crate) fn rows_for_agent(&self, agent: Option) -> &AgentSidebarRows { agent .and_then(|agent| self.rows_by_agent.get(crate::detect::agent_label(agent))) .unwrap_or(&self.rows) } } impl Default for AgentsSidebarConfig { fn default() -> Self { Self { rows: vec![ vec![ AgentSidebarToken::StateIcon, AgentSidebarToken::Workspace, AgentSidebarToken::Tab, ], vec![AgentSidebarToken::Agent], ], rows_by_agent: BTreeMap::new(), } } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct SpacesSidebarConfig { #[serde(deserialize_with = "deserialize_sidebar_rows")] pub rows: SpaceSidebarRows, } impl Default for SpacesSidebarConfig { fn default() -> Self { Self { rows: vec![ vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace], vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus], ], } } } #[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(default)] pub struct SidebarConfig { pub agents: AgentsSidebarConfig, pub spaces: SpacesSidebarConfig, } #[cfg(test)] mod tests { use super::*; #[test] fn defaults_match_the_compact_agent_and_existing_space_layouts() { let config = SidebarConfig::default(); assert_eq!( config.agents.rows, vec![ vec![ AgentSidebarToken::StateIcon, AgentSidebarToken::Workspace, AgentSidebarToken::Tab, ], vec![AgentSidebarToken::Agent], ] ); assert!(config.agents.rows_by_agent.is_empty()); assert_eq!( config.spaces.rows, vec![ vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace], vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus], ] ); } #[test] fn parses_builtin_and_arbitrary_custom_tokens() { let config: crate::config::Config = toml::from_str( r#" [ui.sidebar.agents] rows = [["state_icon", "workspace"], ["state_text", "agent", "$summary"], ["terminal_title", "terminal_title_stripped", "$terminal_title"]] [ui.sidebar.agents.rows_by_agent] claude = [["terminal_title_stripped"], ["agent", "$model"]] [ui.sidebar.spaces] rows = [["workspace"], ["$jj_status"]] "#, ) .expect("sidebar token config"); assert_eq!( config.ui.sidebar.agents.rows[1], vec![ AgentSidebarToken::StateText, AgentSidebarToken::Agent, AgentSidebarToken::Custom("summary".into()), ] ); assert_eq!( config.ui.sidebar.agents.rows[2], vec![ AgentSidebarToken::TerminalTitle, AgentSidebarToken::TerminalTitleStripped, AgentSidebarToken::Custom("terminal_title".into()), ] ); assert_eq!( config.ui.sidebar.agents.rows_by_agent["claude"], vec![ vec![AgentSidebarToken::TerminalTitleStripped], vec![ AgentSidebarToken::Agent, AgentSidebarToken::Custom("model".into()), ], ] ); assert_eq!( config.ui.sidebar.spaces.rows[1], vec![SpaceSidebarToken::Custom("jj_status".into())] ); } #[test] fn rejects_unknown_bare_and_malformed_custom_tokens() { for token in ["summary", "$", "$bad.name"] { let input = format!("[ui.sidebar.agents]\\nrows = [[\"{token}\"]]\\n"); assert!(toml::from_str::(&input).is_err()); } } #[test] fn rejects_oversized_sidebar_layouts() { let too_many_rows = std::iter::repeat_n("[\"agent\"]", MAX_SIDEBAR_ROWS + 1) .collect::>() .join(","); let input = format!("[ui.sidebar.agents]\nrows = [{too_many_rows}]\n"); assert!(toml::from_str::(&input).is_err()); let too_many_tokens = std::iter::repeat_n("\"workspace\"", MAX_SIDEBAR_TOKENS_PER_ROW + 1) .collect::>() .join(","); let input = format!("[ui.sidebar.spaces]\nrows = [[{too_many_tokens}]]\n"); assert!(toml::from_str::(&input).is_err()); let input = format!("[ui.sidebar.agents.rows_by_agent]\nclaude = [{too_many_rows}]\n"); assert!(toml::from_str::(&input).is_err()); } #[test] fn accepts_every_canonical_agent_override_key() { let agents = [ Agent::Pi, Agent::Claude, Agent::Codex, Agent::Gemini, Agent::Cursor, Agent::Devin, Agent::Antigravity, Agent::Cline, Agent::Omp, Agent::Mastracode, Agent::OpenCode, Agent::GithubCopilot, Agent::Kimi, Agent::Kiro, Agent::Droid, Agent::Amp, Agent::Grok, Agent::Hermes, Agent::Kilo, Agent::Qodercli, Agent::Maki, ]; let entries = agents .iter() .map(|agent| format!("{} = [[\"agent\"]]", crate::detect::agent_label(*agent))) .collect::>() .join("\n"); let input = format!("[ui.sidebar.agents.rows_by_agent]\n{entries}\n"); let config: crate::config::Config = toml::from_str(&input).expect("canonical keys"); assert_eq!(config.ui.sidebar.agents.rows_by_agent.len(), agents.len()); } #[test] fn rejects_alias_case_whitespace_and_unknown_override_keys() { for key in ["claude-code", "Claude", "' claude '", "unknown"] { let input = format!("[ui.sidebar.agents.rows_by_agent]\n{key} = [[\"agent\"]]\n"); assert!( toml::from_str::(&input).is_err(), "accepted key {key:?}" ); } } }