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