Remove TUI plugin marketplace surface

This commit is contained in:
Meta Vens 2026-05-13 15:59:00 +01:00
commit e8c66f32a7
23 changed files with 15 additions and 2936 deletions

View file

@ -868,6 +868,14 @@ mentions, and `codex-tui` no longer depends on `codex-plugin` or
`codex-utils-plugins`. The remaining TUI marketplace code is a product UI
compatibility stump to cut next, not auth/model spine machinery.
That stump is now cut from TUI. `/plugins` is no longer a slash command, the
`chatwidget/plugins.rs` marketplace popup module is deleted, plugin AppEvent
variants and background RPC helpers are gone, plugin enablement write queuing
is gone, and the plugin marketplace popup tests/helpers were deleted. TUI still
has ordinary app connector fields whose protocol shape mentions historical
`plugin_display_names`; those are connector metadata compatibility, not plugin
runtime authority.
The Phase 6 freshness slice is landed. It exposes read-only
`thread/epiphany/freshness` from live retrieval summaries plus graph
frontier/churn state and, for loaded threads, watcher-backed invalidation

Binary file not shown.

View file

@ -306,8 +306,9 @@ current_status:
composition no longer owns plugin mention state, emits structured plugin
mentions, refreshes plugin mentions through `AppEvent`, decodes legacy
`[@plugin](plugin://...)` mention links, or depends on `codex-plugin` /
`codex-utils-plugins`; the remaining TUI plugin marketplace code is a
product UI compatibility stump, not prompt/runtime authority. MCP remains
JSON-RPC at the edge; user-declared MCP servers are the only runtime MCP
authority.
`codex-utils-plugins`. The TUI `/plugins` command and marketplace organ are
now deleted too: no slash command, plugin popup module, plugin AppEvent
variants, plugin background RPC helpers, plugin enablement queue, or
marketplace popup tests remain in `codex-tui`. MCP remains JSON-RPC at the
edge; user-declared MCP servers are the only runtime MCP authority.
next_action: Continue starving coordinator/runtime execution and operator surfaces off app-server JSON-RPC and out of `codex_message_processor.rs`; keep the vestigial Codex organ narrowed to subscription auth/model transport.

View file

