mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
fa2a2f0be9
commit
f7201e5a9f
Generated
+1
@@ -2566,6 +2566,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-ansi-escape",
|
||||
"codex-app-server-client",
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(", ")
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user