/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
2026-04-30 10:25:07 -07:00
committed by GitHub
Unverified
parent c02814c106
commit a85d265097
9 changed files with 593 additions and 9 deletions
@@ -7,6 +7,8 @@
use super::*;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceRemoveParams;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_utils_absolute_path::AbsolutePathBuf;
impl App {
@@ -130,6 +132,30 @@ impl App {
});
}
pub(super) fn fetch_marketplace_remove(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
marketplace_name: String,
marketplace_display_name: 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 marketplace_name_for_event = marketplace_name.clone();
let result = fetch_marketplace_remove(request_handle, marketplace_name)
.await
.map_err(|err| format!("Failed to remove marketplace: {err}"));
app_event_tx.send(AppEvent::MarketplaceRemoveLoaded {
cwd: cwd_for_event,
marketplace_name: marketplace_name_for_event,
marketplace_display_name,
result,
});
});
}
pub(super) fn fetch_plugin_install(
&mut self,
app_server: &AppServerSession,
@@ -582,6 +608,19 @@ fn marketplace_add_source_for_request(cwd: &std::path::Path, source: String) ->
source
}
pub(super) async fn fetch_marketplace_remove(
request_handle: AppServerRequestHandle,
marketplace_name: String,
) -> Result<MarketplaceRemoveResponse> {
let request_id = RequestId::String(format!("marketplace-remove-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::MarketplaceRemove {
request_id,
params: MarketplaceRemoveParams { marketplace_name },
})
.await
.wrap_err("marketplace/remove failed in TUI")
}
pub(super) async fn fetch_plugin_install(
request_handle: AppServerRequestHandle,
marketplace_path: AbsolutePathBuf,
+53
View File
@@ -390,6 +390,21 @@ impl App {
AppEvent::OpenMarketplaceAddLoading { source } => {
self.chat_widget.open_marketplace_add_loading_popup(&source);
}
AppEvent::OpenMarketplaceRemoveConfirm {
marketplace_name,
marketplace_display_name,
} => {
self.chat_widget.open_marketplace_remove_confirmation(
marketplace_name,
marketplace_display_name,
);
}
AppEvent::OpenMarketplaceRemoveLoading {
marketplace_display_name,
} => {
self.chat_widget
.open_marketplace_remove_loading_popup(&marketplace_display_name);
}
AppEvent::OpenPluginDetailLoading {
plugin_display_name,
} => {
@@ -423,6 +438,44 @@ impl App {
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() {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after marketplace add");
}
self.fetch_plugins_list(app_server, cwd);
}
}
AppEvent::FetchMarketplaceRemove {
cwd,
marketplace_name,
marketplace_display_name,
} => {
self.fetch_marketplace_remove(
app_server,
cwd,
marketplace_name,
marketplace_display_name,
);
}
AppEvent::MarketplaceRemoveLoaded {
cwd,
marketplace_name,
marketplace_display_name,
result,
} => {
let remove_succeeded = result.is_ok();
self.chat_widget.on_marketplace_remove_loaded(
cwd.clone(),
marketplace_name,
marketplace_display_name,
result,
);
if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path()
{
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after marketplace remove");
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.fetch_plugins_list(app_server, cwd);
}
}
+27
View File
@@ -14,6 +14,7 @@ use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::PluginInstallResponse;
@@ -309,6 +310,32 @@ pub(crate) enum AppEvent {
result: Result<MarketplaceAddResponse, String>,
},
/// Open the confirmation prompt for removing a marketplace.
OpenMarketplaceRemoveConfirm {
marketplace_name: String,
marketplace_display_name: String,
},
/// Replace the plugins popup with a marketplace-remove loading state.
OpenMarketplaceRemoveLoading {
marketplace_display_name: String,
},
/// Remove a marketplace by name.
FetchMarketplaceRemove {
cwd: PathBuf,
marketplace_name: String,
marketplace_display_name: String,
},
/// Result of removing a marketplace.
MarketplaceRemoveLoaded {
cwd: PathBuf,
marketplace_name: String,
marketplace_display_name: String,
result: Result<MarketplaceRemoveResponse, String>,
},
/// Replace the plugins popup with a plugin-detail loading state.
OpenPluginDetailLoading {
plugin_display_name: String,
@@ -162,6 +162,7 @@ pub(crate) struct SelectionViewParams {
pub subtitle: Option<String>,
pub footer_note: Option<Line<'static>>,
pub footer_hint: Option<Line<'static>>,
pub tab_footer_hints: Vec<(String, Line<'static>)>,
pub items: Vec<SelectionItem>,
pub tabs: Vec<SelectionTab>,
pub initial_tab_id: Option<String>,
@@ -209,6 +210,7 @@ impl Default for SelectionViewParams {
subtitle: None,
footer_note: None,
footer_hint: None,
tab_footer_hints: Vec::new(),
items: Vec::new(),
tabs: Vec::new(),
initial_tab_id: None,
@@ -239,6 +241,7 @@ pub(crate) struct ListSelectionView {
view_id: Option<&'static str>,
footer_note: Option<Line<'static>>,
footer_hint: Option<Line<'static>>,
tab_footer_hints: Vec<(String, Line<'static>)>,
items: Vec<SelectionItem>,
tabs: Vec<SelectionTab>,
active_tab_idx: Option<usize>,
@@ -309,6 +312,7 @@ impl ListSelectionView {
view_id: params.view_id,
footer_note: params.footer_note,
footer_hint: params.footer_hint,
tab_footer_hints: params.tab_footer_hints,
items: params.items,
tabs: params.tabs,
active_tab_idx,
@@ -377,6 +381,16 @@ impl ListSelectionView {
.unwrap_or(self.header.as_ref())
}
fn active_footer_hint(&self) -> Option<&Line<'static>> {
self.active_tab_id()
.and_then(|active_tab_id| {
self.tab_footer_hints
.iter()
.find_map(|(tab_id, hint)| (tab_id.as_str() == active_tab_id).then_some(hint))
})
.or(self.footer_hint.as_ref())
}
fn active_tab_id(&self) -> Option<&str> {
self.active_tab_idx
.and_then(|idx| self.tabs.get(idx))
@@ -1001,7 +1015,7 @@ impl Renderable for ListSelectionView {
let note_lines = wrap_styled_line(note, note_width);
height = height.saturating_add(note_lines.len() as u16);
}
if self.footer_hint.is_some() {
if self.active_footer_hint().is_some() {
height = height.saturating_add(1);
}
height
@@ -1018,7 +1032,7 @@ impl Renderable for ListSelectionView {
.as_ref()
.map(|note| wrap_styled_line(note, note_width));
let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16);
let footer_rows = note_height + u16::from(self.footer_hint.is_some());
let footer_rows = note_height + u16::from(self.active_footer_hint().is_some());
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area);
@@ -1196,7 +1210,11 @@ impl Renderable for ListSelectionView {
if footer_area.height > 0 {
let [note_area, hint_area] = Layout::vertical([
Constraint::Length(note_height),
Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }),
Constraint::Length(if self.active_footer_hint().is_some() {
1
} else {
0
}),
])
.areas(footer_area);
@@ -1221,7 +1239,7 @@ impl Renderable for ListSelectionView {
}
}
if let Some(hint) = &self.footer_hint {
if let Some(hint) = self.active_footer_hint() {
let hint_area = Rect {
x: hint_area.x + 2,
y: hint_area.y,
+5
View File
@@ -5486,6 +5486,7 @@ impl ChatWidget {
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c')
)
&& !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event)
{
self.bottom_pane.handle_key_event(key_event);
if self.bottom_pane.no_modal_or_popup_active() {
@@ -5603,6 +5604,10 @@ impl ChatWidget {
return;
}
if self.handle_plugins_popup_key_event(key_event) {
return;
}
match key_event {
KeyEvent {
code: KeyCode::BackTab,
+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;