fabro/crates/arc-workflows/src/cli/progress.rs
Bryan Helmkamp f4481e5287 Render Run ID line through progress system for consistent styling
The Run: line was using raw eprintln! without indentation or dimming,
making it visually inconsistent with the Logs: and Sandbox: lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:13:39 -04:00

1234 lines
46 KiB
Rust

use std::collections::{HashMap, VecDeque};
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use async_trait::async_trait;
use console::Style;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use crate::event::{EventEmitter, WorkflowRunEvent};
use crate::interviewer::{Answer, Interviewer, Question};
use crate::outcome::StageStatus;
use arc_agent::AgentEvent;
use super::{compute_stage_cost, format_cost, format_tokens_human};
// ── Cached styles ───────────────────────────────────────────────────────
macro_rules! cached_style {
($name:ident, $template:expr) => {
fn $name() -> ProgressStyle {
static STYLE: OnceLock<ProgressStyle> = OnceLock::new();
STYLE
.get_or_init(|| ProgressStyle::with_template($template).expect("valid template"))
.clone()
}
};
}
cached_style!(
style_header_running,
" {spinner:.dim} {wide_msg} {elapsed:.dim}"
);
cached_style!(style_header_done, " {wide_msg:.dim} {prefix:.dim}");
cached_style!(
style_stage_running,
" {spinner:.cyan} {wide_msg} {elapsed:.dim}"
);
cached_style!(style_stage_done, " {wide_msg} {prefix:.dim}");
cached_style!(
style_tool_running,
" {spinner:.dim} {wide_msg} {elapsed:.dim}"
);
cached_style!(style_tool_done, " {wide_msg} {prefix:.dim}");
cached_style!(style_static_dim, " {wide_msg:.dim}");
cached_style!(style_sandbox_detail, " {wide_msg:.dim}");
cached_style!(style_empty, " ");
// ── Cached glyphs ───────────────────────────────────────────────────────
fn green_check() -> &'static str {
static GLYPH: OnceLock<String> = OnceLock::new();
GLYPH.get_or_init(|| Style::new().green().apply_to("\u{2713}").to_string())
}
fn red_cross() -> &'static str {
static GLYPH: OnceLock<String> = OnceLock::new();
GLYPH.get_or_init(|| Style::new().red().apply_to("\u{2717}").to_string())
}
// ── Duration formatting ─────────────────────────────────────────────────
pub(crate) fn format_duration_short(d: Duration) -> String {
let secs = d.as_secs();
if secs >= 60 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else if d.as_millis() >= 1000 {
format!("{}s", secs)
} else {
format!("{}ms", d.as_millis())
}
}
fn format_duration_ms(ms: u64) -> String {
format_duration_short(Duration::from_millis(ms))
}
/// Wrap `text` in an OSC 8 terminal hyperlink pointing to `url`.
fn terminal_hyperlink(url: &str, text: &str) -> String {
format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
}
/// Format a number as an integer if whole, one decimal otherwise.
fn format_number(n: f64) -> String {
if (n - n.round()).abs() < f64::EPSILON {
format!("{}", n as i64)
} else {
format!("{n:.1}")
}
}
// ── Tool call display name ──────────────────────────────────────────────
fn truncate(s: &str, max: usize) -> String {
let single_line: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
if single_line.len() > max {
let mut t: String = single_line.chars().take(max - 3).collect();
t.push_str("...");
t
} else {
single_line
}
}
fn shorten_path(path: &str) -> String {
if let Ok(cwd) = std::env::current_dir() {
if let Ok(rel) = std::path::Path::new(path).strip_prefix(&cwd) {
return rel.display().to_string();
}
}
path.to_string()
}
fn tool_display_name(tool_name: &str, arguments: &serde_json::Value) -> String {
let dim = Style::new().dim();
let arg = |key: &str| arguments.get(key).and_then(|v| v.as_str());
let path_arg = || {
arg("path")
.or_else(|| arg("file_path"))
.map(|p| truncate(&shorten_path(p), 60))
};
let detail = match tool_name {
"bash" | "shell" | "execute_command" => arg("command").map(|c| truncate(c, 60)),
"glob" => arg("pattern").map(String::from),
"grep" | "ripgrep" => arg("pattern").map(|p| truncate(p, 40)),
"read_file" | "read" => path_arg(),
"write_file" | "write" | "create_file" => path_arg(),
"edit_file" | "edit" => path_arg(),
"list_dir" => path_arg(),
"web_search" => arg("query").map(|q| truncate(q, 60)),
"web_fetch" => arg("url").map(|u| truncate(u, 60)),
"spawn_agent" => arg("task").map(|t| truncate(t, 60)),
"use_skill" => arg("skill_name").map(String::from),
_ => None,
};
match detail {
Some(d) => format!("{tool_name}{}", dim.apply_to(format!("({d})"))),
None => tool_name.to_string(),
}
}
// ── Tool call entry ─────────────────────────────────────────────────────
enum ToolCallStatus {
Running,
Succeeded,
Failed,
}
struct ToolCallEntry {
display_name: String,
tool_call_id: String,
status: ToolCallStatus,
bar: ProgressBar,
is_branch: bool,
}
// ── Active stage ────────────────────────────────────────────────────────
struct ActiveStage {
display_name: String,
has_model: bool,
spinner: ProgressBar,
tool_calls: VecDeque<ToolCallEntry>,
compaction_bar: Option<ProgressBar>,
}
impl ActiveStage {
fn last_bar(&self) -> &ProgressBar {
self.tool_calls.back().map_or(&self.spinner, |e| &e.bar)
}
}
const MAX_TOOL_CALLS: usize = 5;
// ── Renderer variants ───────────────────────────────────────────────────
struct TtyRenderer {
multi: MultiProgress,
}
enum ProgressRenderer {
Tty(TtyRenderer),
Plain,
}
// ── ProgressUI ──────────────────────────────────────────────────────────
pub struct ProgressUI {
renderer: ProgressRenderer,
verbose: bool,
active_stages: HashMap<String, ActiveStage>,
/// Turn and tool-call counts per stage, tracked independently of the
/// renderer so that Plain (non-TTY) mode reports accurate stats.
stage_counts: HashMap<String, (u32, u32)>,
setup_command_count: usize,
sandbox_bar: Option<ProgressBar>,
setup_bar: Option<ProgressBar>,
cli_ensure_bar: Option<ProgressBar>,
any_stage_started: bool,
parallel_parent: Option<String>,
}
impl ProgressUI {
pub fn new(is_tty: bool, verbose: bool) -> Self {
let renderer = if is_tty {
ProgressRenderer::Tty(TtyRenderer {
multi: MultiProgress::new(),
})
} else {
ProgressRenderer::Plain
};
Self {
renderer,
verbose,
active_stages: HashMap::new(),
stage_counts: HashMap::new(),
setup_command_count: 0,
sandbox_bar: None,
setup_bar: None,
cli_ensure_bar: None,
any_stage_started: false,
parallel_parent: None,
}
}
/// Register event handlers on the emitter.
pub fn register(progress: &Arc<Mutex<Self>>, emitter: &mut EventEmitter) {
let p = Arc::clone(progress);
emitter.on_event(move |event| {
let mut ui = p.lock().expect("progress lock poisoned");
ui.handle_event(event);
});
}
/// Clear all active bars and release the terminal for normal stderr output.
pub fn finish(&mut self) {
for (_id, stage) in self.active_stages.drain() {
for entry in &stage.tool_calls {
if entry.is_branch || self.verbose {
entry.bar.abandon();
} else {
entry.bar.finish_and_clear();
}
}
stage.spinner.finish_and_clear();
}
if let ProgressRenderer::Tty(tty) = &self.renderer {
// Add a trailing blank line through indicatif so it survives the final redraw
let sep = tty.multi.add(ProgressBar::new_spinner());
sep.set_style(style_empty());
sep.finish();
tty.multi.set_draw_target(ProgressDrawTarget::hidden());
}
}
// ── Event dispatch ──────────────────────────────────────────────────
fn handle_event(&mut self, event: &WorkflowRunEvent) {
match event {
WorkflowRunEvent::Sandbox {
event: sandbox_event,
} => {
self.on_sandbox_event(sandbox_event);
}
WorkflowRunEvent::SetupStarted { command_count } => {
self.on_setup_started(*command_count);
}
WorkflowRunEvent::SetupCompleted { duration_ms } => {
self.on_setup_completed(*duration_ms);
}
WorkflowRunEvent::StageStarted {
node_id,
name,
script,
..
} => {
self.on_stage_started(node_id, name, script.as_deref());
}
WorkflowRunEvent::StageCompleted {
node_id,
name,
duration_ms,
status,
usage,
..
} => {
let succeeded = status
.parse::<StageStatus>()
.map(|s| matches!(s, StageStatus::Success | StageStatus::PartialSuccess))
.unwrap_or(false);
let dur = format_duration_ms(*duration_ms);
let cost_str = usage
.as_ref()
.and_then(compute_stage_cost)
.map(|c| format!("{} ", format_cost(c)))
.unwrap_or_default();
let stats_str = if self.verbose {
let counts = self.stage_counts.get(node_id);
let turn_count = counts.map_or(0, |c| c.0);
let tool_call_count = counts.map_or(0, |c| c.1);
let total_tokens = usage
.as_ref()
.map(|u| u.input_tokens + u.output_tokens)
.unwrap_or(0);
if turn_count > 0 || tool_call_count > 0 || total_tokens > 0 {
let dim = Style::new().dim();
format!(
" {}",
dim.apply_to(format!(
"({} turns, {} tool calls, {} tokens)",
turn_count,
tool_call_count,
format_tokens_human(total_tokens),
))
)
} else {
String::new()
}
} else {
String::new()
};
let prefix = format!("{cost_str}{dur}{stats_str}");
let glyph = if succeeded {
green_check()
} else {
red_cross()
};
self.finish_stage(node_id, name, glyph, &prefix);
}
WorkflowRunEvent::StageFailed {
node_id,
name,
failure,
..
} => {
self.finish_stage(node_id, name, red_cross(), "");
let red = Style::new().red();
self.insert_info_line(&format!("{} {}", red.apply_to("Error:"), failure.message,));
}
WorkflowRunEvent::ParallelStarted { .. } => {
// The fork stage is the (only) active stage at this point.
// In Plain mode active_stages is empty, so use a sentinel.
self.parallel_parent = self
.active_stages
.keys()
.next()
.cloned()
.or_else(|| Some(String::new()));
}
WorkflowRunEvent::ParallelBranchStarted { branch, .. } => {
self.on_parallel_branch_started(branch);
}
WorkflowRunEvent::ParallelBranchCompleted {
branch,
duration_ms,
status,
..
} => {
self.on_parallel_branch_completed(branch, *duration_ms, status);
}
WorkflowRunEvent::ParallelCompleted { .. } => {
self.parallel_parent = None;
}
WorkflowRunEvent::Agent { stage, event } => {
self.on_agent_event(stage, event);
}
WorkflowRunEvent::SshAccessReady { ssh_command } => {
self.on_ssh_access_ready(ssh_command);
}
WorkflowRunEvent::EdgeSelected {
from_node,
to_node,
label,
condition,
} if self.verbose => {
let detail = if let Some(c) = condition {
format!(" [{c}]")
} else if let Some(l) = label {
format!(" \"{l}\"")
} else {
String::new()
};
self.insert_info_line(&format!("\u{2192} {from_node} \u{2192} {to_node}{detail}"));
}
WorkflowRunEvent::LoopRestart { from_node, to_node } if self.verbose => {
self.insert_info_line(&format!(
"\u{21ba} {from_node} \u{2192} {to_node} (loop restart)"
));
}
WorkflowRunEvent::SetupCommandCompleted {
command,
index,
exit_code,
duration_ms,
} if self.verbose => {
let total = self.setup_command_count;
let dur = format_duration_ms(*duration_ms);
let glyph = if *exit_code == 0 {
green_check()
} else {
red_cross()
};
let msg = format!("{glyph} [{}/{total}] {}", index + 1, truncate(command, 60),);
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = if let Some(ref setup_bar) = self.setup_bar {
tty.multi
.insert_before(setup_bar, ProgressBar::new_spinner())
} else {
tty.multi.add(ProgressBar::new_spinner())
};
bar.set_style(style_tool_done());
bar.set_prefix(dur);
bar.finish_with_message(msg);
}
ProgressRenderer::Plain => {
eprintln!(" {msg} {dur}");
}
}
}
WorkflowRunEvent::StageRetrying {
node_id: _,
name,
attempt,
max_attempts,
delay_ms,
..
} if self.verbose => {
let dur = format_duration_ms(*delay_ms);
self.insert_info_line(&format!(
"\u{21bb} {name}: retrying (attempt {attempt}/{max_attempts}, delay {dur})"
));
}
WorkflowRunEvent::CliEnsureStarted { cli_name, .. } => {
self.on_cli_ensure_started(cli_name);
}
WorkflowRunEvent::CliEnsureCompleted {
cli_name,
already_installed,
duration_ms,
..
} => {
self.on_cli_ensure_completed(cli_name, *already_installed, *duration_ms);
}
WorkflowRunEvent::CliEnsureFailed { cli_name, .. } => {
self.on_cli_ensure_failed(cli_name);
}
_ => {}
}
}
// ── Sandbox ─────────────────────────────────────────────────────────
fn on_sandbox_event(&mut self, event: &arc_agent::SandboxEvent) {
use arc_agent::SandboxEvent;
match event {
SandboxEvent::Initializing { provider } => {
if let ProgressRenderer::Tty(tty) = &self.renderer {
let bar = tty.multi.add(ProgressBar::new_spinner());
bar.set_style(style_header_running());
bar.set_message(format!("Initializing {provider} sandbox..."));
bar.enable_steady_tick(Duration::from_millis(100));
self.sandbox_bar = Some(bar);
}
}
SandboxEvent::Ready {
provider,
duration_ms,
name,
cpu,
memory,
url,
} => {
let dur = format_duration_ms(*duration_ms);
let detail = match (name, cpu, memory) {
(Some(n), Some(c), Some(m)) => Some(format!(
"{n} ({} cpu, {} GB)",
format_number(*c),
format_number(*m)
)),
(Some(n), _, _) => Some(n.clone()),
_ => None,
};
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let display_provider = match url {
Some(u) => terminal_hyperlink(u, provider),
None => provider.clone(),
};
if let Some(bar) = self.sandbox_bar.take() {
bar.set_style(style_header_done());
bar.set_prefix(dur);
bar.finish_with_message(format!("Sandbox: {display_provider}"));
if let Some(detail_str) = &detail {
let detail_bar =
tty.multi.insert_after(&bar, ProgressBar::new_spinner());
detail_bar.set_style(style_sandbox_detail());
detail_bar.finish_with_message(detail_str.clone());
}
}
}
ProgressRenderer::Plain => {
eprintln!(" Sandbox: {provider} (ready in {dur})");
if let Some(detail_str) = &detail {
eprintln!(" {detail_str}");
}
}
}
}
_ => {}
}
}
// ── SSH access ──────────────────────────────────────────────────────
fn on_ssh_access_ready(&mut self, ssh_command: &str) {
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = tty.multi.add(ProgressBar::new_spinner());
bar.set_style(style_sandbox_detail());
bar.finish_with_message(ssh_command.to_string());
}
ProgressRenderer::Plain => {
eprintln!(" {ssh_command}");
}
}
}
// ── Setup ───────────────────────────────────────────────────────────
fn on_setup_started(&mut self, command_count: usize) {
self.setup_command_count = command_count;
if let ProgressRenderer::Tty(tty) = &self.renderer {
let bar = tty.multi.add(ProgressBar::new_spinner());
bar.set_style(style_header_running());
bar.set_message(format!(
"Setup: {command_count} command{}...",
if command_count == 1 { "" } else { "s" }
));
bar.enable_steady_tick(Duration::from_millis(100));
self.setup_bar = Some(bar);
}
}
fn on_setup_completed(&mut self, duration_ms: u64) {
let dur = format_duration_ms(duration_ms);
let count = self.setup_command_count;
let suffix = if count == 1 { "" } else { "s" };
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(bar) = self.setup_bar.take() {
bar.set_style(style_header_done());
bar.set_prefix(dur);
bar.finish_with_message(format!("Setup: {count} command{suffix}"));
}
}
ProgressRenderer::Plain => {
eprintln!(" Setup: {count} command{suffix} ({dur})");
}
}
}
// ── CLI ensure ────────────────────────────────────────────────────────
fn on_cli_ensure_started(&mut self, cli_name: &str) {
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = tty.multi.add(ProgressBar::new_spinner());
bar.set_style(style_header_running());
bar.set_message(format!("CLI: ensuring {cli_name}..."));
bar.enable_steady_tick(Duration::from_millis(100));
self.cli_ensure_bar = Some(bar);
}
ProgressRenderer::Plain => {}
}
}
fn on_cli_ensure_completed(
&mut self,
cli_name: &str,
already_installed: bool,
duration_ms: u64,
) {
let dur = format_duration_ms(duration_ms);
let status = if already_installed {
"found"
} else {
"installed"
};
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(bar) = self.cli_ensure_bar.take() {
bar.set_style(style_header_done());
bar.set_prefix(dur);
bar.finish_with_message(format!("CLI: {cli_name} ({status})"));
}
}
ProgressRenderer::Plain => {
eprintln!(" CLI: {cli_name} ({status}, {dur})");
}
}
}
fn on_cli_ensure_failed(&mut self, cli_name: &str) {
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(bar) = self.cli_ensure_bar.take() {
bar.set_style(style_header_done());
bar.finish_with_message(format!(
"{} CLI: {cli_name} install failed",
red_cross()
));
}
}
ProgressRenderer::Plain => {
eprintln!(" {} CLI: {cli_name} install failed", red_cross());
}
}
}
// ── Logs dir (called externally) ────────────────────────────────────
pub fn show_logs_dir(&mut self, logs_dir: &Path) {
let path_str = super::tilde_path(logs_dir);
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = tty.multi.add(ProgressBar::new_spinner());
bar.set_style(style_static_dim());
bar.finish_with_message(format!("Logs: {path_str}"));
}
ProgressRenderer::Plain => {
eprintln!(" Logs: {path_str}");
}
}
}
pub fn show_run_id(&mut self, run_id: &str) {
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = tty.multi.add(ProgressBar::new_spinner());
bar.set_style(style_static_dim());
bar.finish_with_message(format!("Run: {run_id}"));
}
ProgressRenderer::Plain => {
eprintln!(" Run: {run_id}");
}
}
}
// ── Stages ──────────────────────────────────────────────────────────
fn on_stage_started(&mut self, node_id: &str, name: &str, script: Option<&str>) {
self.stage_counts.insert(node_id.to_string(), (0, 0));
let display_name = match script {
Some(s) => {
let dim = Style::new().dim();
format!("{name} {}", dim.apply_to(truncate(s, 60)))
}
None => name.to_string(),
};
if let ProgressRenderer::Tty(tty) = &self.renderer {
if !self.any_stage_started {
self.any_stage_started = true;
let sep = tty.multi.add(ProgressBar::new_spinner());
sep.set_style(style_empty());
sep.finish();
}
let bar = tty.multi.add(ProgressBar::new_spinner());
bar.set_style(style_stage_running());
bar.set_message(display_name.clone());
bar.enable_steady_tick(Duration::from_millis(100));
self.active_stages.insert(
node_id.to_string(),
ActiveStage {
display_name,
has_model: false,
spinner: bar,
tool_calls: VecDeque::new(),
compaction_bar: None,
},
);
}
}
fn finish_stage(&mut self, node_id: &str, name: &str, glyph: &str, prefix: &str) {
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(stage) = self.active_stages.remove(node_id) {
if let Some(bar) = stage.compaction_bar {
bar.finish_and_clear();
}
for entry in &stage.tool_calls {
if entry.is_branch || self.verbose {
// Keep visible: branches always, all entries in verbose mode
entry.bar.abandon();
} else {
entry.bar.finish_and_clear();
}
}
stage.spinner.set_style(style_stage_done());
stage.spinner.set_prefix(prefix.to_string());
stage
.spinner
.finish_with_message(format!("{glyph} {}", stage.display_name));
}
}
ProgressRenderer::Plain => {
if prefix.is_empty() {
eprintln!(" {glyph} {name}");
} else {
eprintln!(" {glyph} {name} {prefix}");
}
}
}
}
// ── Agent / tool calls ──────────────────────────────────────────────
fn on_agent_event(&mut self, stage_node_id: &str, event: &AgentEvent) {
match event {
AgentEvent::AssistantMessage { model, .. } => {
if let Some(counts) = self.stage_counts.get_mut(stage_node_id) {
counts.0 += 1;
}
if let ProgressRenderer::Tty(_) = &self.renderer {
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
if !stage.has_model {
stage.has_model = true;
let dim = Style::new().dim();
let suffix = format!(" {}", dim.apply_to(format!("[{model}]")));
stage.display_name.push_str(&suffix);
stage.spinner.set_message(stage.display_name.clone());
}
}
}
}
AgentEvent::ToolCallStarted {
tool_name,
tool_call_id,
arguments,
} => {
self.on_tool_call_started(stage_node_id, tool_name, tool_call_id, arguments);
}
AgentEvent::ToolCallCompleted {
tool_call_id,
is_error,
..
} => {
if let Some(counts) = self.stage_counts.get_mut(stage_node_id) {
counts.1 += 1;
}
self.on_tool_call_completed(stage_node_id, tool_call_id, *is_error);
}
AgentEvent::ContextWindowWarning { usage_percent, .. } if self.verbose => {
let yellow = Style::new().yellow();
self.insert_info_line_for_stage(
stage_node_id,
&format!(
"{} context window: {usage_percent}% used",
yellow.apply_to("\u{26a0}")
),
);
}
AgentEvent::CompactionStarted { .. } => match &self.renderer {
ProgressRenderer::Tty(tty) => {
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
if let Some(old) = stage.compaction_bar.take() {
old.finish_and_clear();
}
let bar = tty
.multi
.insert_after(stage.last_bar(), ProgressBar::new_spinner());
bar.set_style(style_tool_running());
bar.set_message("\u{27f3} compacting context\u{2026}");
bar.enable_steady_tick(Duration::from_millis(100));
stage.compaction_bar = Some(bar);
}
}
ProgressRenderer::Plain => {}
},
AgentEvent::CompactionCompleted {
original_turn_count,
preserved_turn_count,
tracked_file_count,
..
} => {
let msg = format!(
"\u{27f3} compaction: {original_turn_count} \u{2192} {preserved_turn_count} turns, {tracked_file_count} files"
);
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(bar) = self
.active_stages
.get_mut(stage_node_id)
.and_then(|s| s.compaction_bar.take())
{
bar.set_style(style_tool_done());
bar.finish_with_message(msg);
} else {
self.insert_info_line_for_stage(stage_node_id, &msg);
}
}
ProgressRenderer::Plain => {
eprintln!(" {msg}");
}
}
}
AgentEvent::LlmRetry {
model,
attempt,
delay_secs,
error,
..
} if self.verbose => {
let yellow = Style::new().yellow();
let delay_ms = (*delay_secs * 1000.0) as u64;
let dur = format_duration_ms(delay_ms);
self.insert_info_line_for_stage(
stage_node_id,
&format!(
"{} retry: {model} attempt {attempt} ({error}, delay {dur})",
yellow.apply_to("\u{26a0}")
),
);
}
AgentEvent::SubAgentSpawned { agent_id, task, .. } if self.verbose => {
let dim = Style::new().dim();
let short_id = &agent_id[..agent_id.len().min(8)];
self.insert_info_line_for_stage(
stage_node_id,
&dim.apply_to(format!(
"\u{25b8} subagent[{short_id}] \"{}\"",
truncate(task, 50)
))
.to_string(),
);
}
AgentEvent::SubAgentCompleted {
agent_id,
turns_used,
success,
..
} if self.verbose => {
let short_id = &agent_id[..agent_id.len().min(8)];
let glyph = if *success { green_check() } else { red_cross() };
self.insert_info_line_for_stage(
stage_node_id,
&format!("{glyph} subagent[{short_id}] ({turns_used} turns)"),
);
}
_ => {}
}
}
fn on_tool_call_started(
&mut self,
stage_node_id: &str,
tool_name: &str,
tool_call_id: &str,
arguments: &serde_json::Value,
) {
let display_name = tool_display_name(tool_name, arguments);
if let ProgressRenderer::Tty(tty) = &self.renderer {
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
// Evict oldest if at capacity (prefer completed entries); skip in verbose mode
if !self.verbose && stage.tool_calls.len() >= MAX_TOOL_CALLS {
let evict_idx = stage
.tool_calls
.iter()
.position(|e| !matches!(e.status, ToolCallStatus::Running))
.unwrap_or(0);
if let Some(evicted) = stage.tool_calls.remove(evict_idx) {
evicted.bar.finish_and_clear();
}
}
let bar = tty
.multi
.insert_after(stage.last_bar(), ProgressBar::new_spinner());
bar.set_style(style_tool_running());
bar.set_message(display_name.clone());
bar.enable_steady_tick(Duration::from_millis(100));
stage.tool_calls.push_back(ToolCallEntry {
display_name,
tool_call_id: tool_call_id.to_string(),
status: ToolCallStatus::Running,
bar,
is_branch: false,
});
}
}
}
// ── Parallel branches ─────────────────────────────────────────────
fn on_parallel_branch_started(&mut self, branch: &str) {
let parent_id = match &self.parallel_parent {
Some(id) => id.clone(),
None => return,
};
if let ProgressRenderer::Tty(tty) = &self.renderer {
if let Some(stage) = self.active_stages.get_mut(&parent_id) {
let bar = tty
.multi
.insert_after(stage.last_bar(), ProgressBar::new_spinner());
bar.set_style(style_tool_running());
bar.set_message(branch.to_string());
bar.enable_steady_tick(Duration::from_millis(100));
stage.tool_calls.push_back(ToolCallEntry {
display_name: branch.to_string(),
tool_call_id: branch.to_string(),
status: ToolCallStatus::Running,
bar,
is_branch: true,
});
}
}
}
fn on_parallel_branch_completed(&mut self, branch: &str, duration_ms: u64, status: &str) {
let succeeded = matches!(status, "success" | "partial_success");
let glyph = if succeeded {
green_check()
} else {
red_cross()
};
let dur = format_duration_ms(duration_ms);
let parent_id = match &self.parallel_parent {
Some(id) => id.clone(),
None => return,
};
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(stage) = self.active_stages.get_mut(&parent_id) {
if let Some(entry) = stage
.tool_calls
.iter_mut()
.find(|e| e.tool_call_id == branch)
{
entry.status = if succeeded {
ToolCallStatus::Succeeded
} else {
ToolCallStatus::Failed
};
let elapsed = format_duration_short(entry.bar.elapsed());
entry.bar.set_style(style_tool_done());
entry.bar.set_prefix(elapsed);
entry
.bar
.finish_with_message(format!("{glyph} {}", entry.display_name));
}
}
}
ProgressRenderer::Plain => {
eprintln!(" {glyph} {branch} {dur}");
}
}
}
/// Insert a static info line (verbose-only) at the current position.
fn insert_info_line(&mut self, message: &str) {
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = tty.multi.add(ProgressBar::new_spinner());
bar.set_style(style_static_dim());
bar.finish_with_message(message.to_string());
}
ProgressRenderer::Plain => {
eprintln!(" {message}");
}
}
}
/// Insert a static info line nested under a stage's tool calls.
fn insert_info_line_for_stage(&mut self, stage_node_id: &str, message: &str) {
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = if let Some(stage) = self.active_stages.get(stage_node_id) {
tty.multi
.insert_after(stage.last_bar(), ProgressBar::new_spinner())
} else {
tty.multi.add(ProgressBar::new_spinner())
};
bar.set_style(style_tool_done());
bar.finish_with_message(message.to_string());
}
ProgressRenderer::Plain => {
eprintln!(" {message}");
}
}
}
fn on_tool_call_completed(&mut self, stage_node_id: &str, tool_call_id: &str, is_error: bool) {
if let ProgressRenderer::Tty(_) = &self.renderer {
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
if let Some(entry) = stage
.tool_calls
.iter_mut()
.find(|e| e.tool_call_id == tool_call_id)
{
let glyph = if is_error { red_cross() } else { green_check() };
entry.status = if is_error {
ToolCallStatus::Failed
} else {
ToolCallStatus::Succeeded
};
let elapsed = format_duration_short(entry.bar.elapsed());
entry.bar.set_style(style_tool_done());
entry.bar.set_prefix(elapsed);
entry
.bar
.finish_with_message(format!("{glyph} {}", entry.display_name));
}
}
}
}
}
// ── ProgressAwareInterviewer ────────────────────────────────────────────
/// Wraps a `ConsoleInterviewer` so that progress bars are hidden during
/// interactive prompts (avoids garbled output from concurrent writes).
pub struct ProgressAwareInterviewer {
inner: crate::interviewer::console::ConsoleInterviewer,
progress: Arc<Mutex<ProgressUI>>,
}
impl ProgressAwareInterviewer {
pub fn new(
inner: crate::interviewer::console::ConsoleInterviewer,
progress: Arc<Mutex<ProgressUI>>,
) -> Self {
Self { inner, progress }
}
fn hide_bars(&self) {
let ui = self.progress.lock().expect("progress lock poisoned");
if let ProgressRenderer::Tty(tty) = &ui.renderer {
tty.multi.set_draw_target(ProgressDrawTarget::hidden());
}
}
fn show_bars(&self) {
let ui = self.progress.lock().expect("progress lock poisoned");
if let ProgressRenderer::Tty(tty) = &ui.renderer {
tty.multi.set_draw_target(ProgressDrawTarget::stderr());
}
}
}
#[async_trait]
impl Interviewer for ProgressAwareInterviewer {
async fn ask(&self, question: Question) -> Answer {
{
let ui = self.progress.lock().expect("progress lock poisoned");
if let ProgressRenderer::Tty(tty) = &ui.renderer {
let sep = tty.multi.add(ProgressBar::new_spinner());
sep.set_style(style_empty());
sep.finish();
tty.multi.set_draw_target(ProgressDrawTarget::hidden());
}
}
let answer = self.inner.ask(question).await;
self.show_bars();
answer
}
async fn inform(&self, message: &str, stage: &str) {
self.hide_bars();
self.inner.inform(message, stage).await;
self.show_bars();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stage_started(node_id: &str, name: &str) -> WorkflowRunEvent {
WorkflowRunEvent::StageStarted {
node_id: node_id.into(),
name: name.into(),
index: 0,
handler_type: None,
script: None,
attempt: 1,
max_attempts: 1,
}
}
#[test]
fn parallel_branches_tracked_as_tool_calls() {
let mut ui = ProgressUI::new(true, false);
ui.handle_event(&stage_started("fork1", "Fork Analysis"));
assert!(ui.active_stages.contains_key("fork1"));
assert!(ui.parallel_parent.is_none());
ui.handle_event(&WorkflowRunEvent::ParallelStarted {
branch_count: 2,
join_policy: "wait_all".into(),
error_policy: "continue".into(),
});
assert_eq!(ui.parallel_parent.as_deref(), Some("fork1"));
// Branch started → creates a tool_call entry
ui.handle_event(&WorkflowRunEvent::ParallelBranchStarted {
branch: "security".into(),
index: 0,
});
let stage = ui.active_stages.get("fork1").unwrap();
assert_eq!(stage.tool_calls.len(), 1);
assert_eq!(stage.tool_calls[0].tool_call_id, "security");
assert!(matches!(
stage.tool_calls[0].status,
ToolCallStatus::Running
));
// Branch completed → marks entry as succeeded
ui.handle_event(&WorkflowRunEvent::ParallelBranchCompleted {
branch: "security".into(),
index: 0,
duration_ms: 2000,
status: "success".into(),
});
let stage = ui.active_stages.get("fork1").unwrap();
assert!(matches!(
stage.tool_calls[0].status,
ToolCallStatus::Succeeded
));
// Second branch
ui.handle_event(&WorkflowRunEvent::ParallelBranchStarted {
branch: "quality".into(),
index: 1,
});
ui.handle_event(&WorkflowRunEvent::ParallelBranchCompleted {
branch: "quality".into(),
index: 1,
duration_ms: 3000,
status: "success".into(),
});
let stage = ui.active_stages.get("fork1").unwrap();
assert_eq!(stage.tool_calls.len(), 2);
// Parallel completed → clears parent
ui.handle_event(&WorkflowRunEvent::ParallelCompleted {
duration_ms: 3000,
success_count: 2,
failure_count: 0,
});
assert!(ui.parallel_parent.is_none());
}
#[test]
fn parallel_branch_failure_tracked() {
let mut ui = ProgressUI::new(true, false);
ui.handle_event(&stage_started("fork1", "Fork"));
ui.handle_event(&WorkflowRunEvent::ParallelStarted {
branch_count: 1,
join_policy: "wait_all".into(),
error_policy: "continue".into(),
});
ui.handle_event(&WorkflowRunEvent::ParallelBranchStarted {
branch: "risky".into(),
index: 0,
});
ui.handle_event(&WorkflowRunEvent::ParallelBranchCompleted {
branch: "risky".into(),
index: 0,
duration_ms: 500,
status: "fail".into(),
});
let stage = ui.active_stages.get("fork1").unwrap();
assert!(matches!(stage.tool_calls[0].status, ToolCallStatus::Failed));
}
#[test]
fn compaction_sets_and_clears_bar() {
let mut ui = ProgressUI::new(true, false);
ui.handle_event(&stage_started("s1", "Build"));
assert!(ui.active_stages["s1"].compaction_bar.is_none());
ui.handle_event(&WorkflowRunEvent::Agent {
stage: "s1".into(),
event: AgentEvent::CompactionStarted {
estimated_tokens: 5000,
context_window_size: 8000,
},
});
assert!(ui.active_stages["s1"].compaction_bar.is_some());
ui.handle_event(&WorkflowRunEvent::Agent {
stage: "s1".into(),
event: AgentEvent::CompactionCompleted {
original_turn_count: 20,
preserved_turn_count: 6,
summary_token_estimate: 500,
tracked_file_count: 3,
},
});
assert!(ui.active_stages["s1"].compaction_bar.is_none());
}
#[test]
fn plain_mode_sets_parallel_parent() {
let mut ui = ProgressUI::new(false, false);
ui.handle_event(&stage_started("fork1", "Fork"));
ui.handle_event(&WorkflowRunEvent::ParallelStarted {
branch_count: 2,
join_policy: "wait_all".into(),
error_policy: "continue".into(),
});
// In Plain mode, active_stages is empty so parallel_parent is a sentinel
assert!(ui.parallel_parent.is_some());
ui.handle_event(&WorkflowRunEvent::ParallelCompleted {
duration_ms: 1000,
success_count: 2,
failure_count: 0,
});
assert!(ui.parallel_parent.is_none());
}
}