diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3511283f2c4b..ee3bf5a03837 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -23,6 +23,8 @@ //! popup-specific handler if a popup is visible and otherwise to //! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call //! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. +//! Fresh Vim drafts start in Insert; Normal `/` and `?` search the composer. +//! Backspace on an empty Vim search query cancels search and any pending operator. //! //! # Completion and Popup Dismissal //! @@ -3134,7 +3136,7 @@ impl ChatComposer { | InputResult::ServiceTierCommand(_) | InputResult::CommandWithArgs(_, _, _) ) { - self.draft.textarea.enter_vim_normal_mode(); + self.draft.textarea.enter_vim_insert_mode(); } } @@ -3394,6 +3396,7 @@ impl ChatComposer { slash_input::args_elements(prepared_rest, prepared_rest_offset, &prepared_elements); let trimmed_rest = prepared_rest.trim(); args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements); + self.draft.textarea.enter_vim_insert_mode(); Some((trimmed_rest.to_string(), args_elements)) } @@ -3485,26 +3488,6 @@ impl ChatComposer { { return self.handle_input_basic(key_event); } - if self.draft.textarea.is_vim_normal_mode() - && self.is_empty() - && matches!( - key_event, - KeyEvent { - code: KeyCode::Char('/'), - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } - ) - { - self.footer.mode = reset_mode_after_activity(self.footer.mode); - self.draft.textarea.set_text_clearing_elements("/"); - self.draft - .textarea - .set_cursor(self.draft.textarea.text().len()); - self.draft.textarea.enter_vim_insert_mode(); - return (InputResult::None, true); - } if self.draft.textarea.is_vim_normal_mode() && self.is_empty() && matches!( @@ -6148,13 +6131,13 @@ mod tests { assert!(composer.is_empty()); assert_eq!( composer.vim_mode_indicator_span(), - Some("Vim: Normal".magenta()) + Some("Vim: Insert".green()) ); assert!(matches!(result, InputResult::Command(SlashCommand::Diff))); } #[test] - fn inline_slash_command_dispatch_resets_vim_mode_to_normal() { + fn inline_slash_command_dispatch_resets_vim_mode_to_insert() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -6180,7 +6163,7 @@ mod tests { assert!(needs_redraw); assert_eq!( composer.vim_mode_indicator_span(), - Some("Vim: Normal".magenta()) + Some("Vim: Insert".green()) ); match result { InputResult::CommandWithArgs(cmd, args, text_elements) => { @@ -6190,6 +6173,13 @@ mod tests { } _ => panic!("expected CommandWithArgs"), } + composer + .prepare_inline_args_submission(/*record_history*/ false) + .unwrap(); + for code in [KeyCode::Char('x'), KeyCode::Esc, KeyCode::Char('.')] { + composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); + } + assert_eq!(composer.current_text(), "xx"); } #[test] @@ -6400,7 +6390,7 @@ mod tests { } #[test] - fn vim_mode_resets_to_normal_after_submission() { + fn vim_mode_resets_to_insert_after_submission() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -6431,7 +6421,7 @@ mod tests { assert!(composer.draft.textarea.is_vim_enabled()); assert_eq!( composer.vim_mode_indicator_span(), - Some("Vim: Normal".magenta()) + Some("Vim: Insert".green()) ); assert!(composer.is_empty()); match result { @@ -6441,7 +6431,7 @@ mod tests { } #[test] - fn vim_mode_resets_to_normal_after_queued_submission() { + fn vim_mode_resets_to_insert_after_queued_submission() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -6465,7 +6455,7 @@ mod tests { assert_eq!( composer.vim_mode_indicator_span(), - Some("Vim: Normal".magenta()) + Some("Vim: Insert".green()) ); assert!(composer.is_empty()); match result { 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 index 7dcd25d884dd..be602bdb3b6a 100644 --- 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 @@ -1,5 +1,6 @@ -//! Search input goes through real composer routing and rendering without changing the draft. +//! Fresh draft entry and composer-local Vim search remain separate input paths. +use super::super::InputResult; use super::super::tests::snapshot_composer_state_with_width; use crate::keymap::KeyChordMatch; use crate::keymap::KeyChordMatcher; @@ -11,6 +12,61 @@ use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; use tokio::time::Instant; +#[test] +fn fresh_vim_draft_records_typing_after_submission() { + snapshot_composer_state_with_width( + "vim_fresh_draft", + /*width*/ 40, + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_disable_paste_burst(/*disabled*/ true); + for entry in [KeyCode::Char('i'), KeyCode::Esc] { + composer.set_text_content("sent".into(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + composer.handle_key_event(entry.into()); + let (result, _) = composer.handle_key_event(KeyCode::Enter.into()); + assert!(matches!(result, InputResult::Submitted { .. })); + assert_eq!(composer.draft.textarea.vim_mode_label(), Some("Insert")); + for key in [KeyCode::Char('x'), KeyCode::Esc, KeyCode::Char('.')] { + composer.handle_key_event(key.into()); + } + assert_eq!(composer.current_text(), "xx"); + } + composer.handle_key_event(KeyCode::Enter.into()); + }, + ); +} + +#[test] +fn backspace_cancels_empty_vim_search() { + snapshot_composer_state_with_width( + "vim_search_cancelled", + /*width*/ 40, + /*enhanced_keys_supported*/ false, + |composer| { + for text in ["", "draft"] { + composer.set_text_content(text.into(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + let cursor = composer.cursor(); + for prefix in ["/", "?", "d/", "d?", "c/", "c?", "y/", "y?"] { + for ch in prefix.chars() { + composer + .handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert!(composer.draft.textarea.vim_query().is_some()); + composer.handle_key_event(KeyCode::Backspace.into()); + assert!(!composer.draft.textarea.is_vim_operator_pending()); + assert!(composer.draft.textarea.is_vim_normal_mode()); + assert_eq!( + (composer.current_text(), composer.cursor()), + (text.into(), cursor) + ); + } + } + }, + ); +} + #[test] fn vim_search_query_edits_and_paste_preserve_the_draft() { snapshot_composer_state_with_width( diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_fresh_draft.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_fresh_draft.snap new file mode 100644 index 000000000000..7a31d0b1820f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_fresh_draft.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" 100% context left | Vim: Insert " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_search_cancelled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_search_cancelled.snap new file mode 100644 index 000000000000..423436edfdeb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_search_cancelled.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› draft " +" " +" " +" " +" " +" " +" " +" 100% context left | Vim: Normal " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 5ac9ea20fe01..5e0d163233ac 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -338,6 +338,9 @@ impl TextArea { self.vim_mode = VimMode::Insert; self.vim_pending = VimPending::None; self.cancel_vim_search(); + if self.vim_commands.pending_change.is_empty() && !self.vim_commands.replaying { + self.start_vim_edit(VimAction::Insert(VimInsertPosition::Cursor)); + } } } 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 e50d5f27e73e..1e3e290f8dfc 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs @@ -85,10 +85,10 @@ pub(super) enum VimAction { #[derive(Debug, Default)] pub(super) struct VimCommandState { - pending_change: Vec, + pub(super) pending_change: Vec, last_change: Vec, changed: bool, - replaying: bool, + pub(super) replaying: bool, } impl TextArea { diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs index 1941fbe1ca6a..2091ede8cc4a 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs @@ -133,6 +133,10 @@ impl TextArea { self.vim_pending = VimPending::None; return true; } + (KeyCode::Backspace, KeyModifiers::NONE) if input.editor.is_empty() => { + self.vim_pending = VimPending::None; + return true; + } (KeyCode::Enter, KeyModifiers::NONE) => {} _ => { input.editor.input_with_keymap(event, &self.editor_keymap); diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 24e84747ee4b..a1079ac7c43d 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -265,9 +265,11 @@ impl ChatWidget { if let Some(keymap) = runtime_keymap { widget.bottom_pane.set_keymap_bindings(&keymap); } - widget - .bottom_pane - .set_vim_enabled(widget.config.tui_vim_mode_default); + if widget.config.tui_vim_mode_default { + widget.bottom_pane.enable_vim_in_insert_mode(); + } else { + widget.bottom_pane.set_vim_enabled(/*enabled*/ false); + } widget .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 4d9655db5528..ae97d92eabfd 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -69,7 +69,7 @@ impl ChatWidget { startup_offset .saturating_add(draft.cursor) .saturating_add(cursor_adjustment) - } else if existing_has_content { + } else if existing_has_content || !startup_has_content { existing_cursor } else { self.restore_composer_state(Self::composer_state_from_user_message( diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 2856c4cd4a87..7f2028f33386 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -664,6 +664,9 @@ impl ChatWidget { .set_composer_text(String::new(), Vec::new(), Vec::new()); self.bottom_pane.set_composer_pending_pastes(Vec::new()); self.bottom_pane.drain_pending_submission_state(); + if self.bottom_pane.composer_is_vim_enabled() { + self.bottom_pane.enable_vim_in_insert_mode(); + } } fn prepared_inline_user_message( diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index d17818fe09cb..224b55ebdc7d 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1596,7 +1596,7 @@ async fn vim_mode_default_disabled_starts_composer_in_insert_mode() { } #[tokio::test] -async fn vim_mode_default_enabled_starts_composer_in_normal_mode() { +async fn vim_mode_default_enabled_starts_composer_in_insert_mode() { let chat = make_startup_chat_with_cli_overrides(vec![( "tui.vim_mode_default".to_string(), TomlValue::Boolean(true), @@ -1606,8 +1606,13 @@ async fn vim_mode_default_enabled_starts_composer_in_normal_mode() { assert!(chat.bottom_pane.composer_is_vim_enabled()); assert!(chat.composer_is_empty()); let mut chat = chat; - chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - assert_eq!(chat.bottom_pane.composer_text(), ""); + let empty_startup_draft = chat.bottom_pane.composer_draft_snapshot(); + chat.restore_startup_draft(empty_startup_draft); + chat.handle_key_event(KeyCode::Char('x').into()); + chat.handle_key_event(KeyCode::Right.into()); + chat.handle_key_event(KeyCode::Esc.into()); + chat.handle_key_event(KeyCode::Char('.').into()); + assert_eq!(chat.bottom_pane.composer_text(), "xx"); } async fn make_startup_chat_with_cli_overrides( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2b681d40e274..bfd3fee6865f 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -823,8 +823,12 @@ async fn goal_control_slash_commands_emit_goal_events() { chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); + chat.bottom_pane.set_vim_enabled(/*enabled*/ true); - submit_composer_text(&mut chat, command); + chat.bottom_pane + .set_composer_text(command.into(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyCode::Esc.into()); + chat.handle_key_event(KeyCode::Enter.into()); match status { Some(status) => { @@ -850,6 +854,11 @@ async fn goal_control_slash_commands_emit_goal_events() { assert_eq!(actual_thread_id, thread_id); } } + chat.handle_key_event(KeyCode::Char('x').into()); + chat.handle_key_event(KeyCode::Right.into()); + chat.handle_key_event(KeyCode::Esc.into()); + chat.handle_key_event(KeyCode::Char('.').into()); + assert_eq!(chat.bottom_pane.composer_text(), "xx"); } }