TUI Plugin Sharing 1 - add remote plugin identity (#26701)

This commit is contained in:
canvrno-oai
2026-06-09 16:34:38 -07:00
committed by GitHub
Unverified
parent 51b3cd51f6
commit fb8f1ea0d5
7 changed files with 573 additions and 74 deletions
+1
View File
@@ -11,6 +11,7 @@ use crate::app_event::ExitMode;
use crate::app_event::FeedbackCategory;
use crate::app_event::HistoryLookupResponse;
use crate::app_event::PermissionProfileSelection;
use crate::app_event::PluginLocation;
use crate::app_event::RateLimitRefreshOrigin;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(target_os = "windows")]
+28 -7
View File
@@ -241,7 +241,7 @@ impl App {
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
location: PluginLocation,
plugin_name: String,
plugin_display_name: String,
) {
@@ -249,14 +249,14 @@ impl App {
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let cwd_for_event = cwd.clone();
let marketplace_path_for_event = marketplace_path.clone();
let location_for_event = location.clone();
let plugin_name_for_event = plugin_name.clone();
let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name)
let result = fetch_plugin_install(request_handle, location, plugin_name)
.await
.map_err(|err| format!("Failed to install plugin: {err}"));
app_event_tx.send(AppEvent::PluginInstallLoaded {
cwd: cwd_for_event,
marketplace_path: marketplace_path_for_event,
location: location_for_event,
plugin_name: plugin_name_for_event,
plugin_display_name,
result,
@@ -835,16 +835,17 @@ pub(super) async fn fetch_marketplace_upgrade(
}
pub(super) async fn fetch_plugin_install(
request_handle: AppServerRequestHandle,
marketplace_path: AbsolutePathBuf,
location: PluginLocation,
plugin_name: String,
) -> Result<PluginInstallResponse> {
let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4()));
let (marketplace_path, remote_marketplace_name) = location.into_request_params();
request_handle
.request_typed(ClientRequest::PluginInstall {
request_id,
params: PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
marketplace_path,
remote_marketplace_name,
plugin_name,
},
})
@@ -1062,6 +1063,26 @@ mod tests {
);
}
#[test]
fn plugin_location_request_params_select_exactly_one_location() {
let local_path = test_absolute_path("/marketplaces/local");
assert_eq!(
PluginLocation::Local {
marketplace_path: local_path.clone()
}
.into_request_params(),
(Some(local_path), None)
);
assert_eq!(
PluginLocation::Remote {
marketplace_name: "workspace-directory".to_string()
}
.into_request_params(),
(None, Some("workspace-directory".to_string()))
);
}
#[test]
fn mcp_inventory_maps_prefix_tool_names_by_server() {
let statuses = vec![
+8 -6
View File
@@ -573,14 +573,14 @@ impl App {
}
AppEvent::FetchPluginInstall {
cwd,
marketplace_path,
location,
plugin_name,
plugin_display_name,
} => {
self.fetch_plugin_install(
app_server,
cwd,
marketplace_path,
location,
plugin_name,
plugin_display_name,
);
@@ -601,7 +601,7 @@ impl App {
}
AppEvent::PluginInstallLoaded {
cwd,
marketplace_path,
location,
plugin_name,
plugin_display_name,
result,
@@ -612,7 +612,7 @@ impl App {
}
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
cwd.clone(),
marketplace_path.clone(),
location.clone(),
plugin_name.clone(),
plugin_display_name,
result,
@@ -621,12 +621,14 @@ impl App {
{
self.fetch_plugins_list(app_server, cwd.clone());
if should_refresh_plugin_detail {
let (marketplace_path, remote_marketplace_name) =
location.into_request_params();
self.fetch_plugin_detail(
app_server,
cwd,
PluginReadParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
marketplace_path,
remote_marketplace_name,
plugin_name,
},
);
+17 -2
View File
@@ -109,6 +109,21 @@ pub(crate) struct ConnectorsSnapshot {
pub(crate) connectors: Vec<AppInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PluginLocation {
Local { marketplace_path: AbsolutePathBuf },
Remote { marketplace_name: String },
}
impl PluginLocation {
pub(crate) fn into_request_params(self) -> (Option<AbsolutePathBuf>, Option<String>) {
match self {
PluginLocation::Local { marketplace_path } => (Some(marketplace_path), None),
PluginLocation::Remote { marketplace_name } => (None, Some(marketplace_name)),
}
}
}
/// Distinguishes why a rate-limit refresh was requested so the completion
/// handler can route the result correctly.
///
@@ -493,7 +508,7 @@ pub(crate) enum AppEvent {
/// Install a specific plugin from a marketplace.
FetchPluginInstall {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
location: PluginLocation,
plugin_name: String,
plugin_display_name: String,
},
@@ -501,7 +516,7 @@ pub(crate) enum AppEvent {
/// Result of installing a plugin.
PluginInstallLoaded {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
location: PluginLocation,
plugin_name: String,
plugin_display_name: String,
result: Result<PluginInstallResponse, String>,
+146 -59
View File
@@ -5,6 +5,7 @@ use std::time::Instant;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::app_event::PluginLocation;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
@@ -25,17 +26,18 @@ use crate::tui::FrameRequester;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::PluginAvailability;
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::PluginSource;
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 crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -453,7 +455,7 @@ impl ChatWidget {
pub(crate) fn on_plugin_install_loaded(
&mut self,
cwd: PathBuf,
_marketplace_path: AbsolutePathBuf,
_location: PluginLocation,
_plugin_name: String,
plugin_display_name: String,
result: Result<PluginInstallResponse, String>,
@@ -1625,19 +1627,22 @@ impl ChatWidget {
) -> SelectionViewParams {
let marketplace_label = plugin.marketplace_name.clone();
let display_name = plugin_display_name(&plugin.summary);
let detail_status_label = if plugin.summary.installed {
if plugin.summary.enabled {
"Installed"
let detail_status_label =
if plugin.summary.availability == PluginAvailability::DisabledByAdmin {
"Disabled by admin"
} else if plugin.summary.installed {
if plugin.summary.enabled {
"Installed"
} else {
"Disabled"
}
} else {
"Disabled"
}
} else {
match plugin.summary.install_policy {
PluginInstallPolicy::NotAvailable => "Not installable",
PluginInstallPolicy::Available => "Can be installed",
PluginInstallPolicy::InstalledByDefault => "Available by default",
}
};
match plugin.summary.install_policy {
PluginInstallPolicy::NotAvailable => "Not installable",
PluginInstallPolicy::Available => "Can be installed",
PluginInstallPolicy::InstalledByDefault => "Available by default",
}
};
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
@@ -1676,23 +1681,40 @@ impl ChatWidget {
}];
if plugin.summary.installed {
let uninstall_cwd = self.config.cwd.to_path_buf();
let plugin_id = plugin.summary.id.clone();
let plugin_display_name = display_name;
if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) {
let uninstall_cwd = self.config.cwd.to_path_buf();
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 {
items.push(SelectionItem {
name: "Uninstall plugin".to_string(),
description: Some(
"This remote plugin did not provide an uninstall identity.".to_string(),
),
is_disabled: true,
..Default::default()
});
}
} else if plugin.summary.availability == PluginAvailability::DisabledByAdmin {
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(),
});
})],
name: "Install plugin".to_string(),
description: Some("This plugin is disabled by your workspace admin.".to_string()),
is_disabled: true,
..Default::default()
});
} else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable {
@@ -1704,9 +1726,9 @@ impl ChatWidget {
is_disabled: true,
..Default::default()
});
} else if let Some(marketplace_path) = plugin.marketplace_path.clone() {
} else if let Some(location) = plugin_detail_location(plugin) {
let install_cwd = self.config.cwd.to_path_buf();
let plugin_name = plugin.summary.name.clone();
let plugin_name = plugin_request_name(&plugin.summary);
let plugin_display_name = display_name;
items.push(SelectionItem {
name: "Install plugin".to_string(),
@@ -1718,7 +1740,7 @@ impl ChatWidget {
});
tx.send(AppEvent::FetchPluginInstall {
cwd: install_cwd.clone(),
marketplace_path: marketplace_path.clone(),
location: location.clone(),
plugin_name: plugin_name.clone(),
plugin_display_name: plugin_display_name.clone(),
});
@@ -1728,7 +1750,7 @@ impl ChatWidget {
} else {
items.push(SelectionItem {
name: "Install plugin".to_string(),
description: Some("Installing remote plugins is not supported yet.".to_string()),
description: Some("This plugin did not provide an install location.".to_string()),
is_disabled: true,
..Default::default()
});
@@ -1792,9 +1814,12 @@ impl ChatWidget {
} else {
plugin_brief_description_without_marketplace(plugin, status_label_width)
};
let can_view_details = marketplace.path.is_some();
let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin);
let can_view_details = plugin_detail_request.is_some();
let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin;
let can_toggle_plugin = plugin.installed && !disabled_by_admin;
let selected_status_label = format!("{status_label:<status_label_width$}");
let selected_description = if plugin.installed {
let selected_description = if can_toggle_plugin {
let toggle_action = if plugin.enabled { "disable" } else { "enable" };
if can_view_details {
format!(
@@ -1803,6 +1828,12 @@ impl ChatWidget {
} else {
format!("{selected_status_label} Space to {toggle_action}.")
}
} else if plugin.installed && can_view_details {
format!("{selected_status_label} Press Enter to view plugin details.")
} else if plugin.installed {
format!("{selected_status_label} Plugin details are unavailable.")
} else if disabled_by_admin && can_view_details {
format!("{selected_status_label} Press Enter to view plugin details.")
} else if can_view_details {
format!("{selected_status_label} Press Enter to install or view plugin details.")
} else {
@@ -1814,11 +1845,9 @@ impl ChatWidget {
);
let cwd = self.config.cwd.to_path_buf();
let plugin_display_name = display_name.clone();
let marketplace_path = marketplace.path.clone();
let plugin_name = plugin.name.clone();
let toggle_cwd = cwd.clone();
let toggle_plugin_id = plugin.id.clone();
let toggle = plugin.installed.then(|| SelectionToggle {
let toggle = can_toggle_plugin.then(|| SelectionToggle {
is_on: plugin.enabled,
action: Box::new(move |enabled, tx| {
tx.send(AppEvent::SetPluginEnabled {
@@ -1828,31 +1857,33 @@ impl ChatWidget {
});
}),
});
let actions: Vec<SelectionAction> = if let Some(marketplace_path) = marketplace_path {
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenPluginDetailLoading {
plugin_display_name: plugin_display_name.clone(),
});
tx.send(AppEvent::FetchPluginDetail {
cwd: cwd.clone(),
params: codex_app_server_protocol::PluginReadParams {
marketplace_path: Some(marketplace_path.clone()),
remote_marketplace_name: None,
plugin_name: plugin_name.clone(),
},
});
})]
} else {
Vec::new()
};
let actions: Vec<SelectionAction> =
if let Some((location, plugin_name)) = plugin_detail_request {
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenPluginDetailLoading {
plugin_display_name: plugin_display_name.clone(),
});
let (marketplace_path, remote_marketplace_name) =
location.clone().into_request_params();
tx.send(AppEvent::FetchPluginDetail {
cwd: cwd.clone(),
params: codex_app_server_protocol::PluginReadParams {
marketplace_path,
remote_marketplace_name,
plugin_name: plugin_name.clone(),
},
});
})]
} else {
Vec::new()
};
let is_disabled = !can_view_details && !plugin.installed;
let disabled_reason =
is_disabled.then(|| "remote plugin details are not available yet".to_string());
let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string());
items.push(SelectionItem {
name: display_name,
toggle,
toggle_placeholder: (!plugin.installed).then_some("[-] "),
toggle_placeholder: (!can_toggle_plugin).then_some("[-] "),
description: Some(description),
selected_description: Some(selected_description),
search_value: Some(search_value),
@@ -2082,6 +2113,9 @@ fn plugin_brief_description_without_marketplace(
}
fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
if plugin.availability == PluginAvailability::DisabledByAdmin {
return "Disabled by admin";
}
if plugin.installed {
if plugin.enabled {
"Installed"
@@ -2097,6 +2131,59 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
}
}
fn plugin_location_for_marketplace(
marketplace: &PluginMarketplaceEntry,
plugin: &PluginSummary,
) -> Option<PluginLocation> {
if let Some(marketplace_path) = marketplace.path.clone() {
return Some(PluginLocation::Local { marketplace_path });
}
plugin_remote_identity(plugin).map(|_| PluginLocation::Remote {
marketplace_name: marketplace.name.clone(),
})
}
fn plugin_detail_location(plugin: &PluginDetail) -> Option<PluginLocation> {
if let Some(marketplace_path) = plugin.marketplace_path.clone() {
return Some(PluginLocation::Local { marketplace_path });
}
plugin_remote_identity(&plugin.summary).map(|_| PluginLocation::Remote {
marketplace_name: plugin.marketplace_name.clone(),
})
}
fn plugin_detail_request_for_entry(
marketplace: &PluginMarketplaceEntry,
plugin: &PluginSummary,
) -> Option<(PluginLocation, String)> {
plugin_location_for_marketplace(marketplace, plugin)
.map(|location| (location, plugin_request_name(plugin)))
}
fn plugin_request_name(plugin: &PluginSummary) -> String {
if matches!(&plugin.source, PluginSource::Remote)
&& let Some(remote_plugin_id) = plugin_remote_identity(plugin)
{
return remote_plugin_id;
}
plugin.name.clone()
}
fn plugin_remote_identity(plugin: &PluginSummary) -> Option<String> {
plugin
.share_context
.as_ref()
.map(|context| context.remote_plugin_id.clone())
.or_else(|| plugin.remote_plugin_id.clone())
}
fn plugin_uninstall_id(plugin: &PluginSummary) -> Option<String> {
if matches!(&plugin.source, PluginSource::Remote) {
return plugin_remote_identity(plugin);
}
Some(plugin.id.clone())
}
fn plugin_description(plugin: &PluginSummary) -> Option<String> {
plugin
.interface
@@ -1337,6 +1337,34 @@ pub(super) fn plugins_test_summary(
}
}
pub(super) fn plugins_test_remote_summary(
remote_plugin_id: &str,
name: &str,
display_name: Option<&str>,
description: Option<&str>,
installed: bool,
) -> PluginSummary {
PluginSummary {
id: remote_plugin_id.to_string(),
remote_plugin_id: Some(remote_plugin_id.to_string()),
local_version: None,
name: name.to_string(),
share_context: None,
source: PluginSource::Remote,
installed,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnInstall,
availability: PluginAvailability::Available,
interface: Some(plugins_test_interface(
display_name,
description,
/*long_description*/ None,
)),
keywords: Vec::new(),
}
}
pub(super) fn plugins_test_curated_marketplace(
plugins: Vec<PluginSummary>,
) -> PluginMarketplaceEntry {
@@ -6,6 +6,7 @@ use codex_app_server_protocol::HookErrorInfo;
use codex_app_server_protocol::HooksListEntry;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::PluginAvailability;
use codex_features::Stage;
use pretty_assertions::assert_eq;
@@ -714,6 +715,350 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
);
}
#[tokio::test]
async fn plugins_popup_remote_row_opens_remote_detail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let popup = render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![PluginMarketplaceEntry {
name: "workspace-directory".to_string(),
path: None,
interface: Some(MarketplaceInterface {
display_name: Some("Workspace".to_string()),
}),
plugins: vec![plugins_test_remote_summary(
"plugins~Plugin_calendar",
"calendar",
Some("Calendar"),
Some("Workspace schedules."),
/*installed*/ false,
)],
}]),
);
let remote_row = popup
.lines()
.find(|line| line.contains("Calendar"))
.expect("expected remote plugin row");
assert!(
remote_row.contains("Available")
&& remote_row.contains("Press Enter to install or view plugin details."),
"expected remote plugin row to be viewable, got:\n{remote_row}"
);
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::OpenPluginDetailLoading {
plugin_display_name,
}) => {
assert_eq!(plugin_display_name, "Calendar");
}
other => panic!("expected OpenPluginDetailLoading event, got {other:?}"),
}
match rx.try_recv() {
Ok(AppEvent::FetchPluginDetail { cwd: _, params }) => {
assert_eq!(params.marketplace_path, None);
assert_eq!(
params.remote_marketplace_name,
Some("workspace-directory".to_string())
);
assert_eq!(params.plugin_name, "plugins~Plugin_calendar");
}
other => panic!("expected FetchPluginDetail event, got {other:?}"),
}
}
#[tokio::test]
async fn plugin_detail_remote_install_uses_remote_location() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = plugins_test_remote_summary(
"plugins~Plugin_linear",
"linear",
Some("Linear"),
Some("Issue tracking."),
/*installed*/ false,
);
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(
cwd.to_path_buf(),
Ok(plugins_test_response(vec![PluginMarketplaceEntry {
name: "workspace-shared-with-me-private".to_string(),
path: None,
interface: Some(MarketplaceInterface {
display_name: Some("Shared with me".to_string()),
}),
plugins: vec![summary.clone()],
}])),
);
chat.add_plugins_output();
chat.on_plugin_detail_loaded(
cwd.to_path_buf(),
Ok(PluginReadResponse {
plugin: PluginDetail {
marketplace_name: "workspace-shared-with-me-private".to_string(),
marketplace_path: None,
summary,
description: Some("Install shared Linear plugin.".to_string()),
skills: Vec::new(),
hooks: Vec::new(),
apps: Vec::new(),
app_templates: Vec::new(),
mcp_servers: Vec::new(),
},
}),
);
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("Install plugin") && popup.contains("Install this plugin now."),
"expected remote detail to offer install, got:\n{popup}"
);
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::OpenPluginInstallLoading {
plugin_display_name,
}) => {
assert_eq!(plugin_display_name, "Linear");
}
other => panic!("expected OpenPluginInstallLoading event, got {other:?}"),
}
match rx.try_recv() {
Ok(AppEvent::FetchPluginInstall {
cwd: _,
location: crate::app_event::PluginLocation::Remote { marketplace_name },
plugin_name,
plugin_display_name,
}) => {
assert_eq!(marketplace_name, "workspace-shared-with-me-private");
assert_eq!(plugin_name, "plugins~Plugin_linear");
assert_eq!(plugin_display_name, "Linear");
}
other => panic!("expected remote FetchPluginInstall event, got {other:?}"),
}
}
#[tokio::test]
async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = plugins_test_remote_summary(
"plugins~Plugin_linear",
"linear",
Some("Linear"),
Some("Issue tracking."),
/*installed*/ true,
);
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(
cwd.to_path_buf(),
Ok(plugins_test_response(vec![PluginMarketplaceEntry {
name: "workspace-shared-with-me-private".to_string(),
path: None,
interface: Some(MarketplaceInterface {
display_name: Some("Shared with me".to_string()),
}),
plugins: vec![summary.clone()],
}])),
);
chat.add_plugins_output();
chat.on_plugin_detail_loaded(
cwd.to_path_buf(),
Ok(PluginReadResponse {
plugin: PluginDetail {
marketplace_name: "workspace-shared-with-me-private".to_string(),
marketplace_path: None,
summary,
description: Some("Installed shared Linear plugin.".to_string()),
skills: Vec::new(),
hooks: Vec::new(),
apps: Vec::new(),
app_templates: Vec::new(),
mcp_servers: Vec::new(),
},
}),
);
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::OpenPluginUninstallLoading {
plugin_display_name,
}) => {
assert_eq!(plugin_display_name, "Linear");
}
other => panic!("expected OpenPluginUninstallLoading event, got {other:?}"),
}
match rx.try_recv() {
Ok(AppEvent::FetchPluginUninstall {
plugin_id,
plugin_display_name,
..
}) => {
assert_eq!(plugin_id, "plugins~Plugin_linear");
assert_eq!(plugin_display_name, "Linear");
}
other => panic!("expected remote FetchPluginUninstall event, got {other:?}"),
}
}
#[tokio::test]
async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = PluginSummary {
source: PluginSource::Remote,
..plugins_test_summary(
"linear@workspace-shared-with-me-private",
"linear",
Some("Linear"),
Some("Issue tracking."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
)
};
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(
cwd.to_path_buf(),
Ok(plugins_test_response(vec![PluginMarketplaceEntry {
name: "workspace-shared-with-me-private".to_string(),
path: None,
interface: Some(MarketplaceInterface {
display_name: Some("Shared with me".to_string()),
}),
plugins: vec![summary.clone()],
}])),
);
chat.add_plugins_output();
chat.on_plugin_detail_loaded(
cwd.to_path_buf(),
Ok(PluginReadResponse {
plugin: PluginDetail {
marketplace_name: "workspace-shared-with-me-private".to_string(),
marketplace_path: None,
summary,
description: Some("Installed shared Linear plugin.".to_string()),
skills: Vec::new(),
hooks: Vec::new(),
apps: Vec::new(),
app_templates: Vec::new(),
mcp_servers: Vec::new(),
},
}),
);
let popup = render_bottom_popup(&chat, /*width*/ 120);
assert!(
popup.contains("This remote plugin did not provide an uninstall identity.")
&& !popup.contains("Remove this plugin now."),
"expected missing remote ID to disable uninstall, got:\n{popup}"
);
while rx.try_recv().is_ok() {}
assert!(
rx.try_recv().is_err(),
"expected no action after rendering disabled uninstall state"
);
}
#[tokio::test]
async fn plugin_detail_admin_disabled_plugin_blocks_install() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = PluginSummary {
availability: PluginAvailability::DisabledByAdmin,
..plugins_test_summary(
"plugin-admin-blocked",
"admin-blocked",
Some("Admin Blocked"),
Some("Blocked by policy."),
/*installed*/ false,
/*enabled*/ 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.to_path_buf(), Ok(response));
chat.add_plugins_output();
chat.on_plugin_detail_loaded(
cwd.to_path_buf(),
Ok(PluginReadResponse {
plugin: plugins_test_detail(summary, Some("Blocked by policy."), &[], &[], &[], &[]),
}),
);
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("Admin Blocked · Disabled by admin")
&& popup.contains("This plugin is disabled by your workspace admin.")
&& !popup.contains("Install this plugin now."),
"expected admin-disabled detail to block install, got:\n{popup}"
);
while rx.try_recv().is_ok() {}
assert!(
rx.try_recv().is_err(),
"expected no action after rendering disabled install state"
);
}
#[tokio::test]
async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = PluginSummary {
availability: PluginAvailability::DisabledByAdmin,
..plugins_test_summary(
"plugin-admin-blocked",
"admin-blocked",
Some("Admin Blocked"),
Some("Blocked by policy."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
)
};
render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]),
);
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("Disabled by admin")
&& popup.contains("Press Enter to view plugin details.")
&& !popup.contains("Space to disable"),
"expected admin-disabled installed plugin to omit toggle hint, got:\n{popup}"
);
while rx.try_recv().is_ok() {}
let before = render_bottom_popup(&chat, /*width*/ 100);
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let after = render_bottom_popup(&chat, /*width*/ 100);
assert!(
rx.try_recv().is_err(),
"space should not toggle admin-disabled installed plugins"
);
assert_eq!(after, before);
}
#[tokio::test]
async fn plugin_detail_error_popup_skips_disabled_row_numbering() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;