Skip to content

rtb-tui v0.1 — Wizard, render helpers, Spinner

Status: IMPLEMENTED — landed via PR #32 (slice 1 of v0.4) and shipped in 0.3.0. T1–T12 + S1–S3 BDD scenarios all green; tui Cargo feature on the rtb umbrella is default-on. Parent contract: rust-tool-base.md §8.1, §12; v0.4 scope addendum 2026-05-06-v0.4-scope.md. Replaces: the rtb-tui placeholder crate (pub use (); in lib.rs).


1. Goal

Three reusable building blocks every rtb-cli-shaped tool needs and currently has to roll itself:

  1. Wizard — multi-step interactive form with escape-to-back navigation and shared-state threading. Backed by inquire. Closes the framework spec §8.1 promise that init prompts via "RTB's rtb-tui::Wizard."
  2. Render helpersrender_table / render_json over tabled and serde_json. Consumed by the v0.4 rtb-cli ops subtrees so every --output text|json site gets uniform behaviour.
  3. Spinner — TTY-aware progress indicator backed by console. Suppresses itself when stderr isn't a TTY (CI logs, MCP transports).

The crate is small on purpose. Anything more ambitious (full ratatui widget library, themable wizards, progress bars beyond Spinner) is out of scope at v0.1 and revisited only if a real consumer surfaces.

2. Public API surface

2.1 Wizard and WizardStep

/// Async multi-step interactive form. Each step receives `&mut S`,
/// returns a [`StepOutcome`], and the wizard advances accordingly.
///
/// `S` is the wizard's mutable state — typically a struct that
/// accumulates user input across steps and is consumed at the end.
pub struct Wizard<S> { /* … */ }

#[bon::bon]
impl<S: Send + 'static> Wizard<S> {
    /// Construct a wizard from an initial state and a set of steps.
    /// The state is passed to each step in registration order.
    #[builder]
    pub fn new(
        initial: S,
        #[builder(field)] steps: Vec<Box<dyn WizardStep<S>>>,
    ) -> Self;

    /// Run the wizard interactively. Returns the final state on
    /// success.
    ///
    /// # Errors
    ///
    /// [`WizardError::Cancelled`] if the user escapes out of the
    /// first step (no earlier step to back into).
    /// [`WizardError::Interrupted`] on Ctrl+C (terminal SIGINT).
    /// [`WizardError::Step`] wraps any other [`InquireError`] a
    /// step bubbles up.
    pub async fn run(self) -> Result<S, WizardError>;
}

#[async_trait::async_trait]
pub trait WizardStep<S>: Send + Sync {
    /// Identify the step in error messages and the test harness.
    fn name(&self) -> &'static str;

    /// Run the step against the shared state. Implementations
    /// typically issue one or more `inquire::Text::new(...).prompt()`
    /// calls and write into `state` before returning.
    ///
    /// Return `StepOutcome::Back` to send the wizard back to the
    /// previous step. `inquire::InquireError::OperationCanceled`
    /// (Esc) is mapped to `Back` automatically by the wizard
    /// driver — implementations only need to propagate the error
    /// with `?`.
    async fn prompt(&self, state: &mut S) -> Result<StepOutcome, InquireError>;
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepOutcome {
    /// Advance to the next step (or finish if last).
    Next,
    /// Re-run the previous step. If on the first step, the
    /// wizard returns [`WizardError::Cancelled`].
    Back,
}

2.2 WizardError

#[non_exhaustive], Clone-derivable:

#[derive(Debug, thiserror::Error, miette::Diagnostic, Clone)]
#[non_exhaustive]
pub enum WizardError {
    /// User escaped out of the first step. Distinct from
    /// `Interrupted` — explicit cancel, not Ctrl+C.
    #[error("wizard cancelled")]
    #[diagnostic(code(rtb::tui::wizard_cancelled))]
    Cancelled,

    /// Ctrl+C (terminal SIGINT). Surfaced verbatim so callers can
    /// translate to a process exit code.
    #[error("wizard interrupted (Ctrl+C)")]
    #[diagnostic(code(rtb::tui::wizard_interrupted))]
    Interrupted,

    /// A step's `prompt` returned an `InquireError` that the
    /// wizard driver couldn't map to back-navigation.
    #[error("wizard step `{step}` failed: {message}")]
    #[diagnostic(code(rtb::tui::wizard_step))]
    Step { step: String, message: String },
}

2.3 Render helpers

/// Render `rows` as a `tabled` text table. Returns the rendered
/// string (callers `print!` it themselves so capture in tests is
/// straightforward).
#[must_use]
pub fn render_table<R: tabled::Tabled>(rows: &[R]) -> String;

/// Render `rows` as `serde_json`-formatted output. One JSON array
/// element per row. Pretty-printed to match `cargo --json`-style
/// human inspectability.
///
/// # Errors
///
/// [`RenderError::Json`] if any row fails to serialise — typical
/// causes: `Map` keys that aren't strings, non-finite floats.
pub fn render_json<R: serde::Serialize>(rows: &[R]) -> Result<String, RenderError>;

RenderError is a sibling enum to WizardError:

#[derive(Debug, thiserror::Error, miette::Diagnostic, Clone)]
#[non_exhaustive]
pub enum RenderError {
    #[error("JSON serialisation failed: {0}")]
    #[diagnostic(code(rtb::tui::render_json))]
    Json(String),
}

The String payload deliberately stringifies the underlying serde_json::Error rather than carrying the Arc<serde_json::Error> — the error always represents programmer mistake (non-Serialize-clean data structure), never user input, so retrying or pattern-matching the inner cause has no business value.

2.4 Spinner

/// TTY-aware spinner. Backed by `console::Term::stderr`. When
/// stderr isn't a tty, every method on this type is a no-op.
pub struct Spinner { /* … */ }

impl Spinner {
    /// Construct a spinner with the given message. Emits the
    /// initial frame to stderr immediately.
    #[must_use]
    pub fn new(msg: impl Into<String>) -> Self;

    /// Update the message and redraw.
    pub fn set_message(&mut self, msg: impl Into<String>);

    /// Stop spinning and clear the line. Usually called via the
    /// `Drop` impl, but available explicitly for test ergonomics.
    pub fn finish(self);
}

Drop for Spinner calls finish if it hasn't been called explicitly. The spinner is single-threaded only — no internal tokio::task::spawn for animation; callers tick the spinner manually via set_message between awaits. This keeps the API testable and avoids a hidden async-runtime requirement.

2.5 Crate-level structure

crates/rtb-tui/
├── Cargo.toml
├── src/
│   ├── lib.rs            # `pub use` re-exports
│   ├── error.rs          # WizardError, RenderError
│   ├── wizard.rs         # Wizard, WizardStep, StepOutcome
│   ├── render.rs         # render_table, render_json
│   └── spinner.rs        # Spinner
└── tests/
    ├── wizard_back_navigation.rs
    ├── wizard_state_threading.rs
    ├── wizard_cancellation.rs
    ├── render_table_dual.rs
    └── spinner_no_tty.rs

3. Behavioural contract

3.1 Escape → back navigation

The wizard driver wraps each step's prompt call. If the step returns Err(InquireError::OperationCanceled) (i.e. the user pressed Esc inside an inquire widget), the driver:

  • If the wizard is on step 0, returns Err(WizardError::Cancelled).
  • Otherwise, decrements the step index by 1 and re-runs the previous step.

Steps that want to interpret Esc as "fail this step" rather than "go back" can map the error themselves before returning — but the canonical pattern is to propagate with ? and let the driver handle navigation.

3.2 State threading

  • S is moved into the wizard at construction time and returned to the caller on success.
  • Each step receives &mut S. Mutations made by step N are visible to step N+1.
  • When the user backs into a previous step, the previous step re-runs against the current &mut S. Steps are responsible for being idempotent — calling prompt twice with the same state should produce sensible behaviour. The default-fill pattern (using current S to pre-populate inquire defaults) makes this trivial.

3.3 Test ergonomics

WizardStep is dyn-compatible (Box<dyn WizardStep<S>>). Tests can construct ad-hoc steps via:

struct ScriptedStep<S> {
    name: &'static str,
    body: Box<dyn Fn(&mut S) -> Result<StepOutcome, InquireError> + Send + Sync>,
}

…and an impl<S> WizardStep<S> that calls body. The default integration tests use this scripted-step pattern to drive the wizard end-to-end without a real TTY — no need to mock inquire itself.

3.4 TTY detection for Spinner

Spinner::new's constructor tests console::Term::stderr().is_term() once and caches the result. Subsequent calls to set_message short-circuit to no-ops when not a TTY. There is no environment-variable override (e.g. RTB_FORCE_TTY=1) at v0.1 — call sites that need a forced render can write to stderr directly.

3.5 Render output stability

