From 028353c82ea7265ab4b4325dc12e481d84a0463f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 23 Feb 2026 17:58:18 -0500 Subject: [PATCH] Replace manual CLI prompts with dialoguer for arrow-key navigation Interactive TTY sessions now use dialoguer widgets (Select, MultiSelect, Confirm, Input) instead of raw eprintln/read_line. Non-TTY input falls back to the existing line-based reader. Suppresses redundant "Stage started" inform message for wait.human nodes since the prompt itself serves as notification. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 46 ++++++++- Cargo.toml | 1 + crates/attractor/Cargo.toml | 1 + crates/attractor/src/engine.rs | 10 +- crates/attractor/src/interviewer/console.rs | 108 +++++++++++++++++++- 5 files changed, 160 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5659f1eff..87c73adaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,7 @@ dependencies = [ "axum", "chrono", "clap", + "dialoguer", "dotenvy", "futures", "http-body-util", @@ -476,6 +477,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -508,6 +522,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" @@ -552,6 +578,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -1661,7 +1693,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2193,6 +2225,12 @@ dependencies = [ "time", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -2557,6 +2595,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 11f1a9b24..69d3f03ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,3 +35,4 @@ jsonschema = "0.42" chrono = "0.4" bollard = "0.18" tar = "0.4" +dialoguer = "0.12" diff --git a/crates/attractor/Cargo.toml b/crates/attractor/Cargo.toml index 543b6c067..1b70f9e92 100644 --- a/crates/attractor/Cargo.toml +++ b/crates/attractor/Cargo.toml @@ -37,6 +37,7 @@ async-trait.workspace = true futures.workspace = true chrono = { workspace = true, features = ["serde"] } nom = "7" +dialoguer.workspace = true axum = { version = "0.8", optional = true } tower = { version = "0.5", optional = true } tokio-stream = { workspace = true, optional = true, features = ["sync"] } diff --git a/crates/attractor/src/engine.rs b/crates/attractor/src/engine.rs index d9123752c..f424a38c3 100644 --- a/crates/attractor/src/engine.rs +++ b/crates/attractor/src/engine.rs @@ -814,10 +814,12 @@ impl PipelineEngine { name: node.label().to_string(), index: stage_index, }); - self.inform( - &format!("Stage started: {}", node.label()), - &node.id, - ); + if node.handler_type() != Some("wait.human") { + self.inform( + &format!("Stage started: {}", node.label()), + &node.id, + ); + } let stage_start = Instant::now(); let (mut outcome, attempts_used) = self diff --git a/crates/attractor/src/interviewer/console.rs b/crates/attractor/src/interviewer/console.rs index 92be6e8bc..5e66a02fd 100644 --- a/crates/attractor/src/interviewer/console.rs +++ b/crates/attractor/src/interviewer/console.rs @@ -1,4 +1,7 @@ +use std::io::IsTerminal; + use async_trait::async_trait; +use dialoguer::console::Term; use terminal::Styles; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -55,9 +58,112 @@ async fn read_line(prompt: &str) -> std::io::Result { Ok(line.trim_end().to_string()) } +/// Ask a multiple-choice question using dialoguer's `Select` widget on a TTY. +fn ask_select_interactive(question: &Question) -> Answer { + let items: Vec = question + .options + .iter() + .map(|opt| format!("{} - {}", opt.key, opt.label)) + .collect(); + + let has_freeform = question.allow_freeform; + let mut all_items = items; + if has_freeform { + all_items.push("Other (free text)...".to_string()); + } + + let selection = dialoguer::Select::new() + .with_prompt(&question.text) + .items(&all_items) + .default(0) + .interact_on_opt(&Term::stderr()); + + match selection { + Ok(Some(idx)) if has_freeform && idx == question.options.len() => { + // User chose the free-text option + dialoguer::Input::::new() + .with_prompt("Enter your response") + .interact_on(&Term::stderr()) + .map_or_else(|_| Answer::skipped(), Answer::text) + } + Ok(Some(idx)) if idx < question.options.len() => { + let opt = &question.options[idx]; + Answer { + value: AnswerValue::Selected(opt.key.clone()), + selected_option: Some(opt.clone()), + text: None, + } + } + _ => Answer::skipped(), + } +} + +/// Ask a multi-select question using dialoguer's `MultiSelect` widget on a TTY. +fn ask_multi_select_interactive(question: &Question) -> Answer { + let items: Vec = question + .options + .iter() + .map(|opt| format!("{} - {}", opt.key, opt.label)) + .collect(); + + let selection = dialoguer::MultiSelect::new() + .with_prompt(&question.text) + .items(&items) + .interact_on_opt(&Term::stderr()); + + match selection { + Ok(Some(indices)) if !indices.is_empty() => { + let idx = indices[0]; + let opt = &question.options[idx]; + Answer { + value: AnswerValue::Selected(opt.key.clone()), + selected_option: Some(opt.clone()), + text: None, + } + } + _ => Answer::skipped(), + } +} + +/// Ask a yes/no or confirmation question using dialoguer's `Confirm` widget on a TTY. +fn ask_confirm_interactive(question: &Question) -> Answer { + let confirmed = dialoguer::Confirm::new() + .with_prompt(&question.text) + .default(true) + .interact_on_opt(&Term::stderr()); + + match confirmed { + Ok(Some(true)) => Answer::yes(), + _ => Answer::no(), + } +} + +/// Ask a freeform question using dialoguer's `Input` widget on a TTY. +fn ask_freeform_interactive(question: &Question) -> Answer { + dialoguer::Input::::new() + .with_prompt(&question.text) + .interact_on(&Term::stderr()) + .map_or_else(|_| Answer::skipped(), Answer::text) +} + #[async_trait] impl Interviewer for ConsoleInterviewer { async fn ask(&self, question: Question) -> Answer { + // If stdin is a TTY, use dialoguer for interactive arrow-key navigation. + // Otherwise, fall back to the line-based reader for piped input. + if std::io::stdin().is_terminal() { + let q = question; + return tokio::task::spawn_blocking(move || match q.question_type { + QuestionType::MultipleChoice => ask_select_interactive(&q), + QuestionType::MultiSelect => ask_multi_select_interactive(&q), + QuestionType::YesNo | QuestionType::Confirmation => ask_confirm_interactive(&q), + QuestionType::Freeform => ask_freeform_interactive(&q), + }) + .await + .unwrap_or_else(|_| Answer::skipped()); + } + + // Non-TTY fallback: line-based stdin reading let s = self.styles; eprintln!( "{bold}{cyan}?{reset} {}", @@ -182,4 +288,4 @@ mod tests { let result = find_matching_option("5", &options); assert!(result.is_none()); } -} \ No newline at end of file +}