Use indicatif formatters for human-readable durations, tokens, and bytes

Replace hand-rolled format_duration_human, format_tokens_human, and
format_token_count with indicatif's HumanDuration, HumanCount, and
HumanBytes. Token counts now display as comma-separated (e.g. "1,234")
instead of abbreviated (e.g. "1.2k"), and byte counts show units
(e.g. "1.50 KiB").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 22:09:18 -05:00
parent d373ffd10f
commit 66d3f62131
9 changed files with 69 additions and 81 deletions

27
Cargo.lock generated
View file

@ -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"

View file

@ -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" }

View file

@ -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 }

View file

@ -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),
)),
);
}

View file

@ -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]

View file

@ -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),
)),
);
}

View file

@ -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 {

View file

@ -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!(
"{} {}",

View file

@ -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 ---