From 209257c7b13ef0edd08e69e210313b2eae177456 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 May 2026 13:00:44 -0600 Subject: [PATCH] feat: Improved UX with colored spinners for parallel graph agents and no clobbering outputs for sub-agents --- src/graph/executor.rs | 12 +++++++++--- src/graph/logging.rs | 34 ++++++++++++++++++++-------------- src/graph/progress.rs | 29 ++++++++++------------------- 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/graph/executor.rs b/src/graph/executor.rs index 28e3792..8670b4d 100644 --- a/src/graph/executor.rs +++ b/src/graph/executor.rs @@ -39,8 +39,12 @@ impl GraphExecutor { ctx: &mut RequestContext, abort_signal: AbortSignal, ) -> Result { - let mut logger = - GraphLogger::new(&self.graph.name, self.graph.settings.log_state_snapshots); + let is_nested = ctx.current_depth > 0; + let mut logger = GraphLogger::with_visibility( + &self.graph.name, + self.graph.settings.log_state_snapshots, + is_nested, + ); let result = self.run(&mut logger, ctx, abort_signal).await; if let Err(e) = &result { logger.graph_error(e); @@ -143,12 +147,14 @@ impl GraphExecutor { let semaphore = Arc::new(Semaphore::new(max_concurrency)); let frontier_size = frontier.len(); + let is_nested = ctx.current_depth > 0; let has_progress_nodes = frontier.iter().any(|nid| { graph.get_node(nid).is_some_and(|n| { !matches!(n.node_type, NodeType::Approval(_) | NodeType::Input(_)) }) }); - let progress_tracker = has_progress_nodes.then(BranchProgressTracker::new); + let progress_tracker = + (has_progress_nodes && !is_nested).then(BranchProgressTracker::new); let mut branch_tasks = Vec::with_capacity(frontier_size); for node_id in &frontier { let node = graph diff --git a/src/graph/logging.rs b/src/graph/logging.rs index 69d2990..de7d9bd 100644 --- a/src/graph/logging.rs +++ b/src/graph/logging.rs @@ -25,14 +25,16 @@ impl NodeTiming { pub struct GraphLogger { graph_name: String, log_state_snapshots: bool, + silent: bool, timings: IndexMap, } impl GraphLogger { - pub fn new(graph_name: &str, log_state_snapshots: bool) -> Self { + pub fn with_visibility(graph_name: &str, log_state_snapshots: bool, silent: bool) -> Self { Self { graph_name: graph_name.to_string(), log_state_snapshots, + silent, timings: IndexMap::new(), } } @@ -42,13 +44,15 @@ impl GraphLogger { "[graph:{}] start at '{}' ({} nodes)", self.graph_name, start_node, node_count ); - eprintln!( - "{}", - dimmed_text(&format!( - "▸ graph: {} (start: {start_node})", - self.graph_name - )) - ); + if !self.silent { + eprintln!( + "{}", + dimmed_text(&format!( + "▸ graph: {} (start: {start_node})", + self.graph_name + )) + ); + } } pub fn graph_complete(&self, end_node: &str, elapsed: Duration) { @@ -56,10 +60,12 @@ impl GraphLogger { "[graph:{}] end '{}' (elapsed {:?})", self.graph_name, end_node, elapsed ); - eprintln!( - "{}", - dimmed_text(&format!("▸ graph done in {:.2}s", elapsed.as_secs_f64())) - ); + if !self.silent { + eprintln!( + "{}", + dimmed_text(&format!("▸ graph done in {:.2}s", elapsed.as_secs_f64())) + ); + } self.log_performance_summary(); } @@ -157,7 +163,7 @@ mod tests { #[test] fn records_and_aggregates_node_timings() { - let mut logger = GraphLogger::new("g", false); + let mut logger = GraphLogger::with_visibility("g", false, false); logger.record_timing("a", Duration::from_millis(100)); logger.record_timing("a", Duration::from_millis(300)); logger.record_timing("b", Duration::from_millis(50)); @@ -187,7 +193,7 @@ mod tests { #[test] fn new_logger_has_no_timings() { - let logger = GraphLogger::new("g", true); + let logger = GraphLogger::with_visibility("g", true, false); assert!(logger.timings.is_empty()); assert!(logger.log_state_snapshots); diff --git a/src/graph/progress.rs b/src/graph/progress.rs index 60ca073..c853240 100644 --- a/src/graph/progress.rs +++ b/src/graph/progress.rs @@ -1,10 +1,14 @@ use crate::utils::IS_STDOUT_TERMINAL; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use std::sync::LazyLock; -use std::time::{Duration, Instant}; +use std::time::Duration; + +const GREEN: &str = "\x1b[32m"; +const RED: &str = "\x1b[31m"; +const RESET: &str = "\x1b[0m"; static SPINNER_STYLE: LazyLock = LazyLock::new(|| { - ProgressStyle::with_template("{spinner} [{prefix}] {msg}") + ProgressStyle::with_template("{spinner} [{prefix}] {msg} ({elapsed})") .expect("valid template") .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]) }); @@ -33,36 +37,27 @@ impl BranchProgressTracker { bar.set_prefix(label.to_string()); bar.set_message("running…"); bar.enable_steady_tick(Duration::from_millis(80)); - BranchProgressHandle { - bar: Some(bar), - started: Instant::now(), - } + BranchProgressHandle { bar: Some(bar) } } } pub(super) struct BranchProgressHandle { bar: Option, - started: Instant, } impl BranchProgressHandle { pub fn disabled() -> Self { - Self { - bar: None, - started: Instant::now(), - } + Self { bar: None } } pub fn complete(self) { if let Some(bar) = self.bar { - let elapsed = self.started.elapsed(); - bar.finish_with_message(format!("✓ done ({:.1}s)", elapsed.as_secs_f64())); + bar.finish_with_message(format!("{GREEN}✓ done{RESET}")); } } pub fn fail(self, err: &str) { if let Some(bar) = self.bar { - let elapsed = self.started.elapsed(); let truncated = if err.len() > 80 { let mut s = err[..80].to_string(); s.push('…'); @@ -70,11 +65,7 @@ impl BranchProgressHandle { } else { err.to_string() }; - bar.finish_with_message(format!( - "✗ failed ({:.1}s) — {}", - elapsed.as_secs_f64(), - truncated - )); + bar.finish_with_message(format!("{RED}✗ failed {RESET} — {truncated}")); } } }