Plugins TUI install/uninstall (#15342)

- Add install/uninstall actions to the TUI plugins menu
- Wire plugin install/uninstall through both TUI and `tui_app_server`
- Refresh config/plugin state after changes so the UI updates
immediately
- Add a post-install app setup flow for plugins that require additional
app auth

<img width="1567" height="300" alt="Screenshot 2026-03-20 at 4 08 44 PM"
src="https://github.com/user-attachments/assets/366bd31b-2ffd-4e80-b4a3-3a9a9c674a5f"
/>
<img width="445" height="240" alt="Screenshot 2026-03-20 at 4 08 54 PM"
src="https://github.com/user-attachments/assets/613999ab-269a-4758-ab59-7c057a1742dc"
/>
<img width="797" height="219" alt="Screenshot 2026-03-20 at 4 09 07 PM"
src="https://github.com/user-attachments/assets/b9679e60-40f5-49bb-ade0-2e40449c3fbf"
/>
<img width="499" height="235" alt="Screenshot 2026-03-20 at 4 09 24 PM"
src="https://github.com/user-attachments/assets/261ce2fe-f356-4e99-8ac9-f29ed850bc75"
/>




Note/known issue: The /plugin install flow fails in `tui_app_server`
because after a successful install it tries to trigger a
ReloadUserConfig operation, but `tui_app_server` has not yet implemented
transport for that operation, so it falls through to the generic “Not
available in app-server TUI yet” stub.
This commit is contained in:
canvrno-oai
2026-03-23 12:38:39 -07:00
committed by GitHub
parent f55f5c258f
commit b5d0a5518d
20 changed files with 2611 additions and 56 deletions

View File

@@ -9,12 +9,15 @@ use crate::history_cell;
use crate::render::renderable::ColumnRenderable;
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::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 ratatui::style::Stylize;
use ratatui::text::Line;
@@ -69,19 +72,25 @@ impl ChatWidget {
return;
}
let auth_flow_active = self.plugin_install_auth_flow.is_some();
match result {
Ok(response) => {
self.plugins_fetch_state.cache_cwd = Some(cwd);
self.plugins_cache = PluginsCacheState::Ready(response.clone());
self.refresh_plugins_popup_if_open(&response);
if !auth_flow_active {
self.refresh_plugins_popup_if_open(&response);
}
}
Err(err) => {
self.plugins_fetch_state.cache_cwd = None;
self.plugins_cache = PluginsCacheState::Failed(err.clone());
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_error_popup_params(&err),
);
if !auth_flow_active {
self.plugins_fetch_state.cache_cwd = None;
self.plugins_cache = PluginsCacheState::Failed(err.clone());
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_error_popup_params(&err),
);
}
}
}
}
@@ -130,6 +139,20 @@ impl ChatWidget {
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
}
pub(crate) fn open_plugin_install_loading_popup(&mut self, plugin_display_name: &str) {
let params = self.plugin_install_loading_popup_params(plugin_display_name);
let _ = self
.bottom_pane
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
}
pub(crate) fn open_plugin_uninstall_loading_popup(&mut self, plugin_display_name: &str) {
let params = self.plugin_uninstall_loading_popup_params(plugin_display_name);
let _ = self
.bottom_pane
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
}
pub(crate) fn on_plugin_detail_loaded(
&mut self,
cwd: PathBuf,
@@ -162,6 +185,291 @@ impl ChatWidget {
}
}
pub(crate) fn on_plugin_install_loaded(
&mut self,
cwd: PathBuf,
_marketplace_path: AbsolutePathBuf,
_plugin_name: String,
plugin_display_name: String,
result: Result<PluginInstallResponse, String>,
) -> bool {
if self.config.cwd != cwd {
return true;
}
match result {
Ok(response) => {
self.plugin_install_apps_needing_auth = response.apps_needing_auth;
self.plugin_install_auth_flow = None;
if self.plugin_install_apps_needing_auth.is_empty() {
self.add_info_message(
format!("Installed {plugin_display_name} plugin."),
Some("No additional app authentication is required.".to_string()),
);
true
} else {
let app_names = self
.plugin_install_apps_needing_auth
.iter()
.map(|app| app.name.as_str())
.collect::<Vec<_>>()
.join(", ");
self.add_info_message(
format!("Installed {plugin_display_name} plugin."),
Some(format!(
"{} app(s) still need authentication: {app_names}",
self.plugin_install_apps_needing_auth.len()
)),
);
self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState {
plugin_display_name,
next_app_index: 0,
});
self.open_plugin_install_auth_popup();
false
}
}
Err(err) => {
self.plugin_install_apps_needing_auth.clear();
self.plugin_install_auth_flow = None;
let plugins_response = match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(response) => Some(response),
_ => None,
};
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
);
true
}
}
}
pub(crate) fn on_plugin_uninstall_loaded(
&mut self,
cwd: PathBuf,
plugin_display_name: String,
result: Result<PluginUninstallResponse, String>,
) {
if self.config.cwd != cwd {
return;
}
match result {
Ok(_response) => {
self.plugin_install_apps_needing_auth.clear();
self.plugin_install_auth_flow = None;
self.add_info_message(
format!("Uninstalled {plugin_display_name} plugin."),
Some("Bundled apps remain installed.".to_string()),
);
}
Err(err) => {
let plugins_response = match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(response) => Some(response),
_ => None,
};
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
);
}
}
}
pub(crate) fn advance_plugin_install_auth_flow(&mut self) {
let should_finish = {
let Some(flow) = self.plugin_install_auth_flow.as_mut() else {
return;
};
flow.next_app_index += 1;
flow.next_app_index >= self.plugin_install_apps_needing_auth.len()
};
if should_finish {
self.finish_plugin_install_auth_flow(/*abandoned*/ false);
return;
}
self.open_plugin_install_auth_popup();
}
pub(crate) fn abandon_plugin_install_auth_flow(&mut self) {
self.finish_plugin_install_auth_flow(/*abandoned*/ true);
}
fn open_plugin_install_auth_popup(&mut self) {
let Some(params) = self.plugin_install_auth_popup_params() else {
self.finish_plugin_install_auth_flow(/*abandoned*/ false);
return;
};
if !self
.bottom_pane
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
&& let Some(params) = self.plugin_install_auth_popup_params()
{
self.bottom_pane.show_selection_view(params);
}
}
fn plugin_install_auth_popup_params(&self) -> Option<SelectionViewParams> {
let flow = self.plugin_install_auth_flow.as_ref()?;
let app = self
.plugin_install_apps_needing_auth
.get(flow.next_app_index)?;
let total = self.plugin_install_apps_needing_auth.len();
let current = flow.next_app_index + 1;
let is_installed = self.plugin_install_auth_app_is_installed(app.id.as_str());
let status_label = if is_installed {
"Already installed in this session."
} else {
"Not installed yet."
};
let description = app
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("{} plugin installed.", flow.plugin_display_name).bold(),
));
header.push(Line::from(
format!("App setup {current}/{total}: {}", app.name).dim(),
));
header.push(Line::from(status_label.dim()));
let mut items = vec![SelectionItem {
name: app.name.clone(),
description,
is_disabled: true,
..Default::default()
}];
if let Some(install_url) = app.install_url.clone() {
let install_label = if is_installed {
"Manage on ChatGPT"
} else {
"Install on ChatGPT"
};
items.push(SelectionItem {
name: install_label.to_string(),
description: Some(
"Open the same ChatGPT app management link used by /apps.".to_string(),
),
selected_description: Some("Open the app page in your browser.".to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenUrlInBrowser {
url: install_url.clone(),
});
})],
..Default::default()
});
} else {
items.push(SelectionItem {
name: "ChatGPT link unavailable".to_string(),
description: Some("This app did not provide an install/manage URL.".to_string()),
is_disabled: true,
..Default::default()
});
}
if is_installed {
items.push(SelectionItem {
name: "Continue".to_string(),
description: Some("This app is already installed.".to_string()),
selected_description: Some("Advance to the next app.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::PluginInstallAuthAdvance {
refresh_connectors: false,
});
})],
..Default::default()
});
} else {
items.push(SelectionItem {
name: "I've installed it".to_string(),
description: Some(
"Trust your confirmation and continue to the next app.".to_string(),
),
selected_description: Some(
"Continue without waiting for refresh to complete.".to_string(),
),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::PluginInstallAuthAdvance {
refresh_connectors: true,
});
})],
..Default::default()
});
}
items.push(SelectionItem {
name: "Skip remaining app setup".to_string(),
description: Some("Stop this follow-up flow for this plugin.".to_string()),
selected_description: Some("Abandon remaining required app setup.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::PluginInstallAuthAbandon);
})],
..Default::default()
});
Some(SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugins_popup_hint_line()),
items,
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
})
}
fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool {
self.connectors_for_mentions().is_some_and(|connectors| {
connectors
.iter()
.any(|connector| connector.id == app_id && connector.is_accessible)
})
}
fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) {
let Some(flow) = self.plugin_install_auth_flow.take() else {
return;
};
self.plugin_install_apps_needing_auth.clear();
if abandoned {
self.add_info_message(
format!(
"Skipped remaining app setup for {} plugin.",
flow.plugin_display_name
),
Some("The plugin may not be usable until required apps are installed.".to_string()),
);
} else {
self.add_info_message(
format!(
"Completed app setup flow for {} plugin.",
flow.plugin_display_name
),
Some("You can now continue managing plugins from /plugins.".to_string()),
);
}
let plugins_response = match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(response) => Some(response),
_ => None,
};
if let Some(plugins_response) = plugins_response {
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_popup_params(&plugins_response),
);
}
}
fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) {
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
@@ -212,6 +520,52 @@ impl ChatWidget {
}
}
fn plugin_install_loading_popup_params(
&self,
plugin_display_name: &str,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Installing {plugin_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Installing plugin...".to_string(),
description: Some("This updates when plugin installation completes.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugin_uninstall_loading_popup_params(
&self,
plugin_display_name: &str,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Uninstalling {plugin_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Uninstalling plugin...".to_string(),
description: Some("This updates when plugin removal completes.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
@@ -397,6 +751,59 @@ impl ChatWidget {
..Default::default()
}];
if plugin.summary.installed {
let uninstall_cwd = self.config.cwd.clone();
let plugin_id = plugin.summary.id.clone();
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 if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable {
items.push(SelectionItem {
name: "Install plugin".to_string(),
description: Some(
"This plugin is not installable from this marketplace.".to_string(),
),
is_disabled: true,
..Default::default()
});
} else {
let install_cwd = self.config.cwd.clone();
let marketplace_path = plugin.marketplace_path.clone();
let plugin_name = plugin.summary.name.clone();
let plugin_display_name = display_name;
items.push(SelectionItem {
name: "Install plugin".to_string(),
description: Some("Install this plugin now.".to_string()),
selected_description: Some("Install this plugin now.".to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenPluginInstallLoading {
plugin_display_name: plugin_display_name.clone(),
});
tx.send(AppEvent::FetchPluginInstall {
cwd: install_cwd.clone(),
marketplace_path: marketplace_path.clone(),
plugin_name: plugin_name.clone(),
plugin_display_name: plugin_display_name.clone(),
});
})],
..Default::default()
});
}
items.push(SelectionItem {
name: "Skills".to_string(),
description: Some(plugin_skill_summary(plugin)),

View File

@@ -0,0 +1,16 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Plugins
Figma · ChatGPT Marketplace
Can be installed
Turn Figma files into implementation context.
1. Back to plugins Return to the plugin list.
2. Install plugin Install this plugin now.
3. Skills design-review, extract-copy
4. Apps Figma, Slack
5. MCP Servers figma-mcp, docs-mcp
Press esc to close.

View File

@@ -0,0 +1,17 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Plugins
Browse plugins from the ChatGPT marketplace.
Installed 1 of 3 available plugins.
Using cached marketplace data: remote sync timed out
Type to search plugins
Bravo Search · ChatGPT Marketplace Can be installed. Press Enter to view plugin details.
Alpha Sync · ChatGPT Marketplace Installed · Disabled · ChatGPT Marketplace · Already
installed but disabled.
Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by
default.
Press esc to close.

View File

@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Plugins
Loading available plugins...
This first pass shows the ChatGPT marketplace only.
1. Loading plugins... This updates when the marketplace list is ready.

View File

@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Plugins
Browse plugins from the ChatGPT marketplace.
Installed 0 of 3 available plugins.
sla
Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details.
Press esc to close.

View File

@@ -18,6 +18,18 @@ use crate::history_cell::UserHistoryCell;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use assert_matches::assert_matches;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::MarketplaceInterface;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
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::SkillSummary;
use codex_core::CodexAuth;
use codex_core::config::ApprovalsReviewer;
use codex_core::config::Config;
@@ -35,6 +47,7 @@ use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::config_loader::RequirementSource;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core::skills::model::SkillMetadata;
use codex_features::FEATURES;
use codex_features::Feature;
@@ -2021,6 +2034,8 @@ async fn make_chatwidget_manual(
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
connectors_partial_snapshot: None,
plugin_install_apps_needing_auth: Vec::new(),
plugin_install_auth_flow: None,
connectors_prefetch_in_flight: false,
connectors_force_refetch_pending: false,
pending_mcp_output_requests: 0,
@@ -7067,6 +7082,512 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
lines.join("\n")
}
fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(
std::env::temp_dir()
.join("codex-plugin-menu-tests")
.join(path),
)
.expect("expected absolute test path")
}
fn plugins_test_interface(
display_name: Option<&str>,
short_description: Option<&str>,
long_description: Option<&str>,
) -> PluginInterface {
PluginInterface {
display_name: display_name.map(str::to_string),
short_description: short_description.map(str::to_string),
long_description: long_description.map(str::to_string),
developer_name: None,
category: None,
capabilities: Vec::new(),
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
logo: None,
screenshots: Vec::new(),
}
}
fn plugins_test_summary(
id: &str,
name: &str,
display_name: Option<&str>,
description: Option<&str>,
installed: bool,
enabled: bool,
install_policy: PluginInstallPolicy,
) -> PluginSummary {
PluginSummary {
id: id.to_string(),
name: name.to_string(),
source: PluginSource::Local {
path: plugins_test_absolute_path(&format!("plugins/{name}")),
},
installed,
enabled,
install_policy,
auth_policy: PluginAuthPolicy::OnInstall,
interface: Some(plugins_test_interface(display_name, description, None)),
}
}
fn plugins_test_curated_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketplaceEntry {
PluginMarketplaceEntry {
name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
path: plugins_test_absolute_path("marketplaces/chatgpt"),
interface: Some(MarketplaceInterface {
display_name: Some("ChatGPT Marketplace".to_string()),
}),
plugins,
}
}
fn plugins_test_repo_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketplaceEntry {
PluginMarketplaceEntry {
name: "repo".to_string(),
path: plugins_test_absolute_path("marketplaces/repo"),
interface: Some(MarketplaceInterface {
display_name: Some("Repo Marketplace".to_string()),
}),
plugins,
}
}
fn plugins_test_response(marketplaces: Vec<PluginMarketplaceEntry>) -> PluginListResponse {
PluginListResponse {
marketplaces,
remote_sync_error: None,
featured_plugin_ids: Vec::new(),
}
}
fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String {
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd, Ok(response));
chat.add_plugins_output();
render_bottom_popup(chat, 100)
}
fn plugins_test_detail(
summary: PluginSummary,
description: Option<&str>,
skills: &[&str],
apps: &[(&str, bool)],
mcp_servers: &[&str],
) -> PluginDetail {
PluginDetail {
marketplace_name: "ChatGPT Marketplace".to_string(),
marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"),
summary,
description: description.map(str::to_string),
skills: skills
.iter()
.map(|name| SkillSummary {
name: (*name).to_string(),
description: format!("{name} description"),
short_description: None,
interface: None,
path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
})
.collect(),
apps: apps
.iter()
.map(|(name, needs_auth)| AppSummary {
id: format!("{name}-id"),
name: (*name).to_string(),
description: Some(format!("{name} app")),
install_url: Some(format!("https://example.test/{name}")),
needs_auth: *needs_auth,
})
.collect(),
mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(),
}
}
fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize {
popup
.find(needle)
.unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}"))
}
fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) {
for ch in query.chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
}
#[tokio::test]
async fn plugins_popup_loading_state_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::Plugins, true);
chat.add_plugins_output();
let popup = render_bottom_popup(&chat, 100);
assert!(
popup.contains("Loading available plugins..."),
"expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}"
);
assert_snapshot!("plugins_popup_loading_state", popup);
}
#[tokio::test]
async fn plugins_popup_snapshot_filters_to_curated_marketplace_and_preserves_response_order() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::Plugins, true);
let mut response = plugins_test_response(vec![
plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-bravo",
"bravo",
Some("Bravo Search"),
Some("Search docs and tickets."),
false,
true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-alpha",
"alpha",
Some("Alpha Sync"),
Some("Already installed but disabled."),
true,
false,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-starter",
"starter",
Some("Starter"),
Some("Included by default."),
false,
true,
PluginInstallPolicy::InstalledByDefault,
),
]),
plugins_test_repo_marketplace(vec![plugins_test_summary(
"plugin-hidden",
"hidden",
Some("Hidden Repo Plugin"),
Some("Should not be shown in /plugins."),
false,
true,
PluginInstallPolicy::Available,
)]),
]);
response.remote_sync_error = Some("remote sync timed out".to_string());
let popup = render_loaded_plugins_popup(&mut chat, response);
assert_snapshot!("plugins_popup_curated_marketplace", popup);
assert!(
!popup.contains("Hidden Repo Plugin"),
"expected /plugins to hide non-ChatGPT marketplaces, got:\n{popup}"
);
assert!(
plugins_test_popup_row_position(&popup, "Bravo Search")
< plugins_test_popup_row_position(&popup, "Alpha Sync")
&& plugins_test_popup_row_position(&popup, "Alpha Sync")
< plugins_test_popup_row_position(&popup, "Starter"),
"expected /plugins rows to keep response order, 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(None).await;
chat.set_feature_enabled(Feature::Plugins, true);
let summary = plugins_test_summary(
"plugin-figma",
"figma",
Some("Figma"),
Some("Design handoff."),
false,
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.clone(), Ok(response));
chat.add_plugins_output();
chat.on_plugin_detail_loaded(
cwd,
Ok(PluginReadResponse {
plugin: plugins_test_detail(
summary,
Some("Turn Figma files into implementation context."),
&["design-review", "extract-copy"],
&[("Figma", true), ("Slack", false)],
&["figma-mcp", "docs-mcp"],
),
}),
);
let popup = render_bottom_popup(&chat, 100);
assert_snapshot!("plugin_detail_popup_installable", popup);
}
#[tokio::test]
async fn plugins_popup_refresh_replaces_selection_with_first_row() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::Plugins, true);
let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-notion",
"notion",
Some("Notion"),
Some("Workspace docs."),
false,
true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-slack",
"slack",
Some("Slack"),
Some("Team chat."),
false,
true,
PluginInstallPolicy::Available,
),
])]);
render_loaded_plugins_popup(&mut chat, initial);
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
let before = render_bottom_popup(&chat, 100);
assert!(
before.contains(" Slack"),
"expected Slack to be selected before refresh, got:\n{before}"
);
let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-airtable",
"airtable",
Some("Airtable"),
Some("Structured records."),
false,
true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-notion",
"notion",
Some("Notion"),
Some("Workspace docs."),
false,
true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-slack",
"slack",
Some("Slack"),
Some("Team chat."),
false,
true,
PluginInstallPolicy::Available,
),
])]);
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd, Ok(refreshed));
let after = render_bottom_popup(&chat, 100);
assert!(
after.contains(" Airtable"),
"expected refresh to rebuild the popup from the new first row, got:\n{after}"
);
assert!(
after.contains("Slack · ChatGPT Marketplace"),
"expected refreshed popup to include the updated plugin list, got:\n{after}"
);
}
#[tokio::test]
async fn plugins_popup_refreshes_installed_counts_after_install() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::Plugins, true);
let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-calendar",
"calendar",
Some("Calendar"),
Some("Schedule management."),
false,
true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
true,
true,
PluginInstallPolicy::Available,
),
])]);
let before = render_loaded_plugins_popup(&mut chat, initial);
assert!(
before.contains("Installed 1 of 2 available plugins."),
"expected initial installed count before refresh, got:\n{before}"
);
assert!(
before.contains("Can be installed"),
"expected pre-install popup copy before refresh, got:\n{before}"
);
let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-calendar",
"calendar",
Some("Calendar"),
Some("Schedule management."),
true,
true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
true,
true,
PluginInstallPolicy::Available,
),
])]);
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd, Ok(refreshed));
let after = render_bottom_popup(&chat, 100);
assert!(
after.contains("Installed 2 of 2 available plugins."),
"expected /plugins to refresh installed counts after install, got:\n{after}"
);
assert!(
after.contains("Installed. Press Enter to view plugin details."),
"expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}"
);
}
#[tokio::test]
async fn plugins_popup_search_filters_visible_rows_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::Plugins, true);
render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-calendar",
"calendar",
Some("Calendar"),
Some("Schedule management."),
false,
true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-slack",
"slack",
Some("Slack"),
Some("Team chat."),
false,
true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
false,
true,
PluginInstallPolicy::Available,
),
])]),
);
type_plugins_search_query(&mut chat, "sla");
let popup = render_bottom_popup(&chat, 100);
assert_snapshot!("plugins_popup_search_filtered", popup);
assert!(
!popup.contains("Calendar · ChatGPT Marketplace")
&& !popup.contains("Drive · ChatGPT Marketplace"),
"expected search to leave only matching rows visible, got:\n{popup}"
);
}
#[tokio::test]
async fn plugins_popup_search_no_matches_and_backspace_restores_results() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::Plugins, true);
render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-calendar",
"calendar",
Some("Calendar"),
Some("Schedule management."),
false,
true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-slack",
"slack",
Some("Slack"),
Some("Team chat."),
false,
true,
PluginInstallPolicy::Available,
),
])]),
);
type_plugins_search_query(&mut chat, "zzz");
let no_matches = render_bottom_popup(&chat, 100);
assert!(
no_matches.contains("zzz"),
"expected popup to show the typed search query, got:\n{no_matches}"
);
assert!(
no_matches.contains("no matches"),
"expected popup to render the no-matches UX, got:\n{no_matches}"
);
for _ in 0..3 {
chat.handle_key_event(KeyEvent::from(KeyCode::Backspace));
}
let restored = render_bottom_popup(&chat, 100);
assert!(
restored.contains("Calendar · ChatGPT Marketplace")
&& restored.contains("Slack · ChatGPT Marketplace"),
"expected clearing the query to restore the plugin rows, got:\n{restored}"
);
assert!(
!restored.contains("no matches"),
"did not expect the no-matches state after clearing the query, got:\n{restored}"
);
}
fn selected_permissions_popup_line(popup: &str) -> &str {
popup
.lines()