@ -81,8 +81,6 @@ use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
@ -90,15 +88,6 @@ use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::MergeStrategy;
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::SendAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ServerNotification;
@ -555,10 +544,6 @@ pub(crate) struct App {
primary_session_configured: Option<ThreadSessionState>,
pending_primary_events: VecDeque<ThreadBufferedEvent>,
pending_app_server_requests: PendingAppServerRequests,
// Serialize plugin enablement writes per plugin so stale completions cannot
// overwrite a newer toggle, even if the plugin is toggled from different
// cwd contexts.
pending_plugin_enabled_writes: HashMap<String, Option<bool>>,
}
fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<AppServerTurnError> {
@ -916,7 +901,6 @@ impl App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
};
if let Some(started) = initial_started_thread {
app.enqueue_primary_thread_session(started.session, started.turns)

View file

@ -154,7 +154,7 @@ impl App {
async fn handle_server_notification_event(
&mut self,
app_server_client: &AppServerSession,
_app_server_client: &AppServerSession,
notification: ServerNotification,
) {
match &notification {
@ -190,16 +190,13 @@ impl App {
return;
}
ServerNotification::ExternalAgentConfigImportCompleted(_) => {
let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after external agent config import"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.fetch_plugins_list(app_server_client, cwd);
return;
}
_ => {}

View file

@ -1,6 +1,6 @@
//! Background app-server requests launched by the TUI app.
//!
//! This module owns fire-and-forget fetch/write helpers for MCP inventory, skills, plugins, rate
//! This module owns fire-and-forget fetch/write helpers for MCP inventory, skills, rate
//! limits, add-credit nudges, and feedback uploads. Results are routed back through `AppEvent` so
//! the main event loop remains single-threaded.
@ -78,126 +78,6 @@ impl App {
});
}
pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_plugins_list(request_handle, cwd.clone())
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::PluginsLoaded { cwd, result });
});
}
pub(super) fn fetch_plugin_detail(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
params: PluginReadParams,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_plugin_detail(request_handle, params)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::PluginDetailLoaded { cwd, result });
});
}
pub(super) 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,
});
});
}
pub(super) 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,
});
});
}
pub(super) fn set_plugin_enabled(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
plugin_id: String,
enabled: bool,
) {
if let Some(queued_enabled) = self.pending_plugin_enabled_writes.get_mut(&plugin_id) {
*queued_enabled = Some(enabled);
return;
}
self.pending_plugin_enabled_writes
.insert(plugin_id.clone(), None);
self.spawn_plugin_enabled_write(app_server, cwd, plugin_id, enabled);
}
pub(super) fn spawn_plugin_enabled_write(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
plugin_id: String,
enabled: bool,
) {
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 = write_plugin_enabled(request_handle, plugin_id, enabled)
.await
.map(|_| ())
.map_err(|err| format!("Failed to update plugin config: {err}"));
app_event_tx.send(AppEvent::PluginEnabledSet {
cwd: cwd_for_event,
plugin_id: plugin_id_for_event,
enabled,
result,
});
});
}
pub(super) fn submit_feedback(
&mut self,
app_server: &AppServerSession,
@ -451,98 +331,6 @@ pub(super) async fn fetch_skills_list(
.wrap_err("skills/list failed in TUI")
}
pub(super) async fn fetch_plugins_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
) -> Result<PluginListResponse> {
let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?;
let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4()));
let mut response = request_handle
.request_typed(ClientRequest::PluginList {
request_id,
params: PluginListParams {
cwds: Some(vec![cwd]),
},
})
.await
.wrap_err("plugin/list failed in TUI")?;
hide_cli_only_plugin_marketplaces(&mut response);
Ok(response)
}
const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"];
pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) {
response
.marketplaces
.retain(|marketplace| !CLI_HIDDEN_PLUGIN_MARKETPLACES.contains(&marketplace.name.as_str()));
}
pub(super) async fn fetch_plugin_detail(
request_handle: AppServerRequestHandle,
params: PluginReadParams,
) -> Result<PluginReadResponse> {
let request_id = RequestId::String(format!("plugin-read-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::PluginRead { request_id, params })
.await
.wrap_err("plugin/read failed in TUI")
}
pub(super) 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: Some(marketplace_path),
remote_marketplace_name: None,
plugin_name,
},
})
.await
.wrap_err("plugin/install failed in TUI")
}
pub(super) 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 },
})
.await
.wrap_err("plugin/uninstall failed in TUI")
}
pub(super) async fn write_plugin_enabled(
request_handle: AppServerRequestHandle,
plugin_id: String,
enabled: bool,
) -> Result<ConfigWriteResponse> {
let request_id = RequestId::String(format!("plugin-enable-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::ConfigValueWrite {
request_id,
params: ConfigValueWriteParams {
key_path: format!("plugins.{plugin_id}"),
value: serde_json::json!({ "enabled": enabled }),
merge_strategy: MergeStrategy::Upsert,
file_path: None,
expected_version: None,
},
})
.await
.wrap_err("config/value/write failed while updating plugin enablement in TUI")
}
pub(super) fn build_feedback_upload_params(
origin_thread_id: Option<ThreadId>,
rollout_path: Option<PathBuf>,
@ -621,49 +409,9 @@ pub(super) fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_protocol::mcp::Tool;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
fn test_absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
}
#[test]
fn hide_cli_only_plugin_marketplaces_removes_openai_bundled() {
let mut response = PluginListResponse {
marketplaces: vec![
PluginMarketplaceEntry {
name: "openai-bundled".to_string(),
path: Some(test_absolute_path("/marketplaces/openai-bundled")),
interface: None,
plugins: Vec::new(),
},
PluginMarketplaceEntry {
name: "openai-curated".to_string(),
path: Some(test_absolute_path("/marketplaces/openai-curated")),
interface: None,
plugins: Vec::new(),
},
],
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
};
hide_cli_only_plugin_marketplaces(&mut response);
assert_eq!(
response.marketplaces,
vec![PluginMarketplaceEntry {
name: "openai-curated".to_string(),
path: Some(test_absolute_path("/marketplaces/openai-curated")),
interface: None,
plugins: Vec::new(),
}]
);
}
#[test]
fn mcp_inventory_maps_prefix_tool_names_by_server() {
let statuses = vec![

View file

@ -26,7 +26,6 @@ impl App {
.await?;
self.apply_runtime_policy_overrides(&mut config);
self.config = config;
self.chat_widget.sync_plugin_mentions_config(&self.config);
Ok(())
}

View file

@ -303,151 +303,6 @@ 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);
}
AppEvent::OpenPluginDetailLoading {
plugin_display_name,
} => {
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);
}
AppEvent::FetchPluginDetail { cwd, params } => {
self.fetch_plugin_detail(app_server, cwd, params);
}
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::SetPluginEnabled {
cwd,
plugin_id,
enabled,
} => {
self.set_plugin_enabled(app_server, cwd, plugin_id, enabled);
}
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.refresh_plugin_mentions();
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.as_path() == cwd.as_path()
{
self.fetch_plugins_list(app_server, cwd.clone());
if should_refresh_plugin_detail {
self.fetch_plugin_detail(
app_server,
cwd,
PluginReadParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
plugin_name,
},
);
}
}
}
AppEvent::PluginEnabledSet {
cwd,
plugin_id,
enabled,
result,
} => {
let queued_enabled = self
.pending_plugin_enabled_writes
.get_mut(&plugin_id)
.and_then(Option::take);
let should_apply_result = if let Some(queued_enabled) = queued_enabled
&& (result.is_err() || queued_enabled != enabled)
{
self.spawn_plugin_enabled_write(
app_server,
cwd.clone(),
plugin_id.clone(),
queued_enabled,
);
false
} else {
true
};
if should_apply_result {
self.pending_plugin_enabled_writes.remove(&plugin_id);
let update_succeeded = result.is_ok();
if update_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin toggle"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
self.chat_widget
.on_plugin_enabled_set(cwd, plugin_id, enabled, result);
}
}
AppEvent::FetchMcpInventory { detail } => {
self.fetch_mcp_inventory(app_server, detail);
}
@ -954,34 +809,6 @@ 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.refresh_plugin_mentions();
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.as_path() == cwd.as_path()
{
self.fetch_plugins_list(app_server, cwd);
}
}
AppEvent::PersistPersonalitySelection { personality } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)

