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:
Wizard— multi-step interactive form with escape-to-back navigation and shared-state threading. Backed byinquire. Closes the framework spec §8.1 promise thatinitprompts via "RTB'srtb-tui::Wizard."- Render helpers —
render_table/render_jsonovertabledandserde_json. Consumed by the v0.4rtb-cliops subtrees so every--output text|jsonsite gets uniform behaviour. Spinner— TTY-aware progress indicator backed byconsole. 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¶
Sis 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 — callingprompttwice with the same state should produce sensible behaviour. The default-fill pattern (using currentSto pre-populateinquiredefaults) 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_tableproduces the defaulttabled::Tablestyle (Style::psql-equivalent). No theming knobs at v0.1.render_jsonemits a JSON array, pretty-printed with two-space indent. Each row is a top-level array element. Trailing newline is included so callers canprint!directly.
4. Cross-cutting changes¶
rtb-tuiCargo.toml — addsinquire,tabled,console,tokio,async-trait,bon,thiserror,miette,serde,serde_json(workspace pins).rtbumbrella — thetuiCargo feature flips from "transitive via docs" to default-on. Tools that compile-outtuiexplicitly viadefault-features = false, features = ["cli", ...]are unaffected.rtb-cli— gains acrate::rendermodule that re-exportsrtb_tui::render_table/render_jsonplus theOutputModeenum that the v0.4 ops subtree consumes. Lands with the second v0.4 PR — not in this slice.- Examples —
examples/minimaldoes not exercisertb-tuidirectly at v0.1; the second v0.4 PR (ops subtree) brings it in via thecredentialsandinitflows.
5. Acceptance criteria (TDD)¶
- T1 —
Wizard::newbuilds with zero steps, andrunreturns the initialSunchanged. - T2 —
Wizard::runadvances through three scripted steps that each returnNext; finalSreflects all three steps' mutations in order. - T3 — A scripted step that returns
Backcauses the previous step to re-run; the redo step's body sees the latest mutatedS. - T4 — Esc on step 0 (i.e. an
Err(InquireError::OperationCanceled)from the first scripted step) returnsErr(WizardError::Cancelled). - T5 — Esc on step 2 (a non-first step) maps to back navigation; step 1 re-runs.
- T6 —
Err(InquireError::OperationInterrupted)from any step short-circuits toErr(WizardError::Interrupted)regardless of position. - T7 — Any other
InquireErrorfrom a step is wrapped inWizardError::Step { step, message }; thestepfield carries the step'sname(). - T8 —
render_tablewith a#[derive(Tabled)]struct produces the documented default style with one header row + N data rows. - T9 —
render_jsonemits a pretty-printed JSON array with one element per row; output isserde_json::from_str-roundtrippable. - T10 —
render_jsonreturnsRenderError::Json(_)when given a map with non-string keys. - T11 —
Spinner::newfollowed bySpinner::finishis a no-op when stderr isn't a TTY (the test forcesconsole::set_colors_enabled(false)and asserts no output is captured). - T12 —
WizardError::CancelledandRenderError::JsonareClone(compile-time check viaassert_clone::<…>()).
BDD scenarios:
- S1 — Given a 3-step wizard, When the user advances through all steps, Then the resulting state contains every captured value.
- S2 — Given 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.
- S3 — Given a
--output textinvocation of arender_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_runmode 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::Styleparameter onrender_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 —
Spinnerthread model. Resolved as manual-tick (see §2.4). A backgroundtokio::spawnwould tieSpinnerto a tokio runtime, which we'd rather not require for a small progress indicator. Callers tick the spinner manually viaset_messagebetween awaits.
7. Non-goals for v0.1¶
- Themed wizards / spinner styles.
- Progress bars beyond
Spinner(noindicatifintegration). - Full
ratatuiwidget library —rtb-docs::browseris the onlyratatuiconsumer and doesn't share widgets. dialoguerparity / 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.