teamctl_ui/data.rs
1//! `TeamSnapshot` — point-in-time read of the dogfood team that the UI
2//! renders against. Built by walking up to the nearest `.team/`,
3//! parsing `team-compose.yaml`, querying the supervisor for each
4//! agent's process state, and aggregating a small set of mailbox
5//! counters (unread + pending approvals).
6//!
7//! Read by both `App::tick()` (live refresh every second) and the
8//! snapshot tests (constructed manually). The snapshot is intentionally
9//! cheap to build — every field is derived from a single SQL query
10//! per agent — so refresh cadence stays well under tmux's own
11//! `capture-pane` cost.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result};
17use rusqlite::Connection;
18use team_core::compose::Compose;
19use team_core::supervisor::{AgentSpec, AgentState, Supervisor, TmuxSupervisor};
20
21/// Per-agent fields the UI reads to render the roster + drive
22/// selection / detail-pane streaming.
23#[derive(Debug, Clone)]
24pub struct AgentInfo {
25 /// `<project>:<agent>` — the canonical id used in `teamctl send`
26 /// targets, MCP tool calls, and `reports_to` chains.
27 pub id: String,
28 /// Short agent name within the project (the YAML key).
29 pub agent: String,
30 /// Project id this agent belongs to.
31 pub project: String,
32 /// Resolved tmux session name (`<prefix><project>-<agent>`) — fed
33 /// to the pane-capture call so the detail pane targets the right
34 /// session even when `tmux_prefix` rotates.
35 pub tmux_session: String,
36 /// Process state — `Running`, `Stopped`, or `Unknown` per the
37 /// supervisor trait. Drives the primary glyph in the roster.
38 pub state: AgentState,
39 /// Count of mailbox messages addressed to this agent that haven't
40 /// been ack'd yet. Sourced from the mailbox DB. No longer drives a
41 /// roster glyph (the `✉` unread glyph was dropped in #429); retained
42 /// for future unread surfacing.
43 pub unread_mail: u32,
44 /// Count of `request_approval` rows still in `pending` state for
45 /// this agent. Surfaces the `!` glyph when nonzero (highest
46 /// priority — overrides the process-state glyph).
47 pub pending_approvals: u32,
48 /// `true` for managers (`is_manager: true` in compose), used when
49 /// the roster wants to draw a tier separator. Read but unused in
50 /// PR-UI-2; kept on the struct so PR-UI-4's approvals modal can
51 /// route based on tier without a second compose lookup.
52 pub is_manager: bool,
53 /// T-160: optional human-friendly label from
54 /// `team-compose.yaml`. When `Some`, the TUI renders this in place
55 /// of `id` everywhere an agent label surfaces to the operator
56 /// (roster, detail header, mailbox attribution, statusline,
57 /// approvals, compose modal). When `None`, label falls back to
58 /// `id`. The id stays canonical for routing/tmux/CLI.
59 pub display_name: Option<String>,
60 /// T-212: most recent rate-limit reset timestamp (unix epoch
61 /// seconds) for this agent, sourced from the `rate_limits` table
62 /// populated by `teamctl rl-watch`. `None` when rl-watch has
63 /// never recorded a `resets_at` for this agent. The TUI status
64 /// bar formats this against `now()` via
65 /// [`format_rate_limit_window`] to render "5m 12s" / "1h 23m" —
66 /// past timestamps render as `None` (no active limit).
67 pub rate_limit_resets_at: Option<f64>,
68 /// #428: unix epoch seconds of this agent's activity-heartbeat marker
69 /// mtime (see [`team_core::render::heartbeat_path`]), or `None` when
70 /// the marker is absent — the agent has done nothing since boot, or is
71 /// a non-claude runtime that gets no heartbeat hooks. The agent is
72 /// Working when this is fresh within [`HEARTBEAT_FRESH_SECS`] (see
73 /// [`is_working`]), else Idle — a UI sub-state of `Running`. #429 (C2b)
74 /// renders the working/idle glyph from it.
75 pub last_activity_at: Option<f64>,
76 /// T-211: short agent name (the YAML key in the manager's project)
77 /// this agent reports to. `None` for top-level agents (no parent)
78 /// — they render at depth 0 in the Agents pane. When `Some`, the
79 /// renderer nests this row under its manager with a tree glyph.
80 /// Schema validation (`team-core/src/validate.rs`) guarantees the
81 /// referenced name resolves to an existing agent.
82 pub reports_to: Option<String>,
83}
84
85/// Return the operator-facing label for `agent_id`: the agent's
86/// `display_name` when set, otherwise `agent_id` itself. Read-only
87/// borrow into the snapshot — callers that need an owned `String`
88/// can `.to_string()` at the use-site. Unknown ids fall through to
89/// `agent_id` (the canonical id is always a valid label).
90pub fn agent_label<'a>(team: &'a TeamSnapshot, agent_id: &'a str) -> &'a str {
91 team.agents
92 .iter()
93 .find(|a| a.id == agent_id)
94 .and_then(|a| a.display_name.as_deref())
95 .unwrap_or(agent_id)
96}
97
98/// Return the operator-facing label for a `MessageRow.recipient`.
99/// Recipients come in three shapes:
100///
101/// - `<project>:<agent>` — an agent id. Resolves via [`agent_label`]
102/// to the agent's display name (or canonical id when unset).
103/// - `channel:<project>:<name>` — a broadcast target. Strips the
104/// `channel:` prefix and renders as `#<name>` (matches the
105/// precedent in `compose::ComposeTarget::label` + the
106/// MailboxFirst-layout channel list).
107/// - `user:<handle>` (e.g. `user:telegram`) — an operator-facing
108/// bridge. Renders verbatim; operators recognize the shape and
109/// stripping the prefix would lose useful context.
110///
111/// Owned `String` return (rather than the `&str` shape of
112/// [`agent_label`]) because the channel-recipient path constructs
113/// `#<name>` at call time. Cheap allocation in a single-row render;
114/// 180-char body cap dominates.
115pub fn recipient_label(team: &TeamSnapshot, recipient_id: &str) -> String {
116 if let Some(rest) = recipient_id.strip_prefix("channel:") {
117 // `rest` is `<project>:<name>` — last `:`-segment is the
118 // short channel name (`all`, `dev`, …). When the recipient
119 // has no `:` separator (malformed), fall through to using
120 // `rest` verbatim — better to show garbage we can grep than
121 // hide it.
122 let short = rest.rsplit_once(':').map(|(_, n)| n).unwrap_or(rest);
123 return format!("#{short}");
124 }
125 // Agent or `user:*` — agent_label handles both (agent_label
126 // falls back to the verbatim id when there's no team-snapshot
127 // entry, which is the right shape for `user:*` rows too).
128 agent_label(team, recipient_id).to_string()
129}
130
131/// One channel exposed in `team-compose.yaml`. Used by PR-UI-6's
132/// per-channel broadcast picker and by the Mailbox-first layout's
133/// channel list. `id` is `<project>:<name>` (matches the broker's
134/// `channels.id`); `name` is the short label rendered as `#name`.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ChannelInfo {
137 pub id: String,
138 pub name: String,
139 pub project_id: String,
140}
141
142#[derive(Debug, Clone)]
143pub struct TeamSnapshot {
144 /// Path to the `.team/` discovered by walk-up (the compose root).
145 pub root: PathBuf,
146 /// Human label from `team-compose.yaml::projects[].project.name`
147 /// — falls back to the project id when name is empty.
148 pub team_name: String,
149 /// Agents in deterministic order: managers first, then workers,
150 /// each group sorted by id. Roster navigation (`↑` / `↓`) walks
151 /// this slice directly.
152 pub agents: Vec<AgentInfo>,
153 /// Channels declared across every project file. Drives the
154 /// PR-UI-6 broadcast picker + the Mailbox-first layout's
155 /// channel list.
156 pub channels: Vec<ChannelInfo>,
157}
158
159impl TeamSnapshot {
160 /// Build an empty snapshot rooted at the given path. Used by
161 /// tests and as the rendered shape when no `.team/` is reachable.
162 pub fn empty(root: PathBuf) -> Self {
163 Self {
164 root,
165 team_name: "(no team loaded)".into(),
166 agents: Vec::new(),
167 channels: Vec::new(),
168 }
169 }
170
171 /// Walk up from cwd to find the nearest `.team/`, parse the
172 /// compose tree, query supervisor + mailbox state per agent,
173 /// and return the assembled snapshot. Returns `Ok(None)` when
174 /// no `.team/` is reachable — the UI renders the empty state in
175 /// that case rather than panicking.
176 pub fn discover_and_load() -> Result<Option<Self>> {
177 let cwd = std::env::current_dir().context("get cwd")?;
178 match Compose::discover(&cwd) {
179 Ok(root) => Self::load(&root).map(Some),
180 Err(_) => Ok(None),
181 }
182 }
183
184 /// Build a snapshot for an explicit `.team/` root. Public so
185 /// integration tests can hand-feed a tempdir without going
186 /// through walk-up discovery.
187 pub fn load(root: &Path) -> Result<Self> {
188 let compose = Compose::load(root)?;
189 let mailbox = compose.root.join(&compose.global.broker.path);
190 let counts = mailbox_counts(&mailbox).unwrap_or_default();
191
192 let supervisor = TmuxSupervisor;
193 let team_name = compose
194 .projects
195 .first()
196 .map(|p| {
197 if p.project.name.is_empty() {
198 p.project.id.clone()
199 } else {
200 p.project.name.clone()
201 }
202 })
203 .unwrap_or_else(|| "(unnamed team)".into());
204
205 let mut agents = Vec::new();
206 for h in compose.agents() {
207 let display_name = h.spec.display_name.clone();
208 let reports_to = h.spec.reports_to.clone();
209 let spec =
210 AgentSpec::from_handle(h, &compose.root, &compose.global.supervisor.tmux_prefix);
211 let state = supervisor.state(&spec).unwrap_or(AgentState::Unknown);
212 let id = h.id();
213 let unread_mail = counts.unread.get(&id).copied().unwrap_or(0);
214 let pending_approvals = counts.pending.get(&id).copied().unwrap_or(0);
215 let rate_limit_resets_at = counts.rate_limit.get(&id).copied();
216 // #428: stat the heartbeat marker's mtime (zero DB — the hook
217 // only touches a file). `None` when absent => Idle downstream.
218 let last_activity_at = std::fs::metadata(team_core::render::heartbeat_path(
219 &compose.root,
220 h.project,
221 h.agent,
222 ))
223 .ok()
224 .and_then(|m| m.modified().ok())
225 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
226 .map(|d| d.as_secs_f64());
227 agents.push(AgentInfo {
228 id,
229 agent: h.agent.into(),
230 project: h.project.into(),
231 tmux_session: spec.tmux_session,
232 state,
233 unread_mail,
234 pending_approvals,
235 is_manager: h.is_manager,
236 display_name,
237 rate_limit_resets_at,
238 last_activity_at,
239 reports_to,
240 });
241 }
242
243 // Managers first, then workers; deterministic within each.
244 agents.sort_by(|a, b| match (b.is_manager, a.is_manager) {
245 (x, y) if x == y => a.id.cmp(&b.id),
246 (true, false) => std::cmp::Ordering::Greater,
247 (false, true) => std::cmp::Ordering::Less,
248 _ => std::cmp::Ordering::Equal,
249 });
250
251 // T-211: reorder into tree-DFS so the roster reads top-down
252 // (manager → that manager's reports → next manager → …).
253 // Selection stays index-based + sticky-on-id (`replace_team`
254 // hunts by id), so nav still walks the visible order without
255 // any selection-state refactor. Teams with no `reports_to`
256 // usage degenerate to the prior order — every agent stays at
257 // depth 0 and the Vec is byte-identical to the post-sort
258 // shape above.
259 agents = into_tree_dfs_order(agents);
260
261 let mut channels = Vec::new();
262 for project in &compose.projects {
263 for ch in &project.channels {
264 channels.push(ChannelInfo {
265 id: format!("{}:{}", project.project.id, ch.name),
266 name: ch.name.clone(),
267 project_id: project.project.id.clone(),
268 });
269 }
270 }
271 // Stable order for the picker — operators see the same
272 // sequence on every open.
273 channels.sort_by(|a, b| a.id.cmp(&b.id));
274
275 Ok(Self {
276 root: compose.root,
277 team_name,
278 agents,
279 channels,
280 })
281 }
282}
283
284/// Per-row metadata the Agents pane renderer needs to draw the
285/// `reports_to` tree (T-211). Computed by [`tree_row_meta`] over a
286/// `Vec<AgentInfo>` that's already in tree-DFS order (i.e. produced
287/// by [`into_tree_dfs_order`] during `TeamSnapshot::load`). Lives
288/// next to the agents Vec rather than on `AgentInfo` itself because
289/// it's purely view-layer state — the data struct stays clean.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub struct TreeRowMeta {
292 /// Depth from the top of the tree. Top-level agents (no
293 /// `reports_to`) are depth 0; their direct reports are depth 1.
294 /// V1 schema is one-level (worker → manager); a defensive depth
295 /// >= 2 case falls back to depth 1 in the renderer.
296 pub depth: usize,
297 /// True iff this row is the last child of its parent in render
298 /// order (or, for depth 0, the last top-level agent). Drives the
299 /// `└─` vs `├─` glyph choice in the renderer.
300 pub is_last_sibling: bool,
301}
302
303/// Reorder `agents` into depth-first tree order: each top-level
304/// (`reports_to == None`) agent is followed by its direct reports
305/// in their pre-existing order, recursively. The input order
306/// (managers-first sorted by id within each group, then workers)
307/// is preserved at each tier — this only **interleaves** workers
308/// under their manager rather than putting them all in a flat
309/// post-managers block.
310///
311/// Teams with no `reports_to` usage are passed through unchanged.
312/// Orphan rows (reports_to references a missing agent — should be
313/// caught by validation but checked defensively) are appended at
314/// the end.
315pub fn into_tree_dfs_order(agents: Vec<AgentInfo>) -> Vec<AgentInfo> {
316 if agents.iter().all(|a| a.reports_to.is_none()) {
317 return agents; // Fast path: no tree, no reorder.
318 }
319 // Scope the parent lookup to (project, agent_name) since
320 // `reports_to` resolves within the project per validate.rs:185.
321 let name_to_index: HashMap<(&str, &str), usize> = agents
322 .iter()
323 .enumerate()
324 .map(|(i, a)| ((a.project.as_str(), a.agent.as_str()), i))
325 .collect();
326 let mut children: HashMap<usize, Vec<usize>> = HashMap::new();
327 let mut top_level: Vec<usize> = Vec::new();
328 for (i, a) in agents.iter().enumerate() {
329 let parent_idx = a
330 .reports_to
331 .as_deref()
332 .and_then(|p| name_to_index.get(&(a.project.as_str(), p)).copied());
333 match parent_idx {
334 Some(p) => children.entry(p).or_default().push(i),
335 None => top_level.push(i),
336 }
337 }
338 let mut emitted = vec![false; agents.len()];
339 let mut order: Vec<usize> = Vec::with_capacity(agents.len());
340 fn walk(
341 i: usize,
342 children: &HashMap<usize, Vec<usize>>,
343 emitted: &mut [bool],
344 order: &mut Vec<usize>,
345 ) {
346 if emitted[i] {
347 return; // Defensive: also breaks any cycle past validation.
348 }
349 emitted[i] = true;
350 order.push(i);
351 if let Some(kids) = children.get(&i) {
352 for &k in kids {
353 walk(k, children, emitted, order);
354 }
355 }
356 }
357 for &i in &top_level {
358 walk(i, &children, &mut emitted, &mut order);
359 }
360 // Defensive: schema validation rejects cycles + dangling parents,
361 // but if anything slipped past we'd rather render it at the end
362 // than drop it from the roster entirely.
363 for (i, &was_emitted) in emitted.iter().enumerate() {
364 if !was_emitted {
365 order.push(i);
366 }
367 }
368 let mut indexed: Vec<Option<AgentInfo>> = agents.into_iter().map(Some).collect();
369 order
370 .into_iter()
371 .filter_map(|i| indexed[i].take())
372 .collect()
373}
374
375/// Compute per-row tree metadata for an `agents` slice that's already
376/// in DFS order (post-[`into_tree_dfs_order`]). The renderer pairs
377/// this 1:1 with the Vec to draw `├─` / `└─` glyphs.
378pub fn tree_row_meta(agents: &[AgentInfo]) -> Vec<TreeRowMeta> {
379 if agents.iter().all(|a| a.reports_to.is_none()) {
380 // Fast path: every row is its own top-level entry. `is_last_sibling`
381 // is true for the last top-level row only (drives no glyph at
382 // depth 0 today, but keeps the contract honest for any future
383 // depth-0 separator).
384 let n = agents.len();
385 return (0..n)
386 .map(|i| TreeRowMeta {
387 depth: 0,
388 is_last_sibling: i + 1 == n,
389 })
390 .collect();
391 }
392 let name_to_index: HashMap<(&str, &str), usize> = agents
393 .iter()
394 .enumerate()
395 .map(|(i, a)| ((a.project.as_str(), a.agent.as_str()), i))
396 .collect();
397 // Resolved parent index per agent, or None for top-level / orphan.
398 let parents: Vec<Option<usize>> = agents
399 .iter()
400 .map(|a| {
401 a.reports_to
402 .as_deref()
403 .and_then(|p| name_to_index.get(&(a.project.as_str(), p)).copied())
404 })
405 .collect();
406 // Depth: 0 for top-level / orphan, else parent's depth + 1.
407 // V1 schema is one-level so a single forward pass suffices —
408 // DFS order guarantees parents precede children.
409 let mut depth = vec![0usize; agents.len()];
410 for i in 0..agents.len() {
411 if let Some(p) = parents[i] {
412 depth[i] = depth[p] + 1;
413 }
414 }
415 // is_last_sibling: per parent (or `None` bucket for top-level),
416 // the highest-index row in that bucket is the last.
417 let mut last_in_bucket: HashMap<Option<usize>, usize> = HashMap::new();
418 for (i, p) in parents.iter().enumerate() {
419 last_in_bucket
420 .entry(*p)
421 .and_modify(|stored| {
422 if i > *stored {
423 *stored = i;
424 }
425 })
426 .or_insert(i);
427 }
428 (0..agents.len())
429 .map(|i| TreeRowMeta {
430 depth: depth[i],
431 is_last_sibling: last_in_bucket.get(&parents[i]).copied() == Some(i),
432 })
433 .collect()
434}
435
436#[derive(Debug, Default)]
437struct MailboxCounts {
438 unread: HashMap<String, u32>,
439 pending: HashMap<String, u32>,
440 /// T-212: per-agent latest `rate_limits.resets_at` (unix epoch
441 /// seconds). Only the most recent rate-limit row per agent that
442 /// has a non-null `resets_at` lands here — rows with no parsed
443 /// reset time are still recorded by `rl-watch` for forensics
444 /// but don't drive UI.
445 rate_limit: HashMap<String, f64>,
446}
447
448/// Single sweep of the mailbox to populate per-agent counters. Read
449/// errors degrade silently to zeroes — a missing or unreadable DB
450/// is just "no team running yet" from the UI's perspective, not a
451/// fatal launch error.
452fn mailbox_counts(mailbox: &Path) -> Result<MailboxCounts> {
453 if !mailbox.is_file() {
454 return Ok(MailboxCounts::default());
455 }
456 let conn = Connection::open(mailbox)?;
457 let mut counts = MailboxCounts::default();
458
459 // Unread mail per recipient agent (channels excluded — channel
460 // messages ack independently per subscriber and would require a
461 // join we don't need in PR-UI-2).
462 //
463 // INVARIANT: every `messages.recipient` value falls into exactly
464 // one of three prefix classes — `<project>:<agent>` (DM, no
465 // scheme prefix; the channel-or-user split here relies on that
466 // absence), `channel:<channel_id>`, or `user:<handle>`. The two
467 // `NOT LIKE` clauses below treat anything outside the channel /
468 // user prefixes as a per-agent DM. If a fourth prefix class
469 // ever lands, every site that splits recipients (here,
470 // `mailbox::BrokerMailboxSource::*` queries, and the tail.rs
471 // follow loop) needs to learn it.
472 let mut stmt = conn.prepare(
473 "SELECT recipient, COUNT(*) FROM messages
474 WHERE acked_at IS NULL
475 AND recipient NOT LIKE 'channel:%'
476 AND recipient NOT LIKE 'user:%'
477 GROUP BY recipient",
478 )?;
479 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
480 for row in rows.flatten() {
481 counts.unread.insert(row.0, row.1.max(0) as u32);
482 }
483
484 // Pending approvals per requesting agent.
485 let mut stmt = conn.prepare(
486 "SELECT project_id || ':' || agent_id, COUNT(*) FROM approvals
487 WHERE status = 'pending'
488 GROUP BY project_id, agent_id",
489 )?;
490 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
491 for row in rows.flatten() {
492 counts.pending.insert(row.0, row.1.max(0) as u32);
493 }
494
495 // T-212: latest rate-limit reset per agent. The `rate_limits`
496 // table is populated by `teamctl rl-watch`. We pick the most
497 // recent row per agent (by `id`, which is monotonically
498 // increasing per the schema in team-core::mailbox), and only
499 // when `resets_at` is non-null — null-resets-at rows are still
500 // logged by rl-watch for debugging the parser but don't drive
501 // UI state. Past-`resets_at` filtering happens at format time
502 // in [`format_rate_limit_window`] so we don't need a `now()`
503 // dependency in this query.
504 //
505 // Table missing (rl-watch never ran on this mailbox) →
506 // `prepare()` errors and we degrade silently to empty map,
507 // matching the surrounding "no data is fine" pattern.
508 if let Ok(mut stmt) = conn.prepare(
509 "SELECT agent_id, resets_at FROM rate_limits
510 WHERE id IN (
511 SELECT MAX(id) FROM rate_limits
512 WHERE resets_at IS NOT NULL
513 GROUP BY agent_id
514 )",
515 ) {
516 if let Ok(rows) = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?)))
517 {
518 for row in rows.flatten() {
519 counts.rate_limit.insert(row.0, row.1);
520 }
521 }
522 }
523
524 Ok(counts)
525}
526
527/// T-212: format a rate-limit reset timestamp as a short label for
528/// the status bar. Returns `None` when the limit is in the past, at
529/// the current instant, or unset — the indicator hides in those
530/// cases. For active limits, formats as `42s` (under a minute),
531/// `5m 12s` (under an hour), or `1h 23m` (an hour or more).
532/// Operator-facing string; not for parsing.
533pub fn format_rate_limit_window(resets_at: Option<f64>, now_unix: f64) -> Option<String> {
534 let resets_at = resets_at?;
535 let remaining = resets_at - now_unix;
536 if remaining <= 0.0 {
537 return None;
538 }
539 let secs = remaining as u64;
540 if secs >= 3600 {
541 let hours = secs / 3600;
542 let mins = (secs % 3600) / 60;
543 Some(format!("{hours}h {mins}m"))
544 } else if secs >= 60 {
545 let mins = secs / 60;
546 let s = secs % 60;
547 Some(format!("{mins}m {s}s"))
548 } else {
549 Some(format!("{secs}s"))
550 }
551}
552
553/// #428: freshness window for the activity heartbeat. A marker touched
554/// within this many seconds of "now" means the agent is Working; older or
555/// absent means Idle. The TUI refreshes every 1s, so a 15s window gives a
556/// comfortable margin against a slow tool call between heartbeat touches.
557pub const HEARTBEAT_FRESH_SECS: f64 = 15.0;
558
559/// #428: classify an agent's activity from its heartbeat marker mtime
560/// (see [`AgentInfo::last_activity_at`]). Working iff the marker was
561/// touched within [`HEARTBEAT_FRESH_SECS`] of `now_unix` (strict `<`); a
562/// missing (`None`) or stale marker is Idle. Pure + total so the 15s
563/// boundary is unit-testable without touching the filesystem. #429 (C2b)
564/// calls this to pick the working/idle glyph; Stopped/Unknown agents are
565/// classified by `state` before this is ever consulted.
566pub fn is_working(last_activity_at: Option<f64>, now_unix: f64) -> bool {
567 matches!(last_activity_at, Some(t) if now_unix - t < HEARTBEAT_FRESH_SECS)
568}
569
570/// Single-cell glyph for an agent's primary state — derived from
571/// (`state`, `pending_approvals`, activity) in priority order: pending
572/// approval beats process state. A `Running` agent is further split by
573/// [`is_working`] against `now_unix` into working (filled `●`) vs idle
574/// (hollow `○`). Plain ASCII fallback when the caller signals a
575/// monochrome / no-symbol terminal.
576pub fn state_glyph(info: &AgentInfo, fallback_ascii: bool, now_unix: f64) -> &'static str {
577 match info.state {
578 AgentState::Stopped => {
579 if fallback_ascii {
580 "x"
581 } else {
582 "✕"
583 }
584 }
585 AgentState::Unknown => "?",
586 AgentState::Running => {
587 if info.pending_approvals > 0 {
588 "!"
589 } else if is_working(info.last_activity_at, now_unix) {
590 if fallback_ascii {
591 "*"
592 } else {
593 "●"
594 }
595 } else if fallback_ascii {
596 "o"
597 } else {
598 "○"
599 }
600 }
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 fn info(state: AgentState, unread: u32, pending: u32) -> AgentInfo {
609 AgentInfo {
610 id: "p:a".into(),
611 agent: "a".into(),
612 project: "p".into(),
613 tmux_session: "t-p-a".into(),
614 state,
615 unread_mail: unread,
616 pending_approvals: pending,
617 is_manager: false,
618 display_name: None,
619 rate_limit_resets_at: None,
620 last_activity_at: None,
621 reports_to: None,
622 }
623 }
624
625 // #429 C2b: build a Running agent with an explicit activity marker
626 // so the working/idle split is exercised against a fixed `now`.
627 fn info_active(state: AgentState, last_activity_at: Option<f64>) -> AgentInfo {
628 AgentInfo {
629 last_activity_at,
630 ..info(state, 0, 0)
631 }
632 }
633
634 #[test]
635 fn is_working_classifies_at_the_15s_boundary() {
636 // #428: strict `< 15s` => Working; absent or stale => Idle.
637 let now = 1_000_000.0;
638 assert!(is_working(Some(now), now), "just touched => working");
639 assert!(is_working(Some(now - 14.0), now), "14s old => working");
640 assert!(
641 !is_working(Some(now - 15.0), now),
642 "exactly 15s => idle (strict <)"
643 );
644 assert!(!is_working(Some(now - 16.0), now), "16s old => idle");
645 assert!(!is_working(None, now), "absent marker => idle");
646 }
647
648 #[test]
649 fn state_glyph_priorities_pending_then_working_then_idle() {
650 let now = 1_000.0;
651 // #429 C2b: Running + fresh marker => working `●`.
652 assert_eq!(
653 state_glyph(&info_active(AgentState::Running, Some(now)), false, now),
654 "●"
655 );
656 // Running + absent or stale marker => idle `○`.
657 assert_eq!(
658 state_glyph(&info(AgentState::Running, 0, 0), false, now),
659 "○"
660 );
661 assert_eq!(
662 state_glyph(
663 &info_active(AgentState::Running, Some(now - 20.0)),
664 false,
665 now
666 ),
667 "○"
668 );
669 // Pending approval beats activity entirely (even when working).
670 let mut working_pending = info_active(AgentState::Running, Some(now));
671 working_pending.pending_approvals = 1;
672 assert_eq!(state_glyph(&working_pending, false, now), "!");
673 // #429 C2a regression: unread still surfaces no glyph — an idle
674 // agent with unread mail is still `○`, not the dropped `✉`.
675 assert_eq!(
676 state_glyph(&info(AgentState::Running, 3, 0), false, now),
677 "○"
678 );
679 }
680
681 #[test]
682 fn state_glyph_stopped_and_unknown() {
683 let now = 1_000.0;
684 assert_eq!(
685 state_glyph(&info(AgentState::Stopped, 0, 0), false, now),
686 "✕"
687 );
688 assert_eq!(
689 state_glyph(&info(AgentState::Unknown, 0, 0), false, now),
690 "?"
691 );
692 }
693
694 #[test]
695 fn state_glyph_ascii_fallback() {
696 let now = 1_000.0;
697 // working `*` / idle `o` pair under the Monochrome ASCII gate.
698 assert_eq!(
699 state_glyph(&info_active(AgentState::Running, Some(now)), true, now),
700 "*"
701 );
702 assert_eq!(
703 state_glyph(&info(AgentState::Running, 0, 0), true, now),
704 "o"
705 );
706 assert_eq!(
707 state_glyph(&info(AgentState::Stopped, 0, 0), true, now),
708 "x"
709 );
710 // `!` and `?` are unchanged across the fallback boundary.
711 let mut working_pending = info_active(AgentState::Running, Some(now));
712 working_pending.pending_approvals = 1;
713 assert_eq!(state_glyph(&working_pending, true, now), "!");
714 assert_eq!(
715 state_glyph(&info(AgentState::Unknown, 0, 0), true, now),
716 "?"
717 );
718 }
719
720 // T-212: format_rate_limit_window covers the value-shape rules
721 // the status-bar slot will render against. The SQL-extension
722 // path is exercised by integration tests at the snapshot layer
723 // (matching the existing untested-at-this-layer pattern for
724 // unread/pending) — the formatter is the part with branchy
725 // logic worth pinning.
726
727 #[test]
728 fn format_rate_limit_window_returns_none_when_unset() {
729 assert_eq!(format_rate_limit_window(None, 1000.0), None);
730 }
731
732 #[test]
733 fn format_rate_limit_window_returns_none_when_already_past() {
734 assert_eq!(format_rate_limit_window(Some(500.0), 1000.0), None);
735 }
736
737 #[test]
738 fn format_rate_limit_window_returns_none_at_exact_now() {
739 assert_eq!(format_rate_limit_window(Some(1000.0), 1000.0), None);
740 }
741
742 #[test]
743 fn format_rate_limit_window_under_minute_renders_seconds() {
744 assert_eq!(
745 format_rate_limit_window(Some(1042.0), 1000.0),
746 Some("42s".into())
747 );
748 assert_eq!(
749 format_rate_limit_window(Some(1059.0), 1000.0),
750 Some("59s".into())
751 );
752 }
753
754 #[test]
755 fn format_rate_limit_window_under_hour_renders_minutes_and_seconds() {
756 assert_eq!(
757 format_rate_limit_window(Some(1060.0), 1000.0),
758 Some("1m 0s".into())
759 );
760 assert_eq!(
761 format_rate_limit_window(Some(1312.0), 1000.0),
762 Some("5m 12s".into())
763 );
764 }
765
766 #[test]
767 fn format_rate_limit_window_at_or_over_hour_renders_hours_and_minutes() {
768 assert_eq!(
769 format_rate_limit_window(Some(4600.0), 1000.0),
770 Some("1h 0m".into())
771 );
772 assert_eq!(
773 format_rate_limit_window(Some(5980.0), 1000.0),
774 Some("1h 23m".into())
775 );
776 }
777
778 // T-231: recipient_label resolution matrix.
779
780 fn empty_team() -> TeamSnapshot {
781 TeamSnapshot::empty(std::path::PathBuf::from("/tmp"))
782 }
783
784 #[test]
785 fn recipient_label_returns_agent_id_when_no_display_name() {
786 let team = empty_team();
787 assert_eq!(recipient_label(&team, "p:dev"), "p:dev");
788 }
789
790 #[test]
791 fn recipient_label_returns_display_name_when_set() {
792 use team_core::supervisor::AgentState;
793 let agent = AgentInfo {
794 id: "p:hugo".into(),
795 agent: "hugo".into(),
796 project: "p".into(),
797 tmux_session: "a-p-hugo".into(),
798 state: AgentState::Running,
799 unread_mail: 0,
800 pending_approvals: 0,
801 is_manager: true,
802 display_name: Some("Hugo (PM)".into()),
803 rate_limit_resets_at: None,
804 last_activity_at: None,
805 reports_to: None,
806 };
807 let team = TeamSnapshot {
808 root: std::path::PathBuf::from("/tmp"),
809 team_name: "t".into(),
810 agents: vec![agent],
811 channels: vec![],
812 };
813 assert_eq!(recipient_label(&team, "p:hugo"), "Hugo (PM)");
814 }
815
816 #[test]
817 fn recipient_label_renders_channel_with_hash_prefix() {
818 let team = empty_team();
819 assert_eq!(recipient_label(&team, "channel:teamctl:dev"), "#dev");
820 assert_eq!(recipient_label(&team, "channel:teamctl:all"), "#all");
821 }
822
823 #[test]
824 fn recipient_label_handles_malformed_channel_recipient() {
825 // Defensive — if a `channel:` prefix has no inner `:`,
826 // fall through to using the rest verbatim rather than panic.
827 let team = empty_team();
828 assert_eq!(recipient_label(&team, "channel:malformed"), "#malformed");
829 }
830
831 #[test]
832 fn recipient_label_renders_user_recipient_verbatim() {
833 // `user:*` shapes (operator-facing bridges) render with their
834 // prefix intact — operators recognize the form and stripping
835 // it would lose useful context.
836 let team = empty_team();
837 assert_eq!(recipient_label(&team, "user:telegram"), "user:telegram");
838 }
839}