View file

@ -55,7 +55,6 @@ pub(super) async fn make_test_app() -> App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
}
}

View file

@ -3674,7 +3674,6 @@ async fn make_test_app() -> App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
}
}
@ -3731,7 +3730,6 @@ async fn make_test_app_with_channels() -> (
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
},
rx,
op_rx,

View file

@ -15,11 +15,6 @@ use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::McpServerStatusDetail;
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_app_server_protocol::SkillsListResponse;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
@ -232,99 +227,6 @@ pub(crate) enum AppEvent {
force_refetch: bool,
},
/// Fetch plugin marketplace state for the provided working directory.
FetchPluginsList {
cwd: PathBuf,
},
/// Result of fetching plugin marketplace state.
PluginsLoaded {
cwd: PathBuf,
result: Result<PluginListResponse, String>,
},
/// Replace the plugins popup with a plugin-detail loading state.
OpenPluginDetailLoading {
plugin_display_name: String,
},
/// Fetch detail for a specific plugin from a marketplace.
FetchPluginDetail {
cwd: PathBuf,
params: PluginReadParams,
},
/// Result of fetching plugin detail.
PluginDetailLoaded {
cwd: PathBuf,
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>,
},
/// Enable or disable an installed plugin.
SetPluginEnabled {
cwd: PathBuf,
plugin_id: String,
enabled: bool,
},
/// Result of enabling or disabling a plugin.
PluginEnabledSet {
cwd: PathBuf,
plugin_id: String,
enabled: bool,
result: Result<(), 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 {
detail: McpServerStatusDetail,

View file

@ -368,7 +368,6 @@ pub(crate) struct ChatComposer {
config: ChatComposerConfig,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
connectors_enabled: bool,
plugins_command_enabled: bool,
fast_command_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
@ -428,7 +427,6 @@ impl ChatComposer {
BuiltinCommandFlags {
collaboration_modes_enabled: self.collaboration_modes_enabled,
connectors_enabled: self.connectors_enabled,
plugins_command_enabled: self.plugins_command_enabled,
fast_command_enabled: self.fast_command_enabled,
personality_command_enabled: self.personality_command_enabled,
realtime_conversation_enabled: self.realtime_conversation_enabled,
@ -512,7 +510,6 @@ impl ChatComposer {
config,
collaboration_mode_indicator: None,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
@ -550,10 +547,6 @@ impl ChatComposer {
self.sync_popups();
}
pub fn set_plugins_command_enabled(&mut self, enabled: bool) {
self.plugins_command_enabled = enabled;
}
/// Toggle composer-side image paste handling.
///
/// This only affects whether image-like paste content is converted into attachments; the
@ -3455,7 +3448,6 @@ impl ChatComposer {
if is_editing_slash_command_name {
let collaboration_modes_enabled = self.collaboration_modes_enabled;
let connectors_enabled = self.connectors_enabled;
let plugins_command_enabled = self.plugins_command_enabled;
let fast_command_enabled = self.fast_command_enabled;
let personality_command_enabled = self.personality_command_enabled;
let realtime_conversation_enabled = self.realtime_conversation_enabled;
@ -3463,7 +3455,6 @@ impl ChatComposer {
let mut command_popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled,
connectors_enabled,
plugins_command_enabled,
fast_command_enabled,
personality_command_enabled,
realtime_conversation_enabled,

View file

@ -32,7 +32,6 @@ pub(crate) struct CommandPopup {
pub(crate) struct CommandPopupFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) plugins_command_enabled: bool,
pub(crate) fast_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
@ -46,7 +45,6 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
Self {
collaboration_modes_enabled: value.collaboration_modes_enabled,
connectors_enabled: value.connectors_enabled,
plugins_command_enabled: value.plugins_command_enabled,
fast_command_enabled: value.fast_command_enabled,
personality_command_enabled: value.personality_command_enabled,
realtime_conversation_enabled: value.realtime_conversation_enabled,
@ -355,7 +353,6 @@ mod tests {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
@ -376,7 +373,6 @@ mod tests {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
@ -397,7 +393,6 @@ mod tests {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
@ -425,7 +420,6 @@ mod tests {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
@ -446,7 +440,6 @@ mod tests {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,

View file

@ -90,8 +90,6 @@ mod skills_toggle_view;
pub(crate) mod slash_commands;
pub(crate) use footer::CollaborationModeIndicator;
pub(crate) use list_selection_view::ColumnWidthMode;
pub(crate) use list_selection_view::SelectionRowDisplay;
pub(crate) use list_selection_view::SelectionToggle;
pub(crate) use list_selection_view::SelectionViewParams;
pub(crate) use list_selection_view::SideContentWidth;
pub(crate) use list_selection_view::popup_content_width;
@ -124,7 +122,6 @@ mod selection_tabs;
mod textarea;
mod unified_exec_footer;
pub(crate) use feedback_view::FeedbackNoteView;
pub(crate) use selection_tabs::SelectionTab;
/// How long the "press again to quit" hint stays visible.
///
@ -274,11 +271,6 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_plugins_command_enabled(&mut self, enabled: bool) {
self.composer.set_plugins_command_enabled(enabled);
self.request_redraw();
}
pub fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
self.composer.take_mention_bindings()
}
@ -861,13 +853,6 @@ impl BottomPane {
.and_then(|view| view.selected_index())
}
pub(crate) fn active_tab_id_for_active_view(&self, view_id: &'static str) -> Option<&str> {
self.view_stack
.last()
.filter(|view| view.view_id() == Some(view_id))
.and_then(|view| view.active_tab_id())
}
/// Update the pending-input preview shown above the composer.
pub(crate) fn set_pending_input_preview(
&mut self,

View file

@ -14,7 +14,6 @@ use crate::slash_command::built_in_slash_commands;
pub(crate) struct BuiltinCommandFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) plugins_command_enabled: bool,
pub(crate) fast_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
@ -33,7 +32,6 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
|| !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan)
})
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins)
.filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast)
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
@ -73,7 +71,6 @@ mod tests {
BuiltinCommandFlags {
collaboration_modes_enabled: true,
connectors_enabled: true,
plugins_command_enabled: true,
fast_command_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: true,

View file

@ -81,7 +81,6 @@ use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
use codex_app_server_protocol::AppInfo;
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;
@ -371,8 +370,6 @@ mod slash_dispatch;
use self::skills::collect_tool_mentions;
use self::skills::find_app_mentions;
use self::skills::find_skill_mentions_with_tool_mentions;
mod plugins;
use self::plugins::PluginsCacheState;
mod plan_implementation;
use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE;
mod realtime;
@ -608,18 +605,6 @@ enum ConnectorsCacheState {
Failed(String),
}
#[derive(Debug, Clone, Default)]
struct PluginListFetchState {
cache_cwd: Option<PathBuf>,
in_flight_cwd: Option<PathBuf>,
}
#[derive(Debug, Clone)]
struct PluginInstallAuthFlowState {
plugin_display_name: String,
next_app_index: usize,
}
#[derive(Debug)]
enum RateLimitErrorKind {
ServerOverloaded,
@ -862,11 +847,6 @@ pub(crate) struct ChatWidget {
connectors_partial_snapshot: Option<ConnectorsSnapshot>,
connectors_prefetch_in_flight: bool,
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>,
plugins_active_tab_id: Option<String>,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
@ -2157,8 +2137,6 @@ impl ChatWidget {
self.refresh_status_surfaces();
self.sync_fast_command_enabled();
self.sync_personality_command_enabled();
self.sync_plugins_command_enabled();
self.refresh_plugin_mentions();
if display == SessionConfiguredDisplay::Normal {
let startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status =
@ -5173,11 +5151,6 @@ impl ChatWidget {
connectors_partial_snapshot: None,
connectors_prefetch_in_flight: false,
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,
plugins_active_tab_id: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@ -5257,7 +5230,6 @@ impl ChatWidget {
.set_collaboration_modes_enabled(/*enabled*/ true);
widget.sync_fast_command_enabled();
widget.sync_personality_command_enabled();
widget.sync_plugins_command_enabled();
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
@ -9723,10 +9695,6 @@ impl ChatWidget {
if feature == Feature::Personality {
self.sync_personality_command_enabled();
}
if feature == Feature::Plugins {
self.sync_plugins_command_enabled();
self.refresh_plugin_mentions();
}
if feature == Feature::PreventIdleSleep {
self.turn_sleep_inhibitor = SleepInhibitor::new(enabled);
self.turn_sleep_inhibitor
@ -9965,11 +9933,6 @@ impl ChatWidget {
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));
}
fn sync_plugins_command_enabled(&mut self) {
self.bottom_pane
.set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins));
}
fn current_model_supports_personality(&self) -> bool {
let model = self.current_model();
self.model_catalog
@ -10859,7 +10822,6 @@ impl ChatWidget {
fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) {
self.set_skills_from_response(&ev);
self.refresh_plugin_mentions();
}
pub(crate) fn on_connectors_loaded(
@ -10956,15 +10918,6 @@ impl ChatWidget {
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
pub(crate) fn refresh_plugin_mentions(&mut self) {
}
pub(crate) fn sync_plugin_mentions_config(&mut self, config: &Config) {
self.config.features = config.features.clone();
self.config.config_layer_stack = config.config_layer_stack.clone();
self.config.realtime = config.realtime.clone();
self.config.memories = config.memories.clone();
}
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();

File diff suppressed because it is too large Load diff

View file

@ -364,9 +364,6 @@ impl ChatWidget {
SlashCommand::Apps => {
self.add_connectors_output();
}
SlashCommand::Plugins => {
self.add_plugins_output();
}
SlashCommand::Rollout => {
if let Some(path) = self.rollout_path() {
self.add_info_message(
@ -702,7 +699,6 @@ impl ChatWidget {
slash_commands::BuiltinCommandFlags {
collaboration_modes_enabled: self.collaboration_modes_enabled(),
connectors_enabled: self.connectors_enabled(),
plugins_command_enabled: self.config.features.enabled(Feature::Plugins),
fast_command_enabled: self.fast_mode_enabled(),
personality_command_enabled: self.config.features.enabled(Feature::Personality),
realtime_conversation_enabled: self.realtime_conversation_enabled(),
@ -726,7 +722,6 @@ impl ChatWidget {
| SlashCommand::MemoryUpdate
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Rollout
| SlashCommand::Copy
| SlashCommand::Diff

View file

@ -31,7 +31,6 @@ pub(super) use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
pub(super) use codex_app_server_protocol::AdditionalFileSystemPermissions as AppServerAdditionalFileSystemPermissions;
pub(super) use codex_app_server_protocol::AdditionalNetworkPermissions as AppServerAdditionalNetworkPermissions;
pub(super) use codex_app_server_protocol::AdditionalPermissionProfile as AppServerAdditionalPermissionProfile;
pub(super) use codex_app_server_protocol::AppSummary;
pub(super) use codex_app_server_protocol::AutoReviewDecisionSource as AppServerGuardianApprovalReviewDecisionSource;
pub(super) use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState;
pub(super) use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus;
@ -65,25 +64,14 @@ pub(super) use codex_app_server_protocol::ItemCompletedNotification;
pub(super) use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification;
pub(super) use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification;
pub(super) use codex_app_server_protocol::ItemStartedNotification;
pub(super) use codex_app_server_protocol::MarketplaceInterface;
pub(super) use codex_app_server_protocol::McpServerStartupState;
pub(super) use codex_app_server_protocol::McpServerStatusDetail;
pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification;
pub(super) use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus;
pub(super) use codex_app_server_protocol::PatchChangeKind;
pub(super) use codex_app_server_protocol::PermissionsRequestApprovalParams as AppServerPermissionsRequestApprovalParams;
pub(super) use codex_app_server_protocol::PluginAuthPolicy;
pub(super) use codex_app_server_protocol::PluginDetail;
pub(super) use codex_app_server_protocol::PluginInstallPolicy;
pub(super) use codex_app_server_protocol::PluginInterface;
pub(super) use codex_app_server_protocol::PluginListResponse;
pub(super) use codex_app_server_protocol::PluginMarketplaceEntry;
pub(super) use codex_app_server_protocol::PluginReadResponse;
pub(super) use codex_app_server_protocol::PluginSource;
pub(super) use codex_app_server_protocol::PluginSummary;
pub(super) use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
pub(super) use codex_app_server_protocol::ServerNotification;
pub(super) use codex_app_server_protocol::SkillSummary;
pub(super) use codex_app_server_protocol::ThreadClosedNotification;
pub(super) use codex_app_server_protocol::ThreadItem as AppServerThreadItem;
pub(super) use codex_app_server_protocol::Turn as AppServerTurn;
@ -225,7 +213,6 @@ pub(super) use tokio::sync::mpsc::error::TryRecvError;
pub(super) use tokio::sync::mpsc::unbounded_channel;
pub(super) use toml::Value as TomlValue;
pub(super) const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
pub(super) fn chatwidget_snapshot_dir() -> PathBuf {
let snapshot_file = codex_utils_cargo_bin::find_resource!(

View file

@ -237,13 +237,8 @@ pub(super) async fn make_chatwidget_manual(
mcp_startup_pending_next_round_saw_starting: false,
connectors_cache: ConnectorsCacheState::default(),
connectors_partial_snapshot: None,
plugin_install_apps_needing_auth: Vec::new(),
plugin_install_auth_flow: None,
plugins_active_tab_id: None,
connectors_prefetch_in_flight: false,
connectors_force_refetch_pending: false,
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@ -845,162 +840,6 @@ pub(super) fn strip_osc8_for_snapshot(text: &str) -> String {
stripped
}
pub(super) fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf {
std::env::temp_dir()
.join("codex-plugin-menu-tests")
.join(path)
.abs()
}
pub(super) 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,
composer_icon_url: None,
logo: None,
logo_url: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
}
}
pub(super) 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,
/*long_description*/ None,
)),
}
}
pub(super) fn plugins_test_curated_marketplace(
plugins: Vec<PluginSummary>,
) -> PluginMarketplaceEntry {
PluginMarketplaceEntry {
name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
path: Some(plugins_test_absolute_path("marketplaces/chatgpt")),
interface: Some(MarketplaceInterface {
display_name: Some("ChatGPT Marketplace".to_string()),
}),
plugins,
}
}
pub(super) fn plugins_test_repo_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketplaceEntry {
PluginMarketplaceEntry {
name: "repo".to_string(),
path: Some(plugins_test_absolute_path("marketplaces/repo")),
interface: Some(MarketplaceInterface {
display_name: Some("Repo Marketplace".to_string()),
}),
plugins,
}
}
pub(super) fn plugins_test_response(
marketplaces: Vec<PluginMarketplaceEntry>,
) -> PluginListResponse {
PluginListResponse {
marketplaces,
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
}
}
pub(super) fn render_loaded_plugins_popup(
chat: &mut ChatWidget,
response: PluginListResponse,
) -> String {
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response));
chat.add_plugins_output();
render_bottom_popup(chat, /*width*/ 100)
}
pub(super) 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: Some(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: Some(plugins_test_absolute_path(&format!(
"skills/{name}/SKILL.md"
))),
enabled: true,
})
.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(),
}
}
pub(super) fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize {
popup
.find(needle)
.unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}"))
}
pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) {
for ch in query.chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
}
pub(super) async fn assert_hook_events_snapshot(
event_name: codex_protocol::protocol::HookEventName,
run_id: &str,

View file

@ -86,721 +86,6 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
assert_eq!(chat.current_model(), resolved_model);
}
#[tokio::test]
async fn plugins_popup_loading_state_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
chat.add_plugins_output();
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("Loading available plugins..."),
"expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}"
);
assert_chatwidget_snapshot!("plugins_popup_loading_state", popup);
}
#[tokio::test]
async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_name() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let response = plugins_test_response(vec![
plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-bravo",
"bravo",
Some("Bravo Search"),
Some("Search docs and tickets."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-alpha",
"alpha",
Some("Alpha Sync"),
Some("Already installed but disabled."),
/*installed*/ true,
/*enabled*/ false,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-starter",
"starter",
Some("Starter"),
Some("Included by default."),
/*installed*/ false,
/*enabled*/ 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."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
)]),
]);
let popup = render_loaded_plugins_popup(&mut chat, response);
assert_chatwidget_snapshot!("plugins_popup_curated_marketplace", popup);
assert!(
popup.contains("Hidden Repo Plugin"),
"expected /plugins to include non-curated marketplaces, got:\n{popup}"
);
assert!(
plugins_test_popup_row_position(&popup, "Alpha Sync")
< plugins_test_popup_row_position(&popup, "Bravo Search")
&& plugins_test_popup_row_position(&popup, "Bravo Search")
< plugins_test_popup_row_position(&popup, "Hidden Repo Plugin")
&& plugins_test_popup_row_position(&popup, "Hidden Repo Plugin")
< plugins_test_popup_row_position(&popup, "Starter"),
"expected /plugins rows to sort installed plugins first, then alphabetically, 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(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = plugins_test_summary(
"plugin-figma",
"figma",
Some("Figma"),
Some("Design handoff."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
);
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
summary.clone(),
])]);
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response));
chat.add_plugins_output();
chat.on_plugin_detail_loaded(
cwd.to_path_buf(),
Ok(PluginReadResponse {
plugin: plugins_test_detail(
summary,
Some("Turn Figma files into implementation context."),
&["design-review", "extract-copy"],
&[("Figma", true), ("Slack", false)],
&["figma-mcp", "docs-mcp"],
),
}),
);
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert_chatwidget_snapshot!(
"plugin_detail_popup_installable",
strip_osc8_for_snapshot(&popup)
);
}
#[tokio::test]
async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = plugins_test_summary(
"plugin-figma",
"figma",
Some("Figma"),
Some("Design handoff."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
);
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
summary.clone(),
])]);
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response));
chat.add_plugins_output();
chat.on_plugin_detail_loaded(
cwd.to_path_buf(),
Ok(PluginReadResponse {
plugin: plugins_test_detail(
summary,
Some("Turn Figma files into implementation context."),
&["design-review", "extract-copy"],
&[("Figma", true), ("Slack", false)],
&["figma-mcp", "docs-mcp"],
),
}),
);
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
!popup.contains("Data shared with this app is subject to the app's"),
"expected installed plugin details to hide the disclosure line, got:\n{popup}"
);
assert_chatwidget_snapshot!(
"plugin_detail_popup_installed",
strip_osc8_for_snapshot(&popup)
);
}
#[tokio::test]
async fn plugins_popup_refresh_preserves_selected_row_position() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-notion",
"notion",
Some("Notion"),
Some("Workspace docs."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-slack",
"slack",
Some("Slack"),
Some("Team chat."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]);
render_loaded_plugins_popup(&mut chat, initial);
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
let before = render_bottom_popup(&chat, /*width*/ 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."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-notion",
"notion",
Some("Notion"),
Some("Workspace docs."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-slack",
"slack",
Some("Slack"),
Some("Team chat."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]);
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed));
let after = render_bottom_popup(&chat, /*width*/ 100);
assert!(
after.contains("› [-] Notion"),
"expected refresh to preserve the selected row position, got:\n{after}"
);
assert!(
after.contains("Airtable"),
"expected refreshed popup to include the updated plugin list, got:\n{after}"
);
assert!(
after.contains("Slack"),
"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(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-calendar",
"calendar",
Some("Calendar"),
Some("Schedule management."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ true,
/*enabled*/ 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("Available"),
"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."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]);
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed));
let after = render_bottom_popup(&chat, /*width*/ 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 Space to disable; Enter view details."),
"expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}"
);
}
#[tokio::test]
async fn plugins_popup_space_toggles_installed_plugin_from_list() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let cwd = chat.config.cwd.to_path_buf();
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."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SetPluginEnabled {
cwd: event_cwd,
plugin_id,
enabled,
}) => {
assert_eq!(event_cwd, cwd);
assert_eq!(plugin_id, "plugin-drive");
assert!(!enabled);
}
other => panic!("expected SetPluginEnabled event, got {other:?}"),
}
chat.on_plugin_enabled_set(
cwd,
"plugin-drive".to_string(),
/*enabled*/ false,
Ok(()),
);
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("› [ ] Drive"),
"expected selected plugin row to stay selected after refresh, got:\n{popup}"
);
}
#[tokio::test]
async fn plugins_popup_space_on_uninstalled_row_does_not_start_search() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ 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."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
while rx.try_recv().is_ok() {}
let before = render_bottom_popup(&chat, /*width*/ 100);
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let after = render_bottom_popup(&chat, /*width*/ 100);
assert!(
rx.try_recv().is_err(),
"did not expect Space on an uninstalled plugin to emit an event"
);
assert_eq!(after, before);
}
#[tokio::test]
async fn plugins_popup_space_with_active_search_does_not_toggle_installed_plugin() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ 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."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
type_plugins_search_query(&mut chat, "dr");
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(
rx.try_recv().is_err(),
"did not expect Space with an active plugin search to emit a toggle event"
);
}
#[tokio::test]
async fn plugins_popup_search_filters_visible_rows_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ 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."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-slack",
"slack",
Some("Slack"),
Some("Team chat."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
type_plugins_search_query(&mut chat, "sla");
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert_chatwidget_snapshot!("plugins_popup_search_filtered", popup);
assert!(
!popup.contains("Calendar") && !popup.contains("Drive"),
"expected search to leave only matching rows visible, got:\n{popup}"
);
}
#[tokio::test]
async fn plugins_popup_installed_tab_filters_rows_and_clears_search() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ 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."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-slack",
"slack",
Some("Slack"),
Some("Team chat."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
type_plugins_search_query(&mut chat, "sla");
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("Installed plugins.") && popup.contains("Showing 1 installed plugins."),
"expected Installed tab header, got:\n{popup}"
);
assert!(
popup.contains("Calendar") && !popup.contains("Slack"),
"expected Installed tab to show only installed plugins, got:\n{popup}"
);
assert!(
!popup.contains("sla"),
"expected tab switch to clear search query, got:\n{popup}"
);
}
#[tokio::test]
async fn plugins_popup_openai_curated_tab_omits_marketplace_in_rows() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ 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."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
)]),
plugins_test_repo_marketplace(vec![plugins_test_summary(
"plugin-repo",
"repo",
Some("Repo Plugin"),
Some("Repo-only plugin."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
)]),
]),
);
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("OpenAI Curated marketplace."),
"expected OpenAI Curated tab header, got:\n{popup}"
);
assert!(
popup.contains("Calendar") && !popup.contains("Repo Plugin"),
"expected OpenAI Curated tab to show only official marketplace plugins, got:\n{popup}"
);
assert!(
!popup.contains("ChatGPT Marketplace ·"),
"expected marketplace-specific rows to omit marketplace labels, got:\n{popup}"
);
}
#[tokio::test]
async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let response = plugins_test_response(vec![
PluginMarketplaceEntry {
name: "duplicate".to_string(),
path: Some(plugins_test_absolute_path(
"marketplaces/home/marketplace.json",
)),
interface: Some(MarketplaceInterface {
display_name: Some("Duplicate Marketplace".to_string()),
}),
plugins: vec![plugins_test_summary(
"plugin-home",
"home",
Some("Home Plugin"),
Some("Home marketplace plugin."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
)],
},
PluginMarketplaceEntry {
name: "duplicate".to_string(),
path: Some(plugins_test_absolute_path(
"marketplaces/repo/marketplace.json",
)),
interface: Some(MarketplaceInterface {
display_name: Some("Duplicate Marketplace".to_string()),
}),
plugins: vec![plugins_test_summary(
"plugin-repo",
"repo",
Some("Repo Plugin"),
Some("Repo marketplace plugin."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
)],
},
]);
let cwd = chat.config.cwd.to_path_buf();
chat.on_plugins_loaded(cwd.clone(), Ok(response.clone()));
chat.add_plugins_output();
for _ in 0..4 {
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
}
chat.on_plugins_loaded(cwd, Ok(response));
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("Duplicate Marketplace (2/2)."),
"expected refresh to preserve the second duplicate marketplace tab, got:\n{popup}"
);
assert!(
popup.contains("Repo Plugin") && !popup.contains("Home Plugin"),
"expected second duplicate marketplace rows after refresh, 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(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ 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."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-slack",
"slack",
Some("Slack"),
Some("Team chat."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
type_plugins_search_query(&mut chat, "zzz");
let no_matches = render_bottom_popup(&chat, /*width*/ 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, /*width*/ 100);
assert!(
restored.contains("Calendar") && restored.contains("Slack"),
"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(/*model_override*/ None).await;

View file

@ -1,5 +1,4 @@
mod auth;
pub(crate) mod onboarding_screen;
mod trust_directory;
pub(crate) use auth::mark_url_hyperlink;
mod welcome;

View file

@ -45,7 +45,6 @@ pub enum SlashCommand {
Theme,
Mcp,
Apps,
Plugins,
Logout,
Quit,
Exit,
@ -117,7 +116,6 @@ impl SlashCommand {
SlashCommand::Memories => "configure memory use and generation",
SlashCommand::Mcp => "list configured MCP tools; use /mcp verbose for details",
SlashCommand::Apps => "manage apps",
SlashCommand::Plugins => "browse plugins",
SlashCommand::Logout => "log out of Codex",
SlashCommand::Rollout => "print the rollout file path",
SlashCommand::TestApproval => "test approval request",
@ -188,7 +186,6 @@ impl SlashCommand {
| SlashCommand::Stop
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Feedback
| SlashCommand::Quit
| SlashCommand::Exit