mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why App and skill toggles are user config mutations too. When the TUI is attached to a remote app server, writing those toggles into the local `config.toml` makes the UI report success without updating the server that actually owns the session. This is **[2 of 4]** in a stacked series that moves TUI-owned config mutations onto app-server APIs. ## What changed - Routed app enable/disable persistence through app-server config batch writes. - Routed skill enable/disable persistence through `skills/config/write`. - Avoided refreshing local config from disk after these writes when the TUI is connected to a remote app server. ## Config keys affected - `apps.<app_id>.enabled` - `apps.<app_id>.disabled_reason` - `[[skills.config]]` entries keyed by `path`, with `enabled = false` used for persisted disables ## Suggested manual validation - Connect the TUI to a remote app server, disable an app, reconnect, and confirm the app remains disabled from remote config rather than local disk state. - Re-enable the same app and confirm both `apps.<app_id>.enabled` and `apps.<app_id>.disabled_reason` are cleared remotely. - Disable a skill in the manage-skills UI and confirm a remote `[[skills.config]]` disable entry appears. - Re-enable that skill and confirm the disable entry is removed and the effective enabled state updates without relying on local config reloads. ## Stack 1. [#22913](https://github.com/openai/codex/pull/22913) `[1 of 4]` primary settings writes 2. [#22914](https://github.com/openai/codex/pull/22914) `[2 of 4]` app and skill enablement 3. [#22915](https://github.com/openai/codex/pull/22915) `[3 of 4]` feature and memory toggles 4. [#22916](https://github.com/openai/codex/pull/22916) `[4 of 4]` startup and onboarding bookkeeping
1133 lines
40 KiB
Rust
1133 lines
40 KiB
Rust
//! Background app-server requests launched by the TUI app.
|
|
//!
|
|
//! This module owns fire-and-forget fetch/write helpers for MCP inventory, skills, plugins, rate
|
|
//! limits, add-credit nudges, and feedback uploads. Results are routed back through `AppEvent` so
|
|
//! the main event loop remains single-threaded.
|
|
|
|
use super::plugin_mentions::fetch_plugin_mentions;
|
|
use super::*;
|
|
use crate::app_event::ConnectorsSnapshot;
|
|
use codex_app_server_protocol::AppsListParams;
|
|
use codex_app_server_protocol::AppsListResponse;
|
|
use codex_app_server_protocol::MarketplaceAddParams;
|
|
use codex_app_server_protocol::MarketplaceAddResponse;
|
|
use codex_app_server_protocol::MarketplaceRemoveParams;
|
|
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
|
use codex_app_server_protocol::MarketplaceUpgradeParams;
|
|
use codex_app_server_protocol::MarketplaceUpgradeResponse;
|
|
|
|
use codex_app_server_protocol::RequestId;
|
|
|
|
use crate::hooks_rpc::fetch_hooks_list;
|
|
use crate::hooks_rpc::write_hook_trust;
|
|
use crate::hooks_rpc::write_hook_trusts;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
|
|
impl App {
|
|
pub(super) fn fetch_mcp_inventory(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
detail: McpServerStatusDetail,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let result = fetch_all_mcp_server_statuses(request_handle, detail)
|
|
.await
|
|
.map_err(|err| err.to_string());
|
|
app_event_tx.send(AppEvent::McpInventoryLoaded { result, detail });
|
|
});
|
|
}
|
|
|
|
/// Spawns a background task to fetch account rate limits and deliver the
|
|
/// result as a `RateLimitsLoaded` event.
|
|
///
|
|
/// The `origin` is forwarded to the completion handler so it can distinguish
|
|
/// a startup prefetch (which only updates cached snapshots and schedules a
|
|
/// frame) from a `/status`-triggered refresh (which must finalize the
|
|
/// corresponding status card).
|
|
pub(super) fn refresh_rate_limits(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
origin: RateLimitRefreshOrigin,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let result = fetch_account_rate_limits(request_handle)
|
|
.await
|
|
.map_err(|err| err.to_string());
|
|
app_event_tx.send(AppEvent::RateLimitsLoaded { origin, result });
|
|
});
|
|
}
|
|
|
|
pub(super) fn send_add_credits_nudge_email(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
credit_type: AddCreditsNudgeCreditType,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let result = send_add_credits_nudge_email(request_handle, credit_type)
|
|
.await
|
|
.map_err(|err| err.to_string());
|
|
app_event_tx.send(AppEvent::AddCreditsNudgeEmailFinished { result });
|
|
});
|
|
}
|
|
|
|
/// Starts the initial skills refresh without delaying the first interactive frame.
|
|
///
|
|
/// Startup only needs skill metadata to populate skill mentions and the skills UI; the prompt can be
|
|
/// rendered before that metadata arrives. The result is routed through the normal app event queue so
|
|
/// the same response handler updates the chat widget and emits invalid `SKILL.md` warnings once the
|
|
/// app-server RPC finishes. User-initiated skills refreshes still use the blocking app command path so
|
|
/// callers that explicitly asked for fresh skill state do not race ahead of their own refresh.
|
|
pub(super) fn refresh_startup_skills(&mut self, app_server: &AppServerSession) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
let cwd = self.config.cwd.to_path_buf();
|
|
tokio::spawn(async move {
|
|
let result = fetch_skills_list(request_handle, cwd)
|
|
.await
|
|
.map_err(|err| format!("{err:#}"));
|
|
app_event_tx.send(AppEvent::SkillsListLoaded { result });
|
|
});
|
|
}
|
|
|
|
pub(super) fn fetch_connectors_list(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
force_refetch: bool,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
let thread_id = self
|
|
.current_displayed_thread_id()
|
|
.map(|thread_id| thread_id.to_string());
|
|
tokio::spawn(async move {
|
|
let result = fetch_connectors_list(request_handle, force_refetch, thread_id)
|
|
.await
|
|
.map_err(|err| err.to_string());
|
|
app_event_tx.send(AppEvent::ConnectorsLoaded {
|
|
result,
|
|
is_final: true,
|
|
});
|
|
});
|
|
}
|
|
|
|
pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let result = fetch_plugins_list(request_handle, cwd.clone())
|
|
.await
|
|
.map_err(|err| err.to_string());
|
|
app_event_tx.send(AppEvent::PluginsLoaded { cwd, result });
|
|
});
|
|
}
|
|
|
|
pub(super) fn fetch_hooks_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_hooks_list(request_handle, cwd.clone())
|
|
.await
|
|
.map_err(|err| err.to_string());
|
|
app_event_tx.send(AppEvent::HooksLoaded { cwd, result });
|
|
});
|
|
}
|
|
|
|
pub(super) fn fetch_plugin_detail(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
cwd: PathBuf,
|
|
params: PluginReadParams,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let result = fetch_plugin_detail(request_handle, params)
|
|
.await
|
|
.map_err(|err| err.to_string());
|
|
app_event_tx.send(AppEvent::PluginDetailLoaded { cwd, result });
|
|
});
|
|
}
|
|
|
|
pub(super) fn fetch_marketplace_add(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
cwd: PathBuf,
|
|
source: String,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let cwd_for_event = cwd.clone();
|
|
let source_for_event = source.clone();
|
|
let result = fetch_marketplace_add(request_handle, cwd, source)
|
|
.await
|
|
.map_err(|err| format!("Failed to add marketplace: {err}"));
|
|
app_event_tx.send(AppEvent::MarketplaceAddLoaded {
|
|
cwd: cwd_for_event,
|
|
source: source_for_event,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
|
|
pub(super) fn fetch_marketplace_remove(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
cwd: PathBuf,
|
|
marketplace_name: String,
|
|
marketplace_display_name: String,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let cwd_for_event = cwd.clone();
|
|
let marketplace_name_for_event = marketplace_name.clone();
|
|
let result = fetch_marketplace_remove(request_handle, marketplace_name)
|
|
.await
|
|
.map_err(|err| format!("Failed to remove marketplace: {err}"));
|
|
app_event_tx.send(AppEvent::MarketplaceRemoveLoaded {
|
|
cwd: cwd_for_event,
|
|
marketplace_name: marketplace_name_for_event,
|
|
marketplace_display_name,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
|
|
pub(super) fn fetch_marketplace_upgrade(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
cwd: PathBuf,
|
|
marketplace_name: Option<String>,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let cwd_for_event = cwd.clone();
|
|
let result = fetch_marketplace_upgrade(request_handle, marketplace_name)
|
|
.await
|
|
.map_err(|err| format!("Failed to upgrade marketplace: {err}"));
|
|
app_event_tx.send(AppEvent::MarketplaceUpgradeLoaded {
|
|
cwd: cwd_for_event,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
|
|
pub(super) fn fetch_plugin_install(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
cwd: PathBuf,
|
|
marketplace_path: AbsolutePathBuf,
|
|
plugin_name: String,
|
|
plugin_display_name: String,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let cwd_for_event = cwd.clone();
|
|
let marketplace_path_for_event = marketplace_path.clone();
|
|
let plugin_name_for_event = plugin_name.clone();
|
|
let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name)
|
|
.await
|
|
.map_err(|err| format!("Failed to install plugin: {err}"));
|
|
app_event_tx.send(AppEvent::PluginInstallLoaded {
|
|
cwd: cwd_for_event,
|
|
marketplace_path: marketplace_path_for_event,
|
|
plugin_name: plugin_name_for_event,
|
|
plugin_display_name,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
|
|
pub(super) fn fetch_plugin_uninstall(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
cwd: PathBuf,
|
|
plugin_id: String,
|
|
plugin_display_name: String,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let cwd_for_event = cwd.clone();
|
|
let plugin_id_for_event = plugin_id.clone();
|
|
let result = fetch_plugin_uninstall(request_handle, plugin_id)
|
|
.await
|
|
.map_err(|err| format!("Failed to uninstall plugin: {err}"));
|
|
app_event_tx.send(AppEvent::PluginUninstallLoaded {
|
|
cwd: cwd_for_event,
|
|
plugin_id: plugin_id_for_event,
|
|
plugin_display_name,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
|
|
pub(super) fn set_plugin_enabled(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
cwd: PathBuf,
|
|
plugin_id: String,
|
|
enabled: bool,
|
|
) {
|
|
if let Some(queued_enabled) = self.pending_plugin_enabled_writes.get_mut(&plugin_id) {
|
|
*queued_enabled = Some(enabled);
|
|
return;
|
|
}
|
|
|
|
self.pending_plugin_enabled_writes
|
|
.insert(plugin_id.clone(), None);
|
|
self.spawn_plugin_enabled_write(app_server, cwd, plugin_id, enabled);
|
|
}
|
|
|
|
pub(super) fn spawn_plugin_enabled_write(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
cwd: PathBuf,
|
|
plugin_id: String,
|
|
enabled: bool,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let cwd_for_event = cwd.clone();
|
|
let plugin_id_for_event = plugin_id.clone();
|
|
let result = write_plugin_enabled(request_handle, plugin_id, enabled)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|err| format!("Failed to update plugin config: {err}"));
|
|
app_event_tx.send(AppEvent::PluginEnabledSet {
|
|
cwd: cwd_for_event,
|
|
plugin_id: plugin_id_for_event,
|
|
enabled,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
|
|
pub(super) fn set_hook_enabled(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
key: String,
|
|
enabled: bool,
|
|
) {
|
|
if let Some(queued_enabled) = self.pending_hook_enabled_writes.get_mut(&key) {
|
|
*queued_enabled = Some(enabled);
|
|
return;
|
|
}
|
|
|
|
self.pending_hook_enabled_writes.insert(key.clone(), None);
|
|
self.spawn_hook_enabled_write(app_server, key, enabled);
|
|
}
|
|
|
|
pub(super) fn spawn_hook_enabled_write(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
key: String,
|
|
enabled: bool,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let key_for_event = key.clone();
|
|
let result = write_hook_enabled(request_handle, key, enabled)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|err| format!("Failed to update hook config: {err}"));
|
|
app_event_tx.send(AppEvent::HookEnabledSet {
|
|
key: key_for_event,
|
|
enabled,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
|
|
pub(super) fn trust_hook(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
key: String,
|
|
current_hash: String,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let result = write_hook_trust(request_handle, key, current_hash)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|err| format!("Failed to trust hook: {err}"));
|
|
app_event_tx.send(AppEvent::HookTrusted { result });
|
|
});
|
|
}
|
|
|
|
pub(super) fn trust_hooks(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
updates: Vec<HookTrustUpdate>,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let result = write_hook_trusts(request_handle, updates)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|err| format!("Failed to trust hooks: {err}"));
|
|
app_event_tx.send(AppEvent::HookTrusted { result });
|
|
});
|
|
}
|
|
|
|
pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) {
|
|
let config = self.config.clone();
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
if !config.features.enabled(Feature::Plugins) {
|
|
app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None });
|
|
return;
|
|
}
|
|
|
|
tokio::spawn(async move {
|
|
match fetch_plugin_mentions(request_handle, config).await {
|
|
Ok(plugins) => {
|
|
app_event_tx.send(AppEvent::PluginMentionsLoaded {
|
|
plugins: Some(plugins),
|
|
});
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(error = %err, "plugin/list failed while refreshing plugin mention candidates");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
pub(super) fn submit_feedback(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
category: FeedbackCategory,
|
|
reason: Option<String>,
|
|
turn_id: Option<String>,
|
|
include_logs: bool,
|
|
) {
|
|
let request_handle = app_server.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
let origin_thread_id = self.chat_widget.thread_id();
|
|
let rollout_path = if include_logs {
|
|
self.chat_widget.rollout_path()
|
|
} else {
|
|
None
|
|
};
|
|
let params = build_feedback_upload_params(
|
|
origin_thread_id,
|
|
rollout_path,
|
|
category,
|
|
reason,
|
|
turn_id,
|
|
include_logs,
|
|
);
|
|
tokio::spawn(async move {
|
|
let result = fetch_feedback_upload(request_handle, params)
|
|
.await
|
|
.map(|response| response.thread_id)
|
|
.map_err(|err| err.to_string());
|
|
app_event_tx.send(AppEvent::FeedbackSubmitted {
|
|
origin_thread_id,
|
|
category,
|
|
include_logs,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
|
|
pub(super) fn handle_feedback_thread_event(&mut self, event: FeedbackThreadEvent) {
|
|
match event.result {
|
|
Ok(thread_id) => {
|
|
self.chat_widget
|
|
.add_to_history(crate::bottom_pane::feedback_success_cell(
|
|
event.category,
|
|
event.include_logs,
|
|
&thread_id,
|
|
event.feedback_audience,
|
|
))
|
|
}
|
|
Err(err) => self
|
|
.chat_widget
|
|
.add_to_history(history_cell::new_error_event(format!(
|
|
"Failed to upload feedback: {err}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub(super) async fn enqueue_thread_feedback_event(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
event: FeedbackThreadEvent,
|
|
) {
|
|
let (sender, store) = {
|
|
let channel = self.ensure_thread_channel(thread_id);
|
|
(channel.sender.clone(), Arc::clone(&channel.store))
|
|
};
|
|
|
|
let should_send = {
|
|
let mut guard = store.lock().await;
|
|
guard
|
|
.buffer
|
|
.push_back(ThreadBufferedEvent::FeedbackSubmission(event.clone()));
|
|
if guard.buffer.len() > guard.capacity
|
|
&& let Some(removed) = guard.buffer.pop_front()
|
|
&& let ThreadBufferedEvent::Request(request) = &removed
|
|
{
|
|
guard
|
|
.pending_interactive_replay
|
|
.note_evicted_server_request(request);
|
|
}
|
|
guard.active
|
|
};
|
|
|
|
if should_send {
|
|
match sender.try_send(ThreadBufferedEvent::FeedbackSubmission(event)) {
|
|
Ok(()) => {}
|
|
Err(TrySendError::Full(event)) => {
|
|
tokio::spawn(async move {
|
|
if let Err(err) = sender.send(event).await {
|
|
tracing::warn!("thread {thread_id} event channel closed: {err}");
|
|
}
|
|
});
|
|
}
|
|
Err(TrySendError::Closed(_)) => {
|
|
tracing::warn!("thread {thread_id} event channel closed");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn handle_feedback_submitted(
|
|
&mut self,
|
|
origin_thread_id: Option<ThreadId>,
|
|
category: FeedbackCategory,
|
|
include_logs: bool,
|
|
result: Result<String, String>,
|
|
) {
|
|
let event = FeedbackThreadEvent {
|
|
category,
|
|
include_logs,
|
|
feedback_audience: self.feedback_audience,
|
|
result,
|
|
};
|
|
if let Some(thread_id) = origin_thread_id {
|
|
self.enqueue_thread_feedback_event(thread_id, event).await;
|
|
} else {
|
|
self.handle_feedback_thread_event(event);
|
|
}
|
|
}
|
|
|
|
/// Process the completed MCP inventory fetch: clear the loading spinner, then
|
|
/// render either the full tool/resource listing or an error into chat history.
|
|
///
|
|
/// When both the local config and the app-server report zero servers, a special
|
|
/// "empty" cell is shown instead of the full table.
|
|
pub(super) fn handle_mcp_inventory_result(
|
|
&mut self,
|
|
result: Result<Vec<McpServerStatus>, String>,
|
|
detail: McpServerStatusDetail,
|
|
) {
|
|
let config = self.chat_widget.config_ref().clone();
|
|
self.chat_widget.clear_mcp_inventory_loading();
|
|
self.clear_committed_mcp_inventory_loading();
|
|
|
|
let statuses = match result {
|
|
Ok(statuses) => statuses,
|
|
Err(err) => {
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to load MCP inventory: {err}"));
|
|
return;
|
|
}
|
|
};
|
|
|
|
if config.mcp_servers.get().is_empty() && statuses.is_empty() {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::empty_mcp_output());
|
|
return;
|
|
}
|
|
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_mcp_tools_output_from_statuses(
|
|
&config, &statuses, detail,
|
|
));
|
|
}
|
|
|
|
pub(super) fn clear_committed_mcp_inventory_loading(&mut self) {
|
|
let Some(index) = self
|
|
.transcript_cells
|
|
.iter()
|
|
.rposition(|cell| cell.as_any().is::<history_cell::McpInventoryLoadingCell>())
|
|
else {
|
|
return;
|
|
};
|
|
|
|
self.transcript_cells.remove(index);
|
|
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
|
overlay.replace_cells(self.transcript_cells.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn fetch_all_mcp_server_statuses(
|
|
request_handle: AppServerRequestHandle,
|
|
detail: McpServerStatusDetail,
|
|
) -> Result<Vec<McpServerStatus>> {
|
|
let mut cursor = None;
|
|
let mut statuses = Vec::new();
|
|
|
|
loop {
|
|
let request_id = RequestId::String(format!("mcp-inventory-{}", Uuid::new_v4()));
|
|
let response: ListMcpServerStatusResponse = request_handle
|
|
.request_typed(ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
cursor: cursor.clone(),
|
|
limit: Some(100),
|
|
detail: Some(detail),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("mcpServerStatus/list failed in TUI")?;
|
|
statuses.extend(response.data);
|
|
if let Some(next_cursor) = response.next_cursor {
|
|
cursor = Some(next_cursor);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(statuses)
|
|
}
|
|
|
|
pub(super) async fn fetch_account_rate_limits(
|
|
request_handle: AppServerRequestHandle,
|
|
) -> Result<Vec<RateLimitSnapshot>> {
|
|
let request_id = RequestId::String(format!("account-rate-limits-{}", Uuid::new_v4()));
|
|
let response: GetAccountRateLimitsResponse = request_handle
|
|
.request_typed(ClientRequest::GetAccountRateLimits {
|
|
request_id,
|
|
params: None,
|
|
})
|
|
.await
|
|
.wrap_err("account/rateLimits/read failed in TUI")?;
|
|
|
|
Ok(app_server_rate_limit_snapshots(response))
|
|
}
|
|
|
|
pub(super) async fn send_add_credits_nudge_email(
|
|
request_handle: AppServerRequestHandle,
|
|
credit_type: AddCreditsNudgeCreditType,
|
|
) -> Result<codex_app_server_protocol::AddCreditsNudgeEmailStatus> {
|
|
let request_id = RequestId::String(format!("add-credits-nudge-{}", Uuid::new_v4()));
|
|
let response: codex_app_server_protocol::SendAddCreditsNudgeEmailResponse = request_handle
|
|
.request_typed(ClientRequest::SendAddCreditsNudgeEmail {
|
|
request_id,
|
|
params: SendAddCreditsNudgeEmailParams { credit_type },
|
|
})
|
|
.await
|
|
.wrap_err("account/sendAddCreditsNudgeEmail failed in TUI")?;
|
|
|
|
Ok(response.status)
|
|
}
|
|
|
|
pub(super) async fn fetch_skills_list(
|
|
request_handle: AppServerRequestHandle,
|
|
cwd: PathBuf,
|
|
) -> Result<SkillsListResponse> {
|
|
let request_id = RequestId::String(format!("startup-skills-list-{}", Uuid::new_v4()));
|
|
// Use the cloneable request handle so startup can issue this RPC from a background task without
|
|
// extending a borrow of `AppServerSession` across the first frame render.
|
|
request_handle
|
|
.request_typed(ClientRequest::SkillsList {
|
|
request_id,
|
|
params: SkillsListParams {
|
|
cwds: vec![cwd],
|
|
force_reload: true,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("skills/list failed in TUI")
|
|
}
|
|
|
|
pub(super) async fn fetch_connectors_list(
|
|
request_handle: AppServerRequestHandle,
|
|
force_refetch: bool,
|
|
thread_id: Option<String>,
|
|
) -> Result<ConnectorsSnapshot> {
|
|
let request_id = RequestId::String(format!("apps-list-{}", Uuid::new_v4()));
|
|
let response: AppsListResponse = request_handle
|
|
.request_typed(ClientRequest::AppsList {
|
|
request_id,
|
|
params: AppsListParams {
|
|
cursor: None,
|
|
limit: None,
|
|
thread_id,
|
|
force_refetch,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("app/list failed in TUI")?;
|
|
Ok(ConnectorsSnapshot {
|
|
connectors: response.data,
|
|
})
|
|
}
|
|
|
|
pub(super) async fn fetch_plugins_list(
|
|
request_handle: AppServerRequestHandle,
|
|
cwd: PathBuf,
|
|
) -> Result<PluginListResponse> {
|
|
let mut response = request_plugin_list(request_handle, cwd)
|
|
.await
|
|
.wrap_err("plugin/list failed while loading the plugins menu")?;
|
|
hide_cli_only_plugin_marketplaces(&mut response);
|
|
Ok(response)
|
|
}
|
|
|
|
const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"];
|
|
|
|
pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) {
|
|
response
|
|
.marketplaces
|
|
.retain(|marketplace| !CLI_HIDDEN_PLUGIN_MARKETPLACES.contains(&marketplace.name.as_str()));
|
|
}
|
|
|
|
pub(super) async fn request_plugin_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]),
|
|
marketplace_kinds: None,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("plugin/list failed in TUI")
|
|
}
|
|
|
|
pub(super) async fn fetch_plugin_detail(
|
|
request_handle: AppServerRequestHandle,
|
|
params: PluginReadParams,
|
|
) -> Result<PluginReadResponse> {
|
|
let request_id = RequestId::String(format!("plugin-read-{}", Uuid::new_v4()));
|
|
request_handle
|
|
.request_typed(ClientRequest::PluginRead { request_id, params })
|
|
.await
|
|
.wrap_err("plugin/read failed in TUI")
|
|
}
|
|
|
|
pub(super) async fn fetch_marketplace_add(
|
|
request_handle: AppServerRequestHandle,
|
|
cwd: PathBuf,
|
|
source: String,
|
|
) -> Result<MarketplaceAddResponse> {
|
|
let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("marketplace/add cwd must be absolute")?;
|
|
let source = marketplace_add_source_for_request(cwd.as_path(), source);
|
|
let request_id = RequestId::String(format!("marketplace-add-{}", Uuid::new_v4()));
|
|
request_handle
|
|
.request_typed(ClientRequest::MarketplaceAdd {
|
|
request_id,
|
|
params: MarketplaceAddParams {
|
|
source,
|
|
ref_name: None,
|
|
sparse_paths: None,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("marketplace/add failed in TUI")
|
|
}
|
|
|
|
fn marketplace_add_source_for_request(cwd: &std::path::Path, source: String) -> String {
|
|
let (base_source, suffix) = if let Some((base, ref_name)) = source.rsplit_once('#') {
|
|
(base, Some(format!("#{ref_name}")))
|
|
} else if let Some((base, ref_name)) = source.rsplit_once('@') {
|
|
(base, Some(format!("@{ref_name}")))
|
|
} else {
|
|
(source.as_str(), None)
|
|
};
|
|
|
|
if matches!(base_source, "." | "..")
|
|
|| base_source.starts_with("./")
|
|
|| base_source.starts_with("../")
|
|
|| base_source.starts_with(".\\")
|
|
|| base_source.starts_with("..\\")
|
|
{
|
|
let mut resolved = AbsolutePathBuf::resolve_path_against_base(base_source, cwd)
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
if let Some(suffix) = suffix {
|
|
resolved.push_str(&suffix);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
source
|
|
}
|
|
|
|
pub(super) async fn fetch_marketplace_remove(
|
|
request_handle: AppServerRequestHandle,
|
|
marketplace_name: String,
|
|
) -> Result<MarketplaceRemoveResponse> {
|
|
let request_id = RequestId::String(format!("marketplace-remove-{}", Uuid::new_v4()));
|
|
request_handle
|
|
.request_typed(ClientRequest::MarketplaceRemove {
|
|
request_id,
|
|
params: MarketplaceRemoveParams { marketplace_name },
|
|
})
|
|
.await
|
|
.wrap_err("marketplace/remove failed in TUI")
|
|
}
|
|
|
|
pub(super) async fn fetch_marketplace_upgrade(
|
|
request_handle: AppServerRequestHandle,
|
|
marketplace_name: Option<String>,
|
|
) -> Result<MarketplaceUpgradeResponse> {
|
|
let request_id = RequestId::String(format!("marketplace-upgrade-{}", Uuid::new_v4()));
|
|
request_handle
|
|
.request_typed(ClientRequest::MarketplaceUpgrade {
|
|
request_id,
|
|
params: MarketplaceUpgradeParams { marketplace_name },
|
|
})
|
|
.await
|
|
.wrap_err("marketplace/upgrade failed in TUI")
|
|
}
|
|
pub(super) async fn fetch_plugin_install(
|
|
request_handle: AppServerRequestHandle,
|
|
marketplace_path: AbsolutePathBuf,
|
|
plugin_name: String,
|
|
) -> Result<PluginInstallResponse> {
|
|
let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4()));
|
|
request_handle
|
|
.request_typed(ClientRequest::PluginInstall {
|
|
request_id,
|
|
params: PluginInstallParams {
|
|
marketplace_path: Some(marketplace_path),
|
|
remote_marketplace_name: None,
|
|
plugin_name,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("plugin/install failed in TUI")
|
|
}
|
|
|
|
pub(super) async fn fetch_plugin_uninstall(
|
|
request_handle: AppServerRequestHandle,
|
|
plugin_id: String,
|
|
) -> Result<PluginUninstallResponse> {
|
|
let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4()));
|
|
request_handle
|
|
.request_typed(ClientRequest::PluginUninstall {
|
|
request_id,
|
|
params: PluginUninstallParams { plugin_id },
|
|
})
|
|
.await
|
|
.wrap_err("plugin/uninstall failed in TUI")
|
|
}
|
|
|
|
pub(super) async fn write_plugin_enabled(
|
|
request_handle: AppServerRequestHandle,
|
|
plugin_id: String,
|
|
enabled: bool,
|
|
) -> Result<ConfigWriteResponse> {
|
|
let request_id = RequestId::String(format!("plugin-enable-{}", Uuid::new_v4()));
|
|
request_handle
|
|
.request_typed(ClientRequest::ConfigValueWrite {
|
|
request_id,
|
|
params: ConfigValueWriteParams {
|
|
key_path: format!("plugins.{plugin_id}"),
|
|
value: serde_json::json!({ "enabled": enabled }),
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
file_path: None,
|
|
expected_version: None,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("config/value/write failed while updating plugin enablement in TUI")
|
|
}
|
|
|
|
pub(super) async fn write_hook_enabled(
|
|
request_handle: AppServerRequestHandle,
|
|
key: String,
|
|
enabled: bool,
|
|
) -> Result<ConfigWriteResponse> {
|
|
let request_id = RequestId::String(format!("hooks-config-write-{}", Uuid::new_v4()));
|
|
request_handle
|
|
.request_typed(ClientRequest::ConfigBatchWrite {
|
|
request_id,
|
|
params: ConfigBatchWriteParams {
|
|
edits: vec![codex_app_server_protocol::ConfigEdit {
|
|
key_path: "hooks.state".to_string(),
|
|
value: serde_json::json!({
|
|
key: {
|
|
"enabled": enabled,
|
|
}
|
|
}),
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
}],
|
|
file_path: None,
|
|
expected_version: None,
|
|
reload_user_config: true,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("config/batchWrite failed while updating hook enablement in TUI")
|
|
}
|
|
|
|
pub(super) fn build_feedback_upload_params(
|
|
origin_thread_id: Option<ThreadId>,
|
|
rollout_path: Option<PathBuf>,
|
|
category: FeedbackCategory,
|
|
reason: Option<String>,
|
|
turn_id: Option<String>,
|
|
include_logs: bool,
|
|
) -> FeedbackUploadParams {
|
|
let extra_log_files = if include_logs {
|
|
rollout_path.map(|rollout_path| vec![rollout_path])
|
|
} else {
|
|
None
|
|
};
|
|
let tags = turn_id.map(|turn_id| BTreeMap::from([(String::from("turn_id"), turn_id)]));
|
|
FeedbackUploadParams {
|
|
classification: crate::bottom_pane::feedback_classification(category).to_string(),
|
|
reason,
|
|
thread_id: origin_thread_id.map(|thread_id| thread_id.to_string()),
|
|
include_logs,
|
|
extra_log_files,
|
|
tags,
|
|
}
|
|
}
|
|
|
|
pub(super) async fn fetch_feedback_upload(
|
|
request_handle: AppServerRequestHandle,
|
|
params: FeedbackUploadParams,
|
|
) -> Result<FeedbackUploadResponse> {
|
|
let request_id = RequestId::String(format!("feedback-upload-{}", Uuid::new_v4()));
|
|
request_handle
|
|
.request_typed(ClientRequest::FeedbackUpload { request_id, params })
|
|
.await
|
|
.wrap_err("feedback/upload failed in 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 TUI
|
|
/// renders directly from `McpServerStatus` rather than these maps.
|
|
#[cfg(test)]
|
|
pub(super) type McpInventoryMaps = (
|
|
HashMap<String, codex_protocol::mcp::Tool>,
|
|
HashMap<String, Vec<codex_protocol::mcp::Resource>>,
|
|
HashMap<String, Vec<codex_protocol::mcp::ResourceTemplate>>,
|
|
HashMap<String, McpAuthStatus>,
|
|
);
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -> McpInventoryMaps {
|
|
let mut tools = HashMap::new();
|
|
let mut resources = HashMap::new();
|
|
let mut resource_templates = HashMap::new();
|
|
let mut auth_statuses = HashMap::new();
|
|
|
|
for status in statuses {
|
|
let server_name = status.name;
|
|
auth_statuses.insert(
|
|
server_name.clone(),
|
|
match status.auth_status {
|
|
codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,
|
|
codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,
|
|
codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,
|
|
codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth,
|
|
},
|
|
);
|
|
resources.insert(server_name.clone(), status.resources);
|
|
resource_templates.insert(server_name.clone(), status.resource_templates);
|
|
for (tool_name, tool) in status.tools {
|
|
tools.insert(format!("mcp__{server_name}__{tool_name}"), tool);
|
|
}
|
|
}
|
|
|
|
(tools, resources, resource_templates, auth_statuses)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use codex_app_server_protocol::PluginMarketplaceEntry;
|
|
use codex_protocol::mcp::Tool;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn test_absolute_path(path: &str) -> AbsolutePathBuf {
|
|
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
|
|
}
|
|
|
|
#[test]
|
|
fn marketplace_add_source_for_request_resolves_relative_local_paths() {
|
|
let cwd = if cfg!(windows) {
|
|
PathBuf::from(r"C:\workspace\project")
|
|
} else {
|
|
PathBuf::from("/workspace/project")
|
|
};
|
|
|
|
let resolved = marketplace_add_source_for_request(&cwd, "./marketplace".to_string());
|
|
assert!(std::path::Path::new(&resolved).is_absolute());
|
|
assert_eq!(resolved, cwd.join("marketplace").display().to_string());
|
|
assert_eq!(
|
|
marketplace_add_source_for_request(&cwd, "./marketplace#main".to_string()),
|
|
format!("{}#main", cwd.join("marketplace").display())
|
|
);
|
|
assert_eq!(
|
|
marketplace_add_source_for_request(&cwd, "owner/repo".to_string()),
|
|
"owner/repo"
|
|
);
|
|
assert_eq!(
|
|
marketplace_add_source_for_request(&cwd, "~/marketplace".to_string()),
|
|
"~/marketplace"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hide_cli_only_plugin_marketplaces_removes_openai_bundled() {
|
|
let mut response = PluginListResponse {
|
|
marketplaces: vec![
|
|
PluginMarketplaceEntry {
|
|
name: "openai-bundled".to_string(),
|
|
path: Some(test_absolute_path("/marketplaces/openai-bundled")),
|
|
interface: None,
|
|
plugins: Vec::new(),
|
|
},
|
|
PluginMarketplaceEntry {
|
|
name: "openai-curated".to_string(),
|
|
path: Some(test_absolute_path("/marketplaces/openai-curated")),
|
|
interface: None,
|
|
plugins: Vec::new(),
|
|
},
|
|
],
|
|
marketplace_load_errors: Vec::new(),
|
|
featured_plugin_ids: Vec::new(),
|
|
};
|
|
|
|
hide_cli_only_plugin_marketplaces(&mut response);
|
|
|
|
assert_eq!(
|
|
response.marketplaces,
|
|
vec![PluginMarketplaceEntry {
|
|
name: "openai-curated".to_string(),
|
|
path: Some(test_absolute_path("/marketplaces/openai-curated")),
|
|
interface: None,
|
|
plugins: Vec::new(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_inventory_maps_prefix_tool_names_by_server() {
|
|
let statuses = vec![
|
|
McpServerStatus {
|
|
name: "docs".to_string(),
|
|
tools: HashMap::from([(
|
|
"list".to_string(),
|
|
Tool {
|
|
description: None,
|
|
name: "list".to_string(),
|
|
title: None,
|
|
input_schema: serde_json::json!({"type": "object"}),
|
|
output_schema: None,
|
|
annotations: None,
|
|
icons: None,
|
|
meta: None,
|
|
},
|
|
)]),
|
|
resources: Vec::new(),
|
|
resource_templates: Vec::new(),
|
|
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
|
|
},
|
|
McpServerStatus {
|
|
name: "disabled".to_string(),
|
|
tools: HashMap::new(),
|
|
resources: Vec::new(),
|
|
resource_templates: Vec::new(),
|
|
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
|
|
},
|
|
];
|
|
|
|
let (tools, resources, resource_templates, auth_statuses) =
|
|
mcp_inventory_maps_from_statuses(statuses);
|
|
let mut resource_names = resources.keys().cloned().collect::<Vec<_>>();
|
|
resource_names.sort();
|
|
let mut template_names = resource_templates.keys().cloned().collect::<Vec<_>>();
|
|
template_names.sort();
|
|
|
|
assert_eq!(
|
|
tools.keys().cloned().collect::<Vec<_>>(),
|
|
vec!["mcp__docs__list".to_string()]
|
|
);
|
|
assert_eq!(resource_names, vec!["disabled", "docs"]);
|
|
assert_eq!(template_names, vec!["disabled", "docs"]);
|
|
assert_eq!(
|
|
auth_statuses.get("disabled"),
|
|
Some(&McpAuthStatus::Unsupported)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_feedback_upload_params_includes_thread_id_and_rollout_path() {
|
|
let thread_id = ThreadId::new();
|
|
let rollout_path = PathBuf::from("/tmp/rollout.jsonl");
|
|
|
|
let params = build_feedback_upload_params(
|
|
Some(thread_id),
|
|
Some(rollout_path.clone()),
|
|
FeedbackCategory::SafetyCheck,
|
|
Some("needs follow-up".to_string()),
|
|
Some("turn-123".to_string()),
|
|
/*include_logs*/ true,
|
|
);
|
|
|
|
assert_eq!(params.classification, "safety_check");
|
|
assert_eq!(params.reason, Some("needs follow-up".to_string()));
|
|
assert_eq!(params.thread_id, Some(thread_id.to_string()));
|
|
assert_eq!(
|
|
params
|
|
.tags
|
|
.as_ref()
|
|
.and_then(|tags| tags.get("turn_id"))
|
|
.map(String::as_str),
|
|
Some("turn-123")
|
|
);
|
|
assert_eq!(params.include_logs, true);
|
|
assert_eq!(params.extra_log_files, Some(vec![rollout_path]));
|
|
}
|
|
|
|
#[test]
|
|
fn build_feedback_upload_params_omits_rollout_path_without_logs() {
|
|
let params = build_feedback_upload_params(
|
|
/*origin_thread_id*/ None,
|
|
Some(PathBuf::from("/tmp/rollout.jsonl")),
|
|
FeedbackCategory::GoodResult,
|
|
/*reason*/ None,
|
|
/*turn_id*/ None,
|
|
/*include_logs*/ false,
|
|
);
|
|
|
|
assert_eq!(params.classification, "good_result");
|
|
assert_eq!(params.reason, None);
|
|
assert_eq!(params.thread_id, None);
|
|
assert_eq!(params.tags, None);
|
|
assert_eq!(params.include_logs, false);
|
|
assert_eq!(params.extra_log_files, None);
|
|
}
|
|
}
|