From 524a848fc63f8d6aa063e0f0fa0814847a7ed68b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 13:06:43 +0100 Subject: [PATCH] feat(maestro): show blocked work in the CLI table and warn on kill (#83) --- crates/maestro-cli/src/dispatch.rs | 24 +++++++++++++ crates/maestro-cli/src/sessions.rs | 54 +++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs index 9653629..3e2b36a 100644 --- a/crates/maestro-cli/src/dispatch.rs +++ b/crates/maestro-cli/src/dispatch.rs @@ -62,6 +62,11 @@ pub async fn dispatch( run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?; } Command::Kill(args) => { + // #83: warn BEFORE the attempt, so the refusal is not a surprise. + // Best-effort: an unreachable list must not block the kill. + if !args.force { + warn_if_blocked(&daemon, args.session_id).await; + } let outcome = run_kill(&daemon, &args).await?; println!("killed {}", args.session_id); if let Some(w) = outcome.retained { @@ -99,6 +104,25 @@ pub async fn dispatch( /// Default detach byte, matching `maestro attach --detach-key`'s `^q`. const CTRL_Q: u8 = 0x11; +/// #83: say "this will be refused" before the operator commits to it, in the +/// same words [`maestro_protocol::InTheWay`] uses everywhere else. +async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) { + let Ok(sessions) = daemon.list_sessions().await else { + return; + }; + if let Some(b) = sessions + .iter() + .find(|s| s.id == id) + .and_then(|s| s.blocked_work) + { + if !b.is_clear() { + eprintln!( + "warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it" + ); + } + } +} + /// HTTP client pointed at the daemon's API. That surface needs no auth — /// reaching `bind_addr` is the authorization. #[allow(clippy::result_large_err)] diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index c88d0e7..ad43be6 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -93,8 +93,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon } writeln!( out, - " {:<36} {:<8} {:<9} {:<28} {}", - "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND" + " {:<36} {:<8} {:<9} {:<28} {:<40} {}", + "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED" ) .ok(); for s in sorted { @@ -115,15 +115,28 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon 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} {command}", - s.id, status, activity, dir + " {:<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 { @@ -225,4 +238,37 @@ mod tests { 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}"); + } + } + } }