mirror of
https://github.com/openai/codex.git
synced 2026-05-22 03:54:18 +00:00
Compare commits
5 Commits
rust-v0.13
...
codex/tui-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c90fc48a7 | ||
|
|
1b733e2847 | ||
|
|
46d9abd98c | ||
|
|
a6964bfef6 | ||
|
|
8cb82d3f96 |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -3720,7 +3720,6 @@ dependencies = [
|
||||
"codex-app-server-client",
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-chatgpt",
|
||||
"codex-cli",
|
||||
"codex-cloud-requirements",
|
||||
"codex-config",
|
||||
|
||||
@@ -284,10 +284,15 @@
|
||||
"$ref": "#/definitions/AppInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"isFinal": {
|
||||
"description": "Whether the notification includes the complete merged app list.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
"data",
|
||||
"isFinal"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -5919,10 +5919,15 @@
|
||||
"$ref": "#/definitions/v2/AppInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"isFinal": {
|
||||
"description": "Whether the notification includes the complete merged app list.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
"data",
|
||||
"isFinal"
|
||||
],
|
||||
"title": "AppListUpdatedNotification",
|
||||
"type": "object"
|
||||
|
||||
@@ -479,10 +479,15 @@
|
||||
"$ref": "#/definitions/AppInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"isFinal": {
|
||||
"description": "Whether the notification includes the complete merged app list.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
"data",
|
||||
"isFinal"
|
||||
],
|
||||
"title": "AppListUpdatedNotification",
|
||||
"type": "object"
|
||||
|
||||
@@ -266,10 +266,15 @@
|
||||
"$ref": "#/definitions/AppInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"isFinal": {
|
||||
"description": "Whether the notification includes the complete merged app list.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
"data",
|
||||
"isFinal"
|
||||
],
|
||||
"title": "AppListUpdatedNotification",
|
||||
"type": "object"
|
||||
|
||||
@@ -6,4 +6,8 @@ import type { AppInfo } from "./AppInfo";
|
||||
/**
|
||||
* EXPERIMENTAL - notification emitted when the app list changes.
|
||||
*/
|
||||
export type AppListUpdatedNotification = { data: Array<AppInfo>, };
|
||||
export type AppListUpdatedNotification = { data: Array<AppInfo>,
|
||||
/**
|
||||
* Whether the notification includes the complete merged app list.
|
||||
*/
|
||||
isFinal: boolean, };
|
||||
|
||||
@@ -143,4 +143,6 @@ pub struct AppsListResponse {
|
||||
/// EXPERIMENTAL - notification emitted when the app list changes.
|
||||
pub struct AppListUpdatedNotification {
|
||||
pub data: Vec<AppInfo>,
|
||||
/// Whether the notification includes the complete merged app list.
|
||||
pub is_final: bool,
|
||||
}
|
||||
|
||||
@@ -1639,12 +1639,13 @@ When `threadId` is provided, app feature gating (`Feature::Apps`) is evaluated u
|
||||
|
||||
`app/list` returns after both accessible apps and directory apps are loaded. Set `forceRefetch: true` to bypass app caches and fetch fresh data from sources. Cache entries are only replaced when those refetches succeed.
|
||||
|
||||
The server also emits `app/list/updated` notifications whenever either source (accessible apps or directory apps) finishes loading. Each notification includes the latest merged app list.
|
||||
The server also emits `app/list/updated` notifications whenever either source (accessible apps or directory apps) finishes loading. Each notification includes the latest merged app list and `isFinal`, which is `false` for interim updates and `true` once both sources have loaded.
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "app/list/updated",
|
||||
"params": {
|
||||
"isFinal": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "demo-app",
|
||||
|
||||
@@ -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() {
|
||||
@@ -158,7 +194,12 @@ impl AppsRequestProcessor {
|
||||
accessible_loaded,
|
||||
all_loaded,
|
||||
) {
|
||||
send_app_list_updated_notification(outgoing, merged.clone()).await;
|
||||
send_app_list_updated_notification(
|
||||
outgoing,
|
||||
merged.clone(),
|
||||
/*is_final*/ false,
|
||||
)
|
||||
.await;
|
||||
last_notified_apps = Some(merged);
|
||||
}
|
||||
}
|
||||
@@ -178,8 +219,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)) => {
|
||||
@@ -217,12 +259,22 @@ impl AppsRequestProcessor {
|
||||
all_loaded,
|
||||
) && last_notified_apps.as_ref() != Some(&merged)
|
||||
{
|
||||
send_app_list_updated_notification(outgoing, merged.clone()).await;
|
||||
send_app_list_updated_notification(
|
||||
outgoing,
|
||||
merged.clone(),
|
||||
/*is_final*/ accessible_loaded && all_loaded,
|
||||
)
|
||||
.await;
|
||||
last_notified_apps = Some(merged.clone());
|
||||
}
|
||||
|
||||
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 +330,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>),
|
||||
}
|
||||
|
||||
@@ -328,10 +391,54 @@ fn paginate_apps(
|
||||
async fn send_app_list_updated_notification(
|
||||
outgoing: &Arc<OutgoingMessageSender>,
|
||||
data: Vec<AppInfo>,
|
||||
is_final: bool,
|
||||
) {
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::AppListUpdated(
|
||||
AppListUpdatedNotification { data },
|
||||
AppListUpdatedNotification { data, is_final },
|
||||
))
|
||||
.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, /*is_final*/ true).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,10 @@ impl ConfigRequestProcessor {
|
||||
);
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::AppListUpdated(
|
||||
AppListUpdatedNotification { data },
|
||||
AppListUpdatedNotification {
|
||||
data,
|
||||
is_final: true,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -596,7 +596,6 @@ impl App {
|
||||
) -> crate::chatwidget::ChatWidgetInit {
|
||||
crate::chatwidget::ChatWidgetInit {
|
||||
config: cfg,
|
||||
environment_manager: self.environment_manager.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
workspace_command_runner: self.workspace_command_runner.clone(),
|
||||
@@ -762,7 +761,6 @@ impl App {
|
||||
.await;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
environment_manager: environment_manager.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
workspace_command_runner: Some(workspace_command_runner.clone()),
|
||||
@@ -799,7 +797,6 @@ impl App {
|
||||
})?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
environment_manager: environment_manager.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
workspace_command_runner: Some(workspace_command_runner.clone()),
|
||||
@@ -841,7 +838,6 @@ impl App {
|
||||
})?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
environment_manager: environment_manager.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
workspace_command_runner: Some(workspace_command_runner.clone()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -425,7 +425,6 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
|
||||
let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
|
||||
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
|
||||
config,
|
||||
environment_manager: app.environment_manager.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
app_event_tx: app.app_event_tx.clone(),
|
||||
workspace_command_runner: None,
|
||||
@@ -4935,7 +4934,6 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
|
||||
|
||||
let replacement = ChatWidget::new_with_app_event(ChatWidgetInit {
|
||||
config: app.config.clone(),
|
||||
environment_manager: app.environment_manager.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
app_event_tx: app.app_event_tx.clone(),
|
||||
workspace_command_runner: None,
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ use crate::version::CODEX_CLI_VERSION;
|
||||
use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppListUpdatedNotification;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
||||
use codex_app_server_protocol::CollabAgentTool;
|
||||
@@ -123,13 +124,11 @@ 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;
|
||||
use codex_config::types::WindowsSandboxModeToml;
|
||||
use codex_core_skills::model::SkillMetadata;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Feature;
|
||||
#[cfg(test)]
|
||||
@@ -567,7 +566,6 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String {
|
||||
/// Common initialization parameters shared by all `ChatWidget` constructors.
|
||||
pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) config: Config,
|
||||
pub(crate) environment_manager: Arc<EnvironmentManager>,
|
||||
pub(crate) frame_requester: FrameRequester,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
/// App-server-backed runner used by status surfaces for workspace metadata probes.
|
||||
@@ -661,7 +659,6 @@ pub(crate) struct ChatWidget {
|
||||
bottom_pane: BottomPane,
|
||||
transcript: TranscriptState,
|
||||
config: Config,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
raw_output_mode: bool,
|
||||
/// Runtime value resolved by core. `config.service_tier` remains the explicit user choice.
|
||||
effective_service_tier: Option<String>,
|
||||
@@ -4681,7 +4678,6 @@ impl ChatWidget {
|
||||
fn new_with_op_target(common: ChatWidgetInit, codex_op_target: CodexOpTarget) -> Self {
|
||||
let ChatWidgetInit {
|
||||
config,
|
||||
environment_manager,
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
workspace_command_runner,
|
||||
@@ -4766,7 +4762,6 @@ impl ChatWidget {
|
||||
transcript: TranscriptState::new(active_cell),
|
||||
raw_output_mode: config.tui_raw_output_mode,
|
||||
config,
|
||||
environment_manager,
|
||||
effective_service_tier,
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
@@ -6642,62 +6637,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 +9971,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 +9982,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 +10030,19 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_app_list_updated(&mut self, notification: AppListUpdatedNotification) {
|
||||
if !notification.is_final && !self.connectors.prefetch_in_flight {
|
||||
return;
|
||||
}
|
||||
let is_final = notification.is_final && !self.connectors.prefetch_in_flight;
|
||||
self.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: notification.data,
|
||||
}),
|
||||
is_final,
|
||||
);
|
||||
}
|
||||
|
||||
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 +10061,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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
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(_)
|
||||
|
||||
@@ -161,7 +161,6 @@ pub(super) async fn make_chatwidget_manual(
|
||||
let model_catalog = test_model_catalog(&cfg);
|
||||
let common = ChatWidgetInit {
|
||||
config: cfg,
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx,
|
||||
workspace_command_runner: None,
|
||||
|
||||
@@ -1536,7 +1536,6 @@ async fn make_startup_chat_with_cli_overrides(
|
||||
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg.clone(),
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
|
||||
workspace_command_runner: None,
|
||||
|
||||
@@ -70,7 +70,6 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
|
||||
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg.clone(),
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
|
||||
workspace_command_runner: None,
|
||||
@@ -1306,7 +1305,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 +1340,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!(
|
||||
@@ -1652,7 +1658,7 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_popup_keeps_existing_full_snapshot_while_partial_refresh_loads() {
|
||||
async fn apps_popup_ignores_stale_partial_app_list_update_after_refresh() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config
|
||||
@@ -1701,9 +1707,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(),
|
||||
@@ -1735,8 +1741,9 @@ async fn apps_popup_keeps_existing_full_snapshot_while_partial_refresh_loads() {
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
],
|
||||
is_final: false,
|
||||
}),
|
||||
/*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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -400,7 +400,6 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg.clone(),
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: tx,
|
||||
workspace_command_runner: None,
|
||||
|
||||
Reference in New Issue
Block a user