/plugins: remove marketplace (#19843)

This PR adds marketplace removal to the /plugins menu, giving users a
way to remove user-configured plugin marketplaces. It adds a `Ctrl+R`
shortcut to remove selected marketplace tabs, a confirmation prompt,
loading and error states, and the app-server request flow needed to
perform marketplace/remove. After a successful removal, the TUI
refreshes config, plugin mentions, user config, and plugin data so the
removed marketplace disappears from the menu and other surfaces in the
TUI.

- Add `Ctrl+R` removal option for user-configured marketplace tabs
- Show marketplace removal confirmation, loading, and error states
- Route `marketplace/remove` through the TUI background request flow
- Refresh config, plugin mentions, and plugin data after successful
removal
- Adds reusable per-tab footer hints so removal guidance only appears on
applicable tabs
- Add test coverage for `Ctrl+R` behavior while plugin search is active

Steps to test:
- Add a marketplace using the TUI /plugins menu
- Use Ctrl+R to remove the marketplace
- Accept the confirmation prompt
- Confirm the marketplace is removed when the process completes.
This commit is contained in:
canvrno-oai authored and GitHub committed 2026-04-30 10:25:07 -07:00
1 parent c02814c106
commit a85d265097
9 files changed
+593 -9

No files matched your search

+311 -3
View File
@@ -14,12 +14,15 @@ use crate::bottom_pane::SelectionToggle;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::history_cell;
use crate::key_hint;
use crate::legacy_core::config::Config;
use crate::onboarding::mark_url_hyperlink;
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::MarketplaceRemoveResponse;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInstallResponse;
@@ -31,11 +34,14 @@ 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 ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
@@ -301,6 +307,53 @@ impl ChatWidget {
}
}
pub(crate) fn open_marketplace_remove_confirmation(
&mut self,
marketplace_name: String,
marketplace_display_name: String,
) {
self.plugins_active_tab_id = self
.bottom_pane
.active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID)
.map(str::to_string)
.or_else(|| self.plugins_active_tab_id.clone());
let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd()
else {
return;
};
let params = self.marketplace_remove_confirmation_popup_params(
&plugins_response,
marketplace_name.clone(),
marketplace_display_name.clone(),
);
if !self
.bottom_pane
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
{
self.bottom_pane.show_selection_view(
self.marketplace_remove_confirmation_popup_params(
&plugins_response,
marketplace_name,
marketplace_display_name,
),
);
}
}
pub(crate) fn open_marketplace_remove_loading_popup(&mut self, marketplace_display_name: &str) {
let params = self.marketplace_remove_loading_popup_params(marketplace_display_name);
if !self
.bottom_pane
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
{
self.bottom_pane.show_selection_view(
self.marketplace_remove_loading_popup_params(marketplace_display_name),
);
}
}
pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) {
self.plugins_active_tab_id = self
.bottom_pane
@@ -465,6 +518,82 @@ impl ChatWidget {
}
}
pub(crate) fn on_marketplace_remove_loaded(
&mut self,
cwd: PathBuf,
marketplace_name: String,
marketplace_display_name: String,
result: Result<MarketplaceRemoveResponse, String>,
) {
if self.config.cwd.as_path() != cwd.as_path() {
return;
}
match result {
Ok(response) => {
self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string());
self.add_info_message(
format!("Removed marketplace {marketplace_display_name}."),
Some(match response.installed_root {
Some(installed_root) => {
format!("Marketplace root: {}", installed_root.as_path().display())
}
None => format!(
"Removed marketplace config for {}.",
response.marketplace_name
),
}),
);
}
Err(_) => {
let params = self.marketplace_remove_error_popup_params(
&marketplace_name,
&marketplace_display_name,
);
if !self
.bottom_pane
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
{
self.bottom_pane.show_selection_view(
self.marketplace_remove_error_popup_params(
&marketplace_name,
&marketplace_display_name,
),
);
}
}
}
}
pub(crate) fn handle_plugins_popup_key_event(&mut self, key_event: KeyEvent) -> bool {
if !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) {
return false;
}
let Some(active_tab_id) = self
.bottom_pane
.active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID)
else {
return false;
};
let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd()
else {
return false;
};
let Some(marketplace) = plugins_response.marketplaces.iter().find(|marketplace| {
marketplace_tab_id(marketplace) == active_tab_id
&& marketplace_is_user_configured(&self.config, &marketplace.name)
}) else {
return false;
};
self.open_marketplace_remove_confirmation(
marketplace.name.clone(),
marketplace_display_name(marketplace),
);
true
}
pub(crate) fn on_plugin_enabled_set(
&mut self,
cwd: PathBuf,
@@ -780,6 +909,103 @@ impl ChatWidget {
}
}
fn marketplace_remove_confirmation_popup_params(
&self,
plugins_response: &PluginListResponse,
marketplace_name: String,
marketplace_display_name: String,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Remove {marketplace_display_name} marketplace?").dim(),
));
header.push(Line::from(
"This removes the configured marketplace from Codex.".dim(),
));
let cwd_for_remove = self.config.cwd.to_path_buf();
let cwd_for_cancel = self.config.cwd.to_path_buf();
let cwd_for_on_cancel = self.config.cwd.to_path_buf();
let plugins_response_for_cancel = plugins_response.clone();
let plugins_response_for_on_cancel = plugins_response.clone();
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(Line::from(vec![
Span::from(key_hint::plain(KeyCode::Enter)),
" select".dim(),
" · ".into(),
"esc close".dim(),
])),
items: vec![
SelectionItem {
name: "Remove marketplace".to_string(),
description: Some(
"Remove this marketplace from the available plugin list.".to_string(),
),
selected_description: Some(
"Remove this marketplace from the available plugin list.".to_string(),
),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenMarketplaceRemoveLoading {
marketplace_display_name: marketplace_display_name.clone(),
});
tx.send(AppEvent::FetchMarketplaceRemove {
cwd: cwd_for_remove.clone(),
marketplace_name: marketplace_name.clone(),
marketplace_display_name: marketplace_display_name.clone(),
});
})],
..Default::default()
},
SelectionItem {
name: "Back to plugins".to_string(),
description: Some("Keep this marketplace installed.".to_string()),
selected_description: Some("Keep this marketplace installed.".to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PluginsLoaded {
cwd: cwd_for_cancel.clone(),
result: Ok(plugins_response_for_cancel.clone()),
});
})],
..Default::default()
},
],
on_cancel: Some(Box::new(move |tx| {
tx.send(AppEvent::PluginsLoaded {
cwd: cwd_for_on_cancel.clone(),
result: Ok(plugins_response_for_on_cancel.clone()),
});
})),
..Default::default()
}
}
fn marketplace_remove_loading_popup_params(
&self,
marketplace_display_name: &str,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Removing {marketplace_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Removing marketplace...".to_string(),
description: Some("This updates when marketplace removal 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),
@@ -913,6 +1139,63 @@ impl ChatWidget {
}
}
fn marketplace_remove_error_popup_params(
&self,
marketplace_name: &str,
marketplace_display_name: &str,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to remove marketplace.".dim()));
let marketplace_name = marketplace_name.to_string();
let marketplace_display_name = marketplace_display_name.to_string();
let mut items = vec![
SelectionItem {
name: "Marketplace removal failed".to_string(),
description: Some("Failed to remove the selected marketplace.".to_string()),
is_disabled: true,
..Default::default()
},
SelectionItem {
name: "Try again".to_string(),
description: Some("Review the confirmation prompt again.".to_string()),
selected_description: Some("Review the confirmation prompt again.".to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenMarketplaceRemoveConfirm {
marketplace_name: marketplace_name.clone(),
marketplace_display_name: marketplace_display_name.clone(),
});
})],
..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,
@@ -986,6 +1269,7 @@ impl ChatWidget {
.collect();
let mut tabs = Vec::new();
let mut tab_footer_hints = Vec::new();
tabs.push(SelectionTab {
id: ALL_PLUGINS_TAB_ID.to_string(),
label: "All Plugins".to_string(),
@@ -1070,6 +1354,12 @@ impl ChatWidget {
.filter(|(_, plugin, _)| plugin.installed)
.count();
let tab_id = marketplace_tab_id(marketplace);
if marketplace_is_user_configured(&self.config, &marketplace.name) {
tab_footer_hints.push((
tab_id.clone(),
plugins_popup_hint_line(/*can_remove_marketplace*/ true),
));
}
let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) {
plugins_header(
format!("{label} installed successfully."),
@@ -1102,7 +1392,10 @@ impl ChatWidget {
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(()),
footer_hint: Some(plugins_popup_hint_line()),
footer_hint: Some(plugins_popup_hint_line(
/*can_remove_marketplace*/ false,
)),
tab_footer_hints,
tabs,
initial_tab_id: active_tab_id,
is_searchable: true,
@@ -1390,8 +1683,14 @@ impl ChatWidget {
}
}
fn plugins_popup_hint_line() -> Line<'static> {
Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close")
fn plugins_popup_hint_line(can_remove_marketplace: bool) -> Line<'static> {
if can_remove_marketplace {
Line::from(
"space enable/disable · ←/→ select marketplace · enter view details · ctrl + r remove marketplace · esc close",
)
} else {
Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close")
}
}
fn plugin_detail_hint_line() -> Line<'static> {
@@ -1521,6 +1820,15 @@ fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String {
.unwrap_or_else(|| marketplace.name.clone())
}
fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool {
config
.config_layer_stack
.get_user_layer()
.and_then(|user_layer| user_layer.config.get("marketplaces"))
.and_then(toml::Value::as_table)
.is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name))
}
fn plugin_display_name(plugin: &PluginSummary) -> String {
plugin
.interface
@@ -0,0 +1,13 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 411
expression: confirmation
---
Plugins
Remove Repo Marketplace marketplace?
This removes the configured marketplace from Codex.
› 1. Remove marketplace Remove this marketplace from the available plugin list.
2. Back to plugins Keep this marketplace installed.
enter select · esc close
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 329
assertion_line: 339
expression: popup
---
Plugins
@@ -12,4 +12,4 @@ expression: popup
Type to search plugins
› [-] Debug Plugin Available Press Enter to install or view plugin details.
space enable/disable · ←/→ select marketplace · enter view details · esc close
space enable/disable · ←/→ select marketplace · enter view details · ctrl + r remove marketplace ·
@@ -1,5 +1,6 @@
use super::*;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_features::Stage;
use pretty_assertions::assert_eq;
@@ -283,6 +284,15 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() {
let marketplace_root = plugins_test_absolute_path("marketplaces/debug");
let marketplace_path =
plugins_test_absolute_path("marketplaces/debug/.agents/plugins/marketplace.json");
let temp = tempdir().expect("tempdir");
let config_toml_path = temp.path().join("config.toml").abs();
chat.config.config_layer_stack = ConfigLayerStack::default().with_user_config(
&config_toml_path,
toml::from_str::<TomlValue>(
"[marketplaces.debug]\nsource_type = \"git\"\nsource = \"https://github.com/owner/debug.git\"\n",
)
.expect("marketplace config"),
);
render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]),
@@ -347,6 +357,117 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() {
);
}
#[tokio::test]
async fn plugins_popup_removes_user_configured_marketplace_flow() {
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();
let temp = tempdir().expect("tempdir");
let config_toml_path = temp.path().join("config.toml").abs();
chat.config.config_layer_stack = ConfigLayerStack::default().with_user_config(
&config_toml_path,
toml::from_str::<TomlValue>(
"[marketplaces.repo]\nsource_type = \"git\"\nsource = \"https://github.com/owner/repo.git\"\n",
)
.expect("marketplace config"),
);
render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![
plugins_test_curated_marketplace(Vec::new()),
plugins_test_repo_marketplace(vec![plugins_test_summary(
"plugin-debug",
"debug",
Some("Debug Plugin"),
Some("Debug marketplace plugin."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
)]),
]),
);
while rx.try_recv().is_ok() {}
for _ in 0..3 {
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
}
let repo_tab = render_bottom_popup(&chat, /*width*/ 100);
assert!(
repo_tab.contains("Repo Marketplace.")
&& repo_tab.contains("ctrl + r remove marketplace")
&& repo_tab.contains("Debug Plugin"),
"expected removable user-configured marketplace tab, got:\n{repo_tab}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
let confirmation = render_bottom_popup(&chat, /*width*/ 100);
assert!(
confirmation.contains("Remove Repo Marketplace marketplace?")
&& confirmation.contains("Remove marketplace")
&& confirmation.contains("Back to plugins"),
"expected marketplace removal confirmation, got:\n{confirmation}"
);
assert_chatwidget_snapshot!(
"plugins_popup_marketplace_remove_confirmation",
confirmation
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let marketplace_display_name = match rx.try_recv() {
Ok(AppEvent::OpenMarketplaceRemoveLoading {
marketplace_display_name,
}) => marketplace_display_name,
other => panic!("expected OpenMarketplaceRemoveLoading event, got {other:?}"),
};
assert_eq!(marketplace_display_name, "Repo Marketplace");
match rx.try_recv() {
Ok(AppEvent::FetchMarketplaceRemove {
cwd: event_cwd,
marketplace_name,
marketplace_display_name,
}) => {
assert_eq!(event_cwd, cwd);
assert_eq!(marketplace_name, "repo");
assert_eq!(marketplace_display_name, "Repo Marketplace");
}
other => panic!("expected FetchMarketplaceRemove event, got {other:?}"),
}
chat.open_marketplace_remove_loading_popup(&marketplace_display_name);
let loading = render_bottom_popup(&chat, /*width*/ 100);
assert!(
loading.contains("Removing Repo Marketplace...")
&& loading.contains("Removing marketplace..."),
"expected marketplace removal loading state, got:\n{loading}"
);
chat.on_marketplace_remove_loaded(
cwd.clone(),
"repo".to_string(),
marketplace_display_name,
Ok(MarketplaceRemoveResponse {
marketplace_name: "repo".to_string(),
installed_root: Some(plugins_test_absolute_path("marketplaces/repo")),
}),
);
chat.on_plugins_loaded(
cwd,
Ok(plugins_test_response(vec![
plugins_test_curated_marketplace(Vec::new()),
])),
);
let refreshed = render_bottom_popup(&chat, /*width*/ 100);
assert!(
refreshed.contains("Browse plugins from available marketplaces.")
&& !refreshed.contains("Repo Marketplace")
&& !refreshed.contains("Debug Plugin")
&& !refreshed.contains("ctrl + r remove marketplace"),
"expected refreshed plugin list without removed marketplace, got:\n{refreshed}"
);
}
#[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;