From f0b70188a7b9603cd11fe7ca42adc93dde9313ef Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 21 Feb 2026 16:10:44 -0300 Subject: [PATCH] fix(tui): align Vim esc cursor behavior with normal mode Update `tui/src/bottom_pane/textarea.rs` so `Esc` in Vim insert mode moves the cursor to the previous atomic boundary before switching to normal mode. This matches Vim behavior at end-of-line and avoids leaving the cursor on a virtual trailing cell. Add regression coverage in `tui/src/bottom_pane/textarea.rs` and `tui/src/bottom_pane/chat_composer.rs`, plus a `vim_escape_cursor_position` snapshot to lock rendered cursor placement after `Esc`. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 108 ++++++++++++++++++ ...er__tests__vim_escape_cursor_position.snap | 30 +++++ codex-rs/tui/src/bottom_pane/textarea.rs | 69 +++++++++++ 3 files changed, 207 insertions(+) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_escape_cursor_position.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6a21bc1d35..0b3258a1f9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -5163,6 +5163,80 @@ mod tests { } } + #[test] + fn vim_mode_stays_enabled_after_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + true, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + composer.set_vim_enabled(true); + + assert!(composer.textarea.is_vim_enabled()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("h".to_string(), Vec::new(), Vec::new()); + let (result, _) = composer.handle_submission(false); + + assert!(composer.textarea.is_vim_enabled()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + assert!(composer.is_empty()); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, "h"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn esc_switches_vim_insert_to_normal() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + true, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_vim_enabled(true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("hey".to_string(), Vec::new(), Vec::new()); + composer.textarea.set_cursor(composer.textarea.text().len()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + assert_eq!(composer.textarea.cursor(), "hey".len()); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert_eq!(composer.textarea.cursor(), "he".len()); + } + #[test] fn clear_for_ctrl_c_preserves_image_draft_state() { let (tx, _rx) = unbounded_channel::(); @@ -6623,6 +6697,40 @@ mod tests { } } + #[test] + fn vim_escape_cursor_position_snapshot() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + true, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_vim_enabled(true); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("hey you, how are you?".to_string(), Vec::new(), Vec::new()); + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let area = Rect::new(0, 0, 100, 10); + let mut terminal = + Terminal::new(TestBackend::new(area.width, area.height)).expect("create test terminal"); + terminal + .draw(|frame| composer.render(frame.area(), frame.buffer_mut())) + .expect("draw composer"); + let cursor = composer.cursor_pos(area).expect("cursor should be visible"); + let snapshot = format!("cursor={cursor:?}\n{:?}", terminal.backend()); + insta::assert_snapshot!("vim_escape_cursor_position", snapshot); + } + #[test] fn image_placeholder_snapshots() { snapshot_composer_state( diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_escape_cursor_position.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_escape_cursor_position.snap new file mode 100644 index 0000000000..cdbf5b68af --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_escape_cursor_position.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: snapshot +--- +cursor=(22, 1) +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 100, height: 10 }, + content: [ + " ", + "› hey you, how are you? ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " 100% context left | Vim: Normal ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 1, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 67, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 87, y: 9, fg: Magenta, bg: Reset, underline: Reset, modifier: NONE, + x: 98, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 100, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 4739095366..9a4015ec76 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -569,6 +569,9 @@ impl TextArea { fn handle_vim_insert(&mut self, event: KeyEvent) { if matches!(event.code, KeyCode::Esc) { + if self.cursor_pos > 0 { + self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); + } self.vim_mode = VimMode::Normal; self.vim_operator = None; self.preferred_col = None; @@ -1956,6 +1959,72 @@ mod tests { assert_eq!(t.cursor(), elem_start); } + #[test] + fn vim_insert_and_escape() { + let mut t = TextArea::new(); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.text(), "h"); + assert!(!t.is_vim_insert()); + assert_eq!(t.cursor(), 0); + } + + #[test] + fn vim_escape_from_insert_at_start_does_not_underflow() { + let mut t = TextArea::new(); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!t.is_vim_insert()); + assert_eq!(t.cursor(), 0); + } + + #[test] + fn vim_escape_moves_by_grapheme_boundary() { + let mut t = ta_with("👍👍"); + t.set_cursor(t.text().len()); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!t.is_vim_insert()); + assert_eq!(t.cursor(), "👍".len()); + } + + #[test] + fn vim_escape_respects_atomic_element_boundary() { + let mut t = TextArea::new(); + t.insert_str("a"); + t.insert_element(""); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!t.is_vim_insert()); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn vim_delete_word() { + let mut t = ta_with("hello world"); + t.set_cursor(0); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "world"); + assert_eq!(t.kill_buffer, "hello "); + } + #[test] fn delete_backward_word_and_kill_line_variants() { // delete backward word at end removes the whole previous word