use super::AgentPanelEntry; use crate::config::{ AgentSidebarToken, AgentsSidebarConfig, SidebarTokenStyle, SpaceSidebarToken, SpacesSidebarConfig, }; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct ResolvedToken { pub kind: ResolvedTokenKind, pub style: SidebarTokenStyle, } #[derive(Debug, Clone, PartialEq, Eq)] pub(super) 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(super) fn agent_rows( config: &AgentsSidebarConfig, entry: &AgentPanelEntry, state_text: &str, ) -> Vec> { config .rows_for_agent(entry.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(entry.primary_label.clone())) } AgentSidebarToken::Tab => { entry.primary_tab_label.clone().map(ResolvedTokenKind::Tab) } AgentSidebarToken::Pane => { entry.pane_label.clone().map(ResolvedTokenKind::Pane) } AgentSidebarToken::Agent => { entry.agent_label.clone().map(ResolvedTokenKind::Agent) } AgentSidebarToken::TerminalTitle => entry .terminal_title .clone() .map(ResolvedTokenKind::TerminalTitle), AgentSidebarToken::TerminalTitleStripped => entry .terminal_title_stripped .clone() .map(ResolvedTokenKind::TerminalTitle), AgentSidebarToken::Custom(name) => entry .tokens .get(name) .cloned() .map(ResolvedTokenKind::Custom), AgentSidebarToken::Styled { .. } => None, }?; Some(ResolvedToken::new(kind, style)) }) .collect::>(); (!resolved.is_empty()).then_some(resolved) }) .collect() } pub(super) struct SpaceTokenContext<'a> { pub workspace: &'a str, pub branch: Option<&'a str>, pub state_text: &'a str, pub ahead_behind: Option<(usize, usize)>, pub tokens: &'a std::collections::HashMap, pub suppress_git_details: bool, } pub(super) 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(super) 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}; use crate::detect::AgentState; fn entry() -> AgentPanelEntry { AgentPanelEntry { ws_idx: 0, tab_idx: 0, pane_id: crate::layout::PaneId::from_raw(1), primary_label: "repo".into(), primary_tab_label: None, pane_label: None, terminal_title: None, terminal_title_stripped: None, agent_label: Some("pi".into()), agent_kind_label: Some("pi".into()), agent: Some(crate::detect::Agent::Pi), state: AgentState::Working, seen: true, last_agent_state_change_seq: None, state_labels: std::collections::HashMap::new(), tokens: std::collections::HashMap::new(), } } #[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, &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, &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, &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, &pi, "working"), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent( "renamed pi".into() ))]] ); pi.agent = None; assert_eq!( agent_rows(&config, &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() ))]] ); } }