1use std::io::IsTerminal;
7use std::sync::OnceLock;
8use std::time::Duration;
9
10use clap::ValueEnum;
11use comfy_table::{
12 Attribute, Cell, CellAlignment, Color, ContentArrangement, Table, TableComponent,
13 presets::NOTHING,
14};
15use indicatif::{ProgressBar, ProgressStyle};
16use owo_colors::OwoColorize;
17
18#[derive(Copy, Clone, Debug, ValueEnum)]
19pub enum ColorChoice {
20 Auto,
22 Always,
24 Never,
26}
27
28static COLOR_ON: OnceLock<bool> = OnceLock::new();
29
30pub fn apply_color_choice(choice: ColorChoice) {
31 let on = match choice {
32 ColorChoice::Always => true,
33 ColorChoice::Never => false,
34 ColorChoice::Auto => {
35 std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
36 }
37 };
38 let _ = COLOR_ON.set(on);
39}
40
41fn color_on() -> bool {
42 *COLOR_ON
43 .get_or_init(|| std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal())
44}
45
46pub fn table() -> Table {
52 let mut t = Table::new();
53 t.load_preset(NOTHING);
54 t.set_style(TableComponent::HeaderLines, '─');
55 t.set_content_arrangement(ContentArrangement::Dynamic);
56 if let Some((w, _)) = terminal_size::terminal_size() {
57 t.set_width(w.0);
58 }
59 if color_on() {
60 t.enforce_styling();
61 } else {
62 t.force_no_tty();
63 }
64 t
65}
66
67pub fn header<I, S>(cells: I) -> Vec<Cell>
71where
72 I: IntoIterator<Item = S>,
73 S: Into<String>,
74{
75 cells
76 .into_iter()
77 .map(|c| {
78 Cell::new(c.into())
79 .add_attribute(Attribute::Bold)
80 .fg(Color::Cyan)
81 })
82 .collect()
83}
84
85pub fn right_align(table: &mut Table, cols: &[usize]) {
88 for &i in cols {
89 if let Some(col) = table.column_mut(i) {
90 col.set_cell_alignment(CellAlignment::Right);
91 }
92 }
93}
94
95pub fn total_row<I, S>(cells: I) -> Vec<Cell>
97where
98 I: IntoIterator<Item = S>,
99 S: Into<String>,
100{
101 cells
102 .into_iter()
103 .map(|c| Cell::new(c.into()).add_attribute(Attribute::Bold))
104 .collect()
105}
106
107pub fn cell_project(s: &str) -> Cell {
114 Cell::new(s).fg(Color::Cyan)
115}
116
117pub fn spans_providers<'a, I: IntoIterator<Item = &'a str>>(providers: I) -> bool {
121 let mut seen: Option<&str> = None;
122 for p in providers {
123 match seen {
124 None => seen = Some(p),
125 Some(first) if first != p => return true,
126 _ => {}
127 }
128 }
129 false
130}
131
132pub fn cell_provider(s: &str) -> Cell {
134 let color = match s {
135 "claude" => Color::Blue,
136 "codex" => Color::Green,
137 "pi" => Color::Magenta,
138 _ => Color::White,
139 };
140 Cell::new(s).fg(color)
141}
142
143pub fn cell_cost(usd: f64) -> Cell {
144 Cell::new(fmt_cost(usd)).fg(Color::Green)
145}
146
147pub fn cell_count(n: u64) -> Cell {
148 Cell::new(fmt_count(n))
149}
150
151pub fn cell_model(s: &str) -> Cell {
152 Cell::new(s).fg(Color::Yellow)
153}
154
155pub fn cell_tool(s: &str) -> Cell {
156 Cell::new(s).fg(Color::Magenta)
157}
158
159pub fn cell_dim(s: &str) -> Cell {
160 Cell::new(s).fg(Color::DarkGrey)
161}
162
163pub fn cell_plain(s: impl Into<String>) -> Cell {
164 Cell::new(s.into())
165}
166
167pub fn fmt_cost(usd: f64) -> String {
174 let sign = if usd < 0.0 { "-" } else { "" };
175 let abs = usd.abs();
176 if abs > 0.0 && abs < 0.005 {
177 let sub = (abs * 10_000.0).round() as u64;
179 let whole = sub / 10_000;
180 let frac = sub % 10_000;
181 return format!("{sign}${}.{:04}", group_thousands_u64(whole), frac);
182 }
183 let total_cents = (abs * 100.0).round() as u64;
184 let whole = total_cents / 100;
185 let cents = total_cents % 100;
186 format!("{sign}${}.{:02}", group_thousands_u64(whole), cents)
187}
188
189pub fn fmt_count(n: u64) -> String {
191 group_thousands_u64(n)
192}
193
194fn group_thousands_u64(n: u64) -> String {
195 let s = n.to_string();
196 let bytes = s.as_bytes();
197 let first = bytes.len() % 3;
198 let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
199 for (i, &b) in bytes.iter().enumerate() {
200 if i > 0 && i >= first && (i - first).is_multiple_of(3) {
201 out.push(',');
202 }
203 out.push(b as char);
204 }
205 out
206}
207
208pub fn project(s: &str) -> String {
214 if color_on() {
215 s.bright_blue().to_string()
216 } else {
217 s.to_string()
218 }
219}
220
221pub fn project_headline(s: &str) -> String {
222 if color_on() {
223 s.bright_blue().bold().to_string()
224 } else {
225 s.to_string()
226 }
227}
228
229pub fn session_id(s: &str) -> String {
230 if color_on() {
231 s.dimmed().to_string()
232 } else {
233 s.to_string()
234 }
235}
236
237pub fn timestamp(s: &str) -> String {
238 if color_on() {
239 s.dimmed().to_string()
240 } else {
241 s.to_string()
242 }
243}
244
245pub fn tool_name(s: &str) -> String {
246 if color_on() {
247 s.cyan().to_string()
248 } else {
249 s.to_string()
250 }
251}
252
253pub fn model_name(s: &str) -> String {
254 if color_on() {
255 s.yellow().to_string()
256 } else {
257 s.to_string()
258 }
259}
260
261pub fn role(s: &str) -> String {
262 if color_on() {
263 s.bright_yellow().to_string()
264 } else {
265 s.to_string()
266 }
267}
268
269pub fn section_title(s: &str) -> String {
270 if color_on() {
271 s.bold().to_string()
272 } else {
273 s.to_string()
274 }
275}
276
277pub fn emphasis(s: &str) -> String {
278 if color_on() {
279 s.bold().to_string()
280 } else {
281 s.to_string()
282 }
283}
284
285pub fn match_highlight(s: &str) -> String {
286 if color_on() {
287 s.bright_red().bold().to_string()
288 } else {
289 s.to_string()
290 }
291}
292
293pub fn banner(s: &str) -> String {
294 if color_on() {
295 s.bright_yellow().to_string()
296 } else {
297 s.to_string()
298 }
299}
300
301pub fn note(s: &str) -> String {
303 if color_on() {
304 s.dimmed().to_string()
305 } else {
306 s.to_string()
307 }
308}
309
310pub fn cost(usd: f64) -> String {
312 if color_on() {
313 fmt_cost(usd).green().to_string()
314 } else {
315 fmt_cost(usd)
316 }
317}
318
319pub fn count(n: u64) -> String {
321 fmt_count(n)
322}
323
324pub fn level_error(s: &str) -> String {
327 if color_on() {
328 s.red().bold().to_string()
329 } else {
330 s.to_string()
331 }
332}
333
334pub fn level_warn(s: &str) -> String {
335 if color_on() {
336 s.yellow().to_string()
337 } else {
338 s.to_string()
339 }
340}
341
342pub fn level_debug(s: &str) -> String {
343 if color_on() {
344 s.dimmed().to_string()
345 } else {
346 s.to_string()
347 }
348}
349
350pub fn record_type(ty: &str) -> String {
353 if !color_on() {
354 return ty.to_string();
355 }
356 match ty {
357 "user" => ty.bright_green().bold().to_string(),
358 "assistant" => ty.bright_blue().bold().to_string(),
359 "system" => ty.dimmed().to_string(),
360 _ => ty.bright_yellow().to_string(),
361 }
362}
363
364pub fn classify_text_line(line: &str) -> String {
367 if !color_on() {
368 return line.to_string();
369 }
370 let lower = line.to_lowercase();
371 if lower.contains("error") || lower.contains("fatal") {
372 line.red().to_string()
373 } else if lower.contains("warn") {
374 line.yellow().to_string()
375 } else if lower.contains("tool_call") || lower.contains("tool_use") {
376 line.cyan().to_string()
377 } else if lower.contains("debug") || lower.contains("trace") {
378 line.dimmed().to_string()
379 } else {
380 line.to_string()
381 }
382}
383
384pub struct Spinner(Option<ProgressBar>);
391
392impl Spinner {
393 pub fn start(message: impl Into<String>) -> Self {
394 if !std::io::stderr().is_terminal() {
395 return Self(None);
396 }
397 let pb = ProgressBar::new_spinner();
398 pb.set_style(
399 ProgressStyle::with_template("{spinner} {msg}")
400 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
401 );
402 pb.set_message(message.into());
403 pb.enable_steady_tick(Duration::from_millis(100));
404 Self(Some(pb))
405 }
406
407 pub fn finish(self) {
408 if let Some(pb) = self.0 {
409 pb.finish_and_clear();
410 }
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn group_thousands_edge_cases() {
420 assert_eq!(group_thousands_u64(0), "0");
421 assert_eq!(group_thousands_u64(999), "999");
422 assert_eq!(group_thousands_u64(1_000), "1,000");
423 assert_eq!(group_thousands_u64(12_345), "12,345");
424 assert_eq!(group_thousands_u64(1_234_567), "1,234,567");
425 assert_eq!(group_thousands_u64(1_000_000_000), "1,000,000,000");
426 }
427
428 #[test]
429 fn fmt_cost_rounds_to_two_decimals() {
430 assert_eq!(fmt_cost(12735.6563), "$12,735.66");
431 assert_eq!(fmt_cost(0.0), "$0.00");
432 assert_eq!(fmt_cost(0.125), "$0.13"); assert_eq!(fmt_cost(1_234_567.89), "$1,234,567.89");
434 }
435
436 #[test]
437 fn fmt_cost_preserves_sub_cent_precision() {
438 assert_eq!(fmt_cost(0.0040), "$0.0040");
441 assert_eq!(fmt_cost(0.0001), "$0.0001");
442 assert_eq!(fmt_cost(-0.003), "-$0.0030");
443 assert_eq!(fmt_cost(0.005), "$0.01");
445 }
446
447 #[test]
448 fn fmt_cost_negative_sign_outside_dollar() {
449 assert_eq!(fmt_cost(-5.5), "-$5.50");
450 }
451
452 #[test]
453 fn fmt_count_formats_big_numbers() {
454 assert_eq!(fmt_count(0), "0");
455 assert_eq!(fmt_count(12), "12");
456 assert_eq!(fmt_count(326_347), "326,347");
457 assert_eq!(fmt_count(17_596_000_000), "17,596,000,000");
458 }
459
460 #[test]
461 fn apply_color_choice_always_on() {
462 apply_color_choice(ColorChoice::Always);
463 assert!(color_on() || !color_on()); }
465
466 #[test]
467 fn color_choice_never_strips_output() {
468 let _ = project("x");
471 let _ = session_id("abc");
472 let _ = timestamp("2026-04-18");
473 let _ = tool_name("Bash");
474 let _ = model_name("claude-opus-4-6");
475 let _ = role("user");
476 let _ = section_title("Stats");
477 let _ = emphasis("42");
478 let _ = match_highlight("hit");
479 let _ = banner("──");
480 let _ = level_error("err");
481 let _ = level_warn("warn");
482 let _ = level_debug("dbg");
483 let _ = record_type("user");
484 let _ = record_type("assistant");
485 let _ = record_type("system");
486 let _ = record_type("other");
487 let _ = classify_text_line("ERROR: x");
488 let _ = classify_text_line("warn");
489 let _ = classify_text_line("tool_use");
490 let _ = classify_text_line("debug");
491 let _ = classify_text_line("plain");
492 let _ = cost(12.34);
493 let _ = count(5);
494 }
495
496 #[test]
497 fn cell_builders_produce_cells() {
498 let _ = cell_project("/Users/x/foo");
500 let _ = cell_cost(1_234.56);
501 let _ = cell_count(1_234);
502 let _ = cell_model("Opus");
503 let _ = cell_tool("Bash");
504 let _ = cell_dim("dim");
505 let _ = cell_plain("plain");
506 }
507
508 #[test]
509 fn header_and_total_row_build_cells() {
510 let h = header(["A", "B", "C"]);
511 assert_eq!(h.len(), 3);
512 let t = total_row(["TOTAL", "1", "2"]);
513 assert_eq!(t.len(), 3);
514 }
515
516 #[test]
517 fn table_builder_applies_dynamic_arrangement() {
518 let mut t = table();
519 t.set_header(header(["x", "y"]));
520 right_align(&mut t, &[1]);
521 t.add_row([cell_plain("a"), cell_count(5)]);
522 let rendered = format!("{t}");
524 assert!(rendered.contains("5"));
525 }
526
527 #[test]
528 fn spinner_is_no_op_when_stderr_is_not_tty() {
529 let s = Spinner::start("syncing...");
532 s.finish();
533 }
534}