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.

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.

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?;
  • 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

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.