Files
codex/codex-rs/tui_app_server/src/chatwidget/plugins.rs
T
canvrno-oai b5d0a5518d 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.
2026-03-23 12:38:39 -07:00

958 lines
35 KiB
Rust

use std::path::PathBuf;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
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;
const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection";
const SUPPORTED_MARKETPLACE_NAME: &str = OPENAI_CURATED_MARKETPLACE_NAME;
#[derive(Debug, Clone, Default)]
pub(super) enum PluginsCacheState {
#[default]
Uninitialized,
Loading,
Ready(PluginListResponse),
Failed(String),
}
impl ChatWidget {
pub(crate) fn add_plugins_output(&mut self) {
if !self.config.features.enabled(Feature::Plugins) {
self.add_info_message(
"Plugins are disabled.".to_string(),
Some("Enable the plugins feature to use /plugins.".to_string()),
);
return;
}
self.prefetch_plugins();
match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(response) => {
self.open_plugins_popup(&response);
}
PluginsCacheState::Failed(err) => {
self.add_to_history(history_cell::new_error_event(err));
}
PluginsCacheState::Loading | PluginsCacheState::Uninitialized => {
self.open_plugins_loading_popup();
}
}
self.request_redraw();
}
pub(crate) fn on_plugins_loaded(
&mut self,
cwd: PathBuf,
result: Result<PluginListResponse, String>,
) {
if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) {
self.plugins_fetch_state.in_flight_cwd = None;
}
if self.config.cwd != cwd {
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());
if !auth_flow_active {
self.refresh_plugins_popup_if_open(&response);
}
}
Err(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),
);
}
}
}
}
fn prefetch_plugins(&mut self) {
let cwd = self.config.cwd.clone();
if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) {
return;
}
self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone());
if self.plugins_fetch_state.cache_cwd.as_ref() != Some(&cwd) {
self.plugins_cache = PluginsCacheState::Loading;
}
self.app_event_tx.send(AppEvent::FetchPluginsList { cwd });
}
fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState {
if self.plugins_fetch_state.cache_cwd.as_ref() == Some(&self.config.cwd) {
self.plugins_cache.clone()
} else {
PluginsCacheState::Uninitialized
}
}
fn open_plugins_loading_popup(&mut self) {
if !self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_loading_popup_params(),
) {
self.bottom_pane
.show_selection_view(self.plugins_loading_popup_params());
}
}
fn open_plugins_popup(&mut self, response: &PluginListResponse) {
self.bottom_pane
.show_selection_view(self.plugins_popup_params(response));
}
pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) {
let params = self.plugin_detail_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_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,
result: Result<PluginReadResponse, String>,
) {
if self.config.cwd != cwd {
return;
}
let plugins_response = match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(response) => Some(response),
_ => None,
};
match result {
Ok(response) => {
if let Some(plugins_response) = plugins_response {
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugin_detail_popup_params(&plugins_response, &response.plugin),
);
}
}
Err(err) => {
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 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,
self.plugins_popup_params(response),
);
}
fn plugins_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Loading available plugins...".dim()));
header.push(Line::from(
"This first pass shows the ChatGPT marketplace only.".dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Loading plugins...".to_string(),
description: Some("This updates when the marketplace list is ready.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugin_detail_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!("Loading details for {plugin_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Loading plugin details...".to_string(),
description: Some(
"This updates when the plugin detail request finishes.".to_string(),
),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
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()));
header.push(Line::from("Failed to load plugins.".dim()));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Plugin marketplace unavailable".to_string(),
description: Some(err.to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugin_detail_error_popup_params(
&self,
err: &str,
plugins_response: Option<&PluginListResponse>,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to load plugin details.".dim()));
let mut items = vec![SelectionItem {
name: "Plugin detail unavailable".to_string(),
description: Some(err.to_string()),
is_disabled: true,
..Default::default()
}];
if let Some(plugins_response) = plugins_response.cloned() {
let cwd = self.config.cwd.clone();
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(plugins_popup_hint_line()),
items,
..Default::default()
}
}
fn plugins_popup_params(&self, response: &PluginListResponse) -> SelectionViewParams {
let marketplaces: Vec<&PluginMarketplaceEntry> = response
.marketplaces
.iter()
.filter(|marketplace| marketplace.name == SUPPORTED_MARKETPLACE_NAME)
.collect();
let total: usize = marketplaces
.iter()
.map(|marketplace| marketplace.plugins.len())
.sum();
let installed = marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.filter(|plugin| plugin.installed)
.count();
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
"Browse plugins from the ChatGPT marketplace.".dim(),
));
header.push(Line::from(
format!("Installed {installed} of {total} available plugins.").dim(),
));
if let Some(remote_sync_error) = response.remote_sync_error.as_deref() {
header.push(Line::from(
format!("Using cached marketplace data: {remote_sync_error}").dim(),
));
}
let mut items: Vec<SelectionItem> = Vec::new();
for marketplace in marketplaces {
let marketplace_label = marketplace_display_name(marketplace);
for plugin in &marketplace.plugins {
let display_name = plugin_display_name(plugin);
let status_label = plugin_status_label(plugin);
let description = plugin_brief_description(plugin, &marketplace_label);
let selected_description =
format!("{status_label}. Press Enter to view plugin details.");
let search_value = format!(
"{display_name} {} {} {}",
plugin.id, plugin.name, marketplace_label
);
let cwd = self.config.cwd.clone();
let plugin_display_name = display_name.clone();
let marketplace_path = marketplace.path.clone();
let plugin_name = plugin.name.clone();
items.push(SelectionItem {
name: format!("{display_name} · {marketplace_label}"),
description: Some(description),
selected_description: Some(selected_description),
search_value: Some(search_value),
actions: 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: marketplace_path.clone(),
plugin_name: plugin_name.clone(),
},
});
})],
..Default::default()
});
}
}
if items.is_empty() {
items.push(SelectionItem {
name: "No ChatGPT marketplace plugins available".to_string(),
description: Some(
"This first pass only surfaces the ChatGPT plugin marketplace.".to_string(),
),
is_disabled: true,
..Default::default()
});
}
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugins_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search plugins".to_string()),
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
}
}
fn plugin_detail_popup_params(
&self,
plugins_response: &PluginListResponse,
plugin: &PluginDetail,
) -> SelectionViewParams {
let marketplace_label = plugin.marketplace_name.clone();
let display_name = plugin_display_name(&plugin.summary);
let status_label = plugin_status_label(&plugin.summary);
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("{display_name} · {marketplace_label}").bold(),
));
header.push(Line::from(status_label.dim()));
if let Some(description) = plugin_detail_description(plugin) {
header.push(Line::from(description.dim()));
}
let cwd = self.config.cwd.clone();
let plugins_response = plugins_response.clone();
let mut items = vec![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()
}];
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)),
is_disabled: true,
..Default::default()
});
items.push(SelectionItem {
name: "Apps".to_string(),
description: Some(plugin_app_summary(plugin)),
is_disabled: true,
..Default::default()
});
items.push(SelectionItem {
name: "MCP Servers".to_string(),
description: Some(plugin_mcp_summary(plugin)),
is_disabled: true,
..Default::default()
});
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 plugins_popup_hint_line() -> Line<'static> {
Line::from("Press esc to close.")
}
fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String {
marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.map(str::trim)
.filter(|display_name| !display_name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| marketplace.name.clone())
}
fn plugin_display_name(plugin: &PluginSummary) -> String {
plugin
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.map(str::trim)
.filter(|display_name| !display_name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| plugin.name.clone())
}
fn plugin_brief_description(plugin: &PluginSummary, marketplace_label: &str) -> String {
let status_label = plugin_status_label(plugin);
match plugin_description(plugin) {
Some(description) => format!("{status_label} · {marketplace_label} · {description}"),
None => format!("{status_label} · {marketplace_label}"),
}
}
fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
if plugin.installed {
if plugin.enabled {
"Installed"
} else {
"Installed · Disabled"
}
} else {
match plugin.install_policy {
PluginInstallPolicy::NotAvailable => "Not installable",
PluginInstallPolicy::Available => "Can be installed",
PluginInstallPolicy::InstalledByDefault => "Available by default",
}
}
}
fn plugin_description(plugin: &PluginSummary) -> Option<String> {
plugin
.interface
.as_ref()
.and_then(|interface| {
interface
.short_description
.as_deref()
.or(interface.long_description.as_deref())
})
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn plugin_detail_description(plugin: &PluginDetail) -> Option<String> {
plugin
.description
.as_deref()
.or_else(|| {
plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.long_description.as_deref())
})
.or_else(|| {
plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
})
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn plugin_skill_summary(plugin: &PluginDetail) -> String {
if plugin.skills.is_empty() {
"No plugin skills.".to_string()
} else {
plugin
.skills
.iter()
.map(|skill| skill.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
fn plugin_app_summary(plugin: &PluginDetail) -> String {
if plugin.apps.is_empty() {
"No plugin apps.".to_string()
} else {
plugin
.apps
.iter()
.map(|app| app.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
fn plugin_mcp_summary(plugin: &PluginDetail) -> String {
if plugin.mcp_servers.is_empty() {
"No plugin MCP servers.".to_string()
} else {
plugin.mcp_servers.join(", ")
}
}