use std::io::IsTerminal;
use std::sync::OnceLock;
use std::time::Duration;
use clap::ValueEnum;
use comfy_table::{
Attribute, Cell, CellAlignment, Color, ContentArrangement, Table, TableComponent,
presets::NOTHING,
};
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum ColorChoice {
Auto,
Always,
Never,
}
static COLOR_ON: OnceLock<bool> = OnceLock::new();
pub fn apply_color_choice(choice: ColorChoice) {
let on = match choice {
ColorChoice::Always => true,
ColorChoice::Never => false,
ColorChoice::Auto => {
std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
}
};
let _ = COLOR_ON.set(on);
}
fn color_on() -> bool {
*COLOR_ON
.get_or_init(|| std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal())
}
pub fn table() -> Table {
let mut t = Table::new();
t.load_preset(NOTHING);
t.set_style(TableComponent::HeaderLines, '─');
t.set_content_arrangement(ContentArrangement::Dynamic);
if let Some((w, _)) = terminal_size::terminal_size() {
t.set_width(w.0);
}
if color_on() {
t.enforce_styling();
} else {
t.force_no_tty();
}
t
}
pub fn header<I, S>(cells: I) -> Vec<Cell>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
cells
.into_iter()
.map(|c| {
Cell::new(c.into())
.add_attribute(Attribute::Bold)
.fg(Color::Cyan)
})
.collect()
}
pub fn right_align(table: &mut Table, cols: &[usize]) {
for &i in cols {
if let Some(col) = table.column_mut(i) {
col.set_cell_alignment(CellAlignment::Right);
}
}
}
pub fn total_row<I, S>(cells: I) -> Vec<Cell>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
cells
.into_iter()
.map(|c| Cell::new(c.into()).add_attribute(Attribute::Bold))
.collect()
}
pub fn cell_project(s: &str) -> Cell {
Cell::new(s).fg(Color::Cyan)
}
pub fn spans_providers<'a, I: IntoIterator<Item = &'a str>>(providers: I) -> bool {
let mut seen: Option<&str> = None;
for p in providers {
match seen {
None => seen = Some(p),
Some(first) if first != p => return true,
_ => {}
}
}
false
}
pub fn cell_provider(s: &str) -> Cell {
let color = match s {
"claude" => Color::Blue,
"codex" => Color::Green,
"pi" => Color::Magenta,
_ => Color::White,
};
Cell::new(s).fg(color)
}
pub fn cell_cost(usd: f64) -> Cell {
Cell::new(fmt_cost(usd)).fg(Color::Green)
}
pub fn cell_count(n: u64) -> Cell {
Cell::new(fmt_count(n))
}
pub fn cell_model(s: &str) -> Cell {
Cell::new(s).fg(Color::Yellow)
}
pub fn cell_tool(s: &str) -> Cell {
Cell::new(s).fg(Color::Magenta)
}
pub fn cell_dim(s: &str) -> Cell {
Cell::new(s).fg(Color::DarkGrey)
}
pub fn cell_plain(s: impl Into<String>) -> Cell {
Cell::new(s.into())
}
pub fn fmt_cost(usd: f64) -> String {
let sign = if usd < 0.0 { "-" } else { "" };
let abs = usd.abs();
if abs > 0.0 && abs < 0.005 {
let sub = (abs * 10_000.0).round() as u64;
let whole = sub / 10_000;
let frac = sub % 10_000;
return format!("{sign}${}.{:04}", group_thousands_u64(whole), frac);
}
let total_cents = (abs * 100.0).round() as u64;
let whole = total_cents / 100;
let cents = total_cents % 100;
format!("{sign}${}.{:02}", group_thousands_u64(whole), cents)
}
pub fn fmt_count(n: u64) -> String {
group_thousands_u64(n)
}
fn group_thousands_u64(n: u64) -> String {
let s = n.to_string();
let bytes = s.as_bytes();
let first = bytes.len() % 3;
let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
for (i, &b) in bytes.iter().enumerate() {
if i > 0 && i >= first && (i - first).is_multiple_of(3) {
out.push(',');
}
out.push(b as char);
}
out
}
pub fn project(s: &str) -> String {
if color_on() {
s.bright_blue().to_string()
} else {
s.to_string()
}
}
pub fn project_headline(s: &str) -> String {
if color_on() {
s.bright_blue().bold().to_string()
} else {
s.to_string()
}
}
pub fn session_id(s: &str) -> String {
if color_on() {
s.dimmed().to_string()
} else {
s.to_string()
}
}
pub fn timestamp(s: &str) -> String {
if color_on() {
s.dimmed().to_string()
} else {
s.to_string()
}
}
pub fn tool_name(s: &str) -> String {
if color_on() {
s.cyan().to_string()
} else {
s.to_string()
}
}
pub fn model_name(s: &str) -> String {
if color_on() {
s.yellow().to_string()
} else {
s.to_string()
}
}
pub fn role(s: &str) -> String {
if color_on() {
s.bright_yellow().to_string()
} else {
s.to_string()
}
}
pub fn section_title(s: &str) -> String {
if color_on() {
s.bold().to_string()
} else {
s.to_string()
}
}
pub fn emphasis(s: &str) -> String {
if color_on() {
s.bold().to_string()
} else {
s.to_string()
}
}
pub fn match_highlight(s: &str) -> String {
if color_on() {
s.bright_red().bold().to_string()
} else {
s.to_string()
}
}
pub fn banner(s: &str) -> String {
if color_on() {
s.bright_yellow().to_string()
} else {
s.to_string()
}
}
pub fn note(s: &str) -> String {
if color_on() {
s.dimmed().to_string()
} else {
s.to_string()
}
}
pub fn cost(usd: f64) -> String {
if color_on() {
fmt_cost(usd).green().to_string()
} else {
fmt_cost(usd)
}
}
pub fn count(n: u64) -> String {
fmt_count(n)
}
pub fn level_error(s: &str) -> String {
if color_on() {
s.red().bold().to_string()
} else {
s.to_string()
}
}
pub fn level_warn(s: &str) -> String {
if color_on() {
s.yellow().to_string()
} else {
s.to_string()
}
}
pub fn level_debug(s: &str) -> String {
if color_on() {
s.dimmed().to_string()
} else {
s.to_string()
}
}
pub fn record_type(ty: &str) -> String {
if !color_on() {
return ty.to_string();
}
match ty {
"user" => ty.bright_green().bold().to_string(),
"assistant" => ty.bright_blue().bold().to_string(),
"system" => ty.dimmed().to_string(),
_ => ty.bright_yellow().to_string(),
}
}
pub fn classify_text_line(line: &str) -> String {
if !color_on() {
return line.to_string();
}
let lower = line.to_lowercase();
if lower.contains("error") || lower.contains("fatal") {
line.red().to_string()
} else if lower.contains("warn") {
line.yellow().to_string()
} else if lower.contains("tool_call") || lower.contains("tool_use") {
line.cyan().to_string()
} else if lower.contains("debug") || lower.contains("trace") {
line.dimmed().to_string()
} else {
line.to_string()
}
}
pub struct Spinner(Option<ProgressBar>);
impl Spinner {
pub fn start(message: impl Into<String>) -> Self {
if !std::io::stderr().is_terminal() {
return Self(None);
}
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
pb.set_message(message.into());
pb.enable_steady_tick(Duration::from_millis(100));
Self(Some(pb))
}
pub fn finish(self) {
if let Some(pb) = self.0 {
pb.finish_and_clear();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn group_thousands_edge_cases() {
assert_eq!(group_thousands_u64(0), "0");
assert_eq!(group_thousands_u64(999), "999");
assert_eq!(group_thousands_u64(1_000), "1,000");
assert_eq!(group_thousands_u64(12_345), "12,345");
assert_eq!(group_thousands_u64(1_234_567), "1,234,567");
assert_eq!(group_thousands_u64(1_000_000_000), "1,000,000,000");
}
#[test]
fn fmt_cost_rounds_to_two_decimals() {
assert_eq!(fmt_cost(12735.6563), "$12,735.66");
assert_eq!(fmt_cost(0.0), "$0.00");
assert_eq!(fmt_cost(0.125), "$0.13"); assert_eq!(fmt_cost(1_234_567.89), "$1,234,567.89");
}
#[test]
fn fmt_cost_preserves_sub_cent_precision() {
assert_eq!(fmt_cost(0.0040), "$0.0040");
assert_eq!(fmt_cost(0.0001), "$0.0001");
assert_eq!(fmt_cost(-0.003), "-$0.0030");
assert_eq!(fmt_cost(0.005), "$0.01");
}
#[test]
fn fmt_cost_negative_sign_outside_dollar() {
assert_eq!(fmt_cost(-5.5), "-$5.50");
}
#[test]
fn fmt_count_formats_big_numbers() {
assert_eq!(fmt_count(0), "0");
assert_eq!(fmt_count(12), "12");
assert_eq!(fmt_count(326_347), "326,347");
assert_eq!(fmt_count(17_596_000_000), "17,596,000,000");
}
#[test]
fn apply_color_choice_always_on() {
apply_color_choice(ColorChoice::Always);
assert!(color_on() || !color_on()); }
#[test]
fn color_choice_never_strips_output() {
let _ = project("x");
let _ = session_id("abc");
let _ = timestamp("2026-04-18");
let _ = tool_name("Bash");
let _ = model_name("claude-opus-4-6");
let _ = role("user");
let _ = section_title("Stats");
let _ = emphasis("42");
let _ = match_highlight("hit");
let _ = banner("──");
let _ = level_error("err");
let _ = level_warn("warn");
let _ = level_debug("dbg");
let _ = record_type("user");
let _ = record_type("assistant");
let _ = record_type("system");
let _ = record_type("other");
let _ = classify_text_line("ERROR: x");
let _ = classify_text_line("warn");
let _ = classify_text_line("tool_use");
let _ = classify_text_line("debug");
let _ = classify_text_line("plain");
let _ = cost(12.34);
let _ = count(5);
}
#[test]
fn cell_builders_produce_cells() {
let _ = cell_project("/Users/x/foo");
let _ = cell_cost(1_234.56);
let _ = cell_count(1_234);
let _ = cell_model("Opus");
let _ = cell_tool("Bash");
let _ = cell_dim("dim");
let _ = cell_plain("plain");
}
#[test]
fn header_and_total_row_build_cells() {
let h = header(["A", "B", "C"]);
assert_eq!(h.len(), 3);
let t = total_row(["TOTAL", "1", "2"]);
assert_eq!(t.len(), 3);
}
#[test]
fn table_builder_applies_dynamic_arrangement() {
let mut t = table();
t.set_header(header(["x", "y"]));
right_align(&mut t, &[1]);
t.add_row([cell_plain("a"), cell_count(5)]);
let rendered = format!("{t}");
assert!(rendered.contains("5"));
}
#[test]
fn spinner_is_no_op_when_stderr_is_not_tty() {
let s = Spinner::start("syncing...");
s.finish();
}
}