From f548309797c3bb23a12ae7d3af0767f5cd501cca Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Wed, 11 Mar 2026 14:52:40 -0700 Subject: [PATCH] Keep agent-switch word-motion keys out of draft editing (#14376) ## Summary - only trigger multi-agent fast-switch shortcuts when the composer is empty - keep the Option+b/f fallback for terminals that encode Option+arrow that way - document why the empty-composer gate preserves expected word-wise editing behavior ## Testing - just fmt - cargo test -p codex-tui Co-authored-by: Codex --- codex-rs/tui/src/app.rs | 11 ++++++++ codex-rs/tui/src/multi_agents.rs | 43 ++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 75646d892..d5b249fbc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3686,10 +3686,18 @@ impl App { } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + // Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless + // enhanced keyboard reporting is available. We only treat those word-motion fallbacks as + // agent-switch shortcuts when the composer is empty so we never steal the expected + // editing behavior for moving across words inside a draft. let allow_agent_word_motion_fallback = !self.enhanced_keys_supported && self.chat_widget.composer_text_with_pending().is_empty(); if self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() + // Alt+Left/Right are also natural word-motion keys in the composer. Keep agent + // fast-switch available only once the draft is empty so editing behavior wins whenever + // there is text on screen. + && self.chat_widget.composer_text_with_pending().is_empty() && previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( @@ -3702,6 +3710,9 @@ impl App { } if self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() + // Mirror the previous-agent rule above: empty drafts may use these keys for thread + // switching, but non-empty drafts keep them for expected word-wise cursor motion. + && self.chat_widget.composer_text_with_pending().is_empty() && next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index c68bf1970..db66acf71 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -121,8 +121,10 @@ fn previous_agent_word_motion_fallback( key_event: KeyEvent, allow_word_motion_fallback: bool, ) -> bool { - // macOS terminals often send Option+b/f as word-motion keys instead of Option+arrow events - // unless enhanced keyboard reporting is enabled. + // Some terminals, especially on macOS, send Option+b/f as word-motion keys instead of + // Option+arrow events unless enhanced keyboard reporting is enabled. Callers should only + // enable this fallback when the composer is empty so draft editing retains the expected + // word-wise motion behavior. allow_word_motion_fallback && matches!( key_event, @@ -145,8 +147,10 @@ fn previous_agent_word_motion_fallback( #[cfg(target_os = "macos")] fn next_agent_word_motion_fallback(key_event: KeyEvent, allow_word_motion_fallback: bool) -> bool { - // macOS terminals often send Option+b/f as word-motion keys instead of Option+arrow events - // unless enhanced keyboard reporting is enabled. + // Some terminals, especially on macOS, send Option+b/f as word-motion keys instead of + // Option+arrow events unless enhanced keyboard reporting is enabled. Callers should only + // enable this fallback when the composer is empty so draft editing retains the expected + // word-wise motion behavior. allow_word_motion_fallback && matches!( key_event, @@ -671,7 +675,15 @@ mod tests { #[cfg(target_os = "macos")] #[test] - fn agent_shortcut_matches_option_arrow_word_motion_fallbacks() { + fn agent_shortcut_matches_option_arrow_word_motion_fallbacks_only_when_allowed() { + assert!(previous_agent_shortcut_matches( + KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), + false, + )); + assert!(next_agent_shortcut_matches( + KeyEvent::new(KeyCode::Right, KeyModifiers::ALT), + false, + )); assert!(previous_agent_shortcut_matches( KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), true, @@ -690,6 +702,27 @@ mod tests { )); } + #[cfg(not(target_os = "macos"))] + #[test] + fn agent_shortcut_matches_option_arrows_only() { + assert!(previous_agent_shortcut_matches( + KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::ALT,), + false + )); + assert!(next_agent_shortcut_matches( + KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::ALT,), + false + )); + assert!(!previous_agent_shortcut_matches( + KeyEvent::new(KeyCode::Char('b'), crossterm::event::KeyModifiers::ALT,), + false + )); + assert!(!next_agent_shortcut_matches( + KeyEvent::new(KeyCode::Char('f'), crossterm::event::KeyModifiers::ALT,), + false + )); + } + #[test] fn title_styles_nickname_and_role() { let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")