diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index f8e30e67e584..7d11247c73c0 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -333,6 +333,8 @@ pub struct TuiVimTextObjectKeymap { pub sentence: Option, /// Text object: paragraph. pub paragraph: Option, + /// Text object: enclosing paired tag. + pub tag: 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 3cfcc6390d58..7170dc085008 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2888,6 +2888,7 @@ "parentheses": null, "sentence": null, "single_quote": null, + "tag": null, "word": null } }, @@ -3600,6 +3601,7 @@ "parentheses": null, "sentence": null, "single_quote": null, + "tag": null, "word": null } } @@ -4320,6 +4322,14 @@ ], "description": "Text object: single quotes." }, + "tag": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: enclosing paired tag." + }, "word": { "allOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index ab34f33849fc..ff7207da2efd 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -2909,6 +2909,66 @@ mod tests { assert_eq!(t.kill_buffer, "Use @file now? "); } + #[test] + fn vim_tag_text_objects_cover_nested_tags_attributes_and_change() { + let mut t = ta_with(r#"
hello
"#); + t.set_cursor(/*pos*/ r#"
he"#.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('t'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "
"); + assert_eq!(t.kill_buffer, r#"hello"#); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("
hello
"); + t.set_cursor(/*pos*/ "
he".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('t'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "
"); + assert_eq!(t.kill_buffer, "hello"); + } + + #[test] + fn vim_tag_text_objects_ignore_nonpaired_forms_and_preserve_elements() { + let text = "@file"; + let mut t = ta_with(text); + let mention_start = text.find("@file").expect("test mention exists"); + 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('t'), KeyModifiers::NONE)); + + assert_eq!( + t.text(), + "" + ); + assert_eq!(t.kill_buffer, "@file"); + + let mut t = ta_with(text); + t.set_cursor(/*pos*/ text.find("bad").expect("test comment exists")); + 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('t'), KeyModifiers::NONE)); + + assert_eq!(t.text(), ""); + assert_eq!( + t.kill_buffer, + "@file" + ); + } + #[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 5d79269a8214..91276a140b15 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -6,6 +6,7 @@ use std::ops::Range; mod find; mod prose; +mod tag; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum VimMode { @@ -81,6 +82,7 @@ pub(super) enum VimTextObject { Backtick, Sentence, Paragraph, + Tag, } impl TextArea { @@ -136,6 +138,9 @@ impl TextArea { if self.vim_text_object_keymap.paragraph.is_pressed(event) { return Some(VimTextObject::Paragraph); } + if self.vim_text_object_keymap.tag.is_pressed(event) { + return Some(VimTextObject::Tag); + } None } @@ -155,6 +160,7 @@ impl TextArea { VimTextObject::Backtick => self.quoted_text_object_range(scope, '`'), VimTextObject::Sentence => self.sentence_text_object_range(scope), VimTextObject::Paragraph => self.paragraph_text_object_range(scope), + VimTextObject::Tag => self.tag_text_object_range(scope), } } diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim/tag.rs b/codex-rs/tui/src/bottom_pane/textarea/vim/tag.rs new file mode 100644 index 000000000000..c440b929ebc0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim/tag.rs @@ -0,0 +1,148 @@ +use super::super::TextArea; +use super::VimTextObjectScope; +use std::ops::Range; + +#[derive(Debug)] +enum ParsedTag { + Open { name: String, range: Range }, + Close { name: String, range: Range }, + Ignored { end: usize }, +} + +impl ParsedTag { + fn end(&self) -> usize { + match self { + Self::Open { range, .. } | Self::Close { range, .. } => range.end, + Self::Ignored { end } => *end, + } + } +} + +impl TextArea { + pub(in super::super) fn tag_text_object_range( + &self, + scope: VimTextObjectScope, + ) -> Option> { + let mut open_tags: Vec<(String, Range)> = Vec::new(); + let mut best = None; + let mut pos = 0; + while let Some(offset) = self.text[pos..].find('<') { + let start = pos + offset; + if self.is_inside_element(start) { + pos = start + '<'.len_utf8(); + continue; + } + let Some(tag) = self.parse_tag(start) else { + pos = start + '<'.len_utf8(); + continue; + }; + pos = tag.end(); + match tag { + ParsedTag::Open { name, range } => open_tags.push((name, range)), + ParsedTag::Close { name, range } => { + let Some((open_name, open_range)) = open_tags.last() else { + continue; + }; + if *open_name != name { + continue; + } + let open_range = open_range.clone(); + open_tags.pop(); + if open_range.start <= self.cursor_pos && self.cursor_pos <= range.end { + let candidate = match scope { + VimTextObjectScope::Inner => open_range.end..range.start, + VimTextObjectScope::Around => open_range.start..range.end, + }; + if best + .as_ref() + .is_none_or(|current: &Range| candidate.len() < current.len()) + { + best = Some(candidate); + } + } + } + ParsedTag::Ignored { .. } => {} + } + } + best + } + + fn parse_tag(&self, start: usize) -> Option { + let rest = &self.text[start..]; + if rest.starts_with("").map(|offset| ParsedTag::Ignored { + end: start + offset + "-->".len(), + }); + } + if rest.starts_with("'.len_utf8()].trim().is_empty() { + return None; + } + return Some(ParsedTag::Close { + name, + range: start..end, + }); + } + let body = self.text[pos..end - '>'.len_utf8()].trim_end(); + if body.ends_with('/') { + return Some(ParsedTag::Ignored { end }); + } + Some(ParsedTag::Open { + name, + range: start..end, + }) + } + + fn tag_end(&self, mut pos: usize) -> Option { + let mut quote = None; + while let Some(ch) = self.text[pos..].chars().next() { + if self.is_inside_element(pos) { + return None; + } + if let Some(open_quote) = quote { + if ch == open_quote { + quote = None; + } + } else if matches!(ch, '"' | '\'') { + quote = Some(ch); + } else if ch == '>' { + return Some(pos + ch.len_utf8()); + } + pos += ch.len_utf8(); + } + None + } +} + +fn is_tag_name_start(ch: char) -> bool { + ch.is_ascii_alphabetic() || ch == '_' +} + +fn is_tag_name_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':') +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 083c8155b7f1..a8b071511a3b 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -218,6 +218,7 @@ pub(crate) struct VimTextObjectKeymap { pub(crate) backtick: Vec, pub(crate) sentence: Vec, pub(crate) paragraph: Vec, + pub(crate) tag: Vec, pub(crate) cancel: Vec, } @@ -890,6 +891,7 @@ impl RuntimeKeymap { 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), + tag: resolve_local!(keymap, defaults, vim_text_object, tag), cancel: resolve_local!(keymap, defaults, vim_text_object, cancel), }; @@ -926,6 +928,14 @@ impl RuntimeKeymap { keymap.vim_text_object.backtick.as_ref(), vim_text_object.backtick.as_slice(), ), + ( + keymap.vim_text_object.sentence.as_ref(), + vim_text_object.sentence.as_slice(), + ), + ( + keymap.vim_text_object.paragraph.as_ref(), + vim_text_object.paragraph.as_slice(), + ), ( keymap.vim_text_object.cancel.as_ref(), vim_text_object.cancel.as_slice(), @@ -942,6 +952,11 @@ impl RuntimeKeymap { !configured_vim_text_object_bindings_to_preserve.contains(binding) }); } + if keymap.vim_text_object.tag.is_none() { + vim_text_object.tag.retain(|binding| { + !configured_vim_text_object_bindings_to_preserve.contains(binding) + }); + } let pager = PagerKeymap { scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), @@ -1301,6 +1316,7 @@ impl RuntimeKeymap { backtick: default_bindings![plain(KeyCode::Char('`'))], sentence: default_bindings![plain(KeyCode::Char('s'))], paragraph: default_bindings![plain(KeyCode::Char('p'))], + tag: default_bindings![plain(KeyCode::Char('t'))], cancel: default_bindings![plain(KeyCode::Esc)], }, pager: PagerKeymap { @@ -1776,6 +1792,7 @@ impl RuntimeKeymap { ("backtick", self.vim_text_object.backtick.as_slice()), ("sentence", self.vim_text_object.sentence.as_slice()), ("paragraph", self.vim_text_object.paragraph.as_slice()), + ("tag", self.vim_text_object.tag.as_slice()), ("cancel", self.vim_text_object.cancel.as_slice()), ], )?; @@ -2618,6 +2635,16 @@ mod tests { assert_eq!(runtime.vim_text_object.paragraph, Vec::new()); } + #[test] + fn configured_existing_vim_text_objects_prune_new_tag_default() { + let mut keymap = TuiKeymap::default(); + keymap.vim_text_object.paragraph = Some(one("t")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!(runtime.vim_text_object.tag, 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 9c8a73edfab9..d1014241afbd 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -181,6 +181,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ 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", "tag", "Target the enclosing paired tag."), 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."), @@ -339,6 +340,7 @@ pub(super) fn binding_slot<'a>( ("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", "tag") => Some(&mut keymap.vim_text_object.tag), ("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), @@ -479,6 +481,7 @@ pub(super) fn bindings_for_action<'a>( ("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", "tag") => Some(runtime_keymap.vim_text_object.tag.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 4465a12b08d9..a6b4e26cd003 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. - 123 actions, 1 customized, 2 unbound. + 124 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 7673fbf64c14..9b659c749fcd 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. - 124 actions, 0 customized, 3 unbound. + 125 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 56a6f75f703e..c383460ceb8b 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 (123 selectable) +tab: All (124 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 (64 selectable) +tab: Vim (65 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 3e51c69e039c..b1371537b7da 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. - 123 actions, 0 customized, 2 unbound. + 124 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 8840bd5d7384..b5811501924b 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. - 123 actions, 0 customized, 2 unbound. + 124 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug