From 0de0a328ff450533f6f0769d1162ebbc637a1b2f Mon Sep 17 00:00:00 2001 From: Keerthana Panyam Date: Fri, 12 Jun 2026 11:22:12 -0400 Subject: [PATCH 1/4] add interactive menu for single select elicitations Adds support for single property schemas with oneOf/enum options, rendering them as an interactive menu using cliclack::select. Users can navigate options with arrow keys instead of manual input. Signed-off-by: Keerthana Panyam --- crates/goose-cli/src/session/elicitation.rs | 56 ++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/goose-cli/src/session/elicitation.rs b/crates/goose-cli/src/session/elicitation.rs index 012295ff968e..fba7d7340fff 100644 --- a/crates/goose-cli/src/session/elicitation.rs +++ b/crates/goose-cli/src/session/elicitation.rs @@ -16,7 +16,61 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result>() + } else if let Some(enum_vals) = field_schema.get("enum").and_then(|e| e.as_array()) { + enum_vals + .iter() + .filter_map(|v| { + let value = v.as_str()?; + Some((value.to_string(), value.to_string())) + }) + .collect::>() + } else { + vec![] + }; + + if !options.is_empty() { + // Interactive menu with arrow keys + let items: Vec<(&str, &str, &str)> = options + .iter() + .map(|(value, title)| (value.as_str(), title.as_str(), "")) + .collect(); + + match cliclack::select(field_name.as_str()) + .items(&items) + .interact() + { + Ok(selected_value) => { + let mut data = HashMap::new(); + data.insert( + field_name.clone(), + Value::String(selected_value.to_string()), + ); + return Ok(Some(data)); + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => return Ok(None), + Err(e) => return Err(e), + } + } + } + } + + // Case 2: Schema-less (or empty-schema) elicitations are pure approval prompts — // offer an explicit Y/N confirmation instead of silently auto-accepting. let properties = match properties { Some(props) if !props.is_empty() => props, From a2489c077ccc60ea26830a3c1fcc95a6b4c05c44 Mon Sep 17 00:00:00 2001 From: Keerthana Panyam Date: Wed, 8 Jul 2026 13:13:10 -0400 Subject: [PATCH 2/4] add default handling and const checking for single select menu - Extracts default from schema and finds its position in options - Sets initial_value to highlight correct option - Pressing enter selects schema default instead of first item - oneOf const checking - Counts total oneOf branches vs const only branches - Only shows menu when all branches have const values - Falls back to text input for mixed schemas - Optional field handling - Prepends "Skip" option for optional fields with no default - Empty selection returns Accept with no data - Ctrl+C always returns Cancel Signed-off-by: Keerthana Panyam --- crates/goose-cli/src/session/elicitation.rs | 82 +++++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/crates/goose-cli/src/session/elicitation.rs b/crates/goose-cli/src/session/elicitation.rs index fba7d7340fff..631ed1257e85 100644 --- a/crates/goose-cli/src/session/elicitation.rs +++ b/crates/goose-cli/src/session/elicitation.rs @@ -21,49 +21,103 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result = one_of .iter() .filter_map(|opt| { let value = opt.get("const")?.as_str()?; let title = opt.get("title").and_then(|t| t.as_str()).unwrap_or(value); Some((value.to_string(), title.to_string())) }) - .collect::>() + .collect(); + + // Only use menu if all oneOf branches are const + let all_const = const_options.len() == total_branches; + (const_options, all_const) } else if let Some(enum_vals) = field_schema.get("enum").and_then(|e| e.as_array()) { - enum_vals + let enum_options = enum_vals .iter() .filter_map(|v| { let value = v.as_str()?; Some((value.to_string(), value.to_string())) }) - .collect::>() + .collect(); + (enum_options, true) // enum always has all const values } else { - vec![] + (vec![], true) }; - if !options.is_empty() { + // Only use interactive menu if we have options and all oneOf branches are const + if !options.is_empty() && all_const && std::io::stdin().is_terminal() { + // If field is optional and has no default append a Skip option + const SKIP_SENTINEL: &str = "\x00__SKIP__"; + let has_skip = if !is_required && default_value.is_none() { + options.push((SKIP_SENTINEL.to_string(), "Skip".to_string())); + true + } else { + false + }; + + // Find option index that matches the default + let initial_index = if let Some(default) = default_value { + options.iter().position(|(value, _)| value == default) + } else { + None + }; + // Interactive menu with arrow keys let items: Vec<(&str, &str, &str)> = options .iter() .map(|(value, title)| (value.as_str(), title.as_str(), "")) .collect(); - match cliclack::select(field_name.as_str()) - .items(&items) - .interact() - { + // Build selector and set initial cursor position to default if available + let mut selector = cliclack::select(field_name.as_str()).items(&items); + if let Some(idx) = initial_index { + selector = selector.initial_value(items[idx].0); + } + + match selector.interact() { Ok(selected_value) => { + // If user selected the Skip option return empty data + if has_skip && selected_value == SKIP_SENTINEL { + return Ok(ElicitationInput { + action: ElicitationAction::Accept, + user_data: HashMap::new(), + }); + } + + // Normal selection let mut data = HashMap::new(); data.insert( field_name.clone(), Value::String(selected_value.to_string()), ); - return Ok(Some(data)); + return Ok(ElicitationInput { + action: ElicitationAction::Accept, + user_data: data, + }); + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => { + return Ok(ElicitationInput { + action: ElicitationAction::Cancel, + user_data: HashMap::new(), + }); } - Err(e) if e.kind() == io::ErrorKind::Interrupted => return Ok(None), Err(e) => return Err(e), } } From d3193c4c5647293aa26be049d8c17bf337648064 Mon Sep 17 00:00:00 2001 From: Douwe M Osinga Date: Thu, 30 Jul 2026 19:19:08 +0200 Subject: [PATCH 3/4] fix(cli): clean up elicitation select handling --- crates/goose-cli/src/session/elicitation.rs | 317 +++++++++++++------- 1 file changed, 207 insertions(+), 110 deletions(-) diff --git a/crates/goose-cli/src/session/elicitation.rs b/crates/goose-cli/src/session/elicitation.rs index 631ed1257e85..e224fa6599cf 100644 --- a/crates/goose-cli/src/session/elicitation.rs +++ b/crates/goose-cli/src/session/elicitation.rs @@ -9,6 +9,18 @@ pub struct ElicitationInput { pub user_data: HashMap, } +#[derive(Clone, Debug, Eq, PartialEq)] +enum SelectChoice { + Value(String), + Skip, +} + +struct SingleSelect<'a> { + field_name: &'a str, + options: Vec<(SelectChoice, String)>, + initial_value: Option, +} + pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result { if !message.is_empty() { println!("\n{}", style(message).cyan()); @@ -16,116 +28,12 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result = one_of - .iter() - .filter_map(|opt| { - let value = opt.get("const")?.as_str()?; - let title = opt.get("title").and_then(|t| t.as_str()).unwrap_or(value); - Some((value.to_string(), title.to_string())) - }) - .collect(); - - // Only use menu if all oneOf branches are const - let all_const = const_options.len() == total_branches; - (const_options, all_const) - } else if let Some(enum_vals) = field_schema.get("enum").and_then(|e| e.as_array()) { - let enum_options = enum_vals - .iter() - .filter_map(|v| { - let value = v.as_str()?; - Some((value.to_string(), value.to_string())) - }) - .collect(); - (enum_options, true) // enum always has all const values - } else { - (vec![], true) - }; - - // Only use interactive menu if we have options and all oneOf branches are const - if !options.is_empty() && all_const && std::io::stdin().is_terminal() { - // If field is optional and has no default append a Skip option - const SKIP_SENTINEL: &str = "\x00__SKIP__"; - let has_skip = if !is_required && default_value.is_none() { - options.push((SKIP_SENTINEL.to_string(), "Skip".to_string())); - true - } else { - false - }; - - // Find option index that matches the default - let initial_index = if let Some(default) = default_value { - options.iter().position(|(value, _)| value == default) - } else { - None - }; - - // Interactive menu with arrow keys - let items: Vec<(&str, &str, &str)> = options - .iter() - .map(|(value, title)| (value.as_str(), title.as_str(), "")) - .collect(); - - // Build selector and set initial cursor position to default if available - let mut selector = cliclack::select(field_name.as_str()).items(&items); - if let Some(idx) = initial_index { - selector = selector.initial_value(items[idx].0); - } - - match selector.interact() { - Ok(selected_value) => { - // If user selected the Skip option return empty data - if has_skip && selected_value == SKIP_SENTINEL { - return Ok(ElicitationInput { - action: ElicitationAction::Accept, - user_data: HashMap::new(), - }); - } - - // Normal selection - let mut data = HashMap::new(); - data.insert( - field_name.clone(), - Value::String(selected_value.to_string()), - ); - return Ok(ElicitationInput { - action: ElicitationAction::Accept, - user_data: data, - }); - } - Err(e) if e.kind() == io::ErrorKind::Interrupted => { - return Ok(ElicitationInput { - action: ElicitationAction::Cancel, - user_data: HashMap::new(), - }); - } - Err(e) => return Err(e), - } - } + if io::stdin().is_terminal() { + if let Some(select) = single_select(schema) { + return prompt_single_select(select); } } - // Case 2: Schema-less (or empty-schema) elicitations are pure approval prompts — - // offer an explicit Y/N confirmation instead of silently auto-accepting. let properties = match properties { Some(props) if !props.is_empty() => props, _ => { @@ -170,7 +78,6 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result format!("{} ({})", name, desc), @@ -216,7 +123,6 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result io::Result Option> { + let properties = schema.get("properties")?.as_object()?; + if properties.len() != 1 { + return None; + } + + let (field_name, field_schema) = properties.iter().next()?; + 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::>()? + } 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::>()? + }; + + 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, + options, + initial_value, + }) +} + +fn prompt_single_select(select: SingleSelect<'_>) -> io::Result { + let items: Vec<_> = select + .options + .iter() + .map(|(value, label)| (value.clone(), label, "")) + .collect(); + let mut prompt = cliclack::select(select.field_name).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> { if !std::io::stdin().is_terminal() { let mut line = String::new(); @@ -265,7 +260,7 @@ fn read_line() -> io::Result> { 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), @@ -312,3 +307,105 @@ fn parse_value(input: &str, field_type: &str, enum_values: Option<&Vec>) _ => 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()); + } +} From 185128d97fc6cb9737ae58adec2d3b3dcdf037e9 Mon Sep 17 00:00:00 2001 From: Keerthana Panyam Date: Fri, 31 Jul 2026 17:26:10 -0400 Subject: [PATCH 4/4] fix(cli): gate select menu on stderr TTY and show field description - Also check stderr is a terminal since cliclack renders via Term::stderr(); prevents failure when stderr is redirected - Include field description in selector prompt to match the text input path --- crates/goose-cli/src/session/elicitation.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/goose-cli/src/session/elicitation.rs b/crates/goose-cli/src/session/elicitation.rs index e224fa6599cf..8ddaa496f12f 100644 --- a/crates/goose-cli/src/session/elicitation.rs +++ b/crates/goose-cli/src/session/elicitation.rs @@ -17,6 +17,7 @@ enum SelectChoice { struct SingleSelect<'a> { field_name: &'a str, + description: Option<&'a str>, options: Vec<(SelectChoice, String)>, initial_value: Option, } @@ -28,7 +29,7 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result Option> { } 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 @@ -218,6 +220,7 @@ fn single_select(schema: &Value) -> Option> { Some(SingleSelect { field_name, + description, options, initial_value, }) @@ -229,7 +232,11 @@ fn prompt_single_select(select: SingleSelect<'_>) -> io::Result 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); }