mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -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);
|
||||
|
||||
+20
@@ -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
|
||||
+13
@@ -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
|
||||
+11
@@ -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…
|
||||
+15
@@ -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
|
||||
+1
@@ -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
|
||||
|
||||
+16
@@ -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
|
||||
+1
-1
@@ -10,4 +10,4 @@ expression: buffer_text(&clipped)
|
||||
multiple rows
|
||||
|
||||
enter submit ctrl + ] skip
|
||||
option 2/2 ⌥ + ↓ main prompt
|
||||
option 2/3 ⌥ + ↓ main prompt
|
||||
|
||||
+13
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+15
@@ -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
|
||||
+15
@@ -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
|
||||
+22
@@ -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
|
||||
+20
@@ -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
|
||||
Reference in New Issue
Block a user