  • render_table produces the default tabled::Table style (Style::psql-equivalent). No theming knobs at v0.1.
  • render_json emits a JSON array, pretty-printed with two-space indent. Each row is a top-level array element. Trailing newline is included so callers can print! directly.

4. Cross-cutting changes

  • rtb-tui Cargo.toml — adds inquire, tabled, console, tokio, async-trait, bon, thiserror, miette, serde, serde_json (workspace pins).
  • rtb umbrella — the tui Cargo feature flips from "transitive via docs" to default-on. Tools that compile-out tui explicitly via default-features = false, features = ["cli", ...] are unaffected.
  • rtb-cli — gains a crate::render module that re-exports rtb_tui::render_table / render_json plus the OutputMode enum that the v0.4 ops subtree consumes. Lands with the second v0.4 PR — not in this slice.
  • Examplesexamples/minimal does not exercise rtb-tui directly at v0.1; the second v0.4 PR (ops subtree) brings it in via the credentials and init flows.

5. Acceptance criteria (TDD)

  • T1Wizard::new builds with zero steps, and run returns the initial S unchanged.
  • T2Wizard::run advances through three scripted steps that each return Next; final S reflects all three steps' mutations in order.
  • T3 — A scripted step that returns Back causes the previous step to re-run; the redo step's body sees the latest mutated S.
  • T4 — Esc on step 0 (i.e. an Err(InquireError::OperationCanceled) from the first scripted step) returns Err(WizardError::Cancelled).
  • T5 — Esc on step 2 (a non-first step) maps to back navigation; step 1 re-runs.
  • T6Err(InquireError::OperationInterrupted) from any step short-circuits to Err(WizardError::Interrupted) regardless of position.
  • T7 — Any other InquireError from a step is wrapped in WizardError::Step { step, message }; the step field carries the step's name().
  • T8render_table with a #[derive(Tabled)] struct produces the documented default style with one header row + N data rows.
  • T9render_json emits a pretty-printed JSON array with one element per row; output is serde_json::from_str-roundtrippable.
  • T10render_json returns RenderError::Json(_) when given a map with non-string keys.
  • T11Spinner::new followed by Spinner::finish is a no-op when stderr isn't a TTY (the test forces console::set_colors_enabled(false) and asserts no output is captured).
  • T12WizardError::Cancelled and RenderError::Json are Clone (compile-time check via assert_clone::<…>()).

BDD scenarios:

  • S1Given a 3-step wizard, When the user advances through all steps, Then the resulting state contains every captured value.
  • S2Given a 3-step wizard, When the user advances past step 2 then escapes, Then the wizard re-runs step 2 with the current state and the user can change their answer.
  • S3Given a --output text invocation of a render_table-using command, When the same command is invoked with --output json, Then the JSON array deserialises to a vector with the same row count and field values.

6. Resolutions

All three open questions resolved 2026-05-06. Recorded here for the audit trail — the spec body above carries the live behaviour.

  • W1 — Wizard::dry_run mode for golden-file testing. Resolved as no. The scripted-step pattern in §3.3 lets tests drive the wizard end-to-end without a real TTY and without growing the public surface. Revisit if the v0.4 ops subtree surfaces a real demand for golden-file wizard testing.
  • W2 — tabled::Style parameter on render_table. Resolved as no at v0.1. Single canonical style (psql-equivalent) is one less knob to drift on. Reopen if the docs site or another consumer asks for theming.
  • W3 — Spinner thread model. Resolved as manual-tick (see §2.4). A background tokio::spawn would tie Spinner to a tokio runtime, which we'd rather not require for a small progress indicator. Callers tick the spinner manually via set_message between awaits.

7. Non-goals for v0.1

  • Themed wizards / spinner styles.
  • Progress bars beyond Spinner (no indicatif integration).
  • Full ratatui widget library — rtb-docs::browser is the only ratatui consumer and doesn't share widgets.
  • dialoguer parity / migration tools.
  • Anything mouse-driven.

8. Approval gate

This spec is APPROVED as of 2026-05-06. The slice is implemented when (a) T1–T12 + S1–S3 land green with ≥ 90% line coverage, (b) rtb-tui's lib.rs ships its real surface (no placeholder), © the tui Cargo feature on rtb flips to default-on without breaking any existing downstream tool's smoke tests, (d) §16 of the framework spec gains an "0.4 (slice 1) — rtb-tui v0.1" entry once the PR merges to develop, (e) the spec status above flips to IMPLEMENTED.