---
title: rtb-tui
description: Reusable TUI building blocks — Wizard, render helpers, TTY-aware Spinner.
---
# `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](https://rust.phpboyscout.uk);
extracted from — and battle-tested by —
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base).
## 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 so `WizardStep` impls can `?`-propagate without a direct `inquire` dependency. |
Full API reference: [docs.rs/rtb-tui](https://docs.rs/rtb-tui).
## `Wizard`
Multi-step interactive form backed by
[`inquire`](https://crates.io/crates/inquire).
```rust
use rtb_tui::{Wizard, WizardStep, StepOutcome, InquireError};
use async_trait::async_trait;
struct Greet;
#[async_trait]
impl WizardStep<Profile> for Greet {
fn name(&self) -> &'static str { "greet" }
async fn prompt(&self, state: &mut Profile) -> Result<StepOutcome, InquireError> {
state.greeting = Some(inquire::Text::new("Hello, what should I call you?").prompt()?);
Ok(StepOutcome::Next)
}
}
let profile = Wizard::<Profile>::builder()
.initial(Profile::default())
.step(Greet)
.build()
.run()
.await?;
```
### Navigation rules
- A step that returns `StepOutcome::Next` advances; if it was the last
step, `run` finishes.
- A step that returns `StepOutcome::Back` re-runs the previous step. If
the wizard is on step 0, `run` returns `WizardError::Cancelled`.
- A step that returns `Err(InquireError::OperationCanceled)` (Esc) is
treated identically to `StepOutcome::Back` — the driver maps it for
you, so steps just `?`-propagate.
- `Err(InquireError::OperationInterrupted)` (Ctrl+C) short-circuits to
`WizardError::Interrupted` regardless of position.
- Any other `InquireError` is wrapped in
`WizardError::Step { step, message }` with the step's name attached
for diagnosis.
### State threading
`Wizard<S>` owns its state. Each step receives `&mut S`, so step *N+1*
sees mutations made by step *N*. When the user backs into a previous
step, the step re-runs against the **current** state — implementations
should be idempotent (using current state to default-fill `inquire`
prompts is the canonical pattern).
## Render helpers
```rust
use rtb_tui::{render_table, render_json};
use serde::Serialize;
use tabled::Tabled;
#[derive(Tabled, Serialize)]
struct Row { name: &'static str, count: u32 }
let rows = vec![Row { name: "alpha", count: 1 }];
print!("{}", render_table(&rows)); // psql-style text table
print!("{}", render_json(&rows).unwrap()); // pretty-printed JSON array
```
Both helpers add a trailing newline so callers can `print!` directly
without their own `println!`.
`render_table` is infallible (`tabled` cannot fail over a
`Tabled`-deriving type). `render_json` returns `RenderError::Json(_)`
when a row's `Serialize` impl fails — always programmer mistake
(non-`Serialize`-clean shape), never user input.
## `Spinner`
```rust
use rtb_tui::Spinner;
let mut s = Spinner::new("downloading…");
// … work …
s.set_message("verifying signature…");
// … work …
s.finish(); // explicit; the Drop impl also calls finish()
```
When stderr isn't a TTY (CI logs, MCP-stdio transports), every method
on `Spinner` is a no-op — no escape sequences leak into captured
output. The spinner is single-threaded by design: there is no internal
`tokio::task::spawn` that animates frames. Tick the spinner manually
via `set_message` between awaits.
## Design record
The authoritative contract is the crate's v0.1 spec, retained in the
rust-tool-base
[spec series](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/development/specs/2026-05-06-rtb-tui-v0.1.md).