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.
Public API¶
| 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. |
Full API reference: docs.rs/rtb-tui.
Wizard¶
Multi-step interactive form backed by
inquire.
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::Nextadvances; if it was the last step,runfinishes. - A step that returns
StepOutcome::Backre-runs the previous step. If the wizard is on step 0,runreturnsWizardError::Cancelled. - A step that returns
Err(InquireError::OperationCanceled)(Esc) is treated identically toStepOutcome::Back— the driver maps it for you, so steps just?-propagate. Err(InquireError::OperationInterrupted)(Ctrl+C) short-circuits toWizardError::Interruptedregardless of position.- Any other
InquireErroris wrapped inWizardError::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¶
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¶
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.