From d5bd5fae60d23397dcdaed3480936e4a5b008e27 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:38:33 -0500 Subject: [PATCH] feat(cli): draw context and auth status as cards on a terminal On a terminal, voltage context and voltage auth status draw their field lines inside a box titled in Voltage orange, with rounded corners (or +-| without a UTF-8 locale), bold field names, and dimmed sources. Widths are measured without styles. A card wider than the terminal, and any redirected output, keeps the plain field lines. --- CHANGELOG.md | 1 + docs/reference/output.md | 1 + src/output.rs | 17 ++- src/output/human.rs | 223 ++++++++++++++++++++++++++++++++++++++- src/startup.rs | 6 +- tests/cli_integration.rs | 15 +++ 6 files changed, 255 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3551ceb..1067cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/reference/output.md b/docs/reference/output.md index 674d1da..c133167 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -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. diff --git a/src/output.rs b/src/output.rs index 673e818..0aada43 100644 --- a/src/output.rs +++ b/src/output.rs @@ -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; @@ -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 @@ -152,6 +155,7 @@ impl Envelope { outcome, event: None, layout: Layout::Fields, + card: None, } } @@ -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) -> Self { Self { @@ -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(); diff --git a/src/output/human.rs b/src/output/human.rs index 382d712..1e10d04 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -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}; @@ -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 { @@ -62,6 +68,9 @@ impl View { width: None, now: unix_seconds(SystemTime::now()), color: false, + cards: false, + glyphs: Glyphs::ASCII, + depth: Depth::Basic, } } } @@ -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, + 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::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> { + let widths: Vec = 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(); @@ -548,7 +681,15 @@ fn fields(out: &mut impl Write, map: &Map, 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 { @@ -642,7 +783,7 @@ mod tests { View { width, now: NOW, - color: false, + ..View::plain() } } @@ -835,6 +976,78 @@ mod tests { ))); } + fn carded(width: Option, 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!({ diff --git a/src/startup.rs b/src/startup.rs index 87fce5c..50b0cb9 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -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), } => { @@ -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 } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index d9f646c..91a80ea 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -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;