diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index d774841d74..f8e30e67e5 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -329,6 +329,10 @@ pub struct TuiVimTextObjectKeymap { pub single_quote: Option, /// Text object: backticks. pub backtick: Option, + /// Text object: sentence. + pub sentence: Option, + /// Text object: paragraph. + pub paragraph: Option, /// Cancel the pending text-object command. pub cancel: Option, } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 23c7c2bfbd..3cfcc6390d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2884,7 +2884,9 @@ "brackets": null, "cancel": null, "double_quote": null, + "paragraph": null, "parentheses": null, + "sentence": null, "single_quote": null, "word": null } @@ -3594,7 +3596,9 @@ "brackets": null, "cancel": null, "double_quote": null, + "paragraph": null, "parentheses": null, + "sentence": null, "single_quote": null, "word": null } @@ -4284,6 +4288,14 @@ ], "description": "Text object: double quotes." }, + "paragraph": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: paragraph." + }, "parentheses": { "allOf": [ { @@ -4292,6 +4304,14 @@ ], "description": "Text object: parentheses." }, + "sentence": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: sentence." + }, "single_quote": { "allOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 2fe1879844..ab34f33849 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -2840,6 +2840,75 @@ mod tests { assert_eq!(t.kill_buffer, ""); } + #[test] + fn vim_sentence_text_objects_cover_change_and_around_delete() { + let mut t = ta_with("One. Hello (world)! Next?"); + t.set_cursor(/*pos*/ "One. Hello (".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "One. Next?"); + assert_eq!(t.kill_buffer, "Hello (world)!"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("One. Is this ok?) Next."); + t.set_cursor(/*pos*/ "One. Is ".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "One. Next."); + assert_eq!(t.kill_buffer, "Is this ok?) "); + } + + #[test] + fn vim_paragraph_text_objects_cover_multiline_delete_and_change() { + let mut t = ta_with("one\nline\n\nsecond\nline\n\nthird"); + t.set_cursor(/*pos*/ "one\n".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "\n\nsecond\nline\n\nthird"); + assert_eq!(t.kill_buffer, "one\nline"); + + let mut t = ta_with("one\nline\n\nsecond\nline\n\nthird"); + t.set_cursor(/*pos*/ "one\nline\n\nsecond".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\nline\n\nthird"); + assert_eq!(t.kill_buffer, "second\nline\n\n"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_sentence_text_objects_preserve_unicode_and_atomic_elements() { + let mut t = ta_with("Hi 👍! Use @file now? Done."); + let mention_start = "Hi 👍! Use ".len(); + t.add_element_range(mention_start..mention_start + "@file".len()) + .expect("valid element"); + t.set_cursor(/*pos*/ mention_start); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "Hi 👍! Done."); + assert_eq!(t.kill_buffer, "Use @file now? "); + } + #[test] fn vim_text_object_cancellation_does_not_edit() { let mut t = ta_with("hello world"); diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim.rs b/codex-rs/tui/src/bottom_pane/textarea/vim.rs index 02053170ee..5d79269a82 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -5,6 +5,7 @@ use crossterm::event::KeyEvent; use std::ops::Range; mod find; +mod prose; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum VimMode { @@ -78,6 +79,8 @@ pub(super) enum VimTextObject { DoubleQuote, SingleQuote, Backtick, + Sentence, + Paragraph, } impl TextArea { @@ -127,6 +130,12 @@ impl TextArea { if self.vim_text_object_keymap.backtick.is_pressed(event) { return Some(VimTextObject::Backtick); } + if self.vim_text_object_keymap.sentence.is_pressed(event) { + return Some(VimTextObject::Sentence); + } + if self.vim_text_object_keymap.paragraph.is_pressed(event) { + return Some(VimTextObject::Paragraph); + } None } @@ -144,6 +153,8 @@ impl TextArea { VimTextObject::DoubleQuote => self.quoted_text_object_range(scope, '"'), VimTextObject::SingleQuote => self.quoted_text_object_range(scope, '\''), VimTextObject::Backtick => self.quoted_text_object_range(scope, '`'), + VimTextObject::Sentence => self.sentence_text_object_range(scope), + VimTextObject::Paragraph => self.paragraph_text_object_range(scope), } } diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim/prose.rs b/codex-rs/tui/src/bottom_pane/textarea/vim/prose.rs new file mode 100644 index 0000000000..1f5d2c5a05 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim/prose.rs @@ -0,0 +1,138 @@ +use super::super::TextArea; +use super::VimTextObjectScope; +use std::ops::Range; + +impl TextArea { + pub(in super::super) fn sentence_text_object_range( + &self, + scope: VimTextObjectScope, + ) -> Option> { + let inner = self.prose_range_at_cursor(self.sentence_ranges())?; + Some(self.expand_prose_range(scope, inner)) + } + + pub(in super::super) fn paragraph_text_object_range( + &self, + scope: VimTextObjectScope, + ) -> Option> { + let inner = self.prose_range_at_cursor(self.paragraph_ranges())?; + Some(self.expand_prose_range(scope, inner)) + } + + fn sentence_ranges(&self) -> Vec> { + let mut ranges = Vec::new(); + let mut start = self.skip_prose_separator(/*pos*/ 0); + let mut pos = start; + while pos < self.text.len() { + let Some(ch) = self.text[pos..].chars().next() else { + break; + }; + let next = pos + ch.len_utf8(); + if !self.is_inside_element(pos) && matches!(ch, '.' | '!' | '?') { + let end = self.sentence_closing_punctuation_end(next); + if end == self.text.len() + || self.text[end..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + ranges.push(start..end); + start = self.skip_prose_separator(end); + pos = start; + continue; + } + } + pos = next; + } + if start < self.text.len() { + ranges.push(start..self.text.len()); + } + ranges + } + + fn sentence_closing_punctuation_end(&self, mut pos: usize) -> usize { + while pos < self.text.len() && !self.is_inside_element(pos) { + let Some(ch) = self.text[pos..].chars().next() else { + break; + }; + if !matches!(ch, ')' | ']' | '}' | '"' | '\'' | '`') { + break; + } + pos += ch.len_utf8(); + } + pos + } + + fn paragraph_ranges(&self) -> Vec> { + let mut ranges = Vec::new(); + let mut block_start = None; + let mut block_end = 0; + let mut line_start = 0; + loop { + let line_end = self.text[line_start..] + .find('\n') + .map_or(self.text.len(), |offset| line_start + offset); + if self.line_has_prose_content(line_start..line_end) { + block_start.get_or_insert(line_start); + block_end = line_end; + } else if let Some(start) = block_start.take() { + ranges.push(start..block_end); + } + if line_end == self.text.len() { + break; + } + line_start = line_end + '\n'.len_utf8(); + } + if let Some(start) = block_start { + ranges.push(start..block_end); + } + ranges + } + + fn line_has_prose_content(&self, range: Range) -> bool { + self.text[range.clone()] + .char_indices() + .any(|(offset, ch)| !ch.is_whitespace() || self.is_inside_element(range.start + offset)) + } + + fn prose_range_at_cursor(&self, ranges: Vec>) -> Option> { + ranges + .iter() + .find(|range| range.start <= self.cursor_pos && self.cursor_pos <= range.end) + .cloned() + .or_else(|| { + ranges + .iter() + .find(|range| self.cursor_pos < range.start) + .cloned() + }) + .or_else(|| ranges.last().cloned()) + } + + fn expand_prose_range(&self, scope: VimTextObjectScope, inner: Range) -> Range { + match scope { + VimTextObjectScope::Inner => inner, + VimTextObjectScope::Around => { + let following = self.following_whitespace_end(inner.end); + if following > inner.end { + inner.start..following + } else { + self.preceding_whitespace_start(inner.start)..inner.end + } + } + } + } + + fn skip_prose_separator(&self, mut pos: usize) -> usize { + while pos < self.text.len() && !self.is_inside_element(pos) { + let Some(ch) = self.text[pos..].chars().next() else { + break; + }; + if !ch.is_whitespace() { + break; + } + pos += ch.len_utf8(); + } + pos + } +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 5c68df2288..083c8155b7 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -216,6 +216,8 @@ pub(crate) struct VimTextObjectKeymap { pub(crate) double_quote: Vec, pub(crate) single_quote: Vec, pub(crate) backtick: Vec, + pub(crate) sentence: Vec, + pub(crate) paragraph: Vec, pub(crate) cancel: Vec, } @@ -877,7 +879,7 @@ impl RuntimeKeymap { .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); } - let vim_text_object = VimTextObjectKeymap { + let mut vim_text_object = VimTextObjectKeymap { word: resolve_local!(keymap, defaults, vim_text_object, word), big_word: resolve_local!(keymap, defaults, vim_text_object, big_word), parentheses: resolve_local!(keymap, defaults, vim_text_object, parentheses), @@ -886,9 +888,61 @@ impl RuntimeKeymap { double_quote: resolve_local!(keymap, defaults, vim_text_object, double_quote), single_quote: resolve_local!(keymap, defaults, vim_text_object, single_quote), backtick: resolve_local!(keymap, defaults, vim_text_object, backtick), + sentence: resolve_local!(keymap, defaults, vim_text_object, sentence), + paragraph: resolve_local!(keymap, defaults, vim_text_object, paragraph), cancel: resolve_local!(keymap, defaults, vim_text_object, cancel), }; + let configured_vim_text_object_bindings_to_preserve = configured_bindings_to_preserve([ + ( + keymap.vim_text_object.word.as_ref(), + vim_text_object.word.as_slice(), + ), + ( + keymap.vim_text_object.big_word.as_ref(), + vim_text_object.big_word.as_slice(), + ), + ( + keymap.vim_text_object.parentheses.as_ref(), + vim_text_object.parentheses.as_slice(), + ), + ( + keymap.vim_text_object.brackets.as_ref(), + vim_text_object.brackets.as_slice(), + ), + ( + keymap.vim_text_object.braces.as_ref(), + vim_text_object.braces.as_slice(), + ), + ( + keymap.vim_text_object.double_quote.as_ref(), + vim_text_object.double_quote.as_slice(), + ), + ( + keymap.vim_text_object.single_quote.as_ref(), + vim_text_object.single_quote.as_slice(), + ), + ( + keymap.vim_text_object.backtick.as_ref(), + vim_text_object.backtick.as_slice(), + ), + ( + keymap.vim_text_object.cancel.as_ref(), + vim_text_object.cancel.as_slice(), + ), + ]); + + if keymap.vim_text_object.sentence.is_none() { + vim_text_object.sentence.retain(|binding| { + !configured_vim_text_object_bindings_to_preserve.contains(binding) + }); + } + if keymap.vim_text_object.paragraph.is_none() { + vim_text_object.paragraph.retain(|binding| { + !configured_vim_text_object_bindings_to_preserve.contains(binding) + }); + } + let pager = PagerKeymap { scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), scroll_down: resolve_local!(keymap, defaults, pager, scroll_down), @@ -1245,6 +1299,8 @@ impl RuntimeKeymap { ], single_quote: default_bindings![plain(KeyCode::Char('\''))], backtick: default_bindings![plain(KeyCode::Char('`'))], + sentence: default_bindings![plain(KeyCode::Char('s'))], + paragraph: default_bindings![plain(KeyCode::Char('p'))], cancel: default_bindings![plain(KeyCode::Esc)], }, pager: PagerKeymap { @@ -1718,6 +1774,8 @@ impl RuntimeKeymap { ("double_quote", self.vim_text_object.double_quote.as_slice()), ("single_quote", self.vim_text_object.single_quote.as_slice()), ("backtick", self.vim_text_object.backtick.as_slice()), + ("sentence", self.vim_text_object.sentence.as_slice()), + ("paragraph", self.vim_text_object.paragraph.as_slice()), ("cancel", self.vim_text_object.cancel.as_slice()), ], )?; @@ -2548,6 +2606,18 @@ mod tests { assert_eq!(runtime.vim_operator.select_around_text_object, Vec::new()); } + #[test] + fn configured_legacy_vim_text_objects_prune_new_prose_defaults() { + let mut keymap = TuiKeymap::default(); + keymap.vim_text_object.word = Some(one("s")); + keymap.vim_text_object.big_word = Some(one("p")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!(runtime.vim_text_object.sentence, Vec::new()); + assert_eq!(runtime.vim_text_object.paragraph, Vec::new()); + } + #[test] fn explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 187dfda07b..9c8a73edfa 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -179,6 +179,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_text_object", "Vim text object", "double_quote", "Target enclosing double quotes."), action("vim_text_object", "Vim text object", "single_quote", "Target enclosing single quotes."), action("vim_text_object", "Vim text object", "backtick", "Target enclosing backticks."), + action("vim_text_object", "Vim text object", "sentence", "Target the current sentence."), + action("vim_text_object", "Vim text object", "paragraph", "Target the current paragraph."), action("vim_text_object", "Vim text object", "cancel", "Cancel the pending text object."), action("pager", "Pager", "scroll_up", "Scroll up by one row."), action("pager", "Pager", "scroll_down", "Scroll down by one row."), @@ -335,6 +337,8 @@ pub(super) fn binding_slot<'a>( ("vim_text_object", "double_quote") => Some(&mut keymap.vim_text_object.double_quote), ("vim_text_object", "single_quote") => Some(&mut keymap.vim_text_object.single_quote), ("vim_text_object", "backtick") => Some(&mut keymap.vim_text_object.backtick), + ("vim_text_object", "sentence") => Some(&mut keymap.vim_text_object.sentence), + ("vim_text_object", "paragraph") => Some(&mut keymap.vim_text_object.paragraph), ("vim_text_object", "cancel") => Some(&mut keymap.vim_text_object.cancel), ("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up), ("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down), @@ -473,6 +477,8 @@ pub(super) fn bindings_for_action<'a>( ("vim_text_object", "double_quote") => Some(runtime_keymap.vim_text_object.double_quote.as_slice()), ("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()), ("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()), + ("vim_text_object", "sentence") => Some(runtime_keymap.vim_text_object.sentence.as_slice()), + ("vim_text_object", "paragraph") => Some(runtime_keymap.vim_text_object.paragraph.as_slice()), ("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()), ("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()), ("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.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 60b47f6695..4465a12b08 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. - 121 actions, 1 customized, 2 unbound. + 123 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 e93b6ebb46..7673fbf64c 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. - 122 actions, 0 customized, 3 unbound. + 124 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 5ee7af408d..56a6f75f70 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 (121 selectable) +tab: All (123 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) tab: App (9 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (62 selectable) +tab: Vim (64 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 33738302d3..3e51c69e03 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. - 121 actions, 0 customized, 2 unbound. + 123 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 5eead02e51..8840bd5d73 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. - 121 actions, 0 customized, 2 unbound. + 123 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug