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
Unverified
parent f55f5c258f
commit b5d0a5518d
20 changed files with 2611 additions and 56 deletions
+244
View File
@@ -45,10 +45,14 @@ use codex_app_server_client::InProcessClientStartArgs;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RequestId;
use codex_arg0::Arg0DispatchPaths;
use codex_core::AuthManager;
@@ -355,6 +359,72 @@ async fn request_plugin_detail(
response
}
async fn request_plugin_install(
arg0_paths: Arg0DispatchPaths,
config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
params: PluginInstallParams,
) -> Result<PluginInstallResponse> {
let client = start_plugin_request_client(
arg0_paths,
config,
cli_kv_overrides,
loader_overrides,
cloud_requirements,
feedback,
)
.await?;
let request_handle = client.request_handle();
let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4()));
let response = request_handle
.request_typed(ClientRequest::PluginInstall { request_id, params })
.await
.wrap_err("plugin/install failed in legacy TUI");
if let Err(err) = client.shutdown().await {
tracing::warn!(%err, "failed to shut down embedded app server after plugin/install");
}
response
}
async fn request_plugin_uninstall(
arg0_paths: Arg0DispatchPaths,
config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
plugin_id: String,
) -> Result<PluginUninstallResponse> {
let client = start_plugin_request_client(
arg0_paths,
config,
cli_kv_overrides,
loader_overrides,
cloud_requirements,
feedback,
)
.await?;
let request_handle = client.request_handle();
let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4()));
let response = request_handle
.request_typed(ClientRequest::PluginUninstall {
request_id,
params: PluginUninstallParams {
plugin_id,
force_remote_sync: false,
},
})
.await
.wrap_err("plugin/uninstall failed in legacy TUI");
if let Err(err) = client.shutdown().await {
tracing::warn!(%err, "failed to shut down embedded app server after plugin/uninstall");
}
response
}
fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) {
let mut disabled_folders = Vec::new();
@@ -1365,6 +1435,85 @@ impl App {
});
}
fn fetch_plugin_install(
&mut self,
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
) {
let config = self.config.clone();
let arg0_paths = self.arg0_paths.clone();
let cli_kv_overrides = self.cli_kv_overrides.clone();
let loader_overrides = self.loader_overrides.clone();
let cloud_requirements = self.cloud_requirements.clone();
let feedback = self.feedback.clone();
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 plugin_name_for_event = plugin_name.clone();
let result = request_plugin_install(
arg0_paths,
config,
cli_kv_overrides,
loader_overrides,
cloud_requirements,
feedback,
PluginInstallParams {
marketplace_path,
plugin_name,
force_remote_sync: false,
},
)
.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,
plugin_name: plugin_name_for_event,
plugin_display_name,
result,
});
});
}
fn fetch_plugin_uninstall(
&mut self,
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
) {
let config = self.config.clone();
let arg0_paths = self.arg0_paths.clone();
let cli_kv_overrides = self.cli_kv_overrides.clone();
let loader_overrides = self.loader_overrides.clone();
let cloud_requirements = self.cloud_requirements.clone();
let feedback = self.feedback.clone();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let cwd_for_event = cwd.clone();
let plugin_id_for_event = plugin_id.clone();
let result = request_plugin_uninstall(
arg0_paths,
config,
cli_kv_overrides,
loader_overrides,
cloud_requirements,
feedback,
plugin_id,
)
.await
.map_err(|err| format!("Failed to uninstall plugin: {err}"));
app_event_tx.send(AppEvent::PluginUninstallLoaded {
cwd: cwd_for_event,
plugin_id: plugin_id_for_event,
plugin_display_name,
result,
});
});
}
fn clear_ui_header_lines_with_version(
&self,
width: u16,
@@ -2953,6 +3102,15 @@ impl App {
AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
AppEvent::PluginInstallAuthAdvance { refresh_connectors } => {
if refresh_connectors {
self.chat_widget.refresh_connectors(/*force_refetch*/ true);
}
self.chat_widget.advance_plugin_install_auth_flow();
}
AppEvent::PluginInstallAuthAbandon => {
self.chat_widget.abandon_plugin_install_auth_flow();
}
AppEvent::FetchPluginsList { cwd } => {
self.fetch_plugins_list(cwd);
}
@@ -2962,6 +3120,18 @@ impl App {
self.chat_widget
.open_plugin_detail_loading_popup(&plugin_display_name);
}
AppEvent::OpenPluginInstallLoading {
plugin_display_name,
} => {
self.chat_widget
.open_plugin_install_loading_popup(&plugin_display_name);
}
AppEvent::OpenPluginUninstallLoading {
plugin_display_name,
} => {
self.chat_widget
.open_plugin_uninstall_loading_popup(&plugin_display_name);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
@@ -2983,6 +3153,55 @@ impl App {
AppEvent::PluginDetailLoaded { cwd, result } => {
self.chat_widget.on_plugin_detail_loaded(cwd, result);
}
AppEvent::FetchPluginInstall {
cwd,
marketplace_path,
plugin_name,
plugin_display_name,
} => {
self.fetch_plugin_install(cwd, marketplace_path, plugin_name, plugin_display_name);
}
AppEvent::FetchPluginUninstall {
cwd,
plugin_id,
plugin_display_name,
} => {
self.fetch_plugin_uninstall(cwd, plugin_id, plugin_display_name);
}
AppEvent::PluginInstallLoaded {
cwd,
marketplace_path,
plugin_name,
plugin_display_name,
result,
} => {
let install_succeeded = result.is_ok();
if install_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after plugin install");
}
self.chat_widget.submit_op(Op::ReloadUserConfig);
}
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
cwd.clone(),
marketplace_path.clone(),
plugin_name.clone(),
plugin_display_name,
result,
);
if install_succeeded && self.chat_widget.config_ref().cwd == cwd {
self.fetch_plugins_list(cwd.clone());
if should_refresh_plugin_detail {
self.fetch_plugin_detail(
cwd,
PluginReadParams {
marketplace_path,
plugin_name,
},
);
}
}
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
self.refresh_status_surfaces();
@@ -3400,6 +3619,31 @@ impl App {
}
}
}
AppEvent::PluginUninstallLoaded {
cwd,
plugin_id: _plugin_id,
plugin_display_name,
result,
} => {
let uninstall_succeeded = result.is_ok();
if uninstall_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin uninstall"
);
}
self.chat_widget.submit_op(Op::ReloadUserConfig);
}
self.chat_widget.on_plugin_uninstall_loaded(
cwd.clone(),
plugin_display_name,
result,
);
if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd {
self.fetch_plugins_list(cwd);
}
}
AppEvent::PersistPersonalitySelection { personality } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
+53
View File
@@ -10,15 +10,18 @@
use std::path::PathBuf;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
@@ -193,6 +196,56 @@ pub(crate) enum AppEvent {
result: Result<PluginReadResponse, String>,
},
/// Replace the plugins popup with an install loading state.
OpenPluginInstallLoading {
plugin_display_name: String,
},
/// Replace the plugins popup with an uninstall loading state.
OpenPluginUninstallLoading {
plugin_display_name: String,
},
/// Install a specific plugin from a marketplace.
FetchPluginInstall {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
},
/// Result of installing a plugin.
PluginInstallLoaded {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
result: Result<PluginInstallResponse, String>,
},
/// Uninstall a specific plugin by canonical plugin id.
FetchPluginUninstall {
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
},
/// Result of uninstalling a plugin.
PluginUninstallLoaded {
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
result: Result<PluginUninstallResponse, String>,
},
/// Advance the post-install plugin app-auth flow.
PluginInstallAuthAdvance {
refresh_connectors: bool,
},
/// Abandon the post-install plugin app-auth flow.
PluginInstallAuthAbandon,
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.
+32 -21
View File
@@ -3584,6 +3584,24 @@ impl ChatComposer {
fn mention_items(&self) -> Vec<MentionItem> {
let mut mentions = Vec::new();
let plugin_namespaces: HashSet<String> =
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
plugins
.iter()
.filter_map(|plugin| {
let (plugin_name, _) = plugin
.config_name
.split_once('@')
.unwrap_or((plugin.config_name.as_str(), ""));
let plugin_name = plugin_name.trim();
if plugin_name.is_empty() {
None
} else {
Some(plugin_name.to_ascii_lowercase())
}
})
.collect()
});
let plugin_display_names: HashSet<String> =
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
plugins
@@ -3594,6 +3612,13 @@ impl ChatComposer {
if let Some(skills) = self.skills.as_ref() {
for skill in skills {
let is_plugin_namespaced_skill =
skill.name.split_once(':').is_some_and(|(namespace, _)| {
plugin_namespaces.contains(&namespace.to_ascii_lowercase())
});
if is_plugin_namespaced_skill {
continue;
}
let display_name = skill_display_name(skill).to_string();
let description = skill_description(skill);
let skill_name = skill.name.clone();
@@ -5403,7 +5428,7 @@ mod tests {
}
#[test]
fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() {
fn mention_items_hide_plugin_owned_skill_and_app_duplicates() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
@@ -5465,27 +5490,13 @@ mod tests {
}],
}));
let mut mention_summaries: Vec<_> = composer
.mention_items()
.into_iter()
.map(|mention| (mention.display_name, mention.category_tag, mention.path))
.collect();
mention_summaries.sort();
let mentions = composer.mention_items();
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string()));
assert_eq!(
mention_summaries,
vec![
(
"Google Calendar".to_string(),
Some("[Plugin]".to_string()),
Some("plugin://google-calendar@debug".to_string()),
),
(
"Google Calendar".to_string(),
Some("[Skill]".to_string()),
Some("/tmp/repo/google-calendar/SKILL.md".to_string()),
),
]
mentions[0].path,
Some("plugin://google-calendar@debug".to_string())
);
}
+15
View File
@@ -57,6 +57,7 @@ use crate::terminal_title::clear_terminal_title;
use crate::terminal_title::set_terminal_title;
use crate::text_formatting::proper_join;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::ConfigLayerSource;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
@@ -537,6 +538,12 @@ struct PluginListFetchState {
in_flight_cwd: Option<PathBuf>,
}
#[derive(Debug, Clone)]
struct PluginInstallAuthFlowState {
plugin_display_name: String,
next_app_index: usize,
}
#[derive(Debug)]
enum RateLimitErrorKind {
ServerOverloaded,
@@ -732,6 +739,8 @@ pub(crate) struct ChatWidget {
pending_mcp_output_requests: usize,
plugins_cache: PluginsCacheState,
plugins_fetch_state: PluginListFetchState,
plugin_install_apps_needing_auth: Vec<AppSummary>,
plugin_install_auth_flow: Option<PluginInstallAuthFlowState>,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
@@ -3747,6 +3756,8 @@ impl ChatWidget {
pending_mcp_output_requests: 0,
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
plugin_install_apps_needing_auth: Vec::new(),
plugin_install_auth_flow: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -3949,6 +3960,8 @@ impl ChatWidget {
pending_mcp_output_requests: 0,
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
plugin_install_apps_needing_auth: Vec::new(),
plugin_install_auth_flow: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -4143,6 +4156,8 @@ impl ChatWidget {
pending_mcp_output_requests: 0,
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
plugin_install_apps_needing_auth: Vec::new(),
plugin_install_auth_flow: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
+414 -7
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)),
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+521
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()
+193
View File
@@ -55,10 +55,14 @@ use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
@@ -1831,6 +1835,57 @@ impl App {
});
}
fn fetch_plugin_install(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_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_path_for_event = marketplace_path.clone();
let plugin_name_for_event = plugin_name.clone();
let result = fetch_plugin_install(request_handle, marketplace_path, 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,
plugin_name: plugin_name_for_event,
plugin_display_name,
result,
});
});
}
fn fetch_plugin_uninstall(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
plugin_id: String,
plugin_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 plugin_id_for_event = plugin_id.clone();
let result = fetch_plugin_uninstall(request_handle, plugin_id)
.await
.map_err(|err| format!("Failed to uninstall plugin: {err}"));
app_event_tx.send(AppEvent::PluginUninstallLoaded {
cwd: cwd_for_event,
plugin_id: plugin_id_for_event,
plugin_display_name,
result,
});
});
}
/// Process the completed MCP inventory fetch: clear the loading spinner, then
/// render either the full tool/resource listing or an error into chat history.
///
@@ -3555,6 +3610,15 @@ impl App {
AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
AppEvent::PluginInstallAuthAdvance { refresh_connectors } => {
if refresh_connectors {
self.chat_widget.refresh_connectors(/*force_refetch*/ true);
}
self.chat_widget.advance_plugin_install_auth_flow();
}
AppEvent::PluginInstallAuthAbandon => {
self.chat_widget.abandon_plugin_install_auth_flow();
}
AppEvent::FetchPluginsList { cwd } => {
self.fetch_plugins_list(app_server, cwd);
}
@@ -3564,6 +3628,18 @@ impl App {
self.chat_widget
.open_plugin_detail_loading_popup(&plugin_display_name);
}
AppEvent::OpenPluginInstallLoading {
plugin_display_name,
} => {
self.chat_widget
.open_plugin_install_loading_popup(&plugin_display_name);
}
AppEvent::OpenPluginUninstallLoading {
plugin_display_name,
} => {
self.chat_widget
.open_plugin_uninstall_loading_popup(&plugin_display_name);
}
AppEvent::PluginsLoaded { cwd, result } => {
self.chat_widget.on_plugins_loaded(cwd, result);
}
@@ -3573,6 +3649,62 @@ impl App {
AppEvent::PluginDetailLoaded { cwd, result } => {
self.chat_widget.on_plugin_detail_loaded(cwd, result);
}
AppEvent::FetchPluginInstall {
cwd,
marketplace_path,
plugin_name,
plugin_display_name,
} => {
self.fetch_plugin_install(
app_server,
cwd,
marketplace_path,
plugin_name,
plugin_display_name,
);
}
AppEvent::FetchPluginUninstall {
cwd,
plugin_id,
plugin_display_name,
} => {
self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name);
}
AppEvent::PluginInstallLoaded {
cwd,
marketplace_path,
plugin_name,
plugin_display_name,
result,
} => {
let install_succeeded = result.is_ok();
if install_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after plugin install");
}
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
cwd.clone(),
marketplace_path.clone(),
plugin_name.clone(),
plugin_display_name,
result,
);
if install_succeeded && self.chat_widget.config_ref().cwd == cwd {
self.fetch_plugins_list(app_server, cwd.clone());
if should_refresh_plugin_detail {
self.fetch_plugin_detail(
app_server,
cwd,
PluginReadParams {
marketplace_path,
plugin_name,
},
);
}
}
}
AppEvent::FetchMcpInventory => {
self.fetch_mcp_inventory(app_server);
}
@@ -4012,6 +4144,31 @@ impl App {
}
}
}
AppEvent::PluginUninstallLoaded {
cwd,
plugin_id: _plugin_id,
plugin_display_name,
result,
} => {
let uninstall_succeeded = result.is_ok();
if uninstall_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin uninstall"
);
}
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
self.chat_widget.on_plugin_uninstall_loaded(
cwd.clone(),
plugin_display_name,
result,
);
if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd {
self.fetch_plugins_list(app_server, cwd);
}
}
AppEvent::PersistPersonalitySelection { personality } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
@@ -5133,6 +5290,42 @@ async fn fetch_plugin_detail(
.wrap_err("plugin/read failed in app-server TUI")
}
async fn fetch_plugin_install(
request_handle: AppServerRequestHandle,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
) -> Result<PluginInstallResponse> {
let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::PluginInstall {
request_id,
params: PluginInstallParams {
marketplace_path,
plugin_name,
force_remote_sync: false,
},
})
.await
.wrap_err("plugin/install failed in app-server TUI")
}
async fn fetch_plugin_uninstall(
request_handle: AppServerRequestHandle,
plugin_id: String,
) -> Result<PluginUninstallResponse> {
let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::PluginUninstall {
request_id,
params: PluginUninstallParams {
plugin_id,
force_remote_sync: false,
},
})
.await
.wrap_err("plugin/uninstall failed in app-server TUI")
}
/// Convert flat `McpServerStatus` responses into the per-server maps used by the
/// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus
/// per-server resource/template/auth maps). Test-only because the app-server TUI
+53
View File
@@ -11,9 +11,11 @@
use std::path::PathBuf;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
@@ -21,6 +23,7 @@ use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::GetHistoryEntryResponseEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
@@ -195,6 +198,56 @@ pub(crate) enum AppEvent {
result: Result<PluginReadResponse, String>,
},
/// Replace the plugins popup with an install loading state.
OpenPluginInstallLoading {
plugin_display_name: String,
},
/// Replace the plugins popup with an uninstall loading state.
OpenPluginUninstallLoading {
plugin_display_name: String,
},
/// Install a specific plugin from a marketplace.
FetchPluginInstall {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
},
/// Result of installing a plugin.
PluginInstallLoaded {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
result: Result<PluginInstallResponse, String>,
},
/// Uninstall a specific plugin by canonical plugin id.
FetchPluginUninstall {
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
},
/// Result of uninstalling a plugin.
PluginUninstallLoaded {
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
result: Result<PluginUninstallResponse, String>,
},
/// Advance the post-install plugin app-auth flow.
PluginInstallAuthAdvance {
refresh_connectors: bool,
},
/// Abandon the post-install plugin app-auth flow.
PluginInstallAuthAbandon,
/// Fetch MCP inventory via app-server RPCs and render it into history.
FetchMcpInventory,
@@ -3599,6 +3599,24 @@ impl ChatComposer {
fn mention_items(&self) -> Vec<MentionItem> {
let mut mentions = Vec::new();
let plugin_namespaces: HashSet<String> =
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
plugins
.iter()
.filter_map(|plugin| {
let (plugin_name, _) = plugin
.config_name
.split_once('@')
.unwrap_or((plugin.config_name.as_str(), ""));
let plugin_name = plugin_name.trim();
if plugin_name.is_empty() {
None
} else {
Some(plugin_name.to_ascii_lowercase())
}
})
.collect()
});
let plugin_display_names: HashSet<String> =
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
plugins
@@ -3609,6 +3627,13 @@ impl ChatComposer {
if let Some(skills) = self.skills.as_ref() {
for skill in skills {
let is_plugin_namespaced_skill =
skill.name.split_once(':').is_some_and(|(namespace, _)| {
plugin_namespaces.contains(&namespace.to_ascii_lowercase())
});
if is_plugin_namespaced_skill {
continue;
}
let display_name = skill_display_name(skill).to_string();
let description = skill_description(skill);
let skill_name = skill.name.clone();
@@ -5418,7 +5443,7 @@ mod tests {
}
#[test]
fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() {
fn mention_items_hide_plugin_owned_skill_and_app_duplicates() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
@@ -5480,27 +5505,13 @@ mod tests {
}],
}));
let mut mention_summaries: Vec<_> = composer
.mention_items()
.into_iter()
.map(|mention| (mention.display_name, mention.category_tag, mention.path))
.collect();
mention_summaries.sort();
let mentions = composer.mention_items();
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string()));
assert_eq!(
mention_summaries,
vec![
(
"Google Calendar".to_string(),
Some("[Plugin]".to_string()),
Some("plugin://google-calendar@debug".to_string()),
),
(
"Google Calendar".to_string(),
Some("[Skill]".to_string()),
Some("/tmp/repo/google-calendar/SKILL.md".to_string()),
),
]
mentions[0].path,
Some("plugin://google-calendar@debug".to_string())
);
}
+11
View File
@@ -59,6 +59,7 @@ use crate::status::format_tokens_compact;
use crate::status::rate_limit_snapshot_display_for_limit;
use crate::text_formatting::proper_join;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState;
use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus;
@@ -566,6 +567,12 @@ struct PluginListFetchState {
in_flight_cwd: Option<PathBuf>,
}
#[derive(Debug, Clone)]
struct PluginInstallAuthFlowState {
plugin_display_name: String,
next_app_index: usize,
}
#[derive(Debug)]
enum RateLimitErrorKind {
ServerOverloaded,
@@ -772,6 +779,8 @@ pub(crate) struct ChatWidget {
connectors_force_refetch_pending: bool,
plugins_cache: PluginsCacheState,
plugins_fetch_state: PluginListFetchState,
plugin_install_apps_needing_auth: Vec<AppSummary>,
plugin_install_auth_flow: Option<PluginInstallAuthFlowState>,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
@@ -4309,6 +4318,8 @@ impl ChatWidget {
connectors_force_refetch_pending: false,
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
plugin_install_apps_needing_auth: Vec::new(),
plugin_install_auth_flow: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -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)),
@@ -0,0 +1,16 @@
---
source: tui_app_server/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.
@@ -0,0 +1,17 @@
---
source: tui_app_server/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.
@@ -0,0 +1,9 @@
---
source: tui_app_server/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.
@@ -0,0 +1,12 @@
---
source: tui_app_server/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.
@@ -19,6 +19,7 @@ use crate::model_catalog::ModelCatalog;
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::CollabAgentState as AppServerCollabAgentState;
use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus;
use codex_app_server_protocol::CollabAgentTool as AppServerCollabAgentTool;
@@ -32,9 +33,20 @@ use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification;
use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::MarketplaceInterface;
use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
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::ServerNotification;
use codex_app_server_protocol::SkillSummary;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadItem as AppServerThreadItem;
use codex_app_server_protocol::Turn as AppServerTurn;
@@ -58,6 +70,7 @@ use codex_core::config_loader::ConfigRequirements;
use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::config_loader::RequirementSource;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core::skills::model::SkillMetadata;
use codex_features::FEATURES;
use codex_features::Feature;
@@ -2042,6 +2055,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,
plugins_cache: PluginsCacheState::default(),
@@ -7664,6 +7679,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}"
);
}
#[tokio::test]
async fn apps_popup_stays_loading_until_final_snapshot_updates() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;