From f039a131f84432b4410ba4125b69b6f4aea47f71 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 19:11:30 -0700 Subject: [PATCH] marketplace/add support in plugins menu v2 --- codex-rs/tui/src/app.rs | 73 +++++++ codex-rs/tui/src/app_event.rs | 22 ++ codex-rs/tui/src/chatwidget/plugins.rs | 188 +++++++++++++++++- ...ts__plugins_popup_curated_marketplace.snap | 2 +- ..._tests__plugins_popup_search_filtered.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 116 +++++++++++ 7 files changed, 401 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 800be46069..565f11923e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -82,6 +82,8 @@ use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::MarketplaceAddParams; +use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::MergeStrategy; @@ -2086,6 +2088,28 @@ impl App { }); } + fn fetch_marketplace_add( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + source: String, + ) { + 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 source_for_event = source.clone(); + let result = fetch_marketplace_add(request_handle, source) + .await + .map_err(|err| format!("Failed to add marketplace: {err}")); + app_event_tx.send(AppEvent::MarketplaceAddLoaded { + cwd: cwd_for_event, + source: source_for_event, + result, + }); + }); + } + fn fetch_plugin_detail( &mut self, app_server: &AppServerSession, @@ -4644,6 +4668,37 @@ impl App { AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } + AppEvent::OpenMarketplaceAddPrompt => { + self.chat_widget.open_marketplace_add_prompt(); + } + AppEvent::OpenMarketplaceAddLoading { source } => { + self.chat_widget.open_marketplace_add_loading_popup(&source); + } + AppEvent::FetchMarketplaceAdd { cwd, source } => { + self.fetch_marketplace_add(app_server, cwd, source); + } + AppEvent::MarketplaceAddLoaded { + cwd, + source, + result, + } => { + let add_succeeded = result.is_ok(); + if add_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after marketplace add" + ); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + self.chat_widget + .on_marketplace_add_loaded(cwd.clone(), source, result); + if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { + self.fetch_plugins_list(app_server, cwd); + } + } AppEvent::OpenPluginDetailLoading { plugin_display_name, } => { @@ -6470,6 +6525,24 @@ fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) { .retain(|marketplace| !CLI_HIDDEN_PLUGIN_MARKETPLACES.contains(&marketplace.name.as_str())); } +async fn fetch_marketplace_add( + request_handle: AppServerRequestHandle, + source: String, +) -> Result { + let request_id = RequestId::String(format!("marketplace-add-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::MarketplaceAdd { + request_id, + params: MarketplaceAddParams { + source, + ref_name: None, + sparse_paths: None, + }, + }) + .await + .wrap_err("marketplace/add failed") +} + async fn fetch_plugin_detail( request_handle: AppServerRequestHandle, params: PluginReadParams, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8e56eafcc0..827b7e34aa 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; +use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; @@ -212,6 +213,27 @@ pub(crate) enum AppEvent { result: Result, }, + /// Open the prompt for adding a marketplace source. + OpenMarketplaceAddPrompt, + + /// Replace the plugins popup with a marketplace-add loading state. + OpenMarketplaceAddLoading { + source: String, + }, + + /// Add a marketplace from the provided source. + FetchMarketplaceAdd { + cwd: PathBuf, + source: String, + }, + + /// Result of adding a marketplace. + MarketplaceAddLoaded { + cwd: PathBuf, + source: String, + result: Result, + }, + /// Replace the plugins popup with a plugin-detail loading state. OpenPluginDetailLoading { plugin_display_name: String, diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index b3ef0902e6..8118ad94a6 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -9,6 +9,7 @@ use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionTab; use crate::bottom_pane::SelectionToggle; use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::history_cell; use crate::legacy_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use crate::onboarding::mark_url_hyperlink; @@ -16,6 +17,7 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; +use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -40,6 +42,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 ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; 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); @@ -241,6 +244,43 @@ impl ChatWidget { )); } + pub(crate) fn open_marketplace_add_prompt(&mut self) { + self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); + let tx = self.app_event_tx.clone(); + let cwd = self.config.cwd.to_path_buf(); + let view = CustomPromptView::new( + "Add marketplace".to_string(), + "owner/repo, git URL, or local marketplace path".to_string(), + Some("Examples: owner/repo, git URL, ./marketplace".to_string()), + Box::new(move |source: String| { + let source = source.trim().to_string(); + if source.is_empty() { + return; + } + tx.send(AppEvent::OpenMarketplaceAddLoading { + source: source.clone(), + }); + tx.send(AppEvent::FetchMarketplaceAdd { + cwd: cwd.clone(), + source, + }); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(crate) fn open_marketplace_add_loading_popup(&mut self, source: &str) { + self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); + let params = self.marketplace_add_loading_popup_params(source); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.bottom_pane + .show_selection_view(self.marketplace_add_loading_popup_params(source)); + } + } + pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) { self.plugins_active_tab_id = self .bottom_pane @@ -267,6 +307,51 @@ impl ChatWidget { .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); } + pub(crate) fn on_marketplace_add_loaded( + &mut self, + cwd: PathBuf, + source: String, + result: Result, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + match result { + Ok(response) => { + self.plugins_active_tab_id = + Some(marketplace_tab_id_from_path(&response.installed_root)); + let message = if response.already_added { + format!( + "Marketplace {} is already added.", + response.marketplace_name + ) + } else { + format!("Added marketplace {}.", response.marketplace_name) + }; + self.add_info_message( + message, + Some(format!( + "Marketplace root: {}", + response.installed_root.as_path().display() + )), + ); + } + Err(err) => { + self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); + let params = self.marketplace_add_error_popup_params(&source, &err); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.bottom_pane.show_selection_view( + self.marketplace_add_error_popup_params(&source, &err), + ); + } + } + } + } + pub(crate) fn on_plugin_detail_loaded( &mut self, cwd: PathBuf, @@ -653,6 +738,27 @@ impl ChatWidget { } } + fn marketplace_add_loading_popup_params(&self, source: &str) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Adding marketplace...".to_string(), + Some(source.to_string()), + )), + items: vec![SelectionItem { + name: "Adding marketplace...".to_string(), + description: Some( + "This updates when marketplace installation completes.".to_string(), + ), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), @@ -736,6 +842,54 @@ impl ChatWidget { } } + fn marketplace_add_error_popup_params(&self, source: &str, err: &str) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to add marketplace.".dim())); + + let mut items = vec![ + SelectionItem { + name: "Marketplace add failed".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some(format!("Enter a marketplace source. Last source: {source}")), + selected_description: Some("Enter a marketplace source.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }, + ]; + + if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + fn plugin_detail_error_popup_params( &self, err: &str, @@ -800,6 +954,7 @@ impl ChatWidget { .map(|(_, _, display_name)| { PLUGIN_MENU_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str()) }) + .chain([UnicodeWidthStr::width("Add marketplace")]) .max(); let installed_entries = all_entries .iter() @@ -913,6 +1068,8 @@ impl ChatWidget { }); } + tabs.push(self.marketplace_add_tab()); + SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(()), @@ -928,6 +1085,31 @@ impl ChatWidget { } } + fn marketplace_add_tab(&self) -> SelectionTab { + SelectionTab { + id: ADD_MARKETPLACE_TAB_ID.to_string(), + label: "Add Marketplace".to_string(), + header: plugins_header( + "Add a marketplace from a Git repo or local root.".to_string(), + "Enter a source to make its plugins available in this menu.".to_string(), + /*remote_sync_error*/ None, + ), + items: vec![SelectionItem { + name: "Add marketplace".to_string(), + description: Some( + "Enter owner/repo, a Git URL, or a local marketplace path.".to_string(), + ), + selected_description: Some( + "Press Enter to enter a marketplace source.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }], + } + } + fn plugin_detail_popup_params( &self, plugins_response: &PluginListResponse, @@ -1210,7 +1392,11 @@ fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, } fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { - format!("marketplace:{}", marketplace.path.display()) + marketplace_tab_id_from_path(&marketplace.path) +} + +fn marketplace_tab_id_from_path(path: &AbsolutePathBuf) -> String { + format!("marketplace:{}", path.display()) } fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { 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 563b8d0bd2..f9fbeaa4c9 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 @@ -7,7 +7,7 @@ expression: popup Installed 1 of 4 available plugins. Using cached marketplace data: remote sync timed out - [All Plugins] Installed (1) OpenAI Curated Repo Marketplace + [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace Type to search plugins › [ ] Alpha Sync Disabled Space to enable; Enter view details. 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 da1b0913fe..297a842ea7 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 @@ -6,7 +6,7 @@ expression: popup Browse plugins from available marketplaces. Installed 0 of 3 available plugins. - [All Plugins] Installed (0) OpenAI Curated + [All Plugins] Installed (0) OpenAI Curated Add Marketplace sla › [-] Slack Available Press Enter to view plugin details. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e92a809e80..67be438477 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -68,6 +68,7 @@ pub(super) use codex_app_server_protocol::ItemCompletedNotification; pub(super) use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification; pub(super) use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification; pub(super) use codex_app_server_protocol::ItemStartedNotification; +pub(super) use codex_app_server_protocol::MarketplaceAddResponse; pub(super) use codex_app_server_protocol::MarketplaceInterface; pub(super) use codex_app_server_protocol::McpServerStartupState; pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification; 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 108509042a..0ed77e0756 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -163,6 +163,122 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ ); } +#[tokio::test] +async fn plugins_popup_add_marketplace_tab_opens_prompt_and_submits_source() { + 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::new())]), + ); + + while rx.try_recv().is_ok() {} + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Add a marketplace from a Git repo or local root."), + "expected Add Marketplace tab, got:\n{popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match rx.try_recv() { + Ok(AppEvent::OpenMarketplaceAddPrompt) => {} + other => panic!("expected OpenMarketplaceAddPrompt event, got {other:?}"), + } + + chat.open_marketplace_add_prompt(); + let prompt = render_bottom_popup(&chat, /*width*/ 100); + assert!( + prompt.contains("owner/repo, git URL, or local marketplace path"), + "expected marketplace source prompt, got:\n{prompt}" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenMarketplaceAddLoading { source }) => { + assert_eq!(source, "owner/repo"); + } + other => panic!("expected OpenMarketplaceAddLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchMarketplaceAdd { + cwd: event_cwd, + source, + }) => { + assert_eq!(event_cwd, cwd); + assert_eq!(source, "owner/repo"); + } + other => panic!("expected FetchMarketplaceAdd event, got {other:?}"), + } +} + +#[tokio::test] +async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { + let (mut chat, _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(); + let marketplace_path = plugins_test_absolute_path("marketplaces/debug"); + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]), + ); + chat.open_marketplace_add_loading_popup("owner/repo"); + chat.on_marketplace_add_loaded( + cwd.clone(), + "owner/repo".to_string(), + Ok(MarketplaceAddResponse { + marketplace_name: "debug".to_string(), + installed_root: marketplace_path.clone(), + already_added: false, + }), + ); + chat.on_plugins_loaded( + cwd, + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + PluginMarketplaceEntry { + name: "debug".to_string(), + path: marketplace_path, + interface: Some(MarketplaceInterface { + display_name: Some("Debug Marketplace".to_string()), + }), + plugins: vec![plugins_test_summary( + "plugin-debug", + "debug", + Some("Debug Plugin"), + Some("Debug marketplace plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )], + }, + ])), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Debug Marketplace.") && popup.contains("Debug Plugin"), + "expected marketplace add refresh to switch to the new marketplace tab, got:\n{popup}" + ); +} + #[tokio::test] async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;