feat: Improved UX with colored spinners for parallel graph agents and no clobbering outputs for sub-agents

This commit is contained in:
2026-05-21 13:00:44 -06:00
parent d81d233527
commit 0bb312a85c
3 changed files with 39 additions and 36 deletions
+9 -3
View File
@@ -39,8 +39,12 @@ impl GraphExecutor {
ctx: &mut RequestContext, ctx: &mut RequestContext,
abort_signal: AbortSignal, abort_signal: AbortSignal,
) -> Result<String> { ) -> Result<String> {
let mut logger = let is_nested = ctx.current_depth > 0;
GraphLogger::new(&self.graph.name, self.graph.settings.log_state_snapshots); 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; let result = self.run(&mut logger, ctx, abort_signal).await;
if let Err(e) = &result { if let Err(e) = &result {
logger.graph_error(e); logger.graph_error(e);
@@ -143,12 +147,14 @@ impl GraphExecutor {
let semaphore = Arc::new(Semaphore::new(max_concurrency)); let semaphore = Arc::new(Semaphore::new(max_concurrency));
let frontier_size = frontier.len(); let frontier_size = frontier.len();
let is_nested = ctx.current_depth > 0;
let has_progress_nodes = frontier.iter().any(|nid| { let has_progress_nodes = frontier.iter().any(|nid| {
graph.get_node(nid).is_some_and(|n| { graph.get_node(nid).is_some_and(|n| {
!matches!(n.node_type, NodeType::Approval(_) | NodeType::Input(_)) !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); let mut branch_tasks = Vec::with_capacity(frontier_size);
for node_id in &frontier { for node_id in &frontier {
let node = graph let node = graph
+20 -14
View File
@@ -25,14 +25,16 @@ impl NodeTiming {
pub struct GraphLogger { pub struct GraphLogger {
graph_name: String, graph_name: String,
log_state_snapshots: bool, log_state_snapshots: bool,
silent: bool,
timings: IndexMap<String, NodeTiming>, timings: IndexMap<String, NodeTiming>,
} }
impl GraphLogger { 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 { Self {
graph_name: graph_name.to_string(), graph_name: graph_name.to_string(),
log_state_snapshots, log_state_snapshots,
silent,
timings: IndexMap::new(), timings: IndexMap::new(),
} }
} }
@@ -42,13 +44,15 @@ impl GraphLogger {
"[graph:{}] start at '{}' ({} nodes)", "[graph:{}] start at '{}' ({} nodes)",
self.graph_name, start_node, node_count self.graph_name, start_node, node_count
); );
eprintln!( if !self.silent {
"{}", eprintln!(
dimmed_text(&format!( "{}",
"▸ graph: {} (start: {start_node})", dimmed_text(&format!(
self.graph_name "▸ graph: {} (start: {start_node})",
)) self.graph_name
); ))
);
}
} }
pub fn graph_complete(&self, end_node: &str, elapsed: Duration) { pub fn graph_complete(&self, end_node: &str, elapsed: Duration) {
@@ -56,10 +60,12 @@ impl GraphLogger {
"[graph:{}] end '{}' (elapsed {:?})", "[graph:{}] end '{}' (elapsed {:?})",
self.graph_name, end_node, elapsed self.graph_name, end_node, elapsed
); );
eprintln!( if !self.silent {
"{}", eprintln!(
dimmed_text(&format!("▸ graph done in {:.2}s", elapsed.as_secs_f64())) "{}",
); dimmed_text(&format!("▸ graph done in {:.2}s", elapsed.as_secs_f64()))
);
}
self.log_performance_summary(); self.log_performance_summary();
} }
@@ -157,7 +163,7 @@ mod tests {
#[test] #[test]
fn records_and_aggregates_node_timings() { 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(100));
logger.record_timing("a", Duration::from_millis(300)); logger.record_timing("a", Duration::from_millis(300));
logger.record_timing("b", Duration::from_millis(50)); logger.record_timing("b", Duration::from_millis(50));
@@ -187,7 +193,7 @@ mod tests {
#[test] #[test]
fn new_logger_has_no_timings() { 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.timings.is_empty());
assert!(logger.log_state_snapshots); assert!(logger.log_state_snapshots);
+10 -19
View File
@@ -1,10 +1,14 @@
use crate::utils::IS_STDOUT_TERMINAL; use crate::utils::IS_STDOUT_TERMINAL;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::sync::LazyLock; 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<ProgressStyle> = LazyLock::new(|| { static SPINNER_STYLE: LazyLock<ProgressStyle> = LazyLock::new(|| {
ProgressStyle::with_template("{spinner} [{prefix}] {msg}") ProgressStyle::with_template("{spinner} [{prefix}] {msg} ({elapsed})")
.expect("valid template") .expect("valid template")
.tick_strings(&["", "", "", "", "", "", "", "", "", "", ""]) .tick_strings(&["", "", "", "", "", "", "", "", "", "", ""])
}); });
@@ -33,36 +37,27 @@ impl BranchProgressTracker {
bar.set_prefix(label.to_string()); bar.set_prefix(label.to_string());
bar.set_message("running…"); bar.set_message("running…");
bar.enable_steady_tick(Duration::from_millis(80)); bar.enable_steady_tick(Duration::from_millis(80));
BranchProgressHandle { BranchProgressHandle { bar: Some(bar) }
bar: Some(bar),
started: Instant::now(),
}
} }
} }
pub(super) struct BranchProgressHandle { pub(super) struct BranchProgressHandle {
bar: Option<ProgressBar>, bar: Option<ProgressBar>,
started: Instant,
} }
impl BranchProgressHandle { impl BranchProgressHandle {
pub fn disabled() -> Self { pub fn disabled() -> Self {
Self { Self { bar: None }
bar: None,
started: Instant::now(),
}
} }
pub fn complete(self) { pub fn complete(self) {
if let Some(bar) = self.bar { if let Some(bar) = self.bar {
let elapsed = self.started.elapsed(); bar.finish_with_message(format!("{GREEN}✓ done{RESET}"));
bar.finish_with_message(format!("✓ done ({:.1}s)", elapsed.as_secs_f64()));
} }
} }
pub fn fail(self, err: &str) { pub fn fail(self, err: &str) {
if let Some(bar) = self.bar { if let Some(bar) = self.bar {
let elapsed = self.started.elapsed();
let truncated = if err.len() > 80 { let truncated = if err.len() > 80 {
let mut s = err[..80].to_string(); let mut s = err[..80].to_string();
s.push('…'); s.push('…');
@@ -70,11 +65,7 @@ impl BranchProgressHandle {
} else { } else {
err.to_string() err.to_string()
}; };
bar.finish_with_message(format!( bar.finish_with_message(format!("{RED}✗ failed {RESET}{truncated}"));
"✗ failed ({:.1}s) — {}",
elapsed.as_secs_f64(),
truncated
));
} }
} }
} }