diff --git a/Cargo.lock b/Cargo.lock index 21a47c1e8..e0c6a45fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,7 @@ dependencies = [ "futures", "glob", "htmd", + "indicatif", "jsonschema", "libc", "paste", @@ -327,6 +328,7 @@ dependencies = [ "dotenvy", "futures", "git2", + "indicatif", "nom", "predicates", "rand 0.8.5", @@ -2020,6 +2022,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console 0.16.2", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2811,6 +2826,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -4683,6 +4704,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 4586d485e..8d46c707d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ semver = "1" aho-corasick = "1" sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono"] } dirs = "6" +indicatif = "0.18" toml = "0.8" jsonwebtoken = "9" daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", package = "daytona-sdk" } diff --git a/crates/arc-agent/Cargo.toml b/crates/arc-agent/Cargo.toml index 9a8cbb8d4..a68fdc448 100644 --- a/crates/arc-agent/Cargo.toml +++ b/crates/arc-agent/Cargo.toml @@ -37,6 +37,7 @@ tokio-util.workspace = true tracing.workspace = true dirs = "6" glob = "0.3" +indicatif.workspace = true shell-escape = "0.1" htmd = "0.5" bollard = { workspace = true, optional = true } diff --git a/crates/arc-agent/src/cli.rs b/crates/arc-agent/src/cli.rs index e0ad6237d..34c193d4f 100644 --- a/crates/arc-agent/src/cli.rs +++ b/crates/arc-agent/src/cli.rs @@ -239,15 +239,11 @@ fn print_summary(session: &Session, styles: &Styles) { total_tokens += usage.total_tokens; } } - let token_str = if total_tokens >= 1000 { - format!("{}k tokens", total_tokens / 1000) - } else { - format!("{total_tokens} tokens") - }; eprintln!( "{}", styles.dim.apply_to(format!( - "Done ({turn_count} turns, {tool_call_count} tool calls, {token_str})" + "Done ({turn_count} turns, {tool_call_count} tool calls, {} tokens)", + indicatif::HumanCount(total_tokens as u64), )), ); } diff --git a/crates/arc-workflows/Cargo.toml b/crates/arc-workflows/Cargo.toml index 84676873e..9021d511b 100644 --- a/crates/arc-workflows/Cargo.toml +++ b/crates/arc-workflows/Cargo.toml @@ -40,6 +40,7 @@ base64.workspace = true regex.workspace = true scopeguard = "1" git2.workspace = true +indicatif.workspace = true tokio-util.workspace = true tracing.workspace = true [dev-dependencies] diff --git a/crates/arc-workflows/src/cli/backend.rs b/crates/arc-workflows/src/cli/backend.rs index f08813308..08982a524 100644 --- a/crates/arc-workflows/src/cli/backend.rs +++ b/crates/arc-workflows/src/cli/backend.rs @@ -363,18 +363,14 @@ impl CodergenBackend for AgentApiBackend { // Print session summary to stderr. if self.verbose { - let total_tokens = total_usage.input_tokens + total_usage.output_tokens; - let token_str = if total_tokens >= 1000 { - format!("{}k tokens", total_tokens / 1000) - } else { - format!("{total_tokens} tokens") - }; + let total_tokens = (total_usage.input_tokens + total_usage.output_tokens) as u64; 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}{reuse_label})", + "[{}] Done ({turn_count} turns, {tool_call_count} tool calls, {} tokens{reuse_label})", node.id, + indicatif::HumanCount(total_tokens), )), ); } diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index e9a450d65..be88f9ca3 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -9,6 +9,7 @@ use std::path::Path; use arc_util::terminal::Styles; use clap::{Args, Parser, Subcommand, ValueEnum}; +use indicatif::{HumanBytes, HumanCount}; use std::fmt; use std::path::PathBuf; use std::str::FromStr; @@ -168,26 +169,6 @@ pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) { } } -/// Format milliseconds into a human-readable duration string. -/// -/// - < 1000ms: `123ms` -/// - < 60s: `12.3s` -/// - >= 60s: `1m 23s` -#[must_use] -pub fn format_duration_human(ms: u64) -> String { - if ms < 1000 { - format!("{ms}ms") - } else if ms < 60_000 { - let secs = ms as f64 / 1000.0; - format!("{secs:.1}s") - } else { - let total_secs = ms / 1000; - let minutes = total_secs / 60; - let secs = total_secs % 60; - format!("{minutes}m {secs}s") - } -} - /// One-line summary of a workflow run event for `-v` output (dimmed). #[must_use] pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String { @@ -254,12 +235,11 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String )); } if let Some(u) = usage { - let total = u.input_tokens + u.output_tokens; - let tokens_str = format_tokens_human(total); + let total = (u.input_tokens + u.output_tokens) as u64; if let Some(cost) = compute_stage_cost(u) { - s.push_str(&format!(" tokens={tokens_str} cost={}", format_cost(cost))); + s.push_str(&format!(" tokens={} cost={}", HumanCount(total), format_cost(cost))); } else { - s.push_str(&format!(" tokens={tokens_str}")); + s.push_str(&format!(" tokens={}", HumanCount(total))); } } if let Some(ref f) = failure { @@ -385,14 +365,13 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String tool_call_count, .. } => { - let total = usage.input_tokens + usage.output_tokens; - let tokens_str = format_tokens_human(total); - let mut s = format!("[ASSISTANT_MESSAGE] stage={stage} model={model} tokens={tokens_str} tool_calls={tool_call_count}"); + let total = (usage.input_tokens + usage.output_tokens) as u64; + let mut s = format!("[ASSISTANT_MESSAGE] stage={stage} model={model} tokens={} tool_calls={tool_call_count}", HumanCount(total)); if let Some(cache_read) = usage.cache_read_tokens { - s.push_str(&format!(" cache_read={}", format_tokens_human(cache_read))); + s.push_str(&format!(" cache_read={}", HumanCount(cache_read as u64))); } if let Some(reasoning) = usage.reasoning_tokens { - s.push_str(&format!(" reasoning={}", format_tokens_human(reasoning))); + s.push_str(&format!(" reasoning={}", HumanCount(reasoning as u64))); } s } @@ -558,7 +537,7 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String format!("[STALL_WATCHDOG_TIMEOUT] node={node} idle_seconds={idle_seconds}") } WorkflowRunEvent::AssetsCaptured { node_id, files_copied, total_bytes, files_skipped } => { - format!("[ASSETS_CAPTURED] node={node_id} files_copied={files_copied} total_bytes={total_bytes} files_skipped={files_skipped}") + format!("[ASSETS_CAPTURED] node={node_id} files_copied={files_copied} total_bytes={} files_skipped={files_skipped}", HumanBytes(*total_bytes)) } }; format!("{}", styles.dim.apply_to(body)) @@ -582,15 +561,6 @@ pub fn format_cost(cost: f64) -> String { format!("${cost:.2}") } -/// Format a token count for human display (e.g. `"15.2k"` or `"850"`). -#[must_use] -pub fn format_tokens_human(tokens: i64) -> String { - if tokens >= 1000 { - format!("{:.1}k", tokens as f64 / 1000.0) - } else { - tokens.to_string() - } -} #[cfg(test)] mod tests { diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 351bdc622..63fa7e8d2 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -27,9 +27,12 @@ use super::backend::AgentApiBackend; use super::cli_backend::{BackendRouter, AgentCliBackend}; use super::run_config; use super::run_config::{RunDefaults, WorkflowRunConfig}; +use indicatif::{HumanCount, HumanDuration}; +use std::time::Duration; + use super::{ - compute_stage_cost, format_cost, format_duration_human, - format_event_summary, format_tokens_human, print_diagnostics, read_dot_file, + compute_stage_cost, format_cost, + format_event_summary, print_diagnostics, read_dot_file, SandboxProvider, RunArgs, }; @@ -374,19 +377,19 @@ pub async fn run_command( .. // node_id and other fields } => { let mut line = format!( - "Stage \"{name}\" completed ({status}) in {duration}", - duration = format_duration_human(*duration_ms), + "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); + let total = (u.input_tokens + u.output_tokens) as u64; if let Some(cost) = compute_stage_cost(u) { line.push_str(&format!( - " \u{2014} {tokens_str} tokens ({})", + " \u{2014} {} tokens ({})", + HumanCount(total), format_cost(cost) )); } else { - line.push_str(&format!(" \u{2014} {tokens_str} tokens")); + line.push_str(&format!(" \u{2014} {} tokens", HumanCount(total))); } } eprintln!("{}", styles.dim.apply_to(line)); @@ -719,7 +722,7 @@ pub async fn run_command( "Status: {}", status_color.apply_to(&status_str), ); - eprintln!("Duration: {}", format_duration_human(run_duration_ms)); + eprintln!("Duration: {}", HumanDuration(Duration::from_millis(run_duration_ms))); let acc = accumulator.lock().unwrap(); let total_tokens = acc.total_input_tokens + acc.total_output_tokens; @@ -728,18 +731,18 @@ pub async fn run_command( eprintln!( "Cost: {} ({} tokens)", format_cost(acc.total_cost), - format_tokens_human(total_tokens) + HumanCount(total_tokens as u64) ); } else { - eprintln!("Tokens: {}", format_tokens_human(total_tokens)); + eprintln!("Tokens: {}", HumanCount(total_tokens as u64)); } if acc.total_cache_read_tokens > 0 { eprintln!( "{}", styles.dim.apply_to(format!( "Cache: {} read, {} write", - format_tokens_human(acc.total_cache_read_tokens), - format_tokens_human(acc.total_cache_write_tokens), + HumanCount(acc.total_cache_read_tokens as u64), + HumanCount(acc.total_cache_write_tokens as u64), )), ); } @@ -748,7 +751,7 @@ pub async fn run_command( "{}", styles.dim.apply_to(format!( "Reasoning: {} tokens", - format_tokens_human(acc.total_reasoning_tokens), + HumanCount(acc.total_reasoning_tokens as u64), )), ); } @@ -1045,7 +1048,7 @@ async fn run_from_branch( ); eprintln!( "Duration: {}", - super::format_duration_human(run_duration_ms) + HumanDuration(Duration::from_millis(run_duration_ms)) ); eprintln!( "{} {}", diff --git a/crates/arc-workflows/src/preamble.rs b/crates/arc-workflows/src/preamble.rs index c10f170c1..21e3fccb6 100644 --- a/crates/arc-workflows/src/preamble.rs +++ b/crates/arc-workflows/src/preamble.rs @@ -88,14 +88,7 @@ fn format_value(val: &serde_json::Value) -> String { } fn format_token_count(tokens: i64) -> String { - if tokens >= 1000 { - let k = tokens as f64 / 1000.0; - // One decimal place, strip trailing zero after decimal - let formatted = format!("{k:.1}"); - format!("{formatted}k") - } else { - tokens.to_string() - } + indicatif::HumanCount(tokens as u64).to_string() } /// Returns the set of context keys that are rendered inline under a stage's @@ -841,7 +834,7 @@ mod tests { "should show model name" ); assert!( - preamble.contains("1.2k tokens in"), + preamble.contains("1,234 tokens in"), "should show token count" ); assert!( @@ -1507,7 +1500,7 @@ mod tests { preamble.contains("Model: claude-sonnet-4-20250514"), "should show model" ); - assert!(preamble.contains("1.5k in"), "should show formatted tokens"); + assert!(preamble.contains("1,500 in"), "should show formatted tokens"); assert!( preamble.contains("Files touched: src/lib.rs"), "should show files" @@ -1588,10 +1581,10 @@ mod tests { fn format_token_count_formatting() { assert_eq!(format_token_count(500), "500"); assert_eq!(format_token_count(999), "999"); - assert_eq!(format_token_count(1000), "1.0k"); - assert_eq!(format_token_count(1234), "1.2k"); - assert_eq!(format_token_count(1500), "1.5k"); - assert_eq!(format_token_count(10000), "10.0k"); + assert_eq!(format_token_count(1000), "1,000"); + assert_eq!(format_token_count(1234), "1,234"); + assert_eq!(format_token_count(1500), "1,500"); + assert_eq!(format_token_count(10000), "10,000"); } // --- is_context_key_excluded ---