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 @@ -12,6 +12,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- `--output-file` on a path that already exists says so and suggests a new path, instead of showing an OS error; any other refusal keeps its cause and gets the same hint. Both say that no change was sent.
- `-o` is short for `--output`, as in `voltage wallets list -o json`.
- Color on a terminal: help, `error:`, `hint:`, and `details:` labels. `--color auto|always|never` (or `--no-color`) decides, else `NO_COLOR`, `CLICOLOR_FORCE`, and `CLICOLOR`. JSON is never colored.
- An animated spinner on interactive stderr, with the time waited after two seconds; `VOLTAGE_SPINNER=0` keeps it still. A payment wait on a terminal reads as a timeline: one line per status change and a closing `✓` or `✗` line.

## 0.2.0 - 2026-10-22

Expand Down
7 changes: 4 additions & 3 deletions docs/quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ not precedent. Reviews must approve and document exceptions.
`auth`. Keep settings, credential storage, and private files in `config`.
Keep payment values and payment read views in `payment`. Keep secret text
in `secret`. Keep result envelopes, redaction, and rendering, including the
table layout in `output::human`, in `output`. Keep prompts, pickers, progress,
notices, the terminal's width, and scrubbing of untrusted text bound for the
terminal in `terminal`. Keep which commands ask for missing scope, and in what
table layout in `output::human`, in `output`. Keep prompts, pickers, notices, the
terminal's width, and scrubbing of untrusted text bound for the terminal in
`terminal`; the animated status line in `progress`; a payment wait's status
lines in `timeline`; and the color palette and symbols in `style`. Keep which commands ask for missing scope, and in what
order, in `pick`. Keep the default `--currency` of a payment, read from its
wallet's line of credit, in `wallet_currency`. Keep exit-code categories and
typed error detail in `error`.
Expand Down
1 change: 1 addition & 0 deletions docs/reference/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ A flag beats a variable. A selected profile is a complete configuration, so `VOL
| `VOLTAGE_CONFIG_DIR` | The configuration directory, as an absolute path | `--config-dir` |
| `XDG_CONFIG_HOME` | Parent of the default configuration directory, `$XDG_CONFIG_HOME/voltage` (else `~/.config/voltage`) | `--config-dir`, `VOLTAGE_CONFIG_DIR` |
| `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` |
| `CLICOLOR` | `0` turns off color on a terminal | `--color`, `NO_COLOR`, `CLICOLOR_FORCE` |
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ API fields stay inside `data`. With `--all`, JSON output collects the pages into

## Streams

Diagnostics, prompts, and progress go to stderr. Interactive stderr shows immediate, cancellable status for network requests and waits, with the time waited so far on payment waits and device approval; redirected stderr gets no spinner/control codes. `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs.
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; 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.

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`.

Expand Down
3 changes: 1 addition & 2 deletions src/all_orgs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ use crate::{
config::{Credential, Scope},
error::{ErrorDetail, ErrorKind},
output::{Envelope, Outcome, Output},
terminal::Terminal,
};
use futures_util::{StreamExt, stream};
use serde::Serialize;
Expand Down Expand Up @@ -89,7 +88,7 @@ pub async fn wallets(
let organizations = organizations(&listed);
let token = &login.access_token;
// The exchanges run side by side, so only the one line below shows progress.
let quiet = Terminal::new(true, global.no_input);
let quiet = global.terminal().quieted();
let reads = stream::iter(&organizations)
.map(|organization| async move {
let scope = Scope {
Expand Down
42 changes: 13 additions & 29 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ use crate::{
error::{CredentialSources, ErrorDetail, ErrorKind, ResponseLimit},
input::{PaginationMode, Request},
output::{Envelope, Outcome, Output},
payment::{
Currency, PaymentDirection, PaymentView, ReceiveKind, StatusText, WaitProgress, WaitTarget,
},
payment::{Currency, PaymentDirection, PaymentView, ReceiveKind, WaitProgress, WaitTarget},
registry::{AuthScheme, Method, Operation, OperationId, operation},
secret::Secret,
terminal::{self, Terminal},
terminal::Terminal,
timeline::Timeline,
};
use futures_util::StreamExt;
use serde::{Deserialize, Deserializer, Serialize};
Expand All @@ -49,8 +48,6 @@ const READY_POLL_START: Duration = Duration::from_millis(100);
const COMPLETION_POLL_START: Duration = Duration::from_millis(250);
/// A checkout session projection usually appears within this long.
const SESSION_POLL_START: Duration = Duration::from_millis(100);
/// Waits repeat their status notice at least this often.
const STATUS_NOTICE_INTERVAL: Duration = Duration::from_secs(15);
/// The one read that reconciles an interrupted payment submission.
const RECONCILE_TIMEOUT: Duration = Duration::from_secs(5);
const JOURNAL_DIR: &str = "requests";
Expand Down Expand Up @@ -678,7 +675,7 @@ pub async fn organization_wallets(
let request = Request::build(invocation, &scope, None)?;
let credential = ApiCredential::OrganizationToken(token);
let mut api = Api::new(global, None, Authorization::Account(credential, None))?;
api.terminal = Terminal::new(true, global.no_input);
api.terminal = global.terminal().quieted();
Ok(api
.send(Method::Get, &request.path, &request.query, None)
.await?
Expand Down Expand Up @@ -1313,10 +1310,8 @@ impl Call<'_> {
scope.single_env()?
);
let target = until.as_str();
if invocation.operation.method != Method::Get {
api.terminal
.important(format!("Payment {id} accepted; waiting for {target}."));
}
let accepted = invocation.operation.method != Method::Get;
let mut timeline = Timeline::start(api.terminal, id, target, accepted);
let mut backoff = Backoff::new(
match until {
WaitTarget::Ready => READY_POLL_START,
Expand All @@ -1326,21 +1321,10 @@ impl Call<'_> {
);
let mut pause = backoff.pause();
let mut invoice_presented = false;
let mut last_status: Option<StatusText> = None;
let mut last_notice = Instant::now() - STATUS_NOTICE_INTERVAL;
let started = Instant::now();
loop {
let view = PaymentView::from_body(&response.body);
if let Some(status) = &view.status
&& (Some(status) != last_status.as_ref()
|| last_notice.elapsed() >= STATUS_NOTICE_INTERVAL)
{
api.terminal.notice(format!(
"Payment {id} status: {}; waiting for {target}.",
status.as_str()
));
last_status = Some(status.clone());
last_notice = Instant::now();
if let Some(status) = &view.status {
timeline.status(status);
}
if invocation.presents_invoice()
&& !invoice_presented
Expand All @@ -1355,6 +1339,7 @@ impl Call<'_> {
}
match view.progress(until) {
WaitProgress::Unsuccessful => {
timeline.unsuccessful(view.status.as_ref());
return Err(Error::api("Payment reached an unsuccessful terminal state")
.with_detail(ErrorDetail::Payment(response.body))
.redacted(&api.secrets()));
Expand All @@ -1369,6 +1354,7 @@ impl Call<'_> {
api.terminal,
)?;
}
timeline.reached(outcome);
return self.out.write(
Envelope::new(Some(response.status), response.body, Some(id), outcome),
&api.secrets(),
Expand All @@ -1383,11 +1369,9 @@ impl Call<'_> {
.with_detail(ErrorDetail::pending(id)));
}
api.terminal
.during(
format!(
"Waiting for the payment to be {target}... {}",
terminal::elapsed(started.elapsed())
),
.waiting(
format!("Waiting for the payment to be {target}..."),
timeline.started,
tokio::time::sleep(pause),
)
.await;
Expand Down
10 changes: 4 additions & 6 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::{
},
error::{CredentialRejection, CredentialSources, ErrorDetail, ResponseLimit},
secret::Secret,
terminal::{self, Terminal},
terminal::Terminal,
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::Value;
Expand Down Expand Up @@ -275,11 +275,9 @@ impl AuthService {
return Err(Error::timeout("Login expired; run voltage login again"));
}
self.terminal
.during(
format!(
"Waiting for device approval... {}",
terminal::elapsed(started.elapsed())
),
.waiting(
"Waiting for device approval...",
started,
tokio::time::sleep(Duration::from_secs(interval)),
)
.await;
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ impl GlobalFlags {
}

pub fn terminal(&self) -> Terminal {
Terminal::new(self.quiet, self.no_input)
Terminal::new(self.quiet, self.no_input).with_color(self.color())
}

pub fn scope_selection(&self) -> ScopeSelection {
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ mod output;
mod payment;
mod pick;
mod price;
mod progress;
pub mod registry;
pub mod secret;
mod startup;
mod style;
mod terminal;
mod timeline;
mod wallet_currency;
mod wallet_shortlist;

Expand Down
Loading
Loading