use crate::state::{AttentionEntry, Event};
#[must_use]
pub fn format_list(entries: &[(String, AttentionEntry)]) -> String {
const PROJECT_CAP: usize = 30;
const AGENT_CAP: usize = 16;
const MESSAGE_CAP: usize = 80;
if entries.is_empty() {
return String::new();
}
let project_width = entries
.iter()
.map(|(_, e)| e.project.chars().count().min(PROJECT_CAP))
.max()
.unwrap_or(0);
let agent_width = entries
.iter()
.map(|(_, e)| e.agent.chars().count().min(AGENT_CAP))
.max()
.unwrap_or(0);
let mut out = String::new();
for (sid, e) in entries {
let marker = event_marker(&e.event);
let project = truncate_chars(&e.project, PROJECT_CAP);
let agent = truncate_chars(&e.agent, AGENT_CAP);
let snippet = e
.message
.as_deref()
.map(|m| one_line(m, MESSAGE_CAP))
.unwrap_or_default();
let mut display = format!("{marker} {project:<project_width$} {agent:<agent_width$}");
if !snippet.is_empty() {
display.push_str(" ");
display.push_str(&snippet);
}
out.push_str(sid);
out.push('\t');
out.push_str(e.tmux_pane.as_deref().unwrap_or(""));
out.push('\t');
out.push_str(display.trim_end());
out.push('\n');
}
out
}
fn event_marker(event: &Event) -> &'static str {
match event {
Event::Notify => "[!]",
Event::Done => "[*]",
Event::Working => "[~]",
Event::Idle => "[-]",
Event::Unknown(_) => "[?]",
}
}
fn truncate_chars(s: &str, cap: usize) -> String {
s.chars().take(cap).collect()
}
fn one_line(s: &str, cap: usize) -> String {
let mut flat = String::with_capacity(s.len());
for ch in s.chars() {
if ch == '\n' || ch == '\r' || ch == '\t' {
if !flat.ends_with(' ') {
flat.push(' ');
}
} else {
flat.push(ch);
}
}
truncate_chars(flat.trim(), cap)
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(project: &str, pane: &str, event: &str) -> AttentionEntry {
AttentionEntry {
agent: "claude-code".into(),
project: project.into(),
cwd: format!("/x/{project}"),
event: Event::from(event),
tmux_pane: Some(pane.into()),
ts: 1,
message: None,
pid: None,
tmux_session: None,
}
}
#[test]
fn format_list_emits_session_id_pane_display_columns() {
let entries = vec![
("sess-1".into(), entry("alpha", "%1", "notify")),
("sess-2".into(), entry("beta", "%2", "done")),
];
let out = format_list(&entries);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2);
for line in &lines {
assert_eq!(line.matches('\t').count(), 2, "line: {line:?}");
}
let cols0: Vec<&str> = lines[0].split('\t').collect();
assert_eq!(cols0[0], "sess-1");
assert_eq!(cols0[1], "%1");
assert!(cols0[2].contains("alpha"));
assert!(cols0[2].contains("claude-code"));
}
#[test]
fn format_list_uses_bracket_marker_not_event_word() {
let entries = vec![
("s1".into(), entry("alpha", "%1", "notify")),
("s2".into(), entry("beta", "%2", "done")),
];
let out = format_list(&entries);
for line in out.lines() {
let display = line.split('\t').nth(2).unwrap();
assert!(!display.contains("notify"), "display: {display:?}");
assert!(!display.contains("done"), "display: {display:?}");
}
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].split('\t').nth(2).unwrap().starts_with("[!] "));
assert!(lines[1].split('\t').nth(2).unwrap().starts_with("[*] "));
}
#[test]
fn format_list_pads_project_and_agent_columns_to_max_width() {
let mut a = entry("short", "%1", "notify");
a.agent = "claude-code".into();
let mut b = entry("a-much-longer-project-name", "%2", "done");
b.agent = "pi".into();
let entries = vec![("s1".into(), a), ("s2".into(), b)];
let out = format_list(&entries);
let lines: Vec<&str> = out.lines().collect();
let display0 = lines[0].split('\t').nth(2).unwrap();
let display1 = lines[1].split('\t').nth(2).unwrap();
let agent0 = display0.find("claude-code").expect("agent on line 0");
let agent1 = display1.find("pi").expect("agent on line 1");
assert_eq!(agent0, agent1, "agent column not aligned: {display0:?} vs {display1:?}");
}
#[test]
fn format_list_appends_message_snippet_when_present() {
let mut e = entry("alpha", "%1", "notify");
e.message = Some("Permission required to read /etc/passwd".into());
let out = format_list(&[("s1".into(), e)]);
let display = out.lines().next().unwrap().split('\t').nth(2).unwrap();
assert!(display.contains("Permission required"), "display: {display:?}");
}
#[test]
fn format_list_collapses_newlines_in_message_snippet() {
let mut e = entry("alpha", "%1", "notify");
e.message = Some("line one\nline two\r\nline three".into());
let out = format_list(&[("s1".into(), e)]);
assert_eq!(out.matches('\n').count(), 1, "got: {out:?}");
let display = out.lines().next().unwrap().split('\t').nth(2).unwrap();
assert!(!display.contains('\n'));
assert!(!display.contains('\r'));
}
#[test]
fn format_list_truncates_long_message_snippet() {
let mut e = entry("alpha", "%1", "notify");
e.message = Some("x".repeat(500));
let out = format_list(&[("s1".into(), e)]);
let display = out.lines().next().unwrap().split('\t').nth(2).unwrap();
assert!(display.len() < 200, "display too long: {} chars", display.len());
}
#[test]
fn format_list_empty_input_returns_empty_string() {
assert_eq!(format_list(&[]), "");
}
#[test]
fn format_list_includes_idle_and_working_entries() {
let idle = entry("alpha", "%1", "idle");
let working = entry("beta", "%2", "working");
let waiting = entry("gamma", "%3", "notify");
let out = format_list(&[
("s1".into(), idle),
("s2".into(), working),
("s3".into(), waiting),
]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3, "every session should be listed; got: {lines:?}");
assert!(lines[0].contains("alpha"));
assert!(lines[1].contains("beta"));
assert!(lines[2].contains("gamma"));
}
#[test]
fn format_list_marks_each_event_with_its_own_bracket() {
let entries = vec![
("s1".into(), entry("a", "%1", "notify")),
("s2".into(), entry("b", "%2", "done")),
("s3".into(), entry("c", "%3", "working")),
("s4".into(), entry("d", "%4", "idle")),
("s5".into(), entry("e", "%5", "some-future-event")),
];
let out = format_list(&entries);
let markers: Vec<&str> = out
.lines()
.map(|l| l.split('\t').nth(2).unwrap().split(' ').next().unwrap())
.collect();
assert_eq!(markers, vec!["[!]", "[*]", "[~]", "[-]", "[?]"]);
}
#[test]
fn format_list_never_leaks_raw_event_word() {
let entries = vec![
("s1".into(), entry("a", "%1", "working")),
("s2".into(), entry("b", "%2", "idle")),
];
let out = format_list(&entries);
for line in out.lines() {
let display = line.split('\t').nth(2).unwrap();
assert!(!display.contains("working"), "display: {display:?}");
assert!(!display.contains("idle"), "display: {display:?}");
}
}
}