From 52f9ec871bedbd53a0c0741d0b7bc47e3dae3da9 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 3 Jun 2026 11:10:15 -0700 Subject: [PATCH 1/5] init --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/background_requests.rs | 35 +- codex-rs/tui/src/app/event_dispatch.rs | 14 +- codex-rs/tui/src/app_event.rs | 19 +- codex-rs/tui/src/chatwidget/plugins.rs | 200 ++++++++--- codex-rs/tui/src/chatwidget/tests/helpers.rs | 28 ++ .../chatwidget/tests/popups_and_settings.rs | 339 ++++++++++++++++++ 7 files changed, 564 insertions(+), 72 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4b86eda89852..600193080b58 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -11,6 +11,7 @@ use crate::app_event::ExitMode; use crate::app_event::FeedbackCategory; use crate::app_event::HistoryLookupResponse; use crate::app_event::PermissionProfileSelection; +use crate::app_event::PluginLocation; use crate::app_event::RateLimitRefreshOrigin; use crate::app_event::RealtimeAudioDeviceKind; #[cfg(target_os = "windows")] diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 456676f7963e..a69d98744775 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -241,7 +241,7 @@ impl App { &mut self, app_server: &AppServerSession, cwd: PathBuf, - marketplace_path: AbsolutePathBuf, + location: PluginLocation, plugin_name: String, plugin_display_name: String, ) { @@ -249,14 +249,14 @@ impl App { let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let cwd_for_event = cwd.clone(); - let marketplace_path_for_event = marketplace_path.clone(); + let location_for_event = location.clone(); let plugin_name_for_event = plugin_name.clone(); - let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name) + let result = fetch_plugin_install(request_handle, location, plugin_name) .await .map_err(|err| format!("Failed to install plugin: {err}")); app_event_tx.send(AppEvent::PluginInstallLoaded { cwd: cwd_for_event, - marketplace_path: marketplace_path_for_event, + location: location_for_event, plugin_name: plugin_name_for_event, plugin_display_name, result, @@ -835,16 +835,17 @@ pub(super) async fn fetch_marketplace_upgrade( } pub(super) async fn fetch_plugin_install( request_handle: AppServerRequestHandle, - marketplace_path: AbsolutePathBuf, + location: PluginLocation, plugin_name: String, ) -> Result { let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4())); + let (marketplace_path, remote_marketplace_name) = location.into_request_params(); request_handle .request_typed(ClientRequest::PluginInstall { request_id, params: PluginInstallParams { - marketplace_path: Some(marketplace_path), - remote_marketplace_name: None, + marketplace_path, + remote_marketplace_name, plugin_name, }, }) @@ -1062,6 +1063,26 @@ mod tests { ); } + #[test] + fn plugin_location_request_params_select_exactly_one_location() { + let local_path = test_absolute_path("/marketplaces/local"); + + assert_eq!( + PluginLocation::Local { + marketplace_path: local_path.clone() + } + .into_request_params(), + (Some(local_path), None) + ); + assert_eq!( + PluginLocation::Remote { + marketplace_name: "workspace-directory".to_string() + } + .into_request_params(), + (None, Some("workspace-directory".to_string())) + ); + } + #[test] fn mcp_inventory_maps_prefix_tool_names_by_server() { let statuses = vec![ diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 548aa5442781..2d97a513c0c3 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -573,14 +573,14 @@ impl App { } AppEvent::FetchPluginInstall { cwd, - marketplace_path, + location, plugin_name, plugin_display_name, } => { self.fetch_plugin_install( app_server, cwd, - marketplace_path, + location, plugin_name, plugin_display_name, ); @@ -601,7 +601,7 @@ impl App { } AppEvent::PluginInstallLoaded { cwd, - marketplace_path, + location, plugin_name, plugin_display_name, result, @@ -612,7 +612,7 @@ impl App { } let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( cwd.clone(), - marketplace_path.clone(), + location.clone(), plugin_name.clone(), plugin_display_name, result, @@ -621,12 +621,14 @@ impl App { { self.fetch_plugins_list(app_server, cwd.clone()); if should_refresh_plugin_detail { + let (marketplace_path, remote_marketplace_name) = + location.into_request_params(); self.fetch_plugin_detail( app_server, cwd, PluginReadParams { - marketplace_path: Some(marketplace_path), - remote_marketplace_name: None, + marketplace_path, + remote_marketplace_name, plugin_name, }, ); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index c2f9ee3f312e..41fa6bdce4a0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -109,6 +109,21 @@ pub(crate) struct ConnectorsSnapshot { pub(crate) connectors: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PluginLocation { + Local { marketplace_path: AbsolutePathBuf }, + Remote { marketplace_name: String }, +} + +impl PluginLocation { + pub(crate) fn into_request_params(self) -> (Option, Option) { + match self { + PluginLocation::Local { marketplace_path } => (Some(marketplace_path), None), + PluginLocation::Remote { marketplace_name } => (None, Some(marketplace_name)), + } + } +} + /// Distinguishes why a rate-limit refresh was requested so the completion /// handler can route the result correctly. /// @@ -493,7 +508,7 @@ pub(crate) enum AppEvent { /// Install a specific plugin from a marketplace. FetchPluginInstall { cwd: PathBuf, - marketplace_path: AbsolutePathBuf, + location: PluginLocation, plugin_name: String, plugin_display_name: String, }, @@ -501,7 +516,7 @@ pub(crate) enum AppEvent { /// Result of installing a plugin. PluginInstallLoaded { cwd: PathBuf, - marketplace_path: AbsolutePathBuf, + location: PluginLocation, plugin_name: String, plugin_display_name: String, result: Result, diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 746f42fce942..94b405e170e4 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -5,6 +5,7 @@ use std::time::Instant; use super::ChatWidget; use crate::app_event::AppEvent; +use crate::app_event::PluginLocation; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -25,12 +26,14 @@ use crate::tui::FrameRequester; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeResponse; +use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; @@ -453,7 +456,7 @@ impl ChatWidget { pub(crate) fn on_plugin_install_loaded( &mut self, cwd: PathBuf, - _marketplace_path: AbsolutePathBuf, + _location: PluginLocation, _plugin_name: String, plugin_display_name: String, result: Result, @@ -1625,19 +1628,22 @@ impl ChatWidget { ) -> SelectionViewParams { let marketplace_label = plugin.marketplace_name.clone(); let display_name = plugin_display_name(&plugin.summary); - let detail_status_label = if plugin.summary.installed { - if plugin.summary.enabled { - "Installed" + let detail_status_label = + if plugin.summary.availability == PluginAvailability::DisabledByAdmin { + "Disabled by admin" + } else if plugin.summary.installed { + if plugin.summary.enabled { + "Installed" + } else { + "Disabled" + } } else { - "Disabled" - } - } else { - match plugin.summary.install_policy { - PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Can be installed", - PluginInstallPolicy::InstalledByDefault => "Available by default", - } - }; + match plugin.summary.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + }; let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -1676,23 +1682,40 @@ impl ChatWidget { }]; if plugin.summary.installed { - let uninstall_cwd = self.config.cwd.to_path_buf(); - let plugin_id = plugin.summary.id.clone(); - let plugin_display_name = display_name; + if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) { + let uninstall_cwd = self.config.cwd.to_path_buf(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some("Remove this plugin now.".to_string()), + selected_description: Some("Remove this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginUninstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginUninstall { + cwd: uninstall_cwd.clone(), + plugin_id: plugin_id.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some( + "This remote plugin did not provide an uninstall identity.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + } else if plugin.summary.availability == PluginAvailability::DisabledByAdmin { items.push(SelectionItem { - name: "Uninstall plugin".to_string(), - description: Some("Remove this plugin now.".to_string()), - selected_description: Some("Remove this plugin now.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginUninstallLoading { - plugin_display_name: plugin_display_name.clone(), - }); - tx.send(AppEvent::FetchPluginUninstall { - cwd: uninstall_cwd.clone(), - plugin_id: plugin_id.clone(), - plugin_display_name: plugin_display_name.clone(), - }); - })], + name: "Install plugin".to_string(), + description: Some("This plugin is disabled by your workspace admin.".to_string()), + is_disabled: true, ..Default::default() }); } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { @@ -1704,9 +1727,9 @@ impl ChatWidget { is_disabled: true, ..Default::default() }); - } else if let Some(marketplace_path) = plugin.marketplace_path.clone() { + } else if let Some(location) = plugin_detail_location(plugin) { let install_cwd = self.config.cwd.to_path_buf(); - let plugin_name = plugin.summary.name.clone(); + let plugin_name = plugin_request_name(&plugin.summary); let plugin_display_name = display_name; items.push(SelectionItem { name: "Install plugin".to_string(), @@ -1718,7 +1741,7 @@ impl ChatWidget { }); tx.send(AppEvent::FetchPluginInstall { cwd: install_cwd.clone(), - marketplace_path: marketplace_path.clone(), + location: location.clone(), plugin_name: plugin_name.clone(), plugin_display_name: plugin_display_name.clone(), }); @@ -1728,7 +1751,7 @@ impl ChatWidget { } else { items.push(SelectionItem { name: "Install plugin".to_string(), - description: Some("Installing remote plugins is not supported yet.".to_string()), + description: Some("This plugin did not provide an install location.".to_string()), is_disabled: true, ..Default::default() }); @@ -1792,9 +1815,12 @@ impl ChatWidget { } else { plugin_brief_description_without_marketplace(plugin, status_label_width) }; - let can_view_details = marketplace.path.is_some(); + let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); + let can_view_details = plugin_detail_request.is_some(); + let can_toggle_plugin = + plugin.installed && plugin.availability != PluginAvailability::DisabledByAdmin; let selected_status_label = format!("{status_label: = if let Some(marketplace_path) = marketplace_path { - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginDetailLoading { - plugin_display_name: plugin_display_name.clone(), - }); - tx.send(AppEvent::FetchPluginDetail { - cwd: cwd.clone(), - params: codex_app_server_protocol::PluginReadParams { - marketplace_path: Some(marketplace_path.clone()), - remote_marketplace_name: None, - plugin_name: plugin_name.clone(), - }, - }); - })] - } else { - Vec::new() - }; + let actions: Vec = + if let Some((location, plugin_name)) = plugin_detail_request { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginDetailLoading { + plugin_display_name: plugin_display_name.clone(), + }); + let (marketplace_path, remote_marketplace_name) = + location.clone().into_request_params(); + tx.send(AppEvent::FetchPluginDetail { + cwd: cwd.clone(), + params: codex_app_server_protocol::PluginReadParams { + marketplace_path, + remote_marketplace_name, + plugin_name: plugin_name.clone(), + }, + }); + })] + } else { + Vec::new() + }; let is_disabled = !can_view_details && !plugin.installed; - let disabled_reason = - is_disabled.then(|| "remote plugin details are not available yet".to_string()); + let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string()); items.push(SelectionItem { name: display_name, @@ -2082,6 +2112,9 @@ fn plugin_brief_description_without_marketplace( } fn plugin_status_label(plugin: &PluginSummary) -> &'static str { + if plugin.availability == PluginAvailability::DisabledByAdmin { + return "Disabled by admin"; + } if plugin.installed { if plugin.enabled { "Installed" @@ -2097,6 +2130,59 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { } } +fn plugin_location_for_marketplace( + marketplace: &PluginMarketplaceEntry, + plugin: &PluginSummary, +) -> Option { + if let Some(marketplace_path) = marketplace.path.clone() { + return Some(PluginLocation::Local { marketplace_path }); + } + plugin_remote_identity(plugin).map(|_| PluginLocation::Remote { + marketplace_name: marketplace.name.clone(), + }) +} + +fn plugin_detail_location(plugin: &PluginDetail) -> Option { + if let Some(marketplace_path) = plugin.marketplace_path.clone() { + return Some(PluginLocation::Local { marketplace_path }); + } + plugin_remote_identity(&plugin.summary).map(|_| PluginLocation::Remote { + marketplace_name: plugin.marketplace_name.clone(), + }) +} + +fn plugin_detail_request_for_entry( + marketplace: &PluginMarketplaceEntry, + plugin: &PluginSummary, +) -> Option<(PluginLocation, String)> { + plugin_location_for_marketplace(marketplace, plugin) + .map(|location| (location, plugin_request_name(plugin))) +} + +fn plugin_request_name(plugin: &PluginSummary) -> String { + if matches!(plugin.source, PluginSource::Remote) + && let Some(remote_plugin_id) = plugin_remote_identity(plugin) + { + return remote_plugin_id; + } + plugin.name.clone() +} + +fn plugin_remote_identity(plugin: &PluginSummary) -> Option { + plugin + .share_context + .as_ref() + .map(|context| context.remote_plugin_id.clone()) + .or_else(|| plugin.remote_plugin_id.clone()) +} + +fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { + if matches!(plugin.source, PluginSource::Remote) { + return plugin_remote_identity(plugin); + } + Some(plugin.id.clone()) +} + fn plugin_description(plugin: &PluginSummary) -> Option { plugin .interface diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index b058b47cca39..214ca969aa3e 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1337,6 +1337,34 @@ pub(super) fn plugins_test_summary( } } +pub(super) fn plugins_test_remote_summary( + remote_plugin_id: &str, + name: &str, + display_name: Option<&str>, + description: Option<&str>, + installed: bool, +) -> PluginSummary { + PluginSummary { + id: remote_plugin_id.to_string(), + remote_plugin_id: Some(remote_plugin_id.to_string()), + local_version: None, + name: name.to_string(), + share_context: None, + source: PluginSource::Remote, + installed, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnInstall, + availability: PluginAvailability::Available, + interface: Some(plugins_test_interface( + display_name, + description, + /*long_description*/ None, + )), + keywords: Vec::new(), + } +} + pub(super) fn plugins_test_curated_marketplace( plugins: Vec, ) -> PluginMarketplaceEntry { 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 89c332a4ef6d..4e8cb5efe312 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; +use codex_app_server_protocol::PluginAvailability; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -714,6 +715,344 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { ); } +#[tokio::test] +async fn plugins_popup_remote_row_opens_remote_detail() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let popup = render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![PluginMarketplaceEntry { + name: "workspace-directory".to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some("Workspace".to_string()), + }), + plugins: vec![plugins_test_remote_summary( + "plugins~Plugin_calendar", + "calendar", + Some("Calendar"), + Some("Workspace schedules."), + /*installed*/ false, + )], + }]), + ); + let remote_row = popup + .lines() + .find(|line| line.contains("Calendar")) + .expect("expected remote plugin row"); + assert!( + remote_row.contains("Available") + && remote_row.contains("Press Enter to install or view plugin details."), + "expected remote plugin row to be viewable, got:\n{remote_row}" + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenPluginDetailLoading { + plugin_display_name, + }) => { + assert_eq!(plugin_display_name, "Calendar"); + } + other => panic!("expected OpenPluginDetailLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchPluginDetail { cwd: _, params }) => { + assert_eq!(params.marketplace_path, None); + assert_eq!( + params.remote_marketplace_name, + Some("workspace-directory".to_string()) + ); + assert_eq!(params.plugin_name, "plugins~Plugin_calendar"); + } + other => panic!("expected FetchPluginDetail event, got {other:?}"), + } +} + +#[tokio::test] +async fn plugin_detail_remote_install_uses_remote_location() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = plugins_test_remote_summary( + "plugins~Plugin_linear", + "linear", + Some("Linear"), + Some("Issue tracking."), + /*installed*/ false, + ); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![PluginMarketplaceEntry { + name: "workspace-shared-with-me-private".to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some("Shared with me".to_string()), + }), + plugins: vec![summary.clone()], + }])), + ); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd.to_path_buf(), + Ok(PluginReadResponse { + plugin: PluginDetail { + marketplace_name: "workspace-shared-with-me-private".to_string(), + marketplace_path: None, + summary, + description: Some("Install shared Linear plugin.".to_string()), + skills: Vec::new(), + hooks: Vec::new(), + apps: Vec::new(), + mcp_servers: Vec::new(), + }, + }), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Install plugin") && popup.contains("Install this plugin now."), + "expected remote detail to offer install, got:\n{popup}" + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenPluginInstallLoading { + plugin_display_name, + }) => { + assert_eq!(plugin_display_name, "Linear"); + } + other => panic!("expected OpenPluginInstallLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchPluginInstall { + cwd: _, + location: crate::app_event::PluginLocation::Remote { marketplace_name }, + plugin_name, + plugin_display_name, + }) => { + assert_eq!(marketplace_name, "workspace-shared-with-me-private"); + assert_eq!(plugin_name, "plugins~Plugin_linear"); + assert_eq!(plugin_display_name, "Linear"); + } + other => panic!("expected remote FetchPluginInstall event, got {other:?}"), + } +} + +#[tokio::test] +async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = plugins_test_remote_summary( + "plugins~Plugin_linear", + "linear", + Some("Linear"), + Some("Issue tracking."), + /*installed*/ true, + ); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![PluginMarketplaceEntry { + name: "workspace-shared-with-me-private".to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some("Shared with me".to_string()), + }), + plugins: vec![summary.clone()], + }])), + ); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd.to_path_buf(), + Ok(PluginReadResponse { + plugin: PluginDetail { + marketplace_name: "workspace-shared-with-me-private".to_string(), + marketplace_path: None, + summary, + description: Some("Installed shared Linear plugin.".to_string()), + skills: Vec::new(), + hooks: Vec::new(), + apps: Vec::new(), + mcp_servers: Vec::new(), + }, + }), + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenPluginUninstallLoading { + plugin_display_name, + }) => { + assert_eq!(plugin_display_name, "Linear"); + } + other => panic!("expected OpenPluginUninstallLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchPluginUninstall { + plugin_id, + plugin_display_name, + .. + }) => { + assert_eq!(plugin_id, "plugins~Plugin_linear"); + assert_eq!(plugin_display_name, "Linear"); + } + other => panic!("expected remote FetchPluginUninstall event, got {other:?}"), + } +} + +#[tokio::test] +async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + source: PluginSource::Remote, + ..plugins_test_summary( + "linear@workspace-shared-with-me-private", + "linear", + Some("Linear"), + Some("Issue tracking."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![PluginMarketplaceEntry { + name: "workspace-shared-with-me-private".to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some("Shared with me".to_string()), + }), + plugins: vec![summary.clone()], + }])), + ); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd.to_path_buf(), + Ok(PluginReadResponse { + plugin: PluginDetail { + marketplace_name: "workspace-shared-with-me-private".to_string(), + marketplace_path: None, + summary, + description: Some("Installed shared Linear plugin.".to_string()), + skills: Vec::new(), + hooks: Vec::new(), + apps: Vec::new(), + mcp_servers: Vec::new(), + }, + }), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 120); + assert!( + popup.contains("This remote plugin did not provide an uninstall identity.") + && !popup.contains("Remove this plugin now."), + "expected missing remote ID to disable uninstall, got:\n{popup}" + ); + + while rx.try_recv().is_ok() {} + assert!( + rx.try_recv().is_err(), + "expected no action after rendering disabled uninstall state" + ); +} + +#[tokio::test] +async fn plugin_detail_admin_disabled_plugin_blocks_install() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + availability: PluginAvailability::DisabledByAdmin, + ..plugins_test_summary( + "plugin-admin-blocked", + "admin-blocked", + Some("Admin Blocked"), + Some("Blocked by policy."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd.to_path_buf(), + Ok(PluginReadResponse { + plugin: plugins_test_detail(summary, Some("Blocked by policy."), &[], &[], &[], &[]), + }), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Admin Blocked ยท Disabled by admin") + && popup.contains("This plugin is disabled by your workspace admin.") + && !popup.contains("Install this plugin now."), + "expected admin-disabled detail to block install, got:\n{popup}" + ); + + while rx.try_recv().is_ok() {} + assert!( + rx.try_recv().is_err(), + "expected no action after rendering disabled install state" + ); +} + +#[tokio::test] +async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + availability: PluginAvailability::DisabledByAdmin, + ..plugins_test_summary( + "plugin-admin-blocked", + "admin-blocked", + Some("Admin Blocked"), + Some("Blocked by policy."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Disabled by admin") + && popup.contains("Press Enter to view plugin details.") + && !popup.contains("Space to disable"), + "expected admin-disabled installed plugin to omit toggle hint, got:\n{popup}" + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!( + rx.try_recv().is_err(), + "space should not toggle admin-disabled installed plugins" + ); +} + #[tokio::test] async fn plugin_detail_error_popup_skips_disabled_row_numbering() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 4d32a96c7580b022e5d906bcbb2eb283b80338f7 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 3 Jun 2026 15:09:21 -0700 Subject: [PATCH 2/5] cleanup --- codex-rs/tui/src/chatwidget/plugins.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 94b405e170e4..f8a8029e537a 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -38,7 +38,6 @@ use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_features::Feature; -use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; From ddd48cab1b5d617e8a01737c0665f4025b351421 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 3 Jun 2026 16:18:35 -0700 Subject: [PATCH 3/5] Adjustment for admin-disabled plugins in /plugin menu --- codex-rs/tui/src/chatwidget/plugins.rs | 8 +++++--- codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index f8a8029e537a..0f8ed0eaeb3d 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1816,8 +1816,8 @@ impl ChatWidget { }; let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); let can_view_details = plugin_detail_request.is_some(); - let can_toggle_plugin = - plugin.installed && plugin.availability != PluginAvailability::DisabledByAdmin; + let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin; + let can_toggle_plugin = plugin.installed && !disabled_by_admin; let selected_status_label = format!("{status_label: Date: Wed, 3 Jun 2026 17:23:54 -0700 Subject: [PATCH 4/5] fix --- codex-rs/tui/src/chatwidget/plugins.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 0f8ed0eaeb3d..9bc62fe261f6 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -2161,7 +2161,7 @@ fn plugin_detail_request_for_entry( } fn plugin_request_name(plugin: &PluginSummary) -> String { - if matches!(plugin.source, PluginSource::Remote) + if matches!(&plugin.source, PluginSource::Remote) && let Some(remote_plugin_id) = plugin_remote_identity(plugin) { return remote_plugin_id; @@ -2178,7 +2178,7 @@ fn plugin_remote_identity(plugin: &PluginSummary) -> Option { } fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { - if matches!(plugin.source, PluginSource::Remote) { + if matches!(&plugin.source, PluginSource::Remote) { return plugin_remote_identity(plugin); } Some(plugin.id.clone()) From ccf0f0d9892fd9b253401466d6dee7a1886ac407 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Fri, 5 Jun 2026 17:41:49 -0700 Subject: [PATCH 5/5] tui: update remote plugin detail fixtures --- codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs | 3 +++ 1 file changed, 3 insertions(+) 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 9f6215c9a7dc..ae44cf00cdb2 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -807,6 +807,7 @@ async fn plugin_detail_remote_install_uses_remote_location() { skills: Vec::new(), hooks: Vec::new(), apps: Vec::new(), + app_templates: Vec::new(), mcp_servers: Vec::new(), }, }), @@ -880,6 +881,7 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() { skills: Vec::new(), hooks: Vec::new(), apps: Vec::new(), + app_templates: Vec::new(), mcp_servers: Vec::new(), }, }), @@ -951,6 +953,7 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { skills: Vec::new(), hooks: Vec::new(), apps: Vec::new(), + app_templates: Vec::new(), mcp_servers: Vec::new(), }, }),