[Go to site: main page, start]

Skip to content

rtb-tui

Three small building blocks every CLI tool needs and would otherwise have to roll itself: a multi-step interactive Wizard, uniform structured-output render helpers, and a TTY-aware Spinner.

Part of the phpboyscout Rust toolkit; extracted from — and battle-tested by — rust-tool-base.

[dependencies]
rtb-tui = "0.6"

Where to start

You want to Go to
Build something with it, from nothing Build a three-step wizard
Add one thing to a CLI you already have How-to
Know exactly what a function does or returns Reference
Know why it behaves that way, or what it will not do Explanation

The shortest useful answer to "should I use this?" is on What rtb-tui does not do. It is not a full-screen terminal UI library, it has no Cargo features, and it renders exactly two output formats.

Public API

use rtb_tui::{Wizard, WizardStep, StepOutcome, render_table, render_json, Spinner};
Item Purpose
Wizard<S> / WizardBuilder<S> Multi-step interactive form with escape-to-back navigation, backed by inquire.
WizardStep<S> Async trait a step implements; receives &mut S.
StepOutcome Next advances, Back re-runs the previous step.
WizardError Cancelled, Interrupted, or Step { step, message }.
render_table<R: Tabled>(rows) Infallible psql-style text table.
render_json<R: Serialize>(rows) Pretty-printed JSON array; RenderError::Json on a failing Serialize impl.
Spinner TTY-aware progress indicator; every method no-ops when stderr isn't a terminal.
InquireError Re-export so WizardStep impls can ?-propagate without a direct inquire dependency.

Everything is exported from the crate root; there are no public submodules. Signatures, defaults and failure modes are in Reference; the generated listing is on docs.rs/rtb-tui.

Wizard

Multi-step interactive form backed by inquire. Each step mutates a state value the wizard owns, and run returns that state once the last step advances past the end.

let profile = Wizard::<Profile>::builder()
    .initial(Profile::default())
    .step(AskName)
    .step(AskLanguage)
    .build()
    .run()
    .await?;

.initial(...) is required — build() panics without it. Full behaviour: Wizard reference.

Next advances and finishes after the last step. Back re-runs the previous step, and on the first step returns WizardError::Cancelled. Esc arrives as InquireError::OperationCanceled and is treated exactly like Back, so steps just ?-propagate. Ctrl+C short-circuits to WizardError::Interrupted from anywhere; any other InquireError becomes WizardError::Step { step, message }.

Back navigation moves exactly one step — there is no jump-to-step and no branching. The full table is in Wizard reference, and the reasoning is in Why escape means back.

State threading

Wizard<S> owns its state and hands each step a &mut S, so step N+1 sees what step N wrote. Going back does not roll anything back: the earlier step re-runs against the state as it stands now, which means a step must be safe to run more than once. Assign to fields rather than appending to collections, and pre-fill prompts from the current state so the user sees their previous answer.

Render helpers

print!("{}", render_table(&rows));               // psql-style text table
print!("{}", render_json(&rows).unwrap());       // pretty-printed JSON array

Both take a slice and return a string ending in a newline, so print! is the right macro. render_table is infallible and its style is fixed; render_json always produces a top-level array and returns RenderError::Json(_) when a row's Serialize impl fails.

Exact output for empty input, the failure cases, and the derive attributes that apply: Render helpers reference. Wiring them to a flag: Add a --output text|json flag.

Spinner

let mut spinner = Spinner::new("downloading…");
spinner.set_message("verifying signature…");
spinner.finish();

A one-line status indicator on stderr. When stderr isn't a terminal — CI logs, redirected output, MCP-stdio transports — every method is a no-op and nothing is written.

It does not animate. The glyph is static and redraws only when you call set_message, because there is no background task advancing frames. Spinner reference has the details; Why the spinner does not animate has the reasoning.

Design record

The reasoning behind each of these decisions is written out in Explanation, in prose, checked against the code:

Several rustdoc comments in the source still defer to a design spec that is not in this repository, and a few of them describe behaviour the code does not have. Those are catalogued in Known documentation defects.