How to use dialoguer for interactive CLI

Cli
Use dialoguer::Confirm with task::spawn_blocking to safely prompt users for yes/no confirmation in async Rust CLI applications.

When your CLI needs to ask permission

You are building a tool that deletes a database. You add a safety prompt: "Drop database at postgres://localhost/mydb? [y/N]". You run the tool. The prompt appears. You type y and hit Enter. The terminal hangs. The cursor blinks. Nothing happens. You hit Enter again. Still nothing. Your app is frozen.

Or worse, the prompt appears, but the rest of your application stops responding. Timers don't fire. Network requests stall. The event loop has ground to a halt. You didn't break the prompt. You broke the runtime. The fix isn't a different prompt library. It's understanding that user input is slow, and your async runtime hates slow things.

The problem with slow input

Rust's async runtime manages concurrency by multiplexing many tasks onto a few threads. When a task waits for I/O, it yields control back to the runtime so other tasks can run. This is how you get high throughput with low overhead. The runtime expects every task to be cooperative. It expects tasks to yield when they block.

dialoguer is a crate that makes interactive prompts look professional. It handles colors, defaults, validation, and keyboard navigation. It is also entirely synchronous. When you call interact(), dialoguer writes the prompt to stdout and calls read_line on stdin. It waits. It does not yield. It grabs the thread and holds it hostage until the user types something.

If you call interact() on a thread running the async executor, the executor stops. No other task can run. The app is frozen until the user responds. This is a common mistake when moving from synchronous scripts to async applications. The prompt works, but it kills the concurrency you paid for.

Think of your async runtime as a single waiter in a busy restaurant. The waiter takes orders, checks on tables, and coordinates with the kitchen. If the waiter stops to read a menu item aloud to a customer and waits for the customer to decide, the waiter cannot take orders from anyone else. The restaurant stops. You need a separate person to handle the slow interaction. tokio::spawn_blocking hands the slow job to a background thread pool. The waiter keeps moving.

A synchronous prompt in a synchronous world

In a synchronous program, blocking is fine. There is no event loop to freeze. You can call dialoguer directly. This is the baseline behavior.

use dialoguer::Confirm;

/// Prompts the user for a yes/no decision in a synchronous context.
fn ask_confirmation_sync() -> bool {
    // dialoguer blocks the current thread until input arrives.
    // In a sync program, this is safe and expected.
    let result = Confirm::new()
        .with_prompt("Delete the configuration file?")
        .default(false)
        .interact();

    // Handle the Result from the prompt.
    // Ok(true) means yes, Ok(false) means no.
    // Err occurs if stdin isn't a TTY or input fails.
    match result {
        Ok(confirmed) => confirmed,
        Err(_) => false,
    }
}

fn main() {
    if ask_confirmation_sync() {
        println!("File deleted.");
    } else {
        println!("Aborted.");
    }
}

The code runs on the main thread. interact() blocks. The program waits. When the user responds, interact() returns. The program continues. This works perfectly for simple CLI tools that don't use async. The moment you introduce async fn main or a runtime like tokio, this pattern becomes dangerous.

Bridging the gap with spawn_blocking

When your application uses an async runtime, you must isolate blocking operations. tokio::spawn_blocking moves a closure to a dedicated thread pool for blocking work. The async task yields while the thread runs. The runtime stays responsive.

use dialoguer::Confirm;
use tokio::task;

/// Asks for confirmation without blocking the async runtime.
async fn ask_confirmation_async(message: &str) -> bool {
    // Offload the blocking prompt to a background thread.
    // This keeps the async runtime free to handle other tasks.
    let join_handle = task::spawn_blocking(move || {
        Confirm::new()
            .with_prompt(message)
            .default(false)
            .interact()
    });

    // Await the thread completion.
    // This yields control back to the runtime.
    let result = join_handle.await;

    // Handle the double Result pattern.
    // The outer Result is from the thread joining.
    // The inner Result is from the prompt interaction.
    match result {
        Ok(Ok(true)) => true,
        Ok(Ok(false)) => false,
        Ok(Err(_)) => false, // User pressed Ctrl+C or input failed.
        Err(_) => false,     // The blocking task panicked.
    }
}

