use par_term_mux::{AgentEntry, AgentSource};
use par_term_tmux::TmuxPaneId;
pub(crate) struct AgentRoster {
entries: std::collections::BTreeMap<TmuxPaneId, AgentEntry>,
}
impl AgentRoster {
pub(crate) fn new() -> Self {
Self {
entries: std::collections::BTreeMap::new(),
}
}
pub(crate) fn fill_from_list(&mut self, agents: Vec<AgentEntry>) {
self.entries.clear();
for entry in agents {
self.entries.insert(entry.pane, entry);
}
}
pub(crate) fn apply_push(&mut self, entry: AgentEntry) {
self.entries.insert(entry.pane, entry);
}
pub(crate) fn clear(&mut self) {
self.entries.clear();
}
pub(crate) fn iter(&self) -> impl Iterator<Item = &AgentEntry> {
self.entries.values()
}
pub(crate) fn summary_line(&self) -> Option<String> {
if self.entries.is_empty() {
return None;
}
let mut groups: std::collections::BTreeMap<&str, (u32, u32)> =
std::collections::BTreeMap::new();
for entry in self.iter() {
let counts = groups.entry(entry.state.as_str()).or_insert((0, 0));
match entry.source {
AgentSource::Hook => counts.0 += 1,
AgentSource::Scrape => counts.1 += 1,
}
}
let mut parts: Vec<(u32, &str, String)> = groups
.into_iter()
.map(|(state, (hook, scrape))| {
let count_text = match (hook, scrape) {
(hook, 0) => hook.to_string(),
(0, scrape) => format!("{scrape}~"),
(hook, scrape) => format!("{hook}+{scrape}~"),
};
(hook + scrape, state, count_text)
})
.collect();
parts.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1)));
let breakdown = parts
.into_iter()
.map(|(_, state, count_text)| format!("{count_text} {state}"))
.collect::<Vec<_>>()
.join(", ");
Some(format!("\u{1f465} {breakdown}"))
}
pub(crate) fn tooltip_text(&self) -> Option<String> {
if self.entries.is_empty() {
return None;
}
let lines: Vec<String> = self
.iter()
.map(|entry| {
let source = match entry.source {
AgentSource::Hook => "reported",
AgentSource::Scrape => "detected",
};
let mut line = format!(
"{} · {} · {} (pane {})",
entry.agent, entry.state, source, entry.pane
);
if let Some(reason) = entry.reason.as_ref() {
line.push_str(&format!(" — {reason}"));
}
line
})
.collect();
Some(lines.join("\n"))
}
pub(crate) fn palette_rows(&self) -> Vec<crate::command_palette::catalog::PaletteEntry> {
let mut rows: Vec<crate::command_palette::catalog::PaletteEntry> = self
.iter()
.map(|entry| {
let mut state = entry.state.clone();
if matches!(entry.source, AgentSource::Scrape) {
state.push('~');
}
let mut label = format!("{}: {}", entry.agent, state);
if let Some(reason) = entry.reason.as_ref() {
label.push_str(&format!(" — {reason}"));
}
crate::command_palette::catalog::PaletteEntry {
action_id: format!("agent-roster-focus:{}", entry.pane),
label,
chord: None,
priority: if entry.state.eq_ignore_ascii_case("blocked") {
2
} else {
1
},
}
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.priority));
rows
}
}
#[cfg(test)]
mod tests {
use super::*;
use par_term_mux::AgentSource;
fn entry(pane: u64, agent: &str, state: &str, source: AgentSource) -> AgentEntry {
AgentEntry {
pane,
agent: agent.to_string(),
state: state.to_string(),
source,
reason: None,
}
}
#[test]
fn fill_replaces_wholesale_so_absent_drops_out() {
let mut roster = AgentRoster::new();
roster.fill_from_list(vec![entry(0, "kimi", "working", AgentSource::Hook)]);
roster.fill_from_list(vec![entry(1, "claude", "blocked", AgentSource::Scrape)]);
let panes: Vec<_> = roster.iter().map(|e| e.pane).collect();
assert_eq!(
panes,
vec![1],
"pane 0 stopped reporting and must be absent, not idle"
);
}
#[test]
fn push_upserts_its_pane_only() {
let mut roster = AgentRoster::new();
roster.fill_from_list(vec![
entry(0, "kimi", "working", AgentSource::Hook),
entry(3, "omp", "idle", AgentSource::Hook),
]);
roster.apply_push(entry(0, "kimi", "blocked", AgentSource::Hook));
let snapshot: Vec<_> = roster.iter().map(|e| (e.pane, e.state.clone())).collect();
assert_eq!(
snapshot,
vec![(0, "blocked".to_string()), (3, "idle".to_string())]
);
}
#[test]
fn entries_keep_provenance_and_absent_reason() {
let mut roster = AgentRoster::new();
roster.apply_push(entry(2, "pi", "blocked", AgentSource::Scrape));
let rendered = roster.iter().next().unwrap();
assert_eq!(rendered.source, AgentSource::Scrape);
assert_eq!(rendered.reason, None, "no reason on the wire yet");
}
#[test]
fn clear_empties_for_dead_transports() {
let mut roster = AgentRoster::new();
roster.apply_push(entry(0, "kimi", "working", AgentSource::Hook));
roster.clear();
assert_eq!(roster.iter().count(), 0);
}
#[test]
fn summary_and_tooltip_hide_when_empty() {
let roster = AgentRoster::new();
assert_eq!(roster.summary_line(), None);
assert_eq!(roster.tooltip_text(), None);
}
#[test]
fn summary_groups_states_with_scrape_marker() {
let mut roster = AgentRoster::new();
roster.fill_from_list(vec![
entry(0, "kimi", "working", AgentSource::Hook),
entry(1, "claude", "blocked", AgentSource::Hook),
entry(2, "pi", "blocked", AgentSource::Hook),
entry(3, "omp", "working", AgentSource::Scrape),
]);
assert_eq!(
roster.summary_line().as_deref(),
Some("\u{1f465} 2 blocked, 1+1~ working")
);
}
#[test]
fn summary_pure_scrape_group_gets_bare_marker() {
let mut roster = AgentRoster::new();
roster.apply_push(entry(5, "grok", "waiting", AgentSource::Scrape));
assert_eq!(
roster.summary_line().as_deref(),
Some("\u{1f465} 1~ waiting")
);
}
#[test]
fn summary_orders_same_size_groups_by_state() {
let mut roster = AgentRoster::new();
roster.fill_from_list(vec![
entry(0, "kimi", "working", AgentSource::Hook),
entry(1, "claude", "blocked", AgentSource::Hook),
]);
assert_eq!(
roster.summary_line().as_deref(),
Some("\u{1f465} 1 blocked, 1 working")
);
}
#[test]
fn tooltip_lists_provenance_and_reason() {
let mut roster = AgentRoster::new();
roster.apply_push(entry(0, "kimi", "working", AgentSource::Hook));
roster.apply_push(AgentEntry {
pane: 4,
agent: "pi".to_string(),
state: "blocked".to_string(),
source: AgentSource::Scrape,
reason: Some("waiting on approval".to_string()),
});
assert_eq!(
roster.tooltip_text().as_deref(),
Some(
"kimi · working · reported (pane 0)\npi · blocked · detected (pane 4) — waiting on approval"
)
);
}
#[test]
fn palette_rows_empty_roster_yields_none() {
assert!(AgentRoster::new().palette_rows().is_empty());
}
#[test]
fn palette_rows_blocked_first_then_pane_order() {
let mut roster = AgentRoster::new();
roster.fill_from_list(vec![
entry(0, "kimi", "working", AgentSource::Hook),
entry(1, "claude", "blocked", AgentSource::Hook),
entry(2, "omp", "idle", AgentSource::Hook),
entry(3, "grok", "blocked", AgentSource::Hook),
]);
let rows = roster.palette_rows();
let ids: Vec<&str> = rows.iter().map(|r| r.action_id.as_str()).collect();
assert_eq!(
ids,
[
"agent-roster-focus:1",
"agent-roster-focus:3",
"agent-roster-focus:0",
"agent-roster-focus:2"
],
"blocked agents lead; working/idle keep pane order behind them"
);
}
#[test]
fn palette_rows_label_carries_marker_and_reason() {
let mut roster = AgentRoster::new();
roster.apply_push(entry(0, "kimi", "working", AgentSource::Hook));
roster.apply_push(AgentEntry {
pane: 4,
agent: "pi".to_string(),
state: "blocked".to_string(),
source: AgentSource::Scrape,
reason: Some("waiting on approval".to_string()),
});
let rows = roster.palette_rows();
assert_eq!(rows[0].label, "pi: blocked~ — waiting on approval");
assert_eq!(rows[1].label, "kimi: working");
assert!(rows.iter().all(|r| r.chord.is_none()));
}
}