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

Further reading

The blog carries a curated route through this subject: Rust, and what survived the port collects everything written about it, ordered so you can start at the beginning rather than newest-first.

Ask phpbotscout

phpbotscout

He answers questions about the projects over on the Discord, citing the docs where they already cover it, and offering to raise an issue where they don't. Bring a bug, an idea, or a questionable engineering decision.

Join the Discord