Skip to main content

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}