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 @@ -18,6 +18,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- 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.
- A successful `voltage login` on a terminal shows the `voltage` wordmark, a lightning bolt beside the name in Voltage orange, above `✓ Logged in as`.
- `voltage context` and `voltage auth status` draw their fields in a titled card on a terminal, with sources dimmed. Redirected output is unchanged.

## 0.2.0 - 2026-10-22

Expand Down
1 change: 1 addition & 0 deletions docs/reference/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ On a terminal, results are readable tables; when piped, they are JSON. `--json`
- With color on, table headers and field names are bold, a known status is colored by meaning (`completed` green, `failed` red, `sending` yellow), and so is the outcome line (`accepted`, `dry_run`). Color is added after layout, so it never moves a column; an unknown status stays plain.
- A table starts with the outcome only when it adds something, such as `accepted` or `dry_run`, or when there is no data to show.
- A single resource is shown as aligned `field value` lines, with nested fields joined by dots. `voltage context` shows each setting as `name value (source)`.
- On a terminal, `voltage context` and `voltage auth status` draw those lines inside a titled card (rounded corners, or `+-|` without a UTF-8 locale). A card wider than the terminal falls back to the plain lines, and redirected output always gets them.

The table layout may change between releases; scripts should use JSON.

Expand Down
17 changes: 16 additions & 1 deletion src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
Error, Result,
config::{EXECUTE_VARIABLE, new_private},
error::{ErrorDetail, ErrorKind},
style::{self, ColorChoice, Glyphs, Line, Role},
style::{self, ColorChoice, Depth, Glyphs, Line, Role},
terminal::{self, scrub_line},
};
use clap::ValueEnum;
Expand Down Expand Up @@ -125,6 +125,9 @@ pub struct Envelope {
/// How a person reads the data; never part of the JSON.
#[serde(skip)]
pub layout: Layout,
/// On a terminal, a single result is drawn as a card with this title.
#[serde(skip)]
pub card: Option<&'static str>,
}

/// How table output lays out a single result. Decided by the command that built the data, not
Expand Down Expand Up @@ -152,6 +155,7 @@ impl Envelope {
outcome,
event: None,
layout: Layout::Fields,
card: None,
}
}

Expand All @@ -173,6 +177,14 @@ impl Envelope {
})
}

/// Draw the result as a card with `title` on a terminal.
pub fn titled(self, title: &'static str) -> Self {
Self {
card: Some(title),
..self
}
}

