1use std::borrow::Cow;
40use std::path::PathBuf;
41
42use anyhow::Result;
43use rusqlite::{params, Connection};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum MailboxTab {
47 Inbox,
48 Sent,
49 Channel,
50 Wire,
51}
52
53impl MailboxTab {
54 pub const ALL: [MailboxTab; 2] = [MailboxTab::Inbox, MailboxTab::Sent];
58
59 pub fn label(self) -> &'static str {
60 match self {
61 MailboxTab::Inbox => "Inbox",
62 MailboxTab::Sent => "Sent",
63 MailboxTab::Channel => "Channel",
64 MailboxTab::Wire => "Wire",
65 }
66 }
67
68 pub fn empty_hint(self) -> &'static str {
69 match self {
70 MailboxTab::Inbox => "(no DMs)",
71 MailboxTab::Sent => "(no sent messages)",
72 MailboxTab::Channel => "(no channel traffic)",
73 MailboxTab::Wire => "(quiet)",
74 }
75 }
76
77 pub fn next(self) -> Self {
78 match self {
79 MailboxTab::Inbox => MailboxTab::Sent,
80 MailboxTab::Sent => MailboxTab::Inbox,
81 MailboxTab::Channel | MailboxTab::Wire => MailboxTab::Inbox,
85 }
86 }
87
88 pub fn prev(self) -> Self {
89 match self {
90 MailboxTab::Inbox => MailboxTab::Sent,
91 MailboxTab::Sent => MailboxTab::Inbox,
92 MailboxTab::Channel | MailboxTab::Wire => MailboxTab::Inbox,
93 }
94 }
95}
96
97#[derive(Debug, Clone)]
98pub struct MessageRow {
99 pub id: i64,
100 pub sender: String,
101 pub recipient: String,
102 pub text: String,
103 pub sent_at: f64,
104}
105
106pub fn render_row(row: &MessageRow, team: &crate::data::TeamSnapshot, tab: MailboxTab) -> String {
126 let one_line: String = row
127 .text
128 .replace('\n', " ")
129 .replace('\r', "")
130 .chars()
131 .take(180)
132 .collect();
133 match tab {
134 MailboxTab::Sent => {
135 let recipient = crate::data::recipient_label(team, &row.recipient);
136 format!("[→{recipient}] {one_line}")
137 }
138 MailboxTab::Inbox => {
139 if row.recipient.starts_with("channel:") {
145 let channel = crate::data::recipient_label(team, &row.recipient);
146 let sender = crate::data::agent_label(team, &row.sender);
147 format!("[{channel}] [{sender}] {one_line}")
148 } else {
149 let sender = crate::data::agent_label(team, &row.sender);
150 format!("[{sender}] {one_line}")
151 }
152 }
153 MailboxTab::Channel => {
154 let channel = crate::data::recipient_label(team, &row.recipient);
159 let sender = crate::data::agent_label(team, &row.sender);
160 format!("[{channel}] [{sender}] {one_line}")
161 }
162 MailboxTab::Wire => {
163 let sender = crate::data::agent_label(team, &row.sender);
166 format!("[{sender}] {one_line}")
167 }
168 }
169}
170
171pub fn row_timestamp(now_secs: f64, sent_at: f64) -> String {
192 row_timestamp_in(&chrono::Local, now_secs, sent_at)
193}
194
195pub fn row_timestamp_in<Tz>(tz: &Tz, now_secs: f64, sent_at: f64) -> String
198where
199 Tz: chrono::TimeZone,
200 Tz::Offset: std::fmt::Display,
201{
202 let Some(now) = tz.timestamp_opt(now_secs as i64, 0).single() else {
203 return "—".to_string();
204 };
205 let Some(sent) = tz.timestamp_opt(sent_at as i64, 0).single() else {
206 return "—".to_string();
207 };
208 if now.date_naive() == sent.date_naive() {
209 sent.format("%H:%M").to_string()
210 } else {
211 sent.format("%b %d %H:%M").to_string()
212 }
213}
214
215pub fn kind_label(row: &MessageRow) -> &'static str {
221 if let Some(rest) = row.recipient.strip_prefix("channel:") {
222 if rest.ends_with(":all") {
225 "wire broadcast"
226 } else {
227 "channel broadcast"
228 }
229 } else {
230 "DM"
233 }
234}
235
236pub fn transport_label(row: &MessageRow) -> &'static str {
247 if row.sender.starts_with("user:telegram") {
248 "via telegram"
249 } else if row.sender.starts_with("user:") {
250 "via user"
251 } else if row.sender.contains(':') {
252 "via mcp"
253 } else {
254 "—"
255 }
256}
257
258pub trait MailboxSource: Send + Sync {
263 fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
264 fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
265 fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
266 fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
267}
268
269#[derive(Debug, Clone)]
273pub struct BrokerMailboxSource {
274 pub db_path: PathBuf,
275}
276
277impl BrokerMailboxSource {
278 pub fn new(db_path: PathBuf) -> Self {
279 Self { db_path }
280 }
281
282 fn open(&self) -> Result<Option<Connection>> {
283 if !self.db_path.is_file() {
284 return Ok(None);
285 }
286 let conn = Connection::open(&self.db_path)?;
287 Ok(Some(conn))
288 }
289}
290
291impl MailboxSource for BrokerMailboxSource {
292 fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
293 let Some(conn) = self.open()? else {
294 return Ok(Vec::new());
295 };
296 let mut stmt = conn.prepare(
297 "SELECT id, sender, recipient, text, sent_at FROM messages
298 WHERE id > ?1 AND recipient = ?2
299 ORDER BY id ASC",
300 )?;
301 let rows = stmt
302 .query_map(params![after_id, agent_id], |r| {
303 Ok(MessageRow {
304 id: r.get(0)?,
305 sender: r.get(1)?,
306 recipient: r.get(2)?,
307 text: r.get(3)?,
308 sent_at: r.get(4)?,
309 })
310 })?
311 .flatten()
312 .collect();
313 Ok(rows)
314 }
315
316 fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
317 let Some(conn) = self.open()? else {
318 return Ok(Vec::new());
319 };
320 let mut stmt = conn.prepare(
325 "SELECT id, sender, recipient, text, sent_at FROM messages
326 WHERE id > ?1 AND sender = ?2
327 ORDER BY id ASC",
328 )?;
329 let rows = stmt
330 .query_map(params![after_id, agent_id], |r| {
331 Ok(MessageRow {
332 id: r.get(0)?,
333 sender: r.get(1)?,
334 recipient: r.get(2)?,
335 text: r.get(3)?,
336 sent_at: r.get(4)?,
337 })
338 })?
339 .flatten()
340 .collect();
341 Ok(rows)
342 }
343
344 fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
345 let Some(conn) = self.open()? else {
346 return Ok(Vec::new());
347 };
348 let mut stmt = conn.prepare(
353 "SELECT id, sender, recipient, text, sent_at FROM messages
354 WHERE id > ?1
355 AND recipient IN (
356 SELECT 'channel:' || cm.channel_id FROM channel_members cm
357 WHERE cm.agent_id = ?2
358 )
359 ORDER BY id ASC",
360 )?;
361 let rows = stmt
362 .query_map(params![after_id, agent_id], |r| {
363 Ok(MessageRow {
364 id: r.get(0)?,
365 sender: r.get(1)?,
366 recipient: r.get(2)?,
367 text: r.get(3)?,
368 sent_at: r.get(4)?,
369 })
370 })?
371 .flatten()
372 .collect();
373 Ok(rows)
374 }
375
376 fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
377 let Some(conn) = self.open()? else {
378 return Ok(Vec::new());
379 };
380 let target = format!("channel:{project_id}:all");
384 let mut stmt = conn.prepare(
385 "SELECT id, sender, recipient, text, sent_at FROM messages
386 WHERE id > ?1 AND recipient = ?2
387 ORDER BY id ASC",
388 )?;
389 let rows = stmt
390 .query_map(params![after_id, target], |r| {
391 Ok(MessageRow {
392 id: r.get(0)?,
393 sender: r.get(1)?,
394 recipient: r.get(2)?,
395 text: r.get(3)?,
396 sent_at: r.get(4)?,
397 })
398 })?
399 .flatten()
400 .collect();
401 Ok(rows)
402 }
403}
404
405#[derive(Debug, Default, Clone)]
410pub struct MailboxBuffers {
411 pub agent_id: String,
418 pub inbox: Vec<MessageRow>,
419 pub sent: Vec<MessageRow>,
420 pub channel: Vec<MessageRow>,
421 pub wire: Vec<MessageRow>,
422 pub inbox_after: i64,
423 pub sent_after: i64,
424 pub channel_after: i64,
425 pub wire_after: i64,
426 pub inbox_cursor: CursorState,
433 pub sent_cursor: CursorState,
434 pub channel_cursor: CursorState,
435 pub wire_cursor: CursorState,
436 pub inbox_filter: String,
441 pub sent_filter: String,
442 pub channel_filter: String,
443 pub wire_filter: String,
444 pub inbox_search: String,
445 pub sent_search: String,
446 pub channel_search: String,
447 pub wire_search: String,
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum MailboxInputKind {
458 Filter,
459 Search,
460}
461
462#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
469pub struct CursorState {
470 pub selected_idx: usize,
471}
472
473const MAX_TAB_ROWS: usize = 500;
474
475pub const PAGE_JUMP: usize = 10;
479
480impl MailboxBuffers {
481 pub fn rows(&self, tab: MailboxTab) -> Cow<'_, [MessageRow]> {
490 match tab {
491 MailboxTab::Inbox => Cow::Owned(self.merged_inbox()),
492 MailboxTab::Sent => Cow::Borrowed(&self.sent),
493 MailboxTab::Channel => Cow::Borrowed(&self.channel),
494 MailboxTab::Wire => Cow::Borrowed(&self.wire),
495 }
496 }
497
498 fn merged_inbox(&self) -> Vec<MessageRow> {
514 let me = self.agent_id.as_str();
515 let mut rows: Vec<MessageRow> = self
516 .inbox
517 .iter()
518 .chain(self.channel.iter().filter(|r| r.sender != me))
519 .chain(self.wire.iter().filter(|r| r.sender != me))
520 .cloned()
521 .collect();
522 rows.sort_by_key(|r| r.id);
523 rows.dedup_by_key(|r| r.id);
524 rows
525 }
526
527 pub fn visible_indices(&self, tab: MailboxTab) -> Vec<usize> {
544 self.visible_indices_in(&self.rows(tab), tab)
545 }
546
547 pub fn visible_indices_in(&self, rows: &[MessageRow], tab: MailboxTab) -> Vec<usize> {
554 let filter = self.filter_text(tab).to_lowercase();
555 let search = self.search_text(tab).to_lowercase();
556 if filter.is_empty() && search.is_empty() {
557 return (0..rows.len()).collect();
558 }
559 (0..rows.len())
560 .filter(|&i| {
561 let row = &rows[i];
562 (filter.is_empty() || row.sender.to_lowercase().contains(&filter))
563 && (search.is_empty() || row.text.to_lowercase().contains(&search))
564 })
565 .collect()
566 }
567
568 pub fn filter_text(&self, tab: MailboxTab) -> &str {
570 match tab {
571 MailboxTab::Inbox => &self.inbox_filter,
572 MailboxTab::Sent => &self.sent_filter,
573 MailboxTab::Channel => &self.channel_filter,
574 MailboxTab::Wire => &self.wire_filter,
575 }
576 }
577
578 pub fn search_text(&self, tab: MailboxTab) -> &str {
580 match tab {
581 MailboxTab::Inbox => &self.inbox_search,
582 MailboxTab::Sent => &self.sent_search,
583 MailboxTab::Channel => &self.channel_search,
584 MailboxTab::Wire => &self.wire_search,
585 }
586 }
587
588 fn filter_text_mut(&mut self, tab: MailboxTab) -> &mut String {
589 match tab {
590 MailboxTab::Inbox => &mut self.inbox_filter,
591 MailboxTab::Sent => &mut self.sent_filter,
592 MailboxTab::Channel => &mut self.channel_filter,
593 MailboxTab::Wire => &mut self.wire_filter,
594 }
595 }
596
597 fn search_text_mut(&mut self, tab: MailboxTab) -> &mut String {
598 match tab {
599 MailboxTab::Inbox => &mut self.inbox_search,
600 MailboxTab::Sent => &mut self.sent_search,
601 MailboxTab::Channel => &mut self.channel_search,
602 MailboxTab::Wire => &mut self.wire_search,
603 }
604 }
605
606 pub fn input_push_char(&mut self, tab: MailboxTab, kind: MailboxInputKind, c: char) {
610 match kind {
611 MailboxInputKind::Filter => self.filter_text_mut(tab).push(c),
612 MailboxInputKind::Search => self.search_text_mut(tab).push(c),
613 }
614 self.clamp_cursor(tab);
615 }
616
617 pub fn input_pop_char(&mut self, tab: MailboxTab, kind: MailboxInputKind) {
620 match kind {
621 MailboxInputKind::Filter => {
622 self.filter_text_mut(tab).pop();
623 }
624 MailboxInputKind::Search => {
625 self.search_text_mut(tab).pop();
626 }
627 }
628 self.clamp_cursor(tab);
629 }
630
631 pub fn set_input(&mut self, tab: MailboxTab, kind: MailboxInputKind, value: String) {
634 match kind {
635 MailboxInputKind::Filter => *self.filter_text_mut(tab) = value,
636 MailboxInputKind::Search => *self.search_text_mut(tab) = value,
637 }
638 self.clamp_cursor(tab);
639 }
640
641 fn clamp_cursor(&mut self, tab: MailboxTab) {
646 let len = self.visible_indices(tab).len();
647 let cur = self.cursor_mut(tab);
648 if len == 0 {
649 cur.selected_idx = 0;
650 } else if cur.selected_idx >= len {
651 cur.selected_idx = len - 1;
652 }
653 }
654
655 pub fn cursor(&self, tab: MailboxTab) -> &CursorState {
656 match tab {
657 MailboxTab::Inbox => &self.inbox_cursor,
658 MailboxTab::Sent => &self.sent_cursor,
659 MailboxTab::Channel => &self.channel_cursor,
660 MailboxTab::Wire => &self.wire_cursor,
661 }
662 }
663
664 fn cursor_mut(&mut self, tab: MailboxTab) -> &mut CursorState {
665 match tab {
666 MailboxTab::Inbox => &mut self.inbox_cursor,
667 MailboxTab::Sent => &mut self.sent_cursor,
668 MailboxTab::Channel => &mut self.channel_cursor,
669 MailboxTab::Wire => &mut self.wire_cursor,
670 }
671 }
672
673 pub fn move_cursor_down(&mut self, tab: MailboxTab) {
676 let max = self.visible_indices(tab).len().saturating_sub(1);
677 let c = self.cursor_mut(tab);
678 c.selected_idx = (c.selected_idx + 1).min(max);
679 }
680
681 pub fn move_cursor_up(&mut self, tab: MailboxTab) {
683 let c = self.cursor_mut(tab);
684 c.selected_idx = c.selected_idx.saturating_sub(1);
685 }
686
687 pub fn page_cursor_down(&mut self, tab: MailboxTab) {
689 let max = self.visible_indices(tab).len().saturating_sub(1);
690 let c = self.cursor_mut(tab);
691 c.selected_idx = (c.selected_idx + PAGE_JUMP).min(max);
692 }
693
694 pub fn page_cursor_up(&mut self, tab: MailboxTab) {
696 let c = self.cursor_mut(tab);
697 c.selected_idx = c.selected_idx.saturating_sub(PAGE_JUMP);
698 }
699
700 pub fn cursor_home(&mut self, tab: MailboxTab) {
702 self.cursor_mut(tab).selected_idx = 0;
703 }
704
705 pub fn cursor_end(&mut self, tab: MailboxTab) {
707 let max = self.visible_indices(tab).len().saturating_sub(1);
708 self.cursor_mut(tab).selected_idx = max;
709 }
710
711 pub fn inbox_at_tail(&self) -> bool {
719 let len = self.visible_indices(MailboxTab::Inbox).len();
720 len == 0 || self.inbox_cursor.selected_idx + 1 >= len
721 }
722
723 pub fn follow_inbox_tail(&mut self) {
728 let len = self.visible_indices(MailboxTab::Inbox).len();
729 if len > 0 {
730 self.inbox_cursor.selected_idx = len - 1;
731 }
732 }
733
734 pub fn extend(&mut self, tab: MailboxTab, batch: Vec<MessageRow>) {
743 let prev_visible_len = self.visible_indices(tab).len();
744 let was_at_tail =
745 prev_visible_len == 0 || self.cursor(tab).selected_idx + 1 >= prev_visible_len;
746 let last_id = batch.last().map(|r| r.id);
747 let (buf, after) = match tab {
748 MailboxTab::Inbox => (&mut self.inbox, &mut self.inbox_after),
749 MailboxTab::Sent => (&mut self.sent, &mut self.sent_after),
750 MailboxTab::Channel => (&mut self.channel, &mut self.channel_after),
751 MailboxTab::Wire => (&mut self.wire, &mut self.wire_after),
752 };
753 buf.extend(batch);
754 if buf.len() > MAX_TAB_ROWS {
755 let drop = buf.len() - MAX_TAB_ROWS;
756 buf.drain(..drop);
757 }
758 if let Some(id) = last_id {
759 *after = id;
760 }
761 let new_visible_len = self.visible_indices(tab).len();
762 let cur = self.cursor_mut(tab);
763 if was_at_tail && new_visible_len > 0 {
764 cur.selected_idx = new_visible_len - 1;
765 } else if new_visible_len > 0 {
766 let max = new_visible_len - 1;
767 if cur.selected_idx > max {
768 cur.selected_idx = max;
769 }
770 } else {
771 cur.selected_idx = 0;
772 }
773 }
774
775 pub fn reset(&mut self) {
781 *self = Self::default();
782 }
783}
784
785pub mod test_support {
786 use super::*;
792 use std::sync::Mutex;
793
794 #[derive(Default)]
799 pub struct MockMailboxSource {
800 pub inbox_rows: Vec<MessageRow>,
801 pub sent_rows: Vec<MessageRow>,
802 pub channel_rows: Vec<MessageRow>,
803 pub wire_rows: Vec<MessageRow>,
804 pub inbox_calls: Mutex<Vec<(String, i64)>>,
805 pub sent_calls: Mutex<Vec<(String, i64)>>,
806 pub channel_calls: Mutex<Vec<(String, i64)>>,
807 pub wire_calls: Mutex<Vec<(String, i64)>>,
808 }
809
810 impl MailboxSource for MockMailboxSource {
811 fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
812 self.inbox_calls
813 .lock()
814 .unwrap()
815 .push((agent_id.into(), after_id));
816 Ok(self.inbox_rows.clone())
817 }
818
819 fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
820 self.sent_calls
821 .lock()
822 .unwrap()
823 .push((agent_id.into(), after_id));
824 Ok(self.sent_rows.clone())
825 }
826
827 fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
828 self.channel_calls
829 .lock()
830 .unwrap()
831 .push((agent_id.into(), after_id));
832 Ok(self.channel_rows.clone())
833 }
834
835 fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
836 self.wire_calls
837 .lock()
838 .unwrap()
839 .push((project_id.into(), after_id));
840 Ok(self.wire_rows.clone())
841 }
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::test_support::*;
848 use super::*;
849
850 fn row(id: i64, sender: &str, recipient: &str, text: &str) -> MessageRow {
851 MessageRow {
852 id,
853 sender: sender.into(),
854 recipient: recipient.into(),
855 text: text.into(),
856 sent_at: 0.0,
857 }
858 }
859
860 #[test]
861 fn all_is_inbox_and_sent_only() {
862 assert_eq!(MailboxTab::ALL, [MailboxTab::Inbox, MailboxTab::Sent]);
864 }
865
866 #[test]
867 fn next_toggles_between_inbox_and_sent() {
868 let mut t = MailboxTab::Inbox;
870 t = t.next();
871 assert_eq!(t, MailboxTab::Sent);
872 t = t.next();
873 assert_eq!(t, MailboxTab::Inbox);
874 }
875
876 #[test]
877 fn prev_toggles_between_inbox_and_sent() {
878 let mut t = MailboxTab::Inbox;
879 t = t.prev();
880 assert_eq!(t, MailboxTab::Sent);
881 t = t.prev();
882 assert_eq!(t, MailboxTab::Inbox);
883 }
884
885 #[test]
886 fn internal_channel_wire_variants_fold_to_inbox_on_cycle() {
887 assert_eq!(MailboxTab::Channel.next(), MailboxTab::Inbox);
891 assert_eq!(MailboxTab::Wire.next(), MailboxTab::Inbox);
892 assert_eq!(MailboxTab::Channel.prev(), MailboxTab::Inbox);
893 assert_eq!(MailboxTab::Wire.prev(), MailboxTab::Inbox);
894 }
895
896 #[test]
897 fn extend_appends_and_bumps_cursor() {
898 let mut buf = MailboxBuffers::default();
899 buf.extend(
900 MailboxTab::Inbox,
901 vec![row(7, "p:m", "p:dev", "hi"), row(8, "p:m", "p:dev", "yo")],
902 );
903 assert_eq!(buf.inbox.len(), 2);
904 assert_eq!(buf.inbox_after, 8);
905 buf.extend(MailboxTab::Inbox, vec![]);
907 assert_eq!(buf.inbox_after, 8);
908 }
909
910 #[test]
911 fn extend_trims_to_cap() {
912 let mut buf = MailboxBuffers::default();
913 let batch: Vec<MessageRow> = (1..=600).map(|i| row(i, "p:m", "p:dev", "x")).collect();
914 buf.extend(MailboxTab::Wire, batch);
915 assert_eq!(buf.wire.len(), MAX_TAB_ROWS);
916 assert_eq!(buf.wire_after, 600);
920 assert_eq!(buf.wire.last().unwrap().id, 600);
921 }
922
923 #[test]
924 fn reset_clears_buffers_and_cursors() {
925 let mut buf = MailboxBuffers::default();
926 buf.extend(MailboxTab::Inbox, vec![row(3, "a", "b", "x")]);
927 buf.extend(MailboxTab::Channel, vec![row(4, "a", "channel:p:all", "y")]);
928 buf.reset();
929 assert!(buf.inbox.is_empty());
930 assert!(buf.channel.is_empty());
931 assert_eq!(buf.inbox_after, 0);
932 assert_eq!(buf.channel_after, 0);
933 }
934
935 #[test]
936 fn inbox_merges_inbound_channel_and_wire_sorted_excluding_self() {
937 let buf = MailboxBuffers {
941 agent_id: "p:m".to_string(),
942 inbox: vec![row(2, "p:dev", "p:m", "dm")],
943 channel: vec![
944 row(1, "p:dev", "channel:p:eng", "chan inbound"),
945 row(4, "p:m", "channel:p:eng", "chan from me"), ],
947 wire: vec![
948 row(3, "p:ops", "channel:p:all", "wire inbound"),
949 row(5, "p:m", "channel:p:all", "wire from me"), ],
951 ..Default::default()
952 };
953 let inbox = buf.rows(MailboxTab::Inbox);
954 let ids: Vec<i64> = inbox.iter().map(|r| r.id).collect();
955 assert_eq!(ids, vec![1, 2, 3], "inbound DM+channel+wire, id-sorted");
956 assert_eq!(buf.rows(MailboxTab::Channel).len(), 2);
959 assert_eq!(buf.rows(MailboxTab::Wire).len(), 2);
960 }
961
962 #[test]
963 fn inbox_dedups_all_channel_wire_overlap() {
964 let dup = row(7, "p:dev", "channel:p:all", "release cut");
969 let buf = MailboxBuffers {
970 agent_id: "p:m".to_string(),
971 channel: vec![dup.clone()],
972 wire: vec![dup],
973 ..Default::default()
974 };
975 let ids: Vec<i64> = buf.rows(MailboxTab::Inbox).iter().map(|r| r.id).collect();
976 assert_eq!(ids, vec![7], "the all-channel broadcast appears once");
977 }
978
979 #[test]
980 fn inbox_merge_is_noop_filter_before_agent_id_set() {
981 let buf = MailboxBuffers {
988 channel: vec![row(1, "p:m", "channel:p:eng", "self")],
989 ..Default::default()
990 };
991 assert_eq!(buf.rows(MailboxTab::Inbox).len(), 1);
992 }
993
994 #[test]
995 fn inbox_cursor_follows_channel_wire_arrivals_only_when_at_tail() {
996 let mut buf = MailboxBuffers {
1001 agent_id: "p:m".to_string(),
1002 inbox: vec![row(1, "p:dev", "p:m", "dm one")],
1003 ..Default::default()
1004 };
1005 assert!(buf.inbox_at_tail(), "single row → at tail");
1006 let was_at_tail = buf.inbox_at_tail();
1008 buf.extend(
1009 MailboxTab::Channel,
1010 vec![row(2, "p:dev", "channel:p:eng", "later")],
1011 );
1012 if was_at_tail {
1013 buf.follow_inbox_tail();
1014 }
1015 assert_eq!(
1016 buf.inbox_cursor.selected_idx, 1,
1017 "cursor tracked the channel arrival"
1018 );
1019 buf.inbox_cursor.selected_idx = 0;
1021 let was_at_tail = buf.inbox_at_tail();
1022 buf.extend(
1023 MailboxTab::Wire,
1024 vec![row(3, "p:ops", "channel:p:all", "broadcast")],
1025 );
1026 if was_at_tail {
1027 buf.follow_inbox_tail();
1028 }
1029 assert_eq!(
1030 buf.inbox_cursor.selected_idx, 0,
1031 "scrolled-up cursor stays put"
1032 );
1033 }
1034
1035 fn empty_team() -> crate::data::TeamSnapshot {
1036 crate::data::TeamSnapshot::empty(std::path::PathBuf::from("/tmp"))
1037 }
1038
1039 #[test]
1040 fn render_row_flattens_newlines_and_truncates() {
1041 let team = empty_team();
1042 let r = row(1, "p:m", "p:dev", "first\nsecond\nthird");
1043 assert_eq!(
1044 render_row(&r, &team, MailboxTab::Inbox),
1045 "[p:m] first second third"
1046 );
1047
1048 let long: String = "x".repeat(300);
1049 let r = row(1, "s", "r", &long);
1050 let rendered = render_row(&r, &team, MailboxTab::Inbox);
1051 assert!(rendered.chars().count() <= 185);
1053 }
1054
1055 #[test]
1056 fn render_row_inbox_disambiguates_channel_from_dm() {
1057 let team = empty_team();
1061 let dm = row(1, "p:dev", "p:m", "direct");
1062 assert_eq!(render_row(&dm, &team, MailboxTab::Inbox), "[p:dev] direct");
1063 let chan = row(2, "p:dev", "channel:p:eng", "in channel");
1064 assert_eq!(
1065 render_row(&chan, &team, MailboxTab::Inbox),
1066 "[#eng] [p:dev] in channel"
1067 );
1068 let wire = row(3, "p:ops", "channel:p:all", "broadcast");
1069 assert_eq!(
1070 render_row(&wire, &team, MailboxTab::Inbox),
1071 "[#all] [p:ops] broadcast"
1072 );
1073 }
1074
1075 #[test]
1076 fn render_row_uses_display_name_when_set() {
1077 use crate::data::{AgentInfo, TeamSnapshot};
1081 use team_core::supervisor::AgentState;
1082 let agent = AgentInfo {
1083 id: "p:sage".into(),
1084 agent: "sage".into(),
1085 project: "p".into(),
1086 tmux_session: "a-p-sage".into(),
1087 state: AgentState::Unknown,
1088 unread_mail: 0,
1089 pending_approvals: 0,
1090 is_manager: true,
1091 display_name: Some("Sage (Visionary)".into()),
1092 rate_limit_resets_at: None,
1093 last_activity_at: None,
1094 reports_to: None,
1095 };
1096 let team = TeamSnapshot {
1097 root: std::path::PathBuf::from("/tmp"),
1098 team_name: "t".into(),
1099 agents: vec![agent],
1100 channels: vec![],
1101 };
1102 let r = row(1, "p:sage", "p:hugo", "ping");
1103 assert_eq!(
1104 render_row(&r, &team, MailboxTab::Inbox),
1105 "[Sage (Visionary)] ping"
1106 );
1107 }
1108
1109 #[test]
1113 fn render_row_sent_tab_shows_recipient_with_arrow() {
1114 let team = empty_team();
1118 let r = row(1, "p:me", "p:dev", "ack");
1119 assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→p:dev] ack");
1120 }
1121
1122 #[test]
1123 fn render_row_sent_tab_resolves_recipient_display_name() {
1124 use crate::data::{AgentInfo, TeamSnapshot};
1128 use team_core::supervisor::AgentState;
1129 let agent = AgentInfo {
1130 id: "p:hugo".into(),
1131 agent: "hugo".into(),
1132 project: "p".into(),
1133 tmux_session: "a-p-hugo".into(),
1134 state: AgentState::Running,
1135 unread_mail: 0,
1136 pending_approvals: 0,
1137 is_manager: true,
1138 display_name: Some("Hugo (PM)".into()),
1139 rate_limit_resets_at: None,
1140 last_activity_at: None,
1141 reports_to: None,
1142 };
1143 let team = TeamSnapshot {
1144 root: std::path::PathBuf::from("/tmp"),
1145 team_name: "t".into(),
1146 agents: vec![agent],
1147 channels: vec![],
1148 };
1149 let r = row(1, "p:sage", "p:hugo", "ping");
1150 assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→Hugo (PM)] ping");
1151 }
1152
1153 #[test]
1154 fn render_row_sent_tab_renders_channel_recipient_with_hash() {
1155 let team = empty_team();
1159 let r = row(1, "p:me", "channel:teamctl:dev", "rolling 0.8.3");
1160 assert_eq!(
1161 render_row(&r, &team, MailboxTab::Sent),
1162 "[→#dev] rolling 0.8.3"
1163 );
1164 }
1165
1166 #[test]
1167 fn render_row_sent_tab_renders_user_recipient_verbatim() {
1168 let team = empty_team();
1173 let r = row(1, "p:mgr", "user:telegram", "PR url");
1174 assert_eq!(
1175 render_row(&r, &team, MailboxTab::Sent),
1176 "[→user:telegram] PR url"
1177 );
1178 }
1179
1180 #[test]
1181 fn render_row_non_sent_tabs_still_show_sender() {
1182 let team = empty_team();
1185 let r = row(1, "p:from", "p:me", "yo");
1186 assert_eq!(render_row(&r, &team, MailboxTab::Inbox), "[p:from] yo");
1187 assert_eq!(render_row(&r, &team, MailboxTab::Wire), "[p:from] yo");
1188 }
1189
1190 #[test]
1196 fn render_row_channel_tab_prefixes_channel_name_and_sender() {
1197 let team = empty_team();
1198 let r = row(1, "p:from", "channel:teamctl:dev", "yo");
1199 assert_eq!(
1200 render_row(&r, &team, MailboxTab::Channel),
1201 "[#dev] [p:from] yo"
1202 );
1203 }
1204
1205 #[test]
1206 fn render_row_channel_tab_resolves_sender_display_name() {
1207 use crate::data::{AgentInfo, TeamSnapshot};
1211 use team_core::supervisor::AgentState;
1212 let agent = AgentInfo {
1213 id: "p:wren".into(),
1214 agent: "wren".into(),
1215 project: "p".into(),
1216 tmux_session: "a-p-wren".into(),
1217 state: AgentState::Running,
1218 unread_mail: 0,
1219 pending_approvals: 0,
1220 is_manager: false,
1221 display_name: Some("Wren (Engineer)".into()),
1222 rate_limit_resets_at: None,
1223 last_activity_at: None,
1224 reports_to: None,
1225 };
1226 let team = TeamSnapshot {
1227 root: std::path::PathBuf::from("/tmp"),
1228 team_name: "t".into(),
1229 agents: vec![agent],
1230 channels: vec![],
1231 };
1232 let r = row(1, "p:wren", "channel:p:all", "hello");
1233 assert_eq!(
1234 render_row(&r, &team, MailboxTab::Channel),
1235 "[#all] [Wren (Engineer)] hello"
1236 );
1237 }
1238
1239 #[test]
1240 fn render_row_channel_tab_handles_malformed_channel_recipient() {
1241 let team = empty_team();
1247 let r = row(1, "p:from", "channel:malformed", "yo");
1248 assert_eq!(
1249 render_row(&r, &team, MailboxTab::Channel),
1250 "[#malformed] [p:from] yo"
1251 );
1252 }
1253
1254 #[test]
1255 fn mock_records_calls() {
1256 let mock = MockMailboxSource {
1257 inbox_rows: vec![row(1, "p:m", "p:a", "hi")],
1258 ..Default::default()
1259 };
1260 let _ = mock.inbox("p:a", 0).unwrap();
1261 let _ = mock.sent("p:a", 2).unwrap();
1262 let _ = mock.channel_feed("p:a", 5).unwrap();
1263 let _ = mock.wire("p", 9).unwrap();
1264 assert_eq!(*mock.inbox_calls.lock().unwrap(), vec![("p:a".into(), 0)]);
1265 assert_eq!(*mock.sent_calls.lock().unwrap(), vec![("p:a".into(), 2)]);
1266 assert_eq!(*mock.channel_calls.lock().unwrap(), vec![("p:a".into(), 5)]);
1267 assert_eq!(*mock.wire_calls.lock().unwrap(), vec![("p".into(), 9)]);
1268 }
1269
1270 fn rows_n(n: i64) -> Vec<MessageRow> {
1273 (1..=n).map(|i| row(i, "p:m", "p:dev", "x")).collect()
1274 }
1275
1276 #[test]
1277 fn visible_indices_is_identity_in_pr1() {
1278 let mut buf = MailboxBuffers::default();
1283 buf.extend(MailboxTab::Inbox, rows_n(5));
1284 assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 1, 2, 3, 4]);
1285 assert!(buf.visible_indices(MailboxTab::Sent).is_empty());
1286 }
1287
1288 #[test]
1289 fn extend_into_empty_seats_cursor_at_tail() {
1290 let mut buf = MailboxBuffers::default();
1295 buf.extend(MailboxTab::Inbox, rows_n(7));
1296 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 6);
1297 }
1298
1299 #[test]
1300 fn extend_when_cursor_at_tail_follows_new_arrivals() {
1301 let mut buf = MailboxBuffers::default();
1305 buf.extend(MailboxTab::Inbox, rows_n(3));
1306 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2);
1307 buf.extend(
1308 MailboxTab::Inbox,
1309 vec![row(4, "p:m", "p:dev", "x"), row(5, "p:m", "p:dev", "x")],
1310 );
1311 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
1312 }
1313
1314 #[test]
1315 fn extend_when_cursor_scrolled_up_does_not_follow() {
1316 let mut buf = MailboxBuffers::default();
1320 buf.extend(MailboxTab::Inbox, rows_n(5));
1321 buf.cursor_home(MailboxTab::Inbox); buf.extend(MailboxTab::Inbox, vec![row(6, "p:m", "p:dev", "x")]);
1323 assert_eq!(
1324 buf.cursor(MailboxTab::Inbox).selected_idx,
1325 0,
1326 "scrolled-up cursor must not jump on new arrival"
1327 );
1328 }
1329
1330 #[test]
1331 fn extend_reclamps_cursor_after_drain() {
1332 let mut buf = MailboxBuffers::default();
1336 buf.extend(MailboxTab::Inbox, rows_n(MAX_TAB_ROWS as i64));
1337 buf.cursor_home(MailboxTab::Inbox);
1338 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1339 let next: Vec<MessageRow> = (501..=510).map(|i| row(i, "p:m", "p:dev", "x")).collect();
1341 buf.extend(MailboxTab::Inbox, next);
1342 let visible = buf.visible_indices(MailboxTab::Inbox);
1343 assert_eq!(visible.len(), MAX_TAB_ROWS);
1344 assert!(
1345 buf.cursor(MailboxTab::Inbox).selected_idx < visible.len(),
1346 "post-drain cursor must stay in range; got {}, visible.len {}",
1347 buf.cursor(MailboxTab::Inbox).selected_idx,
1348 visible.len()
1349 );
1350 }
1351
1352 #[test]
1353 fn move_cursor_down_and_up_clamp_at_ends() {
1354 let mut buf = MailboxBuffers::default();
1355 buf.extend(MailboxTab::Inbox, rows_n(3)); buf.move_cursor_down(MailboxTab::Inbox);
1357 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2, "tail clamps");
1358 buf.move_cursor_up(MailboxTab::Inbox);
1359 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
1360 buf.move_cursor_up(MailboxTab::Inbox);
1361 buf.move_cursor_up(MailboxTab::Inbox);
1362 buf.move_cursor_up(MailboxTab::Inbox); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0, "head clamps");
1364 }
1365
1366 #[test]
1367 fn page_cursor_jumps_a_screen() {
1368 let mut buf = MailboxBuffers::default();
1369 buf.extend(MailboxTab::Inbox, rows_n(50));
1370 buf.cursor_home(MailboxTab::Inbox);
1371 buf.page_cursor_down(MailboxTab::Inbox);
1372 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
1373 buf.page_cursor_down(MailboxTab::Inbox);
1374 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2 * PAGE_JUMP);
1375 buf.page_cursor_up(MailboxTab::Inbox);
1376 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
1377 for _ in 0..20 {
1379 buf.page_cursor_down(MailboxTab::Inbox);
1380 }
1381 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 49);
1382 for _ in 0..20 {
1384 buf.page_cursor_up(MailboxTab::Inbox);
1385 }
1386 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1387 }
1388
1389 #[test]
1390 fn cursor_home_and_end_jump_to_ends() {
1391 let mut buf = MailboxBuffers::default();
1392 buf.extend(MailboxTab::Inbox, rows_n(20));
1393 buf.cursor_home(MailboxTab::Inbox);
1394 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1395 buf.cursor_end(MailboxTab::Inbox);
1396 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 19);
1397 }
1398
1399 #[test]
1400 fn cursors_are_per_tab_and_independent() {
1401 let mut buf = MailboxBuffers::default();
1404 buf.extend(MailboxTab::Inbox, rows_n(10));
1405 buf.extend(MailboxTab::Sent, rows_n(10));
1406 buf.cursor_home(MailboxTab::Inbox); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1409 assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 9);
1410 assert_eq!(buf.cursor(MailboxTab::Channel).selected_idx, 0);
1412 assert_eq!(buf.cursor(MailboxTab::Wire).selected_idx, 0);
1413 }
1414
1415 #[test]
1416 fn reset_clears_cursors_too() {
1417 let mut buf = MailboxBuffers::default();
1420 buf.extend(MailboxTab::Inbox, rows_n(5));
1421 buf.cursor_home(MailboxTab::Inbox);
1422 buf.move_cursor_down(MailboxTab::Inbox);
1423 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
1424 buf.reset();
1425 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1426 assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 0);
1427 }
1428
1429 #[test]
1430 fn cursor_methods_are_safe_on_empty_buffer() {
1431 let mut buf = MailboxBuffers::default();
1434 buf.move_cursor_down(MailboxTab::Inbox);
1435 buf.move_cursor_up(MailboxTab::Inbox);
1436 buf.page_cursor_down(MailboxTab::Inbox);
1437 buf.page_cursor_up(MailboxTab::Inbox);
1438 buf.cursor_home(MailboxTab::Inbox);
1439 buf.cursor_end(MailboxTab::Inbox);
1440 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1441 }
1442
1443 fn mixed_rows() -> Vec<MessageRow> {
1446 vec![
1447 row(1, "p:ada", "p:dev", "ready for review"),
1448 row(2, "p:kian", "p:dev", "release pipeline notes"),
1449 row(3, "p:ada", "p:dev", "shipping the patch"),
1450 row(4, "user:telegram", "p:dev", "any blockers?"),
1451 row(5, "p:kian", "p:dev", "Release smoke green"),
1452 ]
1453 }
1454
1455 #[test]
1456 fn visible_indices_identity_when_no_filter_no_search() {
1457 let mut buf = MailboxBuffers::default();
1458 buf.extend(MailboxTab::Inbox, mixed_rows());
1459 assert_eq!(
1460 buf.visible_indices(MailboxTab::Inbox),
1461 vec![0, 1, 2, 3, 4],
1462 "no filter + no search must recover PR-1 identity exactly"
1463 );
1464 }
1465
1466 #[test]
1467 fn filter_restricts_to_sender_substring_case_insensitive() {
1468 let mut buf = MailboxBuffers::default();
1469 buf.extend(MailboxTab::Inbox, mixed_rows());
1470 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ADA".into());
1471 assert_eq!(
1472 buf.visible_indices(MailboxTab::Inbox),
1473 vec![0, 2],
1474 "filter `ADA` (case-insensitive) must match `p:ada` rows only"
1475 );
1476 }
1477
1478 #[test]
1479 fn search_restricts_to_body_substring_case_insensitive() {
1480 let mut buf = MailboxBuffers::default();
1481 buf.extend(MailboxTab::Inbox, mixed_rows());
1482 buf.set_input(
1483 MailboxTab::Inbox,
1484 MailboxInputKind::Search,
1485 "release".into(),
1486 );
1487 assert_eq!(
1488 buf.visible_indices(MailboxTab::Inbox),
1489 vec![1, 4],
1490 "search `release` must match both `release pipeline notes` and \
1491 `Release smoke green` case-insensitively"
1492 );
1493 }
1494
1495 #[test]
1496 fn filter_and_search_compose_via_intersection() {
1497 let mut buf = MailboxBuffers::default();
1498 buf.extend(MailboxTab::Inbox, mixed_rows());
1499 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
1500 buf.set_input(
1501 MailboxTab::Inbox,
1502 MailboxInputKind::Search,
1503 "release".into(),
1504 );
1505 assert_eq!(
1506 buf.visible_indices(MailboxTab::Inbox),
1507 vec![1, 4],
1508 "filter `kian` ∩ search `release` must keep only kian's release rows"
1509 );
1510 let only_filter = {
1513 let mut b = MailboxBuffers::default();
1514 b.extend(MailboxTab::Inbox, mixed_rows());
1515 b.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
1516 b.visible_indices(MailboxTab::Inbox)
1517 };
1518 assert_eq!(only_filter, vec![1, 4]); }
1520
1521 #[test]
1522 fn empty_axis_is_noop() {
1523 let mut buf = MailboxBuffers::default();
1525 buf.extend(MailboxTab::Inbox, mixed_rows());
1526 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1528 assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1529 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, String::new());
1530 assert_eq!(
1531 buf.visible_indices(MailboxTab::Inbox),
1532 vec![0, 1, 2, 3, 4],
1533 "clearing the filter must restore identity"
1534 );
1535 }
1536
1537 #[test]
1538 fn input_push_pop_updates_visible_and_clamps_cursor() {
1539 let mut buf = MailboxBuffers::default();
1540 buf.extend(MailboxTab::Inbox, mixed_rows()); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
1542 buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
1546 buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'd');
1547 buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
1548 assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
1549 assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1550 assert_eq!(
1551 buf.cursor(MailboxTab::Inbox).selected_idx,
1552 1,
1553 "cursor must clamp to the shorter visible_indices len-1"
1554 );
1555 buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
1558 buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
1559 assert_eq!(buf.filter_text(MailboxTab::Inbox), "a");
1560 }
1561
1562 #[test]
1563 fn filter_and_search_are_per_tab() {
1564 let mut buf = MailboxBuffers::default();
1566 buf.extend(MailboxTab::Inbox, mixed_rows());
1567 buf.extend(MailboxTab::Sent, mixed_rows());
1568 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1569 buf.set_input(MailboxTab::Sent, MailboxInputKind::Search, "release".into());
1570 assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
1571 assert_eq!(buf.filter_text(MailboxTab::Sent), "");
1572 assert_eq!(buf.search_text(MailboxTab::Inbox), "");
1573 assert_eq!(buf.search_text(MailboxTab::Sent), "release");
1574 assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1575 assert_eq!(buf.visible_indices(MailboxTab::Sent), vec![1, 4]);
1576 }
1577
1578 #[test]
1579 fn reset_clears_filter_and_search() {
1580 let mut buf = MailboxBuffers::default();
1581 buf.extend(MailboxTab::Inbox, mixed_rows());
1582 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1583 buf.set_input(MailboxTab::Inbox, MailboxInputKind::Search, "ship".into());
1584 buf.reset();
1585 assert_eq!(buf.filter_text(MailboxTab::Inbox), "");
1586 assert_eq!(buf.search_text(MailboxTab::Inbox), "");
1587 assert!(buf.rows(MailboxTab::Inbox).is_empty());
1588 }
1589
1590 #[test]
1591 fn empty_visible_keeps_cursor_at_zero_not_panic() {
1592 let mut buf = MailboxBuffers::default();
1595 buf.extend(MailboxTab::Inbox, mixed_rows());
1596 buf.set_input(
1597 MailboxTab::Inbox,
1598 MailboxInputKind::Filter,
1599 "no-such-sender".into(),
1600 );
1601 assert!(buf.visible_indices(MailboxTab::Inbox).is_empty());
1602 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1603 buf.move_cursor_down(MailboxTab::Inbox);
1605 buf.move_cursor_up(MailboxTab::Inbox);
1606 buf.cursor_end(MailboxTab::Inbox);
1607 assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1608 }
1609
1610 #[test]
1613 fn kind_label_distinguishes_dm_channel_wire() {
1614 let r = row(1, "p:a", "p:dev", "x"); assert_eq!(kind_label(&r), "DM");
1616 let r = row(1, "p:a", "user:telegram", "x"); assert_eq!(kind_label(&r), "DM");
1618 let r = row(1, "p:a", "channel:p:dev", "x"); assert_eq!(kind_label(&r), "channel broadcast");
1620 let r = row(1, "p:a", "channel:p:all", "x"); assert_eq!(kind_label(&r), "wire broadcast");
1622 }
1623
1624 #[test]
1625 fn transport_label_heuristic_covers_documented_cases() {
1626 let r = row(1, "user:telegram", "p:a", "x");
1628 assert_eq!(transport_label(&r), "via telegram");
1629 let r = row(1, "user:discord", "p:a", "x");
1630 assert_eq!(transport_label(&r), "via user");
1631 let r = row(1, "p:agent", "p:other", "x");
1632 assert_eq!(transport_label(&r), "via mcp");
1633 let r = row(1, "p:agent", "channel:p:dev", "x");
1634 assert_eq!(transport_label(&r), "via mcp"); let r = row(1, "weird-no-colon", "p:a", "x");
1636 assert_eq!(transport_label(&r), "—"); }
1638
1639 fn ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, sec: u32) -> f64 {
1646 use chrono::TimeZone;
1647 chrono::Utc
1648 .with_ymd_and_hms(year, month, day, hour, minute, sec)
1649 .unwrap()
1650 .timestamp() as f64
1651 }
1652
1653 #[test]
1654 fn row_timestamp_same_day_renders_24h_hhmm() {
1655 let now = ts(2026, 5, 22, 15, 42, 30);
1656 let sent = ts(2026, 5, 22, 10, 15, 0);
1658 assert_eq!(row_timestamp_in(&chrono::Utc, now, sent), "10:15");
1659 assert_eq!(row_timestamp_in(&chrono::Utc, now, now), "15:42");
1661 let sent_midnight = ts(2026, 5, 22, 0, 0, 0);
1663 assert_eq!(row_timestamp_in(&chrono::Utc, now, sent_midnight), "00:00");
1664 }
1665
1666 #[test]
1667 fn row_timestamp_prior_day_renders_b_d_hhmm() {
1668 let now = ts(2026, 5, 22, 15, 42, 30);
1669 let sent_yesterday = ts(2026, 5, 21, 23, 59, 0);
1671 assert_eq!(
1672 row_timestamp_in(&chrono::Utc, now, sent_yesterday),
1673 "May 21 23:59"
1674 );
1675 let sent_earlier_month = ts(2026, 4, 22, 12, 0, 0);
1677 assert_eq!(
1678 row_timestamp_in(&chrono::Utc, now, sent_earlier_month),
1679 "Apr 22 12:00"
1680 );
1681 }
1682
1683 #[test]
1684 fn row_timestamp_future_send_uses_sent_timestamp() {
1685 let now = ts(2026, 5, 22, 15, 42, 30);
1691 let sent_future_same_day = ts(2026, 5, 22, 16, 42, 30);
1692 assert_eq!(
1693 row_timestamp_in(&chrono::Utc, now, sent_future_same_day),
1694 "16:42"
1695 );
1696 let sent_future_next_day = ts(2026, 5, 23, 15, 42, 30);
1697 assert_eq!(
1698 row_timestamp_in(&chrono::Utc, now, sent_future_next_day),
1699 "May 23 15:42"
1700 );
1701 }
1702
1703 #[test]
1704 fn row_timestamp_zero_epoch_is_same_day_as_itself() {
1705 assert_eq!(row_timestamp_in(&chrono::Utc, 0.0, 0.0), "00:00");
1710 }
1711}