feat: add interactive menu for single select elicitations (#10327)

Signed-off-by: Keerthana Panyam <panyam.k@northeastern.edu>
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Keerthana Panyam
2026-08-04 07:31:28 -04:00
committed by GitHub
parent 7f62ce53e7
commit 1366eef3ac
+217 -5
View File
@@ -9,6 +9,19 @@ pub struct ElicitationInput {
pub user_data: HashMap<String, Value>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum SelectChoice {
Value(String),
Skip,
}
struct SingleSelect<'a> {
field_name: &'a str,
description: Option<&'a str>,
options: Vec<(SelectChoice, String)>,
initial_value: Option<SelectChoice>,
}
pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result<ElicitationInput> {
if !message.is_empty() {
println!("\n{}", style(message).cyan());
@@ -16,8 +29,12 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result<El
let properties = schema.get("properties").and_then(|p| p.as_object());
// Schema-less (or empty-schema) elicitations are pure approval prompts —
// offer an explicit Y/N confirmation instead of silently auto-accepting.
if io::stdin().is_terminal() && io::stderr().is_terminal() {
if let Some(select) = single_select(schema) {
return prompt_single_select(select);
}
}
let properties = match properties {
Some(props) if !props.is_empty() => props,
_ => {
@@ -62,7 +79,6 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result<El
let default = field_schema.get("default");
let enum_values = field_schema.get("enum").and_then(|e| e.as_array());
// makes a little true/false toggle
if field_type == "boolean" {
let label = match description {
Some(desc) => format!("{} ({})", name, desc),
@@ -108,7 +124,6 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result<El
let input = read_line()?;
// Handle Ctrl+C / EOF for cancellation
if input.is_none() {
return Ok(ElicitationInput {
action: ElicitationAction::Cancel,
@@ -148,6 +163,101 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result<El
})
}
fn single_select(schema: &Value) -> Option<SingleSelect<'_>> {
let properties = schema.get("properties")?.as_object()?;
if properties.len() != 1 {
return None;
}
let (field_name, field_schema) = properties.iter().next()?;
let description = field_schema.get("description").and_then(Value::as_str);
let mut options: Vec<(SelectChoice, String)> =
if let Some(one_of) = field_schema.get("oneOf").and_then(Value::as_array) {
one_of
.iter()
.map(|option| {
let value = option.get("const")?.as_str()?;
let label = option.get("title").and_then(Value::as_str).unwrap_or(value);
Some((SelectChoice::Value(value.to_string()), label.to_string()))
})
.collect::<Option<_>>()?
} else {
field_schema
.get("enum")?
.as_array()?
.iter()
.map(|value| {
let value = value.as_str()?;
Some((SelectChoice::Value(value.to_string()), value.to_string()))
})
.collect::<Option<_>>()?
};
if options.is_empty() {
return None;
}
let is_required = schema
.get("required")
.and_then(Value::as_array)
.is_some_and(|required| {
required
.iter()
.any(|value| value.as_str() == Some(field_name))
});
let default_value = field_schema
.get("default")
.and_then(Value::as_str)
.map(|value| SelectChoice::Value(value.to_string()))
.filter(|value| options.iter().any(|(option, _)| option == value));
let initial_value = if !is_required && default_value.is_none() {
options.push((SelectChoice::Skip, "Skip".to_string()));
Some(SelectChoice::Skip)
} else {
default_value
};
Some(SingleSelect {
field_name,
description,
options,
initial_value,
})
}
fn prompt_single_select(select: SingleSelect<'_>) -> io::Result<ElicitationInput> {
let items: Vec<_> = select
.options
.iter()
.map(|(value, label)| (value.clone(), label, ""))
.collect();
let label = match select.description {
Some(desc) => format!("{} ({})", select.field_name, desc),
None => select.field_name.to_string(),
};
let mut prompt = cliclack::select(label).items(&items);
if let Some(initial_value) = select.initial_value {
prompt = prompt.initial_value(initial_value);
}
match prompt.interact() {
Ok(SelectChoice::Value(value)) => Ok(ElicitationInput {
action: ElicitationAction::Accept,
user_data: HashMap::from([(select.field_name.to_string(), Value::String(value))]),
}),
Ok(SelectChoice::Skip) => Ok(ElicitationInput {
action: ElicitationAction::Accept,
user_data: HashMap::new(),
}),
Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(ElicitationInput {
action: ElicitationAction::Cancel,
user_data: HashMap::new(),
}),
Err(error) => Err(error),
}
}
fn read_line() -> io::Result<Option<String>> {
if !std::io::stdin().is_terminal() {
let mut line = String::new();
@@ -157,7 +267,7 @@ fn read_line() -> io::Result<Option<String>> {
let mut line = String::new();
match io::stdin().lock().read_line(&mut line) {
Ok(0) => Ok(None), // EOF
Ok(0) => Ok(None),
Ok(_) => Ok(Some(line.trim().to_string())),
Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(None),
Err(e) => Err(e),
@@ -204,3 +314,105 @@ fn parse_value(input: &str, field_type: &str, enum_values: Option<&Vec<Value>>)
_ => Value::String(input.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use test_case::test_case;
#[test]
fn builds_required_enum_select_with_default() {
let schema = json!({
"type": "object",
"properties": {
"color": {
"type": "string",
"enum": ["red", "green"],
"default": "green"
}
},
"required": ["color"]
});
let select = single_select(&schema).unwrap();
assert_eq!(select.field_name, "color");
assert_eq!(
select.options,
vec![
(SelectChoice::Value("red".to_string()), "red".to_string()),
(
SelectChoice::Value("green".to_string()),
"green".to_string()
)
]
);
assert_eq!(
select.initial_value,
Some(SelectChoice::Value("green".to_string()))
);
}
#[test]
fn optional_select_defaults_to_skip() {
let schema = json!({
"type": "object",
"properties": {
"color": { "type": "string", "enum": ["", "green"] }
}
});
let select = single_select(&schema).unwrap();
assert_eq!(select.initial_value, Some(SelectChoice::Skip));
assert_eq!(select.options.last().unwrap().0, SelectChoice::Skip);
assert_eq!(
select.options.first().unwrap().0,
SelectChoice::Value(String::new())
);
}
#[test]
fn uses_one_of_titles_as_labels() {
let schema = json!({
"type": "object",
"properties": {
"size": {
"oneOf": [
{ "const": "s", "title": "Small" },
{ "const": "l", "title": "Large" }
]
}
},
"required": ["size"]
});
let select = single_select(&schema).unwrap();
assert_eq!(select.options[0].0, SelectChoice::Value("s".to_string()));
assert_eq!(select.options[0].1, "Small");
assert_eq!(select.options[1].0, SelectChoice::Value("l".to_string()));
assert_eq!(select.options[1].1, "Large");
}
#[test_case(json!({}); "missing properties")]
#[test_case(json!({ "properties": {} }); "empty properties")]
#[test_case(json!({
"properties": {
"first": { "enum": ["a"] },
"second": { "enum": ["b"] }
}
}); "multiple properties")]
#[test_case(json!({
"properties": { "choice": { "enum": ["a", 2] } }
}); "non-string enum value")]
#[test_case(json!({
"properties": {
"choice": { "oneOf": [{ "const": "a" }, { "type": "string" }] }
}
}); "oneOf branch without const")]
fn unsupported_schema_does_not_build_select(schema: Value) {
assert!(single_select(&schema).is_none());
}
}