rtb-tui v0.1¶
Three small building blocks every RTB-built CLI tool needs and would otherwise have to roll itself.
Public API¶
| Item | Kind | Since |
|---|---|---|
Wizard<S> |
struct | 0.1.0 |
WizardBuilder<S> |
struct | 0.1.0 |
WizardStep<S> |
trait (async) | 0.1.0 |
StepOutcome |
enum (Next, Back) |
0.1.0 |
WizardError |
enum (Cancelled, Interrupted, Step) |
0.1.0 |
Spinner |
struct | 0.1.0 |
render_table<R: Tabled>(rows) -> String |
fn | 0.1.0 |
render_json<R: Serialize>(rows) -> Result<String, RenderError> |
fn | 0.1.0 |
RenderError |
enum (Json) |
0.1.0 |
InquireError |
re-export | 0.1.0 |
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)
}
}
# async fn main() -> Result<(), rtb_tui::WizardError> {
let profile = Wizard::<Profile>::builder()
.initial(Profile::default())
.step(Greet)
.build()
.run()
.await?;
# Ok(()) }
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.
Crate layout¶
crates/rtb-tui/
├── src/
│ ├── lib.rs # public re-exports
│ ├── error.rs # WizardError, RenderError
│ ├── wizard.rs # Wizard, WizardBuilder, WizardStep, StepOutcome
│ ├── render.rs # render_table, render_json
│ └── spinner.rs # Spinner
└── tests/
├── wizard_back_navigation.rs
├── wizard_cancellation.rs
├── wizard_state_threading.rs
├── render_table_dual.rs
└── spinner_no_tty.rs
Spec¶
Authoritative contract:
docs/development/specs/2026-05-06-rtb-tui-v0.1.md.