diff --git a/Cargo.lock b/Cargo.lock index 516f9d1..d877e71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,9 +249,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", @@ -360,9 +360,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libyml" @@ -529,18 +529,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" @@ -681,12 +681,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -766,9 +766,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -852,9 +852,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom", "js-sys", @@ -978,9 +978,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.0" +version = "8.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +checksum = "3a824aeba0fbb27264f815ada4cff43d65b1741b7a4ed7629ff9089148c4a4e0" dependencies = [ "env_home", "rustix", @@ -1052,16 +1052,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -1079,31 +1070,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -1112,96 +1086,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winsafe" version = "0.0.19" diff --git a/crates/pu-cli/src/commands/mod.rs b/crates/pu-cli/src/commands/mod.rs index cb25e00..a2c5a43 100644 --- a/crates/pu-cli/src/commands/mod.rs +++ b/crates/pu-cli/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod send; pub mod spawn; pub mod status; pub mod swarm; +pub mod watch; use std::collections::HashMap; diff --git a/crates/pu-cli/src/commands/watch.rs b/crates/pu-cli/src/commands/watch.rs new file mode 100644 index 0000000..e410774 --- /dev/null +++ b/crates/pu-cli/src/commands/watch.rs @@ -0,0 +1,452 @@ +use crate::client; +use crate::commands::cwd_string; +use crate::daemon_ctrl; +use crate::error::CliError; +use chrono::Utc; +use owo_colors::OwoColorize; +use pu_core::protocol::{AgentStatusReport, Response}; +use pu_core::types::{AgentStatus, WorktreeEntry}; +use std::io::Write; +use std::path::Path; + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const REFRESH_MS: u64 = 800; + +/// RAII guard that restores the terminal on drop (including panics). +struct TermGuard; + +impl TermGuard { + fn enter() -> Self { + // Hide cursor + enter alt screen + print!("\x1b[?1049h\x1b[?25l"); + let _ = std::io::stdout().flush(); + Self + } +} + +impl Drop for TermGuard { + fn drop(&mut self) { + // Restore cursor + leave alt screen + print!("\x1b[?25h\x1b[?1049l"); + let _ = std::io::stdout().flush(); + } +} + +pub async fn run(socket: &Path, interval: Option) -> Result<(), CliError> { + daemon_ctrl::ensure_daemon(socket).await?; + + let refresh_ms = interval.unwrap_or(REFRESH_MS); + if refresh_ms == 0 { + return Err(CliError::Other("--interval must be at least 1ms".into())); + } + let refresh = std::time::Duration::from_millis(refresh_ms); + let mut tick: usize = 0; + let mut stdout = std::io::stdout(); + let project_root = cwd_string()?; + + let _guard = TermGuard::enter(); + + loop { + match fetch_status(socket, &project_root).await { + Ok((worktrees, agents)) => { + render_dashboard(&mut stdout, &worktrees, &agents, tick)?; + } + Err(CliError::DaemonNotRunning) => { + render_no_daemon(&mut stdout)?; + } + Err(e) => { + render_error(&mut stdout, &e.to_string())?; + } + } + tick = tick.wrapping_add(1); + + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + _ = tokio::time::sleep(refresh) => {} + } + } + + Ok(()) +} + +async fn fetch_status( + socket: &Path, + project_root: &str, +) -> Result<(Vec, Vec), CliError> { + let resp = client::send_request( + socket, + &pu_core::protocol::Request::Status { + project_root: project_root.to_string(), + agent_id: None, + }, + ) + .await?; + + match resp { + Response::StatusReport { worktrees, agents } => Ok((worktrees, agents)), + Response::Error { code, message } => Err(CliError::DaemonError { code, message }), + _ => Err(CliError::Other("unexpected response".into())), + } +} + +fn render_dashboard( + stdout: &mut std::io::Stdout, + worktrees: &[WorktreeEntry], + root_agents: &[AgentStatusReport], + tick: usize, +) -> Result<(), CliError> { + let mut buf = String::with_capacity(4096); + + // Move cursor to top-left, clear screen + buf.push_str("\x1b[H\x1b[2J"); + + // Header + let now = Utc::now().format("%H:%M:%S"); + buf.push_str(&format!( + " {} {}\n", + "pu watch".bold(), + now.to_string().dimmed() + )); + + // Count agents across all worktrees + root + let mut streaming = 0usize; + let mut waiting = 0usize; + let mut done = 0usize; + let mut broken = 0usize; + + for a in root_agents { + count_agent( + a.status, + a.exit_code, + &mut streaming, + &mut waiting, + &mut done, + &mut broken, + ); + } + for wt in worktrees { + for a in wt.agents.values() { + count_agent( + a.status, + a.exit_code, + &mut streaming, + &mut waiting, + &mut done, + &mut broken, + ); + } + } + + let total = streaming + waiting + done + broken; + + // Summary bar + buf.push_str(&format!( + " {} agents {} {} {} {}\n\n", + total.to_string().bold(), + if streaming > 0 { + format!("{streaming} streaming").green().to_string() + } else { + format!("{streaming} streaming").dimmed().to_string() + }, + if waiting > 0 { + format!("{waiting} waiting").cyan().to_string() + } else { + format!("{waiting} waiting").dimmed().to_string() + }, + format!("{done} done").dimmed(), + if broken > 0 { + format!("{broken} broken").red().to_string() + } else { + format!("{broken} broken").dimmed().to_string() + }, + )); + + if total == 0 { + buf.push_str(&format!( + " {}\n", + "No agents running. Use `pu spawn` to start one.".dimmed() + )); + } + + // Root agents + if !root_agents.is_empty() { + buf.push_str(&format!(" {}\n", "Root agents".bold().underline())); + render_agent_table( + &mut buf, + root_agents.iter().map(|a| AgentView { + id: &a.id, + name: &a.name, + status: a.status, + exit_code: a.exit_code, + started_at: a.started_at, + prompt: a.prompt.as_deref(), + suspended: a.suspended, + }), + tick, + ); + buf.push('\n'); + } + + // Worktrees + for wt in worktrees { + let wt_status = format!("{:?}", wt.status); + buf.push_str(&format!( + " {} {} {}\n", + wt.name.bold().underline(), + wt.branch.dimmed(), + wt_status.dimmed() + )); + + if wt.agents.is_empty() { + buf.push_str(&format!(" {}\n", "(no agents)".dimmed())); + } else { + render_agent_table( + &mut buf, + wt.agents.values().map(|a| AgentView { + id: &a.id, + name: &a.name, + status: a.status, + exit_code: a.exit_code, + started_at: a.started_at, + prompt: a.prompt.as_deref(), + suspended: a.suspended, + }), + tick, + ); + } + buf.push('\n'); + } + + // Footer + buf.push_str(&format!(" {}\n", "Press Ctrl+C to exit".dimmed())); + + print!("{buf}"); + stdout.flush().map_err(CliError::Io) +} + +struct AgentView<'a> { + id: &'a str, + name: &'a str, + status: AgentStatus, + exit_code: Option, + started_at: chrono::DateTime, + prompt: Option<&'a str>, + suspended: bool, +} + +fn render_agent_table<'a>( + buf: &mut String, + agents: impl Iterator>, + tick: usize, +) { + for agent in agents { + let spinner = SPINNER_FRAMES[tick % SPINNER_FRAMES.len()]; + + let (indicator, status_str) = match agent.status { + AgentStatus::Streaming if agent.suspended => { + ("⏸".to_string(), "suspended".yellow().to_string()) + } + AgentStatus::Streaming => { + (spinner.green().to_string(), "streaming".green().to_string()) + } + AgentStatus::Waiting if agent.suspended => { + ("⏸".to_string(), "suspended".yellow().to_string()) + } + AgentStatus::Waiting => ("●".cyan().to_string(), "waiting".cyan().to_string()), + AgentStatus::Broken => match agent.exit_code { + Some(0) => ("✓".dimmed().to_string(), "done".dimmed().to_string()), + _ => ("✗".red().to_string(), "broken".red().to_string()), + }, + }; + + let elapsed = format_elapsed(agent.started_at); + + let prompt_preview = agent + .prompt + .map(|p| { + let sanitized = sanitize_prompt(p); + if sanitized.chars().count() > 50 { + let truncated: String = sanitized.chars().take(49).collect(); + format!("{truncated}…") + } else { + sanitized + } + }) + .unwrap_or_default(); + + buf.push_str(&format!( + " {} {:<12} {:<12} {:<10} {:<8} {}\n", + indicator, + agent.id.dimmed(), + agent.name, + status_str, + elapsed.dimmed(), + prompt_preview.dimmed(), + )); + } +} + +fn format_elapsed(started_at: chrono::DateTime) -> String { + let elapsed = Utc::now().signed_duration_since(started_at); + let secs = elapsed.num_seconds().max(0); + + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m{}s", secs / 60, secs % 60) + } else { + format!("{}h{}m", secs / 3600, (secs % 3600) / 60) + } +} + +fn count_agent( + status: AgentStatus, + exit_code: Option, + streaming: &mut usize, + waiting: &mut usize, + done: &mut usize, + broken: &mut usize, +) { + match status { + AgentStatus::Streaming => *streaming += 1, + AgentStatus::Waiting => *waiting += 1, + AgentStatus::Broken => match exit_code { + Some(0) => *done += 1, + _ => *broken += 1, + }, + } +} + +/// Strip ANSI escape sequences and control characters, collapse whitespace. +fn sanitize_prompt(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' { + // Skip CSI sequences: ESC [ + if chars.peek() == Some(&'[') { + chars.next(); + while let Some(&next) = chars.peek() { + chars.next(); + if next.is_ascii_alphabetic() { + break; + } + } + } + result.push(' '); + } else if c.is_control() { + result.push(' '); + } else { + result.push(c); + } + } + result.split_whitespace().collect::>().join(" ") +} + +fn render_no_daemon(stdout: &mut std::io::Stdout) -> Result<(), CliError> { + print!("\x1b[H\x1b[2J"); + print!( + " {} {}\n\n {}\n", + "pu watch".bold(), + Utc::now().format("%H:%M:%S").to_string().dimmed(), + "Daemon not running. Waiting...".yellow(), + ); + stdout.flush().map_err(CliError::Io) +} + +fn render_error(stdout: &mut std::io::Stdout, err: &str) -> Result<(), CliError> { + print!("\x1b[H\x1b[2J"); + print!( + " {} {}\n\n {} {}\n", + "pu watch".bold(), + Utc::now().format("%H:%M:%S").to_string().dimmed(), + "Error:".red().bold(), + err, + ); + stdout.flush().map_err(CliError::Io) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_recent_start_time_format_elapsed_should_show_seconds() { + let started = Utc::now() - chrono::Duration::seconds(42); + let result = format_elapsed(started); + assert_eq!(result, "42s"); + } + + #[test] + fn given_minutes_elapsed_should_show_minutes_and_seconds() { + let started = Utc::now() - chrono::Duration::seconds(125); + let result = format_elapsed(started); + assert_eq!(result, "2m5s"); + } + + #[test] + fn given_hours_elapsed_should_show_hours_and_minutes() { + let started = Utc::now() - chrono::Duration::seconds(7380); + let result = format_elapsed(started); + assert_eq!(result, "2h3m"); + } + + #[test] + fn given_zero_seconds_should_show_0s() { + let started = Utc::now(); + let result = format_elapsed(started); + assert_eq!(result, "0s"); + } + + #[test] + fn given_streaming_status_should_count_as_streaming() { + let (mut s, mut w, mut d, mut b) = (0, 0, 0, 0); + count_agent(AgentStatus::Streaming, None, &mut s, &mut w, &mut d, &mut b); + assert_eq!((s, w, d, b), (1, 0, 0, 0)); + } + + #[test] + fn given_waiting_status_should_count_as_waiting() { + let (mut s, mut w, mut d, mut b) = (0, 0, 0, 0); + count_agent(AgentStatus::Waiting, None, &mut s, &mut w, &mut d, &mut b); + assert_eq!((s, w, d, b), (0, 1, 0, 0)); + } + + #[test] + fn given_broken_with_exit_0_should_count_as_done() { + let (mut s, mut w, mut d, mut b) = (0, 0, 0, 0); + count_agent(AgentStatus::Broken, Some(0), &mut s, &mut w, &mut d, &mut b); + assert_eq!((s, w, d, b), (0, 0, 1, 0)); + } + + #[test] + fn given_broken_with_nonzero_exit_should_count_as_broken() { + let (mut s, mut w, mut d, mut b) = (0, 0, 0, 0); + count_agent(AgentStatus::Broken, Some(1), &mut s, &mut w, &mut d, &mut b); + assert_eq!((s, w, d, b), (0, 0, 0, 1)); + } + + #[test] + fn given_broken_with_no_exit_code_should_count_as_broken() { + let (mut s, mut w, mut d, mut b) = (0, 0, 0, 0); + count_agent(AgentStatus::Broken, None, &mut s, &mut w, &mut d, &mut b); + assert_eq!((s, w, d, b), (0, 0, 0, 1)); + } + + #[test] + fn given_prompt_with_control_chars_sanitize_should_strip_them() { + let input = "hello\x1b[31mworld\rfoo\nbar"; + let result = sanitize_prompt(input); + assert_eq!(result, "hello world foo bar"); + } + + #[test] + fn given_prompt_with_only_whitespace_sanitize_should_return_empty() { + assert_eq!(sanitize_prompt(" \n\t\r "), ""); + } + + #[test] + fn given_clean_prompt_sanitize_should_preserve_content() { + assert_eq!(sanitize_prompt("fix the auth bug"), "fix the auth bug"); + } +} diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index ee8111d..ccce1ae 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -159,6 +159,12 @@ enum Commands { #[arg(long)] json: bool, }, + /// Live dashboard showing all agents in real-time + Watch { + /// Refresh interval in milliseconds (default: 800) + #[arg(long)] + interval: Option, + }, /// Remove worktrees, their agents, and branches Clean { /// Remove a specific worktree @@ -621,6 +627,7 @@ async fn main() { stat, json, } => commands::diff::run(&socket, worktree, stat, json).await, + Commands::Watch { interval } => commands::watch::run(&socket, interval).await, Commands::Clean { worktree, all,