/// One checkout stream event.
pub fn event(data: Value, event: String, id: Option<String>) -> Self {
Self {
Expand Down Expand Up @@ -255,6 +267,9 @@ impl Output {
let view = human::View {
width: terminal::width(),
color: true,
cards: std::io::stdout().is_terminal(),
glyphs: Glyphs::detect(|name| std::env::var(name).ok()),
depth: Depth::detect(|name| std::env::var(name).ok()),
..human::View::plain()
};
let mut buf = Vec::new();
Expand Down
223 changes: 218 additions & 5 deletions src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
use super::{Envelope, Layout, Outcome};
use crate::{
Result,
style::{Line as Styled, Role},
style::{Depth, Glyphs, Line as Styled, Role},
terminal::scrub_line,
};
use comfy_table::{ColumnConstraint, ContentArrangement, Row, Table, TableStyle, Width};
Expand Down Expand Up @@ -53,6 +53,12 @@ pub struct View {
/// Seconds since the Unix epoch.
pub now: i64,
pub color: bool,
/// Draw a titled result as a card; only for a terminal.
pub cards: bool,
/// Unicode or ASCII box lines for a card.
pub glyphs: Glyphs,
/// The depth of the brand color for a card's title.
pub depth: Depth,
}

impl View {
Expand All @@ -62,6 +68,9 @@ impl View {
width: None,
now: unix_seconds(SystemTime::now()),
color: false,
cards: false,
glyphs: Glyphs::ASCII,
depth: Depth::Basic,
}
}
}
Expand Down Expand Up @@ -95,15 +104,139 @@ pub fn render(out: &mut impl Write, envelope: &Envelope, view: View) -> Result<(
summary(out, data, items.len())?;
return Ok(());
}
let local = envelope.layout == Layout::Settings;
match data {
Value::Null => {}
Value::Object(map) => fields(out, map, local, view)?,
Value::Object(map) => object(out, envelope, map, view)?,
other => writeln!(out, "{}", cell(other, view, Place::Field))?,
}
Ok(())
}

/// A single result's fields, inside a card when the result has a title and the card fits.
fn object(
out: &mut impl Write,
envelope: &Envelope,
map: &Map<String, Value>,
view: View,
) -> Result<()> {
let local = envelope.layout == Layout::Settings;
let Some(title) = envelope.card.filter(|_| view.cards) else {
return fields(out, map, local, view);
};
let mut lines = Vec::new();
fields(&mut lines, map, local, view)?;
let lines: Vec<String> = String::from_utf8_lossy(&lines)
.lines()
.map(str::to_owned)
.collect();
match card(title, &lines, view) {
Some(card) => {
for line in card {
writeln!(out, "{line}")?;
}
Ok(())
}
None => fields(out, map, local, view),
}
}

/// Box lines for a card: rounded corners where the terminal draws Unicode.
struct Frame {
top_left: char,
top_right: char,
bottom_left: char,
bottom_right: char,
across: char,
down: char,
}

impl Frame {
fn for_glyphs(glyphs: Glyphs) -> Self {
if glyphs == Glyphs::UNICODE {
Self {
top_left: '╭',
top_right: '╮',
bottom_left: '╰',
bottom_right: '╯',
across: '─',
down: '│',
}
} else {
Self {
top_left: '+',
top_right: '+',
bottom_left: '+',
bottom_right: '+',
across: '-',
down: '|',
}
}
}
}

/// `lines` inside a box titled `title`, or `None` when the box would not fit the terminal.
/// Widths are measured on the text without styles, so styled lines line up.
fn card(title: &'static str, lines: &[String], view: View) -> Option<Vec<String>> {
let widths: Vec<usize> = lines
.iter()
.map(|line| display_width(&anstream::adapter::strip_str(line).to_string()))
.collect();
// The title sits in the top line as `╭─ title ─╮`, so the inside fits it with a dash each side.
let inner = widths
.iter()
.copied()
.max()
.unwrap_or(0)
.max(title.len() + 2);
if view.width.is_some_and(|width| inner + 4 > width) {
return None;
}
let frame = Frame::for_glyphs(view.glyphs);
let border = |text: String| {
if view.color {
Styled::default()
.styled(Role::Muted, &text)
.as_str()
.to_owned()
} else {
text
}
};
let title_text = if view.color {
Styled::default()
.brand(view.depth, title)
.as_str()
.to_owned()
} else {
title.to_owned()
};
let across = |count: usize| frame.across.to_string().repeat(count);
let mut card = vec![format!(
"{}{title_text}{}",
border(format!("{}{} ", frame.top_left, frame.across)),
border(format!(
" {}{}",
across(inner - title.len() - 1),
frame.top_right
))
)];
for (line, width) in lines.iter().zip(widths) {
card.push(format!(
"{} {line}{} {}",
border(frame.down.to_string()),
" ".repeat(inner - width),
border(frame.down.to_string())
));
}
card.push(border(format!(
"{}{}{}",
frame.bottom_left,
across(inner + 2),
frame.bottom_right
)));
Some(card)
}

/// An error's detail as aligned `field value` lines, or one line when it is not an object.
pub fn detail(out: &mut impl Write, detail: &Value) -> Result<()> {
let view = View::plain();
Expand Down Expand Up @@ -548,7 +681,15 @@ fn fields(out: &mut impl Write, map: &Map<String, Value>, local: bool, view: Vie
} in lines
{
let source = source
.map(|source| format!("{GAP}({source})"))
.map(|source| {
let source = format!("({source})");
if view.color {
let dim = Styled::default().styled(Role::Muted, &source);
format!("{GAP}{}", dim.as_str())
} else {
format!("{GAP}{source}")
}
})
.unwrap_or_default();
for (index, value) in values.iter().enumerate() {
let (key, source) = if index == 0 {
Expand Down Expand Up @@ -642,7 +783,7 @@ mod tests {
View {
width,
now: NOW,
color: false,
..View::plain()
}
}

Expand Down Expand Up @@ -835,6 +976,78 @@ mod tests {
)));
}

fn carded(width: Option<usize>, color: bool, glyphs: Glyphs) -> String {
let envelope = Envelope::new(
None,
json!({"account": "me", "kind": "user"}),
None,
Outcome::Succeeded,
)
.titled("voltage auth status");
let view = View {
cards: true,
color,
glyphs,
..view(width)
};
let mut out = Vec::new();
render(&mut out, &envelope, view).unwrap();
String::from_utf8(out).unwrap()
}

#[test]
fn a_titled_result_is_a_card_that_fits_its_title_and_widest_line() {
// The title is wider than every field, so it sets the width.
assert_eq!(
carded(None, false, Glyphs::UNICODE),
"╭─ voltage auth status ─╮\n\
│ account me │\n\
│ kind user │\n\
╰───────────────────────╯\n"
);
assert_eq!(
carded(Some(25), false, Glyphs::ASCII),
"+- voltage auth status -+\n\
| account me |\n\
| kind user |\n\
+-----------------------+\n"
);
// Styles do not move the borders.
let styled = carded(None, true, Glyphs::UNICODE);
assert!(styled.contains("\u{1b}[2m"), "{styled:?}");
assert_eq!(strip(&styled), carded(None, false, Glyphs::UNICODE));
}

#[test]
fn a_card_too_wide_for_the_terminal_or_off_a_terminal_is_plain_fields() {
let plain = "account me\nkind user\n";
assert_eq!(carded(Some(24), false, Glyphs::UNICODE), plain);
let envelope = Envelope::new(
None,
json!({"account": "me", "kind": "user"}),
None,
Outcome::Succeeded,
)
.titled("voltage auth status");
let mut out = Vec::new();
render(&mut out, &envelope, view(None)).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), plain);
}

