From f49b187150fc4044a22c3cf6b7cf1b9965d73b3e Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 17:41:36 -0700 Subject: [PATCH] Toggleable plugin enablement in TUI /plugins menu v2 --- codex-rs/tui/src/app.rs | 76 +++++++++++++ codex-rs/tui/src/app_event.rs | 15 +++ .../src/bottom_pane/list_selection_view.rs | 74 ++++++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget/plugins.rs | 98 +++++++++++++++-- ...ts__plugins_popup_curated_marketplace.snap | 10 +- ..._tests__plugins_popup_search_filtered.snap | 4 +- .../chatwidget/tests/popups_and_settings.rs | 104 +++++++++++++++++- 8 files changed, 361 insertions(+), 21 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3fbcd64909..800be46069 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -75,6 +75,8 @@ use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; @@ -82,6 +84,7 @@ use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; +use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; @@ -2150,6 +2153,31 @@ impl App { }); } + fn set_plugin_enabled( + &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(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = write_plugin_enabled(request_handle, plugin_id, enabled) + .await + .map(|_| ()) + .map_err(|err| format!("Failed to update plugin config: {err}")); + app_event_tx.send(AppEvent::PluginEnabledSet { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + enabled, + result, + }); + }); + } + fn refresh_plugin_mentions(&mut self) { let config = self.config.clone(); let app_event_tx = self.app_event_tx.clone(); @@ -4664,6 +4692,13 @@ impl App { } => { self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name); } + AppEvent::SetPluginEnabled { + cwd, + plugin_id, + enabled, + } => { + self.set_plugin_enabled(app_server, cwd, plugin_id, enabled); + } AppEvent::PluginInstallLoaded { cwd, marketplace_path, @@ -5217,6 +5252,26 @@ impl App { self.fetch_plugins_list(app_server, cwd); } } + AppEvent::PluginEnabledSet { + cwd, + plugin_id, + 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" + ); + } + 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); + } AppEvent::RefreshPluginMentions => { self.refresh_plugin_mentions(); } @@ -6462,6 +6517,27 @@ async fn fetch_plugin_uninstall( .wrap_err("plugin/uninstall failed in TUI") } +async fn write_plugin_enabled( + request_handle: AppServerRequestHandle, + plugin_id: String, + enabled: bool, +) -> Result { + let request_id = RequestId::String(format!("plugin-enable-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::ConfigValueWrite { + request_id, + params: ConfigValueWriteParams { + key_path: format!("plugins.{plugin_id}"), + value: serde_json::json!({ "enabled": enabled }), + merge_strategy: MergeStrategy::Upsert, + file_path: None, + expected_version: None, + }, + }) + .await + .wrap_err("config/value/write failed while updating plugin enablement in TUI") +} + fn build_feedback_upload_params( origin_thread_id: Option, rollout_path: Option, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a3b31aa988..8e56eafcc0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -271,6 +271,21 @@ pub(crate) enum AppEvent { result: Result, }, + /// Enable or disable an installed plugin. + SetPluginEnabled { + cwd: PathBuf, + plugin_id: String, + enabled: bool, + }, + + /// Result of enabling or disabling a plugin. + PluginEnabledSet { + cwd: PathBuf, + plugin_id: String, + enabled: bool, + result: Result<(), String>, + }, + /// Refresh plugin mention bindings from the current config. RefreshPluginMentions, 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 a933e078f3..8629c57208 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -92,6 +92,11 @@ pub(crate) fn side_by_side_layout_widths( /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; +pub(crate) struct SelectionToggle { + pub is_on: bool, + pub action: Box, +} + /// Callback invoked whenever the highlighted item changes (arrow keys, search /// filter, number-key jump). Receives the *actual* index into the unfiltered /// `items` list and the event sender. Used by the theme picker for live preview. @@ -112,6 +117,8 @@ pub(crate) type OnCancelCallback = Option>, + pub toggle: Option, + pub toggle_placeholder: Option<&'static str>, pub display_shortcut: Option, pub description: Option, pub selected_description: Option, @@ -273,6 +280,7 @@ impl ListSelectionView { } else { Some(active_tab_idx.unwrap_or(0)) }; + let has_initial_selected_idx = params.initial_selected_idx.is_some(); let mut s = Self { view_id: params.view_id, footer_note: params.footer_note, @@ -305,7 +313,7 @@ impl ListSelectionView { on_cancel: params.on_cancel, }; s.apply_filter(); - if s.tabs_enabled() { + if s.tabs_enabled() && !has_initial_selected_idx { s.select_first_enabled_row(); } s @@ -326,6 +334,15 @@ impl ListSelectionView { .unwrap_or(self.items.as_slice()) } + fn active_items_mut(&mut self) -> &mut [SelectionItem] { + if let Some(idx) = self.active_tab_idx + && let Some(tab) = self.tabs.get_mut(idx) + { + return tab.items.as_mut_slice(); + } + self.items.as_mut_slice() + } + fn active_header(&self) -> &dyn Renderable { self.active_tab_idx .and_then(|idx| self.tabs.get(idx)) @@ -435,6 +452,11 @@ impl ListSelectionView { let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); let mut name_prefix_spans = Vec::new(); name_prefix_spans.push(wrap_prefix.into()); + if let Some(toggle) = &item.toggle { + name_prefix_spans.push(if toggle.is_on { "[*] " } else { "[ ] " }.into()); + } else if let Some(placeholder) = item.toggle_placeholder { + name_prefix_spans.push(placeholder.into()); + } name_prefix_spans.extend(item.name_prefix_spans.clone()); let description = is_selected .then(|| item.selected_description.clone()) @@ -555,6 +577,44 @@ impl ListSelectionView { } } + fn selected_item_has_toggle(&self) -> bool { + self.selected_actual_idx() + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(|item| { + item.toggle.is_some() && item.disabled_reason.is_none() && !item.is_disabled + }) + } + + fn selected_item_has_toggle_placeholder(&self) -> bool { + self.selected_actual_idx() + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(|item| { + item.toggle.is_none() + && item.toggle_placeholder.is_some() + && item.disabled_reason.is_none() + && !item.is_disabled + }) + } + + fn toggle_selected(&mut self) { + let Some(actual_idx) = self.selected_actual_idx() else { + return; + }; + let app_event_tx = self.app_event_tx.clone(); + let Some(item) = self.active_items_mut().get_mut(actual_idx) else { + return; + }; + if item.is_disabled || item.disabled_reason.is_some() { + return; + } + let Some(toggle) = item.toggle.as_mut() else { + return; + }; + + toggle.is_on = !toggle.is_on; + (toggle.action)(toggle.is_on, &app_event_tx); + } + #[cfg(test)] pub(crate) fn set_search_query(&mut self, query: String) { self.search_query = query; @@ -719,6 +779,18 @@ impl BottomPaneView for ListSelectionView { self.search_query.pop(); self.apply_filter(); } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } if self.selected_item_has_toggle() => self.toggle_selected(), + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } if self.is_searchable + && self.search_query.is_empty() + && self.selected_item_has_toggle_placeholder() => {} KeyEvent { code: KeyCode::Esc, .. } => { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 74aa4ba971..23acfd1f7c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -89,6 +89,7 @@ mod skills_toggle_view; mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::ColumnWidthMode; +pub(crate) use list_selection_view::SelectionToggle; pub(crate) use list_selection_view::SelectionViewParams; pub(crate) use list_selection_view::SideContentWidth; pub(crate) use list_selection_view::popup_content_width; diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index d2a4823251..b3ef0902e6 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -7,6 +7,7 @@ use crate::app_event::AppEvent; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionTab; +use crate::bottom_pane::SelectionToggle; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; use crate::legacy_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; @@ -39,7 +40,7 @@ const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; -const PLUGIN_MENU_ROW_PREFIX_WIDTH: usize = 2; +const PLUGIN_MENU_ROW_PREFIX_WIDTH: usize = 6; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); @@ -232,9 +233,12 @@ impl ChatWidget { fn open_plugins_popup(&mut self, response: &PluginListResponse) { self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string()); - self.bottom_pane.show_selection_view( - self.plugins_popup_params(response, self.plugins_active_tab_id.clone()), - ); + self.bottom_pane + .show_selection_view(self.plugins_popup_params( + response, + self.plugins_active_tab_id.clone(), + /*initial_selected_idx*/ None, + )); } pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) { @@ -387,6 +391,49 @@ impl ChatWidget { } } + pub(crate) fn on_plugin_enabled_set( + &mut self, + cwd: PathBuf, + plugin_id: String, + enabled: bool, + result: Result<(), String>, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + if let Err(err) = result { + self.add_error_message(format!( + "Failed to update plugin config for {plugin_id}: {err}" + )); + if let PluginsCacheState::Ready(response) = self.plugins_cache_for_current_cwd() { + self.refresh_plugins_popup_if_open(&response); + } + return; + } + + let refreshed_response = match &mut self.plugins_cache { + PluginsCacheState::Ready(response) + if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) => + { + for plugin in response + .marketplaces + .iter_mut() + .flat_map(|marketplace| marketplace.plugins.iter_mut()) + .filter(|plugin| plugin.id == plugin_id) + { + plugin.enabled = enabled; + } + Some(response.clone()) + } + _ => None, + }; + + if let Some(response) = refreshed_response { + self.refresh_plugins_popup_if_open(&response); + } + } + pub(crate) fn advance_plugin_install_auth_flow(&mut self) { let should_finish = { let Some(flow) = self.plugin_install_auth_flow.as_mut() else { @@ -562,7 +609,11 @@ impl ChatWidget { let tab_id = self.plugins_active_tab_id.clone(); let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, - self.plugins_popup_params(&plugins_response, tab_id), + self.plugins_popup_params( + &plugins_response, + tab_id, + /*initial_selected_idx*/ None, + ), ); } } @@ -573,10 +624,13 @@ impl ChatWidget { .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) .map(str::to_string) .or_else(|| self.plugins_active_tab_id.clone()); + let selected_idx = self + .bottom_pane + .selected_index_for_active_view(PLUGINS_SELECTION_VIEW_ID); self.plugins_active_tab_id = active_tab_id.clone(); let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, - self.plugins_popup_params(response, active_tab_id), + self.plugins_popup_params(response, active_tab_id, selected_idx), ); } @@ -726,6 +780,7 @@ impl ChatWidget { &self, response: &PluginListResponse, active_tab_id: Option, + initial_selected_idx: Option, ) -> SelectionViewParams { let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); @@ -868,6 +923,7 @@ impl ChatWidget { search_placeholder: Some("Type to search plugins".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, name_column_width, + initial_selected_idx, ..Default::default() } } @@ -883,7 +939,7 @@ impl ChatWidget { if plugin.summary.enabled { "Installed" } else { - "Installed · Disabled" + "Disabled" } } else { match plugin.summary.install_policy { @@ -1035,8 +1091,12 @@ impl ChatWidget { plugin_brief_description_without_marketplace(plugin, status_label_width) }; let selected_status_label = format!("{status_label: Line<'static> { - Line::from("←/→ select marketplace · enter view details · esc close") + Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close") } fn plugin_detail_hint_line() -> Line<'static> { @@ -1228,13 +1302,13 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { if plugin.enabled { "Installed" } else { - "Installed · Disabled" + "Disabled" } } else { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", PluginInstallPolicy::Available => "Available", - PluginInstallPolicy::InstalledByDefault => "Available by default", + PluginInstallPolicy::InstalledByDefault => "Available", } } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 7eafc19672..563b8d0bd2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -10,9 +10,9 @@ expression: popup [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Type to search plugins -› Alpha Sync Installed · Disabled Press Enter to view plugin details. - Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. - Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. - Starter Available by default · ChatGPT Marketplace · Included by default. +› [ ] Alpha Sync Disabled Space to enable; Enter view details. + [-] Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. + [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. + [-] Starter Available · ChatGPT Marketplace · Included by default. - ←/→ select marketplace · enter view details · esc close + space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap index d803aa42ab..da1b0913fe 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -9,6 +9,6 @@ expression: popup [All Plugins] Installed (0) OpenAI Curated sla -› Slack Available Press Enter to view plugin details. +› [-] Slack Available Press Enter to view plugin details. - ←/→ select marketplace · enter view details · esc close + space enable/disable · ←/→ select marketplace · enter view details · esc close 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 218b7fb637..108509042a 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -388,11 +388,113 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { "expected /plugins to refresh installed counts after install, got:\n{after}" ); assert!( - after.contains("Installed Press Enter to view plugin details."), + after.contains("Installed Space to disable; Enter view details."), "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" ); } +#[tokio::test] +async fn plugins_popup_space_toggles_installed_plugin_from_list() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let cwd = chat.config.cwd.to_path_buf(); + 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)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SetPluginEnabled { + cwd: event_cwd, + plugin_id, + enabled, + }) => { + assert_eq!(event_cwd, cwd); + assert_eq!(plugin_id, "plugin-drive"); + assert!(!enabled); + } + other => panic!("expected SetPluginEnabled event, got {other:?}"), + } + + chat.on_plugin_enabled_set( + cwd, + "plugin-drive".to_string(), + /*enabled*/ false, + Ok(()), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("› [ ] Drive"), + "expected selected plugin row to stay selected after refresh, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_space_on_uninstalled_row_does_not_start_search() { + 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*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + while rx.try_recv().is_ok() {} + let before = render_bottom_popup(&chat, /*width*/ 100); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + let after = render_bottom_popup(&chat, /*width*/ 100); + + assert!( + rx.try_recv().is_err(), + "did not expect Space on an uninstalled plugin to emit an event" + ); + assert_eq!(after, before); +} + #[tokio::test] async fn plugins_popup_search_filters_visible_rows_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;