Support selectable answers for asynchronous TUI questions (#42894)

## Why

Asynchronous questions can include suggested answers, but the TUI previously displayed only a freeform input. Show those choices and require them to be fully visible before submission.

## What changed

- Render numbered, wrapped choices with a default selection, list navigation, and digit shortcuts. Submit the selected label as the answer.
- Block submission of clipped choices and prompt the user to expand the terminal. Limit option processing to the first 32 entries and discard labels over 512 bytes.
- Preserve freeform input when no usable choices remain, and add contextual submit, skip, and question navigation hints.
- Ignore repeated submit keys so holding a key cannot answer another question.

## Testing

Add state, snapshot, and chat widget tests covering wrapped and clipped choices, constrained layouts, option limits, long answer preservation, oversized answer rejection, key repeats, and conflicting keybindings.

GitOrigin-RevId: 5d3ebadf61da62a9680ba819e6dfcf8302c4e9da
This commit is contained in:
Ian MacLeod
2026-09-05 00:23:47 +00:00
committed by copyberry
parent 218e8df926
commit 07f18d5ff7
17 changed files with 729 additions and 74 deletions

View File

@@ -4,7 +4,7 @@ use super::*;
impl AsyncQuestions {
pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool {
self.composer.handles_key_as_editing(key)
self.focus_is_notes() && self.composer.handles_key_as_editing(key)
}
pub(super) fn edit(&mut self, key: KeyEvent) {
@@ -31,6 +31,12 @@ 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(crate) fn set_keymap(&mut self, keymap: &RuntimeKeymap) {
self.keymap = keymap.clone();
self.composer.set_keymap_bindings(keymap);
@@ -43,7 +49,11 @@ impl AsyncQuestions {
impl BottomPaneView for AsyncQuestions {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
self.composer.keymap_contexts().with(KeymapContext::Chat)
if self.has_options() {
crate::keymap::KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Chat)
} else {
self.composer.keymap_contexts().with(KeymapContext::Chat)
}
}
fn handle_key_event(&mut self, key: KeyEvent) {
@@ -54,9 +64,6 @@ impl BottomPaneView for AsyncQuestions {
self.edit(key);
return;
}
if key.kind != KeyEventKind::Press && self.keymap.composer.submit.is_pressed(key) {
return;
}
if self.keymap.chat.interrupt_turn.is_pressed(key) {
self.app_event_tx.interrupt();
return;
@@ -69,11 +76,48 @@ impl BottomPaneView for AsyncQuestions {
}
if self.keymap.chat.edit_queued_message.is_pressed(key) {
self.navigate(/*forward*/ true);
} else if self.keymap.chat.prompt_stack_back.is_pressed(key) {
self.navigate(/*forward*/ false);
} else {
self.edit(key);
return;
}
if self.keymap.chat.prompt_stack_back.is_pressed(key) {
self.navigate(/*forward*/ false);
return;
}
if !self.has_options() {
if key.kind == KeyEventKind::Press || !self.keymap.composer.submit.is_pressed(key) {
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();
}
}
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();
return;
}
}
}
self.state.pending[self.state.current_idx].options_state = state;
}
fn is_complete(&self) -> bool {
@@ -95,7 +139,7 @@ impl BottomPaneView for AsyncQuestions {
return false;
}
self.composer.flush_pending_input();
self.composer.handle_paste(text)
!self.has_options() && self.composer.handle_paste(text)
}
fn flush_paste_burst_if_due(&mut self) -> bool {
self.composer.flush_paste_burst_if_due()

View File

@@ -0,0 +1,114 @@
//! Allocate question, options, input, and footer rows without intermediate layout plans.
//! Tight layouts reserve freeform editing space; named choices require the complete prompt.
use ratatui::layout::Rect;
use super::AsyncQuestions;
use super::DESIRED_SPACERS_BETWEEN_SECTIONS;
pub(super) struct LayoutSections {
pub(super) progress_area: Rect,
pub(super) question_area: Rect,
pub(super) question_lines: Vec<String>,
pub(super) options_area: Rect,
pub(super) notes_area: Rect,
pub(super) footer_lines: u16,
pub(super) spacer_after_input: u16,
}
impl AsyncQuestions {
pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections {
let has_options = self.has_options();
let mut question_lines = self.wrapped_question_lines(area.width);
let mut footer_pref = self.footer_lines(area.width, /*option_tip*/ None).len() as u16;
if has_options
&& question_lines.len()
+ usize::from(self.options_required_height(area.width))
+ usize::from(footer_pref)
+ usize::from(self.unanswered_count() > 1)
+ usize::from(DESIRED_SPACERS_BETWEEN_SECTIONS)
> usize::from(area.height)
{
footer_pref = self.footer_lines(area.width, Some(self.option_tip())).len() as u16;
}
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;
question_lines.truncate(usize::from(question_height));
let mut remaining = available.saturating_sub(question_height);
let mut options_height = 0;
let mut spacer_before_question = 0;
let spacer_after_question;
let mut spacer_after_options = 0;
let mut spacer_after_input = 0;
let progress_height;
let footer_lines;
let notes_height;
if has_options {
let full_options_height = self.options_required_height(area.width);
let min_options_height = remaining.min(1);
// Reserve hints and spacers while retaining a row for the selected option.
let reserved = footer_pref + progress_pref + DESIRED_SPACERS_BETWEEN_SECTIONS;
options_height =
full_options_height.min(remaining.saturating_sub(reserved).max(min_options_height));
remaining -= options_height;
progress_height = progress_pref.min(remaining);
remaining -= progress_height;
spacer_after_options = u16::from(remaining > footer_pref);
remaining -= spacer_after_options;
footer_lines = footer_pref.min(remaining);
remaining -= footer_lines;
spacer_after_question = u16::from(remaining > 0);
remaining -= spacer_after_question;
options_height += remaining.min(full_options_height.saturating_sub(options_height));
notes_height = 0;
} else {
// Freeform answers take their preferred input height before hints and progress.
let preferred_notes = self
.composer
.inline_input_height(area.width)
.clamp(1, 8)
.saturating_sub(min_notes)
.min(remaining);
remaining -= preferred_notes;
footer_lines = footer_pref.min(remaining);
remaining -= footer_lines;
progress_height = progress_pref.min(remaining);
remaining -= progress_height;
spacer_after_input = u16::from(remaining > 0);
remaining -= spacer_after_input;
// Drop padding before input or prompt content in a constrained viewport.
spacer_before_question = u16::from(remaining >= 2 && self.unanswered_count() > 1);
spacer_after_question = u16::from(remaining >= 1);
notes_height = min_notes + preferred_notes + remaining
- spacer_before_question
- spacer_after_question;
}
let mut y = area.y;
let mut take_rows = |height| {
let rows = Rect::new(area.x, y, area.width, height);
y = y.saturating_add(height);
rows
};
let progress_area = take_rows(progress_height);
take_rows(spacer_before_question);
let question_area = take_rows(question_height);
take_rows(spacer_after_question);
let options_area = take_rows(options_height);
take_rows(spacer_after_options);
let notes_area = take_rows(notes_height);
LayoutSections {
progress_area,
question_area,
question_lines,
options_area,
notes_area,
footer_lines,
spacer_after_input,
}
}
}

View File

@@ -8,21 +8,32 @@ use crate::bottom_pane::ChatComposerConfig;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::chat_composer::ComposerDraft;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::KeymapContext;
use crate::keymap::ListAction;
use crate::keymap::RuntimeKeymap;
use codex_protocol::items::AsyncUserInputQuestion;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use std::collections::HashSet;
use unicode_width::UnicodeWidthStr;
mod input;
mod layout;
mod render;
mod state;
pub(super) const TIP_SEPARATOR: &str = " ";
pub(super) const DESIRED_SPACERS_BETWEEN_SECTIONS: u16 = 2;
#[derive(Debug, Clone, PartialEq)]
struct PendingQuestion {
question: AsyncUserInputQuestion,
options_state: ScrollState,
draft: ComposerDraft,
}
@@ -45,6 +56,7 @@ pub(crate) struct AsyncQuestions {
pub(crate) expanded: bool,
pub(crate) delivery_enabled: bool,
pub(crate) submission: Option<QuestionSubmission>,
visible_options: std::cell::Cell<(usize, usize)>,
pub(crate) next_hint: Option<crate::key_hint::ShortcutHint>,
keymap: RuntimeKeymap,
pub(super) composer: ChatComposer,
@@ -74,6 +86,7 @@ impl AsyncQuestions {
expanded: false,
delivery_enabled: true,
submission: None,
visible_options: std::cell::Cell::new((0, 0)),
next_hint: None,
keymap,
composer,
@@ -98,6 +111,74 @@ impl AsyncQuestions {
format!("{current} of {total}")
}
fn options(&self) -> &[String] {
self.current_question()
.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()
}
fn option_index_for_digit(&self, ch: char) -> Option<usize> {
let idx = ch.to_digit(10)?.checked_sub(1)? as usize;
(idx < self.options_len()).then_some(idx)
}
fn selected_option_index(&self) -> Option<usize> {
self.current_answer()
.and_then(|answer| answer.options_state.selected_idx)
}
pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec<String> {
self.current_question()
.map(|q| {
textwrap::wrap(&q.title, width.max(1) as usize)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn focus_is_notes(&self) -> bool {
!self.has_options()
}
pub(super) fn option_rows(&self) -> Vec<GenericDisplayRow> {
self.options()
.iter()
.enumerate()
.map(|(index, label)| {
let prefix = if self.selected_option_index() == Some(index) {
''
} else {
' '
};
let number = index + 1;
let prefix = format!("{prefix} {number}. ");
GenericDisplayRow {
name: format!("{prefix}{label}"),
wrap_indent: Some(prefix.width()),
..Default::default()
}
})
.collect()
}
pub(super) fn options_required_height(&self, width: u16) -> u16 {
if !self.has_options() {
return 0;
}
let rows = self.option_rows();
let state = ScrollState::default();
measure_rows_height(&rows, &state, rows.len(), width.saturating_add(1))
}
fn save_current_draft(&mut self) {
self.composer.flush_pending_input();
let draft = self.composer.snapshot_draft();

View File

@@ -1,79 +1,228 @@
//! Render freeform questions inline using the existing modal editing engine.
use super::AsyncQuestions;
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::render::renderable::Renderable;
//! Lay out question text, choices, inline editing, and key hints within the available rows.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
impl AsyncQuestions {
fn question_lines(&self, width: u16) -> Vec<Line<'_>> {
self.current_question()
.map(|q| {
textwrap::wrap(&q.title, usize::from(width.max(1)))
.into_iter()
.map(Line::from)
.collect()
})
.unwrap_or_default()
}
fn input_area(&self, area: Rect) -> Rect {
let area = menu_surface_inset(area);
let input_height = self
.composer
.inline_input_height(area.width)
.min(area.height);
Rect::new(
area.x,
area.bottom().saturating_sub(input_height),
area.width,
input_height,
)
}
}
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::render::renderable::Renderable;
use super::AsyncQuestions;
use super::TIP_SEPARATOR;
use crate::bottom_pane::request_user_input::render::render_rows_bottom_aligned;
use crate::bottom_pane::request_user_input::render::truncate_line_word_boundary_with_ellipsis;
use crate::keymap::KeymapContext;
impl Renderable for AsyncQuestions {
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.composer.cursor_style(area)
}
fn desired_height(&self, width: u16) -> u16 {
let width = menu_surface_inset(Rect::new(/*x*/ 0, /*y*/ 0, width, u16::MAX)).width;
self.question_lines(width).len() as u16
+ self.composer.inline_input_height(width)
+ 1
let extra_height = self.options_required_height(width)
+ if !self.has_options() {
self.composer.inline_input_height(width).clamp(1, 8)
} else {
0
}
+ 2
+ u16::from(!self.has_options() && self.unanswered_count() > 1)
+ self.footer_lines(width, /*option_tip*/ None).len() as u16
+ u16::from(self.unanswered_count() > 1)
+ u16::from(self.unanswered_count() > 1)
+ menu_surface_padding_height()
+ menu_surface_padding_height();
u16::try_from(self.wrapped_question_lines(width).len() + usize::from(extra_height))
.unwrap_or(u16::MAX)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let input = self.input_area(area);
let mut prompt = render_menu_surface(area, buf);
prompt.height = input.y.saturating_sub(prompt.y);
if self.unanswered_count() > 1 && prompt.height > 0 {
Paragraph::new(self.progress_prefix_text().dim()).render(
Rect::new(prompt.x, prompt.y, prompt.width, /*height*/ 1),
buf,
);
prompt.y += 1;
prompt.height -= 1;
self.visible_options.set((0, 0));
ratatui::widgets::Clear.render(area, buf);
let content_area = render_menu_surface(area, buf);
if content_area.is_empty() {
return;
}
if self.unanswered_count() > 1
&& prompt.height > self.question_lines(prompt.width).len() as u16
{
prompt.y += 1;
prompt.height -= 1;
}
Paragraph::new(self.question_lines(prompt.width))
let sections = self.layout_sections(content_area);
Paragraph::new(self.progress_prefix_text().dim()).render(sections.progress_area, buf);
Paragraph::new(sections.question_lines.join("\n"))
.style(crate::style::accent_style())
.bold()
.render(prompt, buf);
self.composer.render_inline_input(input, buf);
.render(sections.question_area, buf);
// 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 {
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
.iter()
.map(|row| {
super::measure_rows_height(
std::slice::from_ref(row),
&super::ScrollState::default(),
/*max_results*/ 1,
sections.options_area.width.saturating_add(1),
)
})
.collect();
let available = sections.options_area.height;
if heights.iter().sum::<u16>() <= available {
options_state.scroll_top = 0;
}
let mut first = options_state.scroll_top.min(selected);
while first < selected && heights[first..=selected].iter().sum::<u16>() > available {
first += 1;
}
let mut used = 0;
let visible = heights[first..]
.iter()
.take_while(|&&height| {
used += height;
used <= available
})
.count();
options_state.scroll_top = first;
self.visible_options.set((first, visible));
render_rows_bottom_aligned(
sections.options_area,
buf,
&option_rows,
&options_state,
option_rows.len().max(1),
"No options",
);
}
if !self.has_options() {
self.composer.render_inline_input(sections.notes_area, buf);
}
let footer_area = Rect::new(
content_area.x,
sections.notes_area.bottom() + sections.spacer_after_input,
content_area.width,
sections.footer_lines,
);
let option_tip = (self.has_options()
&& sections.options_area.height > 0
&& self.options_required_height(content_area.width) > sections.options_area.height)
.then(|| self.option_tip());
let lines = self
.footer_lines(footer_area.width, option_tip)
.into_iter()
.map(|line| truncate_line_word_boundary_with_ellipsis(line, footer_area.width as usize))
.collect::<Vec<_>>();
Paragraph::new(lines).render(footer_area, buf);
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.composer.inline_cursor_pos(self.input_area(area))
if !self.focus_is_notes() {
return None;
}
let content_area = menu_surface_inset(area);
if content_area.width == 0 || content_area.height == 0 {
return None;
}
let sections = self.layout_sections(content_area);
let input_area = sections.notes_area;
if input_area.width == 0 || input_area.height == 0 {
return None;
}
self.composer.inline_cursor_pos(input_area)
}
}
impl AsyncQuestions {
pub(super) fn footer_lines(
&self,
width: u16,
option_tip: Option<Span<'static>>,
) -> Vec<Line<'static>> {
if !self.focus_is_notes()
&& let Some(flash) = self.composer.inline_flash()
{
return vec![flash];
}
let mut tips = Vec::new();
let chat_hint = |action| self.keymap.primary_hint(KeymapContext::Chat, action);
let (context, action) = if self.focus_is_notes() {
(KeymapContext::Composer, "submit")
} else {
(KeymapContext::List, "accept")
};
// Existing cross-context keymaps keep chat priority in questions.
if let Some(key) = self.keymap.primary_hint(context, action)
&& (self.focus_is_notes()
|| !matches!(key, crate::key_hint::ShortcutHint::Single(binding)
if self.keymap.chat.interrupt_turn.contains(&binding)
|| self.keymap.chat.edit_queued_message.contains(&binding)))
{
tips.push(
Span::styled(
format!("{} submit", key.display_label()),
crate::style::accent_style(),
)
.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")
{
tips.push(format!("{} skip", key.display_label()).dim());
}
tips.extend(option_tip);
if let Some(key) = chat_hint("prompt_stack_back") {
let label = if self.state.current_idx > 0 {
"prev question"
} else {
"main prompt"
};
tips.push(format!("{} {label}", key.display_label()).dim());
}
let next = if self.state.current_idx + 1 < self.state.pending.len() {
Some("next question")
} else {
None
};
if let Some(label) = next
&& let Some(key) = self.next_hint
{
tips.push(format!("{} {label}", key.display_label()).dim());
}
let mut lines = Vec::new();
let mut line = Line::default();
for tip in tips {
if !line.spans.is_empty() {
if line.width() + TIP_SEPARATOR.len() + tip.width() > usize::from(width) {
lines.push(std::mem::take(&mut line));
} else {
line.spans.push(TIP_SEPARATOR.into());
}
}
line.spans.push(tip);
}
lines.push(line);
lines
}
pub(super) fn option_tip(&self) -> Span<'static> {
format!(
"option {}/{}",
self.selected_option_index().unwrap_or(0) + 1,
self.options_len()
)
.dim()
}
}

View File

@@ -3,7 +3,7 @@ source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: text
---
1 of 2
A lengthy prompt. A lengthy prompt.
A lengthy prompt. A lengthy prompt.
A lengthy prompt. A lengthy prompt.
A lengthy prompt. A lengthy prompt.

View File

@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: "buffer_text(&render_editor(&editor, 50, 8))"
---
Second
2. A suggested answer that is long enough to
wrap across multiple rows
Expand terminal to read the entire option

View File

@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&clipped)
---
Long question Long question Long question

View File

@@ -0,0 +1,13 @@
---
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
enter submit ctrl + ] skip
⌥ + ↓ main prompt

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&clipped)
---
Second
2. A suggested answer that is
long enough to wrap across
multiple rows
enter submit ctrl + ] skip
option 2/2 ⌥ + ↓ main prompt

View File

@@ -11,12 +11,31 @@ impl AsyncQuestions {
return;
}
let was_empty = self.state.pending.is_empty();
self.state
.pending
.extend(questions.iter().map(|question| PendingQuestion {
question: question.clone(),
self.state.pending.extend(questions.iter().map(|question| {
// Bound work before cloning or wrapping model-authored suggestions.
let question = AsyncUserInputQuestion {
title: question.title.clone(),
options: question.options.as_ref().map(|options| {
options
.iter()
.take(32)
.filter(|label| label.len() <= 512)
.cloned()
.collect()
}),
};
let has_options = question
.options
.as_ref()
.is_some_and(|options| !options.is_empty());
let mut options_state = ScrollState::new();
options_state.selected_idx = has_options.then_some(0);
PendingQuestion {
question,
options_state,
draft: ComposerDraft::default(),
}));
}
}));
if was_empty {
self.state.current_idx = 0;
self.restore_current_draft();
@@ -38,6 +57,7 @@ impl AsyncQuestions {
return false;
};
self.save_current_draft();
self.visible_options.set((0, 0));
self.state.current_idx = next;
self.restore_current_draft();
true
@@ -51,7 +71,27 @@ impl AsyncQuestions {
let Some(answer) = self.current_answer() else {
return;
};
let text = answer.draft.text_with_pending();
let selected = answer
.options_state
.selected_idx
.and_then(|index| answer.question.options.as_ref()?.get(index))
.map(String::as_str)
.unwrap_or_default();
// Only a fully displayed model-authored option may become user authorization.
let (first, count) = self.visible_options.get();
let index = self.selected_option_index().unwrap_or(0);
if !self.focus_is_notes() && !(first..first + count).contains(&index) {
self.composer.show_footer_flash(
"Expand terminal to read the entire option".into(),
std::time::Duration::from_secs(5),
);
return;
}
let text = if self.focus_is_notes() {
answer.draft.text_with_pending()
} else {
selected.to_string()
};
let text = text.trim();
let framing = AnsweredQuestion::new(&answer.question.title).render();
let limit = codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS - framing.chars().count();
@@ -70,6 +110,7 @@ impl AsyncQuestions {
return;
}
self.composer.flush_pending_input();
self.visible_options.set((0, 0));
self.state.pending.remove(self.state.current_idx);
if self.state.current_idx >= self.state.pending.len() {
self.state.current_idx = 0;

View File

@@ -16,6 +16,7 @@ fn editor() -> AsyncQuestions {
/*disable_paste_burst*/ true,
RuntimeKeymap::defaults(),
);
editor.next_hint = Some(crate::key_hint::alt(KeyCode::Up).into());
editor.append(
"message",
&[
@@ -88,6 +89,83 @@ fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
.join("\n")
}
#[test]
fn question_wrapped_options_and_other_share_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.restore_current_draft();
let buffer = render_editor(&editor, /*width*/ 36, /*height*/ 16);
insta::assert_snapshot!("question_wrapped_named_option", buffer_text(&buffer));
let options = editor.state.pending[0].question.options.as_mut().unwrap();
options.insert(
0,
"Another suggested answer that wraps over several rows before the selected option".into(),
);
editor.select_option(/*index*/ 1);
let clipped = render_editor(&editor, /*width*/ 36, /*height*/ 10);
insta::assert_snapshot!("question_wrapped_selected_option", buffer_text(&clipped));
render_editor(&editor, /*width*/ 0, /*height*/ 0);
editor.go_next_or_submit();
assert!(editor.submission.is_none());
render_editor(&editor, /*width*/ 36, /*height*/ 6);
editor.go_next_or_submit();
assert!(editor.submission.is_none());
insta::assert_snapshot!(
"question_clipped_choice_rejected",
buffer_text(&render_editor(
&editor, /*width*/ 50, /*height*/ 8
))
);
render_editor(&editor, /*width*/ 80, /*height*/ 20);
editor.go_next_or_submit();
assert!(editor.submission.take().is_some());
editor.append(
"many",
&[question("Bounded", Some(vec!["x".repeat(41); 1000]))],
);
editor.navigate(/*forward*/ true);
assert_eq!(editor.options().len(), 32);
render_editor(&editor, /*width*/ 50, /*height*/ 10);
let height = editor.desired_height(/*width*/ 50);
assert_eq!(editor.visible_options.get().1, 2);
editor.handle_key_event(KeyEvent::from(KeyCode::End));
assert_eq!(editor.desired_height(/*width*/ 50), height);
assert_eq!(
editor.current_answer().unwrap().options_state,
ScrollState {
selected_idx: Some(31),
scroll_top: 32 - editor.visible_options.get().1,
}
);
render_editor(&editor, /*width*/ 80, /*height*/ 80);
assert_eq!(editor.visible_options.get(), (0, 32));
editor.state.pending[editor.state.current_idx]
.question
.title = "Long question ".repeat(3 * 65_536);
assert_eq!(editor.desired_height(/*width*/ 50), u16::MAX);
let clipped = render_editor(&editor, /*width*/ 50, /*height*/ 3);
editor.go_next_or_submit();
assert!(editor.submission.is_none());
insta::assert_snapshot!("question_clipped_prompt", buffer_text(&clipped));
editor.state.pending.last_mut().unwrap().question.title = "Question".into();
for action in ["move_left", "move_right", "cancel"] {
let config = toml::from_str(&format!("[list]\n{action} = '1'")).unwrap();
editor.set_keymap(&RuntimeKeymap::from_config(&config).unwrap());
editor.set_expanded(/*expanded*/ true);
render_editor(&editor, /*width*/ 80, /*height*/ 80);
for modifiers in [KeyModifiers::CONTROL, KeyModifiers::ALT] {
editor.handle_key_event(KeyEvent::new(KeyCode::Char('1'), modifiers));
assert!(editor.submission.is_none());
}
editor.handle_key_event(KeyEvent::from(KeyCode::Char('1')));
assert_eq!(editor.expanded, action != "cancel");
assert!(editor.submission.is_none());
}
}
fn question(title: &str, options: Option<Vec<String>>) -> AsyncUserInputQuestion {
AsyncUserInputQuestion {
title: title.into(),
@@ -101,3 +179,18 @@ fn render_editor(editor: &AsyncQuestions, width: u16, height: u16) -> Buffer {
editor.render(area, &mut buffer);
buffer
}
#[test]
fn existing_cross_context_keymaps_load_without_misleading_submit_hints() {
for action in ["interrupt_turn", "edit_queued_message"] {
let config =
toml::from_str(&format!("[chat]\n{action} = 'f12'\n[list]\naccept = 'f12'")).unwrap();
let keymap = RuntimeKeymap::from_config(&config).unwrap();
let mut editor = editor();
editor.navigate(/*forward*/ true);
editor.set_keymap(&keymap);
insta::allow_duplicates! { insta::assert_snapshot!(editor.footer_lines(/*width*/ 100, /*option_tip*/ None)[0].to_string(), @"ctrl + ] skip ⌥ + ↓ prev question"); }
editor.handle_key_event(KeyEvent::from(KeyCode::F(12)));
assert!(editor.submission.is_none());
}
}

View File

@@ -19,6 +19,14 @@ impl ChatComposer {
self.restore_draft(draft);
}
pub(crate) fn inline_flash(&self) -> Option<Line<'static>> {
self.footer
.flash
.as_ref()
.filter(|_| self.footer.flash_visible())
.map(|flash| flash.line.clone())
}
pub(crate) fn reset_vim_mode(&mut self) {
self.vim_history = VimHistory::default();
self.draft.textarea.enter_vim_insert_mode();

View File

@@ -80,6 +80,41 @@ fn question_count(chat: &ChatWidget) -> usize {
.unanswered_count()
}
#[tokio::test]
async fn selected_answers_preserve_long_labels_and_reject_oversized_submissions() {
for (length, snapshot) in [
(300, "named_question"),
(
codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS,
"oversized_question",
),
] {
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
let label = format!("{} but do not deploy", "x".repeat(length));
chat.add_async_questions(
"message",
&[question("What next?", Some(vec![label.clone()]))],
);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
insta::assert_snapshot!(snapshot, render_bottom_popup(&chat, /*width*/ 80));
let saved = question_count(&chat);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
if length > 512 {
assert_eq!(question_count(&chat), saved);
assert!(ops.try_recv().is_err());
chat.bottom_pane
.handle_paste("x".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(question_count(&chat), saved);
let rendered = render_bottom_popup(&chat, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().find(|line| line.contains("Answer too long")).unwrap(), @" Answer too long; limit 1048562 characters");
} else {
assert_answer(ops.try_recv().unwrap(), &format!("> What next?\n\n{label}"));
}
}
}
fn open_questions(chat: &mut ChatWidget, options: Option<Vec<String>>) {
chat.thread_id = Some(ThreadId::new());
chat.on_agent_message_item_completed(
@@ -125,12 +160,30 @@ async fn question_queue_key_does_not_steer_the_running_turn() {
assert!(ops.try_recv().is_err());
}
#[tokio::test]
async fn question_key_repeats_do_not_consume_another_question() {
for key in [KeyCode::Enter, KeyCode::Char('1')] {
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
open_questions(&mut chat, Some(vec!["First".into()]));
chat.handle_key_event(KeyEvent::from(key));
ops.try_recv().unwrap();
let mut repeat = KeyEvent::from(key);
repeat.kind = KeyEventKind::Repeat;
chat.handle_key_event(repeat);
assert!(ops.try_recv().is_err());
assert_eq!(question_count(&chat), 1);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(ops.try_recv().is_err());
}
}
#[tokio::test]
async fn single_question_spacing_with_working_status() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane.set_task_running(/*running*/ true);
chat.add_async_questions("single", &[question("Only question?", /*options*/ None)]);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.bottom_pane.handle_paste("A typed answer".into());
let rendered = render_bottom_popup(&chat, /*width*/ 80);
let rows: Vec<_> = rendered.lines().collect();
let question = rows
@@ -139,6 +192,8 @@ async fn single_question_spacing_with_working_status() {
.unwrap();
assert!(rows[question - 2].contains("Working"));
assert!(rows[question - 1].is_empty());
assert_eq!(rows[question + 2].trim(), "A typed answer");
assert!(rows[question + 3].is_empty());
insta::assert_snapshot!("single_question_working_spacing", rendered);
}

View File

@@ -7,3 +7,5 @@ expression: "render_bottom_popup(&chat, 80)"
Which way?
Type your answer
enter submit ctrl + ] skip ⌥ + ↓ main prompt ⌥ + ↑ next question

View File

@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "render_bottom_popup(&chat, 80)"
---
What next?
1.
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxx but do not deploy
enter submit ctrl + ] skip ⌥ + ↓ main prompt

View File

@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "render_bottom_popup(&chat, 80)"
---
What next?
Type your answer
enter submit ctrl + ] skip ⌥ + ↓ main prompt

View File

@@ -6,4 +6,6 @@ expression: rendered
Only question?
Type your answer
A typed answer
enter submit ctrl + ] skip ⌥ + ↓ main prompt