Add inline Other answers to async question choices (#42897)

## Why

Async questions with suggested choices only accepted those choices. Allow users to write an alternative answer directly in the question pane.

## What changed

- Append an editable Other choice. Typing or pasting opens it, while number shortcuts still submit named choices.
- Preserve custom drafts when switching choices, reject blank Other submissions, and distinguish the input from a suggested choice named Other.
- Support Vim editing and search in question inputs while keeping arrow keys available for choice navigation.
- Wrap custom answers inline, cap the editor height, and keep the input visible in narrow layouts.

## Testing

Add interaction tests and snapshots covering custom answer delivery, draft preservation, selector key repeats, blank answers, remapped shortcuts, Vim editing and search, and narrow or wrapped layouts.

GitOrigin-RevId: 0e9aa9fcd2410e8da25f5ca43bdef94cbb9253b8
This commit is contained in:
Ian MacLeod
2026-09-05 00:49:07 +00:00
committed by copyberry
parent 07f18d5ff7
commit c126b0d8ef
21 changed files with 728 additions and 70 deletions
@@ -115,6 +115,84 @@ async fn selected_answers_preserve_long_labels_and_reject_oversized_submissions(
}
}
#[tokio::test]
async fn question_inputs_support_vim_editing_and_search() {
for (name, options) in [("single", None), ("other", Some(vec!["Named".to_string()]))] {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.show_welcome_banner = false;
chat.bottom_pane.set_vim_enabled(/*enabled*/ true);
chat.add_async_questions("message", &[question("Which way?", options.clone())]);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
if options.is_some() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
}
chat.handle_key_event(KeyEvent::from(KeyCode::Char('i')));
chat.bottom_pane
.handle_paste("alpha beta\nalpha gamma".into());
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
for ch in "0k".chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
assert!(chat.bottom_pane.questions.as_ref().unwrap().expanded);
let normal = render_bottom_popup(&chat, /*width*/ 80);
for ch in "dw".chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("alpha beta"));
chat.handle_key_event(KeyEvent::from(KeyCode::Char('u')));
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("alpha beta"));
chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("alpha beta"));
chat.handle_key_event(KeyEvent::from(KeyCode::Char('u')));
for ch in "/gamma".chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
let search = render_bottom_popup(&chat, /*width*/ 80);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(question_count(&chat), 1);
assert!(op_rx.try_recv().is_err());
for ch in "ciw".chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
chat.bottom_pane.handle_paste("delta".into());
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_answer(
op_rx.try_recv().unwrap(),
"> Which way?\n\nalpha beta\nalpha delta",
);
insta::assert_snapshot!(
format!("question_vim_{name}"),
format!("NORMAL\n{normal}\n\nSEARCH\n{search}")
);
}
}
#[tokio::test]
async fn async_questions_reject_blank_answers_but_allow_explicit_skip() {
for options in [None, Some(vec!["Named".to_string()])] {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.add_async_questions("message", &[question("Which way?", options.clone())]);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
if options.is_some() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
}
for text in ["", " \t\n"] {
chat.bottom_pane.handle_paste(text.into());
let before = render_bottom_popup(&chat, /*width*/ 80);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), before);
assert!(chat.input_queue.queued_user_messages.is_empty());
assert!(op_rx.try_recv().is_err());
}
chat.handle_key_event(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::CONTROL));
assert_eq!(question_count(&chat), 0);
assert!(op_rx.try_recv().is_err());
}
}
fn open_questions(chat: &mut ChatWidget, options: Option<Vec<String>>) {
chat.thread_id = Some(ThreadId::new());
chat.on_agent_message_item_completed(
@@ -160,6 +238,25 @@ async fn question_queue_key_does_not_steer_the_running_turn() {
assert!(ops.try_recv().is_err());
}
#[tokio::test]
async fn question_choices_deliver_digits_and_leading_letters() {
for (key, text, expected) in [
('2', "", "Second"),
('j', "ust text", "just text"),
('k', "eep it", "keep it"),
] {
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
open_questions(&mut chat, Some(vec!["First".into(), "Second".into()]));
chat.handle_key_event(KeyEvent::from(KeyCode::Char(key)));
if !text.is_empty() {
chat.bottom_pane.handle_paste(text.into());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
}
assert_answer(ops.try_recv().unwrap(), &format!("> First?\n\n{expected}"));
assert_eq!(question_count(&chat), 1);
}
}
#[tokio::test]
async fn question_key_repeats_do_not_consume_another_question() {
for key in [KeyCode::Enter, KeyCode::Char('1')] {
@@ -216,3 +313,27 @@ fn question(title: &str, options: Option<Vec<String>>) -> AsyncUserInputQuestion
options,
}
}
#[tokio::test]
async fn questions_and_queued_messages_share_the_resolved_shortcut() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("queued".to_string()).into());
chat.refresh_pending_input_preview();
for binding in [key_hint::shift(KeyCode::Left), key_hint::alt(KeyCode::Up)] {
chat.bottom_pane
.set_queued_message_edit_binding(Some(binding.into()));
let hint = binding.display_label();
assert!(render_bottom_popup(&chat, /*width*/ 100).contains(&hint));
chat.add_async_questions(&hint, &questions());
assert!(render_bottom_popup(&chat, /*width*/ 100).contains(&format!("{hint} to answer")));
let (key, modifiers) = binding.parts();
chat.handle_key_event(KeyEvent::new(key, modifiers));
insta::assert_snapshot!(
format!("question_queue_hint_{}", key),
render_bottom_popup(&chat, /*width*/ 100)
);
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
}
}
@@ -10,5 +10,6 @@ expression: "render_bottom_popup(&chat, 80)"
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxx but do not deploy
2. Other
enter submit ctrl + ] skip ⌥ + ↓ main prompt
@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "render_bottom_popup(&chat, 100)"
---
• Queued follow-up inputs
↳ queued
shift + ← edit last queued message
1 of 2
Which way?
Type your answer
enter submit ctrl + ] skip ⌥ + ↓ main prompt shift + ← next question
@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "render_bottom_popup(&chat, 100)"
---
• Queued follow-up inputs
↳ queued
⌥ + ↑ edit last queued message
1 of 4
Which way?
Type your answer
enter submit ctrl + ] skip ⌥ + ↓ main prompt ⌥ + ↑ next question
@@ -0,0 +1,22 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "format!(\"NORMAL\\n{normal}\\n\\nSEARCH\\n{search}\")"
---
NORMAL
Which way?
1. Named
› 2. alpha beta
alpha gamma
Vim: Normal
enter submit ctrl + ] skip ⌥ + ↓ main prompt
SEARCH
Which way?
1. Named
› 2. alpha beta
alpha gamma
/gamma
Vim: Normal
enter submit ctrl + ] skip ⌥ + ↓ main prompt
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "format!(\"NORMAL\\n{normal}\\n\\nSEARCH\\n{search}\")"
---
NORMAL
Which way?
alpha beta
alpha gamma
Vim: Normal
enter submit ctrl + ] skip ⌥ + ↓ main prompt
SEARCH
Which way?
alpha beta
alpha gamma
/gamma
Vim: Normal
enter submit ctrl + ] skip ⌥ + ↓ main prompt