aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent2329bc181d1d21a365704f261e11ce0ee86524a5 (diff)
feat: add conditional styles for sidebar tokens (#3693)
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs20
-rw-r--r--src/client/shell/config.rs13
-rw-r--r--src/config/sidebar.rs139
-rw-r--r--src/config/sidebar/rules.rs234
-rw-r--r--src/server/render_scale_benchmark.rs58
-rw-r--r--src/ui/sidebar/tokens.rs131
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 {