mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
7e7e8a9088
commit
028353c82e
5 changed files with 160 additions and 6 deletions
46
Cargo.lock
generated
46
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -35,3 +35,4 @@ jsonschema = "0.42"
|
|||
chrono = "0.4"
|
||||
bollard = "0.18"
|
||||
tar = "0.4"
|
||||
dialoguer = "0.12"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
|||
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<String> = 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::<String>::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<String> = 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::<String>::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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue