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
@@ -1,12 +1,40 @@
//! Route question actions through the shared composer, preserving drafts until delivery accepts them.
//! Inline Other reserves Up/Down for choices; j/k retain their Vim behavior.
use super::*;
use ratatui::layout::Rect;
impl AsyncQuestions {
pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool {
self.focus_is_notes() && self.composer.handles_key_as_editing(key)
}
pub(super) fn other_selected(&self) -> bool {
self.has_options() && self.selected_option_index() == Some(self.options().len())
}
pub(super) fn other_label(&self) -> String {
self.current_answer()
.filter(|_| !self.other_selected())
.map(|answer| answer.draft.text.trim())
.filter(|text| !text.is_empty())
.map(|text| &text[..text.floor_char_boundary(512)])
.map(|text| crate::text_formatting::truncate_text(text, /*max_graphemes*/ 128))
.unwrap_or_else(|| self.other_placeholder().to_string())
}
pub(super) fn focus_other(&mut self) {
self.select_option(self.options().len());
}
pub(super) fn select_option(&mut self, index: usize) {
self.save_current_draft();
self.state.pending[self.state.current_idx]
.options_state
.selected_idx = Some(index);
self.sync_composer_placeholder();
}
pub(super) fn edit(&mut self, key: KeyEvent) {
if key.kind == KeyEventKind::Repeat && self.keymap.composer.queue.is_pressed(key) {
return;
@@ -17,7 +45,6 @@ impl AsyncQuestions {
}
let before = self.composer.snapshot_draft();
let (result, _) = self.composer.handle_key_event(key);
self.composer.cancel_history_search();
let queued = matches!(result, InputResult::Queued { .. });
if matches!(
result,
@@ -31,12 +58,74 @@ impl AsyncQuestions {
}
}
pub(super) fn select_option(&mut self, index: usize) {
self.state.pending[self.state.current_idx]
.options_state
.selected_idx = Some(index);
pub(super) fn other_prefix_width(&self, width: u16) -> u16 {
let full = format!("› {}. ", self.options_len()).width() as u16;
if full + 2 <= width {
full
} else {
width.saturating_sub(2).min(2)
}
}
pub(super) fn other_input_area(&self, options_area: Rect) -> Rect {
let prefix_width = self.other_prefix_width(options_area.width);
let width = options_area.width.saturating_sub(prefix_width);
let height = self
.composer
.inline_input_height(width)
.clamp(1, 8)
.min(options_area.height);
Rect::new(
options_area.x.saturating_add(prefix_width),
options_area.bottom().saturating_sub(height),
width,
height,
)
}
pub(super) fn render_inline_options(&self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
use ratatui::text::Line;
use ratatui::widgets::Widget;
let row_width = area.width.saturating_add(1);
let input = self.other_input_area(area);
let named_area = Rect::new(area.x, area.y, area.width, input.y.saturating_sub(area.y));
let mut rows = self.option_rows();
rows.pop();
let mut state = ScrollState::default();
while state.scroll_top + 1 < rows.len()
&& measure_rows_height(&rows, &state, rows.len(), row_width) > named_area.height
{
state.scroll_top += 1;
}
if measure_rows_height(&rows, &state, rows.len(), row_width) <= named_area.height {
self.visible_options
.set((state.scroll_top, rows.len() - state.scroll_top));
}
crate::bottom_pane::request_user_input::render::render_rows_bottom_aligned(
named_area,
buf,
&rows,
&state,
rows.len(),
"",
);
let margin = Rect::new(
area.x,
input.y,
input.x.saturating_sub(area.x),
input.height.min(1),
);
let prefix = format!("› {}. ", self.options_len());
Line::from(if usize::from(margin.width) < prefix.width() {
"› ".to_string()
} else {
prefix
})
.style(crate::style::accent_style())
.render(margin, buf);
self.composer.render_inline_input(input, buf);
}
pub(crate) fn set_keymap(&mut self, keymap: &RuntimeKeymap) {
self.keymap = keymap.clone();
self.composer.set_keymap_bindings(keymap);
@@ -49,14 +138,26 @@ impl AsyncQuestions {
impl BottomPaneView for AsyncQuestions {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
if self.has_options() {
crate::keymap::KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Chat)
} else {
if self.focus_is_notes() {
self.composer.keymap_contexts().with(KeymapContext::Chat)
} else {
crate::keymap::KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Chat)
}
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
fn will_interrupt_turn_on_key_event(&self, key: KeyEvent) -> bool {
!self.handles_key_as_editing(key) && self.keymap.chat.interrupt_turn.is_pressed(key)
}
fn handle_key_event(&mut self, key: KeyEvent) {
if key.kind == KeyEventKind::Repeat && self.other_selector == Some(key.code) {
return;
}
self.other_selector = None;
if key.kind == KeyEventKind::Release || self.is_complete() {
return;
}
@@ -82,44 +183,91 @@ impl BottomPaneView for AsyncQuestions {
self.navigate(/*forward*/ false);
return;
}
if !self.has_options() {
if key.kind == KeyEventKind::Press || !self.keymap.composer.submit.is_pressed(key) {
if self.focus_is_notes() && self.keymap.composer.submit.is_pressed(key) {
if key.kind != KeyEventKind::Press {
return;
}
let empty = self.composer.current_text_with_pending().trim().is_empty();
if empty && !self.composer.is_in_paste_burst() {
if !self.other_selected() {
self.go_next_or_submit();
}
} else {
self.edit(key);
}
return;
}
let count = self.options_len();
let visible = self.visible_options.get().1.max(1);
let mut state = self.state.pending[self.state.current_idx].options_state;
match self.keymap.list.action_for(key) {
Some(ListAction::MoveUp) => state.move_up_wrap(count),
Some(ListAction::MoveDown) => state.move_down_wrap(count),
Some(ListAction::PageUp) => state.page_up_clamped(count, visible),
Some(ListAction::PageDown) => state.page_down_clamped(count, visible),
Some(ListAction::JumpTop) => state.jump_top(count, visible),
Some(ListAction::JumpBottom) => state.jump_bottom(count, visible),
Some(ListAction::Accept) => {
if key.kind == KeyEventKind::Press {
self.go_next_or_submit();
if self.has_options() {
// Default j/k start Other; explicit printable list remaps keep their meaning.
let printable = matches!(key.code, KeyCode::Char(_))
&& !crate::key_hint::has_ctrl_or_alt(key.modifiers);
let remapped = self.keymap.list.action_for(key).is_some_and(|action| {
self.keymap.list.bindings_for(action)
!= RuntimeKeymap::defaults().list.bindings_for(action)
});
if (!printable || remapped)
&& (!self.focus_is_notes() || matches!(key.code, KeyCode::Up | KeyCode::Down))
{
let count = self.options_len();
let visible = self.visible_options.get().1.max(1);
let mut state = self.state.pending[self.state.current_idx].options_state;
match self.keymap.list.action_for(key) {
Some(ListAction::MoveUp) => state.move_up_wrap(count),
Some(ListAction::MoveDown) => state.move_down_wrap(count),
Some(ListAction::PageUp) => state.page_up_clamped(count, visible),
Some(ListAction::PageDown) => state.page_down_clamped(count, visible),
Some(ListAction::JumpTop) => state.jump_top(count, visible),
Some(ListAction::JumpBottom) => state.jump_bottom(count, visible),
Some(ListAction::Cancel) => {
self.set_expanded(/*expanded*/ false);
return;
}
Some(ListAction::MoveLeft | ListAction::MoveRight) => return,
Some(ListAction::Accept) | None => {}
}
}
Some(ListAction::Cancel) => self.set_expanded(/*expanded*/ false),
Some(ListAction::MoveLeft | ListAction::MoveRight) => {}
None => {
if key.kind == KeyEventKind::Press
&& !crate::key_hint::has_ctrl_or_alt(key.modifiers)
&& let KeyCode::Char(ch) = key.code
&& let Some(index) = self.option_index_for_digit(ch)
{
self.select_option(index);
self.go_next_or_submit();
if !matches!(
self.keymap.list.action_for(key),
Some(ListAction::Accept) | None
) {
self.select_option(state.selected_idx.unwrap_or(0));
self.state.pending[self.state.current_idx].options_state = state;
return;
}
}
}
self.state.pending[self.state.current_idx].options_state = state;
if self.focus_is_notes() {
self.edit(key);
return;
}
if self.keymap.list.action_for(key) == Some(ListAction::Accept) {
if key.kind != KeyEventKind::Press {
return;
}
self.go_next_or_submit();
} else if let KeyCode::Char(ch) = key.code
&& !crate::key_hint::has_ctrl_or_alt(key.modifiers)
{
if let Some(index) = self.option_index_for_digit(ch) {
if key.kind != KeyEventKind::Press {
return;
}
self.select_option(index);
if self.other_selected() {
self.other_selector = Some(key.code);
} else {
self.go_next_or_submit();
}
} else {
self.focus_other();
self.composer.resume_text_entry();
self.edit(key);
}
}
}
fn terminal_title_requires_action(&self) -> bool {
!self.is_complete()
}
fn is_complete(&self) -> bool {
self.unanswered_count() == 0
}
@@ -127,8 +275,9 @@ impl BottomPaneView for AsyncQuestions {
if self.composer.cancel_vim_search() || self.composer.cancel_history_search() {
return CancellationEvent::Handled;
}
if self.composer.clear_for_ctrl_c().is_some() {
if self.focus_is_notes() && self.composer.clear_for_ctrl_c().is_some() {
self.save_current_draft();
self.sync_composer_placeholder();
} else {
return CancellationEvent::NotHandled;
}
@@ -138,16 +287,20 @@ impl BottomPaneView for AsyncQuestions {
if self.is_complete() || text.is_empty() {
return false;
}
if !self.focus_is_notes() {
self.focus_other();
}
self.composer.flush_pending_input();
!self.has_options() && self.composer.handle_paste(text)
self.composer.handle_paste(text)
}
fn flush_paste_burst_if_due(&mut self) -> bool {
self.composer.flush_paste_burst_if_due()
}
fn next_frame_delay(&self) -> Option<std::time::Duration> {
self.composer.footer_flash_delay()
}
fn is_in_paste_burst(&self) -> bool {
self.composer.is_in_paste_burst()
}
fn next_frame_delay(&self) -> Option<Duration> {
self.composer.footer_flash_delay()
}
}
@@ -34,7 +34,9 @@ impl AsyncQuestions {
let progress_pref = u16::from(self.unanswered_count() > 1);
let min_notes = u16::from(!has_options).min(area.height);
let available = area.height.saturating_sub(min_notes);
let question_height = question_lines.len().min(usize::from(available)) as u16;
let question_height = question_lines.len().min(usize::from(
available.saturating_sub(u16::from(self.other_selected())),
)) as u16;
question_lines.truncate(usize::from(question_height));
let mut remaining = available.saturating_sub(question_height);
let mut options_height = 0;
@@ -20,6 +20,7 @@ use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use std::collections::HashSet;
use std::time::Duration;
use unicode_width::UnicodeWidthStr;
mod input;
@@ -27,6 +28,7 @@ mod layout;
mod render;
mod state;
const OTHER_OPTION_LABEL: &str = "Other";
pub(super) const TIP_SEPARATOR: &str = " ";
pub(super) const DESIRED_SPACERS_BETWEEN_SECTIONS: u16 = 2;
@@ -59,6 +61,8 @@ pub(crate) struct AsyncQuestions {
visible_options: std::cell::Cell<(usize, usize)>,
pub(crate) next_hint: Option<crate::key_hint::ShortcutHint>,
keymap: RuntimeKeymap,
// Ignore autorepeat from the number key that opened Other.
other_selector: Option<KeyCode>,
pub(super) composer: ChatComposer,
}
@@ -89,6 +93,7 @@ impl AsyncQuestions {
visible_options: std::cell::Cell::new((0, 0)),
next_hint: None,
keymap,
other_selector: None,
composer,
}
}
@@ -116,12 +121,13 @@ impl AsyncQuestions {
.and_then(|q| q.options.as_deref())
.unwrap_or_default()
}
fn has_options(&self) -> bool {
!self.options().is_empty()
}
fn options_len(&self) -> usize {
self.options().len()
self.options().len() + usize::from(self.has_options())
}
fn option_index_for_digit(&self, ch: char) -> Option<usize> {
@@ -146,12 +152,17 @@ impl AsyncQuestions {
}
fn focus_is_notes(&self) -> bool {
!self.has_options()
!self.has_options() || self.other_selected()
}
pub(super) fn option_rows(&self) -> Vec<GenericDisplayRow> {
if !self.has_options() {
return Vec::new();
};
let other = self.other_label();
self.options()
.iter()
.chain(std::iter::once(&other))
.enumerate()
.map(|(index, label)| {
let prefix = if self.selected_option_index() == Some(index) {
@@ -174,20 +185,36 @@ impl AsyncQuestions {
if !self.has_options() {
return 0;
}
let row_width = width.saturating_add(1);
let rows = self.option_rows();
let state = ScrollState::default();
measure_rows_height(&rows, &state, rows.len(), width.saturating_add(1))
if self.other_selected() {
let prefix = self.other_prefix_width(width);
measure_rows_height(
&rows[..rows.len() - 1],
&ScrollState::default(),
rows.len(),
row_width,
) + self
.composer
.inline_input_height(width.saturating_sub(prefix).max(1))
.clamp(1, 8)
} else {
measure_rows_height(&rows, &ScrollState::default(), rows.len(), row_width)
}
}
fn save_current_draft(&mut self) {
self.composer.flush_pending_input();
let draft = self.composer.snapshot_draft();
if let Some(answer) = self.current_answer_mut() {
answer.draft = draft;
if self.focus_is_notes() {
let draft = self.composer.snapshot_draft();
if let Some(answer) = self.current_answer_mut() {
answer.draft = draft;
}
}
}
fn restore_current_draft(&mut self) {
self.sync_composer_placeholder();
let draft = self
.current_answer()
.map(|answer| answer.draft.clone())
@@ -195,6 +222,27 @@ impl AsyncQuestions {
self.composer.restore_inline_draft(draft);
}
fn other_placeholder(&self) -> &'static str {
if self
.options()
.iter()
.any(|label| label.eq_ignore_ascii_case("Other"))
{
"Other (write an answer)"
} else {
OTHER_OPTION_LABEL
}
}
fn sync_composer_placeholder(&mut self) {
let text = if self.other_selected() {
self.other_placeholder()
} else {
"Type your answer"
};
self.composer.set_placeholder_text(text.to_string());
}
pub(crate) fn unanswered_count(&self) -> usize {
self.state.pending.len()
}
@@ -57,7 +57,9 @@ impl Renderable for AsyncQuestions {
// The shared measurer reserves a scrollbar column; this renderer uses the full width.
let option_rows = self.option_rows();
if self.has_options() && sections.options_area.height > 0 {
if self.other_selected() {
self.render_inline_options(sections.options_area, buf);
} else if self.has_options() && sections.options_area.height > 0 {
let mut options_state = self.state.pending[self.state.current_idx].options_state;
let selected = options_state.selected_idx.unwrap_or(0);
let heights: Vec<_> = option_rows
@@ -109,6 +111,25 @@ impl Renderable for AsyncQuestions {
content_area.width,
sections.footer_lines,
);
let input_bottom = if self.other_selected() {
sections.options_area.bottom()
} else {
sections.notes_area.bottom()
};
if self.focus_is_notes()
&& footer_area.y > input_bottom
&& let Some(mode) = self.composer.vim_mode_indicator_span()
{
Line::from(mode).right_aligned().render(
Rect::new(
content_area.x,
footer_area.y - 1,
content_area.width,
/*height*/ 1,
),
buf,
);
}
let option_tip = (self.has_options()
&& sections.options_area.height > 0
&& self.options_required_height(content_area.width) > sections.options_area.height)
@@ -130,7 +151,11 @@ impl Renderable for AsyncQuestions {
return None;
}
let sections = self.layout_sections(content_area);
let input_area = sections.notes_area;
let input_area = if self.other_selected() {
self.other_input_area(sections.options_area)
} else {
sections.notes_area
};
if input_area.width == 0 || input_area.height == 0 {
return None;
}
@@ -171,15 +196,7 @@ impl AsyncQuestions {
.bold(),
);
}
if self.focus_is_notes()
&& let Some(mode) = self.composer.vim_mode_indicator_span()
{
tips.push(Span::from(mode.content.into_owned()).dim());
}
if let Some(key) = self
.keymap
.primary_hint(KeymapContext::Chat, "skip_question")
{
if let Some(key) = chat_hint("skip_question") {
tips.push(format!("{} skip", key.display_label()).dim());
}
tips.extend(option_tip);
@@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&buffer)
---
Second
1. A suggested answer that is long enough to
wrap across multiple rows
› 2. long draft
long draft
long draft
long draft
long draft
long draft
long draft
enter submit ctrl + ] skip
⌥ + ↓ main prompt
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&buffer)
---
2 of 2
Second
› 1. Other
2. Other (write an answer)
enter submit ctrl + ] skip
⌥ + ↓ prev question
@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&buffer)
---
2 of
Secon
d
› x
ente…
ctrl…
@@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&buffer)
---
2 of 2
Second
› 1. Option 1 is long enough to wrap across
rows
2. Option 2 is long enough to wrap across
rows
f9 submit ctrl + ] skip option 1/13
⌥ + ↓ prev question
@@ -8,6 +8,7 @@ expression: buffer_text(&buffer)
› 1. A suggested answer that is
long enough to wrap across
multiple rows
2. Other
enter submit ctrl + ] skip
⌥ + ↓ main prompt
@@ -0,0 +1,16 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&buffer)
---
Second
1. A suggested answer that is
long enough to wrap across
multiple rows
› 2. An alternative answer that
wraps across several option
rows
enter submit ctrl + ] skip
⌥ + ↓ main prompt
@@ -10,4 +10,4 @@ expression: buffer_text(&clipped)
multiple rows
enter submit ctrl + ] skip
option 2/2 ⌥ + ↓ main prompt
option 2/3 ⌥ + ↓ main prompt
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&buffer)
---
Long question Long question Long question
Long question Long question Long question
Long question Long question Long question
Long question Long question Long question
Long question Long question Long question
Long question Long question Long question
Long question Long question Long question
› 2. Other
@@ -6,6 +6,7 @@ use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
fn editor() -> AsyncQuestions {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
@@ -76,6 +77,55 @@ fn long_prompt_keeps_the_active_input_visible() {
insta::assert_snapshot!("long_prompt_active_input", text);
}
#[test]
fn selected_other_renders_as_a_dim_placeholder() {
let mut editor = editor();
editor.navigate(/*forward*/ true);
editor.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
let mut repeat = KeyEvent::from(KeyCode::Char('2'));
repeat.kind = KeyEventKind::Repeat;
editor.handle_key_event(repeat);
assert!(editor.composer.is_empty());
editor.handle_paste("saved".into());
editor.handle_key_event(KeyEvent::from(KeyCode::Up));
editor.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
editor.handle_key_event(repeat);
assert_eq!(editor.composer.current_text(), "saved");
editor.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
editor.handle_key_event(repeat);
assert_eq!(editor.composer.current_text(), "saved22");
editor.on_ctrl_c();
editor.state.pending[1].question.title = "Long question ".repeat(40);
let buffer = render_editor(&editor, /*width*/ 50, /*height*/ 10);
let area = buffer.area;
assert!(editor.cursor_pos(area).is_some());
let input = editor.other_input_area(
editor
.layout_sections(crate::bottom_pane::selection_popup_common::menu_surface_inset(area))
.options_area,
);
for offset in 0..5 {
assert_eq!(buffer[(input.x + offset, input.y)].modifier, Modifier::DIM);
}
insta::assert_snapshot!("selected_other_placeholder", buffer_text(&buffer));
editor.state.pending[1].question.title = "Second".into();
render_editor(&editor, /*width*/ 50, /*height*/ 10);
editor.set_vim_enabled(/*enabled*/ true);
editor.handle_key_event(KeyEvent::from(KeyCode::Char('i')));
editor.handle_paste("draft".into());
editor.handle_key_event(KeyEvent::from(KeyCode::Esc));
editor.handle_key_event(KeyEvent::from(KeyCode::Up));
assert_eq!(editor.selected_option_index(), Some(0));
editor.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(editor.submission.take().is_some());
editor.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
editor.handle_key_event(KeyEvent::from(KeyCode::Esc));
for _ in 0..2 {
editor.handle_key_event(KeyEvent::from(KeyCode::Char('u')));
}
assert_eq!(editor.composer.current_text(), "");
}
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
(buffer.area.y..buffer.area.bottom())
.map(|y| {
@@ -89,6 +139,60 @@ fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
.join("\n")
}
#[test]
fn question_choice_remaps_paging_and_inline_feedback() {
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
let mut editor = editor();
editor.navigate(/*forward*/ true);
editor.state.pending[1].question.options = Some(
(1..=12)
.map(|i| format!("Option {i} is long enough to wrap across rows"))
.collect(),
);
let mut config = TuiKeymap::default();
config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("x".into())));
config.list.accept = Some(KeybindingsSpec::One(KeybindingSpec("f9".into())));
config.list.jump_top = Some(KeybindingsSpec::One(KeybindingSpec("7".into())));
editor.set_keymap(&RuntimeKeymap::from_config(&config).unwrap());
editor.handle_key_event(KeyEvent::from(KeyCode::Down));
assert_eq!(editor.selected_option_index(), Some(0));
editor.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
assert_eq!(editor.selected_option_index(), Some(1));
let mut buffer = render_editor(&editor, /*width*/ 50, /*height*/ 12);
let area = buffer.area;
let visible = editor.visible_options.get().1;
assert_eq!(visible, 2);
editor.handle_key_event(KeyEvent::from(KeyCode::PageDown));
assert_eq!(editor.selected_option_index(), Some(1 + visible));
editor.handle_key_event(KeyEvent::from(KeyCode::Char('7')));
assert_eq!(editor.selected_option_index(), Some(0));
editor.handle_key_event(KeyEvent::from(KeyCode::Char('7')));
assert_eq!(editor.selected_option_index(), Some(0));
editor.render(area, &mut buffer);
insta::assert_snapshot!("question_remapped_choice_footer", buffer_text(&buffer));
}
#[test]
fn question_other_remains_visible_at_nine_columns_and_distinguishes_named_other() {
let mut editor = editor();
editor.navigate(/*forward*/ true);
editor.state.pending[1].question.options = Some(vec!["Other".into()]);
editor.handle_paste("other".into());
editor.on_ctrl_c();
editor.handle_key_event(KeyEvent::from(KeyCode::Up));
let buffer = render_editor(&editor, /*width*/ 50, /*height*/ 12);
insta::assert_snapshot!("question_named_other", buffer_text(&buffer));
editor.select_option(/*index*/ 1);
editor.handle_paste("x".into());
let buffer = render_editor(&editor, /*width*/ 9, /*height*/ 8);
let narrow = buffer.area;
assert!(editor.cursor_pos(narrow).is_some());
assert!(buffer.content.iter().any(|cell| cell.symbol() == "x"));
insta::assert_snapshot!("question_narrow_other", buffer_text(&buffer));
}
#[test]
fn question_wrapped_options_and_other_share_the_text_indent() {
let mut editor = editor();
@@ -136,12 +240,13 @@ fn question_wrapped_options_and_other_share_the_text_indent() {
assert_eq!(
editor.current_answer().unwrap().options_state,
ScrollState {
selected_idx: Some(31),
scroll_top: 32 - editor.visible_options.get().1,
selected_idx: Some(editor.options_len() - 1),
scroll_top: editor.options_len() - editor.visible_options.get().1,
}
);
editor.handle_key_event(KeyEvent::from(KeyCode::Up));
render_editor(&editor, /*width*/ 80, /*height*/ 80);
assert_eq!(editor.visible_options.get(), (0, 32));
assert_eq!(editor.visible_options.get(), (0, editor.options_len()));
editor.state.pending[editor.state.current_idx]
.question
.title = "Long question ".repeat(3 * 65_536);
@@ -166,6 +271,35 @@ fn question_wrapped_options_and_other_share_the_text_indent() {
}
}
#[test]
fn question_wrapped_other_shares_the_text_indent() {
let mut editor = editor();
editor.state.pending.remove(0);
editor.state.pending[0].question.options = Some(vec![
"A suggested answer that is long enough to wrap across multiple rows".into(),
]);
editor.select_option(/*index*/ 1);
editor.handle_paste("An alternative answer that wraps across several option rows".into());
let buffer = render_editor(&editor, /*width*/ 36, /*height*/ 16);
insta::assert_snapshot!("question_wrapped_other", buffer_text(&buffer));
editor.composer.clear_for_ctrl_c();
editor.handle_paste(" \n ".into());
editor.select_option(/*index*/ 0);
assert_eq!(editor.other_label(), "Other");
editor.select_option(/*index*/ 1);
editor
.composer
.set_text_content("long draft\n".repeat(1000), Vec::new(), Vec::new());
editor.composer.move_cursor_to_end();
let buffer = render_editor(&editor, /*width*/ 50, /*height*/ 18);
assert_eq!(editor.cursor_pos(buffer.area), Some((7, 12)));
insta::assert_snapshot!("question_capped_other", buffer_text(&buffer));
editor.select_option(/*index*/ 0);
assert!(editor.other_label().len() <= 128);
editor.state.pending[0].draft.text = format!("a{}", "\u{301}".repeat(10_000));
assert!(editor.other_label().len() <= 512);
}
fn question(title: &str, options: Option<Vec<String>>) -> AsyncUserInputQuestion {
AsyncUserInputQuestion {
title: title.into(),
@@ -463,7 +463,7 @@ impl ChatComposer {
/// footer area is collapsed or too narrow, the x coordinate is clamped inside the hint rect so
/// terminal backends do not receive an off-screen cursor position.
pub(super) fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let search = self.history_search.as_ref()?;
self.history_search.as_ref()?;
let [_, _, _, popup_rect] = self.layout_areas(area);
if popup_rect.is_empty() {
return None;
@@ -488,17 +488,27 @@ impl ChatComposer {
return None;
}
let indent = (FOOTER_INDENT_COLS as u16).min(hint_rect.width.saturating_sub(1));
self.history_search_query_cursor_pos(Rect {
x: hint_rect.x.saturating_add(indent),
width: hint_rect.width.saturating_sub(indent),
..hint_rect
})
}
pub(super) fn history_search_query_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let search = self.history_search.as_ref()?;
if area.is_empty() {
return None;
}
let prompt_width = Line::from("reverse-i-search: ").width() as u16;
let query_width = Line::from(search.query.clone()).width() as u16;
let desired_x = hint_rect
let desired_x = area
.x
.saturating_add(FOOTER_INDENT_COLS as u16)
.saturating_add(prompt_width)
.saturating_add(query_width);
let max_x = hint_rect
.x
.saturating_add(hint_rect.width.saturating_sub(1));
Some((desired_x.min(max_x), hint_rect.y))
let max_x = area.x.saturating_add(area.width.saturating_sub(1));
Some((desired_x.min(max_x), area.y))
}
}
@@ -33,12 +33,14 @@ impl ChatComposer {
}
/// Give modal editing precedence over a containing view's submit/navigation keys.
/// Up/Down remain available to inline choices; j/k keep their Vim editing behavior.
pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool {
self.history_search.is_some()
|| self.should_handle_vim_insert_escape(key)
|| self.draft.textarea.is_vim_operator_pending()
|| self.draft.textarea.wants_vim_search_key(key)
|| (self.draft.textarea.is_vim_normal_mode()
&& !matches!(key.code, KeyCode::Up | KeyCode::Down)
&& !self.submit_keys.is_pressed(key)
&& !self.queue_keys.is_pressed(key)
&& (self.vim_normal_keymap.move_up.is_pressed(key)
@@ -72,6 +74,9 @@ impl ChatComposer {
if let Some(query) = self.draft.textarea.vim_query() {
return query.cursor_pos(query_area);
}
if self.history_search.is_some() {
return self.history_search_query_cursor_pos(query_area);
}
self.draft
.textarea
.cursor_pos_with_state(area, *self.draft.textarea_state.borrow())
@@ -101,6 +106,12 @@ impl ChatComposer {
Rect::new(area.x, area.bottom(), area.width, /*height*/ 1),
buf,
);
} else if let Some(line) = self.history_search_footer_line() {
area.height = area.height.saturating_sub(1);
line.render(
Rect::new(area.x, area.bottom(), area.width, /*height*/ 1),
buf,
);
}
let mut state = self.draft.textarea_state.borrow_mut();
let highlights: Vec<_> = self
@@ -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