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`.
This commit is contained in:
Felipe Coury
2026-04-23 10:35:17 -03:00
committed by Felipe Coury
parent cb3e5baa73
commit f0b70188a7
3 changed files with 207 additions and 0 deletions
@@ -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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
@@ -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::<AppEvent>();
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(
@@ -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) }
+69
View File
@@ -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("<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