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 @@ -21,6 +21,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- `voltage context` and `voltage auth status` draw their fields in a titled card on a terminal, with sources dimmed. Redirected output is unchanged.
- `voltage wallets get` on a terminal leads with the wallet's name, its balances (with a USD estimate on mainnet), and a 14-day sparkline of completed payments.
- `--qr` on a terminal draws the invoice black on white with a quiet zone, so it scans on dark themes, under a `Scan to pay` caption with the amount, memo, and expiry, and after the result so it stays on screen; `--wait completed` counts down to the expiry. The caption leads with the network, its dot in the website's color.
- A payment that settles on a terminal closes with `⚡ Payment ID completed` instead of a check mark, printed after the payment's record so it stays on screen.

## 0.2.0 - 2026-10-22

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ API fields stay inside `data`. With `--all`, JSON output collects the pages into

Diagnostics, prompts, and progress go to stderr. Interactive stderr shows an animated spinner for network requests and waits, with the time waited so far on payment waits and device approval and on any wait past two seconds, and a countdown to when login stops waiting for approval (the code's expiry, or 10 minutes at most); it is cut to the terminal's width and cleared when the wait ends or is cancelled. `VOLTAGE_SPINNER=0` (or `false`) or `TERM=dumb` draws it as a still line; a Windows console without ANSI support shows none. Redirected stderr gets no spinner or control codes.

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.
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` (`✓ Payment ID ready` for an invoice that is ready but unpaid) or `✗ Payment ID failed · 4s` before the error. The closing line comes after the payment's record, so the outcome is the last thing on screen; for a ready invoice with `--qr`, the code follows it. 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` for `✓` and `⚡`, `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`, `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`.

Expand Down
4 changes: 3 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1433,11 +1433,13 @@ impl Call<'_> {
{
qr::check(invoice)?;
}
timeline.reached(outcome);
// The record first, then how the wait ended, so the outcome is what
// stays on screen; a ready invoice's code follows it.
self.out.write(
Envelope::new(Some(response.status), response.body, Some(id), outcome),
&api.secrets(),
)?;
timeline.reached(outcome);
let Some(invoice) = invoice else {
return Ok(());
};
Expand Down
4 changes: 4 additions & 0 deletions src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ impl Line {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Glyphs {
pub done: &'static str,
/// A settled payment: the lightning bolt.
pub paid: &'static str,
pub failed: &'static str,
pub step: &'static str,
/// Separates a status line's facts, as in `completed · 4s`.
Expand All @@ -228,13 +230,15 @@ pub struct Glyphs {
impl Glyphs {
pub const UNICODE: Self = Self {
done: "✓",
paid: "⚡",
failed: "✗",
step: "›",
separator: "·",
frames: &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
};
pub const ASCII: Self = Self {
done: "ok",
paid: "ok",
failed: "x",
step: ">",
separator: "-",
Expand Down
31 changes: 28 additions & 3 deletions src/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use crate::{
output::Outcome,
payment::{PaymentStatus, StatusText},
style::{Line, Role},
style::{Glyphs, Line, Role},
terminal::{self, Terminal},
};
use std::time::Duration;
Expand Down Expand Up @@ -106,9 +106,9 @@ impl Timeline {
/// The wait reached its target.
pub fn reached(&self, outcome: Outcome) {
if self.terminal.decorates() {
let glyphs = self.terminal.glyphs();
let (role, symbol) = closing(outcome, self.terminal.glyphs());
let line = Line::default()
.paint(Role::Success, glyphs.done)
.paint(role, symbol)
.text(&format!(" Payment {} ", self.id))
.paint(Role::Success, outcome.as_str());
self.terminal.notice_line(self.waited(line));
Expand Down Expand Up @@ -154,6 +154,15 @@ fn with_status(line: Line, status: &StatusText) -> Line {
}
}

/// A settled payment closes with the lightning bolt; a ready invoice, not yet paid, with a
/// check mark.
fn closing(outcome: Outcome, glyphs: Glyphs) -> (Role, &'static str) {
match outcome {
Outcome::Completed => (Role::Bolt, glyphs.paid),
_ => (Role::Success, glyphs.done),
}
}

fn is_final(status: &StatusText) -> bool {
matches!(
status,
Expand All @@ -178,6 +187,22 @@ fn status_role(status: PaymentStatus) -> Role {
mod tests {
use super::*;

#[test]
fn a_settled_payment_closes_with_a_bolt_and_a_ready_one_with_a_check() {
assert_eq!(
closing(Outcome::Completed, Glyphs::UNICODE),
(Role::Bolt, "⚡")
);
assert_eq!(
closing(Outcome::Ready, Glyphs::UNICODE),
(Role::Success, "✓")
);
assert_eq!(
closing(Outcome::Completed, Glyphs::ASCII),
(Role::Bolt, "ok")
);
}

#[test]
fn statuses_are_colored_by_meaning_and_unknown_text_stays_plain() {
assert_eq!(status_role(PaymentStatus::Completed), Role::Success);
Expand Down
49 changes: 44 additions & 5 deletions tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5773,11 +5773,15 @@ async fn a_payment_wait_on_a_terminal_shows_how_long_it_has_waited() {
// Each status gets one line, and the wait ends with a symbol; color marks only words.
assert!(shown.contains("› sending · 0s"), "{shown}");
assert!(
shown.contains(&format!("✓ Payment {RESOURCE} completed · ")),
shown.contains(&format!("⚡ Payment {RESOURCE} completed · ")),
"{shown}"
);
assert!(!shown.contains("status: sending"), "{shown}");
assert!(run.shown.contains("\u{1b}[32m✓"), "{:?}", run.shown);
assert!(
run.shown.contains("\u{1b}[1m\u{1b}[33m⚡"),
"{:?}",
run.shown
);
server.verify().await;
}

Expand Down Expand Up @@ -5806,7 +5810,7 @@ async fn a_submitted_payment_on_a_terminal_reads_as_a_timeline() {
.expect(&shown);
let receiving = shown.find("› receiving · ").expect(&shown);
let done = shown
.find(&format!("✓ Payment {RESOURCE} completed · "))
.find(&format!("⚡ Payment {RESOURCE} completed · "))
.expect(&shown);
assert!(accepted < receiving && receiving < done, "{shown}");
// An unchanged status is not repeated on a terminal.
Expand Down Expand Up @@ -6139,6 +6143,38 @@ async fn a_qr_code_on_a_terminal_is_framed_for_scanning_under_a_caption() {
assert!(!shown.contains("Scan to pay:"), "{shown}");
}

#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_settled_payment_ends_with_its_closing_line_below_the_record() {
let (server, dir) = fixture().await;
route("GET", payment_path())
.respond_with(in_order(vec![
ok(json!({"id": RESOURCE, "status": "sending"})),
ok(json!({"id": RESOURCE, "status": "completed", "type": "bolt11"})),
]))
.mount(&server)
.await;
let args = [
"payments",
"get",
RESOURCE,
"--org",
ORG,
"--env",
ENV,
"--wait",
"completed",
];
let shown = fully_on_a_terminal(dir.path(), &server, &args).await;
let record = shown.find("type ").expect(&shown);
let closing = shown
.find(&format!("⚡ Payment {RESOURCE} completed · "))
.expect(&shown);
assert!(record < closing, "{shown}");
// Nothing follows the closing line.
assert_eq!(shown[closing..].trim_end().lines().count(), 1, "{shown}");
}

#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_qr_code_for_a_ready_invoice_comes_last_so_it_stays_on_screen() {
Expand Down Expand Up @@ -6169,10 +6205,13 @@ async fn a_qr_code_for_a_ready_invoice_comes_last_so_it_stays_on_screen() {
"--execute",
]);
let shown = fully_on_a_terminal(dir.path(), &server, &args).await;
// The result's fields come first; the caption and code end the output.
// The result's fields, then how the wait ended, then the caption and code.
let fields = shown.find("payment_request").expect(&shown);
let ready = shown
.find(&format!("✓ Payment {RESOURCE} ready"))
.expect(&shown);
let caption = shown.find("Scan to pay").expect(&shown);
assert!(fields < caption, "{shown}");
assert!(fields < ready && ready < caption, "{shown}");
assert!(!shown[caption..].contains("payment_request"), "{shown}");
assert!(shown.contains("● mutinynet · Scan to pay"), "{shown}");
server.verify().await;
Expand Down
Loading