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        "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
169// --- Number formatting ---
170
171/// Format a cost as `$12,345.67` — two decimals, thousands separator. Negative
172/// values render as `-$5.00`, not `$-5.00`. Amounts smaller than one cent but
173/// non-zero fall back to four decimals (`$0.0040`) so per-session costs don't
174/// silently round to `$0.00`.
175pub 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        // Sub-cent — keep four decimals to preserve precision.
180        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
191/// Format an integer with comma thousands separators: `12,345`.
192pub 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
210// --- Palette ---
211//
212// Each helper returns an owned `String` so call sites stay simple. Allocation
213// cost is negligible for report output. Keep helpers semantic, not color-named.
214
215pub 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
303/// A dim, secondary caption printed beneath a table (e.g. a truncation hint).
304pub fn note(s: &str) -> String {
305    if color_on() {
306        s.dimmed().to_string()
307    } else {
308        s.to_string()
309    }
310}
311
312/// Colored cost for non-table contexts (summary). Green dollar figure.
313pub 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
321/// Colored count for non-table contexts. No special color; just formatted.
322pub fn count(n: u64) -> String {
323    fmt_count(n)
324}
325
326// Log-level helpers used by `watch`.
327
328pub 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
352/// Color a session-record `type` string — green for user, blue for assistant,
353/// dimmed for system, yellow for other.
354pub 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
366/// Color a plain text log line based on keywords it contains (fallback for
367/// non-JSON `watch` lines).
368pub 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
386// --- Progress spinner ---
387//
388// TTY-gated: when stderr isn't a terminal (CI, pipes), construction returns a
389// no-op guard so output stays clean. Spinner draws to stderr so `--json` on
390// stdout is never contaminated.
391
392pub 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"); // half-away-from-zero
435        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        // Amounts below $0.005 must not round to $0.00 — show four decimals
441        // so per-session/per-model costs stay meaningful.
442        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        // At the rounding boundary: >= $0.005 rounds up to $0.01 with two decimals.
446        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()); // idempotent — may already be set by a prior test
466    }
467
468    #[test]
469    fn color_choice_never_strips_output() {
470        // Work around the OnceLock: call the formatters under both paths by
471        // checking that helpers never panic regardless of color state.
472        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        // Just exercise the constructors — comfy-table internals do the rest.
501        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        // Rendering should not panic and should include the count.
525        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        // In `cargo test` stderr isn't a TTY, so this constructs a no-op
532        // spinner. The important thing is that start+finish don't panic.
533        let s = Spinner::start("syncing...");
534        s.finish();
535    }
536}