Skip to content

feat(cli): animate progress and show payment waits as a timeline - #91

Merged
thebrandonlucas merged 1 commit into
feat/cli-look-01-colorfrom
feat/cli-look-02-spinner
Oct 23, 2026
Merged

thebrandonlucas merged 1 commit into
feat/cli-look-01-colorfrom
feat/cli-look-02-spinner

Conversation

@thebrandonlucas

@thebrandonlucas thebrandonlucas commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Stack position: 2 of 13 in the look-and-feel stack. Base: feat/cli-look-01-color (#86), whose style module and --color choice this PR uses.

Problem

Progress on a terminal was a still label, Waiting for API response.... During a long wait it looked frozen. A payment wait repeated Payment ID status: sending; waiting for completed. and gave no clear end.

Behavior

On an interactive stderr:

› Payment 4444…-8444-444444444444 accepted; waiting for completed
› receiving · 1s
⠹ Waiting for the payment to be completed... 3s
✓ Payment 4444…-8444-444444444444 completed · 4s

(IDs are shortened here for the table only; the CLI prints them in full.)

Element Rule
Spinner Redrawn every 100 ms in yellow. Shows the time waited after 2 s, or from the start of a timed wait (payment, device approval). Cut to the terminal width so clearing never leaves a wrapped remnant. Cleared on completion, error, or Ctrl-C.
Still line VOLTAGE_SPINNER=0 (screen readers read every redraw) or TERM=dumb: the label is drawn once, as before.
› line One per status change, never repeated. The status word is colored by meaning: in-flight yellow, completed green, failed and expired red. A status the API added later stays plain.
Closing line ✓ Payment ID completed · 4s (green), or ✗ Payment ID failed · 4s (red) followed by the error. A final status gets no › line of its own.
Symbols Unicode when the applicable locale (LC_ALL, LC_CTYPE, LANG) is UTF-8 and TERM is not dumb or linux; on Windows, in Windows Terminal. Otherwise ASCII: ok, x, >, - \ | /.

Redirected stderr is unchanged: there is no spinner, and the same Payment ID status: … sentences repeat every 15 s.

Design notes

  • progress::Progress is an in-house spinner of about 120 lines, so no indicatif, as the plan decided. A tokio task redraws the line. A mutex-guarded cleared flag means that a frame already running when the guard drops cannot draw after the clear.
  • style::Line is the Clean idea from the plan. It can be built only from painted &'static str labels and scrub_line text, so a status line's only escape sequences come from the palette.
  • timeline::Timeline takes the status reporting out of wait_for_payment, which drops its CRAP score from 19.60 to 17.05.
  • Terminal now carries --color (with_color). The two internal quiet terminals use global.terminal().quieted(), so they follow --color too.

Validation

  • cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (143 unit and 124 integration tests), and python3 scripts/check-coverage.py all pass.
  • Unit tests cover frame cycling and wrapping, ASCII frames, the colored glyph, when time is shown, width fitting, guard drop (the redraw task is aborted and the line cleared once), the VOLTAGE_SPINNER and TERM=dumb rules, glyph detection, Line scrubbing, and status roles.
  • New PTY tests check the submitted-payment timeline in order (accepted → one › receiving line → ✓ completed), and a failed payment's ✗ line before the error with --color never and no › failed line. The existing wait test now also checks the spinner frame and the green ✓.
  • The PTY harness pins TERM=xterm-256color and LANG=C.UTF-8 and removes LC_ALL, LC_CTYPE, and VOLTAGE_SPINNER, so the runner's locale cannot change which symbols the tests see.
  • CRAP: 0 of 660 measured functions at or above 20. The highest is 18.00 (src/cli.rs endpoint, untouched); wait_for_payment is 17.05.

Docs

docs/reference/output.md (spinner and timeline), environment.md (VOLTAGE_SPINNER), and CHANGELOG.md.

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on 7b13df4 (base #86 1db2da6). The animation is well contained:

  • The select! drops execute before the Ctrl-C handler runs. Drop aborts the task and takes the cleared lock, so no frame follows the clear.
  • No guard spans a stdout write or a prompt.
  • Redirected stderr keeps the same sentences as before, byte for byte.
  • NO_COLOR, --color never and TERM=dumb remove color from timeline lines.
  • Status text and IDs are scrubbed.

One fix is needed.

P2: --quiet no longer hides the failure line of a payment wait. Timeline::unsuccessful (src/timeline.rs:100) writes with important_line, which ignores --quiet, while reached uses notice_line. Before this PR, a failed wait returned the error with nothing before it. Now payments get ID --wait completed --quiet --json, run with stderr on a terminal (an agent under tmux or expect), writes ✗ Payment ID failed · 0s before the JSON error. That contradicts --quiet, which "hides optional progress and notices, not errors, results, or recovery IDs". The error that follows already names the payment and carries its state. Use notice_line, and add a --quiet case to the timeline tests.

Nonblocking:

  • StatusLine::draw (src/progress.rs:104) calls terminal::stderr_width() on every 100 ms frame while it holds the cleared mutex. When /dev/tty cannot be opened, crossterm falls back to running tput, which is two child processes per frame, and clear waits behind it. Reading the width once in show() avoids both.
  • Frames are written as raw \x1b[... to std::io::stderr(), not through anstream. On a classic Windows console without VT processing, the colored, repeated frames could print literal escape codes. Please check on conhost, or route frames through AutoStream.
  • VOLTAGE_SPINNER matches only 0 (src/terminal.rs:375), so false or off silently keeps the animation. VOLTAGE_EXECUTE accepts 0 and false, and rejects anything else.
  • The progress label is passed through unscrubbed. Every caller passes static text today, but taking &'static str, or scrubbing in status(), would keep the claim that escapes come only from the palette true by construction.
  • TERM=dumb now gets \r\x1b[2K before the still label as well as on clear. output.md does not say that TERM=dumb or TERM=linux selects ASCII symbols.
  • The unit test at src/progress.rs:232 writes escape codes to the real stderr, which bypasses libtest's capture.
  • docs/quality.md still says progress lives in terminal, though it now has its own progress and timeline modules.

Validation on this head, in nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (143 unit, 124 integration) and python3 scripts/check-coverage.py passed. CRAP: 660 measured functions, none at or above 20, highest endpoint at 18.00; 179 functions were unmeasured. CI is green. Like #86, this stack needs a rebase onto current master.

Comment thread src/timeline.rs Outdated
Comment thread src/progress.rs Outdated
On an interactive stderr, progress is a spinner redrawn every 100 ms with
the time waited after two seconds, cut to the terminal's width and cleared
when the wait ends or is cancelled. VOLTAGE_SPINNER=0 or TERM=dumb keeps it
still. A payment wait prints one line per status change and closes with a
check mark or a cross; redirected stderr keeps its plain sentences. Symbols
fall back to ASCII outside a UTF-8 locale.
tee8z
tee8z previously approved these changes Oct 23, 2026

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved ecf6761, a rebase onto #86 6c8a7f6 with fixes. My finding is fixed: Timeline::unsuccessful now writes through notice_line, so --quiet hides the failure line and keeps the error, and a new terminal test covers it. StatusLine also reads the terminal width once, when it is first drawn, as I suggested. The other notes from my last review are nonblocking.

Validation on this head, in nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (155 unit, 127 integration) and python3 scripts/check-coverage.py passed. CRAP: 687 measured functions, none at or above 20, highest endpoint at 18.00; 182 functions were unmeasured. CI is green.

@tee8z

tee8z commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Correction to my approval of ecf6761: the highest CRAP score on this head is wait_for_payment at 17.05, not endpoint at 18.00, which came from the previous head. No function is at or above 20.

@thebrandonlucas

Copy link
Copy Markdown
Contributor Author

Review-body notes, in 2f36b1c:

  • Width per frame: read once in show() (also replied on the thread).
  • Classic Windows console: a status line is shown only when crossterm::ansi_support::supports_ansi() says the console handles ANSI. crossterm turns VT processing on where it can, so legacy conhost gets no control codes at all. I can't run this on Windows here (a local x86_64-pc-windows-msvc check fails on ring's C build), so CI's Windows job is the check.
  • VOLTAGE_SPINNER: 0 and false (any case) keep the line still, matching VOLTAGE_EXECUTE's off values; the env reference says so.
  • Unscrubbed label: Terminal::status scrubs the label, so escapes come only from the line itself.
  • TERM=dumb escapes: unchanged. \r\x1b[2K around the still label behaves as it did before this stack. output.md now says TERM=dumb and TERM=linux select ASCII symbols.
  • Test writing to real stderr: the status line takes an injectable writer, and the unit test passes a no-op.
  • docs/quality.md: now names progress, timeline, and style.

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved 2f36b1c, a rebase onto #86 97e4ea1. My earlier findings stay fixed: --quiet hides the failure line, and the width is read once. This head also takes up my nonblocking notes:

  • The quality guide now names the progress, timeline and style modules.
  • VOLTAGE_SPINNER accepts false.
  • Progress labels are scrubbed.
  • Unit tests no longer write escape codes to stderr.
  • A Windows console without ANSI support gets no spinner.

Validation on this head, in nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked and python3 scripts/check-coverage.py passed. CRAP: 690 measured functions, none at or above 20, highest wait_for_payment at 17.05. CI is green.

@thebrandonlucas
thebrandonlucas merged commit b07f275 into master Oct 23, 2026
8 of 14 checks passed
@thebrandonlucas
thebrandonlucas deleted the feat/cli-look-02-spinner branch October 23, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants