diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 565f11923e..988ba55d05 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1049,6 +1049,9 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + // Serialize plugin enablement writes per plugin so stale completions cannot + // overwrite a newer toggle. + pending_plugin_enabled_writes: HashMap<(PathBuf, String), Option>, } #[derive(Default)] @@ -2183,6 +2186,23 @@ impl App { cwd: PathBuf, plugin_id: String, enabled: bool, + ) { + let key = (cwd.clone(), plugin_id.clone()); + if let Some(queued_enabled) = self.pending_plugin_enabled_writes.get_mut(&key) { + *queued_enabled = Some(enabled); + return; + } + + self.pending_plugin_enabled_writes.insert(key, None); + self.spawn_plugin_enabled_write(app_server, cwd, plugin_id, enabled); + } + + fn spawn_plugin_enabled_write( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + plugin_id: String, + enabled: bool, ) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); @@ -4049,6 +4069,7 @@ impl App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_plugin_enabled_writes: HashMap::new(), }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) @@ -5313,19 +5334,35 @@ impl App { enabled, result, } => { - let update_succeeded = result.is_ok(); - if update_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after plugin toggle" - ); + let key = (cwd.clone(), plugin_id.clone()); + let queued_enabled = self + .pending_plugin_enabled_writes + .get_mut(&key) + .and_then(Option::take); + let should_apply_result = if let Some(queued_enabled) = queued_enabled + && (result.is_err() || queued_enabled != enabled) + { + self.spawn_plugin_enabled_write(app_server, cwd, plugin_id, queued_enabled); + false + } else { + true + }; + if should_apply_result { + self.pending_plugin_enabled_writes.remove(&key); + let update_succeeded = result.is_ok(); + if update_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin toggle" + ); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.chat_widget + .on_plugin_enabled_set(cwd, plugin_id, enabled, result); } - self.chat_widget - .on_plugin_enabled_set(cwd, plugin_id, enabled, result); } AppEvent::RefreshPluginMentions => { self.refresh_plugin_mentions(); diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index cbdd87804f..258ac3fcab 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -798,7 +798,11 @@ impl BottomPaneView for ListSelectionView { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. - } if self.selected_item_has_toggle() => self.toggle_selected(), + } if self.selected_item_has_toggle() + && (!self.is_searchable || self.search_query.is_empty()) => + { + self.toggle_selected() + } KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, @@ -1482,6 +1486,45 @@ mod tests { ); } + #[test] + fn space_appends_to_active_search_instead_of_toggling_selected_item() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![SelectionItem { + name: "Plugin".to_string(), + toggle: Some(SelectionToggle { + is_on: false, + action: Box::new(|_enabled, tx: &_| { + tx.send(AppEvent::OpenApprovalsPopup); + }), + }), + ..Default::default() + }], + is_searchable: true, + ..Default::default() + }, + tx, + ); + view.set_search_query("plugin".to_string()); + + view.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!(view.search_query, "plugin "); + assert!( + !view.active_items()[0] + .toggle + .as_ref() + .is_some_and(|toggle| toggle.is_on), + "expected Space to leave the toggle state unchanged while search is active" + ); + assert!( + rx.try_recv().is_err(), + "expected Space with an active search query to avoid firing the toggle action" + ); + } + #[test] fn enter_with_no_matches_triggers_cancel_callback() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 67ce7edac4..3f8e9616d2 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -663,6 +663,46 @@ async fn plugins_popup_space_on_uninstalled_row_does_not_start_search() { assert_eq!(after, before); } +#[tokio::test] +async fn plugins_popup_space_with_active_search_does_not_toggle_installed_plugin() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + type_plugins_search_query(&mut chat, "dr"); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert!( + rx.try_recv().is_err(), + "did not expect Space with an active plugin search to emit a toggle event" + ); +} + #[tokio::test] async fn plugins_popup_search_filters_visible_rows_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;