diff --git a/codex-rs/tui/src/bottom_pane/async_questions/input.rs b/codex-rs/tui/src/bottom_pane/async_questions/input.rs index 79175a7a74..2c815ed5d3 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/input.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/input.rs @@ -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 { - self.composer.footer_flash_delay() - } fn is_in_paste_burst(&self) -> bool { self.composer.is_in_paste_burst() } + + fn next_frame_delay(&self) -> Option { + self.composer.footer_flash_delay() + } } diff --git a/codex-rs/tui/src/bottom_pane/async_questions/layout.rs b/codex-rs/tui/src/bottom_pane/async_questions/layout.rs index d4317d095c..6316ec939e 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/layout.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/layout.rs @@ -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; diff --git a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs index 6fdbdf7d8f..8d7b0c4bfa 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -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, keymap: RuntimeKeymap, + // Ignore autorepeat from the number key that opened Other. + other_selector: Option, 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 { @@ -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 { + 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() } diff --git a/codex-rs/tui/src/bottom_pane/async_questions/render.rs b/codex-rs/tui/src/bottom_pane/async_questions/render.rs index eb4313d880..094de5fbb0 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/render.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/render.rs @@ -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); diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_capped_other.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_capped_other.snap new file mode 100644 index 0000000000..6e0a26e293 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_capped_other.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_named_other.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_named_other.snap new file mode 100644 index 0000000000..bceeebde78 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_named_other.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_narrow_other.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_narrow_other.snap new file mode 100644 index 0000000000..ea22bbc560 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_narrow_other.snap @@ -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… diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_remapped_choice_footer.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_remapped_choice_footer.snap new file mode 100644 index 0000000000..f4a07f722a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_remapped_choice_footer.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_named_option.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_named_option.snap index c32deb7429..ee4c3d579d 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_named_option.snap +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_named_option.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_other.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_other.snap new file mode 100644 index 0000000000..423d83438e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_other.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_selected_option.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_selected_option.snap index e5efffabc0..5b1c3c3609 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_selected_option.snap +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_selected_option.snap @@ -10,4 +10,4 @@ expression: buffer_text(&clipped) multiple rows enter submit ctrl + ] skip - option 2/2 ⌥ + ↓ main prompt + option 2/3 ⌥ + ↓ main prompt diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__selected_other_placeholder.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__selected_other_placeholder.snap new file mode 100644 index 0000000000..324419be1e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__selected_other_placeholder.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs index 3363b29ae8..692b60de4e 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs @@ -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>) -> AsyncUserInputQuestion { AsyncUserInputQuestion { title: title.into(), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index 3f767d3ca9..892e8d00fd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -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)) } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs index f8f49ca495..2800465429 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/questions_tests.rs b/codex-rs/tui/src/chatwidget/tests/questions_tests.rs index 76ce8b1d7b..5852a451da 100644 --- a/codex-rs/tui/src/chatwidget/tests/questions_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/questions_tests.rs @@ -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>) { 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>) -> 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)); + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__named_question.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__named_question.snap index cb4c1c64b2..ad8a373fcb 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__named_question.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__named_question.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Left.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Left.snap new file mode 100644 index 0000000000..1e3b65e3a9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Left.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap new file mode 100644 index 0000000000..4c459c6d42 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_other.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_other.snap new file mode 100644 index 0000000000..194b67103b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_other.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_single.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_single.snap new file mode 100644 index 0000000000..9e0ce2c531 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_single.snap @@ -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