Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/reference/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 1 addition & 1 deletion docs/reference/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 5 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}

Expand Down Expand Up @@ -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<Self, ParseFailure> {
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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
152 changes: 141 additions & 11 deletions src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use clap::{
ValueEnum,
builder::{
Styles,
styling::{AnsiColor, Style},
styling::{Ansi256Color, AnsiColor, Color, RgbColor, Style},
},
};
use std::{ffi::OsStr, io::Write};
Expand Down Expand Up @@ -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<String>) -> 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,
Expand Down Expand Up @@ -234,16 +249,58 @@ pub fn stdout(choice: ColorChoice) -> anstream::AutoStream<std::io::StdoutLock<'
anstream::AutoStream::new(std::io::stdout().lock(), choice.as_stream())
}

/// Help and parse-error styles in cargo's manner, using only the 16 basic colors so the
/// terminal's theme keeps them legible.
pub const HELP: Styles = Styles::styled()
.header(AnsiColor::Green.on_default().bold())
.usage(AnsiColor::Green.on_default().bold())
.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());
/// How many colors the terminal draws, for the one brand color; every other style uses
/// the 16 basic colors, which the terminal's theme keeps legible.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Depth {
Basic,
Ansi256,
TrueColor,
}

impl Depth {
/// `FORCE_COLOR=2` or `3` names a depth, as chalk reads it; otherwise `COLORTERM` of
/// `truecolor` or `24bit` (or Windows Terminal) means 24-bit, and a `TERM` naming
/// `256color` means 256 colors.
pub fn detect(var: impl Fn(&str) -> Option<String>) -> 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 {
Expand Down Expand Up @@ -368,6 +425,79 @@ mod tests {
}
}

fn vars<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + '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 [
Expand Down
43 changes: 41 additions & 2 deletions tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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}'));
Expand All @@ -171,14 +179,43 @@ 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());
assert!(!help(&[]).contains('\u{1b}'));
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"])
Expand Down Expand Up @@ -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);
}
Expand Down
Loading