aboutsummaryrefslogtreecommitdiff
path: root/src/config
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 03:26:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 03:30:13 +0300
commit5cfe5e5e730246948b0c9428ad876b6938590aad (patch)
tree53d4bbe1aff6887f63f279eed1b5e4e11e7331b7 /src/config
parent7f3364a468f7dcfd8c9c84e5737fe5699f0e3a93 (diff)
feat: add configurable sidebar metadata tokens
Diffstat (limited to 'src/config')
-rw-r--r--src/config/model.rs7
-rw-r--r--src/config/sidebar.rs400
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:?}"
+ );
+ }
+ }
+}