use std::borrow::Cow;
use std::path::PathBuf;
use anyhow::Result;
use rusqlite::{params, Connection};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailboxTab {
Inbox,
Sent,
Channel,
Wire,
}
impl MailboxTab {
pub const ALL: [MailboxTab; 2] = [MailboxTab::Inbox, MailboxTab::Sent];
pub fn label(self) -> &'static str {
match self {
MailboxTab::Inbox => "Inbox",
MailboxTab::Sent => "Sent",
MailboxTab::Channel => "Channel",
MailboxTab::Wire => "Wire",
}
}
pub fn empty_hint(self) -> &'static str {
match self {
MailboxTab::Inbox => "(no DMs)",
MailboxTab::Sent => "(no sent messages)",
MailboxTab::Channel => "(no channel traffic)",
MailboxTab::Wire => "(quiet)",
}
}
pub fn next(self) -> Self {
match self {
MailboxTab::Inbox => MailboxTab::Sent,
MailboxTab::Sent => MailboxTab::Inbox,
MailboxTab::Channel | MailboxTab::Wire => MailboxTab::Inbox,
}
}
pub fn prev(self) -> Self {
match self {
MailboxTab::Inbox => MailboxTab::Sent,
MailboxTab::Sent => MailboxTab::Inbox,
MailboxTab::Channel | MailboxTab::Wire => MailboxTab::Inbox,
}
}
}
#[derive(Debug, Clone)]
pub struct MessageRow {
pub id: i64,
pub sender: String,
pub recipient: String,
pub text: String,
pub sent_at: f64,
}
pub fn render_row(row: &MessageRow, team: &crate::data::TeamSnapshot, tab: MailboxTab) -> String {
let one_line: String = row
.text
.replace('\n', " ")
.replace('\r', "")
.chars()
.take(180)
.collect();
match tab {
MailboxTab::Sent => {
let recipient = crate::data::recipient_label(team, &row.recipient);
format!("[→{recipient}] {one_line}")
}
MailboxTab::Inbox => {
if row.recipient.starts_with("channel:") {
let channel = crate::data::recipient_label(team, &row.recipient);
let sender = crate::data::agent_label(team, &row.sender);
format!("[{channel}] [{sender}] {one_line}")
} else {
let sender = crate::data::agent_label(team, &row.sender);
format!("[{sender}] {one_line}")
}
}
MailboxTab::Channel => {
let channel = crate::data::recipient_label(team, &row.recipient);
let sender = crate::data::agent_label(team, &row.sender);
format!("[{channel}] [{sender}] {one_line}")
}
MailboxTab::Wire => {
let sender = crate::data::agent_label(team, &row.sender);
format!("[{sender}] {one_line}")
}
}
}
pub fn row_timestamp(now_secs: f64, sent_at: f64) -> String {
row_timestamp_in(&chrono::Local, now_secs, sent_at)
}
pub fn row_timestamp_in<Tz>(tz: &Tz, now_secs: f64, sent_at: f64) -> String
where
Tz: chrono::TimeZone,
Tz::Offset: std::fmt::Display,
{
let Some(now) = tz.timestamp_opt(now_secs as i64, 0).single() else {
return "—".to_string();
};
let Some(sent) = tz.timestamp_opt(sent_at as i64, 0).single() else {
return "—".to_string();
};
if now.date_naive() == sent.date_naive() {
sent.format("%H:%M").to_string()
} else {
sent.format("%b %d %H:%M").to_string()
}
}
pub fn kind_label(row: &MessageRow) -> &'static str {
if let Some(rest) = row.recipient.strip_prefix("channel:") {
if rest.ends_with(":all") {
"wire broadcast"
} else {
"channel broadcast"
}
} else {
"DM"
}
}
pub fn transport_label(row: &MessageRow) -> &'static str {
if row.sender.starts_with("user:telegram") {
"via telegram"
} else if row.sender.starts_with("user:") {
"via user"
} else if row.sender.contains(':') {
"via mcp"
} else {
"—"
}
}
pub trait MailboxSource: Send + Sync {
fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
}
#[derive(Debug, Clone)]
pub struct BrokerMailboxSource {
pub db_path: PathBuf,
}
impl BrokerMailboxSource {
pub fn new(db_path: PathBuf) -> Self {
Self { db_path }
}
fn open(&self) -> Result<Option<Connection>> {
if !self.db_path.is_file() {
return Ok(None);
}
let conn = Connection::open(&self.db_path)?;
Ok(Some(conn))
}
}
impl MailboxSource for BrokerMailboxSource {
fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
let Some(conn) = self.open()? else {
return Ok(Vec::new());
};
let mut stmt = conn.prepare(
"SELECT id, sender, recipient, text, sent_at FROM messages
WHERE id > ?1 AND recipient = ?2
ORDER BY id ASC",
)?;
let rows = stmt
.query_map(params![after_id, agent_id], |r| {
Ok(MessageRow {
id: r.get(0)?,
sender: r.get(1)?,
recipient: r.get(2)?,
text: r.get(3)?,
sent_at: r.get(4)?,
})
})?
.flatten()
.collect();
Ok(rows)
}
fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
let Some(conn) = self.open()? else {
return Ok(Vec::new());
};
let mut stmt = conn.prepare(
"SELECT id, sender, recipient, text, sent_at FROM messages
WHERE id > ?1 AND sender = ?2
ORDER BY id ASC",
)?;
let rows = stmt
.query_map(params![after_id, agent_id], |r| {
Ok(MessageRow {
id: r.get(0)?,
sender: r.get(1)?,
recipient: r.get(2)?,
text: r.get(3)?,
sent_at: r.get(4)?,
})
})?
.flatten()
.collect();
Ok(rows)
}
fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
let Some(conn) = self.open()? else {
return Ok(Vec::new());
};
let mut stmt = conn.prepare(
"SELECT id, sender, recipient, text, sent_at FROM messages
WHERE id > ?1
AND recipient IN (
SELECT 'channel:' || cm.channel_id FROM channel_members cm
WHERE cm.agent_id = ?2
)
ORDER BY id ASC",
)?;
let rows = stmt
.query_map(params![after_id, agent_id], |r| {
Ok(MessageRow {
id: r.get(0)?,
sender: r.get(1)?,
recipient: r.get(2)?,
text: r.get(3)?,
sent_at: r.get(4)?,
})
})?
.flatten()
.collect();
Ok(rows)
}
fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
let Some(conn) = self.open()? else {
return Ok(Vec::new());
};
let target = format!("channel:{project_id}:all");
let mut stmt = conn.prepare(
"SELECT id, sender, recipient, text, sent_at FROM messages
WHERE id > ?1 AND recipient = ?2
ORDER BY id ASC",
)?;
let rows = stmt
.query_map(params![after_id, target], |r| {
Ok(MessageRow {
id: r.get(0)?,
sender: r.get(1)?,
recipient: r.get(2)?,
text: r.get(3)?,
sent_at: r.get(4)?,
})
})?
.flatten()
.collect();
Ok(rows)
}
}
#[derive(Debug, Default, Clone)]
pub struct MailboxBuffers {
pub agent_id: String,
pub inbox: Vec<MessageRow>,
pub sent: Vec<MessageRow>,
pub channel: Vec<MessageRow>,
pub wire: Vec<MessageRow>,
pub inbox_after: i64,
pub sent_after: i64,
pub channel_after: i64,
pub wire_after: i64,
pub inbox_cursor: CursorState,
pub sent_cursor: CursorState,
pub channel_cursor: CursorState,
pub wire_cursor: CursorState,
pub inbox_filter: String,
pub sent_filter: String,
pub channel_filter: String,
pub wire_filter: String,
pub inbox_search: String,
pub sent_search: String,
pub channel_search: String,
pub wire_search: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailboxInputKind {
Filter,
Search,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct CursorState {
pub selected_idx: usize,
}
const MAX_TAB_ROWS: usize = 500;
pub const PAGE_JUMP: usize = 10;
impl MailboxBuffers {
pub fn rows(&self, tab: MailboxTab) -> Cow<'_, [MessageRow]> {
match tab {
MailboxTab::Inbox => Cow::Owned(self.merged_inbox()),
MailboxTab::Sent => Cow::Borrowed(&self.sent),
MailboxTab::Channel => Cow::Borrowed(&self.channel),
MailboxTab::Wire => Cow::Borrowed(&self.wire),
}
}
fn merged_inbox(&self) -> Vec<MessageRow> {
let me = self.agent_id.as_str();
let mut rows: Vec<MessageRow> = self
.inbox
.iter()
.chain(self.channel.iter().filter(|r| r.sender != me))
.chain(self.wire.iter().filter(|r| r.sender != me))
.cloned()
.collect();
rows.sort_by_key(|r| r.id);
rows.dedup_by_key(|r| r.id);
rows
}
pub fn visible_indices(&self, tab: MailboxTab) -> Vec<usize> {
self.visible_indices_in(&self.rows(tab), tab)
}
pub fn visible_indices_in(&self, rows: &[MessageRow], tab: MailboxTab) -> Vec<usize> {
let filter = self.filter_text(tab).to_lowercase();
let search = self.search_text(tab).to_lowercase();
if filter.is_empty() && search.is_empty() {
return (0..rows.len()).collect();
}
(0..rows.len())
.filter(|&i| {
let row = &rows[i];
(filter.is_empty() || row.sender.to_lowercase().contains(&filter))
&& (search.is_empty() || row.text.to_lowercase().contains(&search))
})
.collect()
}
pub fn filter_text(&self, tab: MailboxTab) -> &str {
match tab {
MailboxTab::Inbox => &self.inbox_filter,
MailboxTab::Sent => &self.sent_filter,
MailboxTab::Channel => &self.channel_filter,
MailboxTab::Wire => &self.wire_filter,
}
}
pub fn search_text(&self, tab: MailboxTab) -> &str {
match tab {
MailboxTab::Inbox => &self.inbox_search,
MailboxTab::Sent => &self.sent_search,
MailboxTab::Channel => &self.channel_search,
MailboxTab::Wire => &self.wire_search,
}
}
fn filter_text_mut(&mut self, tab: MailboxTab) -> &mut String {
match tab {
MailboxTab::Inbox => &mut self.inbox_filter,
MailboxTab::Sent => &mut self.sent_filter,
MailboxTab::Channel => &mut self.channel_filter,
MailboxTab::Wire => &mut self.wire_filter,
}
}
fn search_text_mut(&mut self, tab: MailboxTab) -> &mut String {
match tab {
MailboxTab::Inbox => &mut self.inbox_search,
MailboxTab::Sent => &mut self.sent_search,
MailboxTab::Channel => &mut self.channel_search,
MailboxTab::Wire => &mut self.wire_search,
}
}
pub fn input_push_char(&mut self, tab: MailboxTab, kind: MailboxInputKind, c: char) {
match kind {
MailboxInputKind::Filter => self.filter_text_mut(tab).push(c),
MailboxInputKind::Search => self.search_text_mut(tab).push(c),
}
self.clamp_cursor(tab);
}
pub fn input_pop_char(&mut self, tab: MailboxTab, kind: MailboxInputKind) {
match kind {
MailboxInputKind::Filter => {
self.filter_text_mut(tab).pop();
}
MailboxInputKind::Search => {
self.search_text_mut(tab).pop();
}
}
self.clamp_cursor(tab);
}
pub fn set_input(&mut self, tab: MailboxTab, kind: MailboxInputKind, value: String) {
match kind {
MailboxInputKind::Filter => *self.filter_text_mut(tab) = value,
MailboxInputKind::Search => *self.search_text_mut(tab) = value,
}
self.clamp_cursor(tab);
}
fn clamp_cursor(&mut self, tab: MailboxTab) {
let len = self.visible_indices(tab).len();
let cur = self.cursor_mut(tab);
if len == 0 {
cur.selected_idx = 0;
} else if cur.selected_idx >= len {
cur.selected_idx = len - 1;
}
}
pub fn cursor(&self, tab: MailboxTab) -> &CursorState {
match tab {
MailboxTab::Inbox => &self.inbox_cursor,
MailboxTab::Sent => &self.sent_cursor,
MailboxTab::Channel => &self.channel_cursor,
MailboxTab::Wire => &self.wire_cursor,
}
}
fn cursor_mut(&mut self, tab: MailboxTab) -> &mut CursorState {
match tab {
MailboxTab::Inbox => &mut self.inbox_cursor,
MailboxTab::Sent => &mut self.sent_cursor,
MailboxTab::Channel => &mut self.channel_cursor,
MailboxTab::Wire => &mut self.wire_cursor,
}
}
pub fn move_cursor_down(&mut self, tab: MailboxTab) {
let max = self.visible_indices(tab).len().saturating_sub(1);
let c = self.cursor_mut(tab);
c.selected_idx = (c.selected_idx + 1).min(max);
}
pub fn move_cursor_up(&mut self, tab: MailboxTab) {
let c = self.cursor_mut(tab);
c.selected_idx = c.selected_idx.saturating_sub(1);
}
pub fn page_cursor_down(&mut self, tab: MailboxTab) {
let max = self.visible_indices(tab).len().saturating_sub(1);
let c = self.cursor_mut(tab);
c.selected_idx = (c.selected_idx + PAGE_JUMP).min(max);
}
pub fn page_cursor_up(&mut self, tab: MailboxTab) {
let c = self.cursor_mut(tab);
c.selected_idx = c.selected_idx.saturating_sub(PAGE_JUMP);
}
pub fn cursor_home(&mut self, tab: MailboxTab) {
self.cursor_mut(tab).selected_idx = 0;
}
pub fn cursor_end(&mut self, tab: MailboxTab) {
let max = self.visible_indices(tab).len().saturating_sub(1);
self.cursor_mut(tab).selected_idx = max;
}
pub fn inbox_at_tail(&self) -> bool {
let len = self.visible_indices(MailboxTab::Inbox).len();
len == 0 || self.inbox_cursor.selected_idx + 1 >= len
}
pub fn follow_inbox_tail(&mut self) {
let len = self.visible_indices(MailboxTab::Inbox).len();
if len > 0 {
self.inbox_cursor.selected_idx = len - 1;
}
}
pub fn extend(&mut self, tab: MailboxTab, batch: Vec<MessageRow>) {
let prev_visible_len = self.visible_indices(tab).len();
let was_at_tail =
prev_visible_len == 0 || self.cursor(tab).selected_idx + 1 >= prev_visible_len;
let last_id = batch.last().map(|r| r.id);
let (buf, after) = match tab {
MailboxTab::Inbox => (&mut self.inbox, &mut self.inbox_after),
MailboxTab::Sent => (&mut self.sent, &mut self.sent_after),
MailboxTab::Channel => (&mut self.channel, &mut self.channel_after),
MailboxTab::Wire => (&mut self.wire, &mut self.wire_after),
};
buf.extend(batch);
if buf.len() > MAX_TAB_ROWS {
let drop = buf.len() - MAX_TAB_ROWS;
buf.drain(..drop);
}
if let Some(id) = last_id {
*after = id;
}
let new_visible_len = self.visible_indices(tab).len();
let cur = self.cursor_mut(tab);
if was_at_tail && new_visible_len > 0 {
cur.selected_idx = new_visible_len - 1;
} else if new_visible_len > 0 {
let max = new_visible_len - 1;
if cur.selected_idx > max {
cur.selected_idx = max;
}
} else {
cur.selected_idx = 0;
}
}
pub fn reset(&mut self) {
*self = Self::default();
}
}
pub mod test_support {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
pub struct MockMailboxSource {
pub inbox_rows: Vec<MessageRow>,
pub sent_rows: Vec<MessageRow>,
pub channel_rows: Vec<MessageRow>,
pub wire_rows: Vec<MessageRow>,
pub inbox_calls: Mutex<Vec<(String, i64)>>,
pub sent_calls: Mutex<Vec<(String, i64)>>,
pub channel_calls: Mutex<Vec<(String, i64)>>,
pub wire_calls: Mutex<Vec<(String, i64)>>,
}
impl MailboxSource for MockMailboxSource {
fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
self.inbox_calls
.lock()
.unwrap()
.push((agent_id.into(), after_id));
Ok(self.inbox_rows.clone())
}
fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
self.sent_calls
.lock()
.unwrap()
.push((agent_id.into(), after_id));
Ok(self.sent_rows.clone())
}
fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
self.channel_calls
.lock()
.unwrap()
.push((agent_id.into(), after_id));
Ok(self.channel_rows.clone())
}
fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
self.wire_calls
.lock()
.unwrap()
.push((project_id.into(), after_id));
Ok(self.wire_rows.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::*;
use super::*;
fn row(id: i64, sender: &str, recipient: &str, text: &str) -> MessageRow {
MessageRow {
id,
sender: sender.into(),
recipient: recipient.into(),
text: text.into(),
sent_at: 0.0,
}
}
#[test]
fn all_is_inbox_and_sent_only() {
assert_eq!(MailboxTab::ALL, [MailboxTab::Inbox, MailboxTab::Sent]);
}
#[test]
fn next_toggles_between_inbox_and_sent() {
let mut t = MailboxTab::Inbox;
t = t.next();
assert_eq!(t, MailboxTab::Sent);
t = t.next();
assert_eq!(t, MailboxTab::Inbox);
}
#[test]
fn prev_toggles_between_inbox_and_sent() {
let mut t = MailboxTab::Inbox;
t = t.prev();
assert_eq!(t, MailboxTab::Sent);
t = t.prev();
assert_eq!(t, MailboxTab::Inbox);
}
#[test]
fn internal_channel_wire_variants_fold_to_inbox_on_cycle() {
assert_eq!(MailboxTab::Channel.next(), MailboxTab::Inbox);
assert_eq!(MailboxTab::Wire.next(), MailboxTab::Inbox);
assert_eq!(MailboxTab::Channel.prev(), MailboxTab::Inbox);
assert_eq!(MailboxTab::Wire.prev(), MailboxTab::Inbox);
}
#[test]
fn extend_appends_and_bumps_cursor() {
let mut buf = MailboxBuffers::default();
buf.extend(
MailboxTab::Inbox,
vec![row(7, "p:m", "p:dev", "hi"), row(8, "p:m", "p:dev", "yo")],
);
assert_eq!(buf.inbox.len(), 2);
assert_eq!(buf.inbox_after, 8);
buf.extend(MailboxTab::Inbox, vec![]);
assert_eq!(buf.inbox_after, 8);
}
#[test]
fn extend_trims_to_cap() {
let mut buf = MailboxBuffers::default();
let batch: Vec<MessageRow> = (1..=600).map(|i| row(i, "p:m", "p:dev", "x")).collect();
buf.extend(MailboxTab::Wire, batch);
assert_eq!(buf.wire.len(), MAX_TAB_ROWS);
assert_eq!(buf.wire_after, 600);
assert_eq!(buf.wire.last().unwrap().id, 600);
}
#[test]
fn reset_clears_buffers_and_cursors() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, vec![row(3, "a", "b", "x")]);
buf.extend(MailboxTab::Channel, vec![row(4, "a", "channel:p:all", "y")]);
buf.reset();
assert!(buf.inbox.is_empty());
assert!(buf.channel.is_empty());
assert_eq!(buf.inbox_after, 0);
assert_eq!(buf.channel_after, 0);
}
#[test]
fn inbox_merges_inbound_channel_and_wire_sorted_excluding_self() {
let buf = MailboxBuffers {
agent_id: "p:m".to_string(),
inbox: vec![row(2, "p:dev", "p:m", "dm")],
channel: vec![
row(1, "p:dev", "channel:p:eng", "chan inbound"),
row(4, "p:m", "channel:p:eng", "chan from me"), ],
wire: vec![
row(3, "p:ops", "channel:p:all", "wire inbound"),
row(5, "p:m", "channel:p:all", "wire from me"), ],
..Default::default()
};
let inbox = buf.rows(MailboxTab::Inbox);
let ids: Vec<i64> = inbox.iter().map(|r| r.id).collect();
assert_eq!(ids, vec![1, 2, 3], "inbound DM+channel+wire, id-sorted");
assert_eq!(buf.rows(MailboxTab::Channel).len(), 2);
assert_eq!(buf.rows(MailboxTab::Wire).len(), 2);
}
#[test]
fn inbox_dedups_all_channel_wire_overlap() {
let dup = row(7, "p:dev", "channel:p:all", "release cut");
let buf = MailboxBuffers {
agent_id: "p:m".to_string(),
channel: vec![dup.clone()],
wire: vec![dup],
..Default::default()
};
let ids: Vec<i64> = buf.rows(MailboxTab::Inbox).iter().map(|r| r.id).collect();
assert_eq!(ids, vec![7], "the all-channel broadcast appears once");
}
#[test]
fn inbox_merge_is_noop_filter_before_agent_id_set() {
let buf = MailboxBuffers {
channel: vec![row(1, "p:m", "channel:p:eng", "self")],
..Default::default()
};
assert_eq!(buf.rows(MailboxTab::Inbox).len(), 1);
}
#[test]
fn inbox_cursor_follows_channel_wire_arrivals_only_when_at_tail() {
let mut buf = MailboxBuffers {
agent_id: "p:m".to_string(),
inbox: vec![row(1, "p:dev", "p:m", "dm one")],
..Default::default()
};
assert!(buf.inbox_at_tail(), "single row → at tail");
let was_at_tail = buf.inbox_at_tail();
buf.extend(
MailboxTab::Channel,
vec![row(2, "p:dev", "channel:p:eng", "later")],
);
if was_at_tail {
buf.follow_inbox_tail();
}
assert_eq!(
buf.inbox_cursor.selected_idx, 1,
"cursor tracked the channel arrival"
);
buf.inbox_cursor.selected_idx = 0;
let was_at_tail = buf.inbox_at_tail();
buf.extend(
MailboxTab::Wire,
vec![row(3, "p:ops", "channel:p:all", "broadcast")],
);
if was_at_tail {
buf.follow_inbox_tail();
}
assert_eq!(
buf.inbox_cursor.selected_idx, 0,
"scrolled-up cursor stays put"
);
}
fn empty_team() -> crate::data::TeamSnapshot {
crate::data::TeamSnapshot::empty(std::path::PathBuf::from("/tmp"))
}
#[test]
fn render_row_flattens_newlines_and_truncates() {
let team = empty_team();
let r = row(1, "p:m", "p:dev", "first\nsecond\nthird");
assert_eq!(
render_row(&r, &team, MailboxTab::Inbox),
"[p:m] first second third"
);
let long: String = "x".repeat(300);
let r = row(1, "s", "r", &long);
let rendered = render_row(&r, &team, MailboxTab::Inbox);
assert!(rendered.chars().count() <= 185);
}
#[test]
fn render_row_inbox_disambiguates_channel_from_dm() {
let team = empty_team();
let dm = row(1, "p:dev", "p:m", "direct");
assert_eq!(render_row(&dm, &team, MailboxTab::Inbox), "[p:dev] direct");
let chan = row(2, "p:dev", "channel:p:eng", "in channel");
assert_eq!(
render_row(&chan, &team, MailboxTab::Inbox),
"[#eng] [p:dev] in channel"
);
let wire = row(3, "p:ops", "channel:p:all", "broadcast");
assert_eq!(
render_row(&wire, &team, MailboxTab::Inbox),
"[#all] [p:ops] broadcast"
);
}
#[test]
fn render_row_uses_display_name_when_set() {
use crate::data::{AgentInfo, TeamSnapshot};
use team_core::supervisor::AgentState;
let agent = AgentInfo {
id: "p:sage".into(),
agent: "sage".into(),
project: "p".into(),
tmux_session: "a-p-sage".into(),
state: AgentState::Unknown,
unread_mail: 0,
pending_approvals: 0,
is_manager: true,
display_name: Some("Sage (Visionary)".into()),
rate_limit_resets_at: None,
last_activity_at: None,
reports_to: None,
};
let team = TeamSnapshot {
root: std::path::PathBuf::from("/tmp"),
team_name: "t".into(),
agents: vec![agent],
channels: vec![],
};
let r = row(1, "p:sage", "p:hugo", "ping");
assert_eq!(
render_row(&r, &team, MailboxTab::Inbox),
"[Sage (Visionary)] ping"
);
}
#[test]
fn render_row_sent_tab_shows_recipient_with_arrow() {
let team = empty_team();
let r = row(1, "p:me", "p:dev", "ack");
assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→p:dev] ack");
}
#[test]
fn render_row_sent_tab_resolves_recipient_display_name() {
use crate::data::{AgentInfo, TeamSnapshot};
use team_core::supervisor::AgentState;
let agent = AgentInfo {
id: "p:hugo".into(),
agent: "hugo".into(),
project: "p".into(),
tmux_session: "a-p-hugo".into(),
state: AgentState::Running,
unread_mail: 0,
pending_approvals: 0,
is_manager: true,
display_name: Some("Hugo (PM)".into()),
rate_limit_resets_at: None,
last_activity_at: None,
reports_to: None,
};
let team = TeamSnapshot {
root: std::path::PathBuf::from("/tmp"),
team_name: "t".into(),
agents: vec![agent],
channels: vec![],
};
let r = row(1, "p:sage", "p:hugo", "ping");
assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→Hugo (PM)] ping");
}
#[test]
fn render_row_sent_tab_renders_channel_recipient_with_hash() {
let team = empty_team();
let r = row(1, "p:me", "channel:teamctl:dev", "rolling 0.8.3");
assert_eq!(
render_row(&r, &team, MailboxTab::Sent),
"[→#dev] rolling 0.8.3"
);
}
#[test]
fn render_row_sent_tab_renders_user_recipient_verbatim() {
let team = empty_team();
let r = row(1, "p:mgr", "user:telegram", "PR url");
assert_eq!(
render_row(&r, &team, MailboxTab::Sent),
"[→user:telegram] PR url"
);
}
#[test]
fn render_row_non_sent_tabs_still_show_sender() {
let team = empty_team();
let r = row(1, "p:from", "p:me", "yo");
assert_eq!(render_row(&r, &team, MailboxTab::Inbox), "[p:from] yo");
assert_eq!(render_row(&r, &team, MailboxTab::Wire), "[p:from] yo");
}
#[test]
fn render_row_channel_tab_prefixes_channel_name_and_sender() {
let team = empty_team();
let r = row(1, "p:from", "channel:teamctl:dev", "yo");
assert_eq!(
render_row(&r, &team, MailboxTab::Channel),
"[#dev] [p:from] yo"
);
}
#[test]
fn render_row_channel_tab_resolves_sender_display_name() {
use crate::data::{AgentInfo, TeamSnapshot};
use team_core::supervisor::AgentState;
let agent = AgentInfo {
id: "p:wren".into(),
agent: "wren".into(),
project: "p".into(),
tmux_session: "a-p-wren".into(),
state: AgentState::Running,
unread_mail: 0,
pending_approvals: 0,
is_manager: false,
display_name: Some("Wren (Engineer)".into()),
rate_limit_resets_at: None,
last_activity_at: None,
reports_to: None,
};
let team = TeamSnapshot {
root: std::path::PathBuf::from("/tmp"),
team_name: "t".into(),
agents: vec![agent],
channels: vec![],
};
let r = row(1, "p:wren", "channel:p:all", "hello");
assert_eq!(
render_row(&r, &team, MailboxTab::Channel),
"[#all] [Wren (Engineer)] hello"
);
}
#[test]
fn render_row_channel_tab_handles_malformed_channel_recipient() {
let team = empty_team();
let r = row(1, "p:from", "channel:malformed", "yo");
assert_eq!(
render_row(&r, &team, MailboxTab::Channel),
"[#malformed] [p:from] yo"
);
}
#[test]
fn mock_records_calls() {
let mock = MockMailboxSource {
inbox_rows: vec![row(1, "p:m", "p:a", "hi")],
..Default::default()
};
let _ = mock.inbox("p:a", 0).unwrap();
let _ = mock.sent("p:a", 2).unwrap();
let _ = mock.channel_feed("p:a", 5).unwrap();
let _ = mock.wire("p", 9).unwrap();
assert_eq!(*mock.inbox_calls.lock().unwrap(), vec![("p:a".into(), 0)]);
assert_eq!(*mock.sent_calls.lock().unwrap(), vec![("p:a".into(), 2)]);
assert_eq!(*mock.channel_calls.lock().unwrap(), vec![("p:a".into(), 5)]);
assert_eq!(*mock.wire_calls.lock().unwrap(), vec![("p".into(), 9)]);
}
fn rows_n(n: i64) -> Vec<MessageRow> {
(1..=n).map(|i| row(i, "p:m", "p:dev", "x")).collect()
}
#[test]
fn visible_indices_is_identity_in_pr1() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(5));
assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 1, 2, 3, 4]);
assert!(buf.visible_indices(MailboxTab::Sent).is_empty());
}
#[test]
fn extend_into_empty_seats_cursor_at_tail() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(7));
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 6);
}
#[test]
fn extend_when_cursor_at_tail_follows_new_arrivals() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(3));
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2);
buf.extend(
MailboxTab::Inbox,
vec![row(4, "p:m", "p:dev", "x"), row(5, "p:m", "p:dev", "x")],
);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
}
#[test]
fn extend_when_cursor_scrolled_up_does_not_follow() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(5));
buf.cursor_home(MailboxTab::Inbox); buf.extend(MailboxTab::Inbox, vec![row(6, "p:m", "p:dev", "x")]);
assert_eq!(
buf.cursor(MailboxTab::Inbox).selected_idx,
0,
"scrolled-up cursor must not jump on new arrival"
);
}
#[test]
fn extend_reclamps_cursor_after_drain() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(MAX_TAB_ROWS as i64));
buf.cursor_home(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
let next: Vec<MessageRow> = (501..=510).map(|i| row(i, "p:m", "p:dev", "x")).collect();
buf.extend(MailboxTab::Inbox, next);
let visible = buf.visible_indices(MailboxTab::Inbox);
assert_eq!(visible.len(), MAX_TAB_ROWS);
assert!(
buf.cursor(MailboxTab::Inbox).selected_idx < visible.len(),
"post-drain cursor must stay in range; got {}, visible.len {}",
buf.cursor(MailboxTab::Inbox).selected_idx,
visible.len()
);
}
#[test]
fn move_cursor_down_and_up_clamp_at_ends() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(3)); buf.move_cursor_down(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2, "tail clamps");
buf.move_cursor_up(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
buf.move_cursor_up(MailboxTab::Inbox);
buf.move_cursor_up(MailboxTab::Inbox);
buf.move_cursor_up(MailboxTab::Inbox); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0, "head clamps");
}
#[test]
fn page_cursor_jumps_a_screen() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(50));
buf.cursor_home(MailboxTab::Inbox);
buf.page_cursor_down(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
buf.page_cursor_down(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2 * PAGE_JUMP);
buf.page_cursor_up(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
for _ in 0..20 {
buf.page_cursor_down(MailboxTab::Inbox);
}
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 49);
for _ in 0..20 {
buf.page_cursor_up(MailboxTab::Inbox);
}
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
}
#[test]
fn cursor_home_and_end_jump_to_ends() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(20));
buf.cursor_home(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
buf.cursor_end(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 19);
}
#[test]
fn cursors_are_per_tab_and_independent() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(10));
buf.extend(MailboxTab::Sent, rows_n(10));
buf.cursor_home(MailboxTab::Inbox); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 9);
assert_eq!(buf.cursor(MailboxTab::Channel).selected_idx, 0);
assert_eq!(buf.cursor(MailboxTab::Wire).selected_idx, 0);
}
#[test]
fn reset_clears_cursors_too() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, rows_n(5));
buf.cursor_home(MailboxTab::Inbox);
buf.move_cursor_down(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
buf.reset();
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 0);
}
#[test]
fn cursor_methods_are_safe_on_empty_buffer() {
let mut buf = MailboxBuffers::default();
buf.move_cursor_down(MailboxTab::Inbox);
buf.move_cursor_up(MailboxTab::Inbox);
buf.page_cursor_down(MailboxTab::Inbox);
buf.page_cursor_up(MailboxTab::Inbox);
buf.cursor_home(MailboxTab::Inbox);
buf.cursor_end(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
}
fn mixed_rows() -> Vec<MessageRow> {
vec![
row(1, "p:ada", "p:dev", "ready for review"),
row(2, "p:kian", "p:dev", "release pipeline notes"),
row(3, "p:ada", "p:dev", "shipping the patch"),
row(4, "user:telegram", "p:dev", "any blockers?"),
row(5, "p:kian", "p:dev", "Release smoke green"),
]
}
#[test]
fn visible_indices_identity_when_no_filter_no_search() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, mixed_rows());
assert_eq!(
buf.visible_indices(MailboxTab::Inbox),
vec![0, 1, 2, 3, 4],
"no filter + no search must recover PR-1 identity exactly"
);
}
#[test]
fn filter_restricts_to_sender_substring_case_insensitive() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, mixed_rows());
buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ADA".into());
assert_eq!(
buf.visible_indices(MailboxTab::Inbox),
vec![0, 2],
"filter `ADA` (case-insensitive) must match `p:ada` rows only"
);
}
#[test]
fn search_restricts_to_body_substring_case_insensitive() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, mixed_rows());
buf.set_input(
MailboxTab::Inbox,
MailboxInputKind::Search,
"release".into(),
);
assert_eq!(
buf.visible_indices(MailboxTab::Inbox),
vec![1, 4],
"search `release` must match both `release pipeline notes` and \
`Release smoke green` case-insensitively"
);
}
#[test]
fn filter_and_search_compose_via_intersection() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, mixed_rows());
buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
buf.set_input(
MailboxTab::Inbox,
MailboxInputKind::Search,
"release".into(),
);
assert_eq!(
buf.visible_indices(MailboxTab::Inbox),
vec![1, 4],
"filter `kian` ∩ search `release` must keep only kian's release rows"
);
let only_filter = {
let mut b = MailboxBuffers::default();
b.extend(MailboxTab::Inbox, mixed_rows());
b.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
b.visible_indices(MailboxTab::Inbox)
};
assert_eq!(only_filter, vec![1, 4]); }
#[test]
fn empty_axis_is_noop() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, mixed_rows());
buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, String::new());
assert_eq!(
buf.visible_indices(MailboxTab::Inbox),
vec![0, 1, 2, 3, 4],
"clearing the filter must restore identity"
);
}
#[test]
fn input_push_pop_updates_visible_and_clamps_cursor() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, mixed_rows()); assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'd');
buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
assert_eq!(
buf.cursor(MailboxTab::Inbox).selected_idx,
1,
"cursor must clamp to the shorter visible_indices len-1"
);
buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
assert_eq!(buf.filter_text(MailboxTab::Inbox), "a");
}
#[test]
fn filter_and_search_are_per_tab() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, mixed_rows());
buf.extend(MailboxTab::Sent, mixed_rows());
buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
buf.set_input(MailboxTab::Sent, MailboxInputKind::Search, "release".into());
assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
assert_eq!(buf.filter_text(MailboxTab::Sent), "");
assert_eq!(buf.search_text(MailboxTab::Inbox), "");
assert_eq!(buf.search_text(MailboxTab::Sent), "release");
assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
assert_eq!(buf.visible_indices(MailboxTab::Sent), vec![1, 4]);
}
#[test]
fn reset_clears_filter_and_search() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, mixed_rows());
buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
buf.set_input(MailboxTab::Inbox, MailboxInputKind::Search, "ship".into());
buf.reset();
assert_eq!(buf.filter_text(MailboxTab::Inbox), "");
assert_eq!(buf.search_text(MailboxTab::Inbox), "");
assert!(buf.rows(MailboxTab::Inbox).is_empty());
}
#[test]
fn empty_visible_keeps_cursor_at_zero_not_panic() {
let mut buf = MailboxBuffers::default();
buf.extend(MailboxTab::Inbox, mixed_rows());
buf.set_input(
MailboxTab::Inbox,
MailboxInputKind::Filter,
"no-such-sender".into(),
);
assert!(buf.visible_indices(MailboxTab::Inbox).is_empty());
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
buf.move_cursor_down(MailboxTab::Inbox);
buf.move_cursor_up(MailboxTab::Inbox);
buf.cursor_end(MailboxTab::Inbox);
assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
}
#[test]
fn kind_label_distinguishes_dm_channel_wire() {
let r = row(1, "p:a", "p:dev", "x"); assert_eq!(kind_label(&r), "DM");
let r = row(1, "p:a", "user:telegram", "x"); assert_eq!(kind_label(&r), "DM");
let r = row(1, "p:a", "channel:p:dev", "x"); assert_eq!(kind_label(&r), "channel broadcast");
let r = row(1, "p:a", "channel:p:all", "x"); assert_eq!(kind_label(&r), "wire broadcast");
}
#[test]
fn transport_label_heuristic_covers_documented_cases() {
let r = row(1, "user:telegram", "p:a", "x");
assert_eq!(transport_label(&r), "via telegram");
let r = row(1, "user:discord", "p:a", "x");
assert_eq!(transport_label(&r), "via user");
let r = row(1, "p:agent", "p:other", "x");
assert_eq!(transport_label(&r), "via mcp");
let r = row(1, "p:agent", "channel:p:dev", "x");
assert_eq!(transport_label(&r), "via mcp"); let r = row(1, "weird-no-colon", "p:a", "x");
assert_eq!(transport_label(&r), "—"); }
fn ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, sec: u32) -> f64 {
use chrono::TimeZone;
chrono::Utc
.with_ymd_and_hms(year, month, day, hour, minute, sec)
.unwrap()
.timestamp() as f64
}
#[test]
fn row_timestamp_same_day_renders_24h_hhmm() {
let now = ts(2026, 5, 22, 15, 42, 30);
let sent = ts(2026, 5, 22, 10, 15, 0);
assert_eq!(row_timestamp_in(&chrono::Utc, now, sent), "10:15");
assert_eq!(row_timestamp_in(&chrono::Utc, now, now), "15:42");
let sent_midnight = ts(2026, 5, 22, 0, 0, 0);
assert_eq!(row_timestamp_in(&chrono::Utc, now, sent_midnight), "00:00");
}
#[test]
fn row_timestamp_prior_day_renders_b_d_hhmm() {
let now = ts(2026, 5, 22, 15, 42, 30);
let sent_yesterday = ts(2026, 5, 21, 23, 59, 0);
assert_eq!(
row_timestamp_in(&chrono::Utc, now, sent_yesterday),
"May 21 23:59"
);
let sent_earlier_month = ts(2026, 4, 22, 12, 0, 0);
assert_eq!(
row_timestamp_in(&chrono::Utc, now, sent_earlier_month),
"Apr 22 12:00"
);
}
#[test]
fn row_timestamp_future_send_uses_sent_timestamp() {
let now = ts(2026, 5, 22, 15, 42, 30);
let sent_future_same_day = ts(2026, 5, 22, 16, 42, 30);
assert_eq!(
row_timestamp_in(&chrono::Utc, now, sent_future_same_day),
"16:42"
);
let sent_future_next_day = ts(2026, 5, 23, 15, 42, 30);
assert_eq!(
row_timestamp_in(&chrono::Utc, now, sent_future_next_day),
"May 23 15:42"
);
}
#[test]
fn row_timestamp_zero_epoch_is_same_day_as_itself() {
assert_eq!(row_timestamp_in(&chrono::Utc, 0.0, 0.0), "00:00");
}
}