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), Workspace(String), Tab(String), Pane(String), Agent(String), TerminalTitle(String), Branch(String), GitStatus { ahead: usize, behind: usize }, Custom(String), } 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) 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::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, }?; 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, }?; 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 { 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 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 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() ))]] ); } }