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 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 #[test]
876 fn render_row_sent_tab_shows_recipient_with_arrow() {
877 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 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 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 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 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 #[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 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 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 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 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 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 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 let mut buf = MailboxBuffers::default();
1081 buf.extend(MailboxTab::Inbox, rows_n(5));
1082 buf.cursor_home(MailboxTab::Inbox); 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 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 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)); 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); 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 for _ in 0..20 {
1140 buf.page_cursor_down(MailboxTab::Inbox);
1141 }
1142 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 49);
1143 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 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); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1170 assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 9);
1171 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 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 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 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 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]); }
1281
1282 #[test]
1283 fn empty_axis_is_noop() {
1284 let mut buf = MailboxBuffers::default();
1286 buf.extend(MailboxTab::Inbox, mixed_rows());
1287 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()); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
1303 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 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 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 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 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 #[test]
1374 fn kind_label_distinguishes_dm_channel_wire() {
1375 let r = row(1, "p:a", "p:dev", "x"); assert_eq!(kind_label(&r), "DM");
1377 let r = row(1, "p:a", "user:telegram", "x"); assert_eq!(kind_label(&r), "DM");
1379 let r = row(1, "p:a", "channel:p:dev", "x"); assert_eq!(kind_label(&r), "channel broadcast");
1381 let r = row(1, "p:a", "channel:p:all", "x"); assert_eq!(kind_label(&r), "wire broadcast");
1383 }
1384
1385 #[test]
1386 fn transport_label_heuristic_covers_documented_cases() {
1387 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"); let r = row(1, "weird-no-colon", "p:a", "x");
1397 assert_eq!(transport_label(&r), "—"); }
1399
1400 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 let sent = ts(2026, 5, 22, 10, 15, 0);
1419 assert_eq!(row_timestamp_in(&chrono::Utc, now, sent), "10:15");
1420 assert_eq!(row_timestamp_in(&chrono::Utc, now, now), "15:42");
1422 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 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 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 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 assert_eq!(row_timestamp_in(&chrono::Utc, 0.0, 0.0), "00:00");
1471 }
1472}