From 5a0419edb5ad720ae31fa1adf8b3b24c8f0c52c5 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Wed, 2 Sep 2026 03:25:14 +0000 Subject: [PATCH] Add Vim replace mode to the TUI composer (#42194) ## What changed - Add Vim Replace mode, entered with `R`, which overwrites graphemes and appends at the end of a line. - Restore overwritten text with Backspace and record replace edits for undo and dot-repeat. - Preserve composer behavior for attachments, completions, paste bursts, history previews, and external editor imports while replacing text. - Expose `vim_normal.enter_replace_mode` in the configurable TUI keymap. ## Testing - Add coverage for grapheme replacement, Backspace recovery, dot-repeat, custom bindings, atomic attachments, completions, paste handling, and history interactions. GitOrigin-RevId: cb04e3adcce013c1eb7b82c994b00e944310c481 --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 69 +++++--- .../chat_composer/vim_history_tests.rs | 145 ++++++++++++++++- .../tui/src/bottom_pane/custom_prompt_view.rs | 5 +- .../bottom_pane/custom_prompt_view_tests.rs | 29 ++++ ..._tests__vim_replace_completion_suffix.snap | 13 ++ ...hat_composer__tests__vim_replace_mode.snap | 13 ++ ...er__tests__vim_replace_slash_recovery.snap | 13 ++ codex-rs/tui/src/bottom_pane/textarea.rs | 39 ++++- codex-rs/tui/src/bottom_pane/textarea/vim.rs | 2 + .../src/bottom_pane/textarea/vim_commands.rs | 131 ++++++++++++++- .../textarea/vim_commands_tests.rs | 151 ++++++++++++++++++ codex-rs/tui/src/keymap.rs | 48 ++++-- codex-rs/tui/src/keymap/bindings.rs | 1 + codex-rs/tui/src/keymap_setup/actions.rs | 2 + ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...up__tests__keymap_picker_global_chord.snap | 2 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- 22 files changed, 634 insertions(+), 53 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_completion_suffix.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_mode.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 54348c4971..fd37e9f820 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -217,6 +217,8 @@ pub struct TuiVimNormalKeymap { pub open_line_below: Option, /// Open a new line above and enter insert mode (`O`). pub open_line_above: Option, + /// Enter replace mode and overwrite characters under the cursor (`R`). + pub enter_replace_mode: Option, /// Move cursor left (`h`). pub move_left: Option, /// Move cursor right (`l`). diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e1bc30dc85..64c1cfda8c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3927,6 +3927,7 @@ "delete_char": null, "delete_to_line_end": null, "enter_insert": null, + "enter_replace_mode": null, "find_backward": null, "find_forward": null, "insert_line_start": null, @@ -4755,6 +4756,7 @@ "delete_char": null, "delete_to_line_end": null, "enter_insert": null, + "enter_replace_mode": null, "find_backward": null, "find_forward": null, "insert_line_start": null, @@ -5100,6 +5102,14 @@ ], "description": "Enter insert mode at cursor (`i`)." }, + "enter_replace_mode": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter replace mode and overwrite characters under the cursor (`R`)." + }, "find_backward": { "allOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 953f2e9263..2735840aae 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -88,6 +88,9 @@ //! //! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is //! running, `Tab` submits just like Enter so input is never dropped. +//! Vim Replace shares Insert's composer actions; only textarea editing differs. +//! Literal completion/paste edits discard stale Replace recovery; subsequent typing records anew. +//! Token markers added during Replace are removed when restoring overwritten characters. //! `Tab` does not submit when entering a `!` shell command. //! //! On submit/queue paths, the composer: @@ -1233,7 +1236,8 @@ impl ChatComposer { && self.image_paste_enabled() && self.handle_paste_image_path(&pasted) { - self.draft.textarea.insert_str(" "); + let cursor = self.draft.textarea.cursor(); + self.draft.textarea.insert_str_at(cursor, " "); } else { self.insert_str(&pasted); } @@ -1336,7 +1340,7 @@ impl ChatComposer { } self.attachments.local_images = kept_images; - // Rebuild textarea so placeholders become elements again. + // Import literally so placeholders remain atomic and Replace recovery starts empty. self.draft.textarea.set_text_clearing_elements(""); let mut remaining: HashMap<&str, usize> = HashMap::new(); for img in &self.attachments.local_images { @@ -1360,14 +1364,14 @@ impl ChatComposer { continue; } if pos > idx { - self.draft.textarea.insert_str(&text[idx..pos]); + self.draft.textarea.insert_str_at(idx, &text[idx..pos]); } self.draft.textarea.insert_element(ph); *count -= 1; idx = pos + ph.len(); } if idx < text.len() { - self.draft.textarea.insert_str(&text[idx..]); + self.draft.textarea.insert_str_at(idx, &text[idx..]); } // Keep local image placeholders normalized in attachment order after the @@ -2113,14 +2117,13 @@ impl ChatComposer { before, retro_chars as usize, ) { - if !grab.grabbed.is_empty() { - self.draft - .textarea - .replace_range(grab.start_byte..safe_cur, ""); + if grab.grabbed.is_empty() + || self.draft.textarea.retract_paste_burst(grab.start_byte) + { + self.draft.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); } - // seed the paste burst buffer with everything (grabbed + new) - self.draft.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); + self.draft.paste_burst.clear_after_explicit_paste(); } // If decide_begin_buffer opted not to start buffering, // fall through to normal insertion below. @@ -2468,12 +2471,12 @@ impl ChatComposer { .next() .is_some_and(|c| !c.is_whitespace()); if separator_precedes_suffix { - self.draft.textarea.insert_str(" "); + self.draft.textarea.insert_str_at(cursor, " "); } else { self.draft.textarea.set_cursor(after_separator); } } else { - self.draft.textarea.insert_str(" "); + self.draft.textarea.insert_str_at(cursor, " "); } } @@ -3735,13 +3738,13 @@ impl ChatComposer { before, retro_chars as usize, ) { - if !grab.grabbed.is_empty() { - self.draft - .textarea - .replace_range(grab.start_byte..safe_cur, ""); + if grab.grabbed.is_empty() + || self.draft.textarea.retract_paste_burst(grab.start_byte) + { + self.draft.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); } - self.draft.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); + self.draft.paste_burst.clear_after_explicit_paste(); } // If decide_begin_buffer opted not to start buffering, // fall through to normal insertion below. @@ -6117,6 +6120,31 @@ mod tests { ); assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); assert!(!composer.footer.esc_backtrack_hint); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Replace".cyan()) + ); + snapshot_composer_state( + "vim_replace_mode", + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_vim_enabled(/*enabled*/ true); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)); + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + }, + ); + composer.insert_str("ok"); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + assert_eq!(composer.current_text(), ""); + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); } #[test] @@ -6620,6 +6648,9 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(style_output(composer.cursor_style(area)), default,); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)); + assert_eq!(style_output(composer.cursor_style(area)), default,); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs index 1c2b265da0..f296f2eb79 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs @@ -14,8 +14,11 @@ use super::super::super::chat_composer_history::HistoryEntry; use super::super::ChatComposer; use super::super::InputResult; use super::super::LARGE_PASTE_CHAR_THRESHOLD; +use super::super::PasteBurst; use super::super::tests::new_test_composer; +use super::super::tests::snapshot_composer_state_with_width; use crate::keymap::RuntimeKeymap; +use crate::slash_command::SlashCommand; fn vim_composer(text: &str) -> ChatComposer { let (mut composer, _receiver) = new_test_composer(); @@ -336,7 +339,11 @@ fn canceled_reverse_history_preview_preserves_vim_undo_and_repeat() { #[test] fn canceled_history_preview_preserves_active_vim_commands() { for query in ["", "archive", "missing"] { - for (command, edited) in [("iX", "XYalpha beta"), ("cwX", "XY beta")] { + for (command, edited) in [ + ("iX", "XYalpha beta"), + ("cwX", "XY beta"), + ("RX", "XYpha beta"), + ] { let mut composer = vim_composer("alpha beta"); composer .history @@ -345,6 +352,11 @@ fn canceled_history_preview_preserves_active_vim_commands() { ctrl_r(&mut composer); keys(&mut composer, query); escape(&mut composer); + if command == "RX" { + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!(composer.current_text(), "alpha beta"); + keys(&mut composer, "X"); + } keys(&mut composer, "Y"); escape(&mut composer); assert_eq!(composer.current_text(), edited); @@ -464,6 +476,13 @@ fn undo_and_redo_restore_deleted_image_attachment() { composer.attach_image(PathBuf::from("example.png")); composer.draft.textarea.set_cursor("alpha ".len()); let before = composer.draft_snapshot(); + keys(&mut composer, "RX"); + ctrl_r(&mut composer); + escape(&mut composer); + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!(composer.draft_snapshot(), before); + escape(&mut composer); + composer.draft.textarea.set_cursor("alpha ".len()); keys(&mut composer, "x"); assert_eq!(composer.current_text(), "alpha "); assert!(composer.local_image_paths().is_empty()); @@ -520,3 +539,127 @@ fn find_jump_and_search_navigation_never_snapshot_the_draft() { escape(&mut composer); assert_eq!(composer.current_text(), "alpha beta\nalpha gamma"); } + +#[test] +fn vim_replace_preserves_slash_completion_and_dispatch() { + let mut composer = vim_composer(""); + keys(&mut composer, "R/dif"); + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Tab)); + assert_eq!(composer.current_text(), "/diff "); + let (result, _) = composer.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(result, InputResult::Command(SlashCommand::Diff)); +} + +#[test] +fn vim_replace_backspace_restores_characters_in_new_slash_element() { + snapshot_composer_state_with_width( + "vim_replace_slash_recovery", + /*width*/ 100, + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_disable_paste_burst(/*disabled*/ true); + composer.set_text_content("abcde tail".into(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + composer.draft.textarea.set_cursor(/*pos*/ 0); + keys(composer, "R/diff"); + assert_eq!(composer.draft.textarea.element_payloads(), vec!["/diff"]); + for expected in [("/dife tail", 4), ("/dide tail", 3)] { + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!( + (composer.current_text(), composer.current_cursor()), + (expected.0.to_string(), expected.1) + ); + } + }, + ); +} + +#[test] +fn vim_editing_modes_use_the_same_large_and_image_paste_handling() { + let temporary = tempfile::tempdir().expect("create image directory"); + let path = temporary.path().join("image.png"); + image::RgbaImage::new(/*width*/ 1, /*height*/ 1) + .save(&path) + .expect("write image"); + for pasted in [ + "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1), + path.to_string_lossy().into_owned(), + ] { + let mut insert = vim_composer("next"); + keys(&mut insert, "i"); + insert.handle_paste(pasted.clone()); + let mut replace = vim_composer("next"); + keys(&mut replace, "R"); + replace.handle_paste(pasted); + assert_eq!(replace.draft_snapshot(), insert.draft_snapshot()); + } +} + +#[test] +fn vim_replace_paste_bursts_preserve_overwritten_text_and_repeat() { + for pasted in ["XYZ", "日\u{3000}語"] { + let mut composer = vim_composer("abcd"); + keys(&mut composer, "R"); + composer.set_disable_paste_burst(/*disabled*/ false); + let now = std::time::Instant::now(); + for ch in pasted.chars() { + let _ = composer.handle_input_basic_with_time(KeyEvent::from(KeyCode::Char(ch)), now); + } + assert!(composer.is_in_paste_burst()); + composer.handle_paste_burst_flush(now + PasteBurst::recommended_active_flush_delay()); + assert_eq!(composer.current_text(), format!("{pasted}d")); + escape(&mut composer); + keys(&mut composer, "u."); + assert_eq!(composer.current_text(), format!("{pasted}d")); + } +} + +#[test] +fn vim_replace_completion_preserves_suffix() { + snapshot_composer_state_with_width( + "vim_replace_completion_suffix", + /*width*/ 100, + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_disable_paste_burst(/*disabled*/ true); + for (text, cursor, command, path) in [ + ("@ma next", 3, "R", "src/main.rs"), + ("abc next", 0, "R@ma", "ma"), + ] { + composer.set_vim_enabled(/*enabled*/ true); + composer.set_text_content(text.into(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(cursor); + keys(composer, command); + composer.insert_selected_file_path(0.."@ma".len(), path); + composer.sync_popups(); + assert_eq!( + (composer.current_text(), composer.current_cursor()), + (format!("{path} next"), path.len() + 1) + ); + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!( + (composer.current_text(), composer.current_cursor()), + (format!("{path} next"), path.len()) + ); + } + }, + ); +} + +#[test] +fn external_editor_import_preserves_replace_backspace_recovery() { + let mut composer = vim_composer("old"); + keys(&mut composer, "RX"); + composer.apply_external_edit("abc".into()); + composer.draft.textarea.set_cursor(/*pos*/ 1); + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!(composer.current_text(), "bc"); + for code in [KeyCode::Char('X'), KeyCode::Backspace] { + let _ = composer.handle_key_event(KeyEvent::from(code)); + } + let textarea = &composer.draft.textarea; + assert_eq!( + (textarea.text(), textarea.vim_mode_label()), + ("bc", Some("Replace")) + ); +} diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index 4a281e3de5..f2c5f953c1 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -206,7 +206,8 @@ impl BottomPaneView for CustomPromptView { } fn prefer_esc_to_handle_key_event(&self) -> bool { - self.textarea.uses_vim_insert_cursor() || self.textarea.is_vim_operator_pending() + self.textarea + .should_handle_vim_insert_escape(KeyEvent::from(KeyCode::Esc)) } fn is_complete(&self) -> bool { @@ -353,7 +354,7 @@ impl Renderable for CustomPromptView { let hint_y = hint_blank_y.saturating_add(1); if hint_y < area.y.saturating_add(area.height) { - let mut hint_line = if self.textarea.uses_vim_insert_cursor() { + let mut hint_line = if self.prefer_esc_to_handle_key_event() { accept_cancel_hint_line( Some(key_hint::plain(KeyCode::Enter).into()), "to confirm", diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs index ae483385c5..403841f413 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs @@ -123,6 +123,8 @@ fn vim_prompt_hint_tracks_escape_behavior() { view.handle_key_event(KeyEvent::from(KeyCode::Esc)); insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back Vim: Normal"); assert_eq!(vim_color(&view), Some(ratatui::style::Color::Magenta)); + view.handle_key_event(KeyEvent::from(KeyCode::Char('R'))); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to enter normal mode Vim: Replace"); } #[test] @@ -195,6 +197,14 @@ fn vim_insert_cursor_tracks_mode_and_normal_mode_commands() { assert_eq!(view.textarea.text(), "rename"); view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + view.handle_key_event(KeyEvent::from(KeyCode::Char('R'))); + assert!(view.prefer_esc_to_handle_key_event()); + view.handle_key_event(KeyEvent::from(KeyCode::Char('x'))); + assert_eq!(view.textarea.text(), "renaxe"); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert!(!view.is_complete()); + assert!(!view.prefer_esc_to_handle_key_event()); + assert_eq!(view.textarea.text(), "renaxe"); view.handle_key_event(KeyEvent::from(KeyCode::Esc)); assert!(view.is_complete()); assert_eq!(view.completion(), Some(ViewCompletion::Cancelled)); @@ -287,6 +297,25 @@ fn background_prefill_survives_cursor_movement() { ); } +#[test] +fn background_prefill_preserves_replace_backspace_recovery() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + view.enable_vim_in_insert_mode(); + for code in [KeyCode::Esc, KeyCode::Char('R')] { + view.handle_key_event(KeyEvent::from(code)); + } + assert!(view.apply_text_suggestion(request_id, Some("abc"))); + for code in [KeyCode::Left, KeyCode::Char('X'), KeyCode::Backspace] { + view.handle_key_event(KeyEvent::from(code)); + } + assert_eq!( + (view.textarea.text(), view.textarea.vim_mode_label()), + ("abc", Some("Replace")) + ); +} + fn custom_prompt_view() -> (CustomPromptView, Receiver) { let (submitted, submitted_rx) = std::sync::mpsc::channel(); let view = CustomPromptView::new( diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_completion_suffix.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_completion_suffix.snap new file mode 100644 index 0000000000..f5b564d2ee --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_completion_suffix.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› ma next " +" " +" " +" " +" " +" " +" " +" 100% context left | Vim: Replace " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_mode.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_mode.snap new file mode 100644 index 0000000000..67e6c51e59 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_mode.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1001 chars] " +" " +" " +" " +" " +" " +" " +" 100% context left | Vim: Replace " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap new file mode 100644 index 0000000000..3620a75299 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /dide tail " +" " +" " +" " +" " +" " +" " +" /diff show git diff (including untracked files) " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 8dd6d331be..e8900ad606 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -318,7 +318,10 @@ impl TextArea { /// Return the keymap context that owns the next editing key. pub(crate) fn keymap_context(&self) -> KeymapContext { - if !self.vim_enabled || self.vim_mode == VimMode::Insert || self.vim_query().is_some() { + if !self.vim_enabled + || matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) + || self.vim_query().is_some() + { return KeymapContext::Editor; } match self.vim_pending { @@ -338,6 +341,7 @@ impl TextArea { if self.vim_enabled { self.vim_mode = VimMode::Insert; self.vim_pending = VimPending::None; + self.clear_vim_replace_recovery(); self.cancel_vim_search(); if self.vim_commands.pending_change.is_empty() && !self.vim_commands.replaying { self.start_vim_edit(VimAction::Insert(VimInsertPosition::Cursor)); @@ -357,6 +361,7 @@ impl TextArea { self.vim_pending = VimPending::None; self.cancel_vim_search(); self.preferred_col = None; + self.clear_vim_replace_recovery(); } } @@ -366,7 +371,7 @@ impl TextArea { /// like `dd` or `yw` remains command input instead of being converted into /// literal text. pub(crate) fn allows_paste_burst(&self) -> bool { - !self.vim_enabled || self.vim_mode == VimMode::Insert + !self.vim_enabled || matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) } /// Return whether rendering should use the insert-mode cursor style. @@ -380,7 +385,8 @@ impl TextArea { /// transition rather than a popup cancel/backtrack or turn-interrupt shortcut. pub(crate) fn should_handle_vim_insert_escape(&self, event: KeyEvent) -> bool { self.vim_enabled - && (self.vim_mode == VimMode::Insert || self.is_vim_operator_pending()) + && (matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) + || self.is_vim_operator_pending()) && event.code == KeyCode::Esc && event.modifiers == KeyModifiers::NONE && matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) @@ -399,6 +405,7 @@ impl TextArea { Some(match self.vim_mode { VimMode::Normal => "Normal", VimMode::Insert => "Insert", + VimMode::Replace => "Replace", }) } @@ -410,6 +417,7 @@ impl TextArea { Some(match self.vim_mode { VimMode::Normal => "Vim: Normal".magenta(), VimMode::Insert => "Vim: Insert".green(), + VimMode::Replace => "Vim: Replace".cyan(), }) } @@ -419,10 +427,15 @@ impl TextArea { pub fn insert_str(&mut self, text: &str) { self.record_vim_inserted_text(text); - self.insert_str_at(self.cursor_pos, text); + if self.is_vim_replace_mode() { + self.replace_vim_text(text); + } else { + self.insert_str_at(self.cursor_pos, text); + } } pub fn insert_str_at(&mut self, pos: usize, text: &str) { + self.clear_vim_replace_recovery(); let pos = self.clamp_pos_for_insertion(pos); self.text.insert_str(pos, text); self.wrap_cache.replace(None); @@ -434,6 +447,12 @@ impl TextArea { } pub fn replace_range(&mut self, range: std::ops::Range, text: &str) { + self.clear_vim_replace_recovery(); + self.replace_range_preserving_recovery(range, text); + } + + // Replace typing and Backspace keep contiguous recovery, but still respect atomic elements. + fn replace_range_preserving_recovery(&mut self, range: Range, text: &str) { let range = self.expand_range_to_element_boundaries(range); self.replace_range_raw(range, text); } @@ -701,10 +720,12 @@ impl TextArea { } let prior_mode = self.vim_mode; match self.vim_mode { - VimMode::Insert => self.handle_vim_insert(event), + VimMode::Insert | VimMode::Replace => self.handle_vim_insert(event), VimMode::Normal => self.handle_vim_normal(event), } - if prior_mode == VimMode::Insert && self.vim_mode == VimMode::Normal { + if matches!(prior_mode, VimMode::Insert | VimMode::Replace) + && self.vim_mode == VimMode::Normal + { self.finish_pending_vim_change(); } } @@ -714,6 +735,12 @@ impl TextArea { self.leave_vim_insert_mode(); return; } + if self.is_vim_replace_mode() + && self.editor_keymap.delete_backward.is_pressed(event) + && self.apply_vim_insert_action(VimAction::RestoreReplacedCharacter) + { + return; + } let keymap = self.editor_keymap.clone(); self.input_with_keymap(event, &keymap); } diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim.rs b/codex-rs/tui/src/bottom_pane/textarea/vim.rs index 8573831398..afd47e7d5d 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -10,6 +10,8 @@ pub(super) enum VimMode { Normal, /// Insert mode routes input through the regular editor keymap until Escape is pressed. Insert, + /// Replace mode overwrites existing graphemes through the regular editor keymap. + Replace, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs index 42df694a76..6502b8a26d 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs @@ -69,6 +69,8 @@ pub(super) enum VimEditTarget { #[derive(Clone, Debug)] pub(super) enum VimAction { Insert(VimInsertPosition), + EnterReplaceMode, + RestoreReplacedCharacter, Delete(VimEditTarget), Change(VimEditTarget), Replace(char), @@ -96,9 +98,101 @@ pub(crate) struct VimCommandState { pub(crate) last_change: Vec, changed: bool, pub(super) replaying: bool, + replace_steps: Vec, +} + +#[derive(Debug)] +struct VimReplaceStep { + // Backspace also retraces attachments skipped before the replacement. + cursor_before: usize, + start: usize, + inserted_len: usize, + original: String, } impl TextArea { + pub(crate) fn is_vim_replace_mode(&self) -> bool { + self.vim_enabled && self.vim_mode == VimMode::Replace + } + + pub(super) fn clear_vim_replace_recovery(&mut self) { + self.vim_commands.replace_steps.clear(); + } + + pub(super) fn replace_vim_text(&mut self, text: &str) { + for grapheme in text.graphemes(/*is_extended*/ true) { + let cursor_before = self.cursor_pos; + while let Some(element) = self + .elements + .iter() + .find(|element| element.range.start == self.cursor_pos) + { + self.set_cursor(element.range.end); + } + let start = self.cursor_pos; + let end = if grapheme == "\n" || start >= self.end_of_current_line() { + start + } else { + self.next_atomic_boundary(start) + }; + let original = self.text[start..end].to_string(); + self.replace_range_preserving_recovery(start..end, grapheme); + self.vim_commands.replace_steps.push(VimReplaceStep { + cursor_before, + start, + inserted_len: grapheme.len(), + original, + }); + } + } + + pub(super) fn restore_vim_replaced_character(&mut self) -> bool { + let steps = &mut self.vim_commands.replace_steps; + let Some(step) = steps + .pop() + .filter(|step| self.cursor_pos == step.start + step.inserted_len) + else { + steps.clear(); + return false; + }; + // Replace skips existing elements, so an overlapping marker was added after typing. + // Unmark it before restoring one character; never expand recovery to the whole token. + self.elements.retain(|element| { + element.range.end <= step.start || element.range.start >= step.start + step.inserted_len + }); + self.replace_range_preserving_recovery( + step.start..step.start + step.inserted_len, + &step.original, + ); + self.set_cursor(step.cursor_before); + true + } + + /// Retract a detected paste prefix without losing overwritten text or crossing attachments. + pub(crate) fn retract_paste_burst(&mut self, start: usize) -> bool { + if self.is_vim_replace_mode() { + let restored_start = self + .vim_commands + .replace_steps + .iter() + .rev() + .take_while(|step| step.start >= start) + .try_fold(self.cursor_pos, |cursor, step| { + (step.start + step.inserted_len == cursor && step.cursor_before == step.start) + .then_some(step.start) + }); + if restored_start != Some(start) { + return false; + } + while self.cursor_pos > start { + self.apply_vim_insert_action(VimAction::RestoreReplacedCharacter); + } + } else { + self.replace_range(start..self.cursor_pos, ""); + } + true + } + pub(crate) fn swap_vim_persistent_state(&mut self, state: &mut VimPersistentState) { std::mem::swap(&mut self.vim_commands, &mut state.commands); std::mem::swap(&mut self.vim_search.last, &mut state.search); @@ -110,14 +204,16 @@ impl TextArea { pub(super) fn record_vim_inserted_text(&mut self, text: &str) { if !self.vim_enabled - || self.vim_mode != VimMode::Insert + || !matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) || self.vim_commands.replaying || self.vim_commands.pending_change.is_empty() || text.is_empty() { return; } - if let Some(VimEdit::Text(pending)) = self.vim_commands.pending_change.last_mut() { + if self.vim_mode == VimMode::Insert + && let Some(VimEdit::Text(pending)) = self.vim_commands.pending_change.last_mut() + { pending.push_str(text); } else { self.vim_commands @@ -127,14 +223,17 @@ impl TextArea { self.vim_commands.changed = true; } - pub(super) fn apply_vim_insert_action(&mut self, action: VimAction) { + pub(super) fn apply_vim_insert_action(&mut self, action: VimAction) -> bool { let recording = self.vim_enabled - && self.vim_mode == VimMode::Insert + && matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) && !self.vim_commands.replaying && !self.vim_commands.pending_change.is_empty(); let prior_len = self.text.len(); - self.apply_vim_editor_action(action.clone()); - let changed = self.text.len() != prior_len; + if !self.apply_vim_editor_action(action.clone()) { + return false; + } + let changed = + self.text.len() != prior_len || matches!(action, VimAction::RestoreReplacedCharacter); let deletion = matches!( action, VimAction::DeleteBackward @@ -151,6 +250,7 @@ impl TextArea { .push(VimEdit::Editor(VimEditorEdit(action))); } self.vim_commands.changed |= recording && changed; + true } pub(super) fn start_vim_edit(&mut self, action: VimAction) -> bool { @@ -185,7 +285,7 @@ impl TextArea { } pub(crate) fn finish_vim_repeat(&mut self) { - if self.vim_mode == VimMode::Insert { + if matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) { self.leave_vim_insert_mode(); } self.vim_pending = VimPending::None; @@ -196,7 +296,7 @@ impl TextArea { match edit { VimEdit::Editor(VimEditorEdit(action)) => self.apply_vim_editor_action(action.clone()), VimEdit::Text(text) => { - if self.vim_mode != VimMode::Insert { + if !matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) { return false; } self.insert_str(text); @@ -206,9 +306,19 @@ impl TextArea { } fn apply_vim_editor_action(&mut self, action: VimAction) -> bool { + // Editor actions invalidate contiguous Replace offsets, including during replay. + if !matches!(action, VimAction::RestoreReplacedCharacter) { + self.clear_vim_replace_recovery(); + } let prior_len = self.text.len(); let is_change = matches!(action, VimAction::Change(_)); match action { + VimAction::EnterReplaceMode => { + self.vim_mode = VimMode::Replace; + } + VimAction::RestoreReplacedCharacter => { + return self.restore_vim_replaced_character(); + } VimAction::Insert(position) => { match position { VimInsertPosition::Cursor => {} @@ -331,6 +441,11 @@ impl TextArea { } pub(super) fn handle_vim_extra_command(&mut self, event: KeyEvent) -> bool { + if self.vim_normal_keymap.enter_replace_mode.is_pressed(event) { + self.start_vim_edit(VimAction::EnterReplaceMode); + return true; + } + if self.vim_normal_keymap.replace_char.is_pressed(event) && self.cursor_pos < self.end_of_current_line() { diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs index 8a6b7ef3bd..a70afc7e02 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs @@ -76,6 +76,157 @@ fn replace_character_preserves_normal_mode_and_grapheme_boundaries() { } } +#[test] +fn replace_mode_overwrites_graphemes_and_appends_at_line_end() { + let mut textarea = vim_textarea("a👩‍💻c\nnext", /*cursor*/ 1); + keys(&mut textarea, "RXY!"); + assert_eq!(textarea.text(), "aXY!\nnext"); + assert_eq!(textarea.vim_mode_label(), Some("Replace")); + assert_eq!( + textarea.keymap_context(), + crate::keymap::KeymapContext::Editor + ); + + escape(&mut textarea); + assert_eq!( + (textarea.vim_mode_label(), textarea.cursor()), + (Some("Normal"), 3) + ); + + let mut textarea = vim_textarea("abc", /*cursor*/ 0); + keys(&mut textarea, "R\nX"); + assert_eq!(textarea.text(), "\nXbc"); + + let mut textarea = vim_textarea("abc def", /*cursor*/ 0); + keys(&mut textarea, "R/?nN"); + assert_eq!(textarea.text(), "/?nNdef"); + escape(&mut textarea); + keys(&mut textarea, "/def\n"); + assert_eq!(textarea.cursor(), 4); +} + +#[test] +fn replace_mode_uses_configured_binding_and_respects_unbinding() { + let mut textarea = vim_textarea("abc", /*cursor*/ 0); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.vim_normal.enter_replace_mode = vec![crate::key_hint::plain(KeyCode::F(2))]; + textarea.set_keymap_bindings(&keymap); + + keys(&mut textarea, "R"); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); + + textarea.input(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + keys(&mut textarea, "X"); + assert_eq!(textarea.text(), "Xbc"); + + escape(&mut textarea); + keymap.vim_normal.enter_replace_mode.clear(); + textarea.set_keymap_bindings(&keymap); + textarea.input(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); +} + +#[test] +fn replace_mode_backspace_restores_original_text() { + let mut textarea = vim_textarea("a👩‍💻", /*cursor*/ 0); + keys(&mut textarea, "RXYZ"); + assert_eq!(textarea.text(), "XYZ"); + + let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + for expected in ["XY", "X👩‍💻", "a👩‍💻", "a👩‍💻"] { + textarea.input(backspace); + assert_eq!(textarea.text(), expected); + } + assert_eq!(textarea.cursor(), 0); + + let mut textarea = vim_textarea("abcd", /*cursor*/ 0); + keys(&mut textarea, "RXY"); + textarea.input(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + textarea.input(backspace); + assert_eq!(textarea.text(), "Ycd"); + + let mut textarea = vim_textarea("abé", /*cursor*/ 0); + keys(&mut textarea, "RXY"); + textarea.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); + textarea.input(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + textarea.input(backspace); + assert_eq!(textarea.text(), ""); + assert_eq!(textarea.vim_mode_label(), Some("Replace")); +} + +#[test] +fn replace_mode_preserves_atomic_attachments() { + for (cursor, command, edited) in [ + (0, "RXY", "XY"), + (1, "RX", "aX"), + ] { + let mut textarea = vim_textarea("ab", /*cursor*/ 1); + textarea.insert_element(""); + textarea.insert_element(""); + textarea.set_cursor(cursor); + let elements = textarea.text_element_snapshots(); + keys(&mut textarea, command); + assert_eq!(textarea.text(), edited); + assert_eq!(textarea.text_element_snapshots(), elements); + assert!(!textarea.retract_paste_burst(/*start*/ 0)); + for _ in command.chars().skip(/*n*/ 1) { + textarea.input(KeyEvent::from(KeyCode::Backspace)); + } + assert_eq!( + ( + textarea.text(), + textarea.cursor(), + textarea.text_element_snapshots(), + ), + ("ab", cursor, elements) + ); + textarea.input(KeyEvent::from(KeyCode::Backspace)); + assert_eq!( + textarea.text(), + if cursor == 0 { + "ab" + } else { + "b" + } + ); + assert_eq!(textarea.element_payloads(), vec!["", ""]); + } +} + +#[test] +fn replace_mode_replays_with_dot() { + let mut textarea = vim_textarea("abc def", /*cursor*/ 0); + keys(&mut textarea, "Rx\u{301}"); + escape(&mut textarea); + assert_eq!(textarea.text(), "x\u{301}c def"); + + keys(&mut textarea, "w."); + assert_eq!(textarea.text(), "x\u{301}c x\u{301}f"); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); + + let mut textarea = vim_textarea("abc def", /*cursor*/ 0); + keys(&mut textarea, "RXY"); + textarea.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + escape(&mut textarea); + assert_eq!(textarea.text(), "Xbc def"); + + keys(&mut textarea, "w."); + assert_eq!(textarea.text(), "Xbc Xef"); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); +} + +#[test] +fn replace_mode_overwrites_explicit_paste() { + let mut textarea = vim_textarea("abcdef", /*cursor*/ 1); + keys(&mut textarea, "R"); + textarea.insert_str("XYZ"); + assert_eq!(textarea.text(), "aXYZef"); + escape(&mut textarea); + + keys(&mut textarea, "0."); + assert_eq!(textarea.text(), "XYZZef"); +} + #[test] fn repeat_replays_delete_replace_and_complete_change() { let mut textarea = vim_textarea("alpha beta gamma", /*cursor*/ 0); diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 2a008a8210..edf521a9d2 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -184,6 +184,7 @@ pub(crate) struct VimNormalKeymap { pub(crate) insert_line_start: Vec, pub(crate) open_line_below: Vec, pub(crate) open_line_above: Vec, + pub(crate) enter_replace_mode: Vec, pub(crate) move_left: Vec, pub(crate) move_right: Vec, pub(crate) move_up: Vec, @@ -723,6 +724,7 @@ impl RuntimeKeymap { insert_line_start: resolve_local!(keymap, defaults, vim_normal, insert_line_start), open_line_below: resolve_local!(keymap, defaults, vim_normal, open_line_below), open_line_above: resolve_local!(keymap, defaults, vim_normal, open_line_above), + enter_replace_mode: resolve_local!(keymap, defaults, vim_normal, enter_replace_mode), move_left: resolve_local!(keymap, defaults, vim_normal, move_left), move_right: resolve_local!(keymap, defaults, vim_normal, move_right), move_up: resolve_local!(keymap, defaults, vim_normal, move_up), @@ -789,6 +791,10 @@ impl RuntimeKeymap { keymap.vim_normal.open_line_above.as_ref(), vim_normal.open_line_above.as_slice(), ), + ( + keymap.vim_normal.enter_replace_mode.as_ref(), + vim_normal.enter_replace_mode.as_slice(), + ), ( keymap.vim_normal.move_left.as_ref(), vim_normal.move_left.as_slice(), @@ -1385,13 +1391,19 @@ impl RuntimeKeymap { keymap.vim_normal.redo.as_ref(), &mut resolved.vim_normal.redo, ), + ( + keymap.vim_normal.enter_replace_mode.as_ref(), + &mut resolved.vim_normal.enter_replace_mode, + ), ] { if setting.is_none() { bindings.retain(|binding| { - !configured.contains(binding) + let (code, modifiers) = binding.parts(); + let event = KeyEvent::new(code, modifiers); + !configured.is_pressed(event) && !resolved.chords.bindings.iter().any(|chord| { chord.action.context.overlaps(KeymapContext::VimNormal) - && chord.chord.prefix.parts() == binding.parts() + && chord.chord.prefix.is_press(event) }) }); } @@ -1534,6 +1546,10 @@ impl RuntimeKeymap { shift(KeyCode::Char('o')), plain(KeyCode::Char('O')) ], + enter_replace_mode: default_bindings![ + shift(KeyCode::Char('r')), + plain(KeyCode::Char('R')) + ], move_left: default_bindings![plain(KeyCode::Char('h')), plain(KeyCode::Left)], move_right: default_bindings![plain(KeyCode::Char('l')), plain(KeyCode::Right)], move_up: default_bindings![plain(KeyCode::Char('k')), plain(KeyCode::Up)], @@ -2842,6 +2858,15 @@ mod tests { expect_conflict(&keymap, "move_left", "start_change_operator"); } + #[test] + fn explicit_replace_mode_binding_conflicts_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("shift-r")); + keymap.vim_normal.enter_replace_mode = Some(one("shift-r")); + + expect_conflict(&keymap, "move_left", "enter_replace_mode"); + } + #[test] fn configured_legacy_vim_normal_bindings_prune_new_substitute_default() { let mut keymap = TuiKeymap::default(); @@ -2996,8 +3021,12 @@ mod tests { } #[test] - fn configured_legacy_bindings_prune_new_history_defaults() { - for key in ["u", "ctrl-r"] { + fn configured_legacy_bindings_prune_new_vim_defaults() { + for (key, new_action) in [ + ("u", "undo"), + ("ctrl-r", "redo"), + ("shift-r", "enter_replace_mode"), + ] { for (context, action, suffix) in [ ("vim_normal", "move_left", ""), ("vim_normal", "move_left", " g"), @@ -3019,12 +3048,11 @@ mod tests { keymap.composer.history_search_previous = Some(KeybindingsSpec::Many(vec![])); } let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); - let bindings = if key == "u" { - runtime.vim_normal.undo - } else { - runtime.vim_normal.redo - }; - assert_eq!(bindings, Vec::new()); + assert_eq!( + bindings_for_action(&runtime, "vim_normal", new_action), + Some([].as_slice()), + "{context}.{action} = {key}{suffix}" + ); } } } diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 4903f63bd4..149f392d26 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -293,6 +293,7 @@ define_runtime_action_bindings! { insert_line_start, open_line_below, open_line_above, + enter_replace_mode, move_left, move_right, move_up, diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 4d84a41b7a..252ba22a9b 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -127,6 +127,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "insert_line_start", "Enter insert mode at the first non-blank character."), action("vim_normal", "Vim normal", "open_line_below", "Open a new line below and enter insert mode."), action("vim_normal", "Vim normal", "open_line_above", "Open a new line above and enter insert mode."), + action("vim_normal", "Vim normal", "enter_replace_mode", "Enter replace mode and overwrite characters."), action("vim_normal", "Vim normal", "move_left", "Move left in Vim normal mode."), action("vim_normal", "Vim normal", "move_right", "Move right in Vim normal mode."), action("vim_normal", "Vim normal", "move_up", "Move up or recall older history in Vim normal mode."), @@ -299,6 +300,7 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "insert_line_start") => Some(&mut keymap.vim_normal.insert_line_start), ("vim_normal", "open_line_below") => Some(&mut keymap.vim_normal.open_line_below), ("vim_normal", "open_line_above") => Some(&mut keymap.vim_normal.open_line_above), + ("vim_normal", "enter_replace_mode") => Some(&mut keymap.vim_normal.enter_replace_mode), ("vim_normal", "move_left") => Some(&mut keymap.vim_normal.move_left), ("vim_normal", "move_right") => Some(&mut keymap.vim_normal.move_right), ("vim_normal", "move_up") => Some(&mut keymap.vim_normal.move_up), 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 1b467c8ab7..843da6280a 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. - 137 actions, 1 customized, 5 unbound. + 138 actions, 1 customized, 5 unbound. [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents 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 52d8426766..0e5ba353d9 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. - 138 actions, 0 customized, 6 unbound. + 139 actions, 0 customized, 6 unbound. [All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents 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 913edbba4f..514b5612a7 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 (137 selectable) +tab: All (138 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (5) (5 selectable) tab: App (14 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (68 selectable) +tab: Vim (69 selectable) tab: Navigation (20 selectable) tab: Agents (5 selectable) tab: Approval (8 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap index 4c79a4fa95..fe855dcb35 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 137 actions, 1 customized, 5 unbound. + 138 actions, 1 customized, 5 unbound. [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug 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 adc5c15800..79c49208fc 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. - 137 actions, 0 customized, 5 unbound. + 138 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents 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 8d25bd8a52..8488ff6496 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. - 137 actions, 0 customized, 5 unbound. + 138 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug