1use 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
93pub 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 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
141pub fn row_timestamp(now_secs: f64, sent_at: f64) -> String {
162 row_timestamp_in(&chrono::Local, now_secs, sent_at)
163}
164
165pub 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
185pub fn kind_label(row: &MessageRow) -> &'static str {
191 if let Some(rest) = row.recipient.strip_prefix("channel:") {
192 if rest.ends_with(":all") {
195 "wire broadcast"
196 } else {
197 "channel broadcast"
198 }
199 } else {
200 "DM"
203 }
204}
205
206pub 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
228pub 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#[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 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 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 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#[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 pub inbox_cursor: CursorState,
396 pub sent_cursor: CursorState,
397 pub channel_cursor: CursorState,
398 pub wire_cursor: CursorState,
399 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum MailboxInputKind {
421 Filter,
422 Search,
423}
424
425#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
432pub struct CursorState {
433 pub selected_idx: usize,
434}
435
436const MAX_TAB_ROWS: usize = 500;
437
438pub 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 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 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 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 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 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 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 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 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 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 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 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 pub fn cursor_home(&mut self, tab: MailboxTab) {
619 self.cursor_mut(tab).selected_idx = 0;
620 }
621
622 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 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 pub fn reset(&mut self) {
675 *self = Self::default();
676 }
677}
678
679pub mod test_support {
680 use super::*;
686 use std::sync::Mutex;
687
688 #[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 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 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 assert!(rendered.chars().count() <= 185);
837 }
838
839 #[test]
840 fn render_row_uses_display_name_when_set() {
841 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 last_activity_at: None,
858 reports_to: None,
859 };
860 let team = TeamSnapshot {
861 root: std::path::PathBuf::from("/tmp"),
862 team_name: "t".into(),
863 agents: vec![agent],
864 channels: vec![],
865 };
866 let r = row(1, "p:sage", "p:hugo", "ping");
867 assert_eq!(
868 render_row(&r, &team, MailboxTab::Inbox),
869 "[Sage (Visionary)] ping"
870 );
871 }
872
873 #[test]
877 fn render_row_sent_tab_shows_recipient_with_arrow() {
878 let team = empty_team();
882 let r = row(1, "p:me", "p:dev", "ack");
883 assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→p:dev] ack");
884 }
885
886 #[test]
887 fn render_row_sent_tab_resolves_recipient_display_name() {
888 use crate::data::{AgentInfo, TeamSnapshot};
892 use team_core::supervisor::AgentState;
893 let agent = AgentInfo {
894 id: "p:hugo".into(),
895 agent: "hugo".into(),
896 project: "p".into(),
897 tmux_session: "a-p-hugo".into(),
898 state: AgentState::Running,
899 unread_mail: 0,
900 pending_approvals: 0,
901 is_manager: true,
902 display_name: Some("Hugo (PM)".into()),
903 rate_limit_resets_at: None,
904 last_activity_at: None,
905 reports_to: None,
906 };
907 let team = TeamSnapshot {
908 root: std::path::PathBuf::from("/tmp"),
909 team_name: "t".into(),
910 agents: vec![agent],
911 channels: vec![],
912 };
913 let r = row(1, "p:sage", "p:hugo", "ping");
914 assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→Hugo (PM)] ping");
915 }
916
917 #[test]
918 fn render_row_sent_tab_renders_channel_recipient_with_hash() {
919 let team = empty_team();
923 let r = row(1, "p:me", "channel:teamctl:dev", "rolling 0.8.3");
924 assert_eq!(
925 render_row(&r, &team, MailboxTab::Sent),
926 "[→#dev] rolling 0.8.3"
927 );
928 }
929
930 #[test]
931 fn render_row_sent_tab_renders_user_recipient_verbatim() {
932 let team = empty_team();
937 let r = row(1, "p:mgr", "user:telegram", "PR url");
938 assert_eq!(
939 render_row(&r, &team, MailboxTab::Sent),
940 "[→user:telegram] PR url"
941 );
942 }
943
944 #[test]
945 fn render_row_non_sent_tabs_still_show_sender() {
946 let team = empty_team();
949 let r = row(1, "p:from", "p:me", "yo");
950 assert_eq!(render_row(&r, &team, MailboxTab::Inbox), "[p:from] yo");
951 assert_eq!(render_row(&r, &team, MailboxTab::Wire), "[p:from] yo");
952 }
953
954 #[test]
960 fn render_row_channel_tab_prefixes_channel_name_and_sender() {
961 let team = empty_team();
962 let r = row(1, "p:from", "channel:teamctl:dev", "yo");
963 assert_eq!(
964 render_row(&r, &team, MailboxTab::Channel),
965 "[#dev] [p:from] yo"
966 );
967 }
968
969 #[test]
970 fn render_row_channel_tab_resolves_sender_display_name() {
971 use crate::data::{AgentInfo, TeamSnapshot};
975 use team_core::supervisor::AgentState;
976 let agent = AgentInfo {
977 id: "p:wren".into(),
978 agent: "wren".into(),
979 project: "p".into(),
980 tmux_session: "a-p-wren".into(),
981 state: AgentState::Running,
982 unread_mail: 0,
983 pending_approvals: 0,
984 is_manager: false,
985 display_name: Some("Wren (Engineer)".into()),
986 rate_limit_resets_at: None,
987 last_activity_at: None,
988 reports_to: None,
989 };
990 let team = TeamSnapshot {
991 root: std::path::PathBuf::from("/tmp"),
992 team_name: "t".into(),
993 agents: vec![agent],
994 channels: vec![],
995 };
996 let r = row(1, "p:wren", "channel:p:all", "hello");
997 assert_eq!(
998 render_row(&r, &team, MailboxTab::Channel),
999 "[#all] [Wren (Engineer)] hello"
1000 );
1001 }
1002
1003 #[test]
1004 fn render_row_channel_tab_handles_malformed_channel_recipient() {
1005 let team = empty_team();
1011 let r = row(1, "p:from", "channel:malformed", "yo");
1012 assert_eq!(
1013 render_row(&r, &team, MailboxTab::Channel),
1014 "[#malformed] [p:from] yo"
1015 );
1016 }
1017
1018 #[test]
1019 fn mock_records_calls() {
1020 let mock = MockMailboxSource {
1021 inbox_rows: vec![row(1, "p:m", "p:a", "hi")],
1022 ..Default::default()
1023 };
1024 let _ = mock.inbox("p:a", 0).unwrap();
1025 let _ = mock.sent("p:a", 2).unwrap();
1026 let _ = mock.channel_feed("p:a", 5).unwrap();
1027 let _ = mock.wire("p", 9).unwrap();
1028 assert_eq!(*mock.inbox_calls.lock().unwrap(), vec![("p:a".into(), 0)]);
1029 assert_eq!(*mock.sent_calls.lock().unwrap(), vec![("p:a".into(), 2)]);
1030 assert_eq!(*mock.channel_calls.lock().unwrap(), vec![("p:a".into(), 5)]);
1031 assert_eq!(*mock.wire_calls.lock().unwrap(), vec![("p".into(), 9)]);
1032 }
1033
1034 fn rows_n(n: i64) -> Vec<MessageRow> {
1037 (1..=n).map(|i| row(i, "p:m", "p:dev", "x")).collect()
1038 }
1039
1040 #[test]
1041 fn visible_indices_is_identity_in_pr1() {
1042 let mut buf = MailboxBuffers::default();
1047 buf.extend(MailboxTab::Inbox, rows_n(5));
1048 assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 1, 2, 3, 4]);
1049 assert!(buf.visible_indices(MailboxTab::Sent).is_empty());
1050 }
1051
1052 #[test]
1053 fn extend_into_empty_seats_cursor_at_tail() {
1054 let mut buf = MailboxBuffers::default();
1059 buf.extend(MailboxTab::Inbox, rows_n(7));
1060 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 6);
1061 }
1062
1063 #[test]
1064 fn extend_when_cursor_at_tail_follows_new_arrivals() {
1065 let mut buf = MailboxBuffers::default();
1069 buf.extend(MailboxTab::Inbox, rows_n(3));
1070 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2);
1071 buf.extend(
1072 MailboxTab::Inbox,
1073 vec![row(4, "p:m", "p:dev", "x"), row(5, "p:m", "p:dev", "x")],
1074 );
1075 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
1076 }
1077
1078 #[test]
1079 fn extend_when_cursor_scrolled_up_does_not_follow() {
1080 let mut buf = MailboxBuffers::default();
1084 buf.extend(MailboxTab::Inbox, rows_n(5));
1085 buf.cursor_home(MailboxTab::Inbox); buf.extend(MailboxTab::Inbox, vec![row(6, "p:m", "p:dev", "x")]);
1087 assert_eq!(
1088 buf.cursor(MailboxTab::Inbox).selected_idx,
1089 0,
1090 "scrolled-up cursor must not jump on new arrival"
1091 );
1092 }
1093
1094 #[test]
1095 fn extend_reclamps_cursor_after_drain() {
1096 let mut buf = MailboxBuffers::default();
1100 buf.extend(MailboxTab::Inbox, rows_n(MAX_TAB_ROWS as i64));
1101 buf.cursor_home(MailboxTab::Inbox);
1102 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1103 let next: Vec<MessageRow> = (501..=510).map(|i| row(i, "p:m", "p:dev", "x")).collect();
1105 buf.extend(MailboxTab::Inbox, next);
1106 let visible = buf.visible_indices(MailboxTab::Inbox);
1107 assert_eq!(visible.len(), MAX_TAB_ROWS);
1108 assert!(
1109 buf.cursor(MailboxTab::Inbox).selected_idx < visible.len(),
1110 "post-drain cursor must stay in range; got {}, visible.len {}",
1111 buf.cursor(MailboxTab::Inbox).selected_idx,
1112 visible.len()
1113 );
1114 }
1115
1116 #[test]
1117 fn move_cursor_down_and_up_clamp_at_ends() {
1118 let mut buf = MailboxBuffers::default();
1119 buf.extend(MailboxTab::Inbox, rows_n(3)); buf.move_cursor_down(MailboxTab::Inbox);
1121 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2, "tail clamps");
1122 buf.move_cursor_up(MailboxTab::Inbox);
1123 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
1124 buf.move_cursor_up(MailboxTab::Inbox);
1125 buf.move_cursor_up(MailboxTab::Inbox);
1126 buf.move_cursor_up(MailboxTab::Inbox); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0, "head clamps");
1128 }
1129
1130 #[test]
1131 fn page_cursor_jumps_a_screen() {
1132 let mut buf = MailboxBuffers::default();
1133 buf.extend(MailboxTab::Inbox, rows_n(50));
1134 buf.cursor_home(MailboxTab::Inbox);
1135 buf.page_cursor_down(MailboxTab::Inbox);
1136 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
1137 buf.page_cursor_down(MailboxTab::Inbox);
1138 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2 * PAGE_JUMP);
1139 buf.page_cursor_up(MailboxTab::Inbox);
1140 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
1141 for _ in 0..20 {
1143 buf.page_cursor_down(MailboxTab::Inbox);
1144 }
1145 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 49);
1146 for _ in 0..20 {
1148 buf.page_cursor_up(MailboxTab::Inbox);
1149 }
1150 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1151 }
1152
1153 #[test]
1154 fn cursor_home_and_end_jump_to_ends() {
1155 let mut buf = MailboxBuffers::default();
1156 buf.extend(MailboxTab::Inbox, rows_n(20));
1157 buf.cursor_home(MailboxTab::Inbox);
1158 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1159 buf.cursor_end(MailboxTab::Inbox);
1160 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 19);
1161 }
1162
1163 #[test]
1164 fn cursors_are_per_tab_and_independent() {
1165 let mut buf = MailboxBuffers::default();
1168 buf.extend(MailboxTab::Inbox, rows_n(10));
1169 buf.extend(MailboxTab::Sent, rows_n(10));
1170 buf.cursor_home(MailboxTab::Inbox); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1173 assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 9);
1174 assert_eq!(buf.cursor(MailboxTab::Channel).selected_idx, 0);
1176 assert_eq!(buf.cursor(MailboxTab::Wire).selected_idx, 0);
1177 }
1178
1179 #[test]
1180 fn reset_clears_cursors_too() {
1181 let mut buf = MailboxBuffers::default();
1184 buf.extend(MailboxTab::Inbox, rows_n(5));
1185 buf.cursor_home(MailboxTab::Inbox);
1186 buf.move_cursor_down(MailboxTab::Inbox);
1187 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
1188 buf.reset();
1189 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1190 assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 0);
1191 }
1192
1193 #[test]
1194 fn cursor_methods_are_safe_on_empty_buffer() {
1195 let mut buf = MailboxBuffers::default();
1198 buf.move_cursor_down(MailboxTab::Inbox);
1199 buf.move_cursor_up(MailboxTab::Inbox);
1200 buf.page_cursor_down(MailboxTab::Inbox);
1201 buf.page_cursor_up(MailboxTab::Inbox);
1202 buf.cursor_home(MailboxTab::Inbox);
1203 buf.cursor_end(MailboxTab::Inbox);
1204 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1205 }
1206
1207 fn mixed_rows() -> Vec<MessageRow> {
1210 vec![
1211 row(1, "p:ada", "p:dev", "ready for review"),
1212 row(2, "p:kian", "p:dev", "release pipeline notes"),
1213 row(3, "p:ada", "p:dev", "shipping the patch"),
1214 row(4, "user:telegram", "p:dev", "any blockers?"),
1215 row(5, "p:kian", "p:dev", "Release smoke green"),
1216 ]
1217 }
1218
1219 #[test]
1220 fn visible_indices_identity_when_no_filter_no_search() {
1221 let mut buf = MailboxBuffers::default();
1222 buf.extend(MailboxTab::Inbox, mixed_rows());
1223 assert_eq!(
1224 buf.visible_indices(MailboxTab::Inbox),
1225 vec![0, 1, 2, 3, 4],
1226 "no filter + no search must recover PR-1 identity exactly"
1227 );
1228 }
1229
1230 #[test]
1231 fn filter_restricts_to_sender_substring_case_insensitive() {
1232 let mut buf = MailboxBuffers::default();
1233 buf.extend(MailboxTab::Inbox, mixed_rows());
1234 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ADA".into());
1235 assert_eq!(
1236 buf.visible_indices(MailboxTab::Inbox),
1237 vec![0, 2],
1238 "filter `ADA` (case-insensitive) must match `p:ada` rows only"
1239 );
1240 }
1241
1242 #[test]
1243 fn search_restricts_to_body_substring_case_insensitive() {
1244 let mut buf = MailboxBuffers::default();
1245 buf.extend(MailboxTab::Inbox, mixed_rows());
1246 buf.set_input(
1247 MailboxTab::Inbox,
1248 MailboxInputKind::Search,
1249 "release".into(),
1250 );
1251 assert_eq!(
1252 buf.visible_indices(MailboxTab::Inbox),
1253 vec![1, 4],
1254 "search `release` must match both `release pipeline notes` and \
1255 `Release smoke green` case-insensitively"
1256 );
1257 }
1258
1259 #[test]
1260 fn filter_and_search_compose_via_intersection() {
1261 let mut buf = MailboxBuffers::default();
1262 buf.extend(MailboxTab::Inbox, mixed_rows());
1263 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
1264 buf.set_input(
1265 MailboxTab::Inbox,
1266 MailboxInputKind::Search,
1267 "release".into(),
1268 );
1269 assert_eq!(
1270 buf.visible_indices(MailboxTab::Inbox),
1271 vec![1, 4],
1272 "filter `kian` ∩ search `release` must keep only kian's release rows"
1273 );
1274 let only_filter = {
1277 let mut b = MailboxBuffers::default();
1278 b.extend(MailboxTab::Inbox, mixed_rows());
1279 b.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
1280 b.visible_indices(MailboxTab::Inbox)
1281 };
1282 assert_eq!(only_filter, vec![1, 4]); }
1284
1285 #[test]
1286 fn empty_axis_is_noop() {
1287 let mut buf = MailboxBuffers::default();
1289 buf.extend(MailboxTab::Inbox, mixed_rows());
1290 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1292 assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1293 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, String::new());
1294 assert_eq!(
1295 buf.visible_indices(MailboxTab::Inbox),
1296 vec![0, 1, 2, 3, 4],
1297 "clearing the filter must restore identity"
1298 );
1299 }
1300
1301 #[test]
1302 fn input_push_pop_updates_visible_and_clamps_cursor() {
1303 let mut buf = MailboxBuffers::default();
1304 buf.extend(MailboxTab::Inbox, mixed_rows()); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
1306 buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
1310 buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'd');
1311 buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
1312 assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
1313 assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1314 assert_eq!(
1315 buf.cursor(MailboxTab::Inbox).selected_idx,
1316 1,
1317 "cursor must clamp to the shorter visible_indices len-1"
1318 );
1319 buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
1322 buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
1323 assert_eq!(buf.filter_text(MailboxTab::Inbox), "a");
1324 }
1325
1326 #[test]
1327 fn filter_and_search_are_per_tab() {
1328 let mut buf = MailboxBuffers::default();
1330 buf.extend(MailboxTab::Inbox, mixed_rows());
1331 buf.extend(MailboxTab::Sent, mixed_rows());
1332 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1333 buf.set_input(MailboxTab::Sent, MailboxInputKind::Search, "release".into());
1334 assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
1335 assert_eq!(buf.filter_text(MailboxTab::Sent), "");
1336 assert_eq!(buf.search_text(MailboxTab::Inbox), "");
1337 assert_eq!(buf.search_text(MailboxTab::Sent), "release");
1338 assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1339 assert_eq!(buf.visible_indices(MailboxTab::Sent), vec![1, 4]);
1340 }
1341
1342 #[test]
1343 fn reset_clears_filter_and_search() {
1344 let mut buf = MailboxBuffers::default();
1345 buf.extend(MailboxTab::Inbox, mixed_rows());
1346 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1347 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Search, "ship".into());
1348 buf.reset();
1349 assert_eq!(buf.filter_text(MailboxTab::Inbox), "");
1350 assert_eq!(buf.search_text(MailboxTab::Inbox), "");
1351 assert!(buf.rows(MailboxTab::Inbox).is_empty());
1352 }
1353
1354 #[test]
1355 fn empty_visible_keeps_cursor_at_zero_not_panic() {
1356 let mut buf = MailboxBuffers::default();
1359 buf.extend(MailboxTab::Inbox, mixed_rows());
1360 buf.set_input(
1361 MailboxTab::Inbox,
1362 MailboxInputKind::Filter,
1363 "no-such-sender".into(),
1364 );
1365 assert!(buf.visible_indices(MailboxTab::Inbox).is_empty());
1366 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1367 buf.move_cursor_down(MailboxTab::Inbox);
1369 buf.move_cursor_up(MailboxTab::Inbox);
1370 buf.cursor_end(MailboxTab::Inbox);
1371 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1372 }
1373
1374 #[test]
1377 fn kind_label_distinguishes_dm_channel_wire() {
1378 let r = row(1, "p:a", "p:dev", "x"); assert_eq!(kind_label(&r), "DM");
1380 let r = row(1, "p:a", "user:telegram", "x"); assert_eq!(kind_label(&r), "DM");
1382 let r = row(1, "p:a", "channel:p:dev", "x"); assert_eq!(kind_label(&r), "channel broadcast");
1384 let r = row(1, "p:a", "channel:p:all", "x"); assert_eq!(kind_label(&r), "wire broadcast");
1386 }
1387
1388 #[test]
1389 fn transport_label_heuristic_covers_documented_cases() {
1390 let r = row(1, "user:telegram", "p:a", "x");
1392 assert_eq!(transport_label(&r), "via telegram");
1393 let r = row(1, "user:discord", "p:a", "x");
1394 assert_eq!(transport_label(&r), "via user");
1395 let r = row(1, "p:agent", "p:other", "x");
1396 assert_eq!(transport_label(&r), "via mcp");
1397 let r = row(1, "p:agent", "channel:p:dev", "x");
1398 assert_eq!(transport_label(&r), "via mcp"); let r = row(1, "weird-no-colon", "p:a", "x");
1400 assert_eq!(transport_label(&r), "—"); }
1402
1403 fn ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, sec: u32) -> f64 {
1410 use chrono::TimeZone;
1411 chrono::Utc
1412 .with_ymd_and_hms(year, month, day, hour, minute, sec)
1413 .unwrap()
1414 .timestamp() as f64
1415 }
1416
1417 #[test]
1418 fn row_timestamp_same_day_renders_24h_hhmm() {
1419 let now = ts(2026, 5, 22, 15, 42, 30);
1420 let sent = ts(2026, 5, 22, 10, 15, 0);
1422 assert_eq!(row_timestamp_in(&chrono::Utc, now, sent), "10:15");
1423 assert_eq!(row_timestamp_in(&chrono::Utc, now, now), "15:42");
1425 let sent_midnight = ts(2026, 5, 22, 0, 0, 0);
1427 assert_eq!(row_timestamp_in(&chrono::Utc, now, sent_midnight), "00:00");
1428 }
1429
1430 #[test]
1431 fn row_timestamp_prior_day_renders_b_d_hhmm() {
1432 let now = ts(2026, 5, 22, 15, 42, 30);
1433 let sent_yesterday = ts(2026, 5, 21, 23, 59, 0);
1435 assert_eq!(
1436 row_timestamp_in(&chrono::Utc, now, sent_yesterday),
1437 "May 21 23:59"
1438 );
1439 let sent_earlier_month = ts(2026, 4, 22, 12, 0, 0);
1441 assert_eq!(
1442 row_timestamp_in(&chrono::Utc, now, sent_earlier_month),
1443 "Apr 22 12:00"
1444 );
1445 }
1446
1447 #[test]
1448 fn row_timestamp_future_send_uses_sent_timestamp() {
1449 let now = ts(2026, 5, 22, 15, 42, 30);
1455 let sent_future_same_day = ts(2026, 5, 22, 16, 42, 30);
1456 assert_eq!(
1457 row_timestamp_in(&chrono::Utc, now, sent_future_same_day),
1458 "16:42"
1459 );
1460 let sent_future_next_day = ts(2026, 5, 23, 15, 42, 30);
1461 assert_eq!(
1462 row_timestamp_in(&chrono::Utc, now, sent_future_next_day),
1463 "May 23 15:42"
1464 );
1465 }
1466
1467 #[test]
1468 fn row_timestamp_zero_epoch_is_same_day_as_itself() {
1469 assert_eq!(row_timestamp_in(&chrono::Utc, 0.0, 0.0), "00:00");
1474 }
1475}