diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 591fff58..f379f990 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -20,6 +20,7 @@ clap = { workspace = true } clap_complete = "4" anyhow = { workspace = true } colored = { workspace = true } +owo-colors = "4" indicatif = { workspace = true } dialoguer = { workspace = true } tabled = { workspace = true } diff --git a/crates/cli/src/commands/auth.rs b/crates/cli/src/commands/auth.rs index 1d5dafbe..0a5112df 100644 --- a/crates/cli/src/commands/auth.rs +++ b/crates/cli/src/commands/auth.rs @@ -1,8 +1,7 @@ //! `prism login` and `prism logout` — Manage API credentials for hosted services. -use clap::{Args, Subcommand}; use anyhow::{Result, anyhow}; -use colored::Colorize; +use clap::{Args, Subcommand}; use dialoguer::Select; use rpassword::prompt_password; use serde::{Deserialize, Serialize}; @@ -72,6 +71,8 @@ async fn login( config_path: Option, output_format: &str, ) -> Result<()> { + let palette = crate::output::theme::ColorPalette::default(); + // Determine provider name let provider = match provider_param { Some(p) => p, @@ -79,11 +80,14 @@ async fn login( }; // Prompt for API key securely - let prompt = format!("Enter your API key for {}: ", provider.green()); + let prompt = format!( + "Enter your API key for {}: ", + palette.success_text(&provider) + ); let api_key = prompt_password(&prompt)?; if api_key.trim().is_empty() { - eprintln!("{}", "API key cannot be empty.".red()); + eprintln!("{}", palette.error_text("API key cannot be empty.")); std::process::exit(1); } @@ -102,11 +106,11 @@ async fn login( }); println!("{}", serde_json::to_string_pretty(&payload)?); } else { - println!("✓ Credentials for {} saved.", provider.green()); + println!("✓ Credentials for {} saved.", palette.success_text(&provider)); } } Err(e) => { - eprintln!("{} {}", "Error:".red(), e); + eprintln!("{} {}", palette.error_text("Error:"), e); std::process::exit(1); } } @@ -120,6 +124,8 @@ async fn logout( config_path: Option, output_format: &str, ) -> Result<()> { + let palette = crate::output::theme::ColorPalette::default(); + // Determine provider name let provider = match provider_param { Some(p) => p, @@ -141,7 +147,7 @@ async fn logout( }); println!("{}", serde_json::to_string_pretty(&payload)?); } else { - println!("✓ Credentials for {} removed.", provider.green()); + println!("✓ Credentials for {} removed.", palette.success_text(&provider)); } } Ok(false) => { @@ -157,11 +163,11 @@ async fn logout( }); println!("{}", serde_json::to_string_pretty(&payload)?); } else { - println!("No credentials found for {}.", provider.yellow()); + println!("No credentials found for {}.", palette.warning_text(&provider)); } } Err(e) => { - eprintln!("{} {}", "Error:".red(), e); + eprintln!("{} {}", palette.error_text("Error:"), e); std::process::exit(1); } } diff --git a/crates/cli/src/commands/diagnostic.rs b/crates/cli/src/commands/diagnostic.rs index 0cb5ef63..9e58ca8d 100644 --- a/crates/cli/src/commands/diagnostic.rs +++ b/crates/cli/src/commands/diagnostic.rs @@ -4,8 +4,8 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use anyhow::Result; -use colored::Colorize; use directories::ProjectDirs; +use crate::output::theme::ColorPalette; // ─── Args ──────────────────────────────────────────────────────────────────── @@ -42,11 +42,12 @@ impl Status { } } - fn label(&self) -> colored::ColoredString { + fn label(&self) -> String { + let palette = ColorPalette::default(); match self { - Self::Ok => " OK ".green().bold(), - Self::Warning(_) => " WARN ".yellow().bold(), - Self::Error(_) => " ERROR ".red().bold(), + Self::Ok => palette.success_text(" OK "), + Self::Warning(_) => palette.warning_text(" WARN "), + Self::Error(_) => palette.error_text(" ERROR "), } } } @@ -264,9 +265,10 @@ fn dir_size_mib(path: &PathBuf) -> Result { // ─── Report ─────────────────────────────────────────────────────────────────── fn print_report(checks: &[Check], quiet: bool) { + let palette = ColorPalette::default(); let sep = "─".repeat(58); - println!("\n {}", "Prism Diagnostic Report".bold()); - println!(" {}\n", sep.dimmed()); + println!("\n {}", palette.accent_text("Prism Diagnostic Report")); + println!(" {}\n", palette.muted_text(&sep)); for check in checks { if quiet && check.status.is_ok() { @@ -274,11 +276,15 @@ fn print_report(checks: &[Check], quiet: bool) { } println!(" [{}] {}", check.status.label(), check.name); if let Some(detail) = check.status.detail() { - println!(" {} {}", "└─".dimmed(), detail.dimmed()); + println!( + " {} {}", + palette.muted_text("└─"), + palette.muted_text(detail) + ); } } - println!("\n {}", sep.dimmed()); + println!("\n {}", palette.muted_text(&sep)); let warnings = checks .iter() @@ -287,12 +293,12 @@ fn print_report(checks: &[Check], quiet: bool) { let errors = checks.iter().filter(|c| c.status.is_error()).count(); if errors == 0 && warnings == 0 { - println!(" {}\n", "All checks passed.".green().bold()); + println!(" {}\n", palette.success_text("All checks passed.")); } else { println!( " {} warning(s), {} error(s).\n", - warnings.to_string().yellow(), - errors.to_string().red() + palette.warning_text(&warnings.to_string()), + palette.error_text(&errors.to_string()) ); } } @@ -300,7 +306,8 @@ fn print_report(checks: &[Check], quiet: bool) { // ─── Entry point ────────────────────────────────────────────────────────────── pub async fn run(args: DiagnosticArgs) -> Result<()> { - println!("{}", "Running diagnostics…".dimmed()); + let palette = ColorPalette::default(); + println!("{}", palette.muted_text("Running diagnostics...")); let mut checks: Vec = Vec::new(); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 6422e1b9..3111ff68 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -77,6 +77,10 @@ struct Cli { /// Suppress non-essential output. #[arg(long, short, global = true)] quiet: bool, + + /// Disable ANSI colors in terminal output. + #[arg(long, global = true)] + no_color: bool, } #[derive(Subcommand)] @@ -128,10 +132,13 @@ async fn main() -> anyhow::Result<()> { output = %cli.output, network_arg = %cli.network, verbose = cli.verbose, + no_color = cli.no_color, config_loaded = loaded_config.is_some(), "CLI arguments parsed" ); + output::theme::set_color_enabled(!cli.no_color); + let mut network = prism_core::network::config::resolve_network(&cli.network); if let Some(ref rpc_url) = cli.rpc_url { network.rpc_url = rpc_url.clone(); diff --git a/crates/cli/src/output/mod.rs b/crates/cli/src/output/mod.rs index a01e43e6..3a8bb5d9 100644 --- a/crates/cli/src/output/mod.rs +++ b/crates/cli/src/output/mod.rs @@ -12,6 +12,7 @@ pub mod compact; pub mod human; pub mod json; pub mod renderers; +pub mod theme; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputFormat { @@ -69,8 +70,9 @@ pub fn print_resource_profile( renderers::BudgetBar::new("Memory", profile.total_memory, profile.memory_limit) .render() ); + let palette = theme::ColorPalette::default(); for warning in &profile.warnings { - println!("{} {warning}", colored::Colorize::yellow("⚠")); + println!("{} {warning}", palette.warning_text("⚠")); } println!(); print!("{}", renderers::render_heatmap(profile)); @@ -84,13 +86,14 @@ pub fn print_state_diff(diff: &StateDiff, output_format: &str) -> anyhow::Result OutputFormat::Json => println!("{}", serde_json::to_string_pretty(diff)?), OutputFormat::Short => println!("{}", format_state_diff_summary(diff)), OutputFormat::Human => { - println!("{}", colored::Colorize::bold("State Diff")); + let palette = theme::ColorPalette::default(); + println!("{}", palette.accent_text("State Diff")); for entry in &diff.entries { let symbol = match entry.change_type { - DiffChangeType::Created => colored::Colorize::green("+"), - DiffChangeType::Deleted => colored::Colorize::red("-"), - DiffChangeType::Updated => colored::Colorize::yellow("~"), - DiffChangeType::Unchanged => colored::Colorize::dimmed(" "), + DiffChangeType::Created => palette.success_text("+"), + DiffChangeType::Deleted => palette.error_text("-"), + DiffChangeType::Updated => palette.warning_text("~"), + DiffChangeType::Unchanged => palette.muted_text(" "), }; println!("{symbol} {}", entry.key); } diff --git a/crates/cli/src/output/renderers.rs b/crates/cli/src/output/renderers.rs index ed8a01cc..6297bf12 100644 --- a/crates/cli/src/output/renderers.rs +++ b/crates/cli/src/output/renderers.rs @@ -2,10 +2,10 @@ #![allow(dead_code)] -use colored::Colorize; use prism_core::types::report::TransactionContext; use prism_core::types::trace::ResourceProfile; use tabled::{Table, Tabled}; +use crate::output::theme::ColorPalette; const BAR_WIDTH: usize = 10; const HEAT_BLOCKS: [&str; 4] = ["░", "▒", "▓", "█"]; @@ -31,8 +31,9 @@ impl<'a> SectionHeader<'a> { let border = format!("+{}+", "-".repeat(inner.chars().count())); let middle = format!("|{}|", inner); - let border = border.cyan().bold().to_string(); - let middle = middle.white().bold().to_string(); + let palette = ColorPalette::default(); + let border = palette.metadata_text(&border); + let middle = palette.accent_text(&middle); format!("{}\n{}\n{}", border, middle, border) } @@ -61,12 +62,13 @@ impl BudgetBar { let empty = BAR_WIDTH.saturating_sub(filled); let bar_str = format!("{}{}", "█".repeat(filled), "░".repeat(empty)); + let palette = ColorPalette::default(); let colored_bar = if pct >= 0.9 { - bar_str.red().bold().to_string() + palette.error_text(&bar_str) } else if pct >= 0.7 { - bar_str.yellow().to_string() + palette.warning_text(&bar_str) } else { - bar_str.green().to_string() + palette.success_text(&bar_str) }; format!( @@ -95,24 +97,26 @@ fn heat_cell(intensity: f64) -> String { let empty = BAR_WIDTH.saturating_sub(filled); let cell = format!("{}{}", block.repeat(filled), "░".repeat(empty)); + let palette = ColorPalette::default(); if intensity >= 0.75 { - cell.red().bold().to_string() + palette.error_text(&cell) } else if intensity >= 0.5 { - cell.yellow().to_string() + palette.warning_text(&cell) } else if intensity >= 0.25 { - cell.cyan().to_string() + palette.metadata_text(&cell) } else { - cell.dimmed().to_string() + palette.muted_text(&cell) } } /// Render a resource heatmap grid from a `ResourceProfile`. pub fn render_heatmap(profile: &ResourceProfile) -> String { if profile.hotspots.is_empty() { + let palette = ColorPalette::default(); return format!( "{}\n {}\n", render_section_header("Resource Heatmap"), - "No hotspot data available.".dimmed() + palette.muted_text("No hotspot data available.") ); } @@ -186,12 +190,13 @@ pub fn render_heatmap(profile: &ResourceProfile) -> String { } out.push('\n'); + let palette = ColorPalette::default(); out.push_str(&format!( " Legend: {} cold {} low {} medium {} hot\n", - "░░░░░░░░░░".dimmed(), - "▒▒▒▒▒▒▒▒▒▒".cyan(), - "▓▓▓▓▓▓▓▓▓▓".yellow(), - "██████████".red().bold(), + palette.muted_text("░░░░░░░░░░"), + palette.metadata_text("▒▒▒▒▒▒▒▒▒▒"), + palette.warning_text("▓▓▓▓▓▓▓▓▓▓"), + palette.error_text("██████████"), )); out diff --git a/crates/cli/src/output/theme.rs b/crates/cli/src/output/theme.rs new file mode 100644 index 00000000..2b1bd32d --- /dev/null +++ b/crates/cli/src/output/theme.rs @@ -0,0 +1,72 @@ +//! Semantic terminal color palette for CLI output. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use owo_colors::{OwoColorize, Style}; + +static COLOR_ENABLED: AtomicBool = AtomicBool::new(true); + +pub fn set_color_enabled(enabled: bool) { + COLOR_ENABLED.store(enabled, Ordering::Relaxed); +} + +pub fn colors_enabled() -> bool { + COLOR_ENABLED.load(Ordering::Relaxed) +} + +#[derive(Clone, Copy)] +pub struct ColorPalette { + pub error: Style, + pub warning: Style, + pub success: Style, + pub metadata: Style, + pub muted: Style, + pub accent: Style, +} + +impl Default for ColorPalette { + fn default() -> Self { + Self { + error: Style::new().red().bold(), + warning: Style::new().yellow().bold(), + success: Style::new().green().bold(), + metadata: Style::new().cyan(), + muted: Style::new().dimmed(), + accent: Style::new().white().bold(), + } + } +} + +impl ColorPalette { + fn paint(&self, text: &str, style: Style) -> String { + if colors_enabled() { + format!("{}", text.style(style)) + } else { + text.to_string() + } + } + + pub fn error_text(&self, text: &str) -> String { + self.paint(text, self.error) + } + + pub fn warning_text(&self, text: &str) -> String { + self.paint(text, self.warning) + } + + pub fn success_text(&self, text: &str) -> String { + self.paint(text, self.success) + } + + pub fn metadata_text(&self, text: &str) -> String { + self.paint(text, self.metadata) + } + + pub fn muted_text(&self, text: &str) -> String { + self.paint(text, self.muted) + } + + pub fn accent_text(&self, text: &str) -> String { + self.paint(text, self.accent) + } +}