diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-13 03:26:03 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-13 03:30:13 +0300 |
| commit | 5cfe5e5e730246948b0c9428ad876b6938590aad (patch) | |
| tree | 53d4bbe1aff6887f63f279eed1b5e4e11e7331b7 /src/config | |
| parent | 7f3364a468f7dcfd8c9c84e5737fe5699f0e3a93 (diff) | |
feat: add configurable sidebar metadata tokens
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/model.rs | 7 | ||||
| -rw-r--r-- | src/config/sidebar.rs | 400 |
2 files changed, 405 insertions, 2 deletions
diff --git a/src/config/model.rs b/src/config/model.rs index 16bc595d..60dd2471 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -4,8 +4,8 @@ use crossterm::event::KeyModifiers; use serde::{de, Deserialize, Deserializer, Serialize}; use super::{ - ActionKeybinds, BindingConfig, CommandKeybindConfig, IndexedKeybind, Keybinds, SoundConfig, - ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD, DEFAULT_MOUSE_SCROLL_LINES, + ActionKeybinds, BindingConfig, CommandKeybindConfig, IndexedKeybind, Keybinds, SidebarConfig, + SoundConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD, DEFAULT_MOUSE_SCROLL_LINES, DEFAULT_SCROLLBACK_LIMIT_BYTES, }; @@ -810,6 +810,8 @@ pub struct UiConfig { pub hide_tab_bar_when_single_tab: bool, /// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces". pub agent_panel_sort: AgentPanelSortConfig, + /// Expanded sidebar row composition. + pub sidebar: SidebarConfig, /// Accent color for highlights, borders, and navigation UI. /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)). pub accent: String, @@ -1003,6 +1005,7 @@ impl Default for UiConfig { show_agent_labels_on_pane_borders: false, hide_tab_bar_when_single_tab: false, agent_panel_sort: AgentPanelSortConfig::Spaces, + sidebar: SidebarConfig::default(), accent: "cyan".into(), toast: ToastConfig::default(), sound: SoundConfig::default(), diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs new file mode 100644 index 00000000..d0ae5240 --- /dev/null +++ b/src/config/sidebar.rs @@ -0,0 +1,400 @@ +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<Vec<Vec<T>>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + let rows = Vec::<Vec<T>>::deserialize(deserializer)?; + validate_sidebar_rows(&rows).map_err(serde::de::Error::custom)?; + Ok(rows) +} + +fn validate_sidebar_rows<T>(rows: &[Vec<T>]) -> 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<T, D::Error> +where + D: serde::Deserializer<'de>, + T: Clone + From<String>, +{ + 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + 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<String> for AgentSidebarToken { + fn from(value: String) -> Self { + Self::Custom(value) + } +} + +impl<'de> Deserialize<'de> for AgentSidebarToken { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + 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<String> for SpaceSidebarToken { + fn from(value: String) -> Self { + Self::Custom(value) + } +} + +impl<'de> Deserialize<'de> for SpaceSidebarToken { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + 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<Vec<AgentSidebarToken>>; +type SpaceSidebarRows = Vec<Vec<SpaceSidebarToken>>; + +fn deserialize_rows_by_agent<'de, D>( + deserializer: D, +) -> Result<BTreeMap<String, AgentSidebarRows>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let rows_by_agent = BTreeMap::<String, AgentSidebarRows>::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<String, AgentSidebarRows>, +} + +impl AgentsSidebarConfig { + pub(crate) fn rows_for_agent(&self, agent: Option<Agent>) -> &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::<crate::config::Config>(&input).is_err()); + } + } + + #[test] + fn rejects_oversized_sidebar_layouts() { + let too_many_rows = std::iter::repeat_n("[\"agent\"]", MAX_SIDEBAR_ROWS + 1) + .collect::<Vec<_>>() + .join(","); + let input = format!("[ui.sidebar.agents]\nrows = [{too_many_rows}]\n"); + assert!(toml::from_str::<crate::config::Config>(&input).is_err()); + + let too_many_tokens = std::iter::repeat_n("\"workspace\"", MAX_SIDEBAR_TOKENS_PER_ROW + 1) + .collect::<Vec<_>>() + .join(","); + let input = format!("[ui.sidebar.spaces]\nrows = [[{too_many_tokens}]]\n"); + assert!(toml::from_str::<crate::config::Config>(&input).is_err()); + + let input = format!("[ui.sidebar.agents.rows_by_agent]\nclaude = [{too_many_rows}]\n"); + assert!(toml::from_str::<crate::config::Config>(&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::<Vec<_>>() + .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::<crate::config::Config>(&input).is_err(), + "accepted key {key:?}" + ); + } + } +} |
