diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 99cb3e50fc7..b05a73a9522 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -221,6 +221,20 @@ pub struct TuiVimNormalKeymap { pub move_line_start: Option, /// Move cursor to end of line (`$`). pub move_line_end: Option, + /// Begin a goto sequence (`g`, then `g` jumps to the top). + pub start_goto_sequence: Option, + /// Move cursor to the end of the text (`G`). + pub jump_bottom: Option, + /// Enter visual mode (`v`). + pub enter_visual: Option, + /// Enter linewise visual mode (`V`). + pub enter_visual_line: Option, + /// Undo the previous text edit (`u`). + pub undo: Option, + /// Redo the previous undone edit (`Ctrl-R`). + pub redo: Option, + /// Repeat the previous Vim normal-mode edit (`.`). + pub repeat_last_edit: Option, /// Delete character under cursor (`x`). pub delete_char: Option, /// Delete character under cursor and enter insert mode (`s`). @@ -245,10 +259,10 @@ pub struct TuiVimNormalKeymap { /// Vim operator-pending keybindings for modal editing inside text areas. /// -/// This context is active only while waiting for a motion after `d` or `y`. -/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing -/// `Esc` cancels the pending operator and returns to normal mode without -/// modifying text. +/// This context is active only while waiting for a motion after `d`, `y`, or +/// `c`. Repeating delete/yank (`dd`, `yy`) targets the entire line; change uses +/// the normal-mode change key (`cc`). Pressing `Esc` cancels the pending +/// operator and returns to normal mode without modifying text. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct TuiVimOperatorKeymap { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2d7fd818c95..5bd5e1dc346 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2842,7 +2842,10 @@ "delete_char": null, "delete_to_line_end": null, "enter_insert": null, + "enter_visual": null, + "enter_visual_line": null, "insert_line_start": null, + "jump_bottom": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -2855,10 +2858,14 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "redo": null, + "repeat_last_edit": null, "start_change_operator": null, "start_delete_operator": null, + "start_goto_sequence": null, "start_yank_operator": null, "substitute_char": null, + "undo": null, "yank_line": null }, "vim_operator": { @@ -3533,7 +3540,10 @@ "delete_char": null, "delete_to_line_end": null, "enter_insert": null, + "enter_visual": null, + "enter_visual_line": null, "insert_line_start": null, + "jump_bottom": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -3546,10 +3556,14 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "redo": null, + "repeat_last_edit": null, "start_change_operator": null, "start_delete_operator": null, + "start_goto_sequence": null, "start_yank_operator": null, "substitute_char": null, + "undo": null, "yank_line": null } }, @@ -3849,6 +3863,22 @@ ], "description": "Enter insert mode at cursor (`i`)." }, + "enter_visual": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter visual mode (`v`)." + }, + "enter_visual_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter linewise visual mode (`V`)." + }, "insert_line_start": { "allOf": [ { @@ -3857,6 +3887,14 @@ ], "description": "Enter insert mode at first non-blank of line (`I`)." }, + "jump_bottom": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to the end of the text (`G`)." + }, "move_down": { "allOf": [ { @@ -3953,6 +3991,22 @@ ], "description": "Paste after cursor (`p`)." }, + "redo": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Redo the previous undone edit (`Ctrl-R`)." + }, + "repeat_last_edit": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat the previous Vim normal-mode edit (`.`)." + }, "start_change_operator": { "allOf": [ { @@ -3969,6 +4023,14 @@ ], "description": "Begin delete operator; next key selects motion (`d`)." }, + "start_goto_sequence": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin a goto sequence (`g`, then `g` jumps to the top)." + }, "start_yank_operator": { "allOf": [ { @@ -3985,6 +4047,14 @@ ], "description": "Delete character under cursor and enter insert mode (`s`)." }, + "undo": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Undo the previous text edit (`u`)." + }, "yank_line": { "allOf": [ { @@ -3998,7 +4068,7 @@ }, "TuiVimOperatorKeymap": { "additionalProperties": false, - "description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d` or `y`. Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.", + "description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d`, `y`, or `c`. Repeating delete/yank (`dd`, `yy`) targets the entire line; change uses the normal-mode change key (`cc`). Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.", "properties": { "cancel": { "allOf": [ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ec3284866d9..381172477c0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1080,6 +1080,8 @@ impl ChatComposer { .vim_mode_label() .map(|label| match label { "Normal" => "Vim: Normal".magenta(), + "Visual" => "Vim: Visual".cyan(), + "Visual Line" => "Vim: Visual Line".cyan(), "Insert" => "Vim: Insert".green(), _ => unreachable!(), }) @@ -5397,6 +5399,34 @@ mod tests { assert!(!composer.footer.esc_backtrack_hint); } + #[test] + fn vim_visual_mode_indicator_renders_without_panic() { + 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( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_vim_enabled(/*enabled*/ true); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!(needs_redraw); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Visual".cyan()) + ); + } + #[test] fn slash_opens_command_popup_in_vim_normal_mode() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index f67c3f8787a..26533c0b680 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -26,6 +26,7 @@ use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; +use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; @@ -41,9 +42,11 @@ use self::vim::VimMode; use self::vim::VimMotion; use self::vim::VimOperator; use self::vim::VimPending; +use self::vim::VimRepeatEdit; use self::vim::VimTextObjectScope; const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; +const MAX_UNDO_SNAPSHOTS: usize = 100; fn is_word_separator(ch: char) -> bool { WORD_SEPARATORS.contains(ch) @@ -82,6 +85,14 @@ struct TextElement { name: Option, } +#[derive(Debug, Clone)] +struct TextAreaUndoSnapshot { + text: String, + cursor_pos: usize, + elements: Vec, + next_element_id: u64, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TextElementSnapshot { pub(crate) id: u64, @@ -110,6 +121,10 @@ pub(crate) struct TextArea { vim_enabled: bool, vim_mode: VimMode, vim_pending: VimPending, + vim_visual_anchor: Option, + vim_last_edit: Option, + undo_stack: Vec, + redo_stack: Vec, editor_keymap: EditorKeymap, vim_normal_keymap: VimNormalKeymap, vim_operator_keymap: VimOperatorKeymap, @@ -151,6 +166,10 @@ impl TextArea { vim_enabled: false, vim_mode: VimMode::Insert, vim_pending: VimPending::None, + vim_visual_anchor: None, + vim_last_edit: None, + undo_stack: Vec::new(), + redo_stack: Vec::new(), editor_keymap: defaults.editor, vim_normal_keymap: defaults.vim_normal, vim_operator_keymap: defaults.vim_operator, @@ -220,6 +239,11 @@ impl TextArea { self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.wrap_cache.replace(None); self.preferred_col = None; + self.vim_visual_anchor = None; + self.vim_pending = VimPending::None; + self.vim_last_edit = None; + self.undo_stack.clear(); + self.redo_stack.clear(); } /// Enable or disable modal Vim editing for the textarea. @@ -231,6 +255,7 @@ impl TextArea { pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { self.vim_enabled = enabled; self.vim_pending = VimPending::None; + self.vim_visual_anchor = None; self.vim_mode = if enabled { VimMode::Normal } else { @@ -278,6 +303,7 @@ impl TextArea { if self.vim_enabled { self.vim_mode = VimMode::Insert; self.vim_pending = VimPending::None; + self.vim_visual_anchor = None; } } @@ -291,6 +317,7 @@ impl TextArea { if self.vim_enabled { self.vim_mode = VimMode::Normal; self.vim_pending = VimPending::None; + self.vim_visual_anchor = None; self.preferred_col = None; } } @@ -311,12 +338,15 @@ impl TextArea { /// Return whether Escape should be intercepted before composer-level routing. /// - /// In Vim insert mode, Escape is an editing transition rather than a popup - /// cancel/backtrack shortcut. Letting the composer handle it first would - /// close UI surfaces while leaving the textarea in insert mode. + /// In Vim insert or visual mode, Escape is an editing transition rather than + /// a popup cancel/backtrack shortcut. Letting the composer handle it first + /// would close UI surfaces while leaving the textarea in a modal state. pub(crate) fn should_handle_vim_insert_escape(&self, event: KeyEvent) -> bool { self.vim_enabled - && self.vim_mode == VimMode::Insert + && matches!( + self.vim_mode, + VimMode::Insert | VimMode::Visual | VimMode::VisualLine + ) && event.code == KeyCode::Esc && event.modifiers == KeyModifiers::NONE && matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) @@ -333,6 +363,8 @@ impl TextArea { } Some(match self.vim_mode { VimMode::Normal => "Normal", + VimMode::Visual => "Visual", + VimMode::VisualLine => "Visual Line", VimMode::Insert => "Insert", }) } @@ -346,6 +378,10 @@ impl TextArea { } pub fn insert_str_at(&mut self, pos: usize, text: &str) { + if text.is_empty() { + return; + } + self.record_undo_snapshot(); let pos = self.clamp_pos_for_insertion(pos); self.text.insert_str(pos, text); self.wrap_cache.replace(None); @@ -370,6 +406,7 @@ impl TextArea { if removed_len == 0 && inserted_len == 0 { return; } + self.record_undo_snapshot(); let diff = inserted_len as isize - removed_len as isize; self.text.replace_range(range, text); @@ -489,6 +526,53 @@ impl TextArea { self.end_of_line(self.cursor_pos) } + fn record_undo_snapshot(&mut self) { + self.undo_stack.push(self.current_undo_snapshot()); + if self.undo_stack.len() > MAX_UNDO_SNAPSHOTS { + self.undo_stack.remove(0); + } + self.redo_stack.clear(); + } + + fn current_undo_snapshot(&self) -> TextAreaUndoSnapshot { + TextAreaUndoSnapshot { + text: self.text.clone(), + cursor_pos: self.cursor_pos, + elements: self.elements.clone(), + next_element_id: self.next_element_id, + } + } + + fn undo_last_edit(&mut self) { + let Some(snapshot) = self.undo_stack.pop() else { + return; + }; + let current = self.current_undo_snapshot(); + self.redo_stack.push(current); + self.restore_undo_snapshot(snapshot); + } + + fn redo_last_undo(&mut self) { + let Some(snapshot) = self.redo_stack.pop() else { + return; + }; + let current = self.current_undo_snapshot(); + self.undo_stack.push(current); + self.restore_undo_snapshot(snapshot); + } + + fn restore_undo_snapshot(&mut self, snapshot: TextAreaUndoSnapshot) { + self.text = snapshot.text; + self.cursor_pos = snapshot.cursor_pos.min(self.text.len()); + self.elements = snapshot.elements; + self.next_element_id = snapshot.next_element_id; + self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + self.vim_visual_anchor = None; + self.vim_pending = VimPending::None; + self.wrap_cache.replace(None); + self.preferred_col = None; + } + pub fn input(&mut self, event: KeyEvent) { // Only process key presses or repeats; ignore releases to avoid inserting // characters on key-up events when modifiers are no longer reported. @@ -625,6 +709,8 @@ impl TextArea { match self.vim_mode { VimMode::Insert => self.handle_vim_insert(event), VimMode::Normal => self.handle_vim_normal(event), + VimMode::Visual => self.handle_vim_visual(event), + VimMode::VisualLine => self.handle_vim_visual_line(event), } } @@ -642,17 +728,8 @@ impl TextArea { } fn handle_vim_normal(&mut self, event: KeyEvent) { - let pending = std::mem::replace(&mut self.vim_pending, VimPending::None); - match pending { - VimPending::None => {} - VimPending::Operator(op) => { - self.handle_vim_operator(op, event); - return; - } - VimPending::TextObject { operator, scope } => { - self.handle_vim_text_object(operator, scope, event); - return; - } + if self.handle_vim_pending(event) { + return; } if self.vim_normal_keymap.enter_insert.is_pressed(event) { @@ -696,44 +773,36 @@ impl TextArea { self.vim_mode = VimMode::Insert; return; } - if self.vim_normal_keymap.move_left.is_pressed(event) { - self.move_cursor_left(); - return; - } - if self.vim_normal_keymap.move_right.is_pressed(event) { - self.move_cursor_right(); - return; - } - if self.vim_normal_keymap.move_down.is_pressed(event) { - self.move_cursor_down(); - return; - } - if self.vim_normal_keymap.move_up.is_pressed(event) { - self.move_cursor_up(); + if self.apply_vim_normal_motion(event) { return; } - if self.vim_normal_keymap.move_word_forward.is_pressed(event) { - self.set_cursor(self.beginning_of_next_word()); + if self.vim_normal_keymap.enter_visual.is_pressed(event) { + self.vim_visual_anchor = Some(self.cursor_pos); + self.vim_mode = VimMode::Visual; + self.vim_pending = VimPending::None; return; } - if self.vim_normal_keymap.move_word_backward.is_pressed(event) { - self.set_cursor(self.beginning_of_previous_word()); + if self.vim_normal_keymap.enter_visual_line.is_pressed(event) { + self.vim_visual_anchor = Some(self.cursor_pos); + self.vim_mode = VimMode::VisualLine; + self.vim_pending = VimPending::None; return; } - if self.vim_normal_keymap.move_word_end.is_pressed(event) { - self.set_cursor(self.vim_word_end_cursor()); + if self.vim_normal_keymap.undo.is_pressed(event) { + self.undo_last_edit(); return; } - if self.vim_normal_keymap.move_line_start.is_pressed(event) { - self.set_cursor(self.beginning_of_current_line()); + if self.vim_normal_keymap.redo.is_pressed(event) { + self.redo_last_undo(); return; } - if self.vim_normal_keymap.move_line_end.is_pressed(event) { - self.set_cursor(self.vim_line_end_cursor()); + if self.vim_normal_keymap.repeat_last_edit.is_pressed(event) { + self.repeat_last_vim_edit(); return; } if self.vim_normal_keymap.delete_char.is_pressed(event) { self.delete_forward_kill(/*n*/ 1); + self.record_last_vim_edit(VimRepeatEdit::DeleteChar); return; } if self.vim_normal_keymap.substitute_char.is_pressed(event) { @@ -758,6 +827,7 @@ impl TextArea { } if self.vim_normal_keymap.paste_after.is_pressed(event) { self.paste_after_cursor(); + self.record_last_vim_edit(VimRepeatEdit::PasteAfter); return; } if self @@ -785,15 +855,205 @@ impl TextArea { } } + fn handle_vim_visual(&mut self, event: KeyEvent) { + if self.handle_vim_pending(event) { + return; + } + if self.vim_normal_keymap.cancel_operator.is_pressed(event) { + self.enter_vim_normal_mode(); + return; + } + if self.vim_normal_keymap.enter_visual.is_pressed(event) { + self.enter_vim_normal_mode(); + return; + } + if self.vim_normal_keymap.enter_visual_line.is_pressed(event) { + self.vim_mode = VimMode::VisualLine; + return; + } + if self.vim_normal_keymap.start_yank_operator.is_pressed(event) { + if let Some(range) = self.vim_visual_selection_range() { + self.yank_range(range); + } + self.enter_vim_normal_mode(); + return; + } + if self + .vim_normal_keymap + .start_delete_operator + .is_pressed(event) + || self.vim_normal_keymap.delete_char.is_pressed(event) + { + if let Some(range) = self.vim_visual_selection_range() { + self.kill_range(range); + } + self.enter_vim_normal_mode(); + return; + } + if self + .vim_normal_keymap + .start_change_operator + .is_pressed(event) + || self.vim_normal_keymap.substitute_char.is_pressed(event) + { + if let Some(range) = self.vim_visual_selection_range() { + self.kill_range(range); + } + self.enter_vim_insert_mode(); + return; + } + let _ = self.apply_vim_normal_motion(event); + } + + fn handle_vim_visual_line(&mut self, event: KeyEvent) { + if self.handle_vim_pending(event) { + return; + } + if self.vim_normal_keymap.cancel_operator.is_pressed(event) { + self.enter_vim_normal_mode(); + return; + } + if self.vim_normal_keymap.enter_visual_line.is_pressed(event) { + self.enter_vim_normal_mode(); + return; + } + if self.vim_normal_keymap.enter_visual.is_pressed(event) { + self.vim_mode = VimMode::Visual; + return; + } + if self.vim_normal_keymap.start_yank_operator.is_pressed(event) { + if let Some(range) = self.vim_visual_line_selection_range() { + self.yank_line_range(range); + } + self.enter_vim_normal_mode(); + return; + } + if self + .vim_normal_keymap + .start_delete_operator + .is_pressed(event) + || self.vim_normal_keymap.delete_char.is_pressed(event) + { + if let Some(range) = self.vim_visual_line_selection_range() { + self.kill_line_range(range); + } + self.enter_vim_normal_mode(); + return; + } + if self + .vim_normal_keymap + .start_change_operator + .is_pressed(event) + || self.vim_normal_keymap.substitute_char.is_pressed(event) + { + if let Some(range) = self.vim_visual_line_selection_range() { + self.kill_line_range(range); + } + self.enter_vim_insert_mode(); + return; + } + let _ = self.apply_vim_normal_motion(event); + } + + fn handle_vim_pending(&mut self, event: KeyEvent) -> bool { + let pending = std::mem::replace(&mut self.vim_pending, VimPending::None); + match pending { + VimPending::None => false, + VimPending::Goto => { + self.handle_vim_goto(event); + true + } + VimPending::Operator(op) => { + self.handle_vim_operator(op, event); + true + } + VimPending::OperatorGoto(op) => { + self.handle_vim_operator_goto(op, event); + true + } + VimPending::TextObject { operator, scope } => { + self.handle_vim_text_object(operator, scope, event); + true + } + } + } + + fn apply_vim_normal_motion(&mut self, event: KeyEvent) -> bool { + if self.vim_normal_keymap.move_left.is_pressed(event) { + self.move_cursor_left(); + return true; + } + if self.vim_normal_keymap.move_right.is_pressed(event) { + self.move_cursor_right(); + return true; + } + if self.vim_normal_keymap.move_down.is_pressed(event) { + self.move_cursor_down(); + return true; + } + if self.vim_normal_keymap.move_up.is_pressed(event) { + self.move_cursor_up(); + return true; + } + if self.vim_normal_keymap.move_word_forward.is_pressed(event) { + self.set_cursor(self.beginning_of_next_word()); + return true; + } + if self.vim_normal_keymap.move_word_backward.is_pressed(event) { + self.set_cursor(self.beginning_of_previous_word()); + return true; + } + if self.vim_normal_keymap.move_word_end.is_pressed(event) { + self.set_cursor(self.vim_word_end_cursor()); + return true; + } + if self.vim_normal_keymap.move_line_start.is_pressed(event) { + self.set_cursor(self.beginning_of_current_line()); + return true; + } + if self.vim_normal_keymap.move_line_end.is_pressed(event) { + self.set_cursor(self.vim_line_end_cursor()); + return true; + } + if self.vim_normal_keymap.start_goto_sequence.is_pressed(event) { + self.vim_pending = VimPending::Goto; + return true; + } + if self.vim_normal_keymap.jump_bottom.is_pressed(event) { + self.set_cursor(self.vim_normal_end_cursor()); + return true; + } + false + } + + fn handle_vim_goto(&mut self, event: KeyEvent) -> bool { + if self.vim_normal_keymap.start_goto_sequence.is_pressed(event) { + self.set_cursor(/*pos*/ 0); + return true; + } + self.vim_normal_keymap.cancel_operator.is_pressed(event) + } + fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool { if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) { self.kill_current_line(); + self.record_last_vim_edit(VimRepeatEdit::DeleteLine); return true; } if op == VimOperator::Yank && self.vim_operator_keymap.yank_line.is_pressed(event) { self.yank_current_line(); return true; } + if op == VimOperator::Change + && self + .vim_normal_keymap + .start_change_operator + .is_pressed(event) + { + self.kill_current_line(); + self.vim_mode = VimMode::Insert; + return true; + } if self.vim_operator_keymap.cancel.is_pressed(event) { return true; } @@ -805,15 +1065,29 @@ impl TextArea { return true; } - if op != VimOperator::Change - && let Some(motion) = self.vim_motion_for_event(event) - { + if let Some(motion) = self.vim_motion_for_event(event) { self.apply_vim_operator(op, motion); return true; } + if self.vim_normal_keymap.start_goto_sequence.is_pressed(event) { + self.vim_pending = VimPending::OperatorGoto(op); + return true; + } + if self.vim_normal_keymap.jump_bottom.is_pressed(event) { + self.apply_vim_operator(op, VimMotion::BufferEnd); + return true; + } false } + fn handle_vim_operator_goto(&mut self, op: VimOperator, event: KeyEvent) -> bool { + if self.vim_normal_keymap.start_goto_sequence.is_pressed(event) { + self.apply_vim_operator(op, VimMotion::BufferStart); + return true; + } + self.vim_normal_keymap.cancel_operator.is_pressed(event) + } + fn handle_vim_text_object( &mut self, op: VimOperator, @@ -876,9 +1150,15 @@ impl TextArea { return; }; match op { - VimOperator::Delete => self.kill_range(range), + VimOperator::Delete => { + self.kill_range(range); + self.record_last_vim_edit(VimRepeatEdit::DeleteMotion(motion)); + } VimOperator::Yank => self.yank_range(range), - VimOperator::Change => {} + VimOperator::Change => { + self.kill_range(range); + self.vim_mode = VimMode::Insert; + } } } @@ -893,6 +1173,25 @@ impl TextArea { } } + fn record_last_vim_edit(&mut self, edit: VimRepeatEdit) { + self.vim_last_edit = Some(edit); + } + + fn repeat_last_vim_edit(&mut self) { + let Some(edit) = self.vim_last_edit else { + return; + }; + match edit { + VimRepeatEdit::DeleteChar => self.delete_forward_kill(/*n*/ 1), + VimRepeatEdit::DeleteLine => self.kill_current_line(), + VimRepeatEdit::DeleteMotion(motion) => { + self.apply_vim_operator(VimOperator::Delete, motion) + } + VimRepeatEdit::PasteAfter => self.paste_after_cursor(), + } + self.vim_last_edit = Some(edit); + } + fn range_for_motion(&mut self, motion: VimMotion) -> Option> { if matches!(motion, VimMotion::Up | VimMotion::Down) { return self.linewise_range_for_vertical_motion(motion); @@ -940,7 +1239,9 @@ impl TextArea { | VimMotion::WordBackward | VimMotion::WordEnd | VimMotion::LineStart - | VimMotion::LineEnd => return None, + | VimMotion::LineEnd + | VimMotion::BufferStart + | VimMotion::BufferEnd => return None, }; (range.start < range.end).then_some(range) } @@ -958,6 +1259,8 @@ impl TextArea { VimMotion::WordEnd => self.set_cursor(self.vim_word_end_exclusive()), VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()), VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()), + VimMotion::BufferStart => self.set_cursor(/*pos*/ 0), + VimMotion::BufferEnd => self.set_cursor(self.text.len()), } let target = self.cursor_pos; self.cursor_pos = original_cursor; @@ -1413,6 +1716,10 @@ impl TextArea { let removed_len = end - start; let inserted_len = new.len(); + if removed_len == inserted_len && self.text.get(range.clone()) == Some(new) { + return true; + } + self.record_undo_snapshot(); let diff = inserted_len as isize - removed_len as isize; self.text.replace_range(range, new); @@ -1930,12 +2237,67 @@ impl TextArea { } scroll } + + fn vim_visual_selection_range(&self) -> Option> { + let anchor = self.vim_visual_anchor?; + if self.text.is_empty() { + return None; + } + let anchor = anchor.min(self.vim_normal_end_cursor()); + let cursor = self.cursor_pos.min(self.vim_normal_end_cursor()); + let start = anchor.min(cursor); + let end_cursor = anchor.max(cursor); + let end = self.next_atomic_boundary(end_cursor); + (start < end).then_some(start..end) + } + + fn vim_visual_line_selection_range(&self) -> Option> { + let anchor = self.vim_visual_anchor?; + if self.text.is_empty() { + return None; + } + let cursor = self.cursor_pos.min(self.vim_normal_end_cursor()); + let anchor_start = self.beginning_of_line(anchor.min(self.text.len())); + let cursor_start = self.beginning_of_line(cursor); + let start = anchor_start.min(cursor_start); + let selected_line_start = anchor_start.max(cursor_start); + let eol = self.end_of_line(selected_line_start); + let end = if eol < self.text.len() { eol + 1 } else { eol }; + (start < end).then_some(start..end) + } + + fn vim_selection_range_for_mode(&self) -> Option> { + match self.vim_mode { + VimMode::Visual => self.vim_visual_selection_range(), + VimMode::VisualLine => self.vim_visual_line_selection_range(), + VimMode::Normal | VimMode::Insert => None, + } + } + + fn combine_highlights_with_visual( + &self, + highlights: &[(Range, Style)], + ) -> Vec<(Range, Style)> { + let mut combined = highlights.to_vec(); + if let Some(range) = self.vim_selection_range_for_mode() { + combined.push((range, Style::default().add_modifier(Modifier::REVERSED))); + } + combined + } } impl WidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let lines = self.wrapped_lines(area.width); - self.render_lines(area, buf, &lines, 0..lines.len(), Style::default(), &[]); + let highlights = self.combine_highlights_with_visual(&[]); + self.render_lines( + area, + buf, + &lines, + 0..lines.len(), + Style::default(), + &highlights, + ); } } @@ -1949,7 +2311,8 @@ impl StatefulWidgetRef for &TextArea { let start = scroll as usize; let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines(area, buf, &lines, start..end, Style::default(), &[]); + let highlights = self.combine_highlights_with_visual(&[]); + self.render_lines(area, buf, &lines, start..end, Style::default(), &highlights); } } @@ -1988,7 +2351,8 @@ impl TextArea { let start = scroll as usize; let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines(area, buf, &lines, start..end, base_style, highlights); + let highlights = self.combine_highlights_with_visual(highlights); + self.render_lines(area, buf, &lines, start..end, base_style, &highlights); } fn render_lines( @@ -2467,6 +2831,213 @@ mod tests { assert_eq!(t.cursor(), "one\n".len()); } + #[test] + fn vim_gg_jumps_to_start_of_text() { + let mut t = ta_with("one\ntwo\nthree"); + t.set_cursor(/*pos*/ "one\ntwo".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + + assert_eq!(t.cursor(), 0); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_uppercase_g_jumps_to_end_of_text() { + let mut t = ta_with("one\ntwo\nthree"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)); + + assert_eq!(t.cursor(), "one\ntwo\nthree".len() - 1); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_v_enters_visual_mode_and_escape_returns_normal() { + let mut t = ta_with("abc"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + + assert_eq!(t.vim_mode_label(), Some("Visual")); + assert_eq!(t.vim_visual_selection_range(), Some(1..2)); + + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.vim_visual_selection_range(), None); + } + + #[test] + fn vim_visual_motion_extends_selection_and_yanks_range() { + let mut t = ta_with("abcd"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "abcd"); + assert_eq!(t.kill_buffer, "bc"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.vim_visual_selection_range(), None); + } + + #[test] + fn vim_visual_delete_removes_selected_range() { + let mut t = ta_with("abcd"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "ad"); + assert_eq!(t.cursor(), 1); + assert_eq!(t.kill_buffer, "bc"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_visual_change_removes_selection_and_enters_insert_mode() { + let mut t = ta_with("abcd"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "ad"); + assert_eq!(t.cursor(), 1); + assert_eq!(t.kill_buffer, "bc"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_uppercase_v_selects_lines() { + let mut t = ta_with("one\ntwo\nthree"); + t.set_cursor(/*pos*/ "one\n".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\ntwo\nthree"); + assert_eq!(t.kill_buffer, "two\nthree"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_visual_line_delete_removes_whole_lines() { + let mut t = ta_with("one\ntwo\nthree"); + t.set_cursor(/*pos*/ "one\n".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\n"); + assert_eq!(t.cursor(), "one\n".len()); + assert_eq!(t.kill_buffer, "two\nthree"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_visual_goto_extends_selection_to_text_start() { + let mut t = ta_with("one\ntwo\nthree"); + t.set_cursor(/*pos*/ "one\ntwo".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + + assert_eq!(t.cursor(), 0); + assert_eq!(t.vim_mode_label(), Some("Visual")); + assert_eq!( + t.vim_visual_selection_range(), + Some(0.."one\ntwo".len() + 1) + ); + } + + #[test] + fn vim_u_rewinds_previous_text_edit() { + let mut t = ta_with("abcd"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(t.text(), "acd"); + + t.input(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "abcd"); + assert_eq!(t.cursor(), 1); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_u_rewinds_single_large_insert() { + let mut t = TextArea::new(); + t.set_vim_enabled(/*enabled*/ true); + + t.insert_str("one\ntwo\nthree"); + t.input(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + + assert_eq!(t.text(), ""); + assert_eq!(t.cursor(), 0); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_u_rewinds_multiple_edits_and_ctrl_r_redoes() { + let mut t = ta_with("abcd"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(t.text(), "cd"); + + t.input(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + assert_eq!(t.text(), "bcd"); + t.input(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + assert_eq!(t.text(), "abcd"); + + t.input(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert_eq!(t.text(), "bcd"); + t.input(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert_eq!(t.text(), "cd"); + } + + #[test] + fn vim_new_edit_after_undo_clears_redo_stack() { + let mut t = ta_with("abcd"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + + assert_eq!(t.text(), "acd"); + assert_eq!(t.cursor(), 1); + } + #[test] fn vim_delete_word() { let mut t = ta_with("hello world"); @@ -2480,6 +3051,129 @@ mod tests { assert_eq!(t.kill_buffer, "hello "); } + #[test] + fn vim_change_word_deletes_motion_and_enters_insert() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "world"); + assert_eq!(t.kill_buffer, "hello "); + assert_eq!(t.cursor(), 0); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_change_to_buffer_end_with_g_motion() { + let mut t = ta_with("one two three"); + t.set_cursor(/*pos*/ "one ".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one "); + assert_eq!(t.kill_buffer, "two three"); + assert_eq!(t.cursor(), "one ".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_change_current_line_enters_insert() { + let mut t = ta_with("one\ntwo\nthree"); + t.set_cursor(/*pos*/ "one\n".len() + 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\nthree"); + assert_eq!(t.kill_buffer, "two\n"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + assert_eq!(t.cursor(), "one\n".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_delete_to_buffer_end_with_g_motion() { + let mut t = ta_with("one two three"); + t.set_cursor(/*pos*/ "one ".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one "); + assert_eq!(t.kill_buffer, "two three"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_yank_to_buffer_end_with_g_motion() { + let mut t = ta_with("one two three"); + t.set_cursor(/*pos*/ "one ".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one two three"); + assert_eq!(t.kill_buffer, "two three"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_delete_to_buffer_start_with_gg_motion() { + let mut t = ta_with("one two three"); + t.set_cursor(/*pos*/ "one two".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + + assert_eq!(t.text(), " three"); + assert_eq!(t.kill_buffer, "one two"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_repeat_replays_delete_char_and_motion() { + let mut t = ta_with("hello world again"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "llo world again"); + assert_eq!(t.kill_buffer, "e"); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "again"); + assert_eq!(t.kill_buffer, "world "); + } + + #[test] + fn vim_repeat_replays_paste_after() { + let mut t = ta_with("ab"); + t.kill_buffer = "X".to_string(); + t.kill_buffer_kind = KillBufferKind::Characterwise; + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "aXbX"); + } + #[test] fn vim_change_inner_word_deletes_word_and_enters_insert() { let mut t = ta_with("hello world"); @@ -2631,20 +3325,12 @@ mod tests { } #[test] - fn vim_text_object_cancellation_and_unsupported_change_motions_do_not_edit() { + fn vim_text_object_cancellation_consumes_pending_operator() { let mut t = ta_with("hello world"); t.set_cursor(/*pos*/ 1); t.set_vim_enabled(/*enabled*/ true); t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE)); - - assert_eq!(t.text(), "hello world"); - assert_eq!(t.kill_buffer, ""); - assert_eq!(t.vim_mode_label(), Some("Normal")); - assert!(!t.is_vim_operator_pending()); - - t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); assert!(t.is_vim_operator_pending()); t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); @@ -3499,6 +4185,45 @@ mod tests { ); } + #[test] + fn render_visual_selection_applies_highlight_style() { + let mut t = ta_with("abcd"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)); + let area = Rect::new(0, 0, 20, 1); + let mut state = TextAreaState::default(); + let mut buf = Buffer::empty(area); + + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + + assert!( + !buf[(0, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + assert!( + buf[(1, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + assert!( + buf[(2, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + assert!( + !buf[(3, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + } + #[test] fn cursor_pos_with_state_basic_and_scroll_behaviors() { // Case 1: No wrapping needed, height fits — scroll ignored, y maps directly. diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim.rs b/codex-rs/tui/src/bottom_pane/textarea/vim.rs index 2c1edc3fe7e..b17fed412c3 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -8,6 +8,10 @@ use std::ops::Range; pub(super) enum VimMode { /// Normal mode routes printable keys to movement, operators, and mode transitions. Normal, + /// Visual mode extends a characterwise selection with normal-mode motions. + Visual, + /// Visual line mode extends a linewise selection with normal-mode motions. + VisualLine, /// Insert mode routes input through the regular editor keymap until Escape is pressed. Insert, } @@ -22,7 +26,9 @@ pub(super) enum VimOperator { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum VimPending { None, + Goto, Operator(VimOperator), + OperatorGoto(VimOperator), TextObject { operator: VimOperator, scope: VimTextObjectScope, @@ -40,6 +46,16 @@ pub(super) enum VimMotion { WordEnd, LineStart, LineEnd, + BufferStart, + BufferEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimRepeatEdit { + DeleteChar, + DeleteLine, + DeleteMotion(VimMotion), + PasteAfter, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 9a656d84680..a9f06e6d059 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -157,6 +157,13 @@ pub(crate) struct VimNormalKeymap { pub(crate) move_word_end: Vec, pub(crate) move_line_start: Vec, pub(crate) move_line_end: Vec, + pub(crate) start_goto_sequence: Vec, + pub(crate) jump_bottom: Vec, + pub(crate) enter_visual: Vec, + pub(crate) enter_visual_line: Vec, + pub(crate) undo: Vec, + pub(crate) redo: Vec, + pub(crate) repeat_last_edit: Vec, pub(crate) delete_char: Vec, pub(crate) substitute_char: Vec, pub(crate) delete_to_line_end: Vec, @@ -169,12 +176,13 @@ pub(crate) struct VimNormalKeymap { pub(crate) cancel_operator: Vec, } -/// Vim operator-pending keybindings active after `d` or `y` in normal mode. +/// Vim operator-pending keybindings active after `d`, `y`, or `c` in normal mode. /// -/// When an operator (`start_delete_operator` or `start_yank_operator`) is -/// pressed, the next keypress is matched against this context to determine the -/// motion range. Repeating the operator key (`dd`, `yy`) acts on the whole -/// line. `Esc` cancels the pending operator and returns to normal mode. +/// When an operator (`start_delete_operator`, `start_yank_operator`, or +/// `start_change_operator`) is pressed, the next keypress is matched against +/// this context to determine the motion range. Repeating delete/yank (`dd`, +/// `yy`) acts on the whole line; change uses the normal-mode change key +/// (`cc`). `Esc` cancels the pending operator and returns to normal mode. #[derive(Clone, Debug, Default)] pub(crate) struct VimOperatorKeymap { pub(crate) delete_line: Vec, @@ -494,6 +502,13 @@ impl RuntimeKeymap { move_word_end: resolve_local!(keymap, defaults, vim_normal, move_word_end), move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start), move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end), + start_goto_sequence: resolve_local!(keymap, defaults, vim_normal, start_goto_sequence), + jump_bottom: resolve_local!(keymap, defaults, vim_normal, jump_bottom), + enter_visual: resolve_local!(keymap, defaults, vim_normal, enter_visual), + enter_visual_line: resolve_local!(keymap, defaults, vim_normal, enter_visual_line), + undo: resolve_local!(keymap, defaults, vim_normal, undo), + redo: resolve_local!(keymap, defaults, vim_normal, redo), + repeat_last_edit: resolve_local!(keymap, defaults, vim_normal, repeat_last_edit), delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char), substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char), delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end), @@ -577,6 +592,28 @@ impl RuntimeKeymap { keymap.vim_normal.move_line_end.as_ref(), vim_normal.move_line_end.as_slice(), ), + ( + keymap.vim_normal.start_goto_sequence.as_ref(), + vim_normal.start_goto_sequence.as_slice(), + ), + ( + keymap.vim_normal.jump_bottom.as_ref(), + vim_normal.jump_bottom.as_slice(), + ), + ( + keymap.vim_normal.enter_visual.as_ref(), + vim_normal.enter_visual.as_slice(), + ), + ( + keymap.vim_normal.enter_visual_line.as_ref(), + vim_normal.enter_visual_line.as_slice(), + ), + (keymap.vim_normal.undo.as_ref(), vim_normal.undo.as_slice()), + (keymap.vim_normal.redo.as_ref(), vim_normal.redo.as_slice()), + ( + keymap.vim_normal.repeat_last_edit.as_ref(), + vim_normal.repeat_last_edit.as_slice(), + ), ( keymap.vim_normal.delete_char.as_ref(), vim_normal.delete_char.as_slice(), @@ -625,6 +662,41 @@ impl RuntimeKeymap { .substitute_char .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); } + if keymap.vim_normal.start_goto_sequence.is_none() { + vim_normal + .start_goto_sequence + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.jump_bottom.is_none() { + vim_normal + .jump_bottom + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.enter_visual.is_none() { + vim_normal + .enter_visual + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.enter_visual_line.is_none() { + vim_normal + .enter_visual_line + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.undo.is_none() { + vim_normal + .undo + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.redo.is_none() { + vim_normal + .redo + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.repeat_last_edit.is_none() { + vim_normal + .repeat_last_edit + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } let mut vim_operator = VimOperatorKeymap { delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line), @@ -1004,6 +1076,19 @@ impl RuntimeKeymap { plain(KeyCode::Char('$')), shift(KeyCode::Char('$')) ], + start_goto_sequence: default_bindings![plain(KeyCode::Char('g'))], + jump_bottom: default_bindings![ + shift(KeyCode::Char('g')), + plain(KeyCode::Char('G')) + ], + enter_visual: default_bindings![plain(KeyCode::Char('v'))], + enter_visual_line: default_bindings![ + shift(KeyCode::Char('v')), + plain(KeyCode::Char('V')) + ], + undo: default_bindings![plain(KeyCode::Char('u'))], + redo: default_bindings![ctrl(KeyCode::Char('r'))], + repeat_last_edit: default_bindings![plain(KeyCode::Char('.'))], delete_char: default_bindings![plain(KeyCode::Char('x'))], substitute_char: default_bindings![plain(KeyCode::Char('s'))], delete_to_line_end: default_bindings![ @@ -1449,6 +1534,22 @@ impl RuntimeKeymap { self.vim_normal.move_line_start.as_slice(), ), ("move_line_end", self.vim_normal.move_line_end.as_slice()), + ( + "start_goto_sequence", + self.vim_normal.start_goto_sequence.as_slice(), + ), + ("jump_bottom", self.vim_normal.jump_bottom.as_slice()), + ("enter_visual", self.vim_normal.enter_visual.as_slice()), + ( + "enter_visual_line", + self.vim_normal.enter_visual_line.as_slice(), + ), + ("undo", self.vim_normal.undo.as_slice()), + ("redo", self.vim_normal.redo.as_slice()), + ( + "repeat_last_edit", + self.vim_normal.repeat_last_edit.as_slice(), + ), ("delete_char", self.vim_normal.delete_char.as_slice()), ( "substitute_char", @@ -2354,6 +2455,79 @@ mod tests { expect_conflict(&keymap, "move_left", "substitute_char"); } + #[test] + fn configured_legacy_vim_normal_bindings_prune_new_jump_defaults() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("g")); + keymap.vim_normal.move_right = Some(one("shift-g")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.vim_normal.move_left, + vec![key_hint::plain(KeyCode::Char('g'))] + ); + assert_eq!( + runtime.vim_normal.move_right, + vec![key_hint::shift(KeyCode::Char('g'))] + ); + assert_eq!(runtime.vim_normal.start_goto_sequence, Vec::new()); + assert_eq!( + runtime.vim_normal.jump_bottom, + vec![key_hint::plain(KeyCode::Char('G'))] + ); + } + + #[test] + fn explicit_new_vim_normal_jump_bindings_still_conflict_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("g")); + keymap.vim_normal.start_goto_sequence = Some(one("g")); + + expect_conflict(&keymap, "move_left", "start_goto_sequence"); + + keymap.vim_normal.start_goto_sequence = None; + keymap.vim_normal.move_left = Some(one("shift-g")); + keymap.vim_normal.jump_bottom = Some(one("shift-g")); + + expect_conflict(&keymap, "move_left", "jump_bottom"); + } + + #[test] + fn configured_legacy_vim_normal_bindings_prune_new_visual_and_undo_defaults() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("v")); + keymap.vim_normal.move_right = Some(one("u")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.vim_normal.move_left, + vec![key_hint::plain(KeyCode::Char('v'))] + ); + assert_eq!( + runtime.vim_normal.move_right, + vec![key_hint::plain(KeyCode::Char('u'))] + ); + assert_eq!(runtime.vim_normal.enter_visual, Vec::new()); + assert_eq!(runtime.vim_normal.undo, Vec::new()); + } + + #[test] + fn explicit_new_vim_normal_visual_and_undo_bindings_still_conflict_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("v")); + keymap.vim_normal.enter_visual = Some(one("v")); + + expect_conflict(&keymap, "move_left", "enter_visual"); + + keymap.vim_normal.enter_visual = None; + keymap.vim_normal.move_left = Some(one("u")); + keymap.vim_normal.undo = Some(one("u")); + + expect_conflict(&keymap, "move_left", "undo"); + } + #[test] fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() { let mut keymap = TuiKeymap::default(); @@ -2422,6 +2596,25 @@ mod tests { key_hint::plain(KeyCode::Down) ] ); + assert_eq!( + runtime.vim_normal.start_goto_sequence, + vec![key_hint::plain(KeyCode::Char('g'))] + ); + assert_eq!( + runtime.vim_normal.jump_bottom, + vec![ + key_hint::shift(KeyCode::Char('g')), + key_hint::plain(KeyCode::Char('G')) + ] + ); + assert_eq!( + runtime.vim_normal.enter_visual, + vec![key_hint::plain(KeyCode::Char('v'))] + ); + assert_eq!( + runtime.vim_normal.undo, + vec![key_hint::plain(KeyCode::Char('u'))] + ); } #[test] diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index b355cda69f9..66cff0bcb9e 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -134,6 +134,13 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "move_word_end", "Move to the end of the current or next word."), action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."), action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."), + action("vim_normal", "Vim normal", "start_goto_sequence", "Begin a goto sequence; pressing it again jumps to the top."), + action("vim_normal", "Vim normal", "jump_bottom", "Move to the end of the text."), + action("vim_normal", "Vim normal", "enter_visual", "Enter characterwise visual mode."), + action("vim_normal", "Vim normal", "enter_visual_line", "Enter linewise visual mode."), + action("vim_normal", "Vim normal", "undo", "Undo the previous text edit."), + action("vim_normal", "Vim normal", "redo", "Redo the previous undone text edit."), + action("vim_normal", "Vim normal", "repeat_last_edit", "Repeat the previous Vim edit."), action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."), action("vim_normal", "Vim normal", "substitute_char", "Delete the character under the cursor and enter insert mode."), action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."), @@ -277,6 +284,13 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "move_word_end") => Some(&mut keymap.vim_normal.move_word_end), ("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start), ("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end), + ("vim_normal", "start_goto_sequence") => Some(&mut keymap.vim_normal.start_goto_sequence), + ("vim_normal", "jump_bottom") => Some(&mut keymap.vim_normal.jump_bottom), + ("vim_normal", "enter_visual") => Some(&mut keymap.vim_normal.enter_visual), + ("vim_normal", "enter_visual_line") => Some(&mut keymap.vim_normal.enter_visual_line), + ("vim_normal", "undo") => Some(&mut keymap.vim_normal.undo), + ("vim_normal", "redo") => Some(&mut keymap.vim_normal.redo), + ("vim_normal", "repeat_last_edit") => Some(&mut keymap.vim_normal.repeat_last_edit), ("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char), ("vim_normal", "substitute_char") => Some(&mut keymap.vim_normal.substitute_char), ("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end), @@ -402,6 +416,13 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "move_word_end") => Some(runtime_keymap.vim_normal.move_word_end.as_slice()), ("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()), ("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()), + ("vim_normal", "start_goto_sequence") => Some(runtime_keymap.vim_normal.start_goto_sequence.as_slice()), + ("vim_normal", "jump_bottom") => Some(runtime_keymap.vim_normal.jump_bottom.as_slice()), + ("vim_normal", "enter_visual") => Some(runtime_keymap.vim_normal.enter_visual.as_slice()), + ("vim_normal", "enter_visual_line") => Some(runtime_keymap.vim_normal.enter_visual_line.as_slice()), + ("vim_normal", "undo") => Some(runtime_keymap.vim_normal.undo.as_slice()), + ("vim_normal", "redo") => Some(runtime_keymap.vim_normal.redo.as_slice()), + ("vim_normal", "repeat_last_edit") => Some(runtime_keymap.vim_normal.repeat_last_edit.as_slice()), ("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()), ("vim_normal", "substitute_char") => Some(runtime_keymap.vim_normal.substitute_char.as_slice()), ("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()), diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 7969d461ef5..e28cdb56a88 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 108 actions, 1 customized, 2 unbound. + 115 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 34381583321..1bfb30c8813 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 109 actions, 0 customized, 3 unbound. + 116 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 4bb6c4dbfca..bd243be54a2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,14 +2,14 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (108 selectable) +tab: All (115 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) tab: App (10 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (48 selectable) +tab: Vim (55 selectable) tab: Navigation (20 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index a4a65e33214..433078c5d36 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 108 actions, 0 customized, 2 unbound. + 115 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 1138ebbf1eb..d986f154404 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 108 actions, 0 customized, 2 unbound. + 115 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug