diff --git a/crates/pu-cli/src/commands/mod.rs b/crates/pu-cli/src/commands/mod.rs index cb25e00..3cb57ee 100644 --- a/crates/pu-cli/src/commands/mod.rs +++ b/crates/pu-cli/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod init; pub mod kill; pub mod logs; pub mod prompt; +pub mod recap; pub mod schedule; pub mod send; pub mod spawn; diff --git a/crates/pu-cli/src/commands/recap.rs b/crates/pu-cli/src/commands/recap.rs new file mode 100644 index 0000000..cfd27dc --- /dev/null +++ b/crates/pu-cli/src/commands/recap.rs @@ -0,0 +1,17 @@ +use crate::client; +use crate::commands::cwd_string; +use crate::daemon_ctrl; +use crate::error::CliError; +use crate::output; +use pu_core::protocol::Request; +use std::path::Path; + +pub async fn run(socket: &Path, json: bool) -> Result<(), CliError> { + daemon_ctrl::ensure_daemon(socket).await?; + + let project_root = cwd_string()?; + let resp = client::send_request(socket, &Request::Recap { project_root }).await?; + let resp = output::check_response(resp, json)?; + output::print_response(&resp, json); + Ok(()) +} diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index ee8111d..217d74c 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -147,6 +147,12 @@ enum Commands { #[command(subcommand)] action: ScheduleAction, }, + /// Recap workspace activity: agent status + code changes at a glance + Recap { + /// Output as JSON + #[arg(long)] + json: bool, + }, /// Show git diffs across agent worktrees Diff { /// Diff a specific worktree @@ -616,6 +622,7 @@ async fn main() { json, } => commands::send::run(&socket, &agent_id, text, no_enter, keys, json).await, Commands::Grid { action } => commands::grid::run(&socket, action).await, + Commands::Recap { json } => commands::recap::run(&socket, json).await, Commands::Diff { worktree, stat, diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index da59fa8..a1652ea 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -34,6 +34,66 @@ fn status_colored(status: AgentStatus, exit_code: Option) -> String { } } +enum RecapCount { + Streaming, + Waiting, + Done, + Broken, +} + +fn agent_recap_icon(status: AgentStatus, exit_code: Option) -> (String, RecapCount) { + match status { + AgentStatus::Streaming => ("●".green().to_string(), RecapCount::Streaming), + AgentStatus::Waiting => ("◆".cyan().to_string(), RecapCount::Waiting), + AgentStatus::Broken => match exit_code { + Some(0) => ("✓".green().to_string(), RecapCount::Done), + _ => ("✗".red().to_string(), RecapCount::Broken), + }, + } +} + +fn recap_status_label(status: AgentStatus, exit_code: Option, suspended: bool) -> String { + if suspended { + return "suspended".cyan().to_string(); + } + match status { + AgentStatus::Streaming => "streaming".green().to_string(), + AgentStatus::Waiting => "waiting".cyan().to_string(), + AgentStatus::Broken => match exit_code { + Some(0) => "done".dimmed().to_string(), + Some(code) => format!("exit {code}").red().to_string(), + None => "broken".red().to_string(), + }, + } +} + +fn format_duration(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m", secs / 60) + } else { + let h = secs / 3600; + let m = (secs % 3600) / 60; + if m > 0 { + format!("{h}h {m}m") + } else { + format!("{h}h") + } + } +} + +fn truncate_prompt(prompt: &str, max_len: usize) -> String { + let first_line = prompt.lines().next().unwrap_or(""); + let char_count: usize = first_line.chars().count(); + if char_count <= max_len { + first_line.to_string() + } else { + let truncated: String = first_line.chars().take(max_len - 3).collect(); + format!("{truncated}...") + } +} + pub fn print_response(response: &Response, json_mode: bool) { if json_mode { println!( @@ -396,6 +456,131 @@ pub fn print_response(response: &Response, json_mode: bool) { } } } + Response::RecapResult { + worktrees, + root_agents, + } => { + if worktrees.is_empty() && root_agents.is_empty() { + println!("{}", "No agents to recap".dimmed()); + return; + } + + println!("{}", "━━━ Workspace Recap ━━━".bold()); + println!(); + + let mut total_streaming = 0usize; + let mut total_waiting = 0usize; + let mut total_done = 0usize; + let mut total_broken = 0usize; + let mut total_files = 0usize; + let mut total_ins = 0usize; + let mut total_del = 0usize; + + // Root agents + for a in root_agents { + let (icon, counted) = agent_recap_icon(a.status, a.exit_code); + match counted { + RecapCount::Streaming => total_streaming += 1, + RecapCount::Waiting => total_waiting += 1, + RecapCount::Done => total_done += 1, + RecapCount::Broken => total_broken += 1, + } + let duration = format_duration(a.duration_seconds); + let status_label = recap_status_label(a.status, a.exit_code, a.suspended); + println!( + " {} {} ({}) {} {}", + icon, + a.name.bold(), + a.id.dimmed(), + status_label, + duration.dimmed() + ); + println!(" {}", a.agent_type.dimmed()); + if let Some(ref prompt) = a.prompt { + let truncated = truncate_prompt(prompt, 72); + println!(" {}", format!("\"{truncated}\"").dimmed()); + } + } + + // Worktrees + for wt in worktrees { + total_files += wt.files_changed.unwrap_or(0); + total_ins += wt.insertions.unwrap_or(0); + total_del += wt.deletions.unwrap_or(0); + + // Worktree header + println!(" {} ({})", wt.worktree_name.bold(), wt.branch.dimmed()); + + for a in &wt.agents { + let (icon, counted) = agent_recap_icon(a.status, a.exit_code); + match counted { + RecapCount::Streaming => total_streaming += 1, + RecapCount::Waiting => total_waiting += 1, + RecapCount::Done => total_done += 1, + RecapCount::Broken => total_broken += 1, + } + let duration = format_duration(a.duration_seconds); + let status_label = recap_status_label(a.status, a.exit_code, a.suspended); + println!( + " {} {} ({}) {} {}", + icon, + a.name.bold(), + a.id.dimmed(), + status_label, + duration.dimmed() + ); + if let Some(ref prompt) = a.prompt { + let truncated = truncate_prompt(prompt, 72); + println!(" {}", format!("\"{truncated}\"").dimmed()); + } + } + if wt.agents.is_empty() { + println!(" {}", "No agents".dimmed()); + } + match (wt.files_changed, wt.diff_error.as_deref()) { + (Some(f), _) if f > 0 => { + println!( + " {} file(s), {}, {}", + f, + format!("+{}", wt.insertions.unwrap_or(0)).green(), + format!("-{}", wt.deletions.unwrap_or(0)).red() + ); + } + (None, Some(err)) => { + println!(" {}", format!("diff unavailable: {err}").dimmed()); + } + _ => {} + } + } + + // Summary line + println!(); + let mut parts = Vec::new(); + if total_done > 0 { + parts.push(format!("{total_done} done").green().to_string()); + } + if total_streaming > 0 { + parts.push(format!("{total_streaming} streaming").green().to_string()); + } + if total_waiting > 0 { + parts.push(format!("{total_waiting} waiting").cyan().to_string()); + } + if total_broken > 0 { + parts.push(format!("{total_broken} broken").red().to_string()); + } + let summary = parts.join(", "); + if total_files > 0 { + println!( + " {} · {} file(s) · {}, {}", + summary, + total_files, + format!("+{total_ins}").green(), + format!("-{total_del}").red() + ); + } else { + println!(" {summary}"); + } + } Response::DiffResult { diffs } => { if diffs.is_empty() { println!("No worktree diffs"); @@ -939,4 +1124,192 @@ mod tests { }; print_response(&resp, false); } + + // --- recap output --- + + #[test] + fn given_empty_recap_should_not_panic() { + let resp = Response::RecapResult { + worktrees: vec![], + root_agents: vec![], + }; + print_response(&resp, false); + } + + #[test] + fn given_recap_with_worktree_agents_should_not_panic() { + let resp = Response::RecapResult { + worktrees: vec![pu_core::protocol::RecapWorktreeEntry { + worktree_id: "wt-1".into(), + worktree_name: "fix-auth".into(), + branch: "pu/fix-auth".into(), + agents: vec![pu_core::protocol::RecapAgentEntry { + id: "ag-1".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Streaming, + exit_code: None, + prompt: Some("Add user auth".into()), + started_at: chrono::Utc::now(), + completed_at: None, + duration_seconds: 720, + suspended: false, + }], + files_changed: Some(4), + insertions: Some(127), + deletions: Some(23), + diff_error: None, + }], + root_agents: vec![], + }; + print_response(&resp, false); + } + + #[test] + fn given_recap_with_root_agents_should_not_panic() { + let resp = Response::RecapResult { + worktrees: vec![], + root_agents: vec![pu_core::protocol::RecapAgentEntry { + id: "ag-root".into(), + name: "point-guard".into(), + agent_type: "claude".into(), + status: AgentStatus::Broken, + exit_code: Some(0), + prompt: Some("Watch the workspace".into()), + started_at: chrono::Utc::now(), + completed_at: Some(chrono::Utc::now()), + duration_seconds: 3700, + suspended: false, + }], + }; + print_response(&resp, false); + } + + #[test] + fn given_recap_mixed_statuses_should_not_panic() { + let resp = Response::RecapResult { + worktrees: vec![ + pu_core::protocol::RecapWorktreeEntry { + worktree_id: "wt-1".into(), + worktree_name: "feature-a".into(), + branch: "pu/feature-a".into(), + agents: vec![pu_core::protocol::RecapAgentEntry { + id: "ag-1".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Broken, + exit_code: Some(0), + prompt: None, + started_at: chrono::Utc::now(), + completed_at: Some(chrono::Utc::now()), + duration_seconds: 180, + suspended: false, + }], + files_changed: Some(2), + insertions: Some(50), + deletions: Some(10), + diff_error: None, + }, + pu_core::protocol::RecapWorktreeEntry { + worktree_id: "wt-2".into(), + worktree_name: "bugfix-b".into(), + branch: "pu/bugfix-b".into(), + agents: vec![pu_core::protocol::RecapAgentEntry { + id: "ag-2".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Broken, + exit_code: Some(1), + prompt: Some("Fix parser".into()), + started_at: chrono::Utc::now(), + completed_at: Some(chrono::Utc::now()), + duration_seconds: 45, + suspended: false, + }], + files_changed: Some(1), + insertions: Some(8), + deletions: Some(3), + diff_error: None, + }, + ], + root_agents: vec![], + }; + print_response(&resp, false); + } + + #[test] + fn given_recap_json_mode_should_not_panic() { + let resp = Response::RecapResult { + worktrees: vec![], + root_agents: vec![], + }; + print_response(&resp, true); + } + + // --- recap helpers --- + + #[test] + fn given_format_duration_seconds_should_show_s() { + assert_eq!(format_duration(45), "45s"); + } + + #[test] + fn given_format_duration_minutes_should_show_m() { + assert_eq!(format_duration(120), "2m"); + } + + #[test] + fn given_format_duration_hours_should_show_h_m() { + assert_eq!(format_duration(3700), "1h 1m"); + } + + #[test] + fn given_format_duration_exact_hour_should_show_h() { + assert_eq!(format_duration(3600), "1h"); + } + + #[test] + fn given_truncate_prompt_short_should_not_truncate() { + assert_eq!(truncate_prompt("short prompt", 72), "short prompt"); + } + + #[test] + fn given_truncate_prompt_long_should_truncate_with_ellipsis() { + let long_prompt = "a".repeat(100); + let result = truncate_prompt(&long_prompt, 72); + assert_eq!(result.len(), 72); + assert!(result.ends_with("...")); + } + + #[test] + fn given_truncate_prompt_multibyte_should_not_panic() { + // CJK characters are 3 bytes each — this would panic with byte-level slicing + let prompt = "你".repeat(30); // 30 chars, 90 bytes + let result = truncate_prompt(&prompt, 10); + assert!(result.ends_with("...")); + assert_eq!(result.chars().count(), 10); // 7 chars + "..." + } + + #[test] + fn given_truncate_prompt_multiline_should_use_first_line() { + assert_eq!(truncate_prompt("line1\nline2\nline3", 72), "line1"); + } + + #[test] + fn given_agent_recap_icon_streaming_should_show_green_dot() { + let (icon, _) = agent_recap_icon(AgentStatus::Streaming, None); + assert!(icon.contains("●")); + } + + #[test] + fn given_agent_recap_icon_done_should_show_checkmark() { + let (icon, _) = agent_recap_icon(AgentStatus::Broken, Some(0)); + assert!(icon.contains("✓")); + } + + #[test] + fn given_agent_recap_icon_broken_should_show_x() { + let (icon, _) = agent_recap_icon(AgentStatus::Broken, Some(1)); + assert!(icon.contains("✗")); + } } diff --git a/crates/pu-core/src/protocol.rs b/crates/pu-core/src/protocol.rs index d35cf56..995b0da 100644 --- a/crates/pu-core/src/protocol.rs +++ b/crates/pu-core/src/protocol.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::types::{AgentStatus, WorktreeEntry}; -pub const PROTOCOL_VERSION: u32 = 2; +pub const PROTOCOL_VERSION: u32 = 3; /// Serde helper: encode Vec as hex in JSON for binary PTY data. mod hex_bytes { @@ -252,6 +252,9 @@ pub enum Request { #[serde(default)] stat: bool, }, + Recap { + project_root: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -530,6 +533,10 @@ pub enum Response { DiffResult { diffs: Vec, }, + RecapResult { + worktrees: Vec, + root_agents: Vec, + }, Ok, ShuttingDown, Error { @@ -573,6 +580,38 @@ pub struct WorktreeDiffEntry { pub error: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct RecapAgentEntry { + pub id: String, + pub name: String, + pub agent_type: String, + pub status: AgentStatus, + pub exit_code: Option, + pub prompt: Option, + pub started_at: DateTime, + pub completed_at: Option>, + pub duration_seconds: u64, + pub suspended: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct RecapWorktreeEntry { + pub worktree_id: String, + pub worktree_name: String, + pub branch: String, + pub agents: Vec, + /// None means diff stats are unavailable (worktree missing or git error). + /// Some(0) means the worktree is clean. + pub files_changed: Option, + pub insertions: Option, + pub deletions: Option, + /// Human-readable reason when diff stats are unavailable. + #[serde(skip_serializing_if = "Option::is_none")] + pub diff_error: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -949,7 +988,7 @@ mod tests { #[test] fn given_protocol_version_should_be_current() { - assert_eq!(PROTOCOL_VERSION, 2); + assert_eq!(PROTOCOL_VERSION, 3); } // --- GridCommand round-trips --- @@ -2155,4 +2194,118 @@ mod tests { _ => panic!("expected CreateWorktreeResult"), } } + + // --- Recap --- + + #[test] + fn given_recap_request_should_round_trip() { + let req = Request::Recap { + project_root: "/test".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + match parsed { + Request::Recap { project_root } => assert_eq!(project_root, "/test"), + _ => panic!("expected Recap"), + } + } + + #[test] + fn given_recap_agent_entry_should_round_trip() { + let entry = RecapAgentEntry { + id: "ag-1".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Streaming, + exit_code: None, + prompt: Some("fix auth".into()), + started_at: chrono::Utc::now(), + completed_at: None, + duration_seconds: 720, + suspended: false, + }; + let json = serde_json::to_string(&entry).unwrap(); + let parsed: RecapAgentEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.id, "ag-1"); + assert_eq!(parsed.duration_seconds, 720); + assert_eq!(parsed.prompt, Some("fix auth".into())); + } + + #[test] + fn given_recap_worktree_entry_should_round_trip() { + let entry = RecapWorktreeEntry { + worktree_id: "wt-1".into(), + worktree_name: "fix-auth".into(), + branch: "pu/fix-auth".into(), + agents: vec![RecapAgentEntry { + id: "ag-1".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Broken, + exit_code: Some(0), + prompt: None, + started_at: chrono::Utc::now(), + completed_at: Some(chrono::Utc::now()), + duration_seconds: 180, + suspended: false, + }], + files_changed: Some(4), + insertions: Some(127), + deletions: Some(23), + diff_error: None, + }; + let json = serde_json::to_string(&entry).unwrap(); + let parsed: RecapWorktreeEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.worktree_id, "wt-1"); + assert_eq!(parsed.files_changed, Some(4)); + assert_eq!(parsed.insertions, Some(127)); + assert_eq!(parsed.agents.len(), 1); + } + + #[test] + fn given_recap_result_should_round_trip() { + let resp = Response::RecapResult { + worktrees: vec![RecapWorktreeEntry { + worktree_id: "wt-1".into(), + worktree_name: "test".into(), + branch: "pu/test".into(), + agents: vec![], + files_changed: Some(0), + insertions: Some(0), + deletions: Some(0), + diff_error: None, + }], + root_agents: vec![], + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + match parsed { + Response::RecapResult { + worktrees, + root_agents, + } => { + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].worktree_id, "wt-1"); + assert!(root_agents.is_empty()); + } + _ => panic!("expected RecapResult"), + } + } + + #[test] + fn given_empty_recap_result_should_round_trip() { + let resp = Response::RecapResult { + worktrees: vec![], + root_agents: vec![], + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + assert!(matches!( + parsed, + Response::RecapResult { + worktrees, + root_agents, + } if worktrees.is_empty() && root_agents.is_empty() + )); + } } diff --git a/crates/pu-engine/src/engine.rs b/crates/pu-engine/src/engine.rs index 33fdcf3..f889060 100644 --- a/crates/pu-engine/src/engine.rs +++ b/crates/pu-engine/src/engine.rs @@ -168,7 +168,8 @@ impl Engine { | Request::SaveSchedule { project_root, .. } | Request::EnableSchedule { project_root, .. } | Request::DisableSchedule { project_root, .. } - | Request::Diff { project_root, .. } => { + | Request::Diff { project_root, .. } + | Request::Recap { project_root, .. } => { self.register_project(project_root); } _ => {} @@ -427,6 +428,7 @@ impl Engine { self.handle_diff(&project_root, worktree_id.as_deref(), stat) .await } + Request::Recap { project_root } => self.handle_recap(&project_root).await, } } @@ -3155,6 +3157,91 @@ impl Engine { } } + async fn handle_recap(&self, project_root: &str) -> Response { + let m = match self.read_manifest_async(project_root).await { + Ok(m) => m, + Err(e) => return Self::error_response(&e), + }; + + let now = chrono::Utc::now(); + let sessions = self.sessions.lock().await; + + // Helper: build RecapAgentEntry using live/normalized status + let build_recap_agent = + |a: &pu_core::types::AgentEntry| -> pu_core::protocol::RecapAgentEntry { + let (status, exit_code, _idle) = self.live_agent_status_sync(&a.id, a, &sessions); + let completed_at = if status == AgentStatus::Broken { + a.completed_at.or(Some(now)) + } else { + None + }; + let end = completed_at.unwrap_or(now); + let duration = (end - a.started_at).num_seconds().max(0) as u64; + pu_core::protocol::RecapAgentEntry { + id: a.id.clone(), + name: a.name.clone(), + agent_type: a.agent_type.clone(), + status, + exit_code, + prompt: a.prompt.clone(), + started_at: a.started_at, + completed_at, + duration_seconds: duration, + suspended: a.suspended, + } + }; + + // Build recap for each active worktree + let mut worktrees = Vec::new(); + for wt in m.worktrees.values() { + if wt.status != WorktreeStatus::Active { + continue; + } + let agents: Vec = + wt.agents.values().map(&build_recap_agent).collect(); + + // Get diff stats (best-effort) + let wt_path = std::path::PathBuf::from(&wt.path); + let (files_changed, insertions, deletions, diff_error) = if wt_path.exists() { + let base = wt.base_branch.as_deref(); + match git::diff_worktree(&wt_path, base, true).await { + Ok(output) => ( + Some(output.files_changed), + Some(output.insertions), + Some(output.deletions), + None, + ), + Err(e) => (None, None, None, Some(format!("git diff failed: {e}"))), + } + } else { + (None, None, None, Some("worktree path not found".into())) + }; + + worktrees.push(pu_core::protocol::RecapWorktreeEntry { + worktree_id: wt.id.clone(), + worktree_name: wt.name.clone(), + branch: wt.branch.clone(), + agents, + files_changed, + insertions, + deletions, + diff_error, + }); + } + + // Build recap for root-level agents + let root_agents: Vec = + m.agents.values().map(&build_recap_agent).collect(); + + // Drop session lock before returning + drop(sessions); + + Response::RecapResult { + worktrees, + root_agents, + } + } + async fn handle_diff( &self, project_root: &str,