From e48ea7d331d797be6da8ee3ad095f21dd3f79ba1 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 7 Mar 2026 22:05:15 -0600 Subject: [PATCH 1/4] feat: Add `pu recap` command for workspace activity summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When you dispatch multiple agents and step away, the first question on return is "what happened?" — `pu recap` answers it instantly by combining agent status with git diff stats into a single structured view. Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/commands/mod.rs | 1 + crates/pu-cli/src/commands/recap.rs | 17 ++ crates/pu-cli/src/main.rs | 7 + crates/pu-cli/src/output.rs | 347 ++++++++++++++++++++++++++++ crates/pu-core/src/protocol.rs | 146 ++++++++++++ crates/pu-engine/src/engine.rs | 90 +++++++- 6 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 crates/pu-cli/src/commands/recap.rs 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..f9697c4 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -34,6 +34,64 @@ 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(""); + if first_line.len() <= max_len { + first_line.to_string() + } else { + format!("{}...", &first_line[..max_len - 3]) + } +} + pub fn print_response(response: &Response, json_mode: bool) { if json_mode { println!( @@ -396,6 +454,119 @@ 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; + total_ins += wt.insertions; + total_del += wt.deletions; + + 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, + wt.worktree_name.bold(), + a.id.dimmed(), + status_label, + duration.dimmed() + ); + if wt.files_changed > 0 { + println!( + " {} file(s), {}, {}", + wt.files_changed, + format!("+{}", wt.insertions).green(), + format!("-{}", wt.deletions).red() + ); + } + if let Some(ref prompt) = a.prompt { + let truncated = truncate_prompt(prompt, 72); + println!(" {}", format!("\"{truncated}\"").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 +1110,180 @@ 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: 4, + insertions: 127, + deletions: 23, + }], + 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: 2, + insertions: 50, + deletions: 10, + }, + 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: 1, + insertions: 8, + deletions: 3, + }, + ], + 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_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..1fc56a4 100644 --- a/crates/pu-core/src/protocol.rs +++ b/crates/pu-core/src/protocol.rs @@ -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,33 @@ 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, + pub files_changed: usize, + pub insertions: usize, + pub deletions: usize, +} + #[cfg(test)] mod tests { use super::*; @@ -2155,4 +2189,116 @@ 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: 4, + insertions: 127, + deletions: 23, + }; + 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, 4); + assert_eq!(parsed.insertions, 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: 0, + insertions: 0, + deletions: 0, + }], + 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..98a450b 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,92 @@ 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(); + + // 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(|a| { + let end = a.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: a.status, + exit_code: a.exit_code, + prompt: a.prompt.clone(), + started_at: a.started_at, + completed_at: a.completed_at, + duration_seconds: duration, + suspended: a.suspended, + } + }) + .collect(); + + // Get diff stats (best-effort) + let wt_path = std::path::PathBuf::from(&wt.path); + let (files_changed, insertions, deletions) = if wt_path.exists() { + let base = wt.base_branch.as_deref(); + match git::diff_worktree(&wt_path, base, true).await { + Ok(output) => (output.files_changed, output.insertions, output.deletions), + Err(_) => (0, 0, 0), + } + } else { + (0, 0, 0) + }; + + 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, + }); + } + + // Build recap for root-level agents + let root_agents: Vec = m + .agents + .values() + .map(|a| { + let end = a.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: a.status, + exit_code: a.exit_code, + prompt: a.prompt.clone(), + started_at: a.started_at, + completed_at: a.completed_at, + duration_seconds: duration, + suspended: a.suspended, + } + }) + .collect(); + + Response::RecapResult { + worktrees, + root_agents, + } + } + async fn handle_diff( &self, project_root: &str, From 2c9a64ee995d401708daee886f63f03b4bf77507 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 8 Mar 2026 10:00:55 -0500 Subject: [PATCH 2/4] fix: Use char-level truncation in truncate_prompt to avoid UTF-8 panic Byte-level slicing on &str panics when the boundary falls mid-character. Switch to chars().take() for safe truncation of user-supplied prompts. Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/output.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index f9697c4..9660b57 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -85,10 +85,12 @@ fn format_duration(secs: u64) -> String { fn truncate_prompt(prompt: &str, max_len: usize) -> String { let first_line = prompt.lines().next().unwrap_or(""); - if first_line.len() <= max_len { + let char_count: usize = first_line.chars().count(); + if char_count <= max_len { first_line.to_string() } else { - format!("{}...", &first_line[..max_len - 3]) + let truncated: String = first_line.chars().take(max_len - 3).collect(); + format!("{truncated}...") } } @@ -1264,6 +1266,15 @@ mod tests { 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"); From dd2d0ba34f96ecc0d7b9df9de80ab6c46b626326 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 8 Mar 2026 13:59:20 -0500 Subject: [PATCH 3/4] fix: Print worktree diff stats once per worktree, not per agent Diff stats (files changed, insertions, deletions) were printed inside the agent loop, duplicating them when a worktree had multiple agents. Move the stats block outside the agent loop so they appear once. Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/output.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index 9660b57..ed11bda 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -526,19 +526,19 @@ pub fn print_response(response: &Response, json_mode: bool) { status_label, duration.dimmed() ); - if wt.files_changed > 0 { - println!( - " {} file(s), {}, {}", - wt.files_changed, - format!("+{}", wt.insertions).green(), - format!("-{}", wt.deletions).red() - ); - } if let Some(ref prompt) = a.prompt { let truncated = truncate_prompt(prompt, 72); println!(" {}", format!("\"{truncated}\"").dimmed()); } } + if wt.files_changed > 0 { + println!( + " {} file(s), {}, {}", + wt.files_changed, + format!("+{}", wt.insertions).green(), + format!("-{}", wt.deletions).red() + ); + } } // Summary line From 63e5fd212f29f9c7bccff6fbd7001bc59c4adeb8 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 8 Mar 2026 14:22:08 -0500 Subject: [PATCH 4/4] fix: Address CodeRabbit review feedback for recap command - Bump PROTOCOL_VERSION from 2 to 3 for new recap wire contract - Make diff counters Optional to distinguish clean vs unavailable - Add diff_error field for human-readable failure reasons - Use normalized live agent status via live_agent_status_sync() instead of raw manifest fields, so recap reflects actual PTY state - Print worktree header once per worktree, show agent name per row - Handle zero-agent worktrees with "No agents" placeholder - Show diff error messages when stats are unavailable Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/output.rs | 59 ++++++++++++++--------- crates/pu-core/src/protocol.rs | 33 ++++++++----- crates/pu-engine/src/engine.rs | 87 +++++++++++++++++----------------- 3 files changed, 100 insertions(+), 79 deletions(-) diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index ed11bda..a1652ea 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -504,9 +504,12 @@ pub fn print_response(response: &Response, json_mode: bool) { // Worktrees for wt in worktrees { - total_files += wt.files_changed; - total_ins += wt.insertions; - total_del += wt.deletions; + 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); @@ -519,25 +522,34 @@ pub fn print_response(response: &Response, json_mode: bool) { let duration = format_duration(a.duration_seconds); let status_label = recap_status_label(a.status, a.exit_code, a.suspended); println!( - " {} {} ({}) {} {}", + " {} {} ({}) {} {}", icon, - wt.worktree_name.bold(), + 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()); + println!(" {}", format!("\"{truncated}\"").dimmed()); } } - if wt.files_changed > 0 { - println!( - " {} file(s), {}, {}", - wt.files_changed, - format!("+{}", wt.insertions).green(), - format!("-{}", wt.deletions).red() - ); + 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()); + } + _ => {} } } @@ -1143,9 +1155,10 @@ mod tests { duration_seconds: 720, suspended: false, }], - files_changed: 4, - insertions: 127, - deletions: 23, + files_changed: Some(4), + insertions: Some(127), + deletions: Some(23), + diff_error: None, }], root_agents: vec![], }; @@ -1192,9 +1205,10 @@ mod tests { duration_seconds: 180, suspended: false, }], - files_changed: 2, - insertions: 50, - deletions: 10, + files_changed: Some(2), + insertions: Some(50), + deletions: Some(10), + diff_error: None, }, pu_core::protocol::RecapWorktreeEntry { worktree_id: "wt-2".into(), @@ -1212,9 +1226,10 @@ mod tests { duration_seconds: 45, suspended: false, }], - files_changed: 1, - insertions: 8, - deletions: 3, + files_changed: Some(1), + insertions: Some(8), + deletions: Some(3), + diff_error: None, }, ], root_agents: vec![], diff --git a/crates/pu-core/src/protocol.rs b/crates/pu-core/src/protocol.rs index 1fc56a4..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 { @@ -602,9 +602,14 @@ pub struct RecapWorktreeEntry { pub worktree_name: String, pub branch: String, pub agents: Vec, - pub files_changed: usize, - pub insertions: usize, - pub deletions: usize, + /// 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)] @@ -983,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 --- @@ -2244,15 +2249,16 @@ mod tests { duration_seconds: 180, suspended: false, }], - files_changed: 4, - insertions: 127, - deletions: 23, + 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, 4); - assert_eq!(parsed.insertions, 127); + assert_eq!(parsed.files_changed, Some(4)); + assert_eq!(parsed.insertions, Some(127)); assert_eq!(parsed.agents.len(), 1); } @@ -2264,9 +2270,10 @@ mod tests { worktree_name: "test".into(), branch: "pu/test".into(), agents: vec![], - files_changed: 0, - insertions: 0, - deletions: 0, + files_changed: Some(0), + insertions: Some(0), + deletions: Some(0), + diff_error: None, }], root_agents: vec![], }; diff --git a/crates/pu-engine/src/engine.rs b/crates/pu-engine/src/engine.rs index 98a450b..f889060 100644 --- a/crates/pu-engine/src/engine.rs +++ b/crates/pu-engine/src/engine.rs @@ -3164,6 +3164,32 @@ impl Engine { }; 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(); @@ -3171,37 +3197,24 @@ impl Engine { if wt.status != WorktreeStatus::Active { continue; } - let agents: Vec = wt - .agents - .values() - .map(|a| { - let end = a.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: a.status, - exit_code: a.exit_code, - prompt: a.prompt.clone(), - started_at: a.started_at, - completed_at: a.completed_at, - duration_seconds: duration, - suspended: a.suspended, - } - }) - .collect(); + 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) = if wt_path.exists() { + 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) => (output.files_changed, output.insertions, output.deletions), - Err(_) => (0, 0, 0), + 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 { - (0, 0, 0) + (None, None, None, Some("worktree path not found".into())) }; worktrees.push(pu_core::protocol::RecapWorktreeEntry { @@ -3212,30 +3225,16 @@ impl Engine { files_changed, insertions, deletions, + diff_error, }); } // Build recap for root-level agents - let root_agents: Vec = m - .agents - .values() - .map(|a| { - let end = a.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: a.status, - exit_code: a.exit_code, - prompt: a.prompt.clone(), - started_at: a.started_at, - completed_at: a.completed_at, - duration_seconds: duration, - suspended: a.suspended, - } - }) - .collect(); + let root_agents: Vec = + m.agents.values().map(&build_recap_agent).collect(); + + // Drop session lock before returning + drop(sessions); Response::RecapResult { worktrees,