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 "copilot" => Color::Cyan,
138 "copilot-vscode" => Color::DarkCyan,
139 "pi" => Color::Magenta,
140 _ => Color::White,
141 };
142 Cell::new(s).fg(color)
143}
144
145pub fn cell_cost(usd: f64) -> Cell {
146 Cell::new(fmt_cost(usd)).fg(Color::Green)
147}
148
149pub fn cell_count(n: u64) -> Cell {
150 Cell::new(fmt_count(n))
151}
152
153pub fn cell_model(s: &str) -> Cell {
154 Cell::new(s).fg(Color::Yellow)
155}
156
157pub fn cell_tool(s: &str) -> Cell {
158 Cell::new(s).fg(Color::Magenta)
159}
160
161pub fn cell_dim(s: &str) -> Cell {
162 Cell::new(s).fg(Color::DarkGrey)
163}
164
165pub fn cell_plain(s: impl Into<String>) -> Cell {
166 Cell::new(s.into())
167}
168
169pub fn fmt_cost(usd: f64) -> String {
176 let sign = if usd < 0.0 { "-" } else { "" };
177 let abs = usd.abs();
178 if abs > 0.0 && abs < 0.005 {
179 let sub = (abs * 10_000.0).round() as u64;
181 let whole = sub / 10_000;
182 let frac = sub % 10_000;
183 return format!("{sign}${}.{:04}", group_thousands_u64(whole), frac);
184 }
185 let total_cents = (abs * 100.0).round() as u64;
186 let whole = total_cents / 100;
187 let cents = total_cents % 100;
188 format!("{sign}${}.{:02}", group_thousands_u64(whole), cents)
189}
190
191pub fn fmt_count(n: u64) -> String {
193 group_thousands_u64(n)
194}
195
196fn group_thousands_u64(n: u64) -> String {
197 let s = n.to_string();
198 let bytes = s.as_bytes();
199 let first = bytes.len() % 3;
200 let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
201 for (i, &b) in bytes.iter().enumerate() {
202 if i > 0 && i >= first && (i - first).is_multiple_of(3) {
203 out.push(',');
204 }
205 out.push(b as char);
206 }
207 out
208}
209
210pub fn project(s: &str) -> String {
216 if color_on() {
217 s.bright_blue().to_string()
218 } else {
219 s.to_string()
220 }
221}
222
223pub fn project_headline(s: &str) -> String {
224 if color_on() {
225 s.bright_blue().bold().to_string()
226 } else {
227 s.to_string()
228 }
229}
230
231pub fn session_id(s: &str) -> String {
232 if color_on() {
233 s.dimmed().to_string()
234 } else {
235 s.to_string()
236 }
237}
238
239pub fn timestamp(s: &str) -> String {
240 if color_on() {
241 s.dimmed().to_string()
242 } else {
243 s.to_string()
244 }
245}
246
247pub fn tool_name(s: &str) -> String {
248 if color_on() {
249 s.cyan().to_string()
250 } else {
251 s.to_string()
252 }
253}
254
255pub fn model_name(s: &str) -> String {
256 if color_on() {
257 s.yellow().to_string()
258 } else {
259 s.to_string()
260 }
261}
262
263pub fn role(s: &str) -> String {
264 if color_on() {
265 s.bright_yellow().to_string()
266 } else {
267 s.to_string()
268 }
269}
270
271pub fn section_title(s: &str) -> String {
272 if color_on() {
273 s.bold().to_string()
274 } else {
275 s.to_string()
276 }
277}
278
279pub fn emphasis(s: &str) -> String {
280 if color_on() {
281 s.bold().to_string()
282 } else {
283 s.to_string()
284 }
285}
286
287pub fn match_highlight(s: &str) -> String {
288 if color_on() {
289 s.bright_red().bold().to_string()
290 } else {
291 s.to_string()
292 }
293}
294
295pub fn banner(s: &str) -> String {
296 if color_on() {
297 s.bright_yellow().to_string()
298 } else {
299 s.to_string()
300 }
301}
302
303pub fn note(s: &str) -> String {
305 if color_on() {
306 s.dimmed().to_string()
307 } else {
308 s.to_string()
309 }
310}
311
312pub fn cost(usd: f64) -> String {
314 if color_on() {
315 fmt_cost(usd).green().to_string()
316 } else {
317 fmt_cost(usd)
318 }
319}
320
321pub fn count(n: u64) -> String {
323 fmt_count(n)
324}
325
326pub fn level_error(s: &str) -> String {
329 if color_on() {
330 s.red().bold().to_string()
331 } else {
332 s.to_string()
333 }
334}
335
336pub fn level_warn(s: &str) -> String {
337 if color_on() {
338 s.yellow().to_string()
339 } else {
340 s.to_string()
341 }
342}
343
344pub fn level_debug(s: &str) -> String {
345 if color_on() {
346 s.dimmed().to_string()
347 } else {
348 s.to_string()
349 }
350}
351
352pub fn record_type(ty: &str) -> String {
355 if !color_on() {
356 return ty.to_string();
357 }
358 match ty {
359 "user" => ty.bright_green().bold().to_string(),
360 "assistant" => ty.bright_blue().bold().to_string(),
361 "system" => ty.dimmed().to_string(),
362 _ => ty.bright_yellow().to_string(),
363 }
364}
365
366pub fn classify_text_line(line: &str) -> String {
369 if !color_on() {
370 return line.to_string();
371 }
372 let lower = line.to_lowercase();
373 if lower.contains("error") || lower.contains("fatal") {
374 line.red().to_string()
375 } else if lower.contains("warn") {
376 line.yellow().to_string()
377 } else if lower.contains("tool_call") || lower.contains("tool_use") {
378 line.cyan().to_string()
379 } else if lower.contains("debug") || lower.contains("trace") {
380 line.dimmed().to_string()
381 } else {
382 line.to_string()
383 }
384}
385
386pub struct Spinner(Option<ProgressBar>);
393
394impl Spinner {
395 pub fn start(message: impl Into<String>) -> Self {
396 if !std::io::stderr().is_terminal() {
397 return Self(None);
398 }
399 let pb = ProgressBar::new_spinner();
400 pb.set_style(
401 ProgressStyle::with_template("{spinner} {msg}")
402 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
403 );
404 pb.set_message(message.into());
405 pb.enable_steady_tick(Duration::from_millis(100));
406 Self(Some(pb))
407 }
408
409 pub fn finish(self) {
410 if let Some(pb) = self.0 {
411 pb.finish_and_clear();
412 }
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn group_thousands_edge_cases() {
422 assert_eq!(group_thousands_u64(0), "0");
423 assert_eq!(group_thousands_u64(999), "999");
424 assert_eq!(group_thousands_u64(1_000), "1,000");
425 assert_eq!(group_thousands_u64(12_345), "12,345");
426 assert_eq!(group_thousands_u64(1_234_567), "1,234,567");
427 assert_eq!(group_thousands_u64(1_000_000_000), "1,000,000,000");
428 }
429
430 #[test]
431 fn fmt_cost_rounds_to_two_decimals() {
432 assert_eq!(fmt_cost(12735.6563), "$12,735.66");
433 assert_eq!(fmt_cost(0.0), "$0.00");
434 assert_eq!(fmt_cost(0.125), "$0.13"); assert_eq!(fmt_cost(1_234_567.89), "$1,234,567.89");
436 }
437
438 #[test]
439 fn fmt_cost_preserves_sub_cent_precision() {
440 assert_eq!(fmt_cost(0.0040), "$0.0040");
443 assert_eq!(fmt_cost(0.0001), "$0.0001");
444 assert_eq!(fmt_cost(-0.003), "-$0.0030");
445 assert_eq!(fmt_cost(0.005), "$0.01");
447 }
448
449 #[test]
450 fn fmt_cost_negative_sign_outside_dollar() {
451 assert_eq!(fmt_cost(-5.5), "-$5.50");
452 }
453
454 #[test]
455 fn fmt_count_formats_big_numbers() {
456 assert_eq!(fmt_count(0), "0");
457 assert_eq!(fmt_count(12), "12");
458 assert_eq!(fmt_count(326_347), "326,347");
459 assert_eq!(fmt_count(17_596_000_000), "17,596,000,000");
460 }
461
462 #[test]
463 fn apply_color_choice_always_on() {
464 apply_color_choice(ColorChoice::Always);
465 assert!(color_on() || !color_on()); }
467
468 #[test]
469 fn color_choice_never_strips_output() {
470 let _ = project("x");
473 let _ = session_id("abc");
474 let _ = timestamp("2026-04-18");
475 let _ = tool_name("Bash");
476 let _ = model_name("claude-opus-4-6");
477 let _ = role("user");
478 let _ = section_title("Stats");
479 let _ = emphasis("42");
480 let _ = match_highlight("hit");
481 let _ = banner("──");
482 let _ = level_error("err");
483 let _ = level_warn("warn");
484 let _ = level_debug("dbg");
485 let _ = record_type("user");
486 let _ = record_type("assistant");
487 let _ = record_type("system");
488 let _ = record_type("other");
489 let _ = classify_text_line("ERROR: x");
490 let _ = classify_text_line("warn");
491 let _ = classify_text_line("tool_use");
492 let _ = classify_text_line("debug");
493 let _ = classify_text_line("plain");
494 let _ = cost(12.34);
495 let _ = count(5);
496 }
497
498 #[test]
499 fn cell_builders_produce_cells() {
500 let _ = cell_project("/Users/x/foo");
502 let _ = cell_cost(1_234.56);
503 let _ = cell_count(1_234);
504 let _ = cell_model("Opus");
505 let _ = cell_tool("Bash");
506 let _ = cell_dim("dim");
507 let _ = cell_plain("plain");
508 }
509
510 #[test]
511 fn header_and_total_row_build_cells() {
512 let h = header(["A", "B", "C"]);
513 assert_eq!(h.len(), 3);
514 let t = total_row(["TOTAL", "1", "2"]);
515 assert_eq!(t.len(), 3);
516 }
517
518 #[test]
519 fn table_builder_applies_dynamic_arrangement() {
520 let mut t = table();
521 t.set_header(header(["x", "y"]));
522 right_align(&mut t, &[1]);
523 t.add_row([cell_plain("a"), cell_count(5)]);
524 let rendered = format!("{t}");
526 assert!(rendered.contains("5"));
527 }
528
529 #[test]
530 fn spinner_is_no_op_when_stderr_is_not_tty() {
531 let s = Spinner::start("syncing...");
534 s.finish();
535 }
536}