#[test]
fn color_dims_a_settings_source() {
let envelope =
Envelope::settings(json!({"account": {"value": "me", "source": "flag"}})).unwrap();
let text = styled(&envelope, None);
assert!(
text.contains(&format!(
"me {}",
Styled::default().styled(Role::Muted, "(flag)").as_str()
)),
"{text:?}"
);
}

#[test]
fn narrow_tables_hide_trailing_columns_then_truncate_text_but_never_ids() {
let item = json!({
Expand Down
6 changes: 4 additions & 2 deletions src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ impl<'a> Session<'a> {
),
LocalCommand::Auth {
command: AuthCommand::Status,
} => Envelope::local(auth::status(&self.settings, scope, global)?),
} => Envelope::local(auth::status(&self.settings, scope, global)?)
.map(|status| status.titled("voltage auth status")),
LocalCommand::Auth {
command: AuthCommand::ImportKey(flags),
} => {
Expand All @@ -229,7 +230,8 @@ impl<'a> Session<'a> {
.discover(scope, Discovery::Environments)
.await?,
),
LocalCommand::Context => Envelope::settings(context::inspect(&self.settings, global)?),
LocalCommand::Context => Envelope::settings(context::inspect(&self.settings, global)?)
.map(|context| context.titled("voltage context")),
LocalCommand::Profiles { command } => {
profiles(command, &mut self.settings, scope, global.terminal()).await
}
Expand Down
15 changes: 15 additions & 0 deletions tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3984,6 +3984,21 @@ async fn api_rejections_lead_with_the_reason_and_keep_the_response_for_json() {
assert_eq!(report["error"]["detail"]["data"], rejection);
}

#[test]
fn a_redirected_context_table_is_plain_fields_without_a_card() {
let dir = private_tempdir();
let result = Command::from_std(human_process_at(dir.path(), "http://127.0.0.1:9"))
.args(["context", "--output", "table", "--color", "always"])
.assert()
.success();
let shown = stdout(&result);
assert!(strip_styles(&shown).starts_with("account "), "{shown}");
assert!(
!shown.contains("voltage context") && !shown.contains('╭'),
"{shown}"
);
}

#[tokio::test(flavor = "multi_thread")]
async fn tables_follow_the_color_choice_on_stdout_and_strip_to_the_plain_table() {
let (server, dir) = fixture().await;
Expand Down
Loading