diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index bc7301e659..7e6d92de7d 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -324,6 +324,21 @@ pub struct TuiVimOperatorKeymap { pub cancel: Option, } +/// Search motions shared by Vim normal and operator-pending input. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiVimSearchKeymap { + /// Search forward in the active buffer (`/`). + pub forward: Option, + /// Search backward in the active buffer (`?`). + pub backward: Option, + /// Repeat the accepted search (`n`). + pub next: Option, + /// Repeat in the opposite direction (`N`). + pub previous: Option, +} + /// Vim text-object keybindings for modal editing inside text areas. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] @@ -470,6 +485,8 @@ pub struct TuiKeymap { #[serde(default)] pub vim_operator: TuiVimOperatorKeymap, #[serde(default)] + pub vim_search: TuiVimSearchKeymap, + #[serde(default)] pub vim_text_object: TuiVimTextObjectKeymap, #[serde(default)] pub pager: TuiPagerKeymap, diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 2eb73e577a..ed956cbf89 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -42,6 +42,7 @@ pub use crate::tui_keymap::TuiListKeymap; pub use crate::tui_keymap::TuiPagerKeymap; pub use crate::tui_keymap::TuiVimNormalKeymap; pub use crate::tui_keymap::TuiVimOperatorKeymap; +pub use crate::tui_keymap::TuiVimSearchKeymap; pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9b453c0db9..bf704872e2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3926,6 +3926,12 @@ "select_inner_text_object": null, "yank_line": null }, + "vim_search": { + "backward": null, + "forward": null, + "next": null, + "previous": null + }, "vim_text_object": { "backtick": null, "big_word": null, @@ -4754,6 +4760,19 @@ "yank_line": null } }, + "vim_search": { + "allOf": [ + { + "$ref": "#/definitions/TuiVimSearchKeymap" + } + ], + "default": { + "backward": null, + "forward": null, + "next": null, + "previous": null + } + }, "vim_text_object": { "allOf": [ { @@ -5405,6 +5424,45 @@ }, "type": "object" }, + "TuiVimSearchKeymap": { + "additionalProperties": false, + "description": "Search motions shared by Vim normal and operator-pending input.", + "properties": { + "backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Search backward in the active buffer (`?`)." + }, + "forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Search forward in the active buffer (`/`)." + }, + "next": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat the accepted search (`n`)." + }, + "previous": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat in the opposite direction (`N`)." + } + }, + "type": "object" + }, "TuiVimTextObjectKeymap": { "additionalProperties": false, "description": "Vim text-object keybindings for modal editing inside text areas.", diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c2f24cd3b9..ee05924377 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -59,6 +59,7 @@ //! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the //! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as //! an editable draft and `Esc` restores the draft that was active when search started. +//! Vim queries stay draft-local. //! //! Slash commands are staged for local history instead of being recorded immediately. Command //! recall is a two-phase handoff: stage the submitted slash text here, then record it after @@ -292,6 +293,7 @@ mod footer_state; mod history_search; mod popup_state; mod slash_input; +mod vim_search; use self::attachment_state::AttachmentState; use self::draft_state::ComposerMentionBinding; @@ -937,13 +939,17 @@ impl ChatComposer { /// Return the contexts whose handlers can consume the next composer key. pub(crate) fn keymap_contexts(&self) -> KeymapContextSet { + if self.draft.textarea.vim_query().is_some() { + return KeymapContextSet::new(KeymapContext::Editor); + } if self.history_search.is_some() { return KeymapContextSet::new(KeymapContext::Composer); } if !self.draft.input_enabled || self.popups.active() { return KeymapContextSet::default(); } - KeymapContextSet::new(KeymapContext::Composer).with(self.draft.textarea.keymap_context()) + let contexts = self.draft.textarea.keymap_contexts(); + contexts.with(KeymapContext::Composer) } pub fn set_collaboration_mode_indicator( @@ -1065,7 +1071,10 @@ impl ChatComposer { return None; } - if let Some(pos) = self.history_search_cursor_pos(area) { + if let Some(pos) = self + .vim_search_cursor_pos(area) + .or_else(|| self.history_search_cursor_pos(area)) + { return Some(pos); } @@ -1187,6 +1196,10 @@ impl ChatComposer { pub fn handle_paste(&mut self, pasted: String) -> bool { let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let pasted = sanitize_user_text(pasted.into()); + if let Some(query) = self.draft.textarea.vim_query_mut() { + query.editor.insert_str(&pasted); + return true; + } let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -1348,6 +1361,7 @@ impl ChatComposer { /// commands, not as candidate literal paste text. It also resets transient /// footer mode so the visible hints match the new editing surface. pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.draft.textarea.enable_vim_search(); self.draft.textarea.set_vim_enabled(enabled); self.draft.paste_burst.clear_after_explicit_paste(); self.footer.mode = reset_mode_after_activity(self.footer.mode); @@ -1949,6 +1963,13 @@ impl ChatComposer { return (InputResult::None, false); } + if self.history_search.is_none() + && !self.popups.active() + && self.draft.textarea.wants_vim_search_key(key_event) + { + return self.handle_input_basic(key_event); + } + if self.history_search.is_some() { return self.handle_history_search_key(key_event); } @@ -1970,9 +1991,11 @@ impl ChatComposer { result } - /// Return true if any popup or history search is active. + /// Whether a popup or query owns input. pub(crate) fn popup_active(&self) -> bool { - self.history_search.is_some() || self.popups.active() + self.history_search.is_some() + || self.draft.textarea.vim_query().is_some() + || self.popups.active() } #[inline] @@ -3724,6 +3747,7 @@ impl ChatComposer { }; if self.draft.is_bash_mode + && self.draft.textarea.vim_query().is_none() && matches!(input.code, KeyCode::Backspace) && self.draft.textarea.cursor() == 0 { @@ -3854,7 +3878,7 @@ impl ChatComposer { /// modes (Esc hint, overlay, quit reminder) can override that base when /// their conditions are active. fn footer_mode(&self) -> FooterMode { - if self.history_search.is_some() { + if self.history_search.is_some() || self.draft.textarea.vim_query().is_some() { return FooterMode::HistorySearch; } @@ -3882,7 +3906,7 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { - if self.footer.flash_visible() { + if self.draft.textarea.vim_query().is_some() || self.footer.flash_visible() { return Some(1); } self.footer @@ -3893,7 +3917,7 @@ impl ChatComposer { pub(crate) fn sync_popups(&mut self) { self.sync_slash_command_elements(); - if self.history_search.is_some() { + if self.history_search.is_some() || self.draft.textarea.vim_query().is_some() { if self.popups.current_file_query.is_some() { self.app_event_tx .send(AppEvent::StartFileSearch(String::new())); @@ -4645,7 +4669,9 @@ impl ChatComposer { } else { popup_rect }; - if let Some(line) = self.history_search_footer_line() { + if let Some(input) = self.draft.textarea.vim_query() { + input.render(inset_footer_hint_area(hint_rect), buf); + } else if let Some(line) = self.history_search_footer_line() { render_footer_line(hint_rect, buf, line); } else { let available_width = @@ -4903,6 +4929,7 @@ impl ChatComposer { highlights.extend( self.history_search_highlight_ranges() .into_iter() + .chain(self.draft.textarea.vim_search_highlights()) .map(|range| (range, search_highlight_style)), ); if highlights.is_empty() { @@ -6037,7 +6064,7 @@ mod tests { } #[test] - fn slash_opens_command_popup_in_vim_normal_mode() { + fn slash_opens_command_popup_in_vim_insert_mode() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -6052,6 +6079,7 @@ mod tests { /*disable_paste_burst*/ true, ); composer.set_vim_enabled(/*enabled*/ true); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); let (result, needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); @@ -6068,7 +6096,7 @@ mod tests { } #[test] - fn slash_command_can_be_typed_and_dispatched_after_vim_normal_slash() { + fn slash_command_dispatches_after_vim_insert_slash() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -6084,7 +6112,7 @@ mod tests { ); composer.set_vim_enabled(/*enabled*/ true); - for ch in ['/', 'd', 'i', 'f', 'f'] { + for ch in ['i', '/', 'd', 'i', 'f', 'f'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } assert_eq!(composer.draft.textarea.text(), "/diff"); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs new file mode 100644 index 0000000000..c6d0af5092 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs @@ -0,0 +1,23 @@ +//! Composer UI bridge for textarea-owned Vim queries, independent of prompt-history recall. + +use super::ChatComposer; +use super::inset_footer_hint_area; +use ratatui::layout::Rect; + +impl ChatComposer { + pub(crate) fn cancel_vim_search(&mut self) -> bool { + self.draft.textarea.cancel_vim_search() + } + + pub(super) fn vim_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let query = self.draft.textarea.vim_query()?; + let [_, _, _, mut footer] = self.layout_areas(area); + footer.y = footer.bottom().saturating_sub(1); + footer.height = footer.height.min(1); + query.cursor_pos(inset_footer_hint_area(footer)) + } +} + +#[cfg(test)] +#[path = "vim_search_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs new file mode 100644 index 0000000000..7dcd25d884 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs @@ -0,0 +1,58 @@ +//! Search input goes through real composer routing and rendering without changing the draft. + +use super::super::tests::snapshot_composer_state_with_width; +use crate::keymap::KeyChordMatch; +use crate::keymap::KeyChordMatcher; +use crate::keymap::RuntimeKeymap; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use tokio::time::Instant; + +#[test] +fn vim_search_query_edits_and_paste_preserve_the_draft() { + snapshot_composer_state_with_width( + "vim_composer_search", + /*width*/ 40, + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_text_content("a g\u{301}g b हि gg".into(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + composer.draft.textarea.set_cursor(/*pos*/ 0); + let config: TuiKeymap = toml::from_str("[vim_search]\nforward='g s'").unwrap(); + let keymap = RuntimeKeymap::from_config(&config).unwrap(); + composer.set_keymap_bindings(&keymap); + let mut matcher = KeyChordMatcher::default(); + for ch in "dgse\u{301}\u{8}gg".chars() { + let code = if ch == '\u{8}' { + KeyCode::Backspace + } else { + KeyCode::Char(ch) + }; + let event = KeyEvent::new(code, KeyModifiers::NONE); + let event = match matcher.advance( + event, + &keymap.chords, + composer.keymap_contexts(), + Instant::now(), + ) { + KeyChordMatch::PassThrough => event, + KeyChordMatch::Completed(event) => event, + KeyChordMatch::Pending(_) => continue, + other => panic!("unexpected chord result: {other:?}"), + }; + composer.handle_key_event(event); + } + composer.handle_paste("photo.png".into()); + let input = composer.draft.textarea.vim_query().unwrap(); + assert_eq!(input.editor.text(), "ggphoto.png"); + assert_eq!(composer.draft.textarea.text(), "a g\u{301}g b हि gg"); + assert!(composer.attachments.local_image_paths().is_empty()); + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + composer.handle_paste("ह".into()); + }, + ); +} diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 1bb3c7a4a2..06eda95148 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -161,7 +161,7 @@ impl CollaborationModeIndicator { /// (for example, showing `QuitShortcutReminder` only while its timer is active). #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum FooterMode { - /// Single-line incremental history search prompt shown while Ctrl+R search is active. + /// Single-line query prompt for history recall or Vim buffer search. HistorySearch, /// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D). QuitShortcutReminder, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 67cdb0a23d..a5ab19a640 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -758,7 +758,7 @@ impl BottomPane { self.request_redraw(); } event - } else if self.composer.cancel_history_search() { + } else if self.composer.cancel_vim_search() || self.composer.cancel_history_search() { self.request_redraw(); CancellationEvent::Handled } else if self.composer_is_empty() { @@ -2141,6 +2141,11 @@ mod tests { assert_eq!(pane.composer_text(), "draft"); assert!(!pane.composer.popup_active()); assert!(!pane.quit_shortcut_hint_visible()); + pane.composer.set_vim_enabled(/*enabled*/ true); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); + assert_eq!(pane.composer_text(), "draft"); + assert!(!pane.composer.popup_active()); } // live ring removed; related tests deleted. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_composer_search.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_composer_search.snap new file mode 100644 index 0000000000..295063fe43 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_composer_search.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› a ǵg b हि gg " Hidden by multi-width symbols: [(10, " ")] +" " +" " +" " +" " +" " +" " +" ?ह " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index aaebb46889..5ac9ea20fe 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -23,6 +23,7 @@ use crate::keymap::KeymapContext; use crate::keymap::RuntimeKeymap; use crate::keymap::VimNormalKeymap; use crate::keymap::VimOperatorKeymap; +use crate::keymap::VimSearchKeymap; use crate::keymap::VimTextObjectKeymap; use crate::width::display_width; use codex_protocol::user_input::ByteRange; @@ -50,6 +51,7 @@ use unicode_segmentation::UnicodeSegmentation; mod hyperlinks; mod vim; mod vim_commands; +mod vim_search; mod wrapping; use self::vim::VimMode; use self::vim::VimMotion; @@ -141,9 +143,12 @@ pub(crate) struct TextArea { vim_mode: VimMode, vim_pending: VimPending, vim_commands: VimCommandState, + vim_search: vim_search::VimSearch, + vim_search_enabled: bool, editor_keymap: Arc, vim_normal_keymap: VimNormalKeymap, vim_operator_keymap: VimOperatorKeymap, + vim_search_keymap: VimSearchKeymap, vim_text_object_keymap: VimTextObjectKeymap, } @@ -184,9 +189,12 @@ impl TextArea { vim_mode: VimMode::Insert, vim_pending: VimPending::None, vim_commands: VimCommandState::default(), + vim_search: vim_search::VimSearch::default(), + vim_search_enabled: false, editor_keymap: defaults.editor, vim_normal_keymap: defaults.vim_normal, vim_operator_keymap: defaults.vim_operator, + vim_search_keymap: defaults.vim_search, vim_text_object_keymap: defaults.vim_text_object, } } @@ -201,6 +209,7 @@ impl TextArea { self.editor_keymap = Arc::clone(&keymap.editor); self.vim_normal_keymap = keymap.vim_normal.clone(); self.vim_operator_keymap = keymap.vim_operator.clone(); + self.vim_search_keymap = keymap.vim_search.clone(); self.vim_text_object_keymap = keymap.vim_text_object.clone(); } @@ -253,6 +262,7 @@ impl TextArea { self.wrap_cache.replace(None); self.preferred_col = None; self.vim_pending = VimPending::None; + self.vim_search = vim_search::VimSearch::default(); self.vim_commands = VimCommandState::default(); } @@ -265,6 +275,7 @@ impl TextArea { pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { self.vim_enabled = enabled; self.vim_pending = VimPending::None; + self.vim_search = vim_search::VimSearch::default(); self.vim_commands = VimCommandState::default(); self.vim_mode = if enabled { VimMode::Normal @@ -301,12 +312,12 @@ impl TextArea { /// This is observable so the composer can avoid stealing the second key of /// `d{motion}` or `y{motion}` for higher-level shortcuts. pub(crate) fn is_vim_operator_pending(&self) -> bool { - !matches!(self.vim_pending, VimPending::None) + self.vim_query().is_some() || !matches!(self.vim_pending, VimPending::None) } /// 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 { + if !self.vim_enabled || self.vim_mode == VimMode::Insert || self.vim_query().is_some() { return KeymapContext::Editor; } match self.vim_pending { @@ -326,6 +337,7 @@ impl TextArea { if self.vim_enabled { self.vim_mode = VimMode::Insert; self.vim_pending = VimPending::None; + self.cancel_vim_search(); } } @@ -339,6 +351,7 @@ impl TextArea { if self.vim_enabled { self.vim_mode = VimMode::Normal; self.vim_pending = VimPending::None; + self.cancel_vim_search(); self.preferred_col = None; } } @@ -363,7 +376,7 @@ 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 || !matches!(self.vim_pending, VimPending::None)) + && (self.vim_mode == VimMode::Insert || self.is_vim_operator_pending()) && event.code == KeyCode::Esc && event.modifiers == KeyModifiers::NONE && matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) @@ -679,6 +692,9 @@ impl TextArea { } fn handle_vim_input(&mut self, event: KeyEvent) { + if self.handle_vim_search_key(event) { + return; + } let prior_mode = self.vim_mode; match self.vim_mode { VimMode::Insert => self.handle_vim_insert(event), @@ -830,6 +846,7 @@ impl TextArea { } if self.vim_normal_keymap.cancel_operator.is_pressed(event) { self.vim_pending = VimPending::None; + self.cancel_vim_search(); return; } self.handle_vim_extra_command(event); 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 757175849c..e50d5f27e7 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs @@ -13,6 +13,7 @@ use super::VimTextObject; use super::VimTextObjectScope; use super::vim::VimFindMotion; use crate::key_hint::KeyBindingListExt; +use crate::vim_search::SearchQuery; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -38,12 +39,13 @@ pub(super) enum VimInsertPosition { OpenBelow, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub(super) enum VimEditTarget { Character, Line, LineEnd, Motion(VimMotion), + Search(SearchQuery), TextObject { scope: VimTextObjectScope, object: VimTextObject, @@ -57,7 +59,7 @@ pub(super) enum VimEditTarget { }, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub(super) enum VimAction { Insert(VimInsertPosition), Delete(VimEditTarget), @@ -119,7 +121,7 @@ impl TextArea { && !self.vim_commands.replaying && !self.vim_commands.pending_change.is_empty(); let prior_len = self.text.len(); - self.apply_vim_editor_action(action); + self.apply_vim_editor_action(action.clone()); let changed = self.text.len() != prior_len; let deletion = matches!( action, @@ -141,9 +143,9 @@ impl TextArea { pub(super) fn start_vim_edit(&mut self, action: VimAction) -> bool { let prior_len = self.text.len(); - self.vim_commands.pending_change = vec![VimEdit::Editor(VimEditorEdit(action))]; + self.vim_commands.pending_change = vec![VimEdit::Editor(VimEditorEdit(action.clone()))]; self.vim_commands.changed = false; - if !self.apply_vim_editor_action(action) { + if !self.apply_vim_editor_action(action.clone()) { self.vim_commands.pending_change.clear(); return false; } @@ -180,7 +182,7 @@ impl TextArea { pub(crate) fn apply_vim_edit(&mut self, edit: &VimEdit) -> bool { match edit { - VimEdit::Editor(VimEditorEdit(action)) => self.apply_vim_editor_action(*action), + VimEdit::Editor(VimEditorEdit(action)) => self.apply_vim_editor_action(action.clone()), VimEdit::Text(text) => { if self.vim_mode != VimMode::Insert { return false; @@ -193,6 +195,7 @@ impl TextArea { fn apply_vim_editor_action(&mut self, action: VimAction) -> bool { let prior_len = self.text.len(); + let is_change = matches!(action, VimAction::Change(_)); match action { VimAction::Insert(position) => { match position { @@ -223,7 +226,7 @@ impl TextArea { self.vim_mode = VimMode::Insert; } VimAction::Delete(target) | VimAction::Change(target) => { - let operator = if matches!(action, VimAction::Delete(_)) { + let operator = if !is_change { VimOperator::Delete } else { VimOperator::Change @@ -254,6 +257,11 @@ impl TextArea { } } VimEditTarget::Motion(motion) => self.apply_vim_operator(operator, motion), + VimEditTarget::Search(query) => { + if !self.apply_vim_search(&query, Some(operator)) { + return false; + } + } VimEditTarget::TextObject { scope, object } => { let Some(range) = self.text_object_range(object, scope) else { return false; @@ -531,7 +539,7 @@ impl TextArea { self.set_cursor(self.first_non_blank_of_current_line()); } - fn is_vim_command_target(&self, position: usize) -> bool { + pub(super) fn is_vim_command_target(&self, position: usize) -> bool { !self .elements .iter() 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 0f2f868bbb..8a6b7ef3bd 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 @@ -2,7 +2,6 @@ use super::super::TextArea; use super::VimAction; use crate::keymap::KeyChordMatch; use crate::keymap::KeyChordMatcher; -use crate::keymap::KeymapContextSet; use crate::keymap::RuntimeKeymap; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -15,6 +14,7 @@ fn vim_textarea(text: &str, cursor: usize) -> TextArea { textarea.insert_str(text); textarea.set_cursor(cursor); textarea.set_vim_enabled(/*enabled*/ true); + textarea.enable_vim_search(); textarea } @@ -22,16 +22,16 @@ fn keys(textarea: &mut TextArea, keys: &str) { let keymap = RuntimeKeymap::defaults(); let mut matcher = KeyChordMatcher::default(); for key in keys.chars() { - let code = if key == '\n' { - KeyCode::Enter - } else { - KeyCode::Char(key) + let code = match key { + '\n' => KeyCode::Enter, + '\x1b' => KeyCode::Esc, + ch => KeyCode::Char(ch), }; let event = KeyEvent::new(code, KeyModifiers::NONE); match matcher.advance( event, &keymap.chords, - KeymapContextSet::new(textarea.keymap_context()), + textarea.keymap_contexts(), Instant::now(), ) { KeyChordMatch::PassThrough => textarea.input(event), @@ -167,7 +167,7 @@ fn repeat_omits_ineffective_deletions_before_inserted_text() { ] { let mut textarea = vim_textarea("one two\n", cursor); keys(&mut textarea, "i"); - textarea.apply_vim_insert_action(action); + textarea.apply_vim_insert_action(action.clone()); keys(&mut textarea, "X"); escape(&mut textarea); textarea.set_cursor(repeat_cursor); @@ -620,3 +620,57 @@ fn find_and_navigation_have_visual_snapshot_coverage() { ^ "###); } + +#[test] +fn search_motions_compose_with_operators_and_repeat() { + for (commands, text, cursor, mode) in [ + ("/b\n", "a b c b d b", 2, "Normal"), + ("/b\nn", "a b c b d b", 6, "Normal"), + ("/b\nnN", "a b c b d b", 2, "Normal"), + ("/b\nnnn", "a b c b d b", 2, "Normal"), + ("?b\n", "a b c b d b", 10, "Normal"), + ("?b\nn", "a b c b d b", 6, "Normal"), + ("?b\nN", "a b c b d b", 2, "Normal"), + ("d/b\n", "b c b d b", 0, "Normal"), + ("c/b\n", "b c b d b", 0, "Insert"), + ("y/b\np", "aa b c b d b", 3, "Normal"), + ("/b\ndn", "a b d b", 2, "Normal"), + ("?b\ndN", "a b", 2, "Normal"), + ("?b\nd?b\n", "a b c b", 6, "Normal"), + ("?b\ny?b\n", "a b c b d b", 6, "Normal"), + ("d/missing\n", "a b c b d b", 0, "Normal"), + ("/b\nc?missing\x1bn", "a b c b d b", 6, "Normal"), + ("c/b\nX\x1bl.", "XXb d b", 1, "Normal"), + ] { + let mut area = vim_textarea("a b c b d b", /*cursor*/ 0); + keys(&mut area, commands); + assert_eq!( + (area.text(), area.cursor(), area.vim_mode_label()), + (text, cursor, Some(mode)), + "{commands:?}" + ); + } + for (cursor, commands, expected) in [ + (1, "d/xyz\n", "a\nxyz"), + (0, "d/xyz\n", "xyz"), + (0, "c/xyz\nQ\x1b", "Q\nxyz"), + (1, "c/xyz\nQ\x1b", "aQ\nxyz"), + (0, "y/xyz\np", "abc\nabc\nxyz"), + ] { + let mut area = vim_textarea("abc\nxyz", cursor); + keys(&mut area, commands); + assert_eq!(area.text(), expected, "{commands:?}"); + } +} + +#[test] +fn search_skips_atomic_elements_and_partial_graphemes() { + let mut area = vim_textarea("é e\u{301}\n", /*cursor*/ 0); + area.insert_element("[photo.png]"); + keys(&mut area, "/photo\n"); + assert_eq!(area.cursor(), "[photo.png]".len()); + keys(&mut area, "/\u{301}\n"); + assert_eq!(area.cursor(), "[photo.png]".len()); + keys(&mut area, "/e\u{301}\n"); + assert_eq!(area.cursor(), "[photo.png]é ".len()); +} diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs new file mode 100644 index 0000000000..1941fbe1ca --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs @@ -0,0 +1,247 @@ +//! Draft-local search motions and operator transactions. Query entry never edits the buffer; +//! accepted searches wrap, skip atomic elements, and record patterns rather than byte offsets. + +use super::TextArea; +use super::TextAreaState; +use super::VimAction; +use super::VimEditTarget; +use super::VimOperator; +use super::VimPending; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::KeymapContext; +use crate::keymap::KeymapContextSet; +use crate::vim_search::SearchDirection; +use crate::vim_search::SearchQuery; +use crate::vim_search::matching_ranges; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; +use std::ops::Range; + +#[derive(Debug, Default)] +pub(super) struct VimSearch { + input: Option>, + last: SearchQuery, +} + +/// Footer query editor, boxed by its owning textarea to keep the draft and query independent. +#[derive(Debug)] +pub(crate) struct SearchInput { + pub(crate) editor: TextArea, + direction: SearchDirection, +} + +impl SearchInput { + pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) { + let prefix = match self.direction { + SearchDirection::Forward => "/", + SearchDirection::Backward => "?", + }; + prefix.cyan().render(area, buf); + StatefulWidgetRef::render_ref( + &&self.editor, + Self::query_area(area), + buf, + &mut TextAreaState::default(), + ); + } + + pub(crate) fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.editor.cursor_pos(Self::query_area(area)) + } + + fn query_area(area: Rect) -> Rect { + Rect { + x: area.x.saturating_add(1), + width: area.width.saturating_sub(1), + ..area + } + } +} + +enum SearchCommand { + Start(SearchDirection), + Next, + Previous, +} + +impl TextArea { + pub(crate) fn keymap_contexts(&self) -> KeymapContextSet { + let context = self.keymap_context(); + let contexts = KeymapContextSet::new(context); + if self.vim_search_enabled + && matches!( + context, + KeymapContext::VimNormal | KeymapContext::VimOperator + ) + { + contexts.with(KeymapContext::VimSearch) + } else { + contexts + } + } + + pub(crate) fn enable_vim_search(&mut self) { + self.vim_search_enabled = true; + } + pub(crate) fn vim_query(&self) -> Option<&SearchInput> { + self.vim_search.input.as_deref() + } + pub(crate) fn vim_query_mut(&mut self) -> Option<&mut SearchInput> { + self.vim_search.input.as_deref_mut() + } + pub(crate) fn cancel_vim_search(&mut self) -> bool { + self.vim_search.input.take().is_some_and(|_| { + self.vim_pending = VimPending::None; + true + }) + } + + pub(crate) fn wants_vim_search_key(&self, event: KeyEvent) -> bool { + self.vim_search.input.is_some() || self.vim_search_command(event).is_some() + } + + fn vim_search_command(&self, event: KeyEvent) -> Option { + use SearchCommand::Start; + + if !self.vim_search_enabled + || !self.is_vim_normal_mode() + || !matches!(self.vim_pending, VimPending::None | VimPending::Operator(_)) + { + return None; + } + let keys = &self.vim_search_keymap; + [ + (&keys.forward, Start(SearchDirection::Forward)), + (&keys.backward, Start(SearchDirection::Backward)), + (&keys.next, SearchCommand::Next), + (&keys.previous, SearchCommand::Previous), + ] + .into_iter() + .find_map(|(bindings, command)| bindings.is_pressed(event).then_some(command)) + } + + pub(super) fn handle_vim_search_key(&mut self, event: KeyEvent) -> bool { + let query = if let Some(mut input) = self.vim_search.input.take() { + match (event.code, event.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.vim_pending = VimPending::None; + return true; + } + (KeyCode::Enter, KeyModifiers::NONE) => {} + _ => { + input.editor.input_with_keymap(event, &self.editor_keymap); + self.vim_search.input = Some(input); + return true; + } + } + let text = if input.editor.is_empty() { + self.vim_search.last.text.clone() + } else { + input.editor.text().to_owned() + }; + let query = SearchQuery { + text, + direction: input.direction, + }; + self.vim_search.last = query.clone(); + query + } else { + let Some(command) = self.vim_search_command(event) else { + return false; + }; + if let SearchCommand::Start(direction) = command { + self.vim_search.input = Some(Box::new(SearchInput { + editor: TextArea::new(), + direction, + })); + return true; + } + let mut query = self.vim_search.last.clone(); + if matches!(command, SearchCommand::Previous) { + query.direction = query.direction.reversed(); + } + query + }; + match std::mem::replace(&mut self.vim_pending, VimPending::None) { + VimPending::Operator(VimOperator::Delete) => { + self.start_vim_edit(VimAction::Delete(VimEditTarget::Search(query))); + } + VimPending::Operator(VimOperator::Change) => { + self.start_vim_edit(VimAction::Change(VimEditTarget::Search(query))); + } + VimPending::Operator(VimOperator::Yank) => { + self.apply_vim_search(&query, Some(VimOperator::Yank)); + } + _ => { + self.apply_vim_search(&query, /*operator*/ None); + } + } + true + } + + pub(super) fn apply_vim_search( + &mut self, + query: &SearchQuery, + operator: Option, + ) -> bool { + let origin = self.cursor_pos; + // Prefer matches before wrapping; order backward matches from the end. + let target = matching_ranges(&self.text, &query.text) + .filter(|range| self.is_vim_command_target(range.start)) + .map(|range| range.start) + .min_by_key(|&pos| match query.direction { + SearchDirection::Forward => (pos <= origin, pos), + SearchDirection::Backward => (pos >= origin, usize::MAX - pos), + }); + let Some(target) = target else { + return false; + }; + if let Some(operator) = operator { + if origin == target { + return false; + } + let mut range = origin.min(target)..origin.max(target); + let line_start = self.beginning_of_line(range.start); + let ends_at_line_start = range.end == self.beginning_of_line(range.end); + // Exclusive motions ending at column zero may become linewise. + let linewise = + ends_at_line_start && self.text[line_start..range.start].trim().is_empty(); + if linewise { + range.start = line_start; + } else if ends_at_line_start { + range.end -= 1; + } + let start = range.start; + if operator == VimOperator::Yank { + self.set_cursor(start); + } + self.apply_vim_operator_to_range(operator, range); + if linewise { + self.kill_buffer_kind = super::KillBufferKind::Linewise; + if operator == VimOperator::Change { + self.insert_str_at(start, "\n"); + self.set_cursor(start); + } + } + } else { + self.set_cursor(target); + } + true + } + + pub(crate) fn vim_search_highlights(&self) -> Vec> { + let query = match self.vim_query() { + Some(input) => input.editor.text(), + None => &self.vim_search.last.text, + }; + matching_ranges(&self.text, query) + .filter(|range| self.is_vim_command_target(range.start)) + .collect() + } +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 5aed29a8cf..fb79fd2c82 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -34,6 +34,8 @@ use std::sync::Arc; mod bindings; mod chords; +mod vim_search; +pub(crate) use vim_search::VimSearchKeymap; #[cfg(test)] #[path = "keymap/conflict_tests.rs"] @@ -70,6 +72,7 @@ pub(crate) struct RuntimeKeymap { pub(crate) editor: Arc, pub(crate) vim_normal: VimNormalKeymap, pub(crate) vim_operator: VimOperatorKeymap, + pub(crate) vim_search: VimSearchKeymap, pub(crate) vim_text_object: VimTextObjectKeymap, pub(crate) pager: PagerKeymap, pub(crate) list: ListKeymap, @@ -1360,6 +1363,7 @@ impl RuntimeKeymap { editor, vim_normal, vim_operator, + vim_search: VimSearchKeymap::default(), vim_text_object, pager, list, @@ -1367,6 +1371,7 @@ impl RuntimeKeymap { approval, }; + resolved.configure_vim_search(keymap)?; resolved.validate_conflicts()?; chords::validate_chord_conflicts(&resolved)?; chords::install_dispatch_bindings(&mut resolved)?; @@ -1544,6 +1549,7 @@ impl RuntimeKeymap { start_change_operator: default_bindings![plain(KeyCode::Char('c'))], cancel_operator: default_bindings![plain(KeyCode::Esc)], }, + vim_search: VimSearchKeymap::default(), vim_operator: VimOperatorKeymap { delete_line: default_bindings![plain(KeyCode::Char('d'))], yank_line: default_bindings![plain(KeyCode::Char('y'))], @@ -2927,6 +2933,27 @@ mod tests { } } + #[test] + fn vim_search_preserves_custom_motion_bindings() { + for operator in [false, true] { + let mut config = TuiKeymap::default(); + if operator { + config.vim_operator.motion_left = Some(one("n")); + } else { + config.vim_normal.move_left = Some(one("n")); + } + assert!( + RuntimeKeymap::from_config(&config) + .unwrap() + .vim_search + .next + .is_empty() + ); + config.vim_search.next = Some(one("n")); + assert!(RuntimeKeymap::from_config(&config).is_err()); + } + } + #[test] fn configured_vim_normal_chord_prefix_prunes_new_repeat_default() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 3ea0aaff8a..5d58c02dd3 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -19,6 +19,7 @@ pub(crate) enum KeymapContext { Editor, VimNormal, VimOperator, + VimSearch, VimTextObject, Pager, List, @@ -35,6 +36,7 @@ impl KeymapContext { Self::Editor => "editor", Self::VimNormal => "vim_normal", Self::VimOperator => "vim_operator", + Self::VimSearch => "vim_search", Self::VimTextObject => "vim_text_object", Self::Pager => "pager", Self::List => "list", @@ -46,7 +48,7 @@ impl KeymapContext { pub(crate) const fn allows_plain_chord_prefix(self) -> bool { matches!( self, - Self::VimNormal | Self::VimOperator | Self::VimTextObject + Self::VimNormal | Self::VimOperator | Self::VimSearch | Self::VimTextObject ) } @@ -57,7 +59,9 @@ impl KeymapContext { matches!( (self, other), - (Self::List, Self::Approval) + (Self::VimSearch, Self::VimNormal | Self::VimOperator) + | (Self::VimNormal | Self::VimOperator, Self::VimSearch) + | (Self::List, Self::Approval) | (Self::Approval, Self::List) | (Self::List, Self::Agents) | (Self::Agents, Self::List) @@ -75,7 +79,11 @@ impl KeymapContext { const fn is_main_editor(self) -> bool { matches!( self, - Self::Editor | Self::VimNormal | Self::VimOperator | Self::VimTextObject + Self::Editor + | Self::VimNormal + | Self::VimOperator + | Self::VimSearch + | Self::VimTextObject ) } } @@ -304,6 +312,7 @@ define_runtime_action_bindings! { start_change_operator, cancel_operator, ], + "vim_search" => VimSearch, vim_search, vim_search [forward, backward, next, previous], "vim_operator" => VimOperator, vim_operator, vim_operator [ delete_line, yank_line, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index a47464c3e6..f4f5f9f690 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -68,6 +68,7 @@ const fn context_bit(context: KeymapContext) -> u16 { KeymapContext::List => 8, KeymapContext::Approval => 9, KeymapContext::Agents => 10, + KeymapContext::VimSearch => 11, } } @@ -514,6 +515,7 @@ Choose a different chord and retry.", | KeymapContext::Editor | KeymapContext::VimNormal | KeymapContext::VimOperator + | KeymapContext::VimSearch | KeymapContext::VimTextObject => MAIN_RESERVED_BINDINGS, KeymapContext::List => LIST_RESERVED_BINDINGS, KeymapContext::Agents | KeymapContext::Approval => &LIST_RESERVED_BINDINGS[..1], diff --git a/codex-rs/tui/src/keymap/vim_search.rs b/codex-rs/tui/src/keymap/vim_search.rs new file mode 100644 index 0000000000..c9d54ed050 --- /dev/null +++ b/codex-rs/tui/src/keymap/vim_search.rs @@ -0,0 +1,78 @@ +//! Search bindings shared by normal and operator-pending input. Existing explicit Vim +//! bindings win over new defaults; explicit search conflicts remain configuration errors. + +use super::bindings::configured_binding_for_action; +use super::*; + +#[derive(Clone, Debug)] +pub(crate) struct VimSearchKeymap { + pub(crate) forward: Vec, + pub(crate) backward: Vec, + pub(crate) next: Vec, + pub(crate) previous: Vec, +} + +impl Default for VimSearchKeymap { + fn default() -> Self { + use key_hint::plain; + use key_hint::shift; + Self { + forward: vec![plain(KeyCode::Char('/'))], + backward: vec![plain(KeyCode::Char('?')), shift(KeyCode::Char('?'))], + next: vec![plain(KeyCode::Char('n'))], + previous: vec![plain(KeyCode::Char('N')), shift(KeyCode::Char('n'))], + } + } +} + +impl RuntimeKeymap { + pub(super) fn configure_vim_search(&mut self, config: &TuiKeymap) -> Result<(), String> { + let others: Vec<_> = runtime_action_bindings(self) + .filter(|binding| { + matches!( + binding.id.context, + KeymapContext::VimNormal | KeymapContext::VimOperator + ) + }) + .collect(); + let mut search = self.vim_search.clone(); + let config_search = &config.vim_search; + let mut actions = [ + ("forward", &config_search.forward, &mut search.forward), + ("backward", &config_search.backward, &mut search.backward), + ("next", &config_search.next, &mut search.next), + ("previous", &config_search.previous, &mut search.previous), + ]; + for (action, configured, bindings) in &mut actions { + let path = format!("tui.keymap.vim_search.{action}"); + **bindings = resolve_bindings(configured.as_ref(), bindings, &path)?; + if configured.is_none() { + bindings.retain(|key| { + !others.iter().any(|other| { + other.bindings.contains(key) + && configured_binding_for_action(config, other.id) + .is_some_and(Option::is_some) + }) && !self.chords.bindings.iter().any(|chord| { + chord.action.context.overlaps(KeymapContext::VimSearch) + && chord.chord.prefix.parts() == key.parts() + }) + }); + } + if let Some(other) = others + .iter() + .find(|other| other.bindings.iter().any(|key| bindings.contains(key))) + { + return Err(format!( + "Conflicting `{path}` and `{}` bindings", + other.id.config_path() + )); + } + } + validate_unique( + "vim_search", + actions.map(|(action, _, bindings)| (action, bindings.as_slice())), + )?; + self.vim_search = search; + Ok(()) + } +} diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index e8fec1fc76..1125c8349a 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -154,6 +154,10 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."), action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a motion or text object."), action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."), + action("vim_search", "Vim search", "forward", "Search forward in the active buffer."), + action("vim_search", "Vim search", "backward", "Search backward in the active buffer."), + action("vim_search", "Vim search", "next", "Repeat the accepted search."), + action("vim_search", "Vim search", "previous", "Repeat the search in the opposite direction."), action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."), action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."), action("vim_operator", "Vim operator", "motion_left", "Operator motion left."), @@ -320,6 +324,10 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator), ("vim_normal", "start_change_operator") => Some(&mut keymap.vim_normal.start_change_operator), ("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator), + ("vim_search", "forward") => Some(&mut keymap.vim_search.forward), + ("vim_search", "backward") => Some(&mut keymap.vim_search.backward), + ("vim_search", "next") => Some(&mut keymap.vim_search.next), + ("vim_search", "previous") => Some(&mut keymap.vim_search.previous), ("vim_operator", "delete_line") => Some(&mut keymap.vim_operator.delete_line), ("vim_operator", "yank_line") => Some(&mut keymap.vim_operator.yank_line), ("vim_operator", "motion_left") => Some(&mut keymap.vim_operator.motion_left), diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index 54c971c2d1..6133a06b70 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -104,7 +104,12 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[ id: "vim-shortcuts", label: "Vim", description: "Vim normal-mode and operator shortcuts.", - contexts: &["vim_normal", "vim_operator", "vim_text_object"], + contexts: &[ + "vim_normal", + "vim_operator", + "vim_search", + "vim_text_object", + ], }, KeymapContextTab { id: "navigation-shortcuts", diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0cd2d7ed24..9ca0d639bd 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -215,6 +215,7 @@ mod updates; #[cfg(any(not(debug_assertions), test))] mod updates_cache; mod version; +mod vim_search; mod width; #[cfg(any(target_os = "windows", test))] mod windows_sandbox; 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 5ed3c668fb..0f4999bb42 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. - 131 actions, 1 customized, 4 unbound. + 135 actions, 1 customized, 4 unbound. [All] Common Customized (1) Unbound (4) 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 6689f447b1..421b7ea7fe 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. - 132 actions, 0 customized, 5 unbound. + 136 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_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index e2ff90f225..46394f6ec6 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 (131 selectable) +tab: All (135 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (4) (4 selectable) tab: App (14 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (62 selectable) +tab: Vim (66 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 4f822ff698..2b3b90ee23 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. - 131 actions, 1 customized, 4 unbound. + 135 actions, 1 customized, 4 unbound. [All] Common Customized (1) Unbound (4) 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 e8099d1e16..f10d0c3da7 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. - 131 actions, 0 customized, 4 unbound. + 135 actions, 0 customized, 4 unbound. [All] Common Customized (0) Unbound (4) 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 95a54489c4..d0734ddea6 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. - 131 actions, 0 customized, 4 unbound. + 135 actions, 0 customized, 4 unbound. [All] Common Customized (0) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/vim_search.rs b/codex-rs/tui/src/vim_search.rs new file mode 100644 index 0000000000..2511a9213c --- /dev/null +++ b/codex-rs/tui/src/vim_search.rs @@ -0,0 +1,44 @@ +//! Surface-independent literal Vim search queries. Matches span complete graphemes; +//! callers own navigation, operator ranges, and the lifetime of accepted versus pending input. + +use std::ops::Range; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) enum SearchDirection { + #[default] + Forward, + Backward, +} + +impl SearchDirection { + pub(crate) fn reversed(self) -> Self { + match self { + Self::Forward => Self::Backward, + Self::Backward => Self::Forward, + } + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SearchQuery { + pub(crate) text: String, + pub(crate) direction: SearchDirection, +} + +pub(crate) fn matching_ranges<'a>( + text: &'a str, + query: &'a str, +) -> impl Iterator> + 'a { + let text = if query.is_empty() { "" } else { text }; + text.grapheme_indices(/*is_extended*/ true) + .filter(move |&(start, _)| text[start..].starts_with(query)) + .map(move |(start, _)| { + let end = text[start..] + .grapheme_indices(/*is_extended*/ true) + .map(|(offset, grapheme)| start + offset + grapheme.len()) + .find(|&end| end >= start + query.len()) + .unwrap_or(text.len()); + start..end + }) +}