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 authored and GitHub committed 2026-03-19 21:28:33 -07:00
1 parent fa2a2f0be9
commit f7201e5a9f
15 files changed
+1505 -5

No files matched your search

+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,