Redesign --verbose mode to use ProgressUI instead of raw event logs

Both verbose and normal modes now use the same indicatif-based ProgressUI
renderer with the same visual hierarchy (indentation, glyphs, colors).
In verbose mode: tool calls persist after stage completion, no 5-call cap,
stage completion shows stats (turns, tool calls, tokens), and additional
events are rendered (edge transitions, loop restarts, setup commands,
retries, context warnings, compaction, subagents).

Remove the old format_event_summary function and its ~70 tests, the
verbose stderr printing from AgentApiBackend, and the verbose/styles
fields that are no longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-04 09:48:10 -05:00
parent 45165c1fa3
commit c959368452
6 changed files with 247 additions and 1548 deletions

View file

@ -107,8 +107,6 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
Some(Box::new(AgentApiBackend::new(
model.clone(),
provider_enum,
false,
styles,
)))
}
})

View file

@ -10,7 +10,6 @@ use arc_agent::{
};
use arc_llm::client::Client;
use arc_llm::provider::Provider;
use arc_util::terminal::Styles;
use crate::context::Context;
use crate::error::ArcError;
@ -25,19 +24,15 @@ use crate::outcome::StageUsage;
pub struct AgentApiBackend {
model: String,
provider: Provider,
verbose: bool,
styles: &'static Styles,
sessions: Mutex<HashMap<String, Session>>,
}
impl AgentApiBackend {
#[must_use]
pub fn new(model: String, provider: Provider, verbose: bool, styles: &'static Styles) -> Self {
pub fn new(model: String, provider: Provider) -> Self {
Self {
model,
provider,
verbose,
styles,
sessions: Mutex::new(HashMap::new()),
}
}
@ -224,10 +219,8 @@ impl CodergenBackend for AgentApiBackend {
let pending_clone = Arc::clone(&pending_tool_calls);
let files_clone = Arc::clone(&files_touched);
// Subscribe to session events: forward to pipeline emitter and optionally print to stderr.
let verbose = self.verbose;
// Subscribe to session events: forward to pipeline emitter.
let node_id = node.id.clone();
let styles = self.styles;
let pipeline_emitter = Arc::clone(emitter);
let mut rx = session.subscribe();
tokio::spawn(async move {
@ -282,34 +275,6 @@ impl CodergenBackend for AgentApiBackend {
});
}
// Verbose stderr printing (gated on verbosity)
if verbose {
match &event.event {
AgentEvent::ToolCallStarted {
tool_name,
arguments,
..
} => {
eprintln!(
"{} {} {}{}",
styles.dim.apply_to(format!("[{node_id}]")),
styles.dim.apply_to("\u{25cf}"),
styles.bold_cyan.apply_to(tool_name),
styles
.dim
.apply_to(format!("({})", format_tool_args(arguments))),
);
}
AgentEvent::Error { error } => {
eprintln!(
"{} {}",
styles.dim.apply_to(format!("[{node_id}]")),
styles.red.apply_to(format!("\u{2717} {error}")),
);
}
_ => {}
}
}
}
});
@ -339,15 +304,9 @@ impl CodergenBackend for AgentApiBackend {
result?;
// Aggregate token usage only from new turns (prevents double-counting on reuse).
let (mut turn_count, mut tool_call_count) = (0usize, 0usize);
let mut total_usage = arc_llm::types::Usage::default();
for turn in &session.history().turns()[turns_before..] {
if let Turn::Assistant {
tool_calls, usage, ..
} = turn
{
turn_count += 1;
tool_call_count += tool_calls.len();
if let Turn::Assistant { usage, .. } = turn {
total_usage = total_usage + usage.clone();
}
}
@ -363,20 +322,6 @@ impl CodergenBackend for AgentApiBackend {
};
stage_usage.cost = super::compute_stage_cost(&stage_usage);
// Print session summary to stderr.
if self.verbose {
let total_tokens = total_usage.input_tokens + total_usage.output_tokens;
let token_str = super::format_tokens_human(total_tokens);
let reuse_label = if is_reused { " (reused session)" } else { "" };
eprintln!(
"{}",
self.styles.dim.apply_to(format!(
"[{}] Done ({turn_count} turns, {tool_call_count} tool calls, {token_str} tokens{reuse_label})",
node.id,
)),
);
}
// Extract last assistant response from the session history.
let response = session
.history()
@ -423,26 +368,6 @@ impl CodergenBackend for AgentApiBackend {
}
}
fn format_tool_args(args: &serde_json::Value) -> String {
let Some(obj) = args.as_object() else {
return args.to_string();
};
obj.iter()
.map(|(k, v)| match v {
serde_json::Value::String(s) => {
let display = if s.len() > 80 {
format!("{}...", &s[..77])
} else {
s.clone()
};
format!("{k}={display:?}")
}
other => format!("{k}={other}"),
})
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
@ -450,39 +375,20 @@ mod tests {
#[test]
fn agent_backend_stores_config() {
let styles = Box::leak(Box::new(Styles::new(false)));
let backend = AgentApiBackend::new(
"claude-opus-4-6".to_string(),
Provider::OpenAi,
true,
styles,
);
let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::OpenAi);
assert_eq!(backend.model, "claude-opus-4-6");
assert_eq!(backend.provider, Provider::OpenAi);
assert!(backend.verbose);
}
#[test]
fn agent_backend_initializes_empty_sessions() {
let styles = Box::leak(Box::new(Styles::new(false)));
let backend = AgentApiBackend::new(
"claude-opus-4-6".to_string(),
Provider::Anthropic,
false,
styles,
);
let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::Anthropic);
assert!(backend.sessions.lock().unwrap().is_empty());
}
#[test]
fn build_profile_can_register_subagent_tools() {
let styles = Box::leak(Box::new(Styles::new(false)));
let backend = AgentApiBackend::new(
"claude-opus-4-6".to_string(),
Provider::Anthropic,
false,
styles,
);
let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::Anthropic);
let mut profile = backend.build_profile();
let manager = Arc::new(tokio::sync::Mutex::new(SubAgentManager::new(1)));
let factory: SessionFactory = Arc::new(|| {

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ use crate::interviewer::{Answer, Interviewer, Question};
use crate::outcome::StageStatus;
use arc_agent::AgentEvent;
use super::{compute_stage_cost, format_cost};
use super::{compute_stage_cost, format_cost, format_tokens_human};
// ── Cached styles ───────────────────────────────────────────────────────
@ -159,6 +159,8 @@ struct ActiveStage {
has_model: bool,
spinner: ProgressBar,
tool_calls: VecDeque<ToolCallEntry>,
turn_count: u32,
tool_call_count: u32,
}
const MAX_TOOL_CALLS: usize = 5;
@ -178,6 +180,7 @@ enum ProgressRenderer {
pub struct ProgressUI {
renderer: ProgressRenderer,
verbose: bool,
active_stages: HashMap<String, ActiveStage>,
setup_command_count: usize,
sandbox_bar: Option<ProgressBar>,
@ -187,7 +190,7 @@ pub struct ProgressUI {
}
impl ProgressUI {
pub fn new(is_tty: bool) -> Self {
pub fn new(is_tty: bool, verbose: bool) -> Self {
let renderer = if is_tty {
ProgressRenderer::Tty(TtyRenderer {
multi: MultiProgress::new(),
@ -197,6 +200,7 @@ impl ProgressUI {
};
Self {
renderer,
verbose,
active_stages: HashMap::new(),
setup_command_count: 0,
sandbox_bar: None,
@ -219,7 +223,7 @@ impl ProgressUI {
pub fn finish(&mut self) {
for (_id, stage) in self.active_stages.drain() {
for entry in &stage.tool_calls {
if entry.is_branch {
if entry.is_branch || self.verbose {
entry.bar.abandon();
} else {
entry.bar.finish_and_clear();
@ -277,7 +281,32 @@ impl ProgressUI {
.and_then(compute_stage_cost)
.map(|c| format!("{} ", format_cost(c)))
.unwrap_or_default();
let prefix = format!("{cost_str}{dur}");
let stats_str = if self.verbose {
let stage = self.active_stages.get(node_id);
let turn_count = stage.map_or(0, |s| s.turn_count);
let tool_call_count = stage.map_or(0, |s| s.tool_call_count);
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 {
@ -318,6 +347,74 @@ impl ProgressUI {
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})"
));
}
_ => {}
}
}
@ -474,6 +571,8 @@ impl ProgressUI {
has_model: false,
spinner: bar,
tool_calls: VecDeque::new(),
turn_count: 0,
tool_call_count: 0,
},
);
}
@ -484,8 +583,8 @@ impl ProgressUI {
ProgressRenderer::Tty(_) => {
if let Some(stage) = self.active_stages.remove(node_id) {
for entry in &stage.tool_calls {
if entry.is_branch {
// Already finished by on_parallel_branch_completed; keep visible
if entry.is_branch || self.verbose {
// Keep visible: branches always, all entries in verbose mode
entry.bar.abandon();
} else {
entry.bar.finish_and_clear();
@ -515,6 +614,7 @@ impl ProgressUI {
AgentEvent::AssistantMessage { model, .. } => {
if let ProgressRenderer::Tty(_) = &self.renderer {
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
stage.turn_count += 1;
if !stage.has_model {
stage.has_model = true;
let dim = Style::new().dim();
@ -537,8 +637,89 @@ impl ProgressUI {
is_error,
..
} => {
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
stage.tool_call_count += 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::CompactionCompleted {
original_turn_count,
preserved_turn_count,
tracked_file_count,
..
} if self.verbose => {
let dim = Style::new().dim();
self.insert_info_line_for_stage(
stage_node_id,
&format!(
"{}",
dim.apply_to(format!(
"\u{27f3} compaction: {original_turn_count} \u{2192} {preserved_turn_count} turns, {tracked_file_count} files"
))
),
);
}
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,
&format!(
"{}",
dim.apply_to(format!(
"\u{25b8} subagent[{short_id}] \"{}\"",
truncate(task, 50)
))
),
);
}
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)"),
);
}
_ => {}
}
}
@ -554,8 +735,8 @@ impl ProgressUI {
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)
if stage.tool_calls.len() >= MAX_TOOL_CALLS {
// 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()
@ -649,6 +830,43 @@ impl ProgressUI {
}
}
/// 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 after = self
.active_stages
.get(stage_node_id)
.map(|s| s.tool_calls.back().map_or(&s.spinner, |e| &e.bar));
let bar = if let Some(after_bar) = after {
tty.multi
.insert_after(after_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) {
@ -749,7 +967,7 @@ mod tests {
#[test]
fn parallel_branches_tracked_as_tool_calls() {
let mut ui = ProgressUI::new(true);
let mut ui = ProgressUI::new(true, false);
ui.handle_event(&stage_started("fork1", "Fork Analysis"));
assert!(ui.active_stages.contains_key("fork1"));
@ -810,7 +1028,7 @@ mod tests {
#[test]
fn parallel_branch_failure_tracked() {
let mut ui = ProgressUI::new(true);
let mut ui = ProgressUI::new(true, false);
ui.handle_event(&stage_started("fork1", "Fork"));
ui.handle_event(&WorkflowRunEvent::ParallelStarted {
@ -838,7 +1056,7 @@ mod tests {
#[test]
fn plain_mode_sets_parallel_parent() {
let mut ui = ProgressUI::new(false);
let mut ui = ProgressUI::new(false, false);
ui.handle_event(&stage_started("fork1", "Fork"));
ui.handle_event(&WorkflowRunEvent::ParallelStarted {

View file

@ -31,8 +31,8 @@ use indicatif::HumanDuration;
use std::time::Duration;
use super::{
compute_stage_cost, format_cost, format_event_summary, format_tokens_human, print_diagnostics,
read_dot_file, RunArgs, SandboxProvider,
compute_stage_cost, format_cost, format_tokens_human, print_diagnostics, read_dot_file,
RunArgs, SandboxProvider,
};
/// Return the default model string for a given provider.
@ -287,22 +287,14 @@ pub async fn run_command(
}
}
// Create progress UI (used for non-verbose mode)
// Create progress UI (used for both normal and verbose modes)
let is_tty = std::io::stderr().is_terminal();
let progress_ui = Arc::new(Mutex::new(progress::ProgressUI::new(is_tty)));
let progress_ui = Arc::new(Mutex::new(progress::ProgressUI::new(is_tty, args.verbose)));
if args.verbose {
eprintln!(
"{} {}",
styles.dim.apply_to("Logs:"),
styles.underline.apply_to(super::tilde_path(&logs_dir)),
);
} else {
progress_ui
.lock()
.expect("progress lock poisoned")
.show_logs_dir(&logs_dir);
}
progress_ui
.lock()
.expect("progress lock poisoned")
.show_logs_dir(&logs_dir);
// 3. Build event emitter
let mut emitter = EventEmitter::new();
@ -385,19 +377,11 @@ pub async fn run_command(
});
}
if args.verbose {
emitter.on_event(move |event| {
eprintln!("{}", format_event_summary(event, styles));
});
} else {
progress::ProgressUI::register(&progress_ui, &mut emitter);
}
progress::ProgressUI::register(&progress_ui, &mut emitter);
// 4. Build interviewer
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
Arc::new(AutoApproveInterviewer)
} else if args.verbose {
Arc::new(ConsoleInterviewer::new(styles))
} else {
Arc::new(progress::ProgressAwareInterviewer::new(
ConsoleInterviewer::new(styles),
@ -617,7 +601,7 @@ pub async fn run_command(
if dry_run_mode {
None
} else {
let api = AgentApiBackend::new(model.clone(), provider_enum, args.verbose, styles);
let api = AgentApiBackend::new(model.clone(), provider_enum);
let cli = AgentCliBackend::new(model.clone(), provider_enum);
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
}
@ -700,9 +684,7 @@ pub async fn run_command(
}
// Finish progress bars before printing summary
if !args.verbose {
progress_ui.lock().expect("progress lock poisoned").finish();
}
progress_ui.lock().expect("progress lock poisoned").finish();
// Auto-derive retro (always, cheap) and optionally run retro agent
if !args.no_retro {
@ -1005,7 +987,7 @@ async fn run_from_branch(
if dry_run_mode {
None
} else {
let api = AgentApiBackend::new(model.clone(), provider_enum, args.verbose, styles);
let api = AgentApiBackend::new(model.clone(), provider_enum);
let cli = AgentCliBackend::new(model.clone(), provider_enum);
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
}

View file

@ -7204,8 +7204,6 @@ async fn arc_e2e_with_real_llm() {
Some(Box::new(AgentApiBackend::new(
model.clone(),
Provider::Anthropic,
false,
&TEST_STYLES,
))
as Box<dyn arc_workflows::handler::codergen::CodergenBackend>)
});