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. 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}