Skip to main content

claudex_cli/
ui.rs

1//! Presentation layer — one place owns the palette, table style, terminal
2//! width, color detection, and progress indicators. Commands call semantic
3//! helpers (`project`, `timestamp`, `emphasis`, …) rather than raw
4//! `owo_colors` methods so the palette can change in one place.
5
6use 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    /// Colorize when stdout is a TTY and NO_COLOR is unset (default)
21    Auto,
22    /// Always emit ANSI color escapes
23    Always,
24    /// Never emit ANSI color escapes
25    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
46// --- Table builder ---
47
48/// Preconfigured comfy-table with a minimal style: no outer box, no vertical
49/// dividers, no per-row separators — just a horizontal rule under the header.
50/// Dynamic arrangement fits content to the current terminal width.
51pub 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
67/// Build bold + cyan header cells. Comfy-table renders these as ANSI when
68/// the table's styling is enabled; `force_no_tty` (called by `table()` under
69/// `--color never`) suppresses them cleanly.
70pub 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
85/// Right-align the specified column indices. Use for numeric columns so
86/// digits line up on the decimal.
87pub 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
95/// Build a bold summary row (e.g. "TOTAL"). Cells inherit column alignment.
96pub 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
107// --- Cell builders for table rows ---
108//
109// Use these instead of passing raw `String` values to `add_row` so every
110// command gets the same palette. Comfy-table strips styling automatically
111// when the table is in non-TTY mode (`--color never` calls `force_no_tty`).
112
113pub fn cell_project(s: &str) -> Cell {
114    Cell::new(s).fg(Color::Cyan)
115}
116
117/// Whether a result set spans more than one provider — used to decide if a
118/// table should carry a Provider column (omitted when everything is one
119/// provider, e.g. a Claude-only user, to keep tables narrow).
120pub 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
132/// Provider label cell, colored per agent so mixed result sets read at a glance.
133pub 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
167// --- Number formatting ---
168
169/// Format a cost as `$12,345.67` — two decimals, thousands separator. Negative
170/// values render as `-$5.00`, not `$-5.00`. Amounts smaller than one cent but
171/// non-zero fall back to four decimals (`$0.0040`) so per-session costs don't
172/// silently round to `$0.00`.
173pub 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        // Sub-cent — keep four decimals to preserve precision.
178        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
189/// Format an integer with comma thousands separators: `12,345`.
190pub 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
208// --- Palette ---
209//
210// Each helper returns an owned `String` so call sites stay simple. Allocation
211// cost is negligible for report output. Keep helpers semantic, not color-named.
212
213pub 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
301/// A dim, secondary caption printed beneath a table (e.g. a truncation hint).
302pub fn note(s: &str) -> String {
303    if color_on() {
304        s.dimmed().to_string()
305    } else {
306        s.to_string()
307    }
308}
309
310/// Colored cost for non-table contexts (summary). Green dollar figure.
311pub 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
319/// Colored count for non-table contexts. No special color; just formatted.
320pub fn count(n: u64) -> String {
321    fmt_count(n)
322}
323
324// Log-level helpers used by `watch`.
325
326pub 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
350/// Color a session-record `type` string — green for user, blue for assistant,
351/// dimmed for system, yellow for other.
352pub 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
364/// Color a plain text log line based on keywords it contains (fallback for
365/// non-JSON `watch` lines).
366pub 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
384// --- Progress spinner ---
385//
386// TTY-gated: when stderr isn't a terminal (CI, pipes), construction returns a
387// no-op guard so output stays clean. Spinner draws to stderr so `--json` on
388// stdout is never contaminated.
389
390pub 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"); // half-away-from-zero
433        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        // Amounts below $0.005 must not round to $0.00 — show four decimals
439        // so per-session/per-model costs stay meaningful.
440        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        // At the rounding boundary: >= $0.005 rounds up to $0.01 with two decimals.
444        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()); // idempotent — may already be set by a prior test
464    }
465
466    #[test]
467    fn color_choice_never_strips_output() {
468        // Work around the OnceLock: call the formatters under both paths by
469        // checking that helpers never panic regardless of color state.
470        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        // Just exercise the constructors — comfy-table internals do the rest.
499        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        // Rendering should not panic and should include the count.
523        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        // In `cargo test` stderr isn't a TTY, so this constructs a no-op
530        // spinner. The important thing is that start+finish don't panic.
531        let s = Spinner::start("syncing...");
532        s.finish();
533    }
534}