Interactive Flows
This page is for apps that drive an interactive shell themselves — wizards, setup helpers, REPLs, anything that asks one question, reacts, asks the next. standout does not own the driver loop; you do. What it does provide is the two ingredients each step needs:
- Dynamic, themed text for the step body — same
Renderer+Themeyou use for normal command output. - Prompts that work without a
&clap::ArgMatches— every interactive source instandout::inputexposes a.prompt()shortcut.
Composing those with a ~30-line step graph you own gives you the full pattern.
The Step Graph You Own
Standout is deliberately not opinionated about flow control. A small, hand-rolled state machine is the right tool — you get loops, jumps, early exit, branching on side-effect output, all in idiomatic Rust:
#![allow(unused)] fn main() { use std::collections::HashMap; enum Next { Go(&'static str), // jump to a step (also used to re-ask) Done, Quit, } struct Step { render: fn(&Ctx, &Renderer) -> String, prompt: fn(&Ctx) -> Result<Answer, FlowError>, branch: fn(Answer, &mut Ctx) -> Next, } struct Ctx { /* whatever your wizard accumulates */ } enum Answer { Text(String), Bool(bool), Choice(usize) } fn run(steps: &HashMap<&str, Step>, mut ctx: Ctx, r: &Renderer) -> Result<(), FlowError> { let mut cur = "intro"; loop { let step = &steps[cur]; println!("{}", (step.render)(&ctx, r)); let answer = (step.prompt)(&ctx)?; match (step.branch)(answer, &mut ctx) { Next::Go(next) => cur = next, Next::Done => return Ok(()), Next::Quit => return Err(FlowError::Cancelled), } } } }
That's the whole driver. From here on we focus on what each step looks like.
A Step in Detail
Render
Every step's body is a registered template, rendered against Ctx. Templates can use the full styling system: colors, adaptive themes, tags, {% if %} / {% for %}. The same machinery your CLI commands already use.
#![allow(unused)] fn main() { // One-time setup, before the loop let theme = Theme::default() .add("title", Style::new().bold().cyan()) .add("path", Style::new().green()); let mut renderer = Renderer::new(theme)?; renderer.add_template("pick_pack", PICK_PACK_TPL)?; // Inside the step's render fn fn render_pick_pack(ctx: &Ctx, r: &Renderer) -> String { r.render("pick_pack", ctx).expect("template") } }
The body of pick_pack template is just a normal standout template:
[title]Choose a pack[/title]
Found [count]{{ packs | length }}[/count] packs in [path]{{ root }}[/path]:
{% for p in packs %}
- {{ p.name }}{% if p.recommended %} [hint](recommended)[/hint]{% endif %}
{% endfor %}
Use embed_templates! for static templates so the wizard ships with no runtime file dependencies.
Prompt
Every interactive source exposes .prompt(). No &ArgMatches, no chain — just call it:
#![allow(unused)] fn main() { use standout::input::{InquireSelect, InquireText, InquireConfirm}; // Free-form text let pack = InquireText::new("Pack name:") .help("a-z0-9-") .prompt()?; // Result<String, InputError> // Pick from options let env = InquireSelect::new("Environment:", vec!["dev", "staging", "prod"]) .prompt()?; // Result<&'static str, _> // Yes/no let proceed = InquireConfirm::new("Continue?") .default(true) .prompt()?; // Result<bool, _> }
Behavior:
- Stdin not a TTY or empty submission →
InputError::NoInput - Otherwise → the typed value
- User cancellation is backend-specific:
Inquire*prompts: Esc / Ctrl+C →InputError::PromptCancelledTextPromptSource/ConfirmPromptSource: EOF (Ctrl+D) →InputError::PromptCancelled; Ctrl+C terminates the process the same way it does for any line-buffered readEditorSource(withrequire_save): closing the editor without saving →InputError::EditorCancelled
A re-ask on bad input is a single match:
#![allow(unused)] fn main() { fn prompt_pack_name(_ctx: &Ctx) -> Result<Answer, FlowError> { loop { let pack = InquireText::new("Pack name:").prompt()?; if valid_pack_name(&pack) { return Ok(Answer::Text(pack)); } // Could render an error template here for context eprintln!("Pack names must be lowercase a-z, 0-9, '-'."); } } }
Same idea for EditorSource if a step opens an editor:
#![allow(unused)] fn main() { let body = EditorSource::new() .extension(".md") .initial_content("# Pack notes\n\n") .prompt()?; }
Branch
Pure user code. The branch decides the next step from the answer plus any side-effects you ran:
#![allow(unused)] fn main() { fn branch_pick_pack(answer: Answer, ctx: &mut Ctx) -> Next { let Answer::Text(pack) = answer else { return Next::Quit }; ctx.pack = Some(pack.clone()); match read_status(&ctx.root, &pack) { Ok(s) if s.dirty => Next::Go("confirm_dirty"), Ok(_) => Next::Go("apply"), Err(_) => Next::Go("setup_help"), } } }
Restart Later
"Run the wizard again next week" is just run(&steps, Ctx::fresh(), &renderer). If you want to resume mid-flow with previously collected state, make Ctx Serialize/Deserialize, persist on each branch, and pass cur and Ctx into run. Standout doesn't standardize a checkpoint format — but every piece of Ctx is your data, so serde is fine.
Section Framing (cliclack-style)
cliclack ships nice intro/outro/note/log helpers for visual pacing. Standout doesn't ship equivalents, but the pattern is two lines of template:
{# templates/note.jinja #}
[note_marker]●[/note_marker] [note_title]{{ title }}[/note_title]
{{ body }}
#![allow(unused)] fn main() { fn note(r: &Renderer, title: &str, body: &str) { let v = serde_json::json!({ "title": title, "body": body }); println!("{}", r.render("note", &v).unwrap()); } }
Style note_marker and note_title in your theme — adaptive light/dark falls out for free.
Putting It Together
use std::collections::HashMap; use standout::{Renderer, Theme}; use standout::input::{InquireConfirm, InquireSelect, InquireText}; fn main() -> anyhow::Result<()> { let mut renderer = Renderer::new(theme())?; register_templates(&mut renderer)?; let steps: HashMap<&str, Step> = HashMap::from([ ("intro", Step { render: render_intro, prompt: noop_prompt, branch: |_, _| Next::Go("pick_pack") }), ("pick_pack", Step { render: render_pick_pack, prompt: prompt_pack, branch: branch_pick_pack }), ("confirm_dirty",Step { render: render_dirty, prompt: prompt_confirm, branch: branch_dirty }), ("apply", Step { render: render_apply, prompt: noop_prompt, branch: |_, _| Next::Done }), ("setup_help", Step { render: render_help, prompt: noop_prompt, branch: |_, _| Next::Done }), ]); let ctx = Ctx::fresh(); run(&steps, ctx, &renderer)?; Ok(()) }
You wrote ~50 lines of glue and got: themed dynamic text per step, polished TUI prompts, branching, looping, re-ask, restart. That's the deal: standout owns the I/O quality, you own the flow shape.
Testing Wizards
A wizard built on .prompt() is fully testable in process — no real TTY, no expectrl subprocess. Every interactive source consults a PromptResponder before it touches stdin; in tests you install a ScriptedResponder and the production wizard code is unchanged.
#![allow(unused)] fn main() { use serial_test::serial; use standout_input::{PromptResponse, ScriptedResponder}; use standout_test::TestHarness; use std::sync::Arc; #[test] #[serial] fn setup_wizard_creates_pack_and_picks_environment() { let result = TestHarness::new() .prompts(Arc::new(ScriptedResponder::new([ PromptResponse::text("foo"), // pack name PromptResponse::Bool(true), // confirm dirty PromptResponse::Choice(2), // env: dev=0, staging=1, prod=2 -> "prod" ]))) .run(&app(), command(), ["mycli", "setup"]); result.assert_success(); result.assert_stdout_contains("Created pack `foo` in prod"); } }
Two design choices to keep tests honest:
- Open prompts (
InquireText,InquirePassword,InquireEditor,TextPromptSource,EditorSource) takePromptResponse::Text("...")— the answer is the value. - Finite-choice prompts take a position, not a label.
Choice(2)picksoptions[2]from whatever the wizard passed toInquireSelect::new. Renaming"Production"to"Live"in the option list doesn't break a test that picked index 2 — the wizard logic is unchanged, only copy moved. Same forConfirm: assert on the bool, not on"y"/"yes".
ScriptedResponder validates each response against the prompt kind the source actually asked for. A wizard reorder bug — e.g., a Confirm step swapped to land where a Text was expected — fails the test loudly with the position, the prompt kind, and the queued response, rather than producing a silently wrong assertion three steps later.
Two kind-agnostic responses cover the cancel and skip branches:
#![allow(unused)] fn main() { PromptResponse::Cancel // -> Err(InputError::PromptCancelled) inside the wizard PromptResponse::Skip // -> Err(InputError::NoInput) — same path as "no TTY" }
Use them to test the wizard's abort and re-ask logic without involving real signal handling.
For lower-level tests that don't need the harness, install the responder directly:
#![allow(unused)] fn main() { use std::sync::Arc; use standout_input::{ set_default_prompt_responder, reset_default_prompt_responder, ScriptedResponder, PromptResponse, }; #[test] #[serial(prompt_responder)] fn pack_name_validation_re_asks_on_invalid() { set_default_prompt_responder(Arc::new(ScriptedResponder::new([ PromptResponse::text("BadName!"), // first try, rejected by validator PromptResponse::text("good-name"), // re-ask, accepted ]))); assert_eq!(prompt_pack_name(&Ctx::fresh()).unwrap(), Answer::Text("good-name".into())); reset_default_prompt_responder(); } }
This serializes on the prompt_responder axis (the global override is process-wide, like stdin / clipboard). The harness handles the install + reset for you when used as .prompts(...).
When to Reach for the Framework Instead
If your interactive flow is launched as a subcommand of an otherwise-normal CLI app (e.g. mycli setup), you can still use App::builder() for everything outside the wizard — argument parsing, help rendering, the other commands. Just have the setup handler call your wizard run() function. The handler itself produces Output::Silent (or a small summary) and lets the wizard own its own stdout while it runs. See Framework Integration for the broader CLI integration story.