Initial plugins TUI menu - list and read only. tui + tui_app_server (#15215)

### Preliminary /plugins TUI menu
- Adds a preliminary /plugins menu flow in both tui and tui_app_server.
- Fetches plugin list data asynchronously and shows loading/error/cached
states.
  - Limits this first pass to the curated ChatGPT marketplace.
  - Shows available plugins with installed/status metadata.
- Supports in-menu search over plugin display name, plugin id, plugin
name, and marketplace label.
- Opens a plugin detail view on selection, including summaries for
Skills, Apps, and MCP Servers, with back navigation.

### Testing
  - Launch codex-cli with plugins enabled (`--enable plugins`).
  - Run /plugins and verify:
      - loading state appears first
      - plugin list is shown
      - search filters results
- selecting a plugin opens detail view, with a list of
skills/connectors/MCP servers for the plugin
      - back action returns to the list.
- Verify disabled behavior by running /plugins without plugins enabled
(shows “Plugins are disabled” message).
- Launch with `--enable tui_app_server` (and plugins enabled) and repeat
the same /plugins flow; behavior should match.
This commit is contained in:
canvrno-oai
2026-03-19 21:28:33 -07:00
committed by GitHub
Unverified
parent fa2a2f0be9
commit f7201e5a9f
15 changed files with 1505 additions and 5 deletions
+1
View File
@@ -2566,6 +2566,7 @@ dependencies = [
"chrono",
"clap",
"codex-ansi-escape",
"codex-app-server-client",
"codex-app-server-protocol",
"codex-arg0",
"codex-backend-client",
+1
View File
@@ -29,6 +29,7 @@ base64 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
codex-ansi-escape = { workspace = true }
codex-app-server-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-backend-client = { workspace = true }
+211 -4
View File
@@ -39,7 +39,18 @@ use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
use codex_app_server_client::InProcessAppServerClient;
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::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::RequestId;
use codex_arg0::Arg0DispatchPaths;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ThreadManager;
@@ -50,7 +61,9 @@ use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::types::ApprovalsReviewer;
use codex_core::config::types::ModelAvailabilityNuxConfig;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::config_loader::LoaderOverrides;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
@@ -112,6 +125,7 @@ use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::unbounded_channel;
use tokio::task::JoinHandle;
use toml::Value as TomlValue;
use uuid::Uuid;
mod agent_navigation;
mod pending_interactive_replay;
@@ -233,6 +247,114 @@ fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorI
}
}
fn config_warning_notifications(config: &Config) -> Vec<ConfigWarningNotification> {
config
.startup_warnings
.iter()
.map(|warning| ConfigWarningNotification {
summary: warning.clone(),
details: None,
path: None,
range: None,
})
.collect()
}
async fn start_plugin_request_client(
arg0_paths: Arg0DispatchPaths,
config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
) -> Result<InProcessAppServerClient> {
InProcessAppServerClient::start(InProcessClientStartArgs {
arg0_paths,
config_warnings: config_warning_notifications(&config),
config: Arc::new(config),
cli_overrides: cli_kv_overrides,
loader_overrides,
cloud_requirements,
feedback,
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
client_name: "codex-tui".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await
.wrap_err("failed to start embedded app server for plugin request")
}
async fn request_plugins_list(
arg0_paths: Arg0DispatchPaths,
config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
cwd: PathBuf,
) -> Result<PluginListResponse> {
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 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 response = request_handle
.request_typed(ClientRequest::PluginList {
request_id,
params: PluginListParams {
cwds: Some(vec![cwd]),
force_remote_sync: false,
},
})
.await
.wrap_err("plugin/list failed in legacy TUI");
if let Err(err) = client.shutdown().await {
tracing::warn!(%err, "failed to shut down embedded app server after plugin/list");
}
response
}
async fn request_plugin_detail(
arg0_paths: Arg0DispatchPaths,
config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
params: PluginReadParams,
) -> Result<PluginReadResponse> {
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-read-{}", Uuid::new_v4()));
let response = request_handle
.request_typed(ClientRequest::PluginRead { request_id, params })
.await
.wrap_err("plugin/read failed in legacy TUI");
if let Err(err) = client.shutdown().await {
tracing::warn!(%err, "failed to shut down embedded app server after plugin/read");
}
response
}
fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) {
let mut disabled_folders = Vec::new();
@@ -706,6 +828,9 @@ pub(crate) struct App {
pub(crate) config: Config,
pub(crate) active_profile: Option<String>,
cli_kv_overrides: Vec<(String, TomlValue)>,
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
harness_overrides: ConfigOverrides,
runtime_approval_policy_override: Option<AskForApproval>,
runtime_sandbox_policy_override: Option<SandboxPolicy>,
@@ -1184,6 +1309,62 @@ impl App {
.add_info_message(format!("Opened {url} in your browser."), /*hint*/ None);
}
fn fetch_plugins_list(&mut self, cwd: PathBuf) {
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 result = request_plugins_list(
arg0_paths,
config,
cli_kv_overrides,
loader_overrides,
cloud_requirements,
feedback,
cwd,
)
.await
.map_err(|err| format!("Failed to load plugins: {err}"));
app_event_tx.send(AppEvent::PluginsLoaded {
cwd: cwd_for_event,
result,
});
});
}
fn fetch_plugin_detail(&mut self, cwd: PathBuf, params: PluginReadParams) {
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 result = request_plugin_detail(
arg0_paths,
config,
cli_kv_overrides,
loader_overrides,
cloud_requirements,
feedback,
params,
)
.await
.map_err(|err| format!("Failed to load plugin details: {err}"));
app_event_tx.send(AppEvent::PluginDetailLoaded {
cwd: cwd_for_event,
result,
});
});
}
fn clear_ui_header_lines_with_version(
&self,
width: u16,
@@ -2000,6 +2181,9 @@ impl App {
auth_manager: Arc<AuthManager>,
mut config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
harness_overrides: ConfigOverrides,
active_profile: Option<String>,
initial_prompt: Option<String>,
@@ -2029,10 +2213,6 @@ impl App {
.enabled(Feature::DefaultModeRequestUserInput),
},
));
// TODO(xl): Move into PluginManager once this no longer depends on config feature gating.
thread_manager
.plugins_manager()
.maybe_start_curated_repo_sync_for_config(&config, auth_manager.clone());
let mut model = thread_manager
.get_models_manager()
.get_default_model(&config.model, RefreshStrategy::Offline)
@@ -2227,6 +2407,9 @@ impl App {
config,
active_profile,
cli_kv_overrides,
arg0_paths,
loader_overrides,
cloud_requirements,
harness_overrides,
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
@@ -2770,6 +2953,15 @@ impl App {
AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
AppEvent::FetchPluginsList { cwd } => {
self.fetch_plugins_list(cwd);
}
AppEvent::OpenPluginDetailLoading {
plugin_display_name,
} => {
self.chat_widget
.open_plugin_detail_loading_popup(&plugin_display_name);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
@@ -2782,6 +2974,15 @@ impl App {
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
}
AppEvent::PluginsLoaded { cwd, result } => {
self.chat_widget.on_plugins_loaded(cwd, result);
}
AppEvent::FetchPluginDetail { cwd, params } => {
self.fetch_plugin_detail(cwd, params);
}
AppEvent::PluginDetailLoaded { cwd, result } => {
self.chat_widget.on_plugin_detail_loaded(cwd, result);
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
self.refresh_status_surfaces();
@@ -6553,6 +6754,9 @@ guardian_approval = true
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
arg0_paths: Arg0DispatchPaths::default(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
@@ -6614,6 +6818,9 @@ guardian_approval = true
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
arg0_paths: Arg0DispatchPaths::default(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
+31
View File
@@ -10,6 +10,9 @@
use std::path::PathBuf;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
@@ -162,6 +165,34 @@ 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>,
},
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.
+19
View File
@@ -289,6 +289,8 @@ mod skills;
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 realtime;
use self::realtime::RealtimeConversationUiState;
use self::realtime::RenderedUserMessageEvent;
@@ -520,6 +522,12 @@ enum ConnectorsCacheState {
Failed(String),
}
#[derive(Debug, Clone, Default)]
struct PluginListFetchState {
cache_cwd: Option<PathBuf>,
in_flight_cwd: Option<PathBuf>,
}
#[derive(Debug)]
enum RateLimitErrorKind {
ServerOverloaded,
@@ -712,6 +720,8 @@ 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,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
@@ -3654,6 +3664,8 @@ 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(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -3852,6 +3864,8 @@ 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(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -4042,6 +4056,8 @@ 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(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -4655,6 +4671,9 @@ 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(
+550
View File
@@ -0,0 +1,550 @@
use std::path::PathBuf;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::history_cell;
use crate::render::renderable::ColumnRenderable;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginSummary;
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_features::Feature;
use ratatui::style::Stylize;
use ratatui::text::Line;
const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection";
const SUPPORTED_MARKETPLACE_NAME: &str = OPENAI_CURATED_MARKETPLACE_NAME;
#[derive(Debug, Clone, Default)]
pub(super) enum PluginsCacheState {
#[default]
Uninitialized,
Loading,
Ready(PluginListResponse),
Failed(String),
}
impl ChatWidget {
pub(crate) fn add_plugins_output(&mut self) {
if !self.config.features.enabled(Feature::Plugins) {
self.add_info_message(
"Plugins are disabled.".to_string(),
Some("Enable the plugins feature to use /plugins.".to_string()),
);
return;
}
self.prefetch_plugins();
match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(response) => {
self.open_plugins_popup(&response);
}
PluginsCacheState::Failed(err) => {
self.add_to_history(history_cell::new_error_event(err));
}
PluginsCacheState::Loading | PluginsCacheState::Uninitialized => {
self.open_plugins_loading_popup();
}
}
self.request_redraw();
}
pub(crate) fn on_plugins_loaded(
&mut self,
cwd: PathBuf,
result: Result<PluginListResponse, String>,
) {
if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) {
self.plugins_fetch_state.in_flight_cwd = None;
}
if self.config.cwd != cwd {
return;
}
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);
}
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),
);
}
}
}
fn prefetch_plugins(&mut self) {
let cwd = self.config.cwd.clone();
if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) {
return;
}
self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone());
if self.plugins_fetch_state.cache_cwd.as_ref() != Some(&cwd) {
self.plugins_cache = PluginsCacheState::Loading;
}
self.app_event_tx.send(AppEvent::FetchPluginsList { cwd });
}
fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState {
if self.plugins_fetch_state.cache_cwd.as_ref() == Some(&self.config.cwd) {
self.plugins_cache.clone()
} else {
PluginsCacheState::Uninitialized
}
}
fn open_plugins_loading_popup(&mut self) {
if !self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_loading_popup_params(),
) {
self.bottom_pane
.show_selection_view(self.plugins_loading_popup_params());
}
}
fn open_plugins_popup(&mut self, response: &PluginListResponse) {
self.bottom_pane
.show_selection_view(self.plugins_popup_params(response));
}
pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) {
let params = self.plugin_detail_loading_popup_params(plugin_display_name);
let _ = self
.bottom_pane
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
}
pub(crate) fn on_plugin_detail_loaded(
&mut self,
cwd: PathBuf,
result: Result<PluginReadResponse, String>,
) {
if self.config.cwd != cwd {
return;
}
let plugins_response = match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(response) => Some(response),
_ => None,
};
match result {
Ok(response) => {
if let Some(plugins_response) = plugins_response {
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugin_detail_popup_params(&plugins_response, &response.plugin),
);
}
}
Err(err) => {
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
);
}
}
}
fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) {
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_popup_params(response),
);
}
fn plugins_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Loading available plugins...".dim()));
header.push(Line::from(
"This first pass shows the ChatGPT marketplace only.".dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Loading plugins...".to_string(),
description: Some("This updates when the marketplace list is ready.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Loading details for {plugin_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Loading plugin details...".to_string(),
description: Some(
"This updates when the plugin detail request finishes.".to_string(),
),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to load plugins.".dim()));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Plugin marketplace unavailable".to_string(),
description: Some(err.to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugin_detail_error_popup_params(
&self,
err: &str,
plugins_response: Option<&PluginListResponse>,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to load plugin details.".dim()));
let mut items = vec![SelectionItem {
name: "Plugin detail unavailable".to_string(),
description: Some(err.to_string()),
is_disabled: true,
..Default::default()
}];
if let Some(plugins_response) = plugins_response.cloned() {
let cwd = self.config.cwd.clone();
items.push(SelectionItem {
name: "Back to plugins".to_string(),
description: Some("Return to the plugin list.".to_string()),
selected_description: Some("Return to the plugin list.".to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PluginsLoaded {
cwd: cwd.clone(),
result: Ok(plugins_response.clone()),
});
})],
..Default::default()
});
}
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugins_popup_hint_line()),
items,
..Default::default()
}
}
fn plugins_popup_params(&self, response: &PluginListResponse) -> SelectionViewParams {
let marketplaces: Vec<&PluginMarketplaceEntry> = response
.marketplaces
.iter()
.filter(|marketplace| marketplace.name == SUPPORTED_MARKETPLACE_NAME)
.collect();
let total: usize = marketplaces
.iter()
.map(|marketplace| marketplace.plugins.len())
.sum();
let installed = marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.filter(|plugin| plugin.installed)
.count();
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
"Browse plugins from the ChatGPT marketplace.".dim(),
));
header.push(Line::from(
format!("Installed {installed} of {total} available plugins.").dim(),
));
if let Some(remote_sync_error) = response.remote_sync_error.as_deref() {
header.push(Line::from(
format!("Using cached marketplace data: {remote_sync_error}").dim(),
));
}
let mut items: Vec<SelectionItem> = Vec::new();
for marketplace in marketplaces {
let marketplace_label = marketplace_display_name(marketplace);
for plugin in &marketplace.plugins {
let display_name = plugin_display_name(plugin);
let status_label = plugin_status_label(plugin);
let description = plugin_brief_description(plugin, &marketplace_label);
let selected_description =
format!("{status_label}. Press Enter to view plugin details.");
let search_value = format!(
"{display_name} {} {} {}",
plugin.id, plugin.name, marketplace_label
);
let cwd = self.config.cwd.clone();
let plugin_display_name = display_name.clone();
let marketplace_path = marketplace.path.clone();
let plugin_name = plugin.name.clone();
items.push(SelectionItem {
name: format!("{display_name} · {marketplace_label}"),
description: Some(description),
selected_description: Some(selected_description),
search_value: Some(search_value),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenPluginDetailLoading {
plugin_display_name: plugin_display_name.clone(),
});
tx.send(AppEvent::FetchPluginDetail {
cwd: cwd.clone(),
params: codex_app_server_protocol::PluginReadParams {
marketplace_path: marketplace_path.clone(),
plugin_name: plugin_name.clone(),
},
});
})],
..Default::default()
});
}
}
if items.is_empty() {
items.push(SelectionItem {
name: "No ChatGPT marketplace plugins available".to_string(),
description: Some(
"This first pass only surfaces the ChatGPT plugin marketplace.".to_string(),
),
is_disabled: true,
..Default::default()
});
}
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugins_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search plugins".to_string()),
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
}
}
fn plugin_detail_popup_params(
&self,
plugins_response: &PluginListResponse,
plugin: &PluginDetail,
) -> SelectionViewParams {
let marketplace_label = plugin.marketplace_name.clone();
let display_name = plugin_display_name(&plugin.summary);
let status_label = plugin_status_label(&plugin.summary);
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("{display_name} · {marketplace_label}").bold(),
));
header.push(Line::from(status_label.dim()));
if let Some(description) = plugin_detail_description(plugin) {
header.push(Line::from(description.dim()));
}
let cwd = self.config.cwd.clone();
let plugins_response = plugins_response.clone();
let mut items = vec![SelectionItem {
name: "Back to plugins".to_string(),
description: Some("Return to the plugin list.".to_string()),
selected_description: Some("Return to the plugin list.".to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PluginsLoaded {
cwd: cwd.clone(),
result: Ok(plugins_response.clone()),
});
})],
..Default::default()
}];
items.push(SelectionItem {
name: "Skills".to_string(),
description: Some(plugin_skill_summary(plugin)),
is_disabled: true,
..Default::default()
});
items.push(SelectionItem {
name: "Apps".to_string(),
description: Some(plugin_app_summary(plugin)),
is_disabled: true,
..Default::default()
});
items.push(SelectionItem {
name: "MCP Servers".to_string(),
description: Some(plugin_mcp_summary(plugin)),
is_disabled: true,
..Default::default()
});
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugins_popup_hint_line()),
items,
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
}
}
}
fn plugins_popup_hint_line() -> Line<'static> {
Line::from("Press esc to close.")
}
fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String {
marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.map(str::trim)
.filter(|display_name| !display_name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| marketplace.name.clone())
}
fn plugin_display_name(plugin: &PluginSummary) -> String {
plugin
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.map(str::trim)
.filter(|display_name| !display_name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| plugin.name.clone())
}
fn plugin_brief_description(plugin: &PluginSummary, marketplace_label: &str) -> String {
let status_label = plugin_status_label(plugin);
match plugin_description(plugin) {
Some(description) => format!("{status_label} · {marketplace_label} · {description}"),
None => format!("{status_label} · {marketplace_label}"),
}
}
fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
if plugin.installed {
if plugin.enabled {
"Installed"
} else {
"Installed · Disabled"
}
} else {
match plugin.install_policy {
PluginInstallPolicy::NotAvailable => "Not installable",
PluginInstallPolicy::Available => "Can be installed",
PluginInstallPolicy::InstalledByDefault => "Available by default",
}
}
}
fn plugin_description(plugin: &PluginSummary) -> Option<String> {
plugin
.interface
.as_ref()
.and_then(|interface| {
interface
.short_description
.as_deref()
.or(interface.long_description.as_deref())
})
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn plugin_detail_description(plugin: &PluginDetail) -> Option<String> {
plugin
.description
.as_deref()
.or_else(|| {
plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.long_description.as_deref())
})
.or_else(|| {
plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
})
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn plugin_skill_summary(plugin: &PluginDetail) -> String {
if plugin.skills.is_empty() {
"No plugin skills.".to_string()
} else {
plugin
.skills
.iter()
.map(|skill| skill.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
fn plugin_app_summary(plugin: &PluginDetail) -> String {
if plugin.apps.is_empty() {
"No plugin apps.".to_string()
} else {
plugin
.apps
.iter()
.map(|app| app.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
fn plugin_mcp_summary(plugin: &PluginDetail) -> String {
if plugin.mcp_servers.is_empty() {
"No plugin MCP servers.".to_string()
} else {
plugin.mcp_servers.join(", ")
}
}
+2
View File
@@ -1895,6 +1895,8 @@ async fn make_chatwidget_manual(
connectors_partial_snapshot: 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(),
+8 -1
View File
@@ -266,7 +266,7 @@ pub use public_widgets::composer_input::ComposerInput;
pub async fn run_main(
mut cli: Cli,
arg0_paths: Arg0DispatchPaths,
_loader_overrides: LoaderOverrides,
loader_overrides: LoaderOverrides,
) -> std::io::Result<AppExitInfo> {
let (sandbox_mode, approval_policy) = if cli.full_auto {
(
@@ -569,9 +569,11 @@ pub async fn run_main(
run_ratatui_app(
cli,
arg0_paths,
config,
overrides,
cli_kv_overrides,
loader_overrides,
cloud_requirements,
feedback,
)
@@ -582,9 +584,11 @@ pub async fn run_main(
#[allow(clippy::too_many_arguments)]
async fn run_ratatui_app(
cli: Cli,
arg0_paths: Arg0DispatchPaths,
initial_config: Config,
overrides: ConfigOverrides,
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
mut cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
) -> color_eyre::Result<AppExitInfo> {
@@ -985,6 +989,9 @@ async fn run_ratatui_app(
auth_manager,
config,
cli_kv_overrides.clone(),
arg0_paths,
loader_overrides,
cloud_requirements,
overrides.clone(),
active_profile,
prompt,
+3
View File
@@ -43,6 +43,7 @@ pub enum SlashCommand {
Theme,
Mcp,
Apps,
Plugins,
Logout,
Quit,
Exit,
@@ -110,6 +111,7 @@ impl SlashCommand {
SlashCommand::Experimental => "toggle experimental features",
SlashCommand::Mcp => "list configured MCP tools",
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",
@@ -168,6 +170,7 @@ impl SlashCommand {
| SlashCommand::Stop
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Feedback
| SlashCommand::Quit
| SlashCommand::Exit => true,
+78
View File
@@ -53,6 +53,10 @@ 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::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
@@ -1826,6 +1830,33 @@ impl App {
});
}
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 });
});
}
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 });
});
}
/// Process the completed MCP inventory fetch: clear the loading spinner, then
/// render either the full tool/resource listing or an error into chat history.
///
@@ -3648,6 +3679,24 @@ impl App {
AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
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::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::FetchMcpInventory => {
self.fetch_mcp_inventory(app_server);
}
@@ -5194,6 +5243,35 @@ async fn fetch_all_mcp_server_statuses(
Ok(statuses)
}
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()));
request_handle
.request_typed(ClientRequest::PluginList {
request_id,
params: PluginListParams {
cwds: Some(vec![cwd]),
force_remote_sync: false,
},
})
.await
.wrap_err("plugin/list failed in app-server TUI")
}
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 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
+31
View File
@@ -11,6 +11,9 @@
use std::path::PathBuf;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
@@ -164,6 +167,34 @@ 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>,
},
/// Fetch MCP inventory via app-server RPCs and render it into history.
FetchMcpInventory,
+15
View File
@@ -329,6 +329,8 @@ mod skills;
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 realtime;
use self::realtime::RealtimeConversationUiState;
use self::realtime::RenderedUserMessageEvent;
@@ -549,6 +551,12 @@ enum ConnectorsCacheState {
Failed(String),
}
#[derive(Debug, Clone, Default)]
struct PluginListFetchState {
cache_cwd: Option<PathBuf>,
in_flight_cwd: Option<PathBuf>,
}
#[derive(Debug)]
enum RateLimitErrorKind {
ServerOverloaded,
@@ -753,6 +761,8 @@ 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,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
@@ -4211,6 +4221,8 @@ 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(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -4815,6 +4827,9 @@ 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(
@@ -0,0 +1,550 @@
use std::path::PathBuf;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::history_cell;
use crate::render::renderable::ColumnRenderable;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginSummary;
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_features::Feature;
use ratatui::style::Stylize;
use ratatui::text::Line;
const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection";
const SUPPORTED_MARKETPLACE_NAME: &str = OPENAI_CURATED_MARKETPLACE_NAME;
#[derive(Debug, Clone, Default)]
pub(super) enum PluginsCacheState {
#[default]
Uninitialized,
Loading,
Ready(PluginListResponse),
Failed(String),
}
impl ChatWidget {
pub(crate) fn add_plugins_output(&mut self) {
if !self.config.features.enabled(Feature::Plugins) {
self.add_info_message(
"Plugins are disabled.".to_string(),
Some("Enable the plugins feature to use /plugins.".to_string()),
);
return;
}
self.prefetch_plugins();
match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(response) => {
self.open_plugins_popup(&response);
}
PluginsCacheState::Failed(err) => {
self.add_to_history(history_cell::new_error_event(err));
}
PluginsCacheState::Loading | PluginsCacheState::Uninitialized => {
self.open_plugins_loading_popup();
}
}
self.request_redraw();
}
pub(crate) fn on_plugins_loaded(
&mut self,
cwd: PathBuf,
result: Result<PluginListResponse, String>,
) {
if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) {
self.plugins_fetch_state.in_flight_cwd = None;
}
if self.config.cwd != cwd {
return;
}
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);
}
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),
);
}
}
}
fn prefetch_plugins(&mut self) {
let cwd = self.config.cwd.clone();
if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) {
return;
}
self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone());
if self.plugins_fetch_state.cache_cwd.as_ref() != Some(&cwd) {
self.plugins_cache = PluginsCacheState::Loading;
}
self.app_event_tx.send(AppEvent::FetchPluginsList { cwd });
}
fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState {
if self.plugins_fetch_state.cache_cwd.as_ref() == Some(&self.config.cwd) {
self.plugins_cache.clone()
} else {
PluginsCacheState::Uninitialized
}
}
fn open_plugins_loading_popup(&mut self) {
if !self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_loading_popup_params(),
) {
self.bottom_pane
.show_selection_view(self.plugins_loading_popup_params());
}
}
fn open_plugins_popup(&mut self, response: &PluginListResponse) {
self.bottom_pane
.show_selection_view(self.plugins_popup_params(response));
}
pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) {
let params = self.plugin_detail_loading_popup_params(plugin_display_name);
let _ = self
.bottom_pane
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
}
pub(crate) fn on_plugin_detail_loaded(
&mut self,
cwd: PathBuf,
result: Result<PluginReadResponse, String>,
) {
if self.config.cwd != cwd {
return;
}
let plugins_response = match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(response) => Some(response),
_ => None,
};
match result {
Ok(response) => {
if let Some(plugins_response) = plugins_response {
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugin_detail_popup_params(&plugins_response, &response.plugin),
);
}
}
Err(err) => {
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
);
}
}
}
fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) {
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_popup_params(response),
);
}
fn plugins_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Loading available plugins...".dim()));
header.push(Line::from(
"This first pass shows the ChatGPT marketplace only.".dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Loading plugins...".to_string(),
description: Some("This updates when the marketplace list is ready.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Loading details for {plugin_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Loading plugin details...".to_string(),
description: Some(
"This updates when the plugin detail request finishes.".to_string(),
),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to load plugins.".dim()));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Plugin marketplace unavailable".to_string(),
description: Some(err.to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn plugin_detail_error_popup_params(
&self,
err: &str,
plugins_response: Option<&PluginListResponse>,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Failed to load plugin details.".dim()));
let mut items = vec![SelectionItem {
name: "Plugin detail unavailable".to_string(),
description: Some(err.to_string()),
is_disabled: true,
..Default::default()
}];
if let Some(plugins_response) = plugins_response.cloned() {
let cwd = self.config.cwd.clone();
items.push(SelectionItem {
name: "Back to plugins".to_string(),
description: Some("Return to the plugin list.".to_string()),
selected_description: Some("Return to the plugin list.".to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PluginsLoaded {
cwd: cwd.clone(),
result: Ok(plugins_response.clone()),
});
})],
..Default::default()
});
}
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugins_popup_hint_line()),
items,
..Default::default()
}
}
fn plugins_popup_params(&self, response: &PluginListResponse) -> SelectionViewParams {
let marketplaces: Vec<&PluginMarketplaceEntry> = response
.marketplaces
.iter()
.filter(|marketplace| marketplace.name == SUPPORTED_MARKETPLACE_NAME)
.collect();
let total: usize = marketplaces
.iter()
.map(|marketplace| marketplace.plugins.len())
.sum();
let installed = marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.filter(|plugin| plugin.installed)
.count();
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
"Browse plugins from the ChatGPT marketplace.".dim(),
));
header.push(Line::from(
format!("Installed {installed} of {total} available plugins.").dim(),
));
if let Some(remote_sync_error) = response.remote_sync_error.as_deref() {
header.push(Line::from(
format!("Using cached marketplace data: {remote_sync_error}").dim(),
));
}
let mut items: Vec<SelectionItem> = Vec::new();
for marketplace in marketplaces {
let marketplace_label = marketplace_display_name(marketplace);
for plugin in &marketplace.plugins {
let display_name = plugin_display_name(plugin);
let status_label = plugin_status_label(plugin);
let description = plugin_brief_description(plugin, &marketplace_label);
let selected_description =
format!("{status_label}. Press Enter to view plugin details.");
let search_value = format!(
"{display_name} {} {} {}",
plugin.id, plugin.name, marketplace_label
);
let cwd = self.config.cwd.clone();
let plugin_display_name = display_name.clone();
let marketplace_path = marketplace.path.clone();
let plugin_name = plugin.name.clone();
items.push(SelectionItem {
name: format!("{display_name} · {marketplace_label}"),
description: Some(description),
selected_description: Some(selected_description),
search_value: Some(search_value),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenPluginDetailLoading {
plugin_display_name: plugin_display_name.clone(),
});
tx.send(AppEvent::FetchPluginDetail {
cwd: cwd.clone(),
params: codex_app_server_protocol::PluginReadParams {
marketplace_path: marketplace_path.clone(),
plugin_name: plugin_name.clone(),
},
});
})],
..Default::default()
});
}
}
if items.is_empty() {
items.push(SelectionItem {
name: "No ChatGPT marketplace plugins available".to_string(),
description: Some(
"This first pass only surfaces the ChatGPT plugin marketplace.".to_string(),
),
is_disabled: true,
..Default::default()
});
}
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugins_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search plugins".to_string()),
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
}
}
fn plugin_detail_popup_params(
&self,
plugins_response: &PluginListResponse,
plugin: &PluginDetail,
) -> SelectionViewParams {
let marketplace_label = plugin.marketplace_name.clone();
let display_name = plugin_display_name(&plugin.summary);
let status_label = plugin_status_label(&plugin.summary);
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("{display_name} · {marketplace_label}").bold(),
));
header.push(Line::from(status_label.dim()));
if let Some(description) = plugin_detail_description(plugin) {
header.push(Line::from(description.dim()));
}
let cwd = self.config.cwd.clone();
let plugins_response = plugins_response.clone();
let mut items = vec![SelectionItem {
name: "Back to plugins".to_string(),
description: Some("Return to the plugin list.".to_string()),
selected_description: Some("Return to the plugin list.".to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PluginsLoaded {
cwd: cwd.clone(),
result: Ok(plugins_response.clone()),
});
})],
..Default::default()
}];
items.push(SelectionItem {
name: "Skills".to_string(),
description: Some(plugin_skill_summary(plugin)),
is_disabled: true,
..Default::default()
});
items.push(SelectionItem {
name: "Apps".to_string(),
description: Some(plugin_app_summary(plugin)),
is_disabled: true,
..Default::default()
});
items.push(SelectionItem {
name: "MCP Servers".to_string(),
description: Some(plugin_mcp_summary(plugin)),
is_disabled: true,
..Default::default()
});
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(plugins_popup_hint_line()),
items,
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
}
}
}
fn plugins_popup_hint_line() -> Line<'static> {
Line::from("Press esc to close.")
}
fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String {
marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.map(str::trim)
.filter(|display_name| !display_name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| marketplace.name.clone())
}
fn plugin_display_name(plugin: &PluginSummary) -> String {
plugin
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.map(str::trim)
.filter(|display_name| !display_name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| plugin.name.clone())
}
fn plugin_brief_description(plugin: &PluginSummary, marketplace_label: &str) -> String {
let status_label = plugin_status_label(plugin);
match plugin_description(plugin) {
Some(description) => format!("{status_label} · {marketplace_label} · {description}"),
None => format!("{status_label} · {marketplace_label}"),
}
}
fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
if plugin.installed {
if plugin.enabled {
"Installed"
} else {
"Installed · Disabled"
}
} else {
match plugin.install_policy {
PluginInstallPolicy::NotAvailable => "Not installable",
PluginInstallPolicy::Available => "Can be installed",
PluginInstallPolicy::InstalledByDefault => "Available by default",
}
}
}
fn plugin_description(plugin: &PluginSummary) -> Option<String> {
plugin
.interface
.as_ref()
.and_then(|interface| {
interface
.short_description
.as_deref()
.or(interface.long_description.as_deref())
})
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn plugin_detail_description(plugin: &PluginDetail) -> Option<String> {
plugin
.description
.as_deref()
.or_else(|| {
plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.long_description.as_deref())
})
.or_else(|| {
plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
})
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn plugin_skill_summary(plugin: &PluginDetail) -> String {
if plugin.skills.is_empty() {
"No plugin skills.".to_string()
} else {
plugin
.skills
.iter()
.map(|skill| skill.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
fn plugin_app_summary(plugin: &PluginDetail) -> String {
if plugin.apps.is_empty() {
"No plugin apps.".to_string()
} else {
plugin
.apps
.iter()
.map(|app| app.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
fn plugin_mcp_summary(plugin: &PluginDetail) -> String {
if plugin.mcp_servers.is_empty() {
"No plugin MCP servers.".to_string()
} else {
plugin.mcp_servers.join(", ")
}
}
@@ -1916,6 +1916,8 @@ async fn make_chatwidget_manual(
connectors_partial_snapshot: 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(),
@@ -42,6 +42,7 @@ pub enum SlashCommand {
Theme,
Mcp,
Apps,
Plugins,
Logout,
Quit,
Exit,
@@ -108,6 +109,7 @@ impl SlashCommand {
SlashCommand::Experimental => "toggle experimental features",
SlashCommand::Mcp => "list configured MCP tools",
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",
@@ -166,6 +168,7 @@ impl SlashCommand {
| SlashCommand::Stop
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Feedback
| SlashCommand::Quit
| SlashCommand::Exit => true,