The move || closure captures the message by value. spawn_blocking returns a JoinHandle. Calling .await on the handle yields the current task. The runtime schedules other work. Meanwhile, a thread from the blocking pool executes the closure. dialoguer runs. The user types. The thread finishes. The result is sent back. The async task resumes.

You get a Result from await. That's the thread joining. Inside that, you have the Result from interact. That's the user input. You have to match twice. This is the tax for crossing the async boundary. The outer Result tells you if the thread panicked. The inner Result tells you if the prompt succeeded.

Walkthrough of the double Result

The double Result pattern trips up many developers. It looks like boilerplate, but it carries meaning.

The outer Result<JoinError, T> comes from spawn_blocking. If the closure panics, you get Err(JoinError). This is rare for dialoguer, but possible if you capture a reference that becomes invalid or if you trigger a panic inside the closure.

The inner Result<DialoguerError, T> comes from interact(). This tells you about the interaction. Ok(true) means the user confirmed. Ok(false) means the user declined. Err(DialoguerError) means something went wrong with I/O. This happens when stdin is not a TTY, or when the user sends a signal like Ctrl+C.

Convention aside: the community often chains these with flatten() or and_then(), but explicit matching is clearer for readers. It shows you considered both failure modes. Don't hide the thread panic behind an unwrap(). If the blocking task panics, your async app should handle it gracefully, not crash.

Pitfalls that trip up CLIs

Blocking the runtime is the obvious pitfall. There are subtler ones.

Piped input breaks prompts. If a user runs my_tool < input.txt, stdin is a pipe, not a terminal. dialoguer detects this. interact() returns an error because there is no interactive terminal to display the prompt. Your tool crashes. Check for a TTY before prompting.

use std::io::IsTerminal;

fn is_interactive() -> bool {
    // Check if stdin is connected to a terminal.
    // Returns false if input is piped or redirected.
    std::io::stdin().is_terminal()
}

If is_interactive() returns false, skip the prompt or use a command-line flag. Scripts hate being asked questions. They expect non-interactive behavior.

Non-Send types in spawn_blocking. spawn_blocking requires the closure to be Send. If you capture a MutexGuard, Rc, or a raw pointer, the compiler rejects you with E0277 (trait bound not satisfied). dialoguer types are Send, but your captured data might not be. Clone the data before moving it into the closure.

Ctrl+C handling. When the user presses Ctrl+C, the OS sends a signal. dialoguer usually returns an error. Your match arm should treat this as a cancellation. Return false or exit the program. Don't panic on user cancellation.

Convention aside: dialoguer has a wait_for_newline(true) method. By default, dialoguer might return as soon as you press y or n. Setting wait_for_newline(true) forces the user to press Enter. This prevents the prompt from looking like it's eating the Enter key. It's a small detail, but it makes the UX feel polished. Most community examples use wait_for_newline(true).

Choosing the right tool

Use dialoguer when you need styled prompts with defaults and validation in a synchronous context. Use tokio::spawn_blocking when you must run dialoguer inside an async function to avoid freezing the runtime. Use std::io::stdin().read_line() when you want to avoid external dependencies and can handle parsing manually. Use clap for command-line arguments, not for interactive questions. Reach for inquire when you need complex multi-step forms with rich UI elements.

dialoguer is the workhorse for simple prompts. It's lightweight and focused. inquire is heavier but offers more features like multi-select menus and text editors. Pick based on complexity. If you just need a yes/no or a text input, dialoguer is enough. If you need a wizard-style flow with validation across steps, inquire saves time.

Never call interact() on the async thread. The runtime will punish you. Treat spawn_blocking as the airlock between your fast async world and the slow real world. Check for TTY before prompting. Scripts hate being asked questions.

Where to go next