Use app/list for TUI app catalog

This commit is contained in:
canvrno-oai
2026-05-11 16:48:30 -07:00
parent 22e84c49d0
commit 8cb82d3f96
9 changed files with 243 additions and 108 deletions
-1
View File
@@ -3720,7 +3720,6 @@ dependencies = [
"codex-app-server-client",
"codex-app-server-protocol",
"codex-arg0",
"codex-chatgpt",
"codex-cli",
"codex-cloud-requirements",
"codex-config",
@@ -41,15 +41,20 @@ impl AppsRequestProcessor {
request_id: &ConnectionRequestId,
params: AppsListParams,
) -> Result<Option<AppsListResponse>, JSONRPCErrorError> {
let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if let Some(thread_id) = params.thread_id.as_deref() {
let config = if let Some(thread_id) = params.thread_id.as_deref() {
let (_, thread) = self.load_thread(thread_id).await?;
let thread_config = thread.config().await;
let mut config = self
.load_latest_config(Some(thread_config.cwd.to_path_buf()))
.await?;
let _ = config
.features
.set_enabled(Feature::Apps, thread.enabled(Feature::Apps));
}
config
} else {
self.load_latest_config(/*fallback_cwd*/ None).await?
};
let auth = self.auth_manager.auth().await;
if !config
@@ -88,8 +93,35 @@ impl AppsRequestProcessor {
config: Config,
environment_manager: Arc<EnvironmentManager>,
) {
let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await;
outgoing.send_result(request_id, result).await;
match Self::apps_list_response(
&outgoing,
params,
config.clone(),
Arc::clone(&environment_manager),
)
.await
{
Ok(result) => {
let should_force_refetch_after_response =
result.should_force_refetch_after_response;
let full_data = result.full_data;
let response: Result<AppsListResponse, JSONRPCErrorError> = Ok(result.response);
outgoing.send_result(request_id, response).await;
if should_force_refetch_after_response {
send_force_refetched_app_list_updated_notification(
&outgoing,
config,
environment_manager,
full_data,
)
.await;
}
}
Err(err) => {
let response: Result<AppsListResponse, JSONRPCErrorError> = Err(err);
outgoing.send_result(request_id, response).await;
}
}
}
async fn apps_list_response(
@@ -97,7 +129,7 @@ impl AppsRequestProcessor {
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
) -> Result<AppsListResponse, JSONRPCErrorError> {
) -> Result<AppsListTaskResult, JSONRPCErrorError> {
let AppsListParams {
cursor,
limit,
@@ -130,7 +162,10 @@ impl AppsRequestProcessor {
&environment_manager,
)
.await
.map(|status| status.connectors)
.map(|status| AccessibleAppsList {
connectors: status.connectors,
codex_apps_ready: status.codex_apps_ready,
})
.map_err(|err| format!("failed to load accessible apps: {err}"));
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
});
@@ -146,6 +181,7 @@ impl AppsRequestProcessor {
let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT;
let mut accessible_loaded = false;
let mut all_loaded = false;
let mut codex_apps_ready = true;
let mut last_notified_apps = None;
if accessible_connectors.is_some() || all_connectors.is_some() {
@@ -178,8 +214,9 @@ impl AppsRequestProcessor {
};
match result {
AppListLoadResult::Accessible(Ok(connectors)) => {
accessible_connectors = Some(connectors);
AppListLoadResult::Accessible(Ok(apps)) => {
accessible_connectors = Some(apps.connectors);
codex_apps_ready = apps.codex_apps_ready;
accessible_loaded = true;
}
AppListLoadResult::Accessible(Err(err)) => {
@@ -222,7 +259,12 @@ impl AppsRequestProcessor {
}
if accessible_loaded && all_loaded {
return paginate_apps(merged.as_slice(), start, limit);
let response = paginate_apps(merged.as_slice(), start, limit)?;
return Ok(AppsListTaskResult {
response,
full_data: merged,
should_force_refetch_after_response: !force_refetch && !codex_apps_ready,
});
}
}
}
@@ -278,8 +320,19 @@ impl AppsRequestProcessor {
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
struct AppsListTaskResult {
response: AppsListResponse,
full_data: Vec<AppInfo>,
should_force_refetch_after_response: bool,
}
struct AccessibleAppsList {
connectors: Vec<AppInfo>,
codex_apps_ready: bool,
}
enum AppListLoadResult {
Accessible(Result<Vec<AppInfo>, String>),
Accessible(Result<AccessibleAppsList, String>),
Directory(Result<Vec<AppInfo>, String>),
}
@@ -335,3 +388,46 @@ async fn send_app_list_updated_notification(
))
.await;
}
async fn send_force_refetched_app_list_updated_notification(
outgoing: &Arc<OutgoingMessageSender>,
config: Config,
environment_manager: Arc<EnvironmentManager>,
previous_data: Vec<AppInfo>,
) {
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true),
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config,
/*force_refetch*/ true,
&environment_manager,
),
);
let all_connectors = match all_connectors_result {
Ok(connectors) => connectors,
Err(err) => {
warn!("failed to force-refresh directory apps after app/list response: {err:#}");
return;
}
};
let accessible_connectors = match accessible_connectors_result {
Ok(status) => status.connectors,
Err(err) => {
warn!("failed to force-refresh accessible apps after app/list response: {err:#}");
return;
}
};
let data = connectors::with_app_enabled_state(
connectors::merge_connectors_with_accessible(
all_connectors,
accessible_connectors,
/*all_connectors_loaded*/ true,
),
&config,
);
if data != previous_data {
send_app_list_updated_notification(outgoing, data).await;
}
}
-1
View File
@@ -31,7 +31,6 @@ codex-app-server-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-install-context = { workspace = true }
codex-chatgpt = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-config = { workspace = true }
codex-connectors = { workspace = true }
@@ -5,6 +5,9 @@
//! the main event loop remains single-threaded.
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;
@@ -91,6 +94,24 @@ impl App {
});
}
pub(super) fn fetch_apps_list(
&mut self,
app_server: &AppServerSession,
force_refetch: bool,
thread_id: Option<ThreadId>,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_apps_list(request_handle, force_refetch, thread_id)
.await
.map_err(|err| format!("Failed to load apps: {err}"));
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();
@@ -631,6 +652,32 @@ pub(super) async fn fetch_skills_list(
.wrap_err("skills/list failed in TUI")
}
pub(super) async fn fetch_apps_list(
request_handle: AppServerRequestHandle,
force_refetch: bool,
thread_id: Option<ThreadId>,
) -> Result<ConnectorsSnapshot> {
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
let request_id = RequestId::String(format!("app-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,
+6
View File
@@ -384,6 +384,12 @@ impl App {
AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
AppEvent::FetchAppsList {
force_refetch,
thread_id,
} => {
self.fetch_apps_list(app_server, force_refetch, thread_id);
}
AppEvent::PluginInstallAuthAdvance { refresh_connectors } => {
if refresh_connectors {
self.chat_widget.refresh_connectors(/*force_refetch*/ true);
+6
View File
@@ -293,6 +293,12 @@ pub(crate) enum AppEvent {
is_final: bool,
},
/// Fetch app connector state through the app-server app-list API.
FetchAppsList {
force_refetch: bool,
thread_id: Option<ThreadId>,
},
/// Result of computing a `/diff` command.
DiffResult(String),
+17 -67
View File
@@ -123,7 +123,6 @@ use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnPlanStepStatus;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_chatgpt::connectors as chatgpt_connectors;
use codex_config::ConfigLayerStackOrdering;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::Notifications;
@@ -6642,62 +6641,9 @@ impl ChatWidget {
self.connectors.cache = ConnectorsCacheState::Loading;
}
let config = self.config.clone();
let environment_manager = Arc::clone(&self.environment_manager);
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let accessible_result =
match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config,
force_refetch,
&environment_manager,
)
.await
{
Ok(connectors) => connectors,
Err(err) => {
app_event_tx.send(AppEvent::ConnectorsLoaded {
result: Err(format!("Failed to load apps: {err}")),
is_final: true,
});
return;
}
};
let should_schedule_force_refetch =
!force_refetch && !accessible_result.codex_apps_ready;
let accessible_connectors = accessible_result.connectors;
app_event_tx.send(AppEvent::ConnectorsLoaded {
result: Ok(ConnectorsSnapshot {
connectors: accessible_connectors.clone(),
}),
is_final: false,
});
let result: Result<ConnectorsSnapshot, String> = async {
let all_connectors =
chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch)
.await?;
let connectors = chatgpt_connectors::merge_connectors_with_accessible(
all_connectors,
accessible_connectors,
/*all_connectors_loaded*/ true,
);
Ok(ConnectorsSnapshot { connectors })
}
.await
.map_err(|err: anyhow::Error| format!("Failed to load apps: {err}"));
app_event_tx.send(AppEvent::ConnectorsLoaded {
result,
is_final: true,
});
if should_schedule_force_refetch {
app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
}
self.app_event_tx.send(AppEvent::FetchAppsList {
force_refetch,
thread_id: self.thread_id,
});
}
@@ -10029,6 +9975,7 @@ impl ChatWidget {
is_final: bool,
) {
let mut trigger_pending_force_refetch = false;
let preserve_enabled_state = self.connectors.force_refetch_pending;
if is_final {
self.connectors.prefetch_in_flight = false;
if self.connectors.force_refetch_pending {
@@ -10039,16 +9986,9 @@ impl ChatWidget {
match result {
Ok(mut snapshot) => {
if !is_final {
snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible(
Vec::new(),
snapshot.connectors,
/*all_connectors_loaded*/ false,
);
}
snapshot.connectors =
chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config);
if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache {
if preserve_enabled_state
&& let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache
{
let enabled_by_id: HashMap<&str, bool> = existing_snapshot
.connectors
.iter()
@@ -10094,6 +10034,13 @@ impl ChatWidget {
}
}
fn on_app_list_updated(&mut self, connectors: Vec<AppInfo>) {
self.on_connectors_loaded(
Ok(ConnectorsSnapshot { connectors }),
/*is_final*/ !self.connectors.prefetch_in_flight,
);
}
pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) {
let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else {
return;
@@ -10112,6 +10059,9 @@ impl ChatWidget {
return;
}
if self.connectors.prefetch_in_flight {
self.connectors.force_refetch_pending = true;
}
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
+5 -1
View File
@@ -157,6 +157,11 @@ impl ChatWidget {
ServerNotification::McpServerStatusUpdated(notification) => {
self.on_mcp_server_status_updated(notification)
}
ServerNotification::AppListUpdated(notification) => {
if !from_replay {
self.on_app_list_updated(notification.data);
}
}
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
self.on_guardian_review_notification(
notification.review_id,
@@ -226,7 +231,6 @@ impl ChatWidget {
| ServerNotification::FileChangePatchUpdated(_)
| ServerNotification::McpToolCallProgress(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AppListUpdated(_)
| ServerNotification::RemoteControlStatusChanged(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::FsChanged(_)
@@ -1306,7 +1306,7 @@ async fn plugins_popup_search_no_matches_and_backspace_restores_results() {
#[tokio::test]
async fn apps_popup_stays_loading_until_final_snapshot_updates() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.config
.features
@@ -1341,6 +1341,13 @@ async fn apps_popup_stays_loading_until_final_snapshot_updates() {
chat.connectors.prefetch_in_flight,
"expected /apps to trigger a forced connectors refresh"
);
assert_matches!(
rx.try_recv(),
Ok(AppEvent::FetchAppsList {
force_refetch: true,
thread_id: None,
})
);
let before = render_bottom_popup(&chat, /*width*/ 80);
assert!(
@@ -1701,9 +1708,9 @@ async fn apps_popup_keeps_existing_full_snapshot_while_partial_refresh_loads() {
);
chat.add_connectors_output();
chat.on_connectors_loaded(
Ok(ConnectorsSnapshot {
connectors: vec![
chat.handle_server_notification(
ServerNotification::AppListUpdated(codex_app_server_protocol::AppListUpdatedNotification {
data: vec![
AppInfo {
id: "unit_test_connector_1".to_string(),
name: "Notion".to_string(),
@@ -1736,7 +1743,7 @@ async fn apps_popup_keeps_existing_full_snapshot_while_partial_refresh_loads() {
},
],
}),
/*is_final*/ false,
/*replay_kind*/ None,
);
assert_matches!(
@@ -1858,7 +1865,7 @@ async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() {
}
#[tokio::test]
async fn apps_initial_load_applies_enabled_state_from_config() {
async fn apps_initial_load_uses_enabled_state_from_app_list() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.config
@@ -1867,17 +1874,6 @@ async fn apps_initial_load_applies_enabled_state_from_config() {
.expect("test config should allow feature update");
chat.bottom_pane.set_connectors_enabled(/*enabled*/ true);
let temp = tempdir().expect("tempdir");
let config_toml_path = temp.path().join("config.toml").abs();
let user_config = toml::from_str::<TomlValue>(
"[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n",
)
.expect("apps config");
chat.config.config_layer_stack = chat
.config
.config_layer_stack
.with_user_config(&config_toml_path, user_config);
chat.on_connectors_loaded(
Ok(ConnectorsSnapshot {
connectors: vec![AppInfo {
@@ -1892,7 +1888,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() {
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}],
}),
@@ -1911,7 +1907,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() {
}
#[tokio::test]
async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_override() {
async fn apps_initial_load_uses_app_list_disabled_state_with_user_override() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.config
@@ -1959,7 +1955,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}],
}),
@@ -1985,7 +1981,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove
}
#[tokio::test]
async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_entry() {
async fn apps_initial_load_uses_app_list_disabled_state_with_requirements() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.config
@@ -2024,7 +2020,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}],
}),
@@ -2050,7 +2046,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_
}
#[tokio::test]
async fn apps_refresh_preserves_toggled_enabled_state() {
async fn apps_refresh_uses_enabled_state_from_app_list_unless_local_toggle_races() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.config
@@ -2109,14 +2105,46 @@ async fn apps_refresh_preserves_toggled_enabled_state() {
.connectors
.iter()
.find(|connector| connector.id == "connector_1")
.is_some_and(|connector| !connector.is_enabled)
.is_some_and(|connector| connector.is_enabled)
);
chat.add_connectors_output();
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Installed · Disabled. Press Enter to open the app page"),
"expected disabled status to persist after reload, got:\n{popup}"
popup.contains("Installed. Press Enter to open the app page"),
"expected refreshed app-list status to render as enabled, got:\n{popup}"
);
chat.update_connector_enabled("connector_1", /*enabled*/ false);
chat.on_connectors_loaded(
Ok(ConnectorsSnapshot {
connectors: vec![AppInfo {
id: "connector_1".to_string(),
name: "Notion".to_string(),
description: Some("Workspace docs".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}],
}),
/*is_final*/ true,
);
assert_matches!(
&chat.connectors_cache,
ConnectorsCacheState::Ready(snapshot)
if snapshot
.connectors
.iter()
.find(|connector| connector.id == "connector_1")
.is_some_and(|connector| !connector.is_enabled)
);
}