marketplace/add support in plugins menu v2

This commit is contained in:
canvrno-oai
2026-04-16 09:50:40 -07:00
parent f49b187150
commit f039a131f8
7 changed files with 401 additions and 3 deletions
+73
View File
@@ -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<MarketplaceAddResponse> {
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,
+22
View File
@@ -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<PluginListResponse, String>,
},
/// 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<MarketplaceAddResponse, String>,
},
/// Replace the plugins popup with a plugin-detail loading state.
OpenPluginDetailLoading {
plugin_display_name: String,
+187 -1
View File
@@ -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<MarketplaceAddResponse, String>,
) {
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<String>) -> Vec<String> {
@@ -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.
@@ -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.
+1
View File
@@ -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;
@@ -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;