diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1058a17123..ec1b00c938 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1344,8 +1344,7 @@ impl ChatComposer { enabled } - /// Return whether Vim editing is enabled for tests that assert mode transitions. - #[cfg(test)] + /// Return whether Vim editing is enabled. pub(crate) fn is_vim_enabled(&self) -> bool { self.draft.textarea.is_vim_enabled() } @@ -1362,14 +1361,7 @@ impl ChatComposer { } fn vim_mode_indicator_span(&self) -> Option> { - self.draft - .textarea - .vim_mode_label() - .map(|label| match label { - "Normal" => "Vim: Normal".magenta(), - "Insert" => "Vim: Insert".green(), - _ => unreachable!(), - }) + self.draft.textarea.vim_mode_indicator_span() } fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option> { diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index bb81af7478..170cd4d9bb 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -13,14 +13,21 @@ use ratatui::widgets::Widget; use std::cell::RefCell; use std::time::Instant; +use crate::key_hint; use crate::key_hint::has_ctrl_or_alt; +use crate::keymap::KeymapContextSet; +use crate::keymap::RuntimeKeymap; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::renderable::Renderable; +use super::popup_consts::accept_cancel_hint_line; use super::popup_consts::standard_popup_hint_line; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; use super::bottom_pane_view::ViewCompletion; +use super::footer::max_left_width_for_right; +use super::footer::render_context_right; use super::paste_burst::PasteBurst; use super::textarea::TextArea; use super::textarea::TextAreaState; @@ -68,19 +75,42 @@ impl CustomPromptView { } } + /// Apply the same editor and Vim bindings used by the main composer. + pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.textarea.set_keymap_bindings(keymap); + } + + /// Enable Vim editing while keeping the prompt ready for immediate input. + pub(crate) fn enable_vim_in_insert_mode(&mut self) { + self.textarea.set_vim_enabled(/*enabled*/ true); + self.textarea.enter_vim_insert_mode(); + } + fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) { + if self.textarea.is_vim_operator_pending() { + self.textarea.input(key_event); + return; + } + match key_event { KeyEvent { code: KeyCode::Esc, .. } => { - self.on_ctrl_c(); + if self.textarea.should_handle_vim_insert_escape(key_event) { + self.textarea.input(key_event); + self.paste_burst.clear_after_explicit_paste(); + } else { + self.on_ctrl_c(); + } } KeyEvent { code: KeyCode::Enter, modifiers, .. } => { - if self.paste_burst.direct_insert_newline_should_insert(now) { + if self.textarea.allows_paste_burst() + && self.paste_burst.direct_insert_newline_should_insert(now) + { self.paste_burst.extend_window(now); self.textarea.insert_str("\n"); return; @@ -126,6 +156,10 @@ impl CustomPromptView { } impl BottomPaneView for CustomPromptView { + fn keymap_contexts(&self) -> KeymapContextSet { + KeymapContextSet::new(self.textarea.keymap_context()) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { self.handle_key_event_at(key_event, Instant::now()); } @@ -135,6 +169,10 @@ impl BottomPaneView for CustomPromptView { CancellationEvent::Handled } + fn prefer_esc_to_handle_key_event(&self) -> bool { + self.textarea.uses_vim_insert_cursor() || self.textarea.is_vim_operator_pending() + } + fn is_complete(&self) -> bool { self.completion.is_some() } @@ -253,15 +291,37 @@ impl Renderable for CustomPromptView { let hint_y = hint_blank_y.saturating_add(1); if hint_y < area.y.saturating_add(area.height) { - Paragraph::new(standard_popup_hint_line()).render( - Rect { - x: area.x, - y: hint_y, - width: area.width, - height: 1, - }, - buf, - ); + let mut hint_line = if self.textarea.uses_vim_insert_cursor() { + accept_cancel_hint_line( + Some(key_hint::plain(KeyCode::Enter).into()), + "to confirm", + Some(key_hint::plain(KeyCode::Esc).into()), + "to enter normal mode", + ) + } else { + standard_popup_hint_line() + }; + let hint_area = Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }; + let mut vim_mode = self.textarea.vim_mode_indicator_span().map(Line::from); + let available_hint_width = vim_mode + .as_ref() + .and_then(|line| max_left_width_for_right(hint_area, line.width() as u16)) + .filter(|width| *width > 0); + if vim_mode.is_some() && available_hint_width.is_none() { + vim_mode = None; + } + if let Some(width) = available_hint_width { + hint_line = truncate_line_with_ellipsis_if_overflow(hint_line, width as usize); + } + Paragraph::new(hint_line).render(hint_area, buf); + if let Some(line) = vim_mode { + render_context_right(hint_area, buf, &line); + } } } @@ -284,6 +344,14 @@ impl Renderable for CustomPromptView { let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) } + + fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { + if self.textarea.uses_vim_insert_cursor() { + crossterm::cursor::SetCursorStyle::SteadyBar + } else { + crossterm::cursor::SetCursorStyle::DefaultUserShape + } + } } impl CustomPromptView { diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs index 61ab7e6cba..4160acfa3c 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs @@ -1,4 +1,7 @@ use super::*; +use crate::key_hint; +use crate::keymap::KeymapContext; +use crate::keymap::RuntimeKeymap; use pretty_assertions::assert_eq; use std::sync::mpsc::Receiver; @@ -74,6 +77,139 @@ fn delayed_enter_after_typing_submits() { assert!(view.is_complete()); } +#[test] +fn vim_insert_escape_clears_paste_burst_before_enter() { + let (mut view, submitted_rx) = custom_prompt_view(); + let now = Instant::now(); + view.enable_vim_in_insert_mode(); + + for (idx, ch) in "abc".chars().enumerate() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(idx)); + } + view.handle_key_event_at(KeyEvent::from(KeyCode::Esc), now + elapsed(/*ms*/ 3)); + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(/*ms*/ 4)); + + assert_eq!(submitted_rx.try_recv(), Ok("abc".to_string())); + assert_eq!(view.completion(), Some(ViewCompletion::Accepted)); +} + +#[test] +fn vim_prompt_hint_tracks_escape_behavior() { + let (mut view, _submitted_rx) = custom_prompt_view(); + let rendered_hint = |view: &CustomPromptView, width: u16| { + let area = Rect::new(0, 0, width, 5); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + (0..area.width) + .map(|x| buf[(x, area.height - 1)].symbol()) + .collect::() + .trim_end() + .to_string() + }; + let vim_color = |view: &CustomPromptView| { + let area = Rect::new(0, 0, 80, 5); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + buf[(67, 4)].style().fg + }; + + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back"); + view.enable_vim_in_insert_mode(); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to enter normal mode Vim: Insert"); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 60), @"Press enter to confirm or esc to enter norm… Vim: Insert"); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 14), @"Press enter to"); + assert_eq!(vim_color(&view), Some(ratatui::style::Color::Green)); + + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back Vim: Normal"); + assert_eq!(vim_color(&view), Some(ratatui::style::Color::Magenta)); +} + +#[test] +fn vim_insert_cursor_tracks_mode_and_normal_mode_commands() { + let (mut view, _submitted_rx) = custom_prompt_view(); + let area = Rect::new(0, 0, 80, 10); + view.textarea.set_text_clearing_elements("rename"); + view.textarea.set_cursor("rename".len()); + let mut config = codex_config::types::TuiKeymap::default(); + config.editor.move_left = Some(codex_config::types::KeybindingsSpec::One( + codex_config::types::KeybindingSpec("f2".to_string()), + )); + let mut keymap = RuntimeKeymap::from_config(&config).expect("valid editor remap"); + keymap.vim_normal.delete_char = vec![key_hint::plain(KeyCode::F(3))]; + view.set_keymap_bindings(&keymap); + view.handle_key_event(KeyEvent::from(KeyCode::F(2))); + assert_eq!(view.textarea.cursor(), "rename".len() - 1); + view.textarea.set_cursor("rename".len()); + + assert!(matches!( + view.cursor_style(area), + crossterm::cursor::SetCursorStyle::DefaultUserShape + )); + + view.enable_vim_in_insert_mode(); + assert!(view.prefer_esc_to_handle_key_event()); + assert!(view.keymap_contexts().contains(KeymapContext::Editor)); + assert!(matches!( + view.cursor_style(area), + crossterm::cursor::SetCursorStyle::SteadyBar + )); + + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert!(!view.is_complete()); + assert!(!view.prefer_esc_to_handle_key_event()); + assert!(view.keymap_contexts().contains(KeymapContext::VimNormal)); + assert!(matches!( + view.cursor_style(area), + crossterm::cursor::SetCursorStyle::DefaultUserShape + )); + + for key_code in [KeyCode::Enter, KeyCode::Esc] { + view.handle_key_event(KeyEvent::from(KeyCode::Char('d'))); + assert!(view.prefer_esc_to_handle_key_event()); + assert!(view.keymap_contexts().contains(KeymapContext::VimOperator)); + view.handle_key_event(KeyEvent::from(key_code)); + assert!(!view.is_complete()); + assert!(!view.prefer_esc_to_handle_key_event()); + assert_eq!(view.textarea.text(), "rename"); + } + + view.handle_key_event(KeyEvent::from(KeyCode::Char('d'))); + view.handle_key_event(KeyEvent::from(KeyCode::Char('i'))); + assert!( + view.keymap_contexts() + .contains(KeymapContext::VimTextObject) + ); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + view.handle_key_event(KeyEvent::from(KeyCode::Char('h'))); + view.handle_key_event(KeyEvent::from(KeyCode::F(3))); + assert_eq!(view.textarea.text(), "renae"); + + view.handle_key_event(KeyEvent::from(KeyCode::Char('i'))); + assert!(matches!( + view.cursor_style(area), + crossterm::cursor::SetCursorStyle::SteadyBar + )); + view.handle_key_event(KeyEvent::from(KeyCode::Char('m'))); + assert_eq!(view.textarea.text(), "rename"); + + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert!(view.is_complete()); + assert_eq!(view.completion(), Some(ViewCompletion::Cancelled)); +} + +#[test] +fn escape_without_vim_cancels_prompt() { + let (mut view, _submitted_rx) = custom_prompt_view(); + + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!(view.is_complete()); + assert_eq!(view.completion(), Some(ViewCompletion::Cancelled)); +} + fn custom_prompt_view() -> (CustomPromptView, Receiver) { let (submitted, submitted_rx) = std::sync::mpsc::channel(); let view = CustomPromptView::new( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 69296480f9..d2869815ac 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1380,7 +1380,6 @@ impl BottomPane { self.composer.is_empty() } - #[cfg(test)] pub(crate) fn composer_is_vim_enabled(&self) -> bool { self.composer.is_vim_enabled() } @@ -1455,6 +1454,15 @@ impl BottomPane { self.push_view(view); } + /// Show a text prompt with the composer's current editing preferences. + pub(crate) fn show_text_prompt(&mut self, mut view: custom_prompt_view::CustomPromptView) { + view.set_keymap_bindings(&self.keymap); + if self.composer_is_vim_enabled() { + view.enable_vim_in_insert_mode(); + } + self.push_view(Box::new(view)); + } + /// Called when the agent requests user approval. pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { let request = if let Some(view) = self.view_stack.last_mut() { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 61f1df1b9f..80d0f4071e 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -33,6 +33,8 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Span; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; use std::borrow::Cow; @@ -356,6 +358,7 @@ impl TextArea { /// `None` means Vim editing is disabled, so callers should omit the mode /// indicator rather than rendering an insert-mode label for normal /// non-modal editing. + #[cfg(test)] pub(crate) fn vim_mode_label(&self) -> Option<&'static str> { if !self.vim_enabled { return None; @@ -366,6 +369,17 @@ impl TextArea { }) } + /// Return the styled footer indicator for the active Vim editing mode. + pub(crate) fn vim_mode_indicator_span(&self) -> Option> { + if !self.vim_enabled { + return None; + } + Some(match self.vim_mode { + VimMode::Normal => "Vim: Normal".magenta(), + VimMode::Insert => "Vim: Insert".green(), + }) + } + pub fn text(&self) -> &str { &self.text } diff --git a/codex-rs/tui/src/chatwidget/goal_menu.rs b/codex-rs/tui/src/chatwidget/goal_menu.rs index d8e36c2486..a3358c2550 100644 --- a/codex-rs/tui/src/chatwidget/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/goal_menu.rs @@ -33,7 +33,7 @@ impl ChatWidget { }); }), ); - self.bottom_pane.show_view(Box::new(view)); + self.bottom_pane.show_text_prompt(view); } pub(crate) fn show_resume_paused_goal_prompt( diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index f08da8e455..dffb1e37df 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -357,8 +357,7 @@ impl ChatWidget { tx.set_thread_name(name); }), ); - - self.bottom_pane.show_view(Box::new(view)); + self.bottom_pane.show_text_prompt(view); } pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool { diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index b8b8c62bc5..06f5e7be11 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -303,7 +303,7 @@ impl ChatWidget { }); }), ); - self.bottom_pane.show_view(Box::new(view)); + self.bottom_pane.show_text_prompt(view); } pub(crate) fn open_marketplace_add_loading_popup(&mut self, _source: &str) { diff --git a/codex-rs/tui/src/chatwidget/review_popups.rs b/codex-rs/tui/src/chatwidget/review_popups.rs index b069649ef3..846434d46f 100644 --- a/codex-rs/tui/src/chatwidget/review_popups.rs +++ b/codex-rs/tui/src/chatwidget/review_popups.rs @@ -142,7 +142,7 @@ impl ChatWidget { }); }), ); - self.bottom_pane.show_view(Box::new(view)); + self.bottom_pane.show_text_prompt(view); } } diff --git a/codex-rs/tui/src/chatwidget/transcript_export.rs b/codex-rs/tui/src/chatwidget/transcript_export.rs index a12686b487..9253cc9b06 100644 --- a/codex-rs/tui/src/chatwidget/transcript_export.rs +++ b/codex-rs/tui/src/chatwidget/transcript_export.rs @@ -68,7 +68,7 @@ impl ChatWidget { }); }), ); - self.bottom_pane.show_view(Box::new(view)); + self.bottom_pane.show_text_prompt(view); self.request_redraw(); } }