mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
committed by
Felipe Coury
parent
cb3e5baa73
commit
f0b70188a7
@@ -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(
|
||||
|
||||
+30
@@ -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) }
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user