aboutsummaryrefslogtreecommitdiff
path: root/src/config
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-06 23:44:15 +0300
committerGitHub <noreply@github.com>2026-09-06 23:44:15 +0300
commitc7b79294e28fe7c835691a25597fabf226ecfc20 (patch)
tree0f53309032f46aa119b7c44386524b1f02436759 /src/config
parent2329bc181d1d21a365704f261e11ce0ee86524a5 (diff)
feat: add conditional styles for sidebar tokens (#3693)
Diffstat (limited to 'src/config')
-rw-r--r--src/config/sidebar.rs139
-rw-r--r--src/config/sidebar/rules.rs234
2 files changed, 353 insertions, 20 deletions
diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs
index 32838c21..0b489765 100644
--- a/src/config/sidebar.rs
+++ b/src/config/sidebar.rs
@@ -1,3 +1,7 @@
+mod rules;
+
+pub use rules::SidebarTokenRule;
+
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
@@ -115,6 +119,7 @@ pub enum AgentSidebarToken {
Styled {
token: Box<AgentSidebarToken>,
style: SidebarTokenStyle,
+ rules: Vec<SidebarTokenRule>,
},
}
@@ -129,22 +134,37 @@ pub enum SpaceSidebarToken {
Styled {
token: Box<SpaceSidebarToken>,
style: SidebarTokenStyle,
+ rules: Vec<SidebarTokenRule>,
},
}
impl AgentSidebarToken {
+ pub(crate) fn style_for_value(&self, value: &str) -> SidebarTokenStyle {
+ match self {
+ Self::Styled { style, rules, .. } => rules::matching_style(rules, *style, value),
+ _ => SidebarTokenStyle::default(),
+ }
+ }
+
pub(crate) fn parts(&self) -> (&Self, SidebarTokenStyle) {
match self {
- Self::Styled { token, style } => (token, *style),
+ Self::Styled { token, style, .. } => (token, *style),
token => (token, SidebarTokenStyle::default()),
}
}
}
impl SpaceSidebarToken {
+ pub(crate) fn style_for_value(&self, value: &str) -> SidebarTokenStyle {
+ match self {
+ Self::Styled { style, rules, .. } => rules::matching_style(rules, *style, value),
+ _ => SidebarTokenStyle::default(),
+ }
+ }
+
pub(crate) fn parts(&self) -> (&Self, SidebarTokenStyle) {
match self {
- Self::Styled { token, style } => (token, *style),
+ Self::Styled { token, style, .. } => (token, *style),
token => (token, SidebarTokenStyle::default()),
}
}
@@ -160,6 +180,8 @@ struct RawStyledSidebarToken {
bold: Option<bool>,
#[serde(default)]
dim: Option<bool>,
+ #[serde(default)]
+ rules: Vec<SidebarTokenRule>,
}
#[derive(Deserialize)]
@@ -170,17 +192,28 @@ enum RawSidebarToken {
}
impl RawSidebarToken {
- fn parts(self) -> (String, Option<SidebarTokenStyle>) {
+ fn parts(self) -> Result<(String, Option<SidebarTokenStyle>, Vec<SidebarTokenRule>), String> {
match self {
- Self::Plain(token) => (token, None),
- Self::Styled(token) => (
- token.token,
- Some(SidebarTokenStyle {
- fg: token.fg,
- bold: token.bold,
- dim: token.dim,
- }),
- ),
+ Self::Plain(token) => Ok((token, None, Vec::new())),
+ Self::Styled(token) => {
+ if token.rules.len() > 16 {
+ return Err("sidebar tokens may contain at most 16 rules".into());
+ }
+ if !token.rules.is_empty()
+ && matches!(token.token.as_str(), "state_icon" | "git_status")
+ {
+ return Err("sidebar rules require a text-valued token".into());
+ }
+ Ok((
+ token.token,
+ Some(SidebarTokenStyle {
+ fg: token.fg,
+ bold: token.bold,
+ dim: token.dim,
+ }),
+ token.rules,
+ ))
+ }
}
}
}
@@ -211,6 +244,7 @@ where
fn serialize_styled_token<S>(
name: String,
style: SidebarTokenStyle,
+ rules: &[SidebarTokenRule],
serializer: S,
) -> Result<S::Ok, S::Error>
where
@@ -228,6 +262,9 @@ where
if let Some(dim) = style.dim {
map.serialize_entry("dim", &dim)?;
}
+ if !rules.is_empty() {
+ map.serialize_entry("rules", rules)?;
+ }
map.end()
}
@@ -265,9 +302,11 @@ impl Serialize for AgentSidebarToken {
S: serde::Serializer,
{
match self {
- Self::Styled { token, style } => {
- serialize_styled_token(agent_token_name(token), *style, serializer)
- }
+ Self::Styled {
+ token,
+ style,
+ rules,
+ } => serialize_styled_token(agent_token_name(token), *style, rules, serializer),
token => serializer.serialize_str(&agent_token_name(token)),
}
}
@@ -284,7 +323,9 @@ impl<'de> Deserialize<'de> for AgentSidebarToken {
where
D: serde::Deserializer<'de>,
{
- let (value, style) = RawSidebarToken::deserialize(deserializer)?.parts();
+ let (value, style, rules) = RawSidebarToken::deserialize(deserializer)?
+ .parts()
+ .map_err(serde::de::Error::custom)?;
let token = parse_sidebar_token(
value,
&[
@@ -303,6 +344,7 @@ impl<'de> Deserialize<'de> for AgentSidebarToken {
Ok(style.map_or(token.clone(), |style| Self::Styled {
token: Box::new(token),
style,
+ rules,
}))
}
}
@@ -313,9 +355,11 @@ impl Serialize for SpaceSidebarToken {
S: serde::Serializer,
{
match self {
- Self::Styled { token, style } => {
- serialize_styled_token(space_token_name(token), *style, serializer)
- }
+ Self::Styled {
+ token,
+ style,
+ rules,
+ } => serialize_styled_token(space_token_name(token), *style, rules, serializer),
token => serializer.serialize_str(&space_token_name(token)),
}
}
@@ -332,7 +376,9 @@ impl<'de> Deserialize<'de> for SpaceSidebarToken {
where
D: serde::Deserializer<'de>,
{
- let (value, style) = RawSidebarToken::deserialize(deserializer)?.parts();
+ let (value, style, rules) = RawSidebarToken::deserialize(deserializer)?
+ .parts()
+ .map_err(serde::de::Error::custom)?;
let token = parse_sidebar_token(
value,
&[
@@ -347,6 +393,7 @@ impl<'de> Deserialize<'de> for SpaceSidebarToken {
Ok(style.map_or(token.clone(), |style| Self::Styled {
token: Box::new(token),
style,
+ rules,
}))
}
}
@@ -563,6 +610,58 @@ rows = [[{ token = "git_status", fg = "#ff00aa" }], [{ token = "$jj", bold = tru
}
#[test]
+ fn conditional_sidebar_rules_round_trip() {
+ let input = r##"
+[agents]
+rows = [[{ token = "machine", fg = "#fff", rules = [{ equals = "Local", fg = "#f00" }, { starts_with = "fed", ignore_case = true, bold = true }] }]]
+[agents.rows_by_agent]
+pi = [[{ token = "$load", rules = [{ gt = 80, dim = false }, { lt = 20.5, dim = true }] }]]
+[spaces]
+rows = [[{ token = "$status", rules = [{ contains = "error", bold = true }] }]]
+"##;
+ let config: SidebarConfig = toml::from_str(input).expect("conditional sidebar config");
+ let encoded = toml::to_string(&config).unwrap();
+ assert!(encoded.contains("rules"));
+ assert_eq!(toml::from_str::<SidebarConfig>(&encoded).unwrap(), config);
+ }
+
+ #[test]
+ fn conditional_sidebar_rules_reject_invalid_conditions_and_nontext_tokens() {
+ for rule in [
+ "{ bold = true }",
+ "{ equals = 'x', contains = 'x' }",
+ "{ regex = 'x' }",
+ "{ gt = '80' }",
+ "{ equals = 80 }",
+ "{ gt = nan }",
+ "{ lt = inf }",
+ "{ gt = 80, ignore_case = false }",
+ "{ equals = 'x', underline = true }",
+ "{ equals = 'x', fg = 'red' }",
+ ] {
+ let input = format!("[agents]\nrows = [[{{ token = 'machine', rules = [{rule}] }}]]");
+ assert!(toml::from_str::<SidebarConfig>(&input).is_err(), "{rule}");
+ }
+ for (section, token) in [
+ ("agents", "state_icon"),
+ ("spaces", "state_icon"),
+ ("spaces", "git_status"),
+ ] {
+ let input = format!(
+ "[{section}]\nrows = [[{{ token = '{token}', rules = [{{ equals = 'x' }}] }}]]"
+ );
+ assert!(toml::from_str::<SidebarConfig>(&input).is_err());
+ }
+ for count in [16, 17] {
+ let rules = std::iter::repeat_n("{ equals = 'x' }", count)
+ .collect::<Vec<_>>()
+ .join(",");
+ let input = format!("[agents]\nrows = [[{{ token = 'machine', rules = [{rules}] }}]]");
+ assert_eq!(toml::from_str::<SidebarConfig>(&input).is_ok(), count == 16);
+ }
+ }
+
+ #[test]
fn rejects_invalid_occurrence_styles() {
for entry in [
r##"{ token = "workspace", fg = "red" }"##,
diff --git a/src/config/sidebar/rules.rs b/src/config/sidebar/rules.rs
new file mode 100644
index 00000000..3ee72e56
--- /dev/null
+++ b/src/config/sidebar/rules.rs
@@ -0,0 +1,234 @@
+use serde::{Deserialize, Serialize};
+
+use super::{SidebarTokenColor, SidebarTokenStyle};
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(try_from = "RawRule", into = "RawRule")]
+pub struct SidebarTokenRule {
+ condition: Condition,
+ ignore_case: bool,
+ style: SidebarTokenStyle,
+}
+
+// Deserialization rejects non-finite thresholds, so equality is reflexive.
+impl Eq for SidebarTokenRule {}
+
+#[derive(Debug, Clone, PartialEq)]
+enum Condition {
+ Equals(String),
+ Contains(String),
+ StartsWith(String),
+ GreaterThan(f64),
+ LessThan(f64),
+}
+
+#[derive(Clone, Default, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+struct RawRule {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ equals: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ contains: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ starts_with: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ gt: Option<f64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ lt: Option<f64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ ignore_case: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ fg: Option<SidebarTokenColor>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ bold: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ dim: Option<bool>,
+}
+
+impl TryFrom<RawRule> for SidebarTokenRule {
+ type Error = String;
+
+ fn try_from(raw: RawRule) -> Result<Self, Self::Error> {
+ let count = [
+ raw.equals.is_some(),
+ raw.contains.is_some(),
+ raw.starts_with.is_some(),
+ raw.gt.is_some(),
+ raw.lt.is_some(),
+ ]
+ .into_iter()
+ .filter(|present| *present)
+ .count();
+ if count != 1 {
+ return Err(
+ "sidebar rule requires exactly one of equals, contains, starts_with, gt, lt".into(),
+ );
+ }
+ let condition = if let Some(value) = raw.equals {
+ Condition::Equals(value)
+ } else if let Some(value) = raw.contains {
+ Condition::Contains(value)
+ } else if let Some(value) = raw.starts_with {
+ Condition::StartsWith(value)
+ } else {
+ let (value, greater) = match (raw.gt, raw.lt) {
+ (Some(value), _) => (value, true),
+ (_, Some(value)) => (value, false),
+ _ => unreachable!("validated condition count"),
+ };
+ if !value.is_finite() {
+ return Err("sidebar numeric rule threshold must be finite".into());
+ }
+ if raw.ignore_case.is_some() {
+ return Err("ignore_case applies only to sidebar text conditions".into());
+ }
+ if greater {
+ Condition::GreaterThan(value)
+ } else {
+ Condition::LessThan(value)
+ }
+ };
+ Ok(Self {
+ condition,
+ ignore_case: raw.ignore_case.unwrap_or(false),
+ style: SidebarTokenStyle {
+ fg: raw.fg,
+ bold: raw.bold,
+ dim: raw.dim,
+ },
+ })
+ }
+}
+
+impl From<SidebarTokenRule> for RawRule {
+ fn from(rule: SidebarTokenRule) -> Self {
+ let mut raw = Self {
+ ignore_case: rule.ignore_case.then_some(true),
+ fg: rule.style.fg,
+ bold: rule.style.bold,
+ dim: rule.style.dim,
+ ..Self::default()
+ };
+ match rule.condition {
+ Condition::Equals(value) => raw.equals = Some(value),
+ Condition::Contains(value) => raw.contains = Some(value),
+ Condition::StartsWith(value) => raw.starts_with = Some(value),
+ Condition::GreaterThan(value) => raw.gt = Some(value),
+ Condition::LessThan(value) => raw.lt = Some(value),
+ }
+ raw
+ }
+}
+
+impl SidebarTokenRule {
+ fn matches(&self, value: &str, numeric: &mut Option<Option<f64>>) -> bool {
+ match &self.condition {
+ Condition::Equals(expected) => {
+ if self.ignore_case {
+ value.eq_ignore_ascii_case(expected)
+ } else {
+ value == expected
+ }
+ }
+ Condition::StartsWith(expected) => {
+ if self.ignore_case {
+ value
+ .as_bytes()
+ .get(..expected.len())
+ .is_some_and(|prefix| prefix.eq_ignore_ascii_case(expected.as_bytes()))
+ } else {
+ value.starts_with(expected)
+ }
+ }
+ Condition::Contains(expected) => {
+ if self.ignore_case {
+ expected.is_empty()
+ || value
+ .as_bytes()
+ .windows(expected.len())
+ .any(|part| part.eq_ignore_ascii_case(expected.as_bytes()))
+ } else {
+ value.contains(expected)
+ }
+ }
+ Condition::GreaterThan(threshold) | Condition::LessThan(threshold) => {
+ let parsed = numeric.get_or_insert_with(|| {
+ value
+ .parse::<f64>()
+ .ok()
+ .filter(|number| number.is_finite())
+ });
+ parsed.is_some_and(|number| match self.condition {
+ Condition::GreaterThan(_) => number > *threshold,
+ _ => number < *threshold,
+ })
+ }
+ }
+ }
+}
+
+pub(super) fn matching_style(
+ rules: &[SidebarTokenRule],
+ base: SidebarTokenStyle,
+ value: &str,
+) -> SidebarTokenStyle {
+ let mut numeric = None;
+ for rule in rules {
+ if rule.matches(value, &mut numeric) {
+ return SidebarTokenStyle {
+ fg: rule.style.fg.or(base.fg),
+ bold: rule.style.bold.or(base.bold),
+ dim: rule.style.dim.or(base.dim),
+ };
+ }
+ }
+ base
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn string_conditions_use_exact_case_or_ascii_folding() {
+ for (condition, yes, no) in [
+ ("equals = 'Local'", "Local", "Localhost"),
+ ("contains = 'Local'", "myLocalbox", "remote"),
+ ("starts_with = 'Local'", "Localhost", "myLocal"),
+ ] {
+ let rule: SidebarTokenRule = toml::from_str(condition).unwrap();
+ assert!(rule.matches(yes, &mut None));
+ assert!(!rule.matches(no, &mut None));
+ assert!(!rule.matches(&yes.to_ascii_lowercase(), &mut None));
+ let folded: SidebarTokenRule =
+ toml::from_str(&format!("{condition}\nignore_case = true")).unwrap();
+ assert!(folded.matches(&yes.to_ascii_lowercase(), &mut None));
+ }
+ for condition in ["equals", "contains", "starts_with"] {
+ let rule: SidebarTokenRule =
+ toml::from_str(&format!("{condition} = 'ÉA'\nignore_case = true")).unwrap();
+ assert!(rule.matches("Éa", &mut None));
+ assert!(!rule.matches("éa", &mut None));
+ }
+ let empty: SidebarTokenRule = toml::from_str("contains = ''\nignore_case = true").unwrap();
+ assert!(empty.matches("", &mut None));
+ }
+
+ #[test]
+ fn numeric_conditions_require_full_finite_numbers_and_strict_comparison() {
+ for (condition, yes, no) in [
+ ("gt = 80", ["90", "8.1e1", "+90"], "70"),
+ ("lt = 80", ["70", "7.9e1", "-90"], "90"),
+ ] {
+ let rule: SidebarTokenRule = toml::from_str(condition).unwrap();
+ for value in yes {
+ assert!(rule.matches(value, &mut None), "{condition}: {value}");
+ }
+ for value in [
+ no, "80", "90%", " 90", "90 ", "", "NaN", "inf", "-inf", "1e999",
+ ] {
+ assert!(!rule.matches(value, &mut None), "{condition}: {value}");
+ }
+ }
+ }
+}