diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-06 23:44:15 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-06 23:44:15 +0300 |
| commit | c7b79294e28fe7c835691a25597fabf226ecfc20 (patch) | |
| tree | 0f53309032f46aa119b7c44386524b1f02436759 /src | |
| parent | 2329bc181d1d21a365704f261e11ce0ee86524a5 (diff) | |
feat: add conditional styles for sidebar tokens (#3693)
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/mod.rs | 20 | ||||
| -rw-r--r-- | src/client/shell/config.rs | 13 | ||||
| -rw-r--r-- | src/config/sidebar.rs | 139 | ||||
| -rw-r--r-- | src/config/sidebar/rules.rs | 234 | ||||
| -rw-r--r-- | src/server/render_scale_benchmark.rs | 58 | ||||
| -rw-r--r-- | src/ui/sidebar/tokens.rs | 131 |
6 files changed, 570 insertions, 25 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 0f78cf8b..110be9b7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1829,6 +1829,26 @@ mod tests { ); assert_eq!(app.state.sidebar_spaces.row_gap, 3); + let conditional = "[ui.sidebar.agents]\nrows = [[{ token = '$load', rules = [{ gt = 80, bold = true }] }]]\n"; + std::fs::write(&path, conditional).unwrap(); + assert_eq!( + app.reload_config().status, + crate::config::ConfigReloadStatus::Applied + ); + assert_eq!( + app.state.sidebar_agents.rows[0][0] + .style_for_value("90") + .bold, + Some(true) + ); + let previous = app.state.sidebar_agents.clone(); + std::fs::write(&path, conditional.replace("gt = 80", "gt = 'invalid'")).unwrap(); + assert_eq!( + app.reload_config().status, + crate::config::ConfigReloadStatus::Partial + ); + assert_eq!(app.state.sidebar_agents, previous); + let previous_agents = app.state.sidebar_agents.clone(); std::fs::write( &path, diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index 1c2993b7..e8a9b4bb 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -441,7 +441,7 @@ mod tests { next.ui.tab_bar_position = TabBarPositionConfig::Bottom; next.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; - next.ui.sidebar.agents.row_gap = 2; + next.ui.sidebar.agents = toml::from_str("rows = [[{ token = 'machine', rules = [{ equals = 'Local', bold = true }] }]]\nrow_gap = 2").unwrap(); next.keys.prefix = "ctrl+a".to_owned(); let diagnostics = shell.apply_live_config(&next, &[], &[]); @@ -459,6 +459,17 @@ mod tests { ); assert_eq!(shell.agents.row_gap, 2); assert_eq!( + shell.agents.rows[0][0].style_for_value("Local").bold, + Some(true) + ); + let previous = shell.agents.clone(); + shell.apply_live_config( + &Config::default(), + &[], + &["ui".to_owned(), "keys".to_owned()], + ); + assert_eq!(shell.agents, previous); + assert_eq!( shell.keybinds.prefix, (KeyCode::Char('a'), KeyModifiers::CONTROL) ); 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}"); + } + } + } +} diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs index b8bb2e89..7503f06d 100644 --- a/src/server/render_scale_benchmark.rs +++ b/src/server/render_scale_benchmark.rs @@ -39,10 +39,13 @@ struct RenderPipeline { impl RenderPipeline { fn new(workspaces: Vec<Workspace>) -> Self { - let config = Config::default(); + Self::with_config(workspaces, &Config::default()) + } + + fn with_config(workspaces: Vec<Workspace>, config: &Config) -> Self { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( - &config, + config, AppPolicy::TEST, None, api_rx, @@ -53,7 +56,7 @@ impl RenderPipeline { app.state.selected = 0; app.state.pane_scrollbars = true; - let mut client = ClientShellState::new(ClientShellConfig::from_config(&config)); + let mut client = ClientShellState::new(ClientShellConfig::from_config(config)); client.set_snapshot(Box::new(super::client_shell::snapshot( &app, "bench-boot", @@ -171,7 +174,10 @@ fn summarize(mut samples: Vec<Duration>) -> StageStats { } fn profile(build: fn(usize) -> Vec<Workspace>, count: usize) -> PipelineStats { - let mut pipeline = RenderPipeline::new(build(count)); + profile_pipeline(RenderPipeline::new(build(count))) +} + +fn profile_pipeline(mut pipeline: RenderPipeline) -> PipelineStats { for _ in 0..WARMUP_COUNT { black_box(pipeline.render_once()); } @@ -263,6 +269,49 @@ fn print_snapshot_encoding_profiles(label: &str, build: fn(usize) -> Vec<Workspa } } +fn print_token_rule_profiles() { + let rules = std::iter::repeat_n( + "{ contains = 'NO-MATCH', ignore_case = true, bold = true }", + 15, + ) + .chain(std::iter::once("{ starts_with = 'bench', bold = true }")) + .collect::<Vec<_>>() + .join(","); + for (label, build) in [ + ("background", workspaces as fn(usize) -> Vec<Workspace>), + ("active", active_panes), + ] { + for conditional in [false, true] { + let config: Config = toml::from_str(&format!( + "[ui.sidebar.agents]\nrows = [[{{ token = 'workspace', rules = [{}] }}]]\n[ui.sidebar.spaces]\nrows = [[{{ token = 'workspace', rules = [{}] }}]]", + if conditional { &rules } else { "" }, if conditional { &rules } else { "" }, + )).unwrap(); + let rows = [1, 15].map(|count| { + let mut pipeline = RenderPipeline::with_config(build(count), &config); + pipeline.app.state.ensure_test_terminals(); + for terminal in pipeline.app.state.terminals.values_mut() { + terminal.detected_agent = Some(crate::detect::Agent::Pi); + } + pipeline + .client + .set_snapshot(Box::new(super::client_shell::snapshot( + &pipeline.app, + "bench-boot", + 1, + None, + None, + ))); + (count, profile_pipeline(pipeline)) + }); + println!( + "token rules {label}: populated agents, rules_per_token={}", + if conditional { 16 } else { 0 } + ); + print_stage("client shell composition", &rows, |stats| stats.client); + } + } +} + #[tokio::test(flavor = "current_thread")] #[ignore = "manual client-rendered pipeline scaling profile"] async fn render_scale_profile() { @@ -270,4 +319,5 @@ async fn render_scale_profile() { print_snapshot_encoding_profiles("background workspaces", workspaces); print_profiles("active panes (one workspace)", active_panes); print_snapshot_encoding_profiles("active panes", active_panes); + print_token_rule_profiles(); } diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index 911c87f9..d9cefe99 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -24,6 +24,23 @@ pub(crate) enum ResolvedTokenKind { Custom(String), } +impl ResolvedTokenKind { + fn text_value(&self) -> Option<&str> { + match self { + Self::StateText(value) + | Self::Machine(value) + | Self::Workspace(value) + | Self::Tab(value) + | Self::Pane(value) + | Self::Agent(value) + | Self::TerminalTitle(value) + | Self::Branch(value) + | Self::Custom(value) => Some(value), + Self::StateIcon | Self::GitStatus { .. } => None, + } + } +} + impl ResolvedToken { fn new(kind: ResolvedTokenKind, style: SidebarTokenStyle) -> Self { Self { kind, style } @@ -93,6 +110,9 @@ pub(crate) fn agent_rows( .map(ResolvedTokenKind::Custom), AgentSidebarToken::Styled { .. } => None, }?; + let style = kind + .text_value() + .map_or(style, |value| configured.style_for_value(value)); Some(ResolvedToken::new(kind, style)) }) .collect::<Vec<_>>(); @@ -146,6 +166,9 @@ pub(crate) fn space_rows( .map(ResolvedTokenKind::Custom), SpaceSidebarToken::Styled { .. } => None, }?; + let style = kind + .text_value() + .map_or(style, |value| configured.style_for_value(value)); Some(ResolvedToken::new(kind, style)) }) .collect::<Vec<_>>(); @@ -208,6 +231,114 @@ mod tests { } #[test] + fn conditional_styles_merge_first_match_and_keep_missing_values_absent() { + let config: AgentsSidebarConfig = toml::from_str(r##" +rows = [["state_icon", { token = "machine", fg = "#fff", bold = true, dim = true, rules = [{ equals = "Local", fg = "#f00", bold = false }, { contains = "Loc", fg = "#0f0", dim = false }] }], [{ token = "$missing", rules = [{ equals = "", bold = true }] }]] +"##).unwrap(); + let entry = entry(); + for (machine, color, bold, dim) in [ + ("Local", (255, 0, 0), false, true), + ("Localhost", (0, 255, 0), true, false), + ("local", (255, 255, 255), true, true), + ] { + let mut context = context(&entry); + context.machine = Some(machine); + let rows = agent_rows(&config, context, "working"); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0][0], + ResolvedToken::unstyled(ResolvedTokenKind::StateIcon) + ); + let token = &rows[0][1]; + assert_eq!(token.kind, ResolvedTokenKind::Machine(machine.into())); + assert_eq!( + token.style.fg.unwrap().ratatui(), + ratatui::style::Color::Rgb(color.0, color.1, color.2) + ); + assert_eq!(token.style.bold, Some(bold)); + assert_eq!(token.style.dim, Some(dim)); + } + assert_eq!(agent_rows(&config, context(&entry), "working")[0].len(), 1); + } + + #[test] + fn conditional_style_survives_truncation_and_removes_theme_modifiers() { + use ratatui::style::{Color, Modifier, Style}; + let config: AgentsSidebarConfig = toml::from_str(r##" +rows = [[{ token = "workspace", rules = [{ equals = "long-workspace-name", fg = "#f00", bold = false, dim = false }] }]] +"##).unwrap(); + let mut entry = entry(); + entry.workspace = "long-workspace-name".into(); + let rows = agent_rows(&config, context(&entry), "working"); + let theme = Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD | Modifier::DIM); + for width in [4, 40] { + let spans = super::super::resolved_token_spans( + &rows[0], + ("*", theme), + theme, + theme, + theme, + theme, + &super::super::Palette::catppuccin(), + width, + ); + assert_eq!(spans.len(), 1); + assert!(super::super::display_width(&spans[0].content) <= width); + assert_eq!(spans[0].style.fg, Some(Color::Rgb(255, 0, 0))); + assert!(!spans[0] + .style + .add_modifier + .intersects(Modifier::BOLD | Modifier::DIM)); + assert!(spans[0] + .style + .sub_modifier + .contains(Modifier::BOLD | Modifier::DIM)); + } + } + + #[test] + fn custom_numeric_rules_resolve_in_agent_overrides_and_space_rows() { + let config: crate::config::SidebarConfig = toml::from_str( + r#" +[agents] +rows = [["workspace"]] +[agents.rows_by_agent] +pi = [[{ token = "$load", rules = [{ gt = 80, bold = true }, { gt = 50, dim = true }] }]] +[spaces] +rows = [[{ token = "$load", rules = [{ lt = 50, dim = true }] }]] +"#, + ) + .unwrap(); + let mut entry = entry(); + for (value, bold, dim) in [ + ("90", Some(true), None), + ("60", None, Some(true)), + ("20", None, None), + ("90%", None, None), + ] { + entry.tokens.insert("load".into(), value.into()); + let rows = agent_rows(&config.agents, context(&entry), "working"); + assert_eq!(rows[0][0].kind, ResolvedTokenKind::Custom(value.into())); + assert_eq!(rows[0][0].style.bold, bold); + assert_eq!(rows[0][0].style.dim, dim); + let spaces = space_rows( + &config.spaces, + SpaceTokenContext { + workspace: "repo", + branch: None, + state_text: "working", + ahead_behind: None, + suppress_git_details: false, + tokens: &entry.tokens, + }, + ); + assert_eq!(spaces[0][0].style.dim, (value == "20").then_some(true)); + } + } + + #[test] fn missing_custom_tokens_elide_rows_and_separators() { let entry = entry(); let config = AgentsSidebarConfig { |
