//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting. use std::collections::HashMap; use std::io::Write; use maestro_protocol::{ ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session, SessionStatus, WorktreeSpec, }; use serde_json::Value; use crate::cli::{KillArgs, ListArgs, SpawnArgs}; /// `maestro list` pub async fn run_list( client: &DaemonClient, args: &ListArgs, out: &mut impl Write, ) -> Result<(), ClientError> { let sessions = client.list_sessions().await?; let info = client.daemon_info().await.unwrap_or_default(); if args.json { let body = serde_json::json!({ "sessions": sessions }); writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok(); return Ok(()); } print_session_table(out, &sessions, &info); Ok(()) } /// `maestro spawn` pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result { let mut env: HashMap = HashMap::new(); for (k, v) in args.env.iter() { env.insert(k.clone(), v.clone()); } let metadata: HashMap = HashMap::new(); let worktree = match (args.worktree_from.as_ref(), args.branch.as_ref()) { (Some(repo), Some(branch)) => Some(WorktreeSpec { base_repo: repo.clone(), branch: branch.clone(), create_branch: !args.existing_branch, }), _ => None, }; let body = CreateSessionRequest { cwd: args.cwd.clone(), command: args.command.clone(), env, metadata, worktree, }; client.create_session(&body).await } /// `maestro kill`. A forced kill (#81) retains the guarded worktree instead /// of removing it; the outcome names what was kept so dispatch can say where /// the work went. pub async fn run_kill( client: &DaemonClient, args: &KillArgs, ) -> Result { client.kill_session(args.session_id, args.force).await } #[allow(clippy::write_literal)] fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) { if sessions.is_empty() { writeln!(out, "no sessions").ok(); return; } let mut sorted: Vec<&Session> = sessions.iter().collect(); sorted.sort_by_key(|s| s.created_at); if !info.hostname.is_empty() { let warn = if maestro_protocol::is_compatible(&info.version) { "" } else { " (protocol mismatch)" }; let version = if info.version.is_empty() { String::new() } else { format!(" maestro {}{warn}", info.version) }; writeln!(out, "\n{}{version}", info.hostname).ok(); } writeln!( out, " {:<36} {:<8} {:<9} {:<28} {:<40} {}", "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED" ) .ok(); for s in sorted { // The pane's live cwd if observed, else the spawn dir, home-abbreviated. let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), &info.home_dir); let status = match s.status { SessionStatus::Running => "running", SessionStatus::Dead => "dead", }; let activity = match s.activity { ActivityState::Active => "active", ActivityState::Idle => "idle", ActivityState::AwaitingInput => "awaiting", }; // Show the live foreground command alongside the spawn command when // the daemon has observed one that differs (NAP-340). let command = match &s.foreground { Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg), _ => s.command.clone(), }; // #83: what an unforced kill would be refused for, in InTheWay's own // words; blank while nothing blocks (or hasn't been probed yet). let blocked = blocked_label(s); writeln!( out, " {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}", s.id, status, activity, dir, command ) .ok(); } } /// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`. /// Empty unless something actually blocks — "no unsaved work" would just be /// noise on every row. pub(crate) fn blocked_label(s: &Session) -> String { s.blocked_work .filter(|b| !b.is_clear()) .map(|b| b.to_string()) .unwrap_or_default() } /// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web /// UI's abbreviation so CLI and browser show the same directory shorthand. pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String { let p = path.to_string_lossy(); if !home.is_empty() { if p == home { return "~".into(); } if let Some(rest) = p.strip_prefix(&format!("{home}/")) { return format!("~/{rest}"); } } p.into_owned() } #[cfg(test)] mod tests { use std::collections::HashMap; use std::path::PathBuf; use chrono::{TimeZone, Utc}; use maestro_protocol::{ActivityState, Session, SessionStatus}; use uuid::Uuid; use super::DaemonInfo; fn session(cwd: &str, command: &str, id: u128) -> Session { Session { id: Uuid::from_u128(id), cwd: PathBuf::from(cwd), command: command.into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, status: SessionStatus::Running, created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(), foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), current_cwd: None, blocked_work: None, } } fn info(home: &str) -> DaemonInfo { DaemonInfo { hostname: "naps-laptop".into(), home_dir: home.into(), ..Default::default() } } #[test] fn formats_table() { let sessions = vec![ session("/home/naps/maestro", "claude", 1), session("/srv/work/api", "bash", 2), ]; let mut buf = Vec::new(); super::print_session_table(&mut buf, &sessions, &info("/home/naps")); let s = String::from_utf8(buf).unwrap(); assert!(s.contains("naps-laptop"), "{s}"); // Home dir collapses to ~ in the DIR column. assert!(s.contains("~/maestro"), "{s}"); // Outside the home dir → path shown verbatim. assert!(s.contains("/srv/work/api"), "{s}"); assert!(s.contains("running"), "{s}"); assert!(s.contains("ACTIVITY"), "{s}"); assert!(s.contains("DIR"), "{s}"); assert!(s.contains("idle"), "{s}"); } #[test] fn live_cwd_overrides_spawn_dir_in_table() { let mut s = session("/home/naps/maestro", "bash", 1); s.current_cwd = Some(PathBuf::from("/home/naps/maestro/crates/maestro-cli")); let mut buf = Vec::new(); super::print_session_table(&mut buf, &[s], &info("/home/naps")); let out = String::from_utf8(buf).unwrap(); assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}"); } #[test] fn shows_activity_and_foreground() { let mut s1 = session("/tmp", "bash", 1); s1.activity = ActivityState::AwaitingInput; s1.foreground = Some("claude".into()); // differs from spawn command let mut buf = Vec::new(); super::print_session_table(&mut buf, &[s1], &info("")); let out = String::from_utf8(buf).unwrap(); assert!(out.contains("awaiting"), "{out}"); // Live foreground surfaces next to the spawn command. assert!(out.contains("bash [claude]"), "{out}"); } #[test] fn empty_sessions() { let mut buf = Vec::new(); super::print_session_table(&mut buf, &[], &info("")); assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n"); } /// #83: a blocked session shows what an unforced kill would be refused /// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing. #[test] fn shows_the_blocked_work_summary_only_when_something_blocks() { use maestro_protocol::InTheWay; let mut blocked = session("/tmp", "claude", 1); blocked.blocked_work = Some(InTheWay { uncommitted_files: 3, unpushed_commits: 2, ..InTheWay::default() }); let mut clear = session("/tmp", "bash", 2); clear.blocked_work = Some(InTheWay::default()); let unprobed = session("/tmp", "zsh", 3); let mut buf = Vec::new(); super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info("")); let out = String::from_utf8(buf).unwrap(); assert!( out.contains("3 uncommitted files, 2 unpushed commits"), "{out}" ); // Clear and unprobed rows stay blank in the BLOCKED column. let lines: Vec<&str> = out.lines().collect(); let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap(); for line in &lines[header + 1..] { if !line.contains("uncommitted files") { assert!(!line.contains("unsaved work"), "{line}"); } } } }