Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/pu-cli/src/commands/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub async fn run_bench(
}
}

output::print_response(&resp, json);
output::print_response(&resp, json)?;
Ok(())
}

Expand All @@ -75,6 +75,6 @@ pub async fn run_play(socket: &Path, agent_id: &str, json: bool) -> Result<(), C
)
.await?;
let resp = output::check_response(resp, json)?;
output::print_response(&resp, json);
output::print_response(&resp, json)?;
Ok(())
}
1 change: 1 addition & 0 deletions crates/pu-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod init;
pub mod kill;
pub mod logs;
pub mod prompt;
pub mod pulse;
pub mod schedule;
pub mod send;
pub mod spawn;
Expand Down
16 changes: 16 additions & 0 deletions crates/pu-cli/src/commands/pulse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use crate::client;
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 = crate::commands::cwd_string()?;
let resp = client::send_request(socket, &Request::Pulse { project_root }).await?;
let resp = output::check_response(resp, json)?;
output::print_response(&resp, json)?;
Ok(())
}
7 changes: 7 additions & 0 deletions crates/pu-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ enum Commands {
#[command(subcommand)]
action: ScheduleAction,
},
/// Workspace pulse — agents, runtimes, and git stats at a glance
Pulse {
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Show git diffs across agent worktrees
Diff {
/// Diff a specific worktree
Expand Down Expand Up @@ -653,6 +659,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::Pulse { json } => commands::pulse::run(&socket, json).await,
Commands::Diff {
worktree,
stat,
Expand Down
186 changes: 185 additions & 1 deletion crates/pu-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,46 @@ fn status_colored_with_suspended(
}
}

fn format_duration(seconds: i64) -> String {
if seconds < 60 {
format!("{seconds}s")
} else if seconds < 3600 {
format!("{}m {}s", seconds / 60, seconds % 60)
} else {
let h = seconds / 3600;
let m = (seconds % 3600) / 60;
format!("{h}h {m}m")
}
}

fn print_agent_pulse(a: &pu_core::protocol::AgentPulseEntry) {
let status_str = status_colored(a.status, a.exit_code);
let runtime = format_duration(a.runtime_seconds);
let idle = a
.idle_seconds
.map(|s| {
if s > 0 {
format!(" idle {}", format_duration(s as i64))
} else {
String::new()
}
})
.unwrap_or_default();

println!(
" {} {} {} ({}{}){}",
a.id.dimmed(),
a.name,
status_str,
runtime.dimmed(),
idle.dimmed(),
a.prompt_snippet
.as_ref()
.map(|s| format!("\n {}", s.dimmed()))
.unwrap_or_default()
);
}

pub fn print_response(response: &Response, json_mode: bool) -> Result<(), CliError> {
if json_mode {
println!("{}", serde_json::to_string_pretty(response)?);
Expand Down Expand Up @@ -443,6 +483,66 @@ pub fn print_response(response: &Response, json_mode: bool) -> Result<(), CliErr
}
}
}
Response::PulseReport {
worktrees,
root_agents,
} => {
if worktrees.is_empty() && root_agents.is_empty() {
println!("{}", "No active workspace".dimmed());
return Ok(());
}

// Root-level agents
if !root_agents.is_empty() {
println!("{}", "Root Agents".bold().underline());
for a in root_agents {
print_agent_pulse(a);
}
if !worktrees.is_empty() {
println!();
}
}

for (i, wt) in worktrees.iter().enumerate() {
if i > 0 {
println!();
}
// Worktree header with elapsed time
let elapsed = format_duration(wt.elapsed_seconds);
println!(
"{} {} {} ({})",
"Worktree".bold(),
wt.worktree_name.bold(),
wt.branch.green(),
elapsed.dimmed()
);

// Git stats
if let Some(ref err) = wt.diff_error {
println!(" git: {} {}", "error".red(), err);
} else if wt.files_changed > 0 {
println!(
" git: {} file(s), {} {}, {} {}",
wt.files_changed.to_string().bold(),
format!("+{}", wt.insertions).green(),
"ins".dimmed(),
format!("-{}", wt.deletions).red(),
"del".dimmed()
);
} else {
println!(" git: {}", "no changes yet".dimmed());
}

// Agents in this worktree
if wt.agents.is_empty() {
println!(" {}", "no agents".dimmed());
} else {
for a in &wt.agents {
print_agent_pulse(a);
}
}
}
}
Response::ScheduleList { schedules } => {
if schedules.is_empty() {
println!("No schedules");
Expand Down Expand Up @@ -719,7 +819,7 @@ mod tests {
#[test]
fn given_empty_suspend_result_should_not_panic() {
let resp = Response::SuspendResult { suspended: vec![] };
print_response(&resp, false);
print_response(&resp, false).unwrap();
}

#[test]
Expand Down Expand Up @@ -935,6 +1035,90 @@ mod tests {
print_response(&resp, false).unwrap();
}

// --- pulse output ---

#[test]
fn given_pulse_report_should_not_panic() {
let resp = Response::PulseReport {
worktrees: vec![pu_core::protocol::WorktreePulseEntry {
worktree_id: "wt-1".into(),
worktree_name: "feature-5".into(),
branch: "pu/feature-5".into(),
elapsed_seconds: 3661,
agents: vec![pu_core::protocol::AgentPulseEntry {
id: "ag-1".into(),
name: "claude".into(),
agent_type: "claude".into(),
status: AgentStatus::Streaming,
exit_code: None,
runtime_seconds: 120,
idle_seconds: Some(5),
prompt_snippet: Some("Add pulse command to CLI".into()),
}],
files_changed: 3,
insertions: 42,
deletions: 7,
diff_error: None,
}],
root_agents: vec![pu_core::protocol::AgentPulseEntry {
id: "ag-2".into(),
name: "point-guard".into(),
agent_type: "claude".into(),
status: AgentStatus::Waiting,
exit_code: None,
runtime_seconds: 7200,
idle_seconds: Some(30),
prompt_snippet: None,
}],
};
print_response(&resp, false).unwrap();
}

#[test]
fn given_empty_pulse_report_should_not_panic() {
let resp = Response::PulseReport {
worktrees: vec![],
root_agents: vec![],
};
print_response(&resp, false).unwrap();
}

#[test]
fn given_pulse_report_json_should_produce_valid_json() {
let resp = Response::PulseReport {
worktrees: vec![pu_core::protocol::WorktreePulseEntry {
worktree_id: "wt-1".into(),
worktree_name: "test".into(),
branch: "pu/test".into(),
elapsed_seconds: 60,
agents: vec![],
files_changed: 0,
insertions: 0,
deletions: 0,
diff_error: None,
}],
root_agents: vec![],
};
let json = serde_json::to_string_pretty(&resp).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "pulse_report");
}

#[test]
fn given_format_duration_under_60s() {
assert_eq!(format_duration(45), "45s");
}

#[test]
fn given_format_duration_minutes() {
assert_eq!(format_duration(125), "2m 5s");
}

#[test]
fn given_format_duration_hours() {
assert_eq!(format_duration(3661), "1h 1m");
}

// --- schedule output ---

#[test]
Expand Down
39 changes: 37 additions & 2 deletions crates/pu-core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>` as hex in JSON for binary PTY data.
mod hex_bytes {
Expand Down Expand Up @@ -252,6 +252,9 @@ pub enum Request {
#[serde(default)]
stat: bool,
},
Pulse {
project_root: String,
},
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -530,6 +533,10 @@ pub enum Response {
DiffResult {
diffs: Vec<WorktreeDiffEntry>,
},
PulseReport {
worktrees: Vec<WorktreePulseEntry>,
root_agents: Vec<AgentPulseEntry>,
},
Ok,
ShuttingDown,
Error {
Expand Down Expand Up @@ -573,6 +580,34 @@ pub struct WorktreeDiffEntry {
pub error: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentPulseEntry {
pub id: String,
pub name: String,
pub agent_type: String,
pub status: AgentStatus,
pub exit_code: Option<i32>,
pub runtime_seconds: i64,
pub idle_seconds: Option<u64>,
pub prompt_snippet: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct WorktreePulseEntry {
pub worktree_id: String,
pub worktree_name: String,
pub branch: String,
pub elapsed_seconds: i64,
pub agents: Vec<AgentPulseEntry>,
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff_error: Option<String>,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -949,7 +984,7 @@ mod tests {

#[test]
fn given_protocol_version_should_be_current() {
assert_eq!(PROTOCOL_VERSION, 2);
assert_eq!(PROTOCOL_VERSION, 3);
}

// --- GridCommand round-trips ---
Expand Down
Loading
Loading