From 73ba6b35b6710842e762c96db8f7243f1c9c9c30 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:15:38 -0500 Subject: [PATCH] feat(cli): color help headers Voltage orange and honor FORCE_COLOR Help headers and usage use Voltage orange (#FF5000): 24-bit with COLORTERM=truecolor or FORCE_COLOR=3, color 202 on a 256-color terminal or with FORCE_COLOR=2, and bold elsewhere. Every other style stays on the 16 basic colors. FORCE_COLOR=0 or false turns color off and any other value turns it on when redirected; --color and NO_COLOR still win, and it wins over CLICOLOR_FORCE. --- CHANGELOG.md | 1 + docs/reference/environment.md | 4 +- docs/reference/output.md | 2 +- src/cli.rs | 7 +- src/startup.rs | 3 +- src/style.rs | 152 +++++++++++++++++++++++++++++++--- tests/cli_integration.rs | 43 +++++++++- 7 files changed, 194 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4041095..063da81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - `voltage login` on a terminal shows the verification URL as a clickable link, the code in bold, a countdown to the code's expiry, and a closing `✓ Logged in as EMAIL` line. - A change described without `--execute` starts, on a terminal, with what it would do and the values it would use (`! Not sent: this would send a payment`) and ends with how to send it, instead of an `error:` line. Exit code 6 is unchanged. - Human output shows amounts with grouped digits, every BTC amount in sats (`150,000 sats`, `1.5 sats`) and cents as dollars (`$1,234.56`), and table timestamps relative to now (`3 minutes ago`); single resources show the UTC time with the relative time beside it. With color on, headers and field names are bold and known statuses are colored. JSON is unchanged. +- Help headers and usage are Voltage orange where the terminal draws 256 or 24-bit color, and bold elsewhere. `FORCE_COLOR` is honored: `0` or `false` turns color off, other values turn it on when redirected; `--color` and `NO_COLOR` still win. ## 0.2.0 - 2026-10-22 diff --git a/docs/reference/environment.md b/docs/reference/environment.md index ef8bd33..375d3a4 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -16,7 +16,9 @@ A flag beats a variable. A selected profile is a complete configuration, so `VOL | `COLUMNS` | Terminal width for human tables; otherwise the terminal's own width | — | | `VOLTAGE_SPINNER` | `0` or `false` draws progress as a still line instead of an animated spinner, for screen readers, which read every redraw | — | | `NO_COLOR` | Any non-empty value turns off color in human output and help | `--color` | -| `CLICOLOR_FORCE` | Any non-zero value colors human output and help even when redirected | `--color`, `NO_COLOR` | +| `FORCE_COLOR` | `0` or `false` turns off color; any other non-empty value colors human output and help even when redirected. `2` and `3` also select 256 and 24-bit color for the brand color | `--color`, `NO_COLOR` | +| `COLORTERM` | `truecolor` or `24bit` draws the brand color in 24-bit color; otherwise a `TERM` naming `256color` draws it in 256 colors, and other terminals show it as bold | — | +| `CLICOLOR_FORCE` | Any non-zero value colors human output and help even when redirected | `--color`, `NO_COLOR`, `FORCE_COLOR` | | `CLICOLOR` | `0` turns off color on a terminal | `--color`, `NO_COLOR`, `CLICOLOR_FORCE` | Related: [configuration](configuration.md), [global flags](global-flags.md), [scripting](../guides/scripting.md). diff --git a/docs/reference/output.md b/docs/reference/output.md index 4bc3954..674d1da 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -30,7 +30,7 @@ Diagnostics, prompts, and progress go to stderr. Interactive stderr shows an ani On interactive stderr a payment wait reads as a timeline: `› Payment ID accepted; waiting for completed`, one `› status · time` line each time the status changes, and a closing `✓ Payment ID completed · 4s` or `✗ Payment ID failed · 4s` before the error. Redirected stderr keeps the plain `Payment ID status: sending; waiting for completed.` lines, repeated every 15 seconds while the status holds. Symbols are Unicode when the locale is UTF-8 and `TERM` is not `dumb` or `linux` (on Windows, in Windows Terminal), and ASCII otherwise (`ok`, `x`, `>`). `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs. -Human output and help are colored on a terminal: `error:` is red, `hint:` cyan, and `details:` dim, and color only marks a label, so the words read the same without it. `--color always|never` (or `--no-color`) decides; otherwise `NO_COLOR`, `CLICOLOR_FORCE`, `CLICOLOR`, `TERM=dumb`, and whether each stream is a terminal decide, separately for stdout and stderr. JSON, NDJSON, `--output-file`, and completions are never colored, even with `--color always`. +Human output and help are colored on a terminal: `error:` is red, `hint:` cyan, and `details:` dim, and color only marks a label, so the words read the same without it. `--color always|never` (or `--no-color`) decides; otherwise `NO_COLOR`, `FORCE_COLOR`, `CLICOLOR_FORCE`, `CLICOLOR`, `TERM=dumb`, and whether each stream is a terminal decide, in that order, separately for stdout and stderr. Help headers and usage are Voltage orange: 24-bit with `COLORTERM=truecolor`, 256-color on a `256color` terminal, and bold elsewhere; every other color is one of the 16 basic colors your terminal theme sets. JSON, NDJSON, `--output-file`, and completions are never colored, even with `--color always`. Human errors start with `error:`, which leads with the API's reason when it rejects a request with a client error (HTTP 4xx), and may include a safe `hint:` and an indented `details:` block of `field value` lines. `--json` reports the complete API response in `error.detail`, and makes command-line parse errors machine-readable as well as runtime errors. A downstream consumer that closes stdout early is treated as successful pipeline completion. diff --git a/src/cli.rs b/src/cli.rs index 7d3fe78..eb0fa9e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -131,11 +131,12 @@ impl GlobalFlags { OutputFormat::select(self.json, self.output) } + /// `--color` or `--no-color`, else `FORCE_COLOR`, else automatic. pub fn color(&self) -> ColorChoice { if self.no_color { ColorChoice::Never } else { - self.color + self.color.with_environment(|name| std::env::var(name).ok()) } } @@ -667,8 +668,10 @@ impl Invocation { /// whether parse failures need clap's human rendering or the JSON error envelope; `color` /// colors clap's help and errors, since `--color` itself is not parsed yet. pub fn from_env(color: ColorChoice) -> std::result::Result { + let depth = style::Depth::detect(|name| std::env::var(name).ok()); let matches = command() .color(color.as_clap()) + .styles(style::help(depth)) .try_get_matches() .map_err(ParseFailure::Clap)?; Self::from_matches(&matches).map_err(ParseFailure::Validation) @@ -901,7 +904,7 @@ pub fn command() -> ClapCommand { .version(env!("VOLTAGE_VERSION")) .subcommand_required(true) .arg_required_else_help(true) - .styles(style::HELP); + .styles(style::help(style::Depth::Basic)); let root = GlobalFlags::augment_args(root); let mut root = LocalCommand::augment_subcommands(root); for name in root diff --git a/src/startup.rs b/src/startup.rs index 78e9b95..87fce5c 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -25,7 +25,8 @@ const BINARY_NAME: &str = "voltage"; pub async fn run() -> ExitCode { let json_errors = cli::json_errors_requested(std::env::args_os()); - let color = ColorChoice::requested(std::env::args_os()); + let color = ColorChoice::requested(std::env::args_os()) + .with_environment(|name| std::env::var(name).ok()); let invocation = match Invocation::from_env(color) { Ok(invocation) => invocation, Err(failure) => return fail_parse(failure, json_errors, color), diff --git a/src/style.rs b/src/style.rs index 269cc0a..fe2b191 100644 --- a/src/style.rs +++ b/src/style.rs @@ -12,7 +12,7 @@ use clap::{ ValueEnum, builder::{ Styles, - styling::{AnsiColor, Style}, + styling::{Ansi256Color, AnsiColor, Color, RgbColor, Style}, }, }; use std::{ffi::OsStr, io::Write}; @@ -64,6 +64,21 @@ impl ColorChoice { choice } + /// Fold in `FORCE_COLOR` when no flag decided: `0` or `false` turns color off, and any + /// other non-empty value turns it on even when redirected. `NO_COLOR` still wins, and + /// `FORCE_COLOR` wins over `CLICOLOR_FORCE` and `CLICOLOR`, which anstream reads. + pub fn with_environment(self, var: impl Fn(&str) -> Option) -> Self { + let set = |name| var(name).filter(|value: &String| !value.is_empty()); + if self != Self::Auto || set("NO_COLOR").is_some() { + return self; + } + match set("FORCE_COLOR").as_deref() { + Some("0" | "false") => Self::Never, + Some(_) => Self::Always, + None => self, + } + } + pub fn as_clap(self) -> clap::ColorChoice { match self { Self::Auto => clap::ColorChoice::Auto, @@ -234,16 +249,58 @@ pub fn stdout(choice: ColorChoice) -> anstream::AutoStream Option) -> Self { + match var("FORCE_COLOR").as_deref() { + Some("3") => return Self::TrueColor, + Some("2") => return Self::Ansi256, + _ => {} + } + let truecolor = matches!(var("COLORTERM").as_deref(), Some("truecolor" | "24bit")) + || (cfg!(windows) && var("WT_SESSION").is_some()); + if truecolor { + Self::TrueColor + } else if var("TERM").is_some_and(|term| term.contains("256color")) { + Self::Ansi256 + } else { + Self::Basic + } + } +} + +/// Voltage orange, `#FF5000`, at the depth the terminal draws; bold alone without one. +pub fn brand(depth: Depth) -> Style { + let color = match depth { + Depth::TrueColor => Some(Color::Rgb(RgbColor(0xFF, 0x50, 0x00))), + Depth::Ansi256 => Some(Color::Ansi256(Ansi256Color(202))), + Depth::Basic => None, + }; + Style::new().fg_color(color).bold() +} + +/// Help and parse-error styles in cargo's manner, with headers and usage in the brand color. +pub fn help(depth: Depth) -> Styles { + Styles::styled() + .header(brand(depth)) + .usage(brand(depth)) + .literal(AnsiColor::Cyan.on_default().bold()) + .placeholder(AnsiColor::Cyan.on_default()) + .error(AnsiColor::Red.on_default().bold()) + .valid(AnsiColor::Green.on_default().bold()) + .invalid(AnsiColor::Yellow.on_default().bold()) +} #[cfg(test)] mod tests { @@ -368,6 +425,79 @@ mod tests { } } + fn vars<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| { + vars.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_owned()) + } + } + + #[test] + fn force_color_applies_only_without_a_flag_or_no_color() { + use ColorChoice::{Always, Auto, Never}; + for (choice, env, expected) in [ + (Auto, &[][..], Auto), + (Auto, &[("FORCE_COLOR", "1")][..], Always), + (Auto, &[("FORCE_COLOR", "3")][..], Always), + (Auto, &[("FORCE_COLOR", "true")][..], Always), + (Auto, &[("FORCE_COLOR", "0")][..], Never), + (Auto, &[("FORCE_COLOR", "false")][..], Never), + (Auto, &[("FORCE_COLOR", "")][..], Auto), + // NO_COLOR wins; anstream then turns color off. + (Auto, &[("FORCE_COLOR", "1"), ("NO_COLOR", "1")][..], Auto), + (Auto, &[("FORCE_COLOR", "1"), ("NO_COLOR", "")][..], Always), + // A flag wins over both. + (Never, &[("FORCE_COLOR", "1")][..], Never), + (Always, &[("FORCE_COLOR", "0")][..], Always), + ] { + assert_eq!( + choice.with_environment(vars(env)), + expected, + "{choice:?} {env:?}" + ); + } + } + + #[test] + fn depth_follows_force_color_then_colorterm_then_term() { + assert_eq!(Depth::detect(vars(&[])), Depth::Basic); + assert_eq!( + Depth::detect(vars(&[("TERM", "xterm-256color")])), + Depth::Ansi256 + ); + assert_eq!( + Depth::detect(vars(&[("COLORTERM", "truecolor")])), + Depth::TrueColor + ); + assert_eq!( + Depth::detect(vars(&[("COLORTERM", "24bit")])), + Depth::TrueColor + ); + assert_eq!( + Depth::detect(vars(&[("FORCE_COLOR", "2"), ("COLORTERM", "truecolor")])), + Depth::Ansi256 + ); + assert_eq!( + Depth::detect(vars(&[("FORCE_COLOR", "3")])), + Depth::TrueColor + ); + assert_eq!(Depth::detect(vars(&[("FORCE_COLOR", "1")])), Depth::Basic); + } + + #[test] + fn the_brand_color_degrades_to_bold() { + assert_eq!( + format!("{}", brand(Depth::TrueColor)), + "\u{1b}[1m\u{1b}[38;2;255;80;0m" + ); + assert_eq!( + format!("{}", brand(Depth::Ansi256)), + "\u{1b}[1m\u{1b}[38;5;202m" + ); + assert_eq!(format!("{}", brand(Depth::Basic)), "\u{1b}[1m"); + } + #[test] fn choices_map_one_to_one_onto_clap_and_anstream() { for (choice, clap, stream) in [ diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index c4195e4..ee8bd53 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -151,6 +151,11 @@ fn color_follows_the_flag_then_the_environment_and_never_reaches_json() { (&["--color=never"], &[("CLICOLOR_FORCE", "1")], false), (&["--no-color"], &[("CLICOLOR_FORCE", "1")], false), (&[], &[("CLICOLOR_FORCE", "1"), ("NO_COLOR", "1")], false), + (&[], &[("FORCE_COLOR", "1")], true), + // FORCE_COLOR=0 beats CLICOLOR_FORCE; NO_COLOR and a flag beat FORCE_COLOR. + (&[], &[("FORCE_COLOR", "0"), ("CLICOLOR_FORCE", "1")], false), + (&[], &[("FORCE_COLOR", "1"), ("NO_COLOR", "1")], false), + (&["--color", "never"], &[("FORCE_COLOR", "1")], false), (&["--color", "auto"], &[], false), ] { let shown = missing_org(&[&["--output", "table"], args].concat(), env); @@ -161,7 +166,10 @@ fn color_follows_the_flag_then_the_environment_and_never_reaches_json() { // JSON errors are byte-identical whatever color asks for. let json = missing_org(&["--json"], &[]); assert_eq!( - missing_org(&["--json", "--color", "always"], &[("CLICOLOR_FORCE", "1")]), + missing_org( + &["--json", "--color", "always"], + &[("CLICOLOR_FORCE", "1"), ("FORCE_COLOR", "3")] + ), json ); assert!(!json.contains('\u{1b}')); @@ -171,7 +179,11 @@ fn color_follows_the_flag_then_the_environment_and_never_reaches_json() { fn help_and_parse_errors_follow_the_color_choice() { let voltage = || { let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("voltage")); - cmd.env_remove("NO_COLOR").env_remove("CLICOLOR_FORCE"); + cmd.env_remove("NO_COLOR") + .env_remove("CLICOLOR_FORCE") + .env_remove("FORCE_COLOR") + .env_remove("COLORTERM") + .env("TERM", "xterm"); cmd }; let help = |args: &[&str]| stdout(&voltage().args(args).arg("--help").assert().success()); @@ -179,6 +191,31 @@ fn help_and_parse_errors_follow_the_color_choice() { let colored = help(&["--color", "always"]); assert!(colored.contains('\u{1b}')); assert_eq!(strip_styles(&colored), help(&[])); + // Headers and usage are Voltage orange at the depth the terminal draws, else bold. + let help_with = |name: &str, value: &str, args: &[&str]| { + stdout( + &voltage() + .env(name, value) + .args(args) + .arg("--help") + .assert() + .success(), + ) + }; + let truecolor = help_with("COLORTERM", "truecolor", &["--color", "always"]); + assert!( + truecolor.contains("\u{1b}[1m\u{1b}[38;2;255;80;0mUsage:"), + "{truecolor:?}" + ); + let ansi256 = help_with("TERM", "xterm-256color", &["--color", "always"]); + assert!( + ansi256.contains("\u{1b}[1m\u{1b}[38;5;202mUsage:"), + "{ansi256:?}" + ); + assert!(colored.contains("\u{1b}[1mUsage:"), "{colored:?}"); + // FORCE_COLOR=3 colors redirected help, in 24-bit color. + let forced = help_with("FORCE_COLOR", "3", &[]); + assert!(forced.contains("\u{1b}[38;2;255;80;0m"), "{forced:?}"); let typo = stderr( &voltage() .args(["--color=always", "paymnts"]) @@ -273,6 +310,8 @@ fn human_process_at(dir: &Path, api_url: &str) -> std::process::Command { "NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE", + "FORCE_COLOR", + "COLORTERM", ] { cmd.env_remove(name); }