From c15c202d958d2741c11a3af8ac5d0d4f612b6d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Mon, 4 May 2026 02:02:06 -0400 Subject: [PATCH 1/2] fix(vi): move cursor left when exiting insert mode with Escape In Vi, insert mode places the cursor between characters while normal mode places it on a character. Pressing Escape to leave insert mode must move the cursor one position left to match standard Vi behavior. When Escape is pressed from normal mode, the cursor is not moved. Closes #694 (pt 1) --- src/edit_mode/vi/mod.rs | 44 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 721e45d9..216a1fc3 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -156,8 +156,20 @@ impl EditMode for Vi { } (_, KeyModifiers::NONE, KeyCode::Esc) => { self.cache.clear(); + let was_insert = self.mode == ViMode::Insert; self.mode = ViMode::Normal; - ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + // In Vi, exiting insert mode moves the cursor one position + // left because insert mode places the cursor between + // characters while normal mode places it on a character. + if was_insert { + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Esc, + ReedlineEvent::Repaint, + ]) + } else { + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + } } (ViMode::Normal | ViMode::Visual, _, _) => self .normal_keybindings @@ -231,13 +243,41 @@ mod test { use pretty_assertions::assert_eq; #[test] - fn esc_leads_to_normal_mode_test() { + fn esc_from_insert_mode_moves_cursor_left() { let mut vi = Vi::default(); + assert!(matches!(vi.mode, ViMode::Insert)); + + let esc = + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(); + let result = vi.parse_event(esc); + + // Exiting insert mode should move the cursor left (Vi standard + // behavior: insert mode cursor is between characters, normal mode + // cursor is on a character). + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Esc, + ReedlineEvent::Repaint, + ]) + ); + assert!(matches!(vi.mode, ViMode::Normal)); + } + + #[test] + fn esc_from_normal_mode_does_not_move_cursor() { + let mut vi = Vi { + mode: ViMode::Normal, + ..Default::default() + }; let esc = ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) .unwrap(); let result = vi.parse_event(esc); + // Esc from normal mode should NOT move the cursor left. assert_eq!( result, ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) From ffe0c1812ef1953dc86f0449366e6c0d5843f1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Mon, 4 May 2026 02:02:11 -0400 Subject: [PATCH 2/2] fix(vi): preserve prefix history search in normal mode After the Escape cursor fix, the cursor in Vi normal mode sits on the last character rather than after it. This caused is_cursor_at_buffer_end() to return false, breaking prefix history search (regression seen in #772 which led to the revert of #699). Treat "cursor on the last character" as equivalent to "at buffer end" when the editor is in Vi normal mode, so that prefix history search still triggers correctly after pressing Escape. --- src/core_editor/editor.rs | 46 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 0d7673ba..feb64542 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -306,7 +306,16 @@ impl Editor { } pub(crate) fn is_cursor_at_buffer_end(&self) -> bool { - self.line_buffer.insertion_point() == self.get_buffer().len() + let pos = self.line_buffer.insertion_point(); + let len = self.get_buffer().len(); + if pos == len { + return true; + } + // In Vi normal mode the cursor sits *on* the last character rather + // than after it. Treat that position as "at buffer end" so that + // prefix history search still triggers after pressing Escape. + matches!(self.edit_mode, PromptEditMode::Vi(PromptViMode::Normal)) + && self.line_buffer.grapheme_right_index() == len } pub(crate) fn reset_undo_stack(&mut self) { @@ -2168,4 +2177,39 @@ mod test { assert_eq!(bracket_result, expected_bracket); assert_eq!(quote_result, expected_quote); } + + #[test] + fn test_is_cursor_at_buffer_end_vi_normal_mode() { + // In Vi normal mode, the cursor on the last character should count + // as "at buffer end" for prefix history search purposes. + let mut editor = editor_with("ls"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + + // Cursor on last char 's' (position 1) + editor.line_buffer.set_insertion_point(1); + assert!(editor.is_cursor_at_buffer_end()); + + // Cursor on first char 'l' (position 0) — not at end + editor.line_buffer.set_insertion_point(0); + assert!(!editor.is_cursor_at_buffer_end()); + + // Cursor after last char (position 2) — still at end + editor.line_buffer.set_insertion_point(2); + assert!(editor.is_cursor_at_buffer_end()); + } + + #[test] + fn test_is_cursor_at_buffer_end_insert_mode() { + // In insert mode, only cursor after the last character counts. + let mut editor = editor_with("ls"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Insert)); + + // Cursor on last char 's' (position 1) — NOT at end in insert mode + editor.line_buffer.set_insertion_point(1); + assert!(!editor.is_cursor_at_buffer_end()); + + // Cursor after last char (position 2) — at end + editor.line_buffer.set_insertion_point(2); + assert!(editor.is_cursor_at_buffer_end()); + } }