Add indicatif-based progress UI for arc run start (non-verbose)

Replace plain dimmed text output with cargo/BuildKit-style progress bars
using indicatif MultiProgress. Shows spinners during active stages and
tool calls, right-aligned timers, green/red glyphs for completion status,
and stage costs. Falls back to simple eprintln for non-TTY. Verbose mode
is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 23:58:43 -05:00
parent fb47745cc2
commit c374ceb391
5 changed files with 584 additions and 35 deletions

1
Cargo.lock generated
View file

@ -323,6 +323,7 @@ dependencies = [
"base64",
"chrono",
"clap",
"console 0.15.11",
"daytona-api-client",
"daytona-sdk",
"dialoguer",

View file

@ -40,6 +40,7 @@ base64.workspace = true
regex.workspace = true
scopeguard = "1"
git2.workspace = true
console.workspace = true
indicatif.workspace = true
tokio-util.workspace = true
tracing.workspace = true

View file

@ -1,5 +1,6 @@
pub mod backend;
pub mod cli_backend;
pub mod progress;
pub mod run;
pub mod runs;
pub mod run_config;

View file

@ -0,0 +1,561 @@
use std::collections::{HashMap, VecDeque};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
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::StageUsage;
use arc_agent::AgentEvent;
use super::{compute_stage_cost, format_cost};
// ── Style helpers ───────────────────────────────────────────────────────
fn style_header_running() -> ProgressStyle {
ProgressStyle::with_template(" {spinner:.dim} {wide_msg} {elapsed:.dim}")
.expect("valid template")
}
fn style_header_done() -> ProgressStyle {
ProgressStyle::with_template(" {wide_msg} {prefix:.dim}")
.expect("valid template")
}
fn style_stage_running() -> ProgressStyle {
ProgressStyle::with_template(" {spinner:.cyan} {wide_msg} {elapsed:.dim}")
.expect("valid template")
}
fn style_stage_done() -> ProgressStyle {
ProgressStyle::with_template(" {wide_msg} {prefix:.dim}")
.expect("valid template")
}
fn style_tool_running() -> ProgressStyle {
ProgressStyle::with_template(" {spinner:.dim} {wide_msg} {elapsed:.dim}")
.expect("valid template")
}
fn style_tool_done() -> ProgressStyle {
ProgressStyle::with_template(" {wide_msg}").expect("valid template")
}
fn style_static_dim() -> ProgressStyle {
ProgressStyle::with_template(" {wide_msg:.dim}").expect("valid template")
}
// ── Duration formatting ─────────────────────────────────────────────────
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))
}
// ── Tool call display name ──────────────────────────────────────────────
fn tool_display_name(tool_name: &str, arguments: &serde_json::Value) -> String {
if tool_name == "bash" || tool_name == "execute_command" {
if let Some(cmd) = arguments
.get("command")
.and_then(|v| v.as_str())
{
let truncated = if cmd.len() > 60 {
format!("{}...", &cmd[..57])
} else {
cmd.to_string()
};
return format!("bash: {truncated}");
}
}
tool_name.to_string()
}
// ── ANSI styled glyphs ─────────────────────────────────────────────────
fn green_check() -> String {
Style::new().green().apply_to("").to_string()
}
fn red_cross() -> String {
Style::new().red().apply_to("").to_string()
}
// ── Tool call entry ─────────────────────────────────────────────────────
enum ToolCallStatus {
Running,
Succeeded,
Failed,
}
struct ToolCallEntry {
display_name: String,
tool_call_id: String,
#[allow(dead_code)]
started_at: Instant,
status: ToolCallStatus,
bar: ProgressBar,
}
// ── Active stage ────────────────────────────────────────────────────────
struct ActiveStage {
name: String,
spinner: ProgressBar,
tool_calls: VecDeque<ToolCallEntry>,
}
const MAX_TOOL_CALLS: usize = 5;
// ── Renderer variants ───────────────────────────────────────────────────
struct TtyRenderer {
multi: MultiProgress,
}
enum ProgressRenderer {
Tty(TtyRenderer),
Plain,
}
// ── ProgressUI ──────────────────────────────────────────────────────────
pub struct ProgressUI {
renderer: ProgressRenderer,
active_stages: HashMap<String, ActiveStage>,
setup_command_count: usize,
sandbox_bar: Option<ProgressBar>,
setup_bar: Option<ProgressBar>,
logs_bar: Option<ProgressBar>,
}
impl ProgressUI {
pub fn new(is_tty: bool) -> Self {
let renderer = if is_tty {
ProgressRenderer::Tty(TtyRenderer {
multi: MultiProgress::new(),
})
} else {
ProgressRenderer::Plain
};
Self {
renderer,
active_stages: HashMap::new(),
setup_command_count: 0,
sandbox_bar: None,
setup_bar: None,
logs_bar: None,
}
}
/// Register event handlers on the emitter.
/// The closure captures an `Arc<Mutex<Self>>` for interior mutation.
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 before the summary block is printed.
pub fn finish(&mut self) {
// Clear any remaining tool call bars and stage spinners
for (_id, stage) in self.active_stages.drain() {
for entry in &stage.tool_calls {
entry.bar.finish_and_clear();
}
stage.spinner.finish_and_clear();
}
}
// ── 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::WorkflowRunStarted { .. } => {
// Logs dir is printed by the caller — we don't have the path here.
// This event is handled externally to show the logs line.
}
WorkflowRunEvent::StageStarted { node_id, name, .. } => {
self.on_stage_started(node_id, name);
}
WorkflowRunEvent::StageCompleted {
node_id,
name,
duration_ms,
status,
usage,
..
} => {
self.on_stage_completed(node_id, name, *duration_ms, status, usage.as_ref());
}
WorkflowRunEvent::StageFailed {
node_id, name, ..
} => {
self.on_stage_failed(node_id, name);
}
WorkflowRunEvent::Agent { stage, event } => {
self.on_agent_event(stage, event);
}
_ => {}
}
}
// ── Sandbox ─────────────────────────────────────────────────────────
fn on_sandbox_event(&mut self, event: &arc_agent::SandboxEvent) {
use arc_agent::SandboxEvent;
match event {
SandboxEvent::Initializing { provider } => {
match &self.renderer {
ProgressRenderer::Tty(tty) => {
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);
}
ProgressRenderer::Plain => {}
}
}
SandboxEvent::Ready {
provider,
duration_ms,
} => {
let dur = format_duration_ms(*duration_ms);
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(bar) = self.sandbox_bar.take() {
bar.set_style(style_header_done());
bar.set_prefix(dur);
bar.finish_with_message(format!("Sandbox: {provider}"));
}
}
ProgressRenderer::Plain => {
eprintln!(" Sandbox: {provider} (ready in {dur})");
}
}
}
_ => {}
}
}
// ── Setup ───────────────────────────────────────────────────────────
fn on_setup_started(&mut self, command_count: usize) {
self.setup_command_count = command_count;
match &self.renderer {
ProgressRenderer::Tty(tty) => {
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);
}
ProgressRenderer::Plain => {}
}
}
fn on_setup_completed(&mut self, duration_ms: u64) {
let dur = format_duration_ms(duration_ms);
let count = self.setup_command_count;
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{}",
if count == 1 { "" } else { "s" }
));
}
}
ProgressRenderer::Plain => {
eprintln!(
" Setup: {count} command{} ({dur})",
if count == 1 { "" } else { "s" }
);
}
}
}
// ── Logs dir (called externally after WorkflowRunStarted) ───────────
pub fn show_logs_dir(&mut self, logs_dir: &Path) {
let path_str = logs_dir.display().to_string();
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}"));
// Add a blank separator after header section
let sep = tty.multi.add(ProgressBar::new_spinner());
sep.set_style(ProgressStyle::with_template("").expect("valid template"));
sep.finish();
self.logs_bar = Some(bar);
}
ProgressRenderer::Plain => {
eprintln!(" Logs: {path_str}");
}
}
}
// ── Stages ──────────────────────────────────────────────────────────
fn on_stage_started(&mut self, node_id: &str, name: &str) {
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = tty.multi.add(ProgressBar::new_spinner());
bar.set_style(style_stage_running());
bar.set_message(name.to_string());
bar.enable_steady_tick(Duration::from_millis(100));
self.active_stages.insert(
node_id.to_string(),
ActiveStage {
name: name.to_string(),
spinner: bar,
tool_calls: VecDeque::new(),
},
);
}
ProgressRenderer::Plain => {}
}
}
fn on_stage_completed(
&mut self,
node_id: &str,
name: &str,
duration_ms: u64,
status: &str,
usage: Option<&StageUsage>,
) {
let dur = format_duration_ms(duration_ms);
let cost_str = usage
.and_then(compute_stage_cost)
.map(|c| format!(" {}", format_cost(c)))
.unwrap_or_default();
let succeeded = status == "success" || status == "partial_success";
let glyph = if succeeded { green_check() } else { red_cross() };
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(stage) = self.active_stages.remove(node_id) {
// Clear tool call bars
for entry in &stage.tool_calls {
entry.bar.finish_and_clear();
}
// Settle the stage bar
stage.spinner.set_style(style_stage_done());
stage.spinner.set_prefix(format!("{dur}{cost_str}"));
stage
.spinner
.finish_with_message(format!("{glyph} {}", stage.name));
}
}
ProgressRenderer::Plain => {
eprintln!(" {glyph} {name} {dur}{cost_str}");
}
}
}
fn on_stage_failed(&mut self, node_id: &str, name: &str) {
let glyph = red_cross();
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(stage) = self.active_stages.remove(node_id) {
for entry in &stage.tool_calls {
entry.bar.finish_and_clear();
}
stage.spinner.set_style(style_stage_done());
stage.spinner.set_prefix("");
stage
.spinner
.finish_with_message(format!("{glyph} {}", stage.name));
}
}
ProgressRenderer::Plain => {
eprintln!(" {glyph} {name}");
}
}
}
// ── Agent / tool calls ──────────────────────────────────────────────
fn on_agent_event(&mut self, stage_node_id: &str, event: &AgentEvent) {
match event {
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,
tool_name,
..
} => {
self.on_tool_call_completed(stage_node_id, tool_name, tool_call_id, *is_error);
}
_ => {}
}
}
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);
match &self.renderer {
ProgressRenderer::Tty(tty) => {
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
// Evict oldest if at capacity
if stage.tool_calls.len() >= MAX_TOOL_CALLS {
// Prefer evicting completed entries first
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.tool_calls.back().map_or(&stage.spinner, |e| &e.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(),
started_at: Instant::now(),
status: ToolCallStatus::Running,
bar,
});
}
}
ProgressRenderer::Plain => {}
}
}
fn on_tool_call_completed(
&mut self,
stage_node_id: &str,
tool_name: &str,
tool_call_id: &str,
is_error: bool,
) {
let _ = tool_name; // display_name already stored in entry
match &self.renderer {
ProgressRenderer::Tty(_) => {
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
};
entry.bar.set_style(style_tool_done());
entry
.bar
.finish_with_message(format!("{glyph} {}", entry.display_name));
}
}
}
ProgressRenderer::Plain => {}
}
}
}
// ── 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 {
self.hide_bars();
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();
}
}

View file

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Instant;
@ -25,6 +26,7 @@ use arc_llm::provider::Provider;
use super::backend::AgentApiBackend;
use super::cli_backend::{BackendRouter, AgentCliBackend};
use super::progress;
use super::run_config;
use super::run_config::{RunDefaults, WorkflowRunConfig};
use indicatif::HumanDuration;
@ -270,12 +272,18 @@ pub async fn run_command(
}
}
// Create progress UI (used for non-verbose mode)
let is_tty = std::io::stderr().is_terminal();
let progress_ui = Arc::new(Mutex::new(progress::ProgressUI::new(is_tty)));
if args.verbose {
eprintln!(
"{} {}",
styles.dim.apply_to("Logs:"),
styles.underline.apply_to(logs_dir.display()),
);
} else {
progress_ui.lock().expect("progress lock poisoned").show_logs_dir(&logs_dir);
}
// 3. Build event emitter
@ -368,47 +376,19 @@ pub async fn run_command(
eprintln!("{}", format_event_summary(event, styles));
});
} else {
emitter.on_event(move |event| match event {
crate::event::WorkflowRunEvent::StageCompleted {
name,
duration_ms,
status,
usage,
.. // node_id and other fields
} => {
let mut line = format!(
"Stage \"{name}\" completed ({status}) in {}",
HumanDuration(Duration::from_millis(*duration_ms)),
);
if let Some(u) = usage {
let total = u.input_tokens + u.output_tokens;
let tokens_str = format_tokens_human(total);
if let Some(cost) = compute_stage_cost(u) {
line.push_str(&format!(
" \u{2014} {tokens_str} tokens ({})",
format_cost(cost)
));
} else {
line.push_str(&format!(" \u{2014} {tokens_str} tokens"));
}
}
eprintln!("{}", styles.dim.apply_to(line));
}
crate::event::WorkflowRunEvent::StageFailed { name, .. } => {
eprintln!(
"{}",
styles.dim.apply_to(format!("Stage \"{name}\" failed")),
);
}
_ => {}
});
progress::ProgressUI::register(&progress_ui, &mut emitter);
}
// 4. Build interviewer
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
Arc::new(AutoApproveInterviewer)
} else {
} else if args.verbose {
Arc::new(ConsoleInterviewer::new(styles))
} else {
Arc::new(progress::ProgressAwareInterviewer::new(
ConsoleInterviewer::new(styles),
Arc::clone(&progress_ui),
))
};
// Set up git worktree for local execution (must happen before cwd is captured)
@ -707,6 +687,11 @@ pub async fn run_command(
let outcome = engine_result?;
// Finish progress bars before printing summary
if !args.verbose {
progress_ui.lock().expect("progress lock poisoned").finish();
}
// 8. Print result
eprintln!(
"\n{}",