Skip to main content

teamctl_ui/
mailbox.rs

1//! Mailbox-pane data source and tab definitions.
2//!
3//! Four filter shapes, one per tab in SPEC §2's Triptych mailbox:
4//!
5//! - `Inbox` — DMs whose `recipient = '<project>:<agent>'`.
6//! - `Sent` — every row whose `sender = '<project>:<agent>'`,
7//!   irrespective of recipient class. Closes the "did this agent
8//!   actually emit X" debug loop without pivoting to the recipient.
9//! - `Channel` — channel traffic for channels the focused agent is
10//!   a member of (recipient is `'channel:<channel_id>'`, filtered
11//!   through `channel_members`).
12//! - `Wire` — project-wide broadcast traffic on the `all` channel
13//!   (`recipient = 'channel:<project>:all'`).
14//!
15//! INVARIANT: every `messages.recipient` value falls into exactly
16//! one of three prefix classes — `<project>:<agent>` (DM, no scheme
17//! prefix; the channel-or-user split below depends on this absence),
18//! `channel:<channel_id>`, or `user:<handle>`. `data::mailbox_counts`
19//! relies on the same contract when it filters out channel/user rows
20//! for the per-agent unread-mail counter; if a fourth prefix class
21//! ever lands, the comment there and the queries here both need to
22//! learn it. Sent is the one tab whose filter is sender-side and
23//! recipient-class-agnostic — it returns rows from all three
24//! recipient prefix classes.
25
26use std::path::PathBuf;
27
28use anyhow::Result;
29use rusqlite::{params, Connection};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum MailboxTab {
33    Inbox,
34    Sent,
35    Channel,
36    Wire,
37}
38
39impl MailboxTab {
40    pub const ALL: [MailboxTab; 4] = [
41        MailboxTab::Inbox,
42        MailboxTab::Sent,
43        MailboxTab::Channel,
44        MailboxTab::Wire,
45    ];
46
47    pub fn label(self) -> &'static str {
48        match self {
49            MailboxTab::Inbox => "Inbox",
50            MailboxTab::Sent => "Sent",
51            MailboxTab::Channel => "Channel",
52            MailboxTab::Wire => "Wire",
53        }
54    }
55
56    pub fn empty_hint(self) -> &'static str {
57        match self {
58            MailboxTab::Inbox => "(no DMs)",
59            MailboxTab::Sent => "(no sent messages)",
60            MailboxTab::Channel => "(no channel traffic)",
61            MailboxTab::Wire => "(quiet)",
62        }
63    }
64
65    pub fn next(self) -> Self {
66        match self {
67            MailboxTab::Inbox => MailboxTab::Sent,
68            MailboxTab::Sent => MailboxTab::Channel,
69            MailboxTab::Channel => MailboxTab::Wire,
70            MailboxTab::Wire => MailboxTab::Inbox,
71        }
72    }
73
74    pub fn prev(self) -> Self {
75        match self {
76            MailboxTab::Inbox => MailboxTab::Wire,
77            MailboxTab::Sent => MailboxTab::Inbox,
78            MailboxTab::Channel => MailboxTab::Sent,
79            MailboxTab::Wire => MailboxTab::Channel,
80        }
81    }
82}
83
84#[derive(Debug, Clone)]
85pub struct MessageRow {
86    pub id: i64,
87    pub sender: String,
88    pub recipient: String,
89    pub text: String,
90    pub sent_at: f64,
91}
92
93/// Format a single row for the mailbox pane. Kept terse: prefix in
94/// brackets + one-line body. Multi-line bodies are flattened with a
95/// space so a single message stays one row in the pane.
96///
97/// Prefix is tab-aware (T-231):
98///
99/// - **Inbox / Channel / Wire** → `[<senderName>]`. Sender is the
100///   useful disambiguator for received rows; resolved via
101///   [`crate::data::agent_label`] so `display_name` carries when
102///   set.
103/// - **Sent** → `[→<recipientName>]`. Sender on a Sent row is
104///   always the focused agent (that's the filter), so showing it is
105///   redundant. Operators want to see WHO the agent talked to;
106///   recipient resolution goes through
107///   [`crate::data::recipient_label`] which handles agent,
108///   `channel:`, and `user:` recipient shapes.
109pub fn render_row(row: &MessageRow, team: &crate::data::TeamSnapshot, tab: MailboxTab) -> String {
110    let one_line: String = row
111        .text
112        .replace('\n', " ")
113        .replace('\r', "")
114        .chars()
115        .take(180)
116        .collect();
117    match tab {
118        MailboxTab::Sent => {
119            let recipient = crate::data::recipient_label(team, &row.recipient);
120            format!("[→{recipient}] {one_line}")
121        }
122        MailboxTab::Inbox | MailboxTab::Wire => {
123            let sender = crate::data::agent_label(team, &row.sender);
124            format!("[{sender}] {one_line}")
125        }
126        MailboxTab::Channel => {
127            // T-249: the Channel tab folds every subscribed channel
128            // into a single feed; without the channel name, operators
129            // can't tell `#all` from `#dev` from `#docs`. Two
130            // bracketed segments — channel, then sender — matching
131            // the disambiguator-first convention T-231 set on Sent.
132            // `recipient_label` already maps `channel:<p>:<n>` to
133            // `#<n>`, so the resolution lives in one place.
134            let channel = crate::data::recipient_label(team, &row.recipient);
135            let sender = crate::data::agent_label(team, &row.sender);
136            format!("[{channel}] [{sender}] {one_line}")
137        }
138    }
139}
140
141/// T-131 PR-4: short absolute-datetime stamp for the right-side
142/// mailbox-row indicator. Computed every render from `now_secs`
143/// (clock reading at render time) and the row's `sent_at` (epoch
144/// seconds). Format is **today-folded** to save column budget on
145/// the common case:
146///
147/// - same calendar day in the operator's local timezone → `HH:MM`
148///   (24-hour, 5 chars; e.g. `15:42`).
149/// - any earlier day → `%b %d %H:%M` (12 chars; e.g. `May 22 15:42`).
150///
151/// Variants ratified by owner (tg 3388):
152/// - (1) today-vs-not folding: YES.
153/// - (2) 24-hour clock: YES.
154///
155/// Silent defaults preserved: no seconds; local-to-operator TZ
156/// (the detail modal already shows UTC for the precise reference);
157/// past-day format `%b %d %H:%M`.
158///
159/// Production callers use [`row_timestamp`] (wraps `Local`); tests
160/// drive [`row_timestamp_in`] with `chrono::Utc` for determinism.
161pub fn row_timestamp(now_secs: f64, sent_at: f64) -> String {
162    row_timestamp_in(&chrono::Local, now_secs, sent_at)
163}
164
165/// TZ-injected variant of [`row_timestamp`] — keeps the production
166/// path on `Local` while tests pin behaviour with `Utc`.
167pub fn row_timestamp_in<Tz>(tz: &Tz, now_secs: f64, sent_at: f64) -> String
168where
169    Tz: chrono::TimeZone,
170    Tz::Offset: std::fmt::Display,
171{
172    let Some(now) = tz.timestamp_opt(now_secs as i64, 0).single() else {
173        return "—".to_string();
174    };
175    let Some(sent) = tz.timestamp_opt(sent_at as i64, 0).single() else {
176        return "—".to_string();
177    };
178    if now.date_naive() == sent.date_naive() {
179        sent.format("%H:%M").to_string()
180    } else {
181        sent.format("%b %d %H:%M").to_string()
182    }
183}
184
185/// T-131 PR-3: human-readable kind label for the detail modal.
186/// Derived from the recipient shape — the same prefix classes the
187/// module-doc INVARIANT pins (`<project>:<agent>` DM,
188/// `channel:<project>:all` wire, other `channel:` channel,
189/// `user:` DM-from-or-to-a-user).
190pub fn kind_label(row: &MessageRow) -> &'static str {
191    if let Some(rest) = row.recipient.strip_prefix("channel:") {
192        // `channel:<project>:all` is the project-wide wire; anything
193        // else under `channel:` is a named channel.
194        if rest.ends_with(":all") {
195            "wire broadcast"
196        } else {
197            "channel broadcast"
198        }
199    } else {
200        // Agent id (`<project>:<agent>`) or `user:<handle>` —
201        // either way, a directed message.
202        "DM"
203    }
204}
205
206/// T-131 PR-3: best-effort transport / origin label for the detail
207/// modal. Heuristic from the sender prefix (variant (b) locked):
208///
209/// - `user:telegram` → "via telegram" — by far the most common
210///   non-agent origin, worth its own label.
211/// - any other `user:<handle>` → "via user" — DMs from a different
212///   human-facing adapter, future-proof against new `user:*` shapes.
213/// - agent id (`<project>:<agent>`) → "via mcp" — every agent emits
214///   through the MCP broker.
215/// - else → "—" (unparseable / future schema).
216pub fn transport_label(row: &MessageRow) -> &'static str {
217    if row.sender.starts_with("user:telegram") {
218        "via telegram"
219    } else if row.sender.starts_with("user:") {
220        "via user"
221    } else if row.sender.contains(':') {
222        "via mcp"
223    } else {
224        "—"
225    }
226}
227
228/// Lookup contract: each method returns rows newer than `after_id`
229/// for the given filter, in ascending id order. Callers fold the
230/// returned rows into a per-tab buffer and bump `after_id` to the
231/// last returned id.
232pub trait MailboxSource: Send + Sync {
233    fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
234    fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
235    fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
236    fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
237}
238
239/// Production impl reading the broker SQLite at `<root>/state/mailbox.db`.
240/// Each call opens a fresh connection — `mailbox.db` is local and
241/// short-lived connections cost effectively zero.
242#[derive(Debug, Clone)]
243pub struct BrokerMailboxSource {
244    pub db_path: PathBuf,
245}
246
247impl BrokerMailboxSource {
248    pub fn new(db_path: PathBuf) -> Self {
249        Self { db_path }
250    }
251
252    fn open(&self) -> Result<Option<Connection>> {
253        if !self.db_path.is_file() {
254            return Ok(None);
255        }
256        let conn = Connection::open(&self.db_path)?;
257        Ok(Some(conn))
258    }
259}
260
261impl MailboxSource for BrokerMailboxSource {
262    fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
263        let Some(conn) = self.open()? else {
264            return Ok(Vec::new());
265        };
266        let mut stmt = conn.prepare(
267            "SELECT id, sender, recipient, text, sent_at FROM messages
268             WHERE id > ?1 AND recipient = ?2
269             ORDER BY id ASC",
270        )?;
271        let rows = stmt
272            .query_map(params![after_id, agent_id], |r| {
273                Ok(MessageRow {
274                    id: r.get(0)?,
275                    sender: r.get(1)?,
276                    recipient: r.get(2)?,
277                    text: r.get(3)?,
278                    sent_at: r.get(4)?,
279                })
280            })?
281            .flatten()
282            .collect();
283        Ok(rows)
284    }
285
286    fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
287        let Some(conn) = self.open()? else {
288            return Ok(Vec::new());
289        };
290        // Sender-side filter — every row the focused agent emitted,
291        // irrespective of recipient class. Returns DMs, telegram
292        // replies, channel posts, and wire broadcasts in a single
293        // stream.
294        let mut stmt = conn.prepare(
295            "SELECT id, sender, recipient, text, sent_at FROM messages
296             WHERE id > ?1 AND sender = ?2
297             ORDER BY id ASC",
298        )?;
299        let rows = stmt
300            .query_map(params![after_id, agent_id], |r| {
301                Ok(MessageRow {
302                    id: r.get(0)?,
303                    sender: r.get(1)?,
304                    recipient: r.get(2)?,
305                    text: r.get(3)?,
306                    sent_at: r.get(4)?,
307                })
308            })?
309            .flatten()
310            .collect();
311        Ok(rows)
312    }
313
314    fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
315        let Some(conn) = self.open()? else {
316            return Ok(Vec::new());
317        };
318        // Same shape as `teamctl tail <agent>`'s channel arm: rows
319        // whose recipient is a `channel:` URL the agent is a member
320        // of. Membership lives in `channel_members.agent_id =
321        // <project>:<agent>`.
322        let mut stmt = conn.prepare(
323            "SELECT id, sender, recipient, text, sent_at FROM messages
324             WHERE id > ?1
325               AND recipient IN (
326                   SELECT 'channel:' || cm.channel_id FROM channel_members cm
327                   WHERE cm.agent_id = ?2
328               )
329             ORDER BY id ASC",
330        )?;
331        let rows = stmt
332            .query_map(params![after_id, agent_id], |r| {
333                Ok(MessageRow {
334                    id: r.get(0)?,
335                    sender: r.get(1)?,
336                    recipient: r.get(2)?,
337                    text: r.get(3)?,
338                    sent_at: r.get(4)?,
339                })
340            })?
341            .flatten()
342            .collect();
343        Ok(rows)
344    }
345
346    fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
347        let Some(conn) = self.open()? else {
348            return Ok(Vec::new());
349        };
350        // The project-wide `all` channel is the broadcast wire.
351        // Channel ids are `<project>:<name>`; messages address them
352        // via `channel:<channel_id>`.
353        let target = format!("channel:{project_id}:all");
354        let mut stmt = conn.prepare(
355            "SELECT id, sender, recipient, text, sent_at FROM messages
356             WHERE id > ?1 AND recipient = ?2
357             ORDER BY id ASC",
358        )?;
359        let rows = stmt
360            .query_map(params![after_id, target], |r| {
361                Ok(MessageRow {
362                    id: r.get(0)?,
363                    sender: r.get(1)?,
364                    recipient: r.get(2)?,
365                    text: r.get(3)?,
366                    sent_at: r.get(4)?,
367                })
368            })?
369            .flatten()
370            .collect();
371        Ok(rows)
372    }
373}
374
375/// Per-agent buffer state — four tabs, four `after_id` cursors.
376/// Lives on `App` so swapping the focused agent resets the cursors
377/// without trying to back-fill: the operator sees only forward
378/// motion in the tab they're watching.
379#[derive(Debug, Default, Clone)]
380pub struct MailboxBuffers {
381    pub inbox: Vec<MessageRow>,
382    pub sent: Vec<MessageRow>,
383    pub channel: Vec<MessageRow>,
384    pub wire: Vec<MessageRow>,
385    pub inbox_after: i64,
386    pub sent_after: i64,
387    pub channel_after: i64,
388    pub wire_after: i64,
389    // T-131 PR-1: UI cursor state per tab. `selected_idx` is an index
390    // INTO `visible_indices(tab)`, not directly into `rows(tab)` — the
391    // two coincide when no filter/search is set; PR-2 made them
392    // diverge without changing this invariant or any call site (the
393    // composability payoff of returning `Vec<usize>` indices, not a
394    // slice).
395    pub inbox_cursor: CursorState,
396    pub sent_cursor: CursorState,
397    pub channel_cursor: CursorState,
398    pub wire_cursor: CursorState,
399    // T-131 PR-2: per-tab filter (sender substring) + search (body
400    // substring) text. Both compose: a row is visible iff it passes
401    // BOTH (empty = no-op on that axis). Mirrors the existing per-tab
402    // Vec + cursor pattern. Case-insensitive substring match.
403    pub inbox_filter: String,
404    pub sent_filter: String,
405    pub channel_filter: String,
406    pub wire_filter: String,
407    pub inbox_search: String,
408    pub sent_search: String,
409    pub channel_search: String,
410    pub wire_search: String,
411}
412
413/// Which mailbox input the operator is editing. Singleton at the App
414/// level (only one input can be open at a time across all tabs);
415/// distinct from the per-tab `filter_text` / `search_text` it targets,
416/// which live on [`MailboxBuffers`]. Defined here so the data-side
417/// methods (`input_push_char`, `input_pop_char`, etc.) can take it
418/// without crossing the App boundary.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum MailboxInputKind {
421    Filter,
422    Search,
423}
424
425/// UI cursor state for one mailbox tab. PR-1 stores only the selected
426/// row index; the rendered scroll-window is derived at render time
427/// from `selected_idx` + the actual pane height, so a terminal resize
428/// just changes the next-paint window without touching persisted
429/// state. `selected_idx` is an index into
430/// [`MailboxBuffers::visible_indices`].
431#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
432pub struct CursorState {
433    pub selected_idx: usize,
434}
435
436const MAX_TAB_ROWS: usize = 500;
437
438/// PageUp/PageDown jump size — a screen-ish chunk of rows. Fixed for
439/// PR-1 to keep scope surgical; a follow-up can wire this to the
440/// actual rendered mailbox-pane height once that's plumbed onto App.
441pub const PAGE_JUMP: usize = 10;
442
443impl MailboxBuffers {
444    pub fn rows(&self, tab: MailboxTab) -> &[MessageRow] {
445        match tab {
446            MailboxTab::Inbox => &self.inbox,
447            MailboxTab::Sent => &self.sent,
448            MailboxTab::Channel => &self.channel,
449            MailboxTab::Wire => &self.wire,
450        }
451    }
452
453    /// Indices into `rows(tab)` for the rows currently presented to
454    /// the operator — filter ∩ search. PR-2 swapped this body in;
455    /// every cursor method and the render call site stayed unchanged
456    /// from PR-1 because they go through this abstraction. A row at
457    /// `rows(tab)[i]` is visible iff:
458    ///
459    /// 1. `filter_text(tab)` is empty OR `row.sender` (lower-cased)
460    ///    contains the filter (lower-cased) as a substring.
461    /// 2. `search_text(tab)` is empty OR `row.text` (lower-cased)
462    ///    contains the search (lower-cased) as a substring.
463    ///
464    /// When both axes are empty, the result is identity
465    /// `(0..rows.len())` — PR-1's default behavior recovers exactly.
466    /// Case-insensitive substring is the documented contract; the
467    /// per-keystroke recompute on small (~500-row) buffers is well
468    /// within budget.
469    pub fn visible_indices(&self, tab: MailboxTab) -> Vec<usize> {
470        let rows = self.rows(tab);
471        let filter = self.filter_text(tab).to_lowercase();
472        let search = self.search_text(tab).to_lowercase();
473        if filter.is_empty() && search.is_empty() {
474            return (0..rows.len()).collect();
475        }
476        (0..rows.len())
477            .filter(|&i| {
478                let row = &rows[i];
479                (filter.is_empty() || row.sender.to_lowercase().contains(&filter))
480                    && (search.is_empty() || row.text.to_lowercase().contains(&search))
481            })
482            .collect()
483    }
484
485    /// Current sender-substring filter on `tab`; empty = no filter.
486    pub fn filter_text(&self, tab: MailboxTab) -> &str {
487        match tab {
488            MailboxTab::Inbox => &self.inbox_filter,
489            MailboxTab::Sent => &self.sent_filter,
490            MailboxTab::Channel => &self.channel_filter,
491            MailboxTab::Wire => &self.wire_filter,
492        }
493    }
494
495    /// Current body-substring search on `tab`; empty = no search.
496    pub fn search_text(&self, tab: MailboxTab) -> &str {
497        match tab {
498            MailboxTab::Inbox => &self.inbox_search,
499            MailboxTab::Sent => &self.sent_search,
500            MailboxTab::Channel => &self.channel_search,
501            MailboxTab::Wire => &self.wire_search,
502        }
503    }
504
505    fn filter_text_mut(&mut self, tab: MailboxTab) -> &mut String {
506        match tab {
507            MailboxTab::Inbox => &mut self.inbox_filter,
508            MailboxTab::Sent => &mut self.sent_filter,
509            MailboxTab::Channel => &mut self.channel_filter,
510            MailboxTab::Wire => &mut self.wire_filter,
511        }
512    }
513
514    fn search_text_mut(&mut self, tab: MailboxTab) -> &mut String {
515        match tab {
516            MailboxTab::Inbox => &mut self.inbox_search,
517            MailboxTab::Sent => &mut self.sent_search,
518            MailboxTab::Channel => &mut self.channel_search,
519            MailboxTab::Wire => &mut self.wire_search,
520        }
521    }
522
523    /// Push `c` onto the active input buffer for `tab`, then clamp
524    /// the cursor against the (possibly shorter) new visible_indices.
525    /// Called per-keystroke by the App input-mode handler.
526    pub fn input_push_char(&mut self, tab: MailboxTab, kind: MailboxInputKind, c: char) {
527        match kind {
528            MailboxInputKind::Filter => self.filter_text_mut(tab).push(c),
529            MailboxInputKind::Search => self.search_text_mut(tab).push(c),
530        }
531        self.clamp_cursor(tab);
532    }
533
534    /// Pop one character (Backspace) from the active input buffer for
535    /// `tab`, then re-clamp the cursor.
536    pub fn input_pop_char(&mut self, tab: MailboxTab, kind: MailboxInputKind) {
537        match kind {
538            MailboxInputKind::Filter => {
539                self.filter_text_mut(tab).pop();
540            }
541            MailboxInputKind::Search => {
542                self.search_text_mut(tab).pop();
543            }
544        }
545        self.clamp_cursor(tab);
546    }
547
548    /// Replace the active input buffer for `tab` wholesale — used by
549    /// the Esc-cancel-revert path to restore the pre-open snapshot.
550    pub fn set_input(&mut self, tab: MailboxTab, kind: MailboxInputKind, value: String) {
551        match kind {
552            MailboxInputKind::Filter => *self.filter_text_mut(tab) = value,
553            MailboxInputKind::Search => *self.search_text_mut(tab) = value,
554        }
555        self.clamp_cursor(tab);
556    }
557
558    /// Clamp the per-tab cursor to the current visible_indices range.
559    /// Called from every input mutation and from extend()'s drain
560    /// path so a stale `selected_idx` can never index past the
561    /// visible set.
562    fn clamp_cursor(&mut self, tab: MailboxTab) {
563        let len = self.visible_indices(tab).len();
564        let cur = self.cursor_mut(tab);
565        if len == 0 {
566            cur.selected_idx = 0;
567        } else if cur.selected_idx >= len {
568            cur.selected_idx = len - 1;
569        }
570    }
571
572    pub fn cursor(&self, tab: MailboxTab) -> &CursorState {
573        match tab {
574            MailboxTab::Inbox => &self.inbox_cursor,
575            MailboxTab::Sent => &self.sent_cursor,
576            MailboxTab::Channel => &self.channel_cursor,
577            MailboxTab::Wire => &self.wire_cursor,
578        }
579    }
580
581    fn cursor_mut(&mut self, tab: MailboxTab) -> &mut CursorState {
582        match tab {
583            MailboxTab::Inbox => &mut self.inbox_cursor,
584            MailboxTab::Sent => &mut self.sent_cursor,
585            MailboxTab::Channel => &mut self.channel_cursor,
586            MailboxTab::Wire => &mut self.wire_cursor,
587        }
588    }
589
590    /// Move the cursor one row toward the tail; clamps at the last
591    /// visible row (vim-like — no wrap).
592    pub fn move_cursor_down(&mut self, tab: MailboxTab) {
593        let max = self.visible_indices(tab).len().saturating_sub(1);
594        let c = self.cursor_mut(tab);
595        c.selected_idx = (c.selected_idx + 1).min(max);
596    }
597
598    /// Move the cursor one row toward the head; clamps at 0.
599    pub fn move_cursor_up(&mut self, tab: MailboxTab) {
600        let c = self.cursor_mut(tab);
601        c.selected_idx = c.selected_idx.saturating_sub(1);
602    }
603
604    /// Jump a screen toward the tail.
605    pub fn page_cursor_down(&mut self, tab: MailboxTab) {
606        let max = self.visible_indices(tab).len().saturating_sub(1);
607        let c = self.cursor_mut(tab);
608        c.selected_idx = (c.selected_idx + PAGE_JUMP).min(max);
609    }
610
611    /// Jump a screen toward the head.
612    pub fn page_cursor_up(&mut self, tab: MailboxTab) {
613        let c = self.cursor_mut(tab);
614        c.selected_idx = c.selected_idx.saturating_sub(PAGE_JUMP);
615    }
616
617    /// Jump to the first visible row.
618    pub fn cursor_home(&mut self, tab: MailboxTab) {
619        self.cursor_mut(tab).selected_idx = 0;
620    }
621
622    /// Jump to the last visible row.
623    pub fn cursor_end(&mut self, tab: MailboxTab) {
624        let max = self.visible_indices(tab).len().saturating_sub(1);
625        self.cursor_mut(tab).selected_idx = max;
626    }
627
628    /// Fold a freshly-fetched batch into the appropriate tab,
629    /// trimming to the last `MAX_TAB_ROWS`. Bumps the broker
630    /// pagination cursor to the last returned id when the batch is
631    /// non-empty. T-131 PR-1: when the UI cursor was already at the
632    /// tail (or the tab was empty), follow new arrivals — matching
633    /// the pre-T-131 "tail to whatever fits" UX. Always re-clamps the
634    /// UI cursor against the (possibly drained) post-extend visible
635    /// length so a stale index can never reference a missing row.
636    pub fn extend(&mut self, tab: MailboxTab, batch: Vec<MessageRow>) {
637        let prev_visible_len = self.visible_indices(tab).len();
638        let was_at_tail =
639            prev_visible_len == 0 || self.cursor(tab).selected_idx + 1 >= prev_visible_len;
640        let last_id = batch.last().map(|r| r.id);
641        let (buf, after) = match tab {
642            MailboxTab::Inbox => (&mut self.inbox, &mut self.inbox_after),
643            MailboxTab::Sent => (&mut self.sent, &mut self.sent_after),
644            MailboxTab::Channel => (&mut self.channel, &mut self.channel_after),
645            MailboxTab::Wire => (&mut self.wire, &mut self.wire_after),
646        };
647        buf.extend(batch);
648        if buf.len() > MAX_TAB_ROWS {
649            let drop = buf.len() - MAX_TAB_ROWS;
650            buf.drain(..drop);
651        }
652        if let Some(id) = last_id {
653            *after = id;
654        }
655        let new_visible_len = self.visible_indices(tab).len();
656        let cur = self.cursor_mut(tab);
657        if was_at_tail && new_visible_len > 0 {
658            cur.selected_idx = new_visible_len - 1;
659        } else if new_visible_len > 0 {
660            let max = new_visible_len - 1;
661            if cur.selected_idx > max {
662                cur.selected_idx = max;
663            }
664        } else {
665            cur.selected_idx = 0;
666        }
667    }
668
669    /// Reset every tab's contents and cursor. Called when the
670    /// focused agent changes — the new agent's `inbox` filter would
671    /// otherwise skip historical rows that landed before our last
672    /// `inbox_after`, and the UI cursor would point into the wrong
673    /// agent's buffer.
674    pub fn reset(&mut self) {
675        *self = Self::default();
676    }
677}
678
679pub mod test_support {
680    //! Shared mock — public so unit tests, integration tests, and
681    //! downstream coverage can wire in a recorder without rolling
682    //! their own. Matches the shape used by `compose::test_support`
683    //! and `approvals::test_support`.
684
685    use super::*;
686    use std::sync::Mutex;
687
688    /// Test stub — returns canned rows on each call, records every
689    /// arg pair. Mailbox is the most-asserted test surface in
690    /// PR-UI-3 so the recorder lets snapshot + interaction tests
691    /// verify "is the right filter being asked the right thing."
692    #[derive(Default)]
693    pub struct MockMailboxSource {
694        pub inbox_rows: Vec<MessageRow>,
695        pub sent_rows: Vec<MessageRow>,
696        pub channel_rows: Vec<MessageRow>,
697        pub wire_rows: Vec<MessageRow>,
698        pub inbox_calls: Mutex<Vec<(String, i64)>>,
699        pub sent_calls: Mutex<Vec<(String, i64)>>,
700        pub channel_calls: Mutex<Vec<(String, i64)>>,
701        pub wire_calls: Mutex<Vec<(String, i64)>>,
702    }
703
704    impl MailboxSource for MockMailboxSource {
705        fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
706            self.inbox_calls
707                .lock()
708                .unwrap()
709                .push((agent_id.into(), after_id));
710            Ok(self.inbox_rows.clone())
711        }
712
713        fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
714            self.sent_calls
715                .lock()
716                .unwrap()
717                .push((agent_id.into(), after_id));
718            Ok(self.sent_rows.clone())
719        }
720
721        fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
722            self.channel_calls
723                .lock()
724                .unwrap()
725                .push((agent_id.into(), after_id));
726            Ok(self.channel_rows.clone())
727        }
728
729        fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
730            self.wire_calls
731                .lock()
732                .unwrap()
733                .push((project_id.into(), after_id));
734            Ok(self.wire_rows.clone())
735        }
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::test_support::*;
742    use super::*;
743
744    fn row(id: i64, sender: &str, recipient: &str, text: &str) -> MessageRow {
745        MessageRow {
746            id,
747            sender: sender.into(),
748            recipient: recipient.into(),
749            text: text.into(),
750            sent_at: 0.0,
751        }
752    }
753
754    #[test]
755    fn next_cycles_inbox_sent_channel_wire_inbox() {
756        let mut t = MailboxTab::Inbox;
757        t = t.next();
758        assert_eq!(t, MailboxTab::Sent);
759        t = t.next();
760        assert_eq!(t, MailboxTab::Channel);
761        t = t.next();
762        assert_eq!(t, MailboxTab::Wire);
763        t = t.next();
764        assert_eq!(t, MailboxTab::Inbox);
765    }
766
767    #[test]
768    fn prev_cycles_inbox_wire_channel_sent_inbox() {
769        let mut t = MailboxTab::Inbox;
770        t = t.prev();
771        assert_eq!(t, MailboxTab::Wire);
772        t = t.prev();
773        assert_eq!(t, MailboxTab::Channel);
774        t = t.prev();
775        assert_eq!(t, MailboxTab::Sent);
776        t = t.prev();
777        assert_eq!(t, MailboxTab::Inbox);
778    }
779
780    #[test]
781    fn extend_appends_and_bumps_cursor() {
782        let mut buf = MailboxBuffers::default();
783        buf.extend(
784            MailboxTab::Inbox,
785            vec![row(7, "p:m", "p:dev", "hi"), row(8, "p:m", "p:dev", "yo")],
786        );
787        assert_eq!(buf.inbox.len(), 2);
788        assert_eq!(buf.inbox_after, 8);
789        // Empty batch must not move the cursor backward.
790        buf.extend(MailboxTab::Inbox, vec![]);
791        assert_eq!(buf.inbox_after, 8);
792    }
793
794    #[test]
795    fn extend_trims_to_cap() {
796        let mut buf = MailboxBuffers::default();
797        let batch: Vec<MessageRow> = (1..=600).map(|i| row(i, "p:m", "p:dev", "x")).collect();
798        buf.extend(MailboxTab::Wire, batch);
799        assert_eq!(buf.wire.len(), MAX_TAB_ROWS);
800        // Cap keeps the *latest* rows — the cursor reflects the
801        // batch's actual high-water id, not the trimmed buffer's
802        // first row.
803        assert_eq!(buf.wire_after, 600);
804        assert_eq!(buf.wire.last().unwrap().id, 600);
805    }
806
807    #[test]
808    fn reset_clears_buffers_and_cursors() {
809        let mut buf = MailboxBuffers::default();
810        buf.extend(MailboxTab::Inbox, vec![row(3, "a", "b", "x")]);
811        buf.extend(MailboxTab::Channel, vec![row(4, "a", "channel:p:all", "y")]);
812        buf.reset();
813        assert!(buf.inbox.is_empty());
814        assert!(buf.channel.is_empty());
815        assert_eq!(buf.inbox_after, 0);
816        assert_eq!(buf.channel_after, 0);
817    }
818
819    fn empty_team() -> crate::data::TeamSnapshot {
820        crate::data::TeamSnapshot::empty(std::path::PathBuf::from("/tmp"))
821    }
822
823    #[test]
824    fn render_row_flattens_newlines_and_truncates() {
825        let team = empty_team();
826        let r = row(1, "p:m", "p:dev", "first\nsecond\nthird");
827        assert_eq!(
828            render_row(&r, &team, MailboxTab::Inbox),
829            "[p:m] first second third"
830        );
831
832        let long: String = "x".repeat(300);
833        let r = row(1, "s", "r", &long);
834        let rendered = render_row(&r, &team, MailboxTab::Inbox);
835        // 5 chars ("[s] ") + at most 180 chars of body = 185.
836        assert!(rendered.chars().count() <= 185);
837    }
838
839    #[test]
840    fn render_row_uses_display_name_when_set() {
841        // T-160: when the sender id has a `display_name` in the team
842        // snapshot, the mailbox row renders the label, not the id.
843        // Unknown senders fall through to the raw id (covered above).
844        use crate::data::{AgentInfo, TeamSnapshot};
845        use team_core::supervisor::AgentState;
846        let agent = AgentInfo {
847            id: "p:sage".into(),
848            agent: "sage".into(),
849            project: "p".into(),
850            tmux_session: "a-p-sage".into(),
851            state: AgentState::Unknown,
852            unread_mail: 0,
853            pending_approvals: 0,
854            is_manager: true,
855            display_name: Some("Sage (Visionary)".into()),
856            rate_limit_resets_at: None,
857            reports_to: None,
858        };
859        let team = TeamSnapshot {
860            root: std::path::PathBuf::from("/tmp"),
861            team_name: "t".into(),
862            agents: vec![agent],
863            channels: vec![],
864        };
865        let r = row(1, "p:sage", "p:hugo", "ping");
866        assert_eq!(
867            render_row(&r, &team, MailboxTab::Inbox),
868            "[Sage (Visionary)] ping"
869        );
870    }
871
872    // T-231: tab-aware prefix — Sent shows recipient, others show
873    // sender. These pin the contract the operator-visible UX rests on.
874
875    #[test]
876    fn render_row_sent_tab_shows_recipient_with_arrow() {
877        // Sent rows have the focused agent as sender (constant);
878        // recipient is the disambiguating column. Verify the arrow
879        // glyph + recipient appear in place of the sender.
880        let team = empty_team();
881        let r = row(1, "p:me", "p:dev", "ack");
882        assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→p:dev] ack");
883    }
884
885    #[test]
886    fn render_row_sent_tab_resolves_recipient_display_name() {
887        // Same display-name resolution as the Inbox path — the
888        // recipient's label, not the raw id, when the team snapshot
889        // has a display_name for them.
890        use crate::data::{AgentInfo, TeamSnapshot};
891        use team_core::supervisor::AgentState;
892        let agent = AgentInfo {
893            id: "p:hugo".into(),
894            agent: "hugo".into(),
895            project: "p".into(),
896            tmux_session: "a-p-hugo".into(),
897            state: AgentState::Running,
898            unread_mail: 0,
899            pending_approvals: 0,
900            is_manager: true,
901            display_name: Some("Hugo (PM)".into()),
902            rate_limit_resets_at: None,
903            reports_to: None,
904        };
905        let team = TeamSnapshot {
906            root: std::path::PathBuf::from("/tmp"),
907            team_name: "t".into(),
908            agents: vec![agent],
909            channels: vec![],
910        };
911        let r = row(1, "p:sage", "p:hugo", "ping");
912        assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→Hugo (PM)] ping");
913    }
914
915    #[test]
916    fn render_row_sent_tab_renders_channel_recipient_with_hash() {
917        // Broadcast-to-channel rows have `recipient = channel:<id>`.
918        // The Sent prefix should render as `→#<short>` — operators
919        // recognize `#dev`, not `channel:teamctl:dev`.
920        let team = empty_team();
921        let r = row(1, "p:me", "channel:teamctl:dev", "rolling 0.8.3");
922        assert_eq!(
923            render_row(&r, &team, MailboxTab::Sent),
924            "[→#dev] rolling 0.8.3"
925        );
926    }
927
928    #[test]
929    fn render_row_sent_tab_renders_user_recipient_verbatim() {
930        // Telegram-bound `reply_to_user` rows have `recipient = user:telegram`.
931        // No special prefix-stripping — operators already recognize
932        // the `user:*` shape and dropping the prefix would lose the
933        // "this went to the operator" signal.
934        let team = empty_team();
935        let r = row(1, "p:mgr", "user:telegram", "PR url");
936        assert_eq!(
937            render_row(&r, &team, MailboxTab::Sent),
938            "[→user:telegram] PR url"
939        );
940    }
941
942    #[test]
943    fn render_row_non_sent_tabs_still_show_sender() {
944        // Inbox / Wire prefix is the sender. Channel has its own
945        // two-segment shape pinned in the T-249 tests below.
946        let team = empty_team();
947        let r = row(1, "p:from", "p:me", "yo");
948        assert_eq!(render_row(&r, &team, MailboxTab::Inbox), "[p:from] yo");
949        assert_eq!(render_row(&r, &team, MailboxTab::Wire), "[p:from] yo");
950    }
951
952    // T-249: Channel tab — two bracketed segments, channel then sender.
953    // The disambiguator the operator needs is "which channel was this
954    // posted in", because the tab folds every subscribed channel into
955    // a single feed.
956
957    #[test]
958    fn render_row_channel_tab_prefixes_channel_name_and_sender() {
959        let team = empty_team();
960        let r = row(1, "p:from", "channel:teamctl:dev", "yo");
961        assert_eq!(
962            render_row(&r, &team, MailboxTab::Channel),
963            "[#dev] [p:from] yo"
964        );
965    }
966
967    #[test]
968    fn render_row_channel_tab_resolves_sender_display_name() {
969        // Sender resolution mirrors the Inbox path — display_name
970        // when set on the team snapshot, raw id otherwise. Channel
971        // name resolution is independent.
972        use crate::data::{AgentInfo, TeamSnapshot};
973        use team_core::supervisor::AgentState;
974        let agent = AgentInfo {
975            id: "p:wren".into(),
976            agent: "wren".into(),
977            project: "p".into(),
978            tmux_session: "a-p-wren".into(),
979            state: AgentState::Running,
980            unread_mail: 0,
981            pending_approvals: 0,
982            is_manager: false,
983            display_name: Some("Wren (Engineer)".into()),
984            rate_limit_resets_at: None,
985            reports_to: None,
986        };
987        let team = TeamSnapshot {
988            root: std::path::PathBuf::from("/tmp"),
989            team_name: "t".into(),
990            agents: vec![agent],
991            channels: vec![],
992        };
993        let r = row(1, "p:wren", "channel:p:all", "hello");
994        assert_eq!(
995            render_row(&r, &team, MailboxTab::Channel),
996            "[#all] [Wren (Engineer)] hello"
997        );
998    }
999
1000    #[test]
1001    fn render_row_channel_tab_handles_malformed_channel_recipient() {
1002        // Defensive — channel_feed SQL only returns rows shaped
1003        // `channel:<channel_id>`, but if a malformed value ever
1004        // lands (manual write, future schema shift), the row still
1005        // renders without panic. Pins recipient_label's malformed
1006        // fallback (matches T-231's parallel sent-tab test).
1007        let team = empty_team();
1008        let r = row(1, "p:from", "channel:malformed", "yo");
1009        assert_eq!(
1010            render_row(&r, &team, MailboxTab::Channel),
1011            "[#malformed] [p:from] yo"
1012        );
1013    }
1014
1015    #[test]
1016    fn mock_records_calls() {
1017        let mock = MockMailboxSource {
1018            inbox_rows: vec![row(1, "p:m", "p:a", "hi")],
1019            ..Default::default()
1020        };
1021        let _ = mock.inbox("p:a", 0).unwrap();
1022        let _ = mock.sent("p:a", 2).unwrap();
1023        let _ = mock.channel_feed("p:a", 5).unwrap();
1024        let _ = mock.wire("p", 9).unwrap();
1025        assert_eq!(*mock.inbox_calls.lock().unwrap(), vec![("p:a".into(), 0)]);
1026        assert_eq!(*mock.sent_calls.lock().unwrap(), vec![("p:a".into(), 2)]);
1027        assert_eq!(*mock.channel_calls.lock().unwrap(), vec![("p:a".into(), 5)]);
1028        assert_eq!(*mock.wire_calls.lock().unwrap(), vec![("p".into(), 9)]);
1029    }
1030
1031    // T-131 PR-1: cursor + visible_indices invariants.
1032
1033    fn rows_n(n: i64) -> Vec<MessageRow> {
1034        (1..=n).map(|i| row(i, "p:m", "p:dev", "x")).collect()
1035    }
1036
1037    #[test]
1038    fn visible_indices_is_identity_in_pr1() {
1039        // PR-1 invariant: visible_indices(tab) == (0..rows(tab).len()).
1040        // PR-2 swaps the body — this test guards the PR-1 baseline so
1041        // a PR-2 regression that breaks PR-1's identity assumption
1042        // surfaces here, not in a render call site downstream.
1043        let mut buf = MailboxBuffers::default();
1044        buf.extend(MailboxTab::Inbox, rows_n(5));
1045        assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 1, 2, 3, 4]);
1046        assert!(buf.visible_indices(MailboxTab::Sent).is_empty());
1047    }
1048
1049    #[test]
1050    fn extend_into_empty_seats_cursor_at_tail() {
1051        // Pre-T-131 UX was "tail to whatever fits"; the cursor seat
1052        // preserves it — a freshly-populated tab shows the latest row
1053        // selected, matching the existing snapshot expectations for
1054        // unfocused mailbox panes.
1055        let mut buf = MailboxBuffers::default();
1056        buf.extend(MailboxTab::Inbox, rows_n(7));
1057        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 6);
1058    }
1059
1060    #[test]
1061    fn extend_when_cursor_at_tail_follows_new_arrivals() {
1062        // Standard chat-app "follow tail" UX: as long as the operator
1063        // hasn't scrolled away, new messages keep the cursor at the
1064        // newest row.
1065        let mut buf = MailboxBuffers::default();
1066        buf.extend(MailboxTab::Inbox, rows_n(3));
1067        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2);
1068        buf.extend(
1069            MailboxTab::Inbox,
1070            vec![row(4, "p:m", "p:dev", "x"), row(5, "p:m", "p:dev", "x")],
1071        );
1072        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
1073    }
1074
1075    #[test]
1076    fn extend_when_cursor_scrolled_up_does_not_follow() {
1077        // Operator inspecting older history shouldn't be yanked back
1078        // to the tail by a new arrival — the cursor is sticky once it
1079        // leaves the tail.
1080        let mut buf = MailboxBuffers::default();
1081        buf.extend(MailboxTab::Inbox, rows_n(5));
1082        buf.cursor_home(MailboxTab::Inbox); // selected_idx = 0
1083        buf.extend(MailboxTab::Inbox, vec![row(6, "p:m", "p:dev", "x")]);
1084        assert_eq!(
1085            buf.cursor(MailboxTab::Inbox).selected_idx,
1086            0,
1087            "scrolled-up cursor must not jump on new arrival"
1088        );
1089    }
1090
1091    #[test]
1092    fn extend_reclamps_cursor_after_drain() {
1093        // The MAX_TAB_ROWS drain shifts indices — a cursor that was
1094        // valid pre-drain must be re-clamped against the new visible
1095        // length so render never indexes past the buffer.
1096        let mut buf = MailboxBuffers::default();
1097        buf.extend(MailboxTab::Inbox, rows_n(MAX_TAB_ROWS as i64));
1098        buf.cursor_home(MailboxTab::Inbox);
1099        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1100        // Push another batch large enough to drain off the front.
1101        let next: Vec<MessageRow> = (501..=510).map(|i| row(i, "p:m", "p:dev", "x")).collect();
1102        buf.extend(MailboxTab::Inbox, next);
1103        let visible = buf.visible_indices(MailboxTab::Inbox);
1104        assert_eq!(visible.len(), MAX_TAB_ROWS);
1105        assert!(
1106            buf.cursor(MailboxTab::Inbox).selected_idx < visible.len(),
1107            "post-drain cursor must stay in range; got {}, visible.len {}",
1108            buf.cursor(MailboxTab::Inbox).selected_idx,
1109            visible.len()
1110        );
1111    }
1112
1113    #[test]
1114    fn move_cursor_down_and_up_clamp_at_ends() {
1115        let mut buf = MailboxBuffers::default();
1116        buf.extend(MailboxTab::Inbox, rows_n(3)); // cursor seated at 2
1117        buf.move_cursor_down(MailboxTab::Inbox);
1118        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2, "tail clamps");
1119        buf.move_cursor_up(MailboxTab::Inbox);
1120        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
1121        buf.move_cursor_up(MailboxTab::Inbox);
1122        buf.move_cursor_up(MailboxTab::Inbox);
1123        buf.move_cursor_up(MailboxTab::Inbox); // extra up at 0 is no-op
1124        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0, "head clamps");
1125    }
1126
1127    #[test]
1128    fn page_cursor_jumps_a_screen() {
1129        let mut buf = MailboxBuffers::default();
1130        buf.extend(MailboxTab::Inbox, rows_n(50));
1131        buf.cursor_home(MailboxTab::Inbox);
1132        buf.page_cursor_down(MailboxTab::Inbox);
1133        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
1134        buf.page_cursor_down(MailboxTab::Inbox);
1135        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2 * PAGE_JUMP);
1136        buf.page_cursor_up(MailboxTab::Inbox);
1137        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
1138        // PageDown past the tail clamps.
1139        for _ in 0..20 {
1140            buf.page_cursor_down(MailboxTab::Inbox);
1141        }
1142        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 49);
1143        // PageUp past the head clamps.
1144        for _ in 0..20 {
1145            buf.page_cursor_up(MailboxTab::Inbox);
1146        }
1147        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1148    }
1149
1150    #[test]
1151    fn cursor_home_and_end_jump_to_ends() {
1152        let mut buf = MailboxBuffers::default();
1153        buf.extend(MailboxTab::Inbox, rows_n(20));
1154        buf.cursor_home(MailboxTab::Inbox);
1155        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1156        buf.cursor_end(MailboxTab::Inbox);
1157        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 19);
1158    }
1159
1160    #[test]
1161    fn cursors_are_per_tab_and_independent() {
1162        // Issue AC: "Scrolling is per-tab — Inbox/Sent/Channel/Wire
1163        // each remember their own position."
1164        let mut buf = MailboxBuffers::default();
1165        buf.extend(MailboxTab::Inbox, rows_n(10));
1166        buf.extend(MailboxTab::Sent, rows_n(10));
1167        buf.cursor_home(MailboxTab::Inbox); // Inbox cursor at 0
1168                                            // Sent cursor stays at its post-extend tail (idx 9).
1169        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1170        assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 9);
1171        // And channel/wire are still at 0 with empty buffers.
1172        assert_eq!(buf.cursor(MailboxTab::Channel).selected_idx, 0);
1173        assert_eq!(buf.cursor(MailboxTab::Wire).selected_idx, 0);
1174    }
1175
1176    #[test]
1177    fn reset_clears_cursors_too() {
1178        // Reset is called when the focused agent changes; the new
1179        // agent's mailbox starts from a clean slate, cursor at 0.
1180        let mut buf = MailboxBuffers::default();
1181        buf.extend(MailboxTab::Inbox, rows_n(5));
1182        buf.cursor_home(MailboxTab::Inbox);
1183        buf.move_cursor_down(MailboxTab::Inbox);
1184        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
1185        buf.reset();
1186        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1187        assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 0);
1188    }
1189
1190    #[test]
1191    fn cursor_methods_are_safe_on_empty_buffer() {
1192        // No rows yet — every cursor method must be a no-op on
1193        // selected_idx = 0 rather than panic.
1194        let mut buf = MailboxBuffers::default();
1195        buf.move_cursor_down(MailboxTab::Inbox);
1196        buf.move_cursor_up(MailboxTab::Inbox);
1197        buf.page_cursor_down(MailboxTab::Inbox);
1198        buf.page_cursor_up(MailboxTab::Inbox);
1199        buf.cursor_home(MailboxTab::Inbox);
1200        buf.cursor_end(MailboxTab::Inbox);
1201        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1202    }
1203
1204    // T-131 PR-2: filter + search semantics.
1205
1206    fn mixed_rows() -> Vec<MessageRow> {
1207        vec![
1208            row(1, "p:ada", "p:dev", "ready for review"),
1209            row(2, "p:kian", "p:dev", "release pipeline notes"),
1210            row(3, "p:ada", "p:dev", "shipping the patch"),
1211            row(4, "user:telegram", "p:dev", "any blockers?"),
1212            row(5, "p:kian", "p:dev", "Release smoke green"),
1213        ]
1214    }
1215
1216    #[test]
1217    fn visible_indices_identity_when_no_filter_no_search() {
1218        let mut buf = MailboxBuffers::default();
1219        buf.extend(MailboxTab::Inbox, mixed_rows());
1220        assert_eq!(
1221            buf.visible_indices(MailboxTab::Inbox),
1222            vec![0, 1, 2, 3, 4],
1223            "no filter + no search must recover PR-1 identity exactly"
1224        );
1225    }
1226
1227    #[test]
1228    fn filter_restricts_to_sender_substring_case_insensitive() {
1229        let mut buf = MailboxBuffers::default();
1230        buf.extend(MailboxTab::Inbox, mixed_rows());
1231        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ADA".into());
1232        assert_eq!(
1233            buf.visible_indices(MailboxTab::Inbox),
1234            vec![0, 2],
1235            "filter `ADA` (case-insensitive) must match `p:ada` rows only"
1236        );
1237    }
1238
1239    #[test]
1240    fn search_restricts_to_body_substring_case_insensitive() {
1241        let mut buf = MailboxBuffers::default();
1242        buf.extend(MailboxTab::Inbox, mixed_rows());
1243        buf.set_input(
1244            MailboxTab::Inbox,
1245            MailboxInputKind::Search,
1246            "release".into(),
1247        );
1248        assert_eq!(
1249            buf.visible_indices(MailboxTab::Inbox),
1250            vec![1, 4],
1251            "search `release` must match both `release pipeline notes` and \
1252             `Release smoke green` case-insensitively"
1253        );
1254    }
1255
1256    #[test]
1257    fn filter_and_search_compose_via_intersection() {
1258        let mut buf = MailboxBuffers::default();
1259        buf.extend(MailboxTab::Inbox, mixed_rows());
1260        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
1261        buf.set_input(
1262            MailboxTab::Inbox,
1263            MailboxInputKind::Search,
1264            "release".into(),
1265        );
1266        assert_eq!(
1267            buf.visible_indices(MailboxTab::Inbox),
1268            vec![1, 4],
1269            "filter `kian` ∩ search `release` must keep only kian's release rows"
1270        );
1271        // Pin "compose" semantics: each axis on its own would be a
1272        // superset; intersection is strictly smaller-or-equal.
1273        let only_filter = {
1274            let mut b = MailboxBuffers::default();
1275            b.extend(MailboxTab::Inbox, mixed_rows());
1276            b.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
1277            b.visible_indices(MailboxTab::Inbox)
1278        };
1279        assert_eq!(only_filter, vec![1, 4]); // here filter alone happens to coincide
1280    }
1281
1282    #[test]
1283    fn empty_axis_is_noop() {
1284        // The empty-input contract: empty = clear that axis. Issue AC.
1285        let mut buf = MailboxBuffers::default();
1286        buf.extend(MailboxTab::Inbox, mixed_rows());
1287        // Set then clear filter — visible_indices returns to identity.
1288        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1289        assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1290        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, String::new());
1291        assert_eq!(
1292            buf.visible_indices(MailboxTab::Inbox),
1293            vec![0, 1, 2, 3, 4],
1294            "clearing the filter must restore identity"
1295        );
1296    }
1297
1298    #[test]
1299    fn input_push_pop_updates_visible_and_clamps_cursor() {
1300        let mut buf = MailboxBuffers::default();
1301        buf.extend(MailboxTab::Inbox, mixed_rows()); // cursor lands at 4 (tail)
1302        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
1303        // Type `a`-`d`-`a` → filter shrinks visible to {0, 2}, len 2.
1304        // The cursor was at 4 (out of range for the shorter list), so
1305        // clamp_cursor must bring it to len-1 = 1.
1306        buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
1307        buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'd');
1308        buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
1309        assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
1310        assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1311        assert_eq!(
1312            buf.cursor(MailboxTab::Inbox).selected_idx,
1313            1,
1314            "cursor must clamp to the shorter visible_indices len-1"
1315        );
1316        // Backspace twice → filter becomes `a`, visible widens but
1317        // cursor stays where it landed (in range).
1318        buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
1319        buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
1320        assert_eq!(buf.filter_text(MailboxTab::Inbox), "a");
1321    }
1322
1323    #[test]
1324    fn filter_and_search_are_per_tab() {
1325        // Issue AC: "Filter state is per-tab." So is search.
1326        let mut buf = MailboxBuffers::default();
1327        buf.extend(MailboxTab::Inbox, mixed_rows());
1328        buf.extend(MailboxTab::Sent, mixed_rows());
1329        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1330        buf.set_input(MailboxTab::Sent, MailboxInputKind::Search, "release".into());
1331        assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
1332        assert_eq!(buf.filter_text(MailboxTab::Sent), "");
1333        assert_eq!(buf.search_text(MailboxTab::Inbox), "");
1334        assert_eq!(buf.search_text(MailboxTab::Sent), "release");
1335        assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1336        assert_eq!(buf.visible_indices(MailboxTab::Sent), vec![1, 4]);
1337    }
1338
1339    #[test]
1340    fn reset_clears_filter_and_search() {
1341        let mut buf = MailboxBuffers::default();
1342        buf.extend(MailboxTab::Inbox, mixed_rows());
1343        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1344        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Search, "ship".into());
1345        buf.reset();
1346        assert_eq!(buf.filter_text(MailboxTab::Inbox), "");
1347        assert_eq!(buf.search_text(MailboxTab::Inbox), "");
1348        assert!(buf.rows(MailboxTab::Inbox).is_empty());
1349    }
1350
1351    #[test]
1352    fn empty_visible_keeps_cursor_at_zero_not_panic() {
1353        // Filter that matches no rows yields empty visible_indices.
1354        // clamp_cursor must leave cursor at 0 rather than underflow.
1355        let mut buf = MailboxBuffers::default();
1356        buf.extend(MailboxTab::Inbox, mixed_rows());
1357        buf.set_input(
1358            MailboxTab::Inbox,
1359            MailboxInputKind::Filter,
1360            "no-such-sender".into(),
1361        );
1362        assert!(buf.visible_indices(MailboxTab::Inbox).is_empty());
1363        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1364        // Cursor methods on an empty visible set must not panic.
1365        buf.move_cursor_down(MailboxTab::Inbox);
1366        buf.move_cursor_up(MailboxTab::Inbox);
1367        buf.cursor_end(MailboxTab::Inbox);
1368        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1369    }
1370
1371    // T-131 PR-3: kind_label + transport_label derivation.
1372
1373    #[test]
1374    fn kind_label_distinguishes_dm_channel_wire() {
1375        let r = row(1, "p:a", "p:dev", "x"); // agent-to-agent DM
1376        assert_eq!(kind_label(&r), "DM");
1377        let r = row(1, "p:a", "user:telegram", "x"); // agent-to-user DM
1378        assert_eq!(kind_label(&r), "DM");
1379        let r = row(1, "p:a", "channel:p:dev", "x"); // named channel
1380        assert_eq!(kind_label(&r), "channel broadcast");
1381        let r = row(1, "p:a", "channel:p:all", "x"); // project-wide wire
1382        assert_eq!(kind_label(&r), "wire broadcast");
1383    }
1384
1385    #[test]
1386    fn transport_label_heuristic_covers_documented_cases() {
1387        // Issue's "if discernible" — heuristic from sender prefix.
1388        let r = row(1, "user:telegram", "p:a", "x");
1389        assert_eq!(transport_label(&r), "via telegram");
1390        let r = row(1, "user:discord", "p:a", "x");
1391        assert_eq!(transport_label(&r), "via user");
1392        let r = row(1, "p:agent", "p:other", "x");
1393        assert_eq!(transport_label(&r), "via mcp");
1394        let r = row(1, "p:agent", "channel:p:dev", "x");
1395        assert_eq!(transport_label(&r), "via mcp"); // agent emit, recipient class doesn't matter
1396        let r = row(1, "weird-no-colon", "p:a", "x");
1397        assert_eq!(transport_label(&r), "—"); // graceful degrade
1398    }
1399
1400    // T-131 PR-4: row_timestamp today-fold tests. Owner ratified
1401    // (tg 3388) (1) today-fold YES + (2) 24h YES; silent defaults
1402    // intact (no seconds, local-TZ, past-day `%b %d %H:%M`). Tests
1403    // drive `row_timestamp_in(&Utc, …)` so the assertions are
1404    // timezone-stable regardless of the dev machine's `Local`.
1405
1406    fn ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, sec: u32) -> f64 {
1407        use chrono::TimeZone;
1408        chrono::Utc
1409            .with_ymd_and_hms(year, month, day, hour, minute, sec)
1410            .unwrap()
1411            .timestamp() as f64
1412    }
1413
1414    #[test]
1415    fn row_timestamp_same_day_renders_24h_hhmm() {
1416        let now = ts(2026, 5, 22, 15, 42, 30);
1417        // Sent earlier today at 10:15:00 UTC: `10:15`.
1418        let sent = ts(2026, 5, 22, 10, 15, 0);
1419        assert_eq!(row_timestamp_in(&chrono::Utc, now, sent), "10:15");
1420        // Sent exactly now (truncates the :30 seconds): `15:42`.
1421        assert_eq!(row_timestamp_in(&chrono::Utc, now, now), "15:42");
1422        // Sent at exact midnight same day: `00:00`.
1423        let sent_midnight = ts(2026, 5, 22, 0, 0, 0);
1424        assert_eq!(row_timestamp_in(&chrono::Utc, now, sent_midnight), "00:00");
1425    }
1426
1427    #[test]
1428    fn row_timestamp_prior_day_renders_b_d_hhmm() {
1429        let now = ts(2026, 5, 22, 15, 42, 30);
1430        // Yesterday: full `%b %d %H:%M` past-day format.
1431        let sent_yesterday = ts(2026, 5, 21, 23, 59, 0);
1432        assert_eq!(
1433            row_timestamp_in(&chrono::Utc, now, sent_yesterday),
1434            "May 21 23:59"
1435        );
1436        // A month earlier: same shape, different date.
1437        let sent_earlier_month = ts(2026, 4, 22, 12, 0, 0);
1438        assert_eq!(
1439            row_timestamp_in(&chrono::Utc, now, sent_earlier_month),
1440            "Apr 22 12:00"
1441        );
1442    }
1443
1444    #[test]
1445    fn row_timestamp_future_send_uses_sent_timestamp() {
1446        // Clock skew or test fixture with `sent_at > now`. The
1447        // helper folds purely by date equality, so a future-send on
1448        // the same day still renders `HH:MM`; a future-send on a
1449        // later day renders that day's `%b %d %H:%M`. No special
1450        // negative handling — matches the simplicity-first model.
1451        let now = ts(2026, 5, 22, 15, 42, 30);
1452        let sent_future_same_day = ts(2026, 5, 22, 16, 42, 30);
1453        assert_eq!(
1454            row_timestamp_in(&chrono::Utc, now, sent_future_same_day),
1455            "16:42"
1456        );
1457        let sent_future_next_day = ts(2026, 5, 23, 15, 42, 30);
1458        assert_eq!(
1459            row_timestamp_in(&chrono::Utc, now, sent_future_next_day),
1460            "May 23 15:42"
1461        );
1462    }
1463
1464    #[test]
1465    fn row_timestamp_zero_epoch_is_same_day_as_itself() {
1466        // Snapshot tests use `App::new` (now_secs=0.0) + fixture
1467        // rows (sent_at=0.0) — both map to the Unix epoch, same
1468        // day, format `HH:MM` deterministically across machines
1469        // (snapshots.rs sets TZ=UTC so `Local` resolves to UTC).
1470        assert_eq!(row_timestamp_in(&chrono::Utc, 0.0, 0.0), "00:00");
1471    }
1472}