Skip to main content

ai_usagebar/widget/
cli.rs

1//! Command-line interface — claudebar-compatible flags plus the new
2//! local-testing additions (`--pretty`, `--watch`, `--json`).
3//!
4//! Mirrors claudebar:54-93. The defaults are identical so existing waybar
5//! configs that invoke `claudebar ...` can be retargeted to
6//! `ai-usagebar --vendor anthropic ...` without changing any flags.
7
8use clap::{Parser, ValueEnum};
9
10#[derive(Parser, Debug, Clone)]
11#[command(
12    name = "ai-usagebar",
13    args_conflicts_with_subcommands = true,
14    about = "Waybar widget and terminal dashboard for multi-provider AI plan usage",
15    long_about = "\
16Drop-in replacement for `claudebar` with multi-vendor support.
17
18Output modes:
19  - Default: Waybar JSON ({text, tooltip, class}). Used when stdout is piped.
20  - --pretty: human-readable terminal output for local testing. Auto-enabled
21    when stdout is a TTY, so just running `ai-usagebar --vendor anthropic`
22    in a terminal Does The Right Thing.
23  - --watch N: like --pretty but refreshes every N seconds, clearing the screen
24    between ticks. Useful while iterating on `--format` or `--tooltip-format`.
25  - --json: force JSON output even when stdout is a TTY (for scripting)."
26)]
27pub struct Cli {
28    /// Which vendor to query. When omitted, reads `[ui] primary` from
29    /// `~/.config/ai-usagebar/config.toml`; falls back to `anthropic` if
30    /// neither is set.
31    #[arg(long, value_enum)]
32    pub vendor: Option<Vendor>,
33
34    /// Optional icon prepended to the bar text (Nerd Font glyph / emoji /
35    /// Pango span). claudebar `--icon`.
36    #[arg(long)]
37    pub icon: Option<String>,
38
39    /// Bar-text format string with `{placeholder}` substitutions. Defaults to
40    /// a vendor-specific format (e.g. `{session_pct}% · {session_reset}` for
41    /// Anthropic, `{kimi_weekly_pct}%` for Kimi).
42    #[arg(long)]
43    pub format: Option<String>,
44
45    /// Custom tooltip format. Overrides the default bordered tooltip when
46    /// set; identical placeholder set as `--format`.
47    #[arg(long)]
48    pub tooltip_format: Option<String>,
49
50    /// Tolerance band (in percentage points) for ratio-based pacing icons.
51    #[arg(long, default_value_t = 5)]
52    pub pace_tolerance: u32,
53
54    /// Color pace placeholders individually per window (instead of the
55    /// global usage-based color). Claudebar `--format-pace-color`.
56    #[arg(long)]
57    pub format_pace_color: bool,
58
59    /// Use point-based pacing in the tooltip's pace column (vs ratio-based).
60    /// Also enables an elapsed-position marker on the tooltip progress bars.
61    /// Claudebar `--tooltip-pace-pts`.
62    #[arg(long)]
63    pub tooltip_pace_pts: bool,
64
65    /// Override the low-usage color (#RRGGBB).
66    #[arg(long)]
67    pub color_low: Option<String>,
68    /// Override the mid-usage color (#RRGGBB).
69    #[arg(long)]
70    pub color_mid: Option<String>,
71    /// Override the high-usage color (#RRGGBB).
72    #[arg(long)]
73    pub color_high: Option<String>,
74    /// Override the critical-usage color (#RRGGBB).
75    #[arg(long)]
76    pub color_critical: Option<String>,
77
78    /// Render human-readable terminal output (ANSI colors + box drawing)
79    /// instead of Waybar JSON. Auto-on when stdout is a TTY.
80    #[arg(long)]
81    pub pretty: bool,
82
83    /// Force JSON output even on a TTY (useful when piping into `jq` from
84    /// an interactive shell).
85    #[arg(long, conflicts_with = "pretty")]
86    pub json: bool,
87
88    /// Re-render every N seconds, clearing the screen between ticks. Implies
89    /// `--pretty`. Press Ctrl-C to exit.
90    #[arg(long, value_name = "SECS")]
91    pub watch: Option<u64>,
92
93    /// Cycle the persisted "active vendor" forward and exit. Wire to
94    /// Waybar's `on-scroll-up` to scroll-cycle through enabled vendors.
95    /// Sends SIGRTMIN+13 to waybar afterwards so the bar refreshes
96    /// immediately rather than waiting for the next interval tick.
97    #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
98    pub cycle_next: bool,
99
100    /// Cycle backwards. Wire to `on-scroll-down`.
101    #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
102    pub cycle_prev: bool,
103
104    /// Override the cache directory (default: ~/.cache/ai-usagebar/<vendor>).
105    /// Give each instance its own directory to track multiple accounts of
106    /// the same vendor side by side — see "Multiple accounts" in the README.
107    #[arg(long, value_name = "DIR")]
108    pub cache_dir: Option<std::path::PathBuf>,
109
110    /// Override the Anthropic credentials file (default:
111    /// ~/.claude/.credentials.json, or `[anthropic] credentials_path` from
112    /// config). Only the Anthropic vendor reads this flag. Combine with
113    /// --cache-dir to track multiple Claude accounts — see "Multiple
114    /// accounts" in the README.
115    #[arg(long, value_name = "FILE")]
116    pub creds_path: Option<std::path::PathBuf>,
117
118    /// Select a named Anthropic account from `[[anthropic.accounts]]` in
119    /// config (issue #14). Without it, `--vendor anthropic` uses the default
120    /// account — the singular `[anthropic] credentials_path` — with unchanged
121    /// output and cache path. Anthropic only; conflicts with the lower-level
122    /// `--creds-path` (they both name a credentials file).
123    #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
124    pub account: Option<String>,
125
126    /// Read `--account <LABEL>`'s usage from the Claude **Desktop app's** own
127    /// token instead of a `claude` CLI credential — a saved
128    /// `~/.claude-acc/profiles/<LABEL>` account, no CLI login required (macOS).
129    /// This is how the menu bar shows Desktop accounts in its overview.
130    #[arg(long, requires = "account")]
131    pub desktop: bool,
132
133    /// Administrative command. Omit it to run the normal usage widget.
134    #[command(subcommand)]
135    pub command: Option<Command>,
136}
137
138#[derive(clap::Subcommand, Debug, Clone)]
139pub enum Command {
140    /// Manage named Claude (Anthropic) accounts.
141    Account {
142        #[command(subcommand)]
143        action: AccountAction,
144    },
145
146    /// Quota and time-to-reset for every configured vendor and account.
147    Usage {
148        /// Machine-readable output.
149        #[arg(long)]
150        json: bool,
151    },
152}
153
154#[derive(clap::Subcommand, Debug, Clone)]
155pub enum AccountAction {
156    /// Register an isolated account and open Claude Code to sign it in.
157    Add {
158        /// Stable name used by `--account`, the TUI, and desktop apps.
159        label: String,
160
161        /// Only register the account; do not launch interactive login.
162        #[arg(long, conflicts_with = "desktop")]
163        no_login: bool,
164
165        /// Capture a Claude **Desktop app** account under this label instead
166        /// of a `claude` CLI one (macOS). The app has a single login slot, so
167        /// this signs it out, waits for you to sign in as the new account, and
168        /// saves what it writes. Your current login is restored if you cancel.
169        #[arg(long)]
170        desktop: bool,
171
172        /// E-mail to label a `--desktop` account with. Asked for at the prompt
173        /// if omitted; purely cosmetic, and skipped when not interactive.
174        #[arg(long, requires = "desktop")]
175        email: Option<String>,
176
177        /// Skip the confirmation before signing the Desktop app out.
178        #[arg(short = 'y', long, requires = "desktop")]
179        yes: bool,
180    },
181
182    /// Show which Claude account the Desktop app and the `claude` CLI use.
183    Status {
184        /// Machine-readable output, consumed by the macOS menu bar.
185        #[arg(long)]
186        json: bool,
187    },
188
189    /// Make <LABEL> the active Claude account (macOS).
190    Switch {
191        /// Account to switch to. Desktop profiles come from claude-acc's store;
192        /// CLI accounts from `[[anthropic.accounts]]` / `accounts_dir`.
193        label: String,
194
195        /// Only switch the Claude Desktop app. Neither flag switches both.
196        #[arg(long)]
197        desktop: bool,
198
199        /// Only switch the `claude` CLI's default login.
200        #[arg(long)]
201        cli: bool,
202
203        /// Report what would change and exit without touching anything.
204        #[arg(long)]
205        dry_run: bool,
206
207        /// Skip the confirmation before quitting the Claude Desktop app.
208        #[arg(short = 'y', long)]
209        yes: bool,
210
211        /// Overwrite a `claude` CLI login that belongs to no managed account.
212        /// That login cannot be saved first, so this discards it.
213        #[arg(long)]
214        force: bool,
215
216        /// Keep `bridge-state.json` rather than clearing it. Diagnostic only:
217        /// a stale remote-control session id breaks `/remote-control`.
218        #[arg(long)]
219        keep_bridge: bool,
220
221        /// Also archive the whole session tree, as claude-acc does. Off by
222        /// default because the history merge is additive.
223        #[arg(long)]
224        backup_sessions: bool,
225
226        /// Rollback archives to retain.
227        #[arg(long, default_value_t = 10)]
228        keep_backups: usize,
229
230        /// Confirm that this type-scoped conflict key, deleted in one account
231        /// but still held by another, should be removed everywhere. Repeatable.
232        /// Supplying any suppresses the interactive prompt — keys not listed
233        /// are kept — which is how the macOS menu bar passes an answered dialog
234        /// through.
235        /// `account status --json` lists the candidates as `deletion_conflicts`.
236        #[arg(long, value_name = "KEY")]
237        delete_conflict: Vec<String>,
238    },
239}
240
241#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
242pub enum Vendor {
243    Anthropic,
244    #[value(name = "anthropic_api")]
245    AnthropicApi,
246    Openai,
247    Zai,
248    Openrouter,
249    Deepseek,
250    Kimi,
251    Kilo,
252    Novita,
253    Moonshot,
254    Grok,
255    Antigravity,
256    Cursor,
257    Minimax,
258}
259
260impl Vendor {
261    pub fn to_id(self) -> crate::vendor::VendorId {
262        match self {
263            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
264            Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
265            Vendor::Openai => crate::vendor::VendorId::Openai,
266            Vendor::Zai => crate::vendor::VendorId::Zai,
267            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
268            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
269            Vendor::Kimi => crate::vendor::VendorId::Kimi,
270            Vendor::Kilo => crate::vendor::VendorId::Kilo,
271            Vendor::Novita => crate::vendor::VendorId::Novita,
272            Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
273            Vendor::Grok => crate::vendor::VendorId::Grok,
274            Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
275            Vendor::Cursor => crate::vendor::VendorId::Cursor,
276            Vendor::Minimax => crate::vendor::VendorId::Minimax,
277        }
278    }
279}
280
281impl Cli {
282    /// Whether the selected vendor came from an explicit `--vendor` opt-in.
283    pub fn has_explicit_vendor(&self) -> bool {
284        self.vendor.is_some()
285    }
286
287    /// Resolve the vendor with full precedence:
288    ///   1. explicit `--vendor` (highest)
289    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
290    ///   3. `[ui] primary` from config
291    ///   4. anthropic (lowest)
292    ///
293    /// This reads the persisted scroll-cycle state from disk via
294    /// [`crate::active::read`]. The pure precedence logic lives in
295    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
296    /// `~/.cache/ai-usagebar/active_vendor`.
297    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
298        // Only consult the scroll-cycle state file when it could actually
299        // matter. An explicit `--vendor` wins outright (precedence #1), so we
300        // skip the disk read entirely in that case — preserving the original
301        // short-circuit and keeping the documented `--vendor` widget config off
302        // the `active_vendor` read path.
303        let active = if self.has_explicit_vendor() {
304            None
305        } else {
306            crate::active::read()
307        };
308        self.resolve_vendor_with(config, active)
309    }
310
311    /// Pure precedence resolution given an explicit scroll-cycle `active`
312    /// override (i.e. whatever [`crate::active::read`] returned). Split out
313    /// from the disk read so tests exercise the precedence rules hermetically
314    /// instead of depending on the developer's real `active_vendor` file.
315    pub fn resolve_vendor_with(
316        &self,
317        config: &crate::config::Config,
318        active: Option<crate::vendor::VendorId>,
319    ) -> Vendor {
320        if let Some(v) = self.vendor {
321            return v;
322        }
323        if let Some(id) = active
324            && config.is_enabled(id)
325        {
326            return id_to_vendor(id);
327        }
328        if let Some(id) = config.ui.primary
329            && config.is_enabled(id)
330        {
331            return id_to_vendor(id);
332        }
333        if config.is_enabled(crate::vendor::VendorId::Anthropic) {
334            return Vendor::Anthropic;
335        }
336        config
337            .enabled_vendors()
338            .into_iter()
339            .next()
340            .map(id_to_vendor)
341            // A completely disabled configuration has no enabled choice; keep
342            // the historic final fallback rather than rejecting widget startup.
343            .unwrap_or(Vendor::Anthropic)
344    }
345}
346
347fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
348    match id {
349        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
350        crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
351        crate::vendor::VendorId::Openai => Vendor::Openai,
352        crate::vendor::VendorId::Zai => Vendor::Zai,
353        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
354        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
355        crate::vendor::VendorId::Kimi => Vendor::Kimi,
356        crate::vendor::VendorId::Kilo => Vendor::Kilo,
357        crate::vendor::VendorId::Novita => Vendor::Novita,
358        crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
359        crate::vendor::VendorId::Grok => Vendor::Grok,
360        crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
361        crate::vendor::VendorId::Cursor => Vendor::Cursor,
362        crate::vendor::VendorId::Minimax => Vendor::Minimax,
363    }
364}
365
366impl Cli {
367    /// True when we should emit Waybar JSON. Default behavior: JSON when
368    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
369    pub fn output_json(&self) -> bool {
370        if self.json {
371            return true;
372        }
373        if self.pretty || self.watch.is_some() {
374            return false;
375        }
376        // Auto-detect: emit pretty when stdout is a TTY.
377        !is_stdout_tty()
378    }
379}
380
381fn is_stdout_tty() -> bool {
382    use std::io::IsTerminal;
383    std::io::stdout().is_terminal()
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use clap::Parser;
390
391    #[test]
392    fn usage_subcommand_parses_machine_readable_mode() {
393        let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
394        assert!(matches!(cli.command, Some(Command::Usage { json: true })));
395    }
396
397    #[test]
398    fn defaults_match_claudebar() {
399        let cli = Cli::parse_from(["ai-usagebar"]);
400        assert_eq!(cli.vendor, None);
401        // Without explicit --vendor, no scroll-cycle override, and default
402        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
403        // rather than `resolved_vendor` so the test never reads the real
404        // ~/.cache/ai-usagebar/active_vendor file.
405        let cfg = crate::config::Config::default();
406        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
407        assert_eq!(cli.pace_tolerance, 5);
408        assert!(cli.format.is_none());
409        assert!(cli.tooltip_format.is_none());
410        assert!(cli.icon.is_none());
411        assert!(!cli.format_pace_color);
412        assert!(!cli.tooltip_pace_pts);
413        assert!(!cli.pretty);
414        assert!(!cli.json);
415        assert!(cli.watch.is_none());
416        assert!(cli.command.is_none());
417    }
418
419    #[test]
420    fn account_add_subcommand_parses_without_widget_flags() {
421        let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
422        assert!(matches!(
423            cli.command,
424            Some(Command::Account {
425                action: AccountAction::Add {
426                    ref label,
427                    no_login: true,
428                    desktop: false,
429                    ..
430                }
431            }) if label == "work"
432        ));
433    }
434
435    /// The two halves of `add` capture different things and cannot be combined:
436    /// `--no-login` skips a `claude` login the Desktop capture never runs.
437    #[test]
438    fn account_add_desktop_takes_an_email_and_rejects_no_login() {
439        let cli = Cli::parse_from([
440            "ai-usagebar",
441            "account",
442            "add",
443            "work",
444            "--desktop",
445            "--email",
446            "a@b.test",
447            "-y",
448        ]);
449        assert!(matches!(
450            cli.command,
451            Some(Command::Account {
452                action: AccountAction::Add {
453                    desktop: true,
454                    yes: true,
455                    email: Some(ref email),
456                    ..
457                }
458            }) if email == "a@b.test"
459        ));
460        assert!(
461            Cli::try_parse_from([
462                "ai-usagebar",
463                "account",
464                "add",
465                "w",
466                "--desktop",
467                "--no-login"
468            ])
469            .is_err()
470        );
471        // --email / -y only mean something for the Desktop capture.
472        assert!(
473            Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
474                .is_err()
475        );
476    }
477
478    #[test]
479    fn account_switch_defaults_to_both_surfaces() {
480        let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
481        assert!(matches!(
482            cli.command,
483            Some(Command::Account {
484                action: AccountAction::Switch {
485                    ref label,
486                    desktop: false,
487                    cli: false,
488                    dry_run: true,
489                    keep_backups: 10,
490                    ..
491                }
492            }) if label == "work"
493        ));
494    }
495
496    #[test]
497    fn account_subcommand_rejects_ignored_widget_flags() {
498        assert!(
499            Cli::try_parse_from([
500                "ai-usagebar",
501                "--vendor",
502                "anthropic",
503                "account",
504                "add",
505                "work",
506            ])
507            .is_err()
508        );
509    }
510
511    #[test]
512    fn multi_account_flags_are_stable_api() {
513        // --cache-dir and --creds-path are the documented multi-account
514        // mechanism (README "Multiple accounts") since they were promoted
515        // from hidden debug flags. Renaming either is a breaking change.
516        let cli = Cli::parse_from([
517            "ai-usagebar",
518            "--vendor",
519            "anthropic",
520            "--cache-dir",
521            "/tmp/acct-a",
522            "--creds-path",
523            "/tmp/acct-a/credentials.json",
524        ]);
525        assert_eq!(
526            cli.cache_dir.as_deref(),
527            Some(std::path::Path::new("/tmp/acct-a"))
528        );
529        assert_eq!(
530            cli.creds_path.as_deref(),
531            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
532        );
533    }
534
535    #[test]
536    fn primary_from_config_wins_when_vendor_unset() {
537        // No --vendor and no scroll-cycle override → [ui] primary wins.
538        let cli = Cli::parse_from(["ai-usagebar"]);
539        let mut cfg = crate::config::Config::default();
540        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
541        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
542    }
543
544    #[test]
545    fn explicit_vendor_overrides_everything() {
546        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
547        // [ui] primary.
548        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
549        let mut cfg = crate::config::Config::default();
550        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
551        let active = Some(crate::vendor::VendorId::Openai);
552        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
553    }
554
555    #[test]
556    fn vendor_kimi_parses_to_kimi_variant() {
557        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
558        assert_eq!(cli.vendor, Some(Vendor::Kimi));
559        assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
560    }
561
562    #[test]
563    fn vendor_anthropic_api_uses_the_documented_slug() {
564        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
565        assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
566        assert_eq!(
567            cli.vendor.unwrap().to_id(),
568            crate::vendor::VendorId::AnthropicApi
569        );
570    }
571
572    #[test]
573    fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
574        let cli = Cli::parse_from(["ai-usagebar"]);
575        let mut cfg = crate::config::Config::default();
576        cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
577        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
578    }
579
580    #[test]
581    fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
582        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
583        assert_eq!(
584            cli.resolve_vendor_with(&crate::config::Config::default(), None),
585            Vendor::Kimi
586        );
587    }
588
589    #[test]
590    fn active_override_wins_over_config_primary_when_enabled() {
591        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
592        // primary, as long as it is still enabled.
593        let cli = Cli::parse_from(["ai-usagebar"]);
594        let mut cfg = crate::config::Config::default();
595        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
596        let active = Some(crate::vendor::VendorId::Zai);
597        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
598    }
599
600    #[test]
601    fn disabled_active_override_falls_back_to_config_primary() {
602        // A persisted active vendor the user has since disabled is skipped;
603        // resolution falls through to [ui] primary.
604        let cli = Cli::parse_from(["ai-usagebar"]);
605        let mut cfg = crate::config::Config::default();
606        cfg.zai.enabled = false;
607        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
608        let active = Some(crate::vendor::VendorId::Zai);
609        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
610    }
611
612    #[test]
613    fn claudebar_compatible_flag_surface() {
614        let cli = Cli::parse_from([
615            "ai-usagebar",
616            "--icon",
617            "󰚩",
618            "--format",
619            "{session_pct}% · {session_reset}",
620            "--tooltip-format",
621            "S:{session_pct}",
622            "--pace-tolerance",
623            "10",
624            "--format-pace-color",
625            "--tooltip-pace-pts",
626            "--color-low",
627            "#50fa7b",
628            "--color-mid",
629            "#f1fa8c",
630            "--color-high",
631            "#ffb86c",
632            "--color-critical",
633            "#ff5555",
634        ]);
635        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
636        assert_eq!(
637            cli.format.as_deref(),
638            Some("{session_pct}% · {session_reset}")
639        );
640        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
641        assert_eq!(cli.pace_tolerance, 10);
642        assert!(cli.format_pace_color);
643        assert!(cli.tooltip_pace_pts);
644        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
645        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
646    }
647
648    #[test]
649    fn pretty_and_json_conflict() {
650        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
651        assert!(res.is_err());
652    }
653
654    #[test]
655    fn watch_disables_json_output() {
656        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
657        assert_eq!(cli.watch, Some(5));
658        assert!(!cli.output_json());
659    }
660}