---
title: rtb-tui
description: Three building blocks for line-oriented CLI tools, a multi-step Wizard, text-or-JSON render helpers and a TTY-aware Spinner.
---
# `rtb-tui`
Three small building blocks for a line-oriented CLI tool: a multi-step
interactive `Wizard` where <kbd>Esc</kbd> goes back a step, a pair of
render helpers behind one `--output text|json` flag, and a `Spinner`
that writes nothing when stderr is not a terminal.
Part of the [phpboyscout Rust toolkit](https://rust.phpboyscout.uk),
extracted from [rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base),
which carried it as an internal module before it was published on its
own.
```toml
[dependencies]
rtb-tui = "0.6"
```
## Where to start
| Build something with it, from nothing | [Build a three-step wizard](tutorials/01-build-a-three-step-wizard.md) |
| Add one thing to a CLI you already have | [How-to](how-to/index.md) |
| Know exactly what a function does or returns | [Reference](reference/index.md) |
| Know why it behaves that way, or what it will not do | [Explanation](explanation/index.md) |
The shortest useful answer to "should I use this?" is on
[What rtb-tui does not do](explanation/04-what-rtb-tui-does-not-do.md).
It is not a full-screen terminal UI library, it has no Cargo features,
and it renders exactly two output formats.
## Public API
```rust
use rtb_tui::{Wizard, WizardStep, StepOutcome, render_table, render_json, Spinner};
```
| `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 of the error type in the `WizardStep::prompt` signature. Steps that issue real prompts still depend on `inquire` directly. |
Everything is exported from the crate root; there are no public
submodules. Signatures, defaults and failure modes are in
[Reference](reference/index.md); the generated listing is on
[docs.rs/rtb-tui](https://docs.rs/rtb-tui).
## `Wizard`
Multi-step interactive form backed by
[`inquire`](https://crates.io/crates/inquire). Each step mutates a
state value the wizard owns, and `run` returns that state once the last
step advances past the end.
```rust
let profile = Wizard::<Profile>::builder()
.initial(Profile::default())
.step(AskName)
.step(AskLanguage)
.build()
.run()
.await?;
```
`.initial(...)` is required, and `build()` panics without it. Full
behaviour: [Wizard reference](reference/01-wizard.md).
### Navigation rules
`Next` advances and finishes after the last step. `Back` re-runs the
previous step, and on the first step returns
`WizardError::Cancelled`. <kbd>Esc</kbd> arrives as
`InquireError::OperationCanceled` and is treated exactly like `Back`,
so steps just `?`-propagate. <kbd>Ctrl</kbd>+<kbd>C</kbd> 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](reference/01-wizard.md#navigation-rules), and the
reasoning is in
[Why escape means back](explanation/01-why-escape-means-back.md).
### 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
```rust
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](reference/02-render-helpers.md). Wiring them
to a flag:
[Add a `--output text|json` flag](how-to/01-add-a-text-or-json-output-flag.md).
## `Spinner`
```rust
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](reference/03-spinner.md) has the details;
[Why the spinner does not animate](explanation/02-why-the-spinner-does-not-animate.md)
has the reasoning.
## Design record
The reasoning behind each of these decisions is written out in
[Explanation](explanation/index.md), in prose, checked against the
code:
- [Why escape means back](explanation/01-why-escape-means-back.md)
- [Why the spinner does not animate](explanation/02-why-the-spinner-does-not-animate.md)
- [Why the output helpers are a pair](explanation/03-why-the-output-helpers-are-a-pair.md)
- [What rtb-tui does not do](explanation/04-what-rtb-tui-does-not-do.md)
The original design record is
[spec 0020](https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0020-rtb-tui-v0.1)
on the rust-tool-base wiki, and the rustdoc cites it by item number.
## Further reading
The blog carries a curated route through this subject: **[Rust, and what survived the port](https://phpboyscout.uk/topics/rust/)** collects
everything written about it, ordered so you can start at the beginning rather
than newest-first.
!!! tip "Ask phpbotscout"
{ width="84" align=left style="border-radius:10px;margin-right:1rem" }
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](https://discord.gg/mQzGbmGyzZ){ .md-button .md-button--primary }