diff --git a/server/src/matrix/htop.rs b/server/src/matrix/htop.rs index 164ca29..28b5099 100644 --- a/server/src/matrix/htop.rs +++ b/server/src/matrix/htop.rs @@ -201,6 +201,10 @@ fn gather_process_stats(worktree_path: &str) -> Option { /// /// `tick` is the number of updates sent so far (0 = initial). /// `total_duration_secs` is the configured auto-stop timeout. +/// +/// Output uses a compact single-line format per agent so it renders +/// without wrapping on narrow screens (~40 chars), such as mobile +/// Matrix clients. pub fn build_htop_message(agents: &AgentPool, tick: u32, total_duration_secs: u64) -> String { let elapsed_secs = (tick as u64) * 5; let remaining_secs = total_duration_secs.saturating_sub(elapsed_secs); @@ -210,11 +214,11 @@ pub fn build_htop_message(agents: &AgentPool, tick: u32, total_duration_secs: u6 let load = get_load_average(); let mut lines = vec![ - format!("**htop** — {load}"), format!( - "*Updates every 5s · auto-stops in {}m{}s · send `htop stop` to stop*", + "**htop** · auto-stops in {}m{}s", remaining_mins, remaining_secs_rem ), + load, String::new(), ]; @@ -227,8 +231,6 @@ pub fn build_htop_message(agents: &AgentPool, tick: u32, total_duration_secs: u6 if active.is_empty() { lines.push("*No agents currently running.*".to_string()); } else { - lines.push("| Agent | Story | CPU% | MEM% | Procs |".to_string()); - lines.push("|-------|-------|-----:|-----:|------:|".to_string()); for agent in &active { let story_label = agent .story_id @@ -242,12 +244,8 @@ pub fn build_htop_message(agents: &AgentPool, tick: u32, total_duration_secs: u6 .and_then(gather_process_stats) .unwrap_or_default(); lines.push(format!( - "| {} | {} | {:.1} | {:.1} | {} |", - agent.agent_name, - story_label, - stats.cpu_pct, - stats.mem_pct, - stats.num_procs, + "**{}** #{} cpu:{:.1}% mem:{:.1}%", + agent.agent_name, story_label, stats.cpu_pct, stats.mem_pct, )); } } @@ -569,4 +567,55 @@ mod tests { "should show remaining time: {text}" ); } + + #[test] + fn build_htop_message_load_on_own_line() { + // Load average must be on its own line, not combined with the htop header. + let pool = Arc::new(crate::agents::AgentPool::new_test(3000)); + let text = build_htop_message(&pool, 0, 300); + let lines: Vec<&str> = text.lines().collect(); + let header_line = lines.first().expect("should have a header line"); + // Header line must NOT contain "load" — load is on the second line. + assert!( + !header_line.contains("load"), + "load should be on its own line, not the header: {header_line}" + ); + // Second line must contain "load". + let load_line = lines.get(1).expect("should have a load line"); + assert!( + load_line.contains("load"), + "second line should contain load info: {load_line}" + ); + } + + #[test] + fn build_htop_message_no_table_syntax() { + // Must not use Markdown table format (pipes/separators) — those are too + // wide for narrow mobile screens. + let pool = Arc::new(crate::agents::AgentPool::new_test(3000)); + let text = build_htop_message(&pool, 0, 300); + assert!( + !text.contains("|----"), + "output must not contain table separator rows: {text}" + ); + assert!( + !text.contains("| Agent"), + "output must not contain table header row: {text}" + ); + } + + #[test] + fn build_htop_message_header_fits_40_chars() { + // The header line (htop + remaining time) must fit in ~40 rendered chars. + let pool = Arc::new(crate::agents::AgentPool::new_test(3000)); + let text = build_htop_message(&pool, 0, 300); + let header = text.lines().next().expect("should have a header line"); + // Strip markdown bold markers (**) for length calculation. + let rendered = header.replace("**", ""); + assert!( + rendered.len() <= 40, + "header line too wide for mobile ({} chars): {rendered}", + rendered.len() + ); + } }