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
This commit is contained in:
Benjamin Carlsson
2026-09-02 03:35:41 +00:00
committed by copyberry
parent 02f47d3fb3
commit 5a0419edb5
22 changed files with 634 additions and 53 deletions
+2
View File
@@ -217,6 +217,8 @@ pub struct TuiVimNormalKeymap {
pub open_line_below: Option<KeybindingsSpec>,
/// Open a new line above and enter insert mode (`O`).
pub open_line_above: Option<KeybindingsSpec>,
/// Enter replace mode and overwrite characters under the cursor (`R`).
pub enter_replace_mode: Option<KeybindingsSpec>,
/// Move cursor left (`h`).
pub move_left: Option<KeybindingsSpec>,
/// Move cursor right (`l`).
+10
View File
@@ -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": [
{
+50 -19
View File
@@ -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]
@@ -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"))
);
}
@@ -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",
@@ -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<String>) {
let (submitted, submitted_rx) = std::sync::mpsc::channel();
let view = CustomPromptView::new(
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› ma next "
" "
" "
" "
" "
" "
" "
" 100% context left | Vim: Replace "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› [Pasted Content 1001 chars] "
" "
" "
" "
" "
" "
" "
" 100% context left | Vim: Replace "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› /dide tail "
" "
" "
" "
" "
" "
" "
" /diff show git diff (including untracked files) "
+33 -6
View File
@@ -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<usize>, 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<usize>, 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);
}
@@ -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)]
@@ -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<VimEdit>,
changed: bool,
pub(super) replaying: bool,
replace_steps: Vec<VimReplaceStep>,
}
#[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()
{
@@ -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", "X<image><paste>Y"),
(1, "RX", "a<image><paste>X"),
] {
let mut textarea = vim_textarea("ab", /*cursor*/ 1);
textarea.insert_element("<image>");
textarea.insert_element("<paste>");
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(),
),
("a<image><paste>b", cursor, elements)
);
textarea.input(KeyEvent::from(KeyCode::Backspace));
assert_eq!(
textarea.text(),
if cursor == 0 {
"a<image><paste>b"
} else {
"<image><paste>b"
}
);
assert_eq!(textarea.element_payloads(), vec!["<image>", "<paste>"]);
}
}
#[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);
+38 -10
View File
@@ -184,6 +184,7 @@ pub(crate) struct VimNormalKeymap {
pub(crate) insert_line_start: Vec<KeyBinding>,
pub(crate) open_line_below: Vec<KeyBinding>,
pub(crate) open_line_above: Vec<KeyBinding>,
pub(crate) enter_replace_mode: Vec<KeyBinding>,
pub(crate) move_left: Vec<KeyBinding>,
pub(crate) move_right: Vec<KeyBinding>,
pub(crate) move_up: Vec<KeyBinding>,
@@ -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}"
);
}
}
}
+1
View File
@@ -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,
+2
View File
@@ -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),
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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
@@ -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