diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e1590e6ce..2e5b8337c1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2104,6 +2104,7 @@ name = "codex-backend-client" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "codex-api", "codex-backend-openapi-models", "codex-client", @@ -3634,6 +3635,7 @@ dependencies = [ "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", + "codex-backend-client", "codex-chatgpt", "codex-cli", "codex-cloud-requirements", diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index d2e374ae2a..9e7a55f66c 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] anyhow = "1" +chrono = { workspace = true, features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 6365d527ed..5c2593a7df 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,4 +1,5 @@ use crate::types::CodeTaskDetailsResponse; +use crate::types::CodexWorkspaceMessagesResponse; use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; @@ -408,6 +409,16 @@ impl Client { .map_err(RequestError::from) } + pub async fn list_workspace_messages( + &self, + ) -> std::result::Result { + let url = self.workspace_messages_url(); + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; + self.decode_json::(&url, &ct, &body) + .map_err(RequestError::from) + } + /// Create a new task (user turn) by POSTing to the appropriate backend path /// based on `path_style`. Returns the created task id. pub async fn create_task(&self, request_body: serde_json::Value) -> Result { @@ -539,6 +550,13 @@ impl Client { } } + fn workspace_messages_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/workspace-messages", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/workspace-messages", self.base_url), + } + } + fn map_rate_limit_window( window: Option>>, ) -> Option { @@ -862,4 +880,35 @@ mod tests { serde_json::json!({ "credit_type": "usage_limit" }) ); } + + #[test] + fn workspace_messages_uses_expected_paths() { + let codex_client = Client { + base_url: "https://example.test".to_string(), + http: reqwest::Client::new(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: PathStyle::CodexApi, + }; + assert_eq!( + codex_client.workspace_messages_url(), + "https://example.test/api/codex/workspace-messages" + ); + + let chatgpt_client = Client { + base_url: "https://chatgpt.com/backend-api".to_string(), + http: reqwest::Client::new(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: PathStyle::ChatGptApi, + }; + assert_eq!( + chatgpt_client.workspace_messages_url(), + "https://chatgpt.com/backend-api/wham/workspace-messages" + ); + } } diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 300da81568..2af38a9397 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -6,6 +6,9 @@ pub use client::Client; pub use client::RequestError; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; +pub use types::CodexWorkspaceMessage; +pub use types::CodexWorkspaceMessageType; +pub use types::CodexWorkspaceMessagesResponse; pub use types::ConfigFileResponse; pub use types::PaginatedListTaskListItem; pub use types::TaskListItem; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index d8d24ab9fc..dc7a6aad19 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -8,11 +8,51 @@ pub use codex_backend_openapi_models::models::RateLimitStatusPayload; pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot; pub use codex_backend_openapi_models::models::TaskListItem; +use chrono::DateTime; +use chrono::Utc; use serde::Deserialize; use serde::de::Deserializer; use serde_json::Value; use std::collections::HashMap; +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CodexWorkspaceMessagesResponse { + #[serde(default)] + pub messages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CodexWorkspaceMessage { + pub message_id: String, + pub message_type: CodexWorkspaceMessageType, + pub message_body: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CodexWorkspaceMessageType { + Headline, + Announcement, + #[serde(other)] + Unknown, +} + +impl CodexWorkspaceMessagesResponse { + pub fn headlines(&self) -> impl Iterator { + self.messages + .iter() + .filter(|message| message.message_type == CodexWorkspaceMessageType::Headline) + } + + pub fn announcements(&self) -> impl Iterator { + self.messages + .iter() + .filter(|message| message.message_type == CodexWorkspaceMessageType::Announcement) + } +} + /// Hand-rolled models for the Cloud Tasks task-details response. /// The generated OpenAPI models are pretty bad. This is a half-step /// towards hand-rolling them. diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c5538c02ed..2532b12d4b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -29,6 +29,7 @@ codex-ansi-escape = { workspace = true } codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } +codex-backend-client = { workspace = true } codex-install-context = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-requirements = { workspace = true } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 55c190a66d..f99e32552d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -190,6 +190,7 @@ mod version; mod voice; mod width; mod workspace_command; +mod workspace_messages; #[cfg(target_os = "linux")] #[allow(dead_code)] mod voice { @@ -1104,6 +1105,7 @@ async fn run_ratatui_app( color_eyre::install()?; tooltips::announcement::prewarm(); + workspace_messages::prewarm_headline(&initial_config); // Forward panic reports through tracing so they appear in the UI status // line, but do not swallow the default/color-eyre panic handler. diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs new file mode 100644 index 0000000000..c7d7601d77 --- /dev/null +++ b/codex-rs/tui/src/workspace_messages.rs @@ -0,0 +1,39 @@ +use crate::legacy_core::config::Config; +use codex_backend_client::Client as BackendClient; +use codex_backend_client::CodexWorkspaceMessage; +use codex_login::AuthManager; +use std::sync::OnceLock; +use std::time::Duration; +use tokio::time::timeout; + +const HEADLINE_FETCH_TIMEOUT: Duration = Duration::from_millis(1000); + +static WORKSPACE_HEADLINE: OnceLock> = OnceLock::new(); + +pub(crate) fn prewarm_headline(config: &Config) { + if WORKSPACE_HEADLINE.get().is_some() { + return; + } + + let config = config.clone(); + tokio::spawn(async move { + let headline = timeout(HEADLINE_FETCH_TIMEOUT, fetch_headline(config)) + .await + .ok() + .flatten(); + let _ = WORKSPACE_HEADLINE.set(headline); + }); +} + +async fn fetch_headline(config: Config) -> Option { + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + let auth = auth_manager.auth().await?; + if !auth.uses_codex_backend() { + return None; + } + + let client = BackendClient::from_auth(config.chatgpt_base_url, &auth).ok()?; + let messages = client.list_workspace_messages().await.ok()?; + messages.headlines().next().cloned() +}