use crate::config::{ AgentSidebarToken, AgentsSidebarConfig, SidebarTokenStyle, SpaceSidebarToken, SpacesSidebarConfig, }; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ResolvedToken { pub kind: ResolvedTokenKind, pub style: SidebarTokenStyle, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ResolvedTokenKind { StateIcon, StateText(String), Machine(String), Workspace(String), Tab(String), Pane(String), Agent(String), TerminalTitle(String), Branch(String), GitStatus { ahead: usize, behind: usize }, 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 } } #[cfg(test)] pub(super) fn unstyled(kind: ResolvedTokenKind) -> Self { Self::new(kind, SidebarTokenStyle::default()) } } pub(crate) struct AgentTokenContext<'a> { pub(crate) machine: Option<&'a str>, pub(crate) workspace: &'a str, pub(crate) tab: Option<&'a str>, pub(crate) pane: Option<&'a str>, pub(crate) agent_label: Option<&'a str>, pub(crate) terminal_title: Option<&'a str>, pub(crate) terminal_title_stripped: Option<&'a str>, pub(crate) canonical_agent: Option, pub(crate) tokens: &'a std::collections::HashMap, } pub(crate) fn agent_rows( config: &AgentsSidebarConfig, context: AgentTokenContext<'_>, state_text: &str, ) -> Vec> { config .rows_for_agent(context.canonical_agent) .iter() .filter_map(|row| { let resolved = row .iter() .filter_map(|configured| { let (token, style) = configured.parts(); let kind = match token { AgentSidebarToken::StateIcon => Some(ResolvedTokenKind::StateIcon), AgentSidebarToken::StateText => { Some(ResolvedTokenKind::StateText(state_text.to_string())) } AgentSidebarToken::Machine => context .machine .map(|value| ResolvedTokenKind::Machine(value.to_string())), AgentSidebarToken::Workspace => { Some(ResolvedTokenKind::Workspace(context.workspace.to_string())) } AgentSidebarToken::Tab => context .tab .map(|value| ResolvedTokenKind::Tab(value.to_string())), AgentSidebarToken::Pane => context .pane .map(|value| ResolvedTokenKind::Pane(value.to_string())), AgentSidebarToken::Agent => context .agent_label .map(|value| ResolvedTokenKind::Agent(value.to_string())), AgentSidebarToken::TerminalTitle => context .terminal_title .map(|value| ResolvedTokenKind::TerminalTitle(value.to_string())), AgentSidebarToken::TerminalTitleStripped => context .terminal_title_stripped .map(|value| ResolvedTokenKind::TerminalTitle(value.to_string())), AgentSidebarToken::Custom(name) => context .tokens .get(name) .cloned() .map(ResolvedTokenKind::Custom), AgentSidebarToken::Styled { .. } => None, }?; let style = kind .text_value() .map_or(Some(style), |value| configured.style_for_value(value))?; Some(ResolvedToken::new(kind, style)) }) .collect::>(); (!resolved.is_empty()).then_some(resolved) }) .collect() } pub(crate) struct SpaceTokenContext<'a> { pub(crate) workspace: &'a str, pub(crate) branch: Option<&'a str>, pub(crate) state_text: &'a str, pub(crate) ahead_behind: Option<(usize, usize)>, pub(crate) tokens: &'a std::collections::HashMap, pub(crate) suppress_git_details: bool, } pub(crate) fn space_rows( config: &SpacesSidebarConfig, context: SpaceTokenContext<'_>, ) -> Vec> { config .rows .iter() .filter_map(|row| { let resolved = row .iter() .filter_map(|configured| { let (token, style) = configured.parts(); let kind = match token { SpaceSidebarToken::StateIcon => Some(ResolvedTokenKind::StateIcon), SpaceSidebarToken::StateText => { Some(ResolvedTokenKind::StateText(context.state_text.to_string())) } SpaceSidebarToken::Workspace => { Some(ResolvedTokenKind::Workspace(context.workspace.to_string())) } SpaceSidebarToken::Branch if !context.suppress_git_details => context .branch .map(|branch| ResolvedTokenKind::Branch(branch.to_string())), SpaceSidebarToken::Branch => None, SpaceSidebarToken::GitStatus if !context.suppress_git_details => context .ahead_behind .filter(|(ahead, behind)| *ahead > 0 || *behind > 0) .map(|(ahead, behind)| ResolvedTokenKind::GitStatus { ahead, behind }), SpaceSidebarToken::GitStatus => None, SpaceSidebarToken::Custom(name) => context .tokens .get(name) .cloned() .map(ResolvedTokenKind::Custom), SpaceSidebarToken::Styled { .. } => None, }?; let style = kind .text_value() .map_or(Some(style), |value| configured.style_for_value(value))?; Some(ResolvedToken::new(kind, style)) }) .collect::>(); (!resolved.is_empty()).then_some(resolved) }) .collect() } pub(crate) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str { if matches!(previous.kind, ResolvedTokenKind::StateIcon) || matches!(current.kind, ResolvedTokenKind::GitStatus { .. }) { " " } else { " · " } } #[cfg(test)] mod tests { use super::*; use crate::config::{AgentSidebarToken, SpaceSidebarToken}; struct Entry { workspace: String, tab: Option, pane: Option, agent_label: Option, terminal_title: Option, terminal_title_stripped: Option, canonical_agent: Option, tokens: std::collections::HashMap, } fn entry() -> Entry { Entry { workspace: "repo".into(), tab: None, pane: None, agent_label: Some("pi".into()), terminal_title: None, terminal_title_stripped: None, canonical_agent: Some(crate::detect::Agent::Pi), tokens: std::collections::HashMap::new(), } } fn context(entry: &Entry) -> AgentTokenContext<'_> { AgentTokenContext { machine: None, workspace: &entry.workspace, tab: entry.tab.as_deref(), pane: entry.pane.as_deref(), agent_label: entry.agent_label.as_deref(), terminal_title: entry.terminal_title.as_deref(), terminal_title_stripped: entry.terminal_title_stripped.as_deref(), canonical_agent: entry.canonical_agent, tokens: &entry.tokens, } } #[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 conditional_hide_removes_tokens_and_empty_rows() { let config: crate::config::SidebarConfig = toml::from_str( r##" [agents] rows = [[{ token = "machine", fg = "#61afef", rules = [{ equals = "Local", hide = true }] }, "agent"]] [agents.rows_by_agent] pi = [[{ token = "$load", rules = [{ lt = 50, hide = true }] }], ["agent"]] [spaces] rows = [[{ token = "$load", rules = [{ lt = 50, hide = true }] }], ["workspace"]] "##, ).unwrap(); let encoded = toml::to_string(&config).unwrap(); assert!(encoded.contains("hide = true")); let config: crate::config::SidebarConfig = toml::from_str(&encoded).unwrap(); let mut entry = entry(); entry.canonical_agent = None; for (machine, count) in [("Local", 1), ("Remote", 2)] { let mut ctx = context(&entry); ctx.machine = Some(machine); let rows = agent_rows(&config.agents, ctx, "working"); assert_eq!(rows[0].len(), count); assert_eq!( rows[0].last().unwrap().kind, ResolvedTokenKind::Agent("pi".into()) ); } entry.canonical_agent = Some(crate::detect::Agent::Pi); for (value, count) in [("20", 1), ("90", 2)] { entry.tokens.insert("load".into(), value.into()); assert_eq!( agent_rows(&config.agents, context(&entry), "working").len(), count ); let rows = space_rows( &config.spaces, SpaceTokenContext { workspace: "repo", branch: None, state_text: "working", ahead_behind: None, suppress_git_details: false, tokens: &entry.tokens, }, ); assert_eq!(rows.len(), count); } } #[test] fn conditional_hide_preserves_first_match_wins() { for first in ["hide = false", "bold = true"] { let config: AgentsSidebarConfig = toml::from_str(&format!( "rows = [[{{ token = 'agent', rules = [{{ equals = 'pi', {first} }}, {{ contains = '', hide = true }}] }}]]" )).unwrap(); let entry = entry(); let rows = agent_rows(&config, context(&entry), "working"); assert_eq!(rows[0][0].kind, ResolvedTokenKind::Agent("pi".into())); } } #[test] fn missing_custom_tokens_elide_rows_and_separators() { let entry = entry(); let config = AgentsSidebarConfig { rows: vec![ vec![ AgentSidebarToken::StateIcon, AgentSidebarToken::Custom("missing".into()), ], vec![AgentSidebarToken::Custom("missing".into())], vec![AgentSidebarToken::Agent], ], ..Default::default() }; let rows = agent_rows(&config, context(&entry), "working"); assert_eq!(rows.len(), 2); assert_eq!( rows[0], vec![ResolvedToken::unstyled(ResolvedTokenKind::StateIcon)] ); assert_eq!( rows[1], vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent( "pi".into() ))] ); } #[test] fn machine_token_only_resolves_for_multi_machine_rows() { let entry = entry(); let config = AgentsSidebarConfig { rows: vec![vec![ AgentSidebarToken::Machine, AgentSidebarToken::Workspace, ]], ..Default::default() }; assert_eq!( agent_rows(&config, context(&entry), "working"), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace( "repo".into() ))]] ); let mut remote_context = context(&entry); remote_context.machine = Some("Build"); assert_eq!( agent_rows(&config, remote_context, "working"), vec![vec![ ResolvedToken::unstyled(ResolvedTokenKind::Machine("Build".into())), ResolvedToken::unstyled(ResolvedTokenKind::Workspace("repo".into())), ]] ); } #[test] fn state_text_and_arbitrary_values_are_independent_tokens() { let mut entry = entry(); entry .tokens .insert("summary".into(), "reviewing auth".into()); let config = AgentsSidebarConfig { rows: vec![vec![ AgentSidebarToken::StateText, AgentSidebarToken::Custom("summary".into()), ]], ..Default::default() }; assert_eq!( agent_rows(&config, context(&entry), "deep in the mines"), vec![vec![ ResolvedToken::unstyled(ResolvedTokenKind::StateText("deep in the mines".into())), ResolvedToken::unstyled(ResolvedTokenKind::Custom("reviewing auth".into())), ]] ); } #[test] fn terminal_title_builtins_are_distinct_from_custom_tokens() { let mut entry = entry(); entry.terminal_title = Some("⠋ raw title".into()); entry.terminal_title_stripped = Some("raw title".into()); entry .tokens .insert("terminal_title".into(), "custom title".into()); let config = AgentsSidebarConfig { rows: vec![vec![ AgentSidebarToken::TerminalTitle, AgentSidebarToken::TerminalTitleStripped, AgentSidebarToken::Custom("terminal_title".into()), ]], ..Default::default() }; assert_eq!( agent_rows(&config, context(&entry), "working"), vec![vec![ ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("⠋ raw title".into())), ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("raw title".into())), ResolvedToken::unstyled(ResolvedTokenKind::Custom("custom title".into())), ]] ); } #[test] fn known_agent_override_replaces_default_rows() { let mut config = AgentsSidebarConfig { rows: vec![vec![AgentSidebarToken::Workspace]], ..Default::default() }; config .rows_by_agent .insert("pi".into(), vec![vec![AgentSidebarToken::Agent]]); let mut pi = entry(); pi.agent_label = Some("renamed pi".into()); assert_eq!( agent_rows(&config, context(&pi), "working"), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent( "renamed pi".into() ))]] ); pi.canonical_agent = None; assert_eq!( agent_rows(&config, context(&pi), "working"), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace( "repo".into() ))]] ); } #[test] fn grouped_children_suppress_all_builtin_git_details() { let config = SpacesSidebarConfig::default(); assert_eq!( space_rows( &config, SpaceTokenContext { workspace: "feature", branch: Some("worktree/feature"), state_text: "idle", ahead_behind: Some((2, 1)), tokens: &std::collections::HashMap::new(), suppress_git_details: true, }, ), vec![vec![ ResolvedToken::unstyled(ResolvedTokenKind::StateIcon), ResolvedToken::unstyled(ResolvedTokenKind::Workspace("feature".into())), ]] ); } #[test] fn workspace_custom_token_can_replace_git_specific_details() { let tokens = std::collections::HashMap::from([("jj_status".into(), "2 changes".into())]); let config = SpacesSidebarConfig { rows: vec![vec![SpaceSidebarToken::Custom("jj_status".into())]], ..Default::default() }; assert_eq!( space_rows( &config, SpaceTokenContext { workspace: "repo", branch: None, state_text: "idle", ahead_behind: None, tokens: &tokens, suppress_git_details: false, }, ), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Custom( "2 changes".into() ))]] ); } }