diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 873d0a222a..d025f3736a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -535,6 +535,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.41" @@ -658,6 +664,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-backend-client" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-backend-openapi-models", + "reqwest", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "codex-backend-openapi-models" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "uuid", +] + [[package]] name = "codex-chatgpt" version = "0.0.0" @@ -683,6 +710,7 @@ dependencies = [ "clap_complete", "codex-arg0", "codex-chatgpt", + "codex-cloud-tasks", "codex-common", "codex-core", "codex-exec", @@ -697,6 +725,63 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "codex-cloud-tasks" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "clap", + "codex-backend-client", + "codex-cloud-tasks-api", + "codex-cloud-tasks-client", + "codex-common", + "codex-core", + "codex-login", + "codex-tui", + "crossterm", + "ratatui", + "reqwest", + "serde", + "serde_json", + "throbber-widgets-tui", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", + "unicode-width 0.1.14", +] + +[[package]] +name = "codex-cloud-tasks-api" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "serde", + "thiserror 2.0.16", +] + +[[package]] +name = "codex-cloud-tasks-client" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "codex-apply-patch", + "codex-backend-client", + "codex-cloud-tasks-api", + "diffy", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.16", + "tokio", +] + [[package]] name = "codex-common" version = "0.0.0" @@ -1949,8 +2034,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1960,9 +2047,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -2164,6 +2253,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2200,7 +2290,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -2810,6 +2900,12 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lsp-types" version = "0.94.1" @@ -2991,7 +3087,7 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.9.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", "libc", ] @@ -3641,6 +3737,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.40" @@ -3964,6 +4115,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3971,6 +4124,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3980,6 +4134,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -4038,6 +4193,12 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -4080,6 +4241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4092,6 +4254,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -4563,6 +4726,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.0" @@ -4982,6 +5155,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "throbber-widgets-tui" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d" +dependencies = [ + "rand 0.8.5", + "ratatui", +] + [[package]] name = "tiff" version = "0.9.1" @@ -5057,6 +5240,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.47.1" @@ -5072,7 +5270,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", "windows-sys 0.59.0", ] @@ -5752,6 +5950,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webbrowser" version = "1.0.5" @@ -5768,6 +5976,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.10" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 4155992293..1563706df2 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,8 +1,13 @@ [workspace] members = [ + "backend-client", "ansi-escape", "apply-patch", "arg0", + "codex-backend-openapi-models", + "cloud-tasks", + "cloud-tasks-api", + "cloud-tasks-client", "cli", "common", "core", diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml new file mode 100644 index 0000000000..e7f7962013 --- /dev/null +++ b/codex-rs/backend-client/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "codex-backend-client" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tokio = { version = "1", features = ["macros", "rt"] } +codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs new file mode 100644 index 0000000000..2169c39724 --- /dev/null +++ b/codex-rs/backend-client/src/client.rs @@ -0,0 +1,256 @@ +use crate::types::CodeTaskDetailsResponse; +use crate::types::PaginatedListTaskListItem; +use anyhow::Result; +use reqwest::header::AUTHORIZATION; +use reqwest::header::CONTENT_TYPE; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use reqwest::header::USER_AGENT; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PathStyle { + CodexApi, // /api/codex/... + Wham, // /wham/... +} + +#[derive(Clone, Debug)] +pub struct Client { + base_url: String, + http: reqwest::Client, + bearer_token: Option, + user_agent: Option, + chatgpt_account_id: Option, + path_style: PathStyle, +} + +impl Client { + pub fn new(base_url: impl Into) -> Result { + let mut base_url = base_url.into(); + // Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths. + // Also trim trailing slashes for consistent URL building. + while base_url.ends_with('/') { + base_url.pop(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{}/backend-api", base_url); + } + let http = reqwest::Client::builder().build()?; + let path_style = if base_url.contains("/backend-api") { + PathStyle::Wham + } else { + PathStyle::CodexApi + }; + Ok(Self { + base_url, + http, + bearer_token: None, + user_agent: None, + chatgpt_account_id: None, + path_style, + }) + } + + pub fn with_bearer_token(mut self, token: impl Into) -> Self { + self.bearer_token = Some(token.into()); + self + } + + pub fn with_user_agent(mut self, ua: impl Into) -> Self { + if let Ok(hv) = HeaderValue::from_str(&ua.into()) { + self.user_agent = Some(hv); + } + self + } + + pub fn with_chatgpt_account_id(mut self, account_id: impl Into) -> Self { + self.chatgpt_account_id = Some(account_id.into()); + self + } + + pub fn with_path_style(mut self, style: PathStyle) -> Self { + self.path_style = style; + self + } + + fn headers(&self) -> HeaderMap { + let mut h = HeaderMap::new(); + if let Some(ua) = &self.user_agent { + h.insert(USER_AGENT, ua.clone()); + } else { + h.insert(USER_AGENT, HeaderValue::from_static("codex-cli")); + } + if let Some(token) = &self.bearer_token { + let value = format!("Bearer {}", token); + if let Ok(hv) = HeaderValue::from_str(&value) { + h.insert(AUTHORIZATION, hv); + } + } + if let Some(acc) = &self.chatgpt_account_id { + if let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") { + if let Ok(hv) = HeaderValue::from_str(acc) { + h.insert(name, hv); + } + } + } + // Optional internal toggle: send WHAM-FORCE-INTERNAL header when requested. + // if matches!( + // std::env::var("CODEX_CLOUD_TASKS_FORCE_INTERNAL") + // .ok() + // .as_deref(), + // Some("1") | Some("true") | Some("TRUE") + // ) { + // if let Ok(name) = HeaderName::from_lowercase(b"wham-force-internal") { + // h.insert(name, HeaderValue::from_static("true")); + // } + // } + h + } + + pub async fn list_tasks( + &self, + limit: Option, + task_filter: Option<&str>, + environment_id: Option<&str>, + ) -> Result { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), + PathStyle::Wham => format!("{}/wham/tasks/list", self.base_url), + }; + let req = self.http.get(&url).headers(self.headers()); + let req = if let Some(lim) = limit { + req.query(&[("limit", lim)]) + } else { + req + }; + let req = if let Some(tf) = task_filter { + req.query(&[("task_filter", tf)]) + } else { + req + }; + let req = if let Some(id) = environment_id { + req.query(&[("environment_id", id)]) + } else { + req + }; + let res = req.send().await?; + let status = res.status(); + if !status.is_success() { + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}"); + } + // Decode with better diagnostics on failure + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + match serde_json::from_str::(&body) { + Ok(v) => Ok(v), + Err(e) => { + // Include the full response body to aid debugging rather than truncating. + anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"); + } + } + } + + pub async fn get_task_details(&self, task_id: &str) -> Result { + let (parsed, _body, _ct) = self.get_task_details_with_body(task_id).await?; + Ok(parsed) + } + + pub async fn get_task_details_with_body( + &self, + task_id: &str, + ) -> Result<(CodeTaskDetailsResponse, String, String)> { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id), + PathStyle::Wham => format!("{}/wham/tasks/{}", self.base_url, task_id), + }; + let res = self.http.get(&url).headers(self.headers()).send().await?; + let status = res.status(); + if !status.is_success() { + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}"); + } + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + match serde_json::from_str::(&body) { + Ok(v) => Ok((v, body, ct)), + Err(e) => { + anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"); + } + } + } + + /// 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 { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url), + PathStyle::Wham => format!("{}/wham/tasks", self.base_url), + }; + let res = self + .http + .post(&url) + .headers(self.headers()) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .json(&request_body) + .send() + .await?; + let status = res.status(); + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("POST {url} failed: {status}; content-type={ct}; body={body}"); + } + // Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present. + match serde_json::from_str::(&body) { + Ok(v) => { + if let Some(id) = v + .get("task") + .and_then(|t| t.get("id")) + .and_then(|s| s.as_str()) + { + Ok(id.to_string()) + } else if let Some(id) = v.get("id").and_then(|s| s.as_str()) { + Ok(id.to_string()) + } else { + anyhow::bail!( + "POST {url} succeeded but no task id found; content-type={ct}; body={body}" + ); + } + } + Err(e) => { + anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"); + } + } + } +} diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs new file mode 100644 index 0000000000..49d4b22eeb --- /dev/null +++ b/codex-rs/backend-client/src/lib.rs @@ -0,0 +1,8 @@ +mod client; +pub mod types; + +pub use client::Client; +pub use types::CodeTaskDetailsResponse; +pub use types::CodeTaskDetailsResponseExt; +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 new file mode 100644 index 0000000000..69874309bb --- /dev/null +++ b/codex-rs/backend-client/src/types.rs @@ -0,0 +1,233 @@ +pub use codex_backend_openapi_models::models::CodeTaskDetailsResponse; +pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; +pub use codex_backend_openapi_models::models::TaskListItem; + +use serde_json::Value; + +/// Extension helpers on generated types. +pub trait CodeTaskDetailsResponseExt { + /// Attempt to extract a unified diff string from `current_diff_task_turn`. + fn unified_diff(&self) -> Option; + /// Extract assistant text output messages (no diff) from current turns. + fn assistant_text_messages(&self) -> Vec; + /// Extract an assistant error message (if the turn failed and provided one). + fn assistant_error_message(&self) -> Option; + /// Best-effort: extract a single file old/new path for header synthesis when only hunk bodies are provided. + fn single_file_paths(&self) -> Option<(String, String)>; +} +impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse { + fn unified_diff(&self) -> Option { + // `current_diff_task_turn` is an object; look for `output_items`. + // Prefer explicit diff turn; fallback to assistant turn if needed. + let candidates: [&Option>; 2] = + [&self.current_diff_task_turn, &self.current_assistant_turn]; + + for map in candidates { + let items = map + .as_ref() + .and_then(|m| m.get("output_items")) + .and_then(|v| v.as_array()); + if let Some(items) = items { + for item in items { + match item.get("type").and_then(Value::as_str) { + Some("output_diff") => { + if let Some(s) = item.get("diff").and_then(Value::as_str) { + return Some(s.to_string()); + } + } + Some("pr") => { + if let Some(s) = item + .get("output_diff") + .and_then(|od| od.get("diff")) + .and_then(Value::as_str) + { + return Some(s.to_string()); + } + } + _ => {} + } + } + } + } + None + } + fn assistant_text_messages(&self) -> Vec { + let mut out = Vec::new(); + let candidates: [&Option>; 2] = + [&self.current_diff_task_turn, &self.current_assistant_turn]; + for map in candidates { + let items = map + .as_ref() + .and_then(|m| m.get("output_items")) + .and_then(|v| v.as_array()); + if let Some(items) = items { + for item in items { + if item.get("type").and_then(Value::as_str) == Some("message") { + if let Some(content) = item.get("content").and_then(Value::as_array) { + for part in content { + if part.get("content_type").and_then(Value::as_str) == Some("text") + { + if let Some(txt) = part.get("text").and_then(Value::as_str) { + out.push(txt.to_string()); + } + } + } + } + } + } + } + } + out + } + + fn assistant_error_message(&self) -> Option { + let map = self.current_assistant_turn.as_ref()?; + let err = map.get("error")?.as_object()?; + let message = err.get("message").and_then(Value::as_str).unwrap_or(""); + let code = err.get("code").and_then(Value::as_str).unwrap_or(""); + if message.is_empty() && code.is_empty() { + None + } else if message.is_empty() { + Some(format!("{code}")) + } else if code.is_empty() { + Some(message.to_string()) + } else { + Some(format!("{code}: {message}")) + } + } + fn single_file_paths(&self) -> Option<(String, String)> { + fn try_from_items(items: &Vec) -> Option<(String, String)> { + use serde_json::Value; + for item in items { + if let Some(obj) = item.as_object() { + if let Some(p) = obj.get("path").and_then(Value::as_str) { + let p = p.to_string(); + return Some((p.clone(), p)); + } + let old = obj.get("old_path").and_then(Value::as_str); + let newp = obj.get("new_path").and_then(Value::as_str); + if let (Some(o), Some(n)) = (old, newp) { + return Some((o.to_string(), n.to_string())); + } + if let Some(od) = obj.get("output_diff").and_then(Value::as_object) { + if let Some(fm) = od.get("files_modified") { + if let Some(map) = fm.as_object() { + if map.len() == 1 { + if let Some((k, _)) = map.iter().next() { + let p = k.to_string(); + return Some((p.clone(), p)); + } + } + } else if let Some(arr) = fm.as_array() { + if arr.len() == 1 { + let el = &arr[0]; + if let Some(p) = el.as_str() { + let p = p.to_string(); + return Some((p.clone(), p)); + } + if let Some(o) = el.as_object() { + let path = o.get("path").and_then(Value::as_str); + let oldp = o.get("old_path").and_then(Value::as_str); + let newp = o.get("new_path").and_then(Value::as_str); + if let Some(p) = path { + let p = p.to_string(); + return Some((p.clone(), p)); + } + if let (Some(o1), Some(n1)) = (oldp, newp) { + return Some((o1.to_string(), n1.to_string())); + } + } + } + } + } + if let Some(p) = od.get("path").and_then(Value::as_str) { + let p = p.to_string(); + return Some((p.clone(), p)); + } + } + } + } + None + } + let candidates: [&Option>; 2] = + [&self.current_diff_task_turn, &self.current_assistant_turn]; + for map in candidates { + if let Some(m) = map.as_ref() { + if let Some(items) = m.get("output_items").and_then(serde_json::Value::as_array) { + if let Some(p) = try_from_items(items) { + return Some(p); + } + } + } + } + None + } +} + +/// Best-effort extraction of a list of (old_path, new_path) pairs for files involved +/// in the current task's diff output. For entries where only a single `path` is present, +/// the pair will be (path, path). +pub fn extract_file_paths_list(details: &CodeTaskDetailsResponse) -> Vec<(String, String)> { + use serde_json::Value; + fn push_from_items(out: &mut Vec<(String, String)>, items: &Vec) { + for item in items { + if let Some(obj) = item.as_object() { + if let Some(p) = obj.get("path").and_then(Value::as_str) { + let p = p.to_string(); + out.push((p.clone(), p)); + continue; + } + let old = obj.get("old_path").and_then(Value::as_str); + let newp = obj.get("new_path").and_then(Value::as_str); + if let (Some(o), Some(n)) = (old, newp) { + out.push((o.to_string(), n.to_string())); + continue; + } + if let Some(od) = obj.get("output_diff").and_then(Value::as_object) { + if let Some(fm) = od.get("files_modified") { + if let Some(map) = fm.as_object() { + for (k, _v) in map { + let p = k.to_string(); + out.push((p.clone(), p)); + } + } else if let Some(arr) = fm.as_array() { + for el in arr { + if let Some(p) = el.as_str() { + let p = p.to_string(); + out.push((p.clone(), p)); + } else if let Some(o) = el.as_object() { + let path = o.get("path").and_then(Value::as_str); + let oldp = o.get("old_path").and_then(Value::as_str); + let newp = o.get("new_path").and_then(Value::as_str); + if let Some(p) = path { + let p = p.to_string(); + out.push((p.clone(), p)); + } else if let (Some(o1), Some(n1)) = (oldp, newp) { + out.push((o1.to_string(), n1.to_string())); + } + } + } + } + } + if let Some(p) = od.get("path").and_then(Value::as_str) { + let p = p.to_string(); + out.push((p.clone(), p)); + } + } + } + } + } + let mut out: Vec<(String, String)> = Vec::new(); + let candidates: [&Option>; 2] = [ + &details.current_diff_task_turn, + &details.current_assistant_turn, + ]; + for map in candidates { + if let Some(m) = map.as_ref() { + if let Some(items) = m.get("output_items").and_then(Value::as_array) { + push_from_items(&mut out, items); + } + } + } + out +} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index f7af3349e0..7997945ff7 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -27,6 +27,7 @@ codex-login = { path = "../login" } codex-mcp-server = { path = "../mcp-server" } codex-protocol = { path = "../protocol" } codex-tui = { path = "../tui" } +codex-cloud-tasks = { path = "../cloud-tasks" } serde_json = "1" tokio = { version = "1", features = [ "io-std", diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index cff03e7c16..b8a8e45e02 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -12,8 +12,8 @@ use codex_protocol::mcp_protocol::AuthMode; use std::env; use std::path::PathBuf; -pub async fn login_with_chatgpt(codex_home: PathBuf, originator: String) -> std::io::Result<()> { - let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string(), originator); +pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { + let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string()); let server = run_login_server(opts)?; eprintln!( @@ -27,12 +27,7 @@ pub async fn login_with_chatgpt(codex_home: PathBuf, originator: String) -> std: pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides); - match login_with_chatgpt( - config.codex_home, - config.responses_originator_header.clone(), - ) - .await - { + match login_with_chatgpt(config.codex_home).await { Ok(_) => { eprintln!("Successfully logged in"); std::process::exit(0); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2acc3d84c5..f4b62f81ae 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -12,6 +12,7 @@ use codex_cli::login::run_login_with_api_key; use codex_cli::login::run_login_with_chatgpt; use codex_cli::login::run_logout; use codex_cli::proto; +use codex_cloud_tasks::Cli as CloudTasksCli; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; use codex_tui::Cli as TuiCli; @@ -76,6 +77,9 @@ enum Subcommand { /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), + + /// Browse and apply tasks from the cloud. + CloudTasks(CloudTasksCli), } #[derive(Debug, Parser)] @@ -187,6 +191,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::Completion(completion_cli)) => { print_completion(completion_cli); } + Some(Subcommand::CloudTasks(mut cloud_cli)) => { + prepend_config_flags(&mut cloud_cli.config_overrides, cli.config_overrides); + codex_cloud_tasks::run_main(cloud_cli, codex_linux_sandbox_exe).await?; + } Some(Subcommand::Debug(debug_args)) => match debug_args.cmd { DebugCommand::Seatbelt(mut seatbelt_cli) => { prepend_config_flags(&mut seatbelt_cli.config_overrides, cli.config_overrides); diff --git a/codex-rs/cloud-tasks-api/Cargo.toml b/codex-rs/cloud-tasks-api/Cargo.toml new file mode 100644 index 0000000000..5e600e8d76 --- /dev/null +++ b/codex-rs/cloud-tasks-api/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-cloud-tasks-api" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_cloud_tasks_api" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +thiserror = "2.0.12" + diff --git a/codex-rs/cloud-tasks-api/src/lib.rs b/codex-rs/cloud-tasks-api/src/lib.rs new file mode 100644 index 0000000000..aac620950e --- /dev/null +++ b/codex-rs/cloud-tasks-api/src/lib.rs @@ -0,0 +1,96 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("unimplemented: {0}")] + Unimplemented(&'static str), + #[error("http error: {0}")] + Http(String), + #[error("io error: {0}")] + Io(String), + /// Expected condition: the task has no diff available yet (e.g., still in progress). + #[error("no diff available yet")] + NoDiffYet, + #[error("{0}")] + Msg(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TaskId(pub String); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TaskStatus { + Pending, + Ready, + Applied, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskSummary { + pub id: TaskId, + pub title: String, + pub status: TaskStatus, + pub updated_at: DateTime, + /// Backend environment identifier (when available) + pub environment_id: Option, + /// Human-friendly environment label (when available) + pub environment_label: Option, + pub summary: DiffSummary, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ApplyStatus { + Success, + Partial, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApplyOutcome { + pub applied: bool, + pub status: ApplyStatus, + pub message: String, + #[serde(default)] + pub skipped_paths: Vec, + #[serde(default)] + pub conflict_paths: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreatedTask { + pub id: TaskId, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct DiffSummary { + pub files_changed: usize, + pub lines_added: usize, + pub lines_removed: usize, +} + +#[async_trait::async_trait] +pub trait CloudBackend: Send + Sync { + async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn get_task_diff(&self, id: TaskId) -> Result; + /// Return assistant output messages (no diff) when available. + async fn get_task_messages(&self, id: TaskId) -> Result>; + async fn apply_task(&self, id: TaskId) -> Result; + async fn create_task( + &self, + env_id: &str, + prompt: &str, + git_ref: &str, + qa_mode: bool, + ) -> Result; +} diff --git a/codex-rs/cloud-tasks-client/Cargo.toml b/codex-rs/cloud-tasks-client/Cargo.toml new file mode 100644 index 0000000000..1e5b4c125e --- /dev/null +++ b/codex-rs/cloud-tasks-client/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "codex-cloud-tasks-client" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_cloud_tasks_client" +path = "src/lib.rs" + +[lints] +workspace = true + +[features] +default = ["online"] +online = ["dep:reqwest", "dep:tokio", "dep:codex-backend-client"] +mock = [] + +[dependencies] +anyhow = "1" +codex-cloud-tasks-api = { path = "../cloud-tasks-api" } +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +codex-apply-patch = { path = "../apply-patch" } +diffy = "0.4.2" +reqwest = { version = "0.12", features = ["json"], optional = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2.0.12" +tokio = { version = "1", features = ["macros", "rt-multi-thread"], optional = true } +codex-backend-client = { path = "../backend-client", optional = true } diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs new file mode 100644 index 0000000000..5b2587ccda --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -0,0 +1,487 @@ +use crate::ApplyOutcome; +use crate::ApplyStatus; +use crate::CloudBackend; +use crate::Error; +use crate::Result; +use crate::TaskId; +use crate::TaskStatus; +use crate::TaskSummary; +use chrono::DateTime; +use chrono::Utc; +use codex_cloud_tasks_api::DiffSummary; + +use serde_json::Value; +use std::collections::HashMap; + +use codex_backend_client as backend; +use codex_backend_client::CodeTaskDetailsResponseExt; +use codex_backend_client::types::extract_file_paths_list; + +#[derive(Clone)] +pub struct HttpClient { + pub base_url: String, + backend: backend::Client, +} + +impl HttpClient { + pub fn new(base_url: impl Into) -> anyhow::Result { + let base_url = base_url.into(); + let backend = backend::Client::new(base_url.clone())?; + Ok(Self { base_url, backend }) + } + + pub fn with_bearer_token(mut self, token: impl Into) -> Self { + self.backend = self.backend.clone().with_bearer_token(token); + self + } + + pub fn with_user_agent(mut self, ua: impl Into) -> Self { + self.backend = self.backend.clone().with_user_agent(ua); + self + } + + pub fn with_chatgpt_account_id(mut self, account_id: impl Into) -> Self { + self.backend = self.backend.clone().with_chatgpt_account_id(account_id); + self + } +} + +#[async_trait::async_trait] +impl CloudBackend for HttpClient { + async fn list_tasks(&self, env: Option<&str>) -> Result> { + let resp = self + .backend + .list_tasks(Some(20), Some("current"), env) + .await + .map_err(|e| Error::Http(format!("list_tasks failed: {e}")))?; + + let tasks: Vec = resp + .items + .into_iter() + .map(map_task_list_item_to_summary) + .collect(); + // Debug log for env filtering visibility + append_error_log(&format!( + "http.list_tasks: env={} items={}", + env.unwrap_or(""), + tasks.len() + )); + Ok(tasks) + } + + async fn get_task_diff(&self, _id: TaskId) -> Result { + let id = _id.0; + let (details, body, ct) = self + .backend + .get_task_details_with_body(&id) + .await + .map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?; + if let Some(diff) = details.unified_diff() { + return Ok(diff); + } + // No diff yet (pending or non-diff task). Return a structured error so UI can render cleanly. + // Keep a concise body tail in logs if needed by callers. + let _ = (body, ct); // silence unused if logging is disabled at callsite + Err(Error::NoDiffYet) + } + + async fn get_task_messages(&self, _id: TaskId) -> Result> { + let id = _id.0; + let (details, body, ct) = self + .backend + .get_task_details_with_body(&id) + .await + .map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?; + let mut msgs = details.assistant_text_messages(); + if msgs.is_empty() { + // Fallback: some pending tasks expose only worklog messages; parse from raw body. + if let Ok(full) = serde_json::from_str::(&body) { + // worklog.messages[*] where author.role == "assistant" → content.parts[*].text + if let Some(arr) = full + .get("current_assistant_turn") + .and_then(|v| v.get("worklog")) + .and_then(|v| v.get("messages")) + .and_then(|v| v.as_array()) + { + for m in arr { + let is_assistant = m + .get("author") + .and_then(|a| a.get("role")) + .and_then(|r| r.as_str()) + == Some("assistant"); + if !is_assistant { + continue; + } + if let Some(parts) = m + .get("content") + .and_then(|c| c.get("parts")) + .and_then(|p| p.as_array()) + { + for p in parts { + if let Some(s) = p.as_str() { + // Shape: content { content_type: "text", parts: ["..."] } + if !s.is_empty() { + msgs.push(s.to_string()); + } + continue; + } + if let Some(obj) = p.as_object() { + if obj.get("content_type").and_then(|t| t.as_str()) + == Some("text") + { + if let Some(txt) = obj.get("text").and_then(|t| t.as_str()) + { + msgs.push(txt.to_string()); + } + } + } + } + } + } + } + } + } + if !msgs.is_empty() { + return Ok(msgs); + } + if let Some(err) = details.assistant_error_message() { + return Ok(vec![format!("Task failed: {err}")]); + } + // No assistant messages found; return a debuggable error with context for logging. + let url = if self.base_url.contains("/backend-api") { + format!("{}/wham/tasks/{}", self.base_url, id) + } else { + format!("{}/api/codex/tasks/{}", self.base_url, id) + }; + Err(Error::Http(format!( + "No assistant text messages in response. GET {url}; content-type={ct}; body={body}" + ))) + } + + async fn apply_task(&self, _id: TaskId) -> Result { + let id = _id.0; + // Fetch diff fresh and apply locally via git (unified diffs). + let details = self + .backend + .get_task_details(&id) + .await + .map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?; + let diff = details + .unified_diff() + .ok_or_else(|| Error::Msg(format!("No diff available for task {id}")))?; + let diff = match crate::patch_apply::classify_patch(&diff) { + crate::patch_apply::PatchKind::HunkOnly => { + let files = extract_file_paths_list(&details); + if files.len() > 1 { + let parts = crate::patch_apply::split_hunk_body_into_files(&diff); + if parts.len() == files.len() { + let mut acc = String::new(); + for (i, (oldp, newp)) in files.iter().enumerate() { + let u = crate::patch_apply::synthesize_unified_single_file( + &parts[i], oldp, newp, + ); + acc.push_str(&u); + if !acc.ends_with("\n") { + acc.push('\n'); + } + } + acc + } else if let Some((oldp, newp)) = details.single_file_paths() { + crate::patch_apply::synthesize_unified_single_file(&diff, &oldp, &newp) + } else { + diff + } + } else if let Some((oldp, newp)) = details.single_file_paths() { + crate::patch_apply::synthesize_unified_single_file(&diff, &oldp, &newp) + } else { + diff + } + } + _ => diff, + }; + + // Run the centralized Git apply path (supports unified diffs and Codex conversion) + let ctx = crate::patch_apply::context_from_env( + std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()), + ); + let res = crate::patch_apply::apply_patch(&diff, &ctx); + let status = match res.status { + crate::patch_apply::ApplyStatus::Success => ApplyStatus::Success, + crate::patch_apply::ApplyStatus::Partial => ApplyStatus::Partial, + crate::patch_apply::ApplyStatus::Error => ApplyStatus::Error, + }; + let applied = matches!(status, ApplyStatus::Success); + let message = match status { + ApplyStatus::Success => format!( + "Applied task {id} locally ({} changed)", + res.changed_paths.len() + ), + ApplyStatus::Partial => format!( + "Apply partially succeeded for task {id} (changed={}, skipped={}, conflicts={})", + res.changed_paths.len(), + res.skipped_paths.len(), + res.conflict_paths.len() + ), + ApplyStatus::Error => { + let is_check = res.diagnostics.contains("apply --check failed"); + if is_check { + format!( + "Apply check failed for task {id}: patch does not apply to your working tree. No changes were made. See error.log for details.", + ) + } else { + // Compact, single-line fallback; avoid embedding multiline stderr directly. + let mut diag = res.diagnostics.replace('\n', " "); + if diag.len() > 600 { + diag.truncate(600); + diag.push_str("…"); + } + format!( + "Apply failed for task {id} (changed={}, skipped={}, conflicts={}); {}", + res.changed_paths.len(), + res.skipped_paths.len(), + res.conflict_paths.len(), + diag + ) + } + } + }; + + // On apply failure, log a detailed record including the diff we attempted. + if matches!(status, ApplyStatus::Error) { + let mut log = String::new(); + let summary = summarize_patch_for_logging(&diff); + use std::fmt::Write as _; + let _ = writeln!( + &mut log, + "apply_error: id={} changed={} skipped={} conflicts={}; {}", + id, + res.changed_paths.len(), + res.skipped_paths.len(), + res.conflict_paths.len(), + res.diagnostics + ); + let _ = writeln!(&mut log, "{summary}"); + let _ = writeln!(&mut log, "----- PATCH BEGIN -----"); + let _ = writeln!(&mut log, "{diff}"); + let _ = writeln!(&mut log, "----- PATCH END -----"); + append_error_log(&log); + } + + Ok(ApplyOutcome { + applied, + status, + message, + skipped_paths: res.skipped_paths, + conflict_paths: res.conflict_paths, + }) + } + + async fn create_task( + &self, + env_id: &str, + prompt: &str, + git_ref: &str, + qa_mode: bool, + ) -> Result { + // Build request payload patterned after VSCode/newtask.rs + let mut input_items: Vec = Vec::new(); + input_items.push(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{ "content_type": "text", "text": prompt }] + })); + + if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF") { + if !diff.is_empty() { + input_items.push(serde_json::json!({ + "type": "pre_apply_patch", + "output_diff": { "diff": diff } + })); + } + } + + let request_body = serde_json::json!({ + "new_task": { + "environment_id": env_id, + "branch": git_ref, + "run_environment_in_qa_mode": qa_mode, + }, + "input_items": input_items, + }); + + // Use the underlying backend client to post with proper headers + match self.backend.create_task(request_body).await { + Ok(id) => { + append_error_log(&format!( + "new_task: created id={id} env={} prompt_chars={}", + env_id, + prompt.chars().count() + )); + Ok(codex_cloud_tasks_api::CreatedTask { id: TaskId(id) }) + } + Err(e) => { + append_error_log(&format!( + "new_task: create failed env={} prompt_chars={}: {}", + env_id, + prompt.chars().count(), + e + )); + Err(Error::Http(format!("create_task failed: {e}"))) + } + } + } +} + +fn map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary { + fn env_label_from_status_display(v: Option<&HashMap>) -> Option { + let obj = v?; + let raw = obj.get("environment_label")?; + if let Some(s) = raw.as_str() { + if s.trim().is_empty() { + return None; + } + return Some(s.to_string()); + } + if let Some(o) = raw.as_object() { + // Best-effort support for rich shapes: { text: "..." } or { plain_text: "..." } + if let Some(s) = o.get("text").and_then(Value::as_str) { + if !s.trim().is_empty() { + return Some(s.to_string()); + } + } + if let Some(s) = o.get("plain_text").and_then(Value::as_str) { + if !s.trim().is_empty() { + return Some(s.to_string()); + } + } + // Fallback: compact JSON for debugging + if let Ok(s) = serde_json::to_string(o) { + if !s.is_empty() { + return Some(s); + } + } + } + None + } + + // Best-effort parse of diff_stats (when present in latest_turn_status_display) + fn diff_summary_from_status_display(v: Option<&HashMap>) -> DiffSummary { + let mut out = DiffSummary::default(); + let Some(map) = v else { return out }; + let latest = map + .get("latest_turn_status_display") + .and_then(Value::as_object); + let Some(latest) = latest else { return out }; + if let Some(ds) = latest.get("diff_stats").and_then(Value::as_object) { + if let Some(n) = ds.get("files_modified").and_then(Value::as_i64) { + out.files_changed = n.max(0) as usize; + } + if let Some(n) = ds.get("lines_added").and_then(Value::as_i64) { + out.lines_added = n.max(0) as usize; + } + if let Some(n) = ds.get("lines_removed").and_then(Value::as_i64) { + out.lines_removed = n.max(0) as usize; + } + } + out + } + + TaskSummary { + id: TaskId(src.id), + title: src.title, + status: map_status(src.task_status_display.as_ref()), + updated_at: parse_updated_at(src.updated_at.as_ref()), + environment_id: None, + environment_label: env_label_from_status_display(src.task_status_display.as_ref()), + summary: diff_summary_from_status_display(src.task_status_display.as_ref()), + } +} + +fn map_status(v: Option<&HashMap>) -> TaskStatus { + if let Some(val) = v { + // Prefer nested latest_turn_status_display.turn_status when present. + if let Some(turn) = val + .get("latest_turn_status_display") + .and_then(Value::as_object) + { + if let Some(s) = turn.get("turn_status").and_then(Value::as_str) { + return match s { + "failed" => TaskStatus::Error, + "completed" => TaskStatus::Ready, + "in_progress" => TaskStatus::Pending, + "pending" => TaskStatus::Pending, + "cancelled" => TaskStatus::Error, + _ => TaskStatus::Pending, + }; + } + } + // Legacy or alternative flat state. + if let Some(state) = val.get("state").and_then(Value::as_str) { + return match state { + "pending" => TaskStatus::Pending, + "ready" => TaskStatus::Ready, + "applied" => TaskStatus::Applied, + "error" => TaskStatus::Error, + _ => TaskStatus::Pending, + }; + } + } + TaskStatus::Pending +} + +fn parse_updated_at(ts: Option<&f64>) -> DateTime { + if let Some(v) = ts { + // Value is seconds since epoch with fractional part. + let secs = *v as i64; + let nanos = ((*v - secs as f64) * 1_000_000_000.0) as u32; + return DateTime::::from( + std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos), + ); + } + Utc::now() +} + +/// Return a compact one-line classification of the patch plus a short head snippet +/// to aid debugging when apply fails. +fn summarize_patch_for_logging(patch: &str) -> String { + let trimmed = patch.trim_start(); + let kind = if trimmed.starts_with("*** Begin Patch") { + "codex-patch" + } else if trimmed.starts_with("diff --git ") || trimmed.contains("\n*** End Patch\n") { + // In some cases providers nest a codex patch inside another format; detect both. + "git-diff" + } else if trimmed.starts_with("@@ ") || trimmed.contains("\n@@ ") { + "unified-diff" + } else { + "unknown" + }; + let lines = patch.lines().count(); + let chars = patch.len(); + let cwd = std::env::current_dir() + .ok() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()); + // Grab the first up-to-20 non-empty lines for context. + let head: String = patch.lines().take(20).collect::>().join("\n"); + // Make sure we don't explode logs with huge content. + let head_trunc = if head.len() > 800 { + format!("{}…", &head[..800]) + } else { + head + }; + format!( + "patch_summary: kind={kind} lines={lines} chars={chars} cwd={cwd} ; head=\n{head_trunc}" + ) +} + +fn append_error_log(message: &str) { + let ts = Utc::now().to_rfc3339(); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("error.log") + { + use std::io::Write as _; + let _ = writeln!(f, "[{ts}] {message}"); + } +} diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs new file mode 100644 index 0000000000..5ca6533715 --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -0,0 +1,26 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +pub use api::ApplyOutcome; +pub use api::ApplyStatus; +pub use api::CloudBackend; +pub use api::Error; +pub use api::Result; +pub use api::TaskId; +pub use api::TaskStatus; +pub use api::TaskSummary; +use codex_cloud_tasks_api as api; + +#[cfg(feature = "mock")] +mod mock; + +#[cfg(feature = "online")] +mod http; + +#[cfg(feature = "mock")] +pub use mock::MockClient; + +#[cfg(feature = "online")] +pub use http::HttpClient; + +// Reusable apply engine (git apply runner and helpers) +pub mod patch_apply; diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs new file mode 100644 index 0000000000..c3654622b1 --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -0,0 +1,132 @@ +use crate::ApplyOutcome; +use crate::CloudBackend; +use crate::Error; +use crate::Result; +use crate::TaskId; +use crate::TaskStatus; +use crate::TaskSummary; +use chrono::Utc; +use codex_cloud_tasks_api::DiffSummary; + +#[derive(Clone, Default)] +pub struct MockClient; + +#[async_trait::async_trait] +impl CloudBackend for MockClient { + async fn list_tasks(&self, _env: Option<&str>) -> Result> { + // Slightly vary content by env to aid tests that rely on the mock + let rows = match _env { + Some("env-A") => vec![("T-2000", "A: First", TaskStatus::Ready)], + Some("env-B") => vec![ + ("T-3000", "B: One", TaskStatus::Ready), + ("T-3001", "B: Two", TaskStatus::Pending), + ], + _ => vec![ + ("T-1000", "Update README formatting", TaskStatus::Ready), + ("T-1001", "Fix clippy warnings in core", TaskStatus::Pending), + ("T-1002", "Add contributing guide", TaskStatus::Ready), + ], + }; + let environment_id = _env.map(|s| s.to_string()); + let environment_label = match _env { + Some("env-A") => Some("Env A".to_string()), + Some("env-B") => Some("Env B".to_string()), + Some(other) => Some(format!("{other}")), + None => Some("Global".to_string()), + }; + let mut out = Vec::new(); + for (id_str, title, status) in rows { + let id = TaskId(id_str.to_string()); + let diff = mock_diff_for(&id); + let (a, d) = count_from_unified(&diff); + out.push(TaskSummary { + id, + title: title.to_string(), + status, + updated_at: Utc::now(), + environment_id: environment_id.clone(), + environment_label: environment_label.clone(), + summary: DiffSummary { + files_changed: 1, + lines_added: a, + lines_removed: d, + }, + }); + } + Ok(out) + } + + async fn get_task_diff(&self, id: TaskId) -> Result { + Ok(mock_diff_for(&id)) + } + + async fn get_task_messages(&self, _id: TaskId) -> Result> { + Ok(vec![ + "Mock assistant output: this task contains no diff.".to_string(), + ]) + } + + async fn apply_task(&self, id: TaskId) -> Result { + Ok(ApplyOutcome { + applied: true, + status: crate::ApplyStatus::Success, + message: format!("Applied task {} locally (mock)", id.0), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + }) + } + + async fn create_task( + &self, + env_id: &str, + prompt: &str, + git_ref: &str, + qa_mode: bool, + ) -> Result { + let _ = (env_id, prompt, git_ref, qa_mode); + let id = format!("task_local_{}", chrono::Utc::now().timestamp_millis()); + Ok(codex_cloud_tasks_api::CreatedTask { id: TaskId(id) }) + } +} + +fn mock_diff_for(id: &TaskId) -> String { + match id.0.as_str() { + "T-1000" => { + "diff --git a/README.md b/README.md\nindex 000000..111111 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,3 @@\n Intro\n-Hello\n+Hello, world!\n+Task: T-1000\n".to_string() + } + "T-1001" => { + "diff --git a/core/src/lib.rs b/core/src/lib.rs\nindex 000000..111111 100644\n--- a/core/src/lib.rs\n+++ b/core/src/lib.rs\n@@ -1,2 +1,1 @@\n-use foo;\n use bar;\n".to_string() + } + _ => { + "diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md\nindex 000000..111111 100644\n--- /dev/null\n+++ b/CONTRIBUTING.md\n@@ -0,0 +1,3 @@\n+## Contributing\n+Please open PRs.\n+Thanks!\n".to_string() + } + } +} + +fn count_from_unified(diff: &str) -> (usize, usize) { + if let Ok(patch) = diffy::Patch::from_str(diff) { + patch + .hunks() + .iter() + .flat_map(|h| h.lines()) + .fold((0, 0), |(a, d), l| match l { + diffy::Line::Insert(_) => (a + 1, d), + diffy::Line::Delete(_) => (a, d + 1), + _ => (a, d), + }) + } else { + let mut a = 0; + let mut d = 0; + for l in diff.lines() { + if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") { + continue; + } + match l.as_bytes().first() { + Some(b'+') => a += 1, + Some(b'-') => d += 1, + _ => {} + } + } + (a, d) + } +} diff --git a/codex-rs/cloud-tasks-client/src/patch_apply.rs b/codex-rs/cloud-tasks-client/src/patch_apply.rs new file mode 100644 index 0000000000..2706133975 --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/patch_apply.rs @@ -0,0 +1,607 @@ +#![allow(dead_code)] + +use std::env; +use std::path::Path; +use std::path::PathBuf; + +/// Patch classification used to choose normalization steps before applying. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchKind { + /// Codex Patch format beginning with `*** Begin Patch`. + CodexPatch, + /// Unified diff that includes either `diff --git` headers or just `---/+++` file headers. + GitUnified, + /// Body contains `@@` hunks but lacks required file headers. + HunkOnly, + /// Unknown/unsupported format. + Unknown, +} + +/// How to handle whitespace in `git apply`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WhitespaceMode { + /// Default strict behavior. + Strict, + /// Equivalent to `--ignore-space-change`. + IgnoreSpaceChange, + /// Equivalent to `--whitespace=nowarn`. + WhitespaceNowarn, +} + +/// How to treat CRLF conversions in `git`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrlfMode { + /// Use repo/user defaults. + Default, + /// Apply with `-c core.autocrlf=false -c core.safecrlf=false`. + NoAutoCrlfNoSafe, +} + +/// Context for an apply operation. +#[derive(Debug, Clone)] +pub struct ApplyContext { + pub cwd: PathBuf, + pub whitespace: WhitespaceMode, + pub crlf_mode: CrlfMode, +} + +/// High-level outcome of an apply attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyStatus { + Success, + Partial, + Error, +} + +/// Structured result produced by the apply runner. +#[derive(Debug, Clone)] +pub struct ApplyResult { + pub status: ApplyStatus, + pub changed_paths: Vec, + pub skipped_paths: Vec, + pub conflict_paths: Vec, + pub stdout_tail: String, + pub stderr_tail: String, + pub diagnostics: String, +} + +/// Classify an incoming patch string by format. +pub fn classify_patch(s: &str) -> PatchKind { + let t = s.trim_start(); + if t.starts_with("*** Begin Patch") { + return PatchKind::CodexPatch; + } + // Unified diffs can be either full git style or just `---`/`+++` file headers. + let has_diff_git = t.contains("\ndiff --git ") || t.starts_with("diff --git "); + let has_dash_headers = t.contains("\n--- ") && t.contains("\n+++ "); + let has_hunk = t.contains("\n@@ ") || t.starts_with("@@ "); + if has_diff_git || (has_dash_headers && has_hunk) { + return PatchKind::GitUnified; + } + if has_hunk { + return PatchKind::HunkOnly; + } + PatchKind::Unknown +} + +/// Build an `ApplyContext` from environment variables. +/// +/// Supported envs: +/// - `CODEX_APPLY_WHITESPACE` = `ignore-space-change` | `whitespace-nowarn` | `strict` (default) +/// - `CODEX_APPLY_CRLF` = `no-autocrlf-nosafe` | `default` (default) +pub fn context_from_env(cwd: PathBuf) -> ApplyContext { + let whitespace = match env::var("CODEX_APPLY_WHITESPACE").ok().as_deref() { + Some("ignore-space-change") => WhitespaceMode::IgnoreSpaceChange, + Some("whitespace-nowarn") => WhitespaceMode::WhitespaceNowarn, + _ => WhitespaceMode::Strict, + }; + let crlf_mode = match env::var("CODEX_APPLY_CRLF").ok().as_deref() { + Some("no-autocrlf-nosafe") => CrlfMode::NoAutoCrlfNoSafe, + _ => CrlfMode::Default, + }; + ApplyContext { + cwd, + whitespace, + crlf_mode, + } +} + +/// Main entry point for applying a patch. This will be implemented in subsequent steps. +pub fn apply_patch(patch: &str, ctx: &ApplyContext) -> ApplyResult { + // Classify and convert if needed + let kind = classify_patch(patch); + let unified = match kind { + PatchKind::GitUnified => patch.to_string(), + PatchKind::CodexPatch => match convert_codex_patch_to_unified(patch, &ctx.cwd) { + Ok(u) => u, + Err(e) => { + return ApplyResult { + status: ApplyStatus::Error, + changed_paths: Vec::new(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + stdout_tail: String::new(), + stderr_tail: String::new(), + diagnostics: format!("failed to convert codex patch to unified diff: {e}"), + }; + } + }, + PatchKind::HunkOnly | PatchKind::Unknown => { + return ApplyResult { + status: ApplyStatus::Error, + changed_paths: Vec::new(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + stdout_tail: String::new(), + stderr_tail: String::new(), + diagnostics: format!( + "unsupported patch format: {kind:?}; need unified diff with file headers" + ), + }; + } + }; + + apply_unified(&unified, ctx) +} + +fn apply_unified(unified_patch: &str, ctx: &ApplyContext) -> ApplyResult { + // 1) Ensure `git` exists + if let Err(e) = run_git(&ctx.cwd, &[], &["--version"]) { + return ApplyResult { + status: ApplyStatus::Error, + changed_paths: Vec::new(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + stdout_tail: String::new(), + stderr_tail: String::new(), + diagnostics: format!("git not available: {e}"), + }; + } + // 2) Determine repo root + let repo_root = match run_git_capture(&ctx.cwd, &[], &["rev-parse", "--show-toplevel"]) { + Ok(out) if out.status == 0 => out.stdout.trim().to_string(), + Ok(out) => { + return ApplyResult { + status: ApplyStatus::Error, + changed_paths: Vec::new(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + stdout_tail: String::new(), + stderr_tail: String::new(), + diagnostics: format!( + "not a git repository (exit {}): {}", + out.status, + tail(&out.stderr) + ), + }; + } + Err(e) => { + return ApplyResult { + status: ApplyStatus::Error, + changed_paths: Vec::new(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + stdout_tail: String::new(), + stderr_tail: String::new(), + diagnostics: format!("git rev-parse failed: {e}"), + }; + } + }; + + // 3) Temp file + let mut patch_path = std::env::temp_dir(); + patch_path.push(format!("codex-apply-{}.diff", std::process::id())); + if let Err(e) = std::fs::write(&patch_path, unified_patch) { + return ApplyResult { + status: ApplyStatus::Error, + changed_paths: Vec::new(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + stdout_tail: String::new(), + stderr_tail: String::new(), + diagnostics: format!("failed to write temp patch: {e}"), + }; + } + struct TempPatch(PathBuf); + impl Drop for TempPatch { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + let _guard = TempPatch(patch_path.clone()); + + // 4) Preflight --check + let mut preflight_args: Vec<&str> = vec!["apply", "--check"]; + push_whitespace_flags(&mut preflight_args, ctx.whitespace); + // Compute a shell-friendly representation of the preflight command for logging. + let preflight_cfg = crlf_cfg(ctx.crlf_mode); + let preflight_cmd = render_command_for_log( + &repo_root, + &preflight_cfg, + &prepend(&preflight_args, patch_path.to_string_lossy().as_ref()), + ); + let preflight = run_git_capture( + Path::new(&repo_root), + preflight_cfg.as_slice(), + &prepend(&preflight_args, patch_path.to_string_lossy().as_ref()), + ); + if let Ok(out) = &preflight { + if out.status != 0 { + return ApplyResult { + status: ApplyStatus::Error, + changed_paths: Vec::new(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + stdout_tail: tail(&out.stdout), + stderr_tail: tail(&out.stderr), + diagnostics: format!( + "git apply --check failed; working tree not modified; cmd: {preflight_cmd}" + ), + }; + } + } else if let Err(e) = preflight { + return ApplyResult { + status: ApplyStatus::Error, + changed_paths: Vec::new(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + stdout_tail: String::new(), + stderr_tail: String::new(), + diagnostics: format!("git apply --check failed to run: {e}; cmd: {preflight_cmd}"), + }; + } + + // 5) Snapshot before + let before = list_changed_paths(&repo_root); + // 6) Apply + let mut apply_args: Vec<&str> = vec!["apply", "--3way"]; + push_whitespace_flags(&mut apply_args, ctx.whitespace); + let apply_cfg = crlf_cfg(ctx.crlf_mode); + let apply_cmd = render_command_for_log( + &repo_root, + &apply_cfg, + &prepend(&apply_args, patch_path.to_string_lossy().as_ref()), + ); + let apply_out = run_git_capture( + Path::new(&repo_root), + apply_cfg.as_slice(), + &prepend(&apply_args, patch_path.to_string_lossy().as_ref()), + ); + let mut result = ApplyResult { + status: ApplyStatus::Error, + changed_paths: Vec::new(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + stdout_tail: String::new(), + stderr_tail: String::new(), + diagnostics: String::new(), + }; + match apply_out { + Ok(out) => { + result.stdout_tail = tail(&out.stdout); + result.stderr_tail = tail(&out.stderr); + result.conflict_paths = list_conflicts(&repo_root); + let mut skipped = parse_skipped_paths(&result.stdout_tail); + skipped.extend(parse_skipped_paths(&result.stderr_tail)); + skipped.sort(); + skipped.dedup(); + result.skipped_paths = skipped; + let after = list_changed_paths(&repo_root); + result.changed_paths = set_delta(&before, &after); + result.status = if out.status == 0 { + ApplyStatus::Success + } else if !result.changed_paths.is_empty() || !result.conflict_paths.is_empty() { + ApplyStatus::Partial + } else { + ApplyStatus::Error + }; + result.diagnostics = format!( + "git apply exit={} ({} changed, {} skipped, {} conflicts); cmd: {}", + out.status, + result.changed_paths.len(), + result.skipped_paths.len(), + result.conflict_paths.len(), + apply_cmd + ); + } + Err(e) => { + result.status = ApplyStatus::Error; + result.diagnostics = format!("failed to run git apply: {e}; cmd: {apply_cmd}"); + } + } + result +} + +fn render_command_for_log(cwd: &str, git_cfg: &[&str], args: &[&str]) -> String { + fn quote(s: &str) -> String { + let simple = s + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c)); + if simple { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } + } + let mut parts: Vec = Vec::new(); + parts.push("git".to_string()); + for a in git_cfg { + parts.push(quote(a)); + } + for a in args { + parts.push(quote(a)); + } + format!("(cd {} && {})", quote(cwd), parts.join(" ")) +} + +fn convert_codex_patch_to_unified(patch: &str, cwd: &Path) -> Result { + // Parse codex patch and verify paths relative to cwd + let argv = vec!["apply_patch".to_string(), patch.to_string()]; + let verified = codex_apply_patch::maybe_parse_apply_patch_verified(&argv, cwd); + match verified { + codex_apply_patch::MaybeApplyPatchVerified::Body(action) => { + let mut parts: Vec = Vec::new(); + for (abs_path, change) in action.changes() { + let rel_path = abs_path.strip_prefix(cwd).unwrap_or(abs_path); + let rel_str = rel_path.to_string_lossy(); + match change { + codex_apply_patch::ApplyPatchFileChange::Add { content } => { + let header = format!( + "diff --git a/{rel_str} b/{rel_str} +new file mode 100644 +--- /dev/null ++++ b/{rel_str} +" + ); + let body = build_add_hunk(content); + parts.push(format!("{header}{body}")); + } + codex_apply_patch::ApplyPatchFileChange::Delete { .. } => { + let header = format!( + "diff --git a/{rel_str} b/{rel_str} +deleted file mode 100644 +--- a/{rel_str} ++++ /dev/null +" + ); + parts.push(header); + } + codex_apply_patch::ApplyPatchFileChange::Update { + unified_diff, + move_path, + .. + } => { + let new_rel = move_path + .as_ref() + .map(|p| { + p.strip_prefix(cwd) + .unwrap_or(p) + .to_string_lossy() + .to_string() + }) + .unwrap_or_else(|| rel_str.to_string()); + let header = format!( + "diff --git a/{rel_str} b/{new_rel} +--- a/{rel_str} ++++ b/{new_rel} +" + ); + parts.push(format!("{header}{unified_diff}")); + } + } + } + if parts.is_empty() { + Err("empty patch after conversion".to_string()) + } else { + Ok(parts.join("\n")) + } + } + codex_apply_patch::MaybeApplyPatchVerified::CorrectnessError(e) => { + Err(format!("patch correctness: {e}")) + } + codex_apply_patch::MaybeApplyPatchVerified::ShellParseError(e) => { + Err(format!("shell parse: {e:?}")) + } + _ => Err("not an apply_patch payload".to_string()), + } +} + +fn build_add_hunk(content: &str) -> String { + let norm = content.replace("\r\n", "\n"); + let mut lines: Vec<&str> = norm.split('\n').collect(); + if let Some("") = lines.last().copied() { + lines.pop(); + } + let count = lines.len(); + if count == 0 { + return String::new(); + } + let mut out = String::new(); + out.push_str(&format!("@@ -0,0 +1,{count} @@\n")); + for l in lines { + out.push('+'); + out.push_str(l); + out.push('\n'); + } + out +} + +fn push_whitespace_flags(args: &mut Vec<&str>, mode: WhitespaceMode) { + match mode { + WhitespaceMode::Strict => {} + WhitespaceMode::IgnoreSpaceChange => args.push("--ignore-space-change"), + WhitespaceMode::WhitespaceNowarn => { + args.push("--whitespace"); + args.push("nowarn"); + } + } +} + +fn crlf_cfg(mode: CrlfMode) -> Vec<&'static str> { + match mode { + CrlfMode::Default => vec![], + CrlfMode::NoAutoCrlfNoSafe => { + vec!["-c", "core.autocrlf=false", "-c", "core.safecrlf=false"] + } + } +} + +fn prepend<'a>(base: &'a [&'a str], tail: &'a str) -> Vec<&'a str> { + let mut v = base.to_vec(); + v.push(tail); + v +} + +struct GitOutput { + status: i32, + stdout: String, + stderr: String, +} + +fn run_git(cwd: &std::path::Path, git_cfg: &[&str], args: &[&str]) -> std::io::Result<()> { + let status = std::process::Command::new("git") + .args(git_cfg) + .args(args) + .current_dir(cwd) + .status()?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!( + "git {:?} exited {}", + args, + status.code().unwrap_or(-1) + ))) + } +} + +fn run_git_capture( + cwd: &std::path::Path, + git_cfg: &[&str], + args: &[&str], +) -> std::io::Result { + let out = std::process::Command::new("git") + .args(git_cfg) + .args(args) + .current_dir(cwd) + .output()?; + Ok(GitOutput { + status: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }) +} + +fn list_changed_paths(repo_root: &str) -> Vec { + let cwd = std::path::Path::new(repo_root); + match run_git_capture(cwd, &[], &["diff", "--name-only"]) { + Ok(out) if out.status == 0 => out + .stdout + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + _ => Vec::new(), + } +} + +fn list_conflicts(repo_root: &str) -> Vec { + let cwd = std::path::Path::new(repo_root); + match run_git_capture(cwd, &[], &["ls-files", "-u"]) { + Ok(out) if out.status == 0 => { + let mut set = std::collections::BTreeSet::new(); + for line in out.stdout.lines() { + // format: \t + if let Some((_meta, path)) = line.split_once('\t') { + set.insert(path.trim().to_string()); + } + } + set.into_iter().collect() + } + _ => Vec::new(), + } +} + +fn parse_skipped_paths(text: &str) -> Vec { + let mut out = Vec::new(); + for line in text.lines() { + let l = line.trim(); + // error: path/to/file.txt does not match index + if let Some(rest) = l.strip_prefix("error:") { + let rest = rest.trim(); + if let Some(p) = rest.strip_suffix("does not match index") { + let p = p.trim().trim_end_matches(':').trim(); + if !p.is_empty() { + out.push(p.to_string()); + } + continue; + } + } + // patch failed: path/to/file.txt: content + if let Some(rest) = l.strip_prefix("patch failed:") { + let rest = rest.trim(); + if let Some((p, _)) = rest.split_once(':') { + let p = p.trim(); + if !p.is_empty() { + out.push(p.to_string()); + } + } + } + } + out +} + +fn tail(s: &str) -> String { + const MAX: usize = 2000; + if s.len() <= MAX { + s.to_string() + } else { + s[s.len() - MAX..].to_string() + } +} + +fn set_delta(before: &[String], after: &[String]) -> Vec { + use std::collections::BTreeSet; + let b: BTreeSet<_> = before.iter().collect(); + let a: BTreeSet<_> = after.iter().collect(); + a.difference(&b).map(|s| (*s).clone()).collect() +} + +/// Synthesize a unified git diff for a single file from a bare hunk body. +pub fn synthesize_unified_single_file(hunk_body: &str, old_path: &str, new_path: &str) -> String { + // Ensure body ends with newline + let mut body = hunk_body.to_string(); + if !body.ends_with("\n") { + body.push('\n'); + } + format!( + "diff --git a/{old_path} b/{new_path} +--- a/{old_path} ++++ b/{new_path} +{body}" + ) +} + +/// Split a bare hunk body into per-file segments using a conservative delimiter. +/// We look for lines that equal "*** End of File" (as emitted by our apply-patch format) +/// and use that to separate bodies for multiple files. +pub fn split_hunk_body_into_files(body: &str) -> Vec { + let mut chunks: Vec = Vec::new(); + let mut cur = String::new(); + for line in body.lines() { + if line.trim() == "*** End of File" { + if !cur.is_empty() { + cur.push('\n'); + chunks.push(cur); + cur = String::new(); + } + } else { + cur.push_str(line); + cur.push('\n'); + } + } + if !cur.trim().is_empty() { + chunks.push(cur); + } + chunks +} diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml new file mode 100644 index 0000000000..b7cb49fb33 --- /dev/null +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "codex-cloud-tasks" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_cloud_tasks" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +codex-common = { path = "../common", features = ["cli"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tracing = { version = "0.1.41", features = ["log"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +codex-cloud-tasks-api = { path = "../cloud-tasks-api" } +codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock", "online"] } +ratatui = { version = "0.29.0" } +crossterm = { version = "0.28.1", features = ["event-stream"] } +tokio-stream = "0.1.17" +chrono = { version = "0.4", features = ["serde"] } +codex-login = { path = "../login" } +codex-core = { path = "../core" } +codex-backend-client = { path = "../backend-client" } +throbber-widgets-tui = "0.8.0" +base64 = "0.22" +serde_json = "1" +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +unicode-width = "0.1" +codex-tui = { path = "../tui" } + +[dev-dependencies] +async-trait = "0.1" + +[[bin]] +name = "conncheck" +path = "src/bin/conncheck.rs" + +[[bin]] +name = "newtask" +path = "src/bin/newtask.rs" + +[[bin]] +name = "envcheck" +path = "src/bin/envcheck.rs" diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs new file mode 100644 index 0000000000..779cf7de66 --- /dev/null +++ b/codex-rs/cloud-tasks/src/app.rs @@ -0,0 +1,247 @@ +use std::time::Duration; + +// Environment filter data models for the TUI +#[derive(Clone, Debug, Default)] +pub struct EnvironmentRow { + pub id: String, + pub label: Option, + pub is_pinned: bool, + pub repo_hints: Option, // e.g., "openai/codex" +} + +#[derive(Clone, Debug, Default)] +pub struct EnvModalState { + pub query: String, + pub selected: usize, +} + +use crate::scrollable_diff::ScrollableDiff; +use codex_cloud_tasks_api::CloudBackend; +use codex_cloud_tasks_api::DiffSummary; +use codex_cloud_tasks_api::TaskId; +use codex_cloud_tasks_api::TaskSummary; +use throbber_widgets_tui::ThrobberState; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::UnboundedSender; + +#[derive(Default)] +pub struct App { + pub tasks: Vec, + pub selected: usize, + pub status: String, + pub diff_overlay: Option, + pub pending_apply: Option<(TaskId, String)>, + pub throbber: ThrobberState, + pub refresh_inflight: bool, + pub details_inflight: bool, + // Environment filter state + pub env_filter: Option, + pub env_modal: Option, + pub environments: Vec, + pub env_last_loaded: Option, + pub env_loading: bool, + pub env_error: Option, + // New Task page + pub new_task: Option, + // Background enrichment coordination + pub list_generation: u64, + pub in_flight: std::collections::HashSet, + pub summary_cache: std::collections::HashMap, + pub no_diff_yet: std::collections::HashSet, +} + +impl App { + pub fn new() -> Self { + Self { + tasks: Vec::new(), + selected: 0, + status: "Press r to refresh".to_string(), + diff_overlay: None, + pending_apply: None, + throbber: ThrobberState::default(), + refresh_inflight: false, + details_inflight: false, + env_filter: None, + env_modal: None, + environments: Vec::new(), + env_last_loaded: None, + env_loading: false, + env_error: None, + new_task: None, + list_generation: 0, + in_flight: std::collections::HashSet::new(), + summary_cache: std::collections::HashMap::new(), + no_diff_yet: std::collections::HashSet::new(), + } + } + + pub fn next(&mut self) { + if self.tasks.is_empty() { + return; + } + self.selected = (self.selected + 1).min(self.tasks.len().saturating_sub(1)); + } + + pub fn prev(&mut self) { + if self.tasks.is_empty() { + return; + } + if self.selected > 0 { + self.selected -= 1; + } + } +} + +pub async fn load_tasks( + backend: &dyn CloudBackend, + env: Option<&str>, +) -> anyhow::Result> { + // In later milestones, add a small debounce, spinner, and error display. + let tasks = tokio::time::timeout(Duration::from_secs(5), backend.list_tasks(env)).await??; + Ok(tasks) +} + +pub struct DiffOverlay { + pub title: String, + pub task_id: TaskId, + pub sd: ScrollableDiff, + pub can_apply: bool, +} + +/// Internal app events delivered from background tasks. +/// These let the UI event loop remain responsive and keep the spinner animating. +#[derive(Debug)] +pub enum AppEvent { + TasksLoaded { + env: Option, + result: anyhow::Result>, + }, + /// Background diff summary computed for a task (or determined absent) + TaskSummaryUpdated { + generation: u64, + id: TaskId, + summary: DiffSummary, + no_diff_yet: bool, + environment_id: Option, + }, + /// Autodetection of a likely environment id finished + EnvironmentAutodetected(anyhow::Result), + /// Background completion of environment list fetch + EnvironmentsLoaded(anyhow::Result>), + DetailsDiffLoaded { + id: TaskId, + title: String, + diff: String, + }, + DetailsMessagesLoaded { + id: TaskId, + title: String, + messages: Vec, + }, + DetailsFailed { + id: TaskId, + title: String, + error: String, + }, + /// Background completion of new task submission + NewTaskSubmitted(Result), +} + +pub type AppEventTx = UnboundedSender; +pub type AppEventRx = UnboundedReceiver; + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + struct FakeBackend { + // maps env key to titles + by_env: std::collections::HashMap, Vec<&'static str>>, + } + + #[async_trait::async_trait] + impl codex_cloud_tasks_api::CloudBackend for FakeBackend { + async fn list_tasks( + &self, + env: Option<&str>, + ) -> codex_cloud_tasks_api::Result> { + let key = env.map(|s| s.to_string()); + let titles = self + .by_env + .get(&key) + .cloned() + .unwrap_or_else(|| vec!["default-a", "default-b"]); + let mut out = Vec::new(); + for (i, t) in titles.into_iter().enumerate() { + out.push(TaskSummary { + id: TaskId(format!("T-{}", i)), + title: t.to_string(), + status: codex_cloud_tasks_api::TaskStatus::Ready, + updated_at: Utc::now(), + environment_id: env.map(|s| s.to_string()), + environment_label: None, + summary: codex_cloud_tasks_api::DiffSummary::default(), + }); + } + Ok(out) + } + + async fn get_task_diff(&self, _id: TaskId) -> codex_cloud_tasks_api::Result { + Err(codex_cloud_tasks_api::Error::Unimplemented( + "not used in test", + )) + } + + async fn get_task_messages( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_api::Result> { + Ok(vec![]) + } + + async fn apply_task( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_api::Result { + Err(codex_cloud_tasks_api::Error::Unimplemented( + "not used in test", + )) + } + + async fn create_task( + &self, + _env_id: &str, + _prompt: &str, + _git_ref: &str, + _qa_mode: bool, + ) -> codex_cloud_tasks_api::Result { + Err(codex_cloud_tasks_api::Error::Unimplemented( + "not used in test", + )) + } + } + + #[tokio::test] + async fn load_tasks_uses_env_parameter() { + // Arrange: env-specific task titles + let mut by_env = std::collections::HashMap::new(); + by_env.insert(None, vec!["root-1", "root-2"]); + by_env.insert(Some("env-A".to_string()), vec!["A-1"]); + by_env.insert(Some("env-B".to_string()), vec!["B-1", "B-2", "B-3"]); + let backend = FakeBackend { by_env }; + + // Act + Assert + let root = load_tasks(&backend, None).await.unwrap(); + assert_eq!(root.len(), 2); + assert_eq!(root[0].title, "root-1"); + + let a = load_tasks(&backend, Some("env-A")).await.unwrap(); + assert_eq!(a.len(), 1); + assert_eq!(a[0].title, "A-1"); + + let b = load_tasks(&backend, Some("env-B")).await.unwrap(); + assert_eq!(b.len(), 3); + assert_eq!(b[2].title, "B-3"); + } +} diff --git a/codex-rs/cloud-tasks/src/bin/conncheck.rs b/codex-rs/cloud-tasks/src/bin/conncheck.rs new file mode 100644 index 0000000000..a0b7ddcf72 --- /dev/null +++ b/codex-rs/cloud-tasks/src/bin/conncheck.rs @@ -0,0 +1,135 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use std::time::Duration; + +use base64::Engine; +use codex_backend_client::Client as BackendClient; +use codex_core::config::find_codex_home; +use codex_core::default_client::get_codex_user_agent; +use codex_login::AuthManager; +use codex_login::AuthMode; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Base URL (default to ChatGPT backend API) + let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + println!("base_url: {base_url}"); + let path_style = if base_url.contains("/backend-api") { + "wham" + } else { + "codex-api" + }; + println!("path_style: {path_style}"); + + // Locate CODEX_HOME and try to load ChatGPT auth + let codex_home = match find_codex_home() { + Ok(p) => { + println!("codex_home: {}", p.display()); + Some(p) + } + Err(e) => { + println!("codex_home: ({e})"); + None + } + }; + + // Build backend client with UA + let ua = get_codex_user_agent(Some("codex_cloud_tasks_conncheck")); + let mut client = BackendClient::new(base_url.clone())?.with_user_agent(ua); + + // Attach bearer token if available from ChatGPT auth + let mut have_auth = false; + if let Some(home) = codex_home { + let authm = AuthManager::new( + home, + AuthMode::ChatGPT, + "codex_cloud_tasks_conncheck".to_string(), + ); + if let Some(auth) = authm.auth() { + match auth.get_token().await { + Ok(token) if !token.is_empty() => { + have_auth = true; + println!("auth: ChatGPT token present ({} chars)", token.len()); + // Add Authorization header + client = client.with_bearer_token(&token); + + // Attempt to extract ChatGPT account id from the JWT and set header. + if let Some(account_id) = extract_chatgpt_account_id(&token) { + println!("auth: ChatGPT-Account-Id: {account_id}"); + client = client.with_chatgpt_account_id(account_id); + } else if let Some(acc) = auth.get_account_id() { + // Fallback: some older auth.jsons persist account_id + println!("auth: ChatGPT-Account-Id (from auth.json): {acc}"); + client = client.with_chatgpt_account_id(acc); + } + } + Ok(_) => { + println!("auth: ChatGPT token empty"); + } + Err(e) => { + println!("auth: failed to load ChatGPT token: {e}"); + } + } + } else { + println!("auth: no ChatGPT auth.json"); + } + } + + if !have_auth { + println!("note: Online endpoints typically require ChatGPT sign-in. Run: `codex login`"); + } + + // Attempt the /list call with a short timeout to avoid hanging + match path_style { + "wham" => println!("request: GET /wham/tasks/list?limit=5&task_filter=current"), + _ => println!("request: GET /api/codex/tasks/list?limit=5&task_filter=current"), + } + let fut = client.list_tasks(Some(5), Some("current"), None); + let res = tokio::time::timeout(Duration::from_secs(30), fut).await; + match res { + Err(_) => { + println!("error: request timed out after 30s"); + std::process::exit(2); + } + Ok(Err(e)) => { + // backend-client includes HTTP status and body in errors. + println!("error: {e}"); + std::process::exit(1); + } + Ok(Ok(list)) => { + println!("ok: received {} tasks", list.items.len()); + for item in list.items.iter().take(5) { + println!("- {} — {}", item.id, item.title); + } + // Print the full response object for debugging/inspection. + match serde_json::to_string_pretty(&list) { + Ok(json) => { + println!("\nfull response object (pretty JSON):\n{}", json); + } + Err(e) => { + println!("failed to serialize response to JSON: {e}"); + } + } + } + } + + Ok(()) +} + +fn extract_chatgpt_account_id(token: &str) -> Option { + // JWT: header.payload.signature + let mut parts = token.split('.'); + let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => return None, + }; + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + v.get("https://api.openai.com/auth") + .and_then(|auth| auth.get("chatgpt_account_id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) +} diff --git a/codex-rs/cloud-tasks/src/bin/detailcheck.rs b/codex-rs/cloud-tasks/src/bin/detailcheck.rs new file mode 100644 index 0000000000..3672a19c17 --- /dev/null +++ b/codex-rs/cloud-tasks/src/bin/detailcheck.rs @@ -0,0 +1,50 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use codex_backend_client::Client as BackendClient; +use codex_core::config::find_codex_home; +use codex_core::default_client::get_codex_user_agent; +use codex_login::AuthManager; +use codex_login::AuthMode; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + let ua = get_codex_user_agent(Some("codex_cloud_tasks_detailcheck")); + let mut client = BackendClient::new(base_url)?.with_user_agent(ua); + + if let Ok(home) = find_codex_home() { + let am = AuthManager::new( + home, + AuthMode::ChatGPT, + "codex_cloud_tasks_detailcheck".to_string(), + ); + if let Some(auth) = am.auth() { + if let Ok(tok) = auth.get_token().await { + client = client.with_bearer_token(tok); + } + } + } + + let list = client.list_tasks(Some(5), Some("current"), None).await?; + println!("items: {}", list.items.len()); + for item in list.items.iter().take(5) { + println!("item: {} {}", item.id, item.title); + let (details, body, ct) = client.get_task_details_with_body(&item.id).await?; + let diff = codex_backend_client::CodeTaskDetailsResponseExt::unified_diff(&details); + match diff { + Some(d) => println!( + "unified diff len={} sample=\n{}", + d.len(), + &d.lines().take(10).collect::>().join("\n") + ), + None => { + println!( + "no unified diff found; ct={ct}; body sample=\n{}", + &body.chars().take(5000).collect::() + ); + } + } + } + Ok(()) +} diff --git a/codex-rs/cloud-tasks/src/bin/envcheck.rs b/codex-rs/cloud-tasks/src/bin/envcheck.rs new file mode 100644 index 0000000000..b119743ed0 --- /dev/null +++ b/codex-rs/cloud-tasks/src/bin/envcheck.rs @@ -0,0 +1,141 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use base64::Engine; +use clap::Parser; +use codex_core::config::find_codex_home; +use codex_core::default_client::get_codex_user_agent; +use codex_login::AuthManager; +use codex_login::AuthMode; +use reqwest::header::AUTHORIZATION; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; + +#[derive(Debug, Parser)] +#[command(version, about = "Resolve Codex environment id (debug helper)")] +struct Args { + /// Optional override for environment id; if present we just echo it. + #[arg(long = "env-id")] + environment_id: Option, + /// Optional label to select a matching environment (case-insensitive exact match). + #[arg(long = "env-label")] + environment_label: Option, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + // Base URL (default to ChatGPT backend API) with normalization + let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + while base_url.ends_with('/') { + base_url.pop(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{}/backend-api", base_url); + } + println!("base_url: {base_url}"); + println!( + "path_style: {}", + if base_url.contains("/backend-api") { + "wham" + } else { + "codex-api" + } + ); + + // Build headers: UA + ChatGPT auth if available + let ua = get_codex_user_agent(Some("codex_cloud_tasks_envcheck")); + let mut headers = HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), + ); + + // Locate CODEX_HOME and try to load ChatGPT auth + if let Ok(home) = find_codex_home() { + println!("codex_home: {}", home.display()); + let authm = AuthManager::new( + home, + AuthMode::ChatGPT, + "codex_cloud_tasks_envcheck".to_string(), + ); + if let Some(auth) = authm.auth() { + match auth.get_token().await { + Ok(token) if !token.is_empty() => { + println!("auth: ChatGPT token present ({} chars)", token.len()); + let value = format!("Bearer {}", token); + if let Ok(hv) = HeaderValue::from_str(&value) { + headers.insert(AUTHORIZATION, hv); + } + if let Some(account_id) = auth + .get_account_id() + .or_else(|| extract_chatgpt_account_id(&token)) + { + println!("auth: ChatGPT-Account-Id: {account_id}"); + if let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") { + if let Ok(hv) = HeaderValue::from_str(&account_id) { + headers.insert(name, hv); + } + } + } + } + Ok(_) => println!("auth: ChatGPT token empty"), + Err(e) => println!("auth: failed to load ChatGPT token: {e}"), + } + } else { + println!("auth: no ChatGPT auth.json"); + } + } else { + println!("codex_home: "); + } + + // If user supplied an environment id, just echo it and exit. + if let Some(id) = args.environment_id { + println!("env: provided env-id={id}"); + return Ok(()); + } + + // Auto-detect environment id using shared env_detect + match codex_cloud_tasks::env_detect::autodetect_environment_id( + &base_url, + &headers, + args.environment_label, + ) + .await + { + Ok(sel) => { + println!( + "env: selected environment_id={} label={}", + sel.id, + sel.label.unwrap_or_else(|| "".to_string()) + ); + Ok(()) + } + Err(e) => { + println!("env: failed: {e}"); + std::process::exit(2) + } + } +} + +fn extract_chatgpt_account_id(token: &str) -> Option { + // JWT: header.payload.signature + let mut parts = token.split('.'); + let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => return None, + }; + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + v.get("https://api.openai.com/auth") + .and_then(|auth| auth.get("chatgpt_account_id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) +} diff --git a/codex-rs/cloud-tasks/src/bin/newtask.rs b/codex-rs/cloud-tasks/src/bin/newtask.rs new file mode 100644 index 0000000000..72a1f7c8b8 --- /dev/null +++ b/codex-rs/cloud-tasks/src/bin/newtask.rs @@ -0,0 +1,211 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use base64::Engine; +use clap::Parser; +use codex_core::config::find_codex_home; +use codex_core::default_client::get_codex_user_agent; +use codex_login::AuthManager; +use codex_login::AuthMode; +use reqwest::header::AUTHORIZATION; +use reqwest::header::CONTENT_TYPE; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; + +#[derive(Debug, Parser)] +#[command(version, about = "Create a new Codex cloud task (debug helper)")] +struct Args { + /// Optional override for environment id; if absent we auto-detect. + #[arg(long = "env-id")] + environment_id: Option, + /// Optional label match for environment selection (case-insensitive, exact match). + #[arg(long = "env-label")] + environment_label: Option, + /// Branch or ref to use (e.g., main) + #[arg(long = "ref", default_value = "main")] + git_ref: String, + /// Run environment in QA (ask) mode + #[arg(long = "qa-mode", default_value_t = false)] + qa_mode: bool, + /// Task prompt text + #[arg(required = true)] + prompt: Vec, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let prompt = args.prompt.join(" "); + + // Base URL (default to ChatGPT backend API) + let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + while base_url.ends_with('/') { + base_url.pop(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{}/backend-api", base_url); + } + println!("base_url: {base_url}"); + let is_wham = base_url.contains("/backend-api"); + println!("path_style: {}", if is_wham { "wham" } else { "codex-api" }); + + // Build headers: UA + ChatGPT auth if available + let ua = get_codex_user_agent(Some("codex_cloud_tasks_newtask")); + let mut headers = HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), + ); + let mut have_auth = false; + // Locate CODEX_HOME and try to load ChatGPT auth + if let Ok(home) = find_codex_home() { + let authm = AuthManager::new( + home, + AuthMode::ChatGPT, + "codex_cloud_tasks_newtask".to_string(), + ); + if let Some(auth) = authm.auth() { + match auth.get_token().await { + Ok(token) if !token.is_empty() => { + have_auth = true; + println!("auth: ChatGPT token present ({} chars)", token.len()); + let value = format!("Bearer {}", token); + if let Ok(hv) = HeaderValue::from_str(&value) { + headers.insert(AUTHORIZATION, hv); + } + if let Some(account_id) = auth + .get_account_id() + .or_else(|| extract_chatgpt_account_id(&token)) + { + println!("auth: ChatGPT-Account-Id: {account_id}"); + if let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") { + if let Ok(hv) = HeaderValue::from_str(&account_id) { + headers.insert(name, hv); + } + } + } + } + Ok(_) => println!("auth: ChatGPT token empty"), + Err(e) => println!("auth: failed to load ChatGPT token: {e}"), + } + } else { + println!("auth: no ChatGPT auth.json"); + } + } + if !have_auth { + println!("note: Online endpoints typically require ChatGPT sign-in. Run: `codex login`"); + } + + // Determine environment id: prefer flag, then by-repo lookup, then full list. + let env_id = if let Some(id) = args.environment_id.clone() { + println!("env: using provided env-id={id}"); + id + } else { + match codex_cloud_tasks::env_detect::autodetect_environment_id( + &base_url, + &headers, + args.environment_label.clone(), + ) + .await + { + Ok(sel) => sel.id, + Err(e) => { + println!("env: failed to auto-detect environment: {e}"); + std::process::exit(2); + } + } + }; + println!("env: selected environment_id={env_id}"); + + // Build request payload patterned after VSCode: POST /wham/tasks + let url = if is_wham { + format!("{}/wham/tasks", base_url) + } else { + format!("{}/api/codex/tasks", base_url) + }; + println!( + "request: POST {}", + url.strip_prefix(&base_url).unwrap_or(&url) + ); + + // input_items + let mut input_items: Vec = Vec::new(); + input_items.push(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{ "content_type": "text", "text": prompt }] + })); + + // Optional: starting diff via env var for quick testing + if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF") { + if !diff.is_empty() { + input_items.push(serde_json::json!({ + "type": "pre_apply_patch", + "output_diff": { "diff": diff } + })); + } + } + + let request_body = serde_json::json!({ + "new_task": { + "environment_id": env_id, + "branch": args.git_ref, + "run_environment_in_qa_mode": args.qa_mode, + }, + "input_items": input_items, + }); + + let http = reqwest::Client::builder().build()?; + let res = http + .post(&url) + .headers(headers) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .json(&request_body) + .send() + .await?; + + let status = res.status(); + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + println!("status: {}", status); + println!("content-type: {}", ct); + match serde_json::from_str::(&body) { + Ok(v) => println!( + "response (pretty JSON):\n{}", + serde_json::to_string_pretty(&v).unwrap_or(body) + ), + Err(_) => println!("response (raw):\n{}", body), + } + + if !status.is_success() { + // Exit non-zero on failure + std::process::exit(1); + } + Ok(()) +} + +fn extract_chatgpt_account_id(token: &str) -> Option { + // JWT: header.payload.signature + let mut parts = token.split('.'); + let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => return None, + }; + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + v.get("https://api.openai.com/auth") + .and_then(|auth| auth.get("chatgpt_account_id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) +} diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs new file mode 100644 index 0000000000..81125aeb1c --- /dev/null +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -0,0 +1,9 @@ +use clap::Parser; +use codex_common::CliConfigOverrides; + +#[derive(Parser, Debug, Default)] +#[command(version)] +pub struct Cli { + #[clap(skip)] + pub config_overrides: CliConfigOverrides, +} diff --git a/codex-rs/cloud-tasks/src/env_detect.rs b/codex-rs/cloud-tasks/src/env_detect.rs new file mode 100644 index 0000000000..e3987dc881 --- /dev/null +++ b/codex-rs/cloud-tasks/src/env_detect.rs @@ -0,0 +1,380 @@ +use reqwest::header::CONTENT_TYPE; +use reqwest::header::HeaderMap; +use std::collections::HashMap; +use tracing::info; +use tracing::warn; + +#[derive(Debug, Clone, serde::Deserialize)] +struct CodeEnvironment { + id: String, + #[serde(default)] + label: Option, + #[serde(default)] + is_pinned: Option, + #[serde(default)] + task_count: Option, +} + +#[derive(Debug, Clone)] +pub struct AutodetectSelection { + pub id: String, + pub label: Option, +} + +pub async fn autodetect_environment_id( + base_url: &str, + headers: &HeaderMap, + desired_label: Option, +) -> anyhow::Result { + // 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode) + let origins = get_git_origins(); + crate::append_error_log(format!("env: git origins: {:?}", origins)); + let mut by_repo_envs: Vec = Vec::new(); + for origin in &origins { + if let Some((owner, repo)) = parse_owner_repo(origin) { + let url = if base_url.contains("/backend-api") { + format!( + "{}/wham/environments/by-repo/{}/{}/{}", + base_url, "github", owner, repo + ) + } else { + format!( + "{}/api/codex/environments/by-repo/{}/{}/{}", + base_url, "github", owner, repo + ) + }; + crate::append_error_log(format!("env: GET {}", url)); + match get_json::>(&url, headers).await { + Ok(mut list) => { + crate::append_error_log(format!( + "env: by-repo returned {} env(s) for {}/{}", + list.len(), + owner, + repo + )); + by_repo_envs.append(&mut list); + } + Err(e) => crate::append_error_log(format!( + "env: by-repo fetch failed for {}/{}: {e}", + owner, repo + )), + } + } + } + if let Some(env) = pick_environment_row(&by_repo_envs, desired_label.as_deref()) { + return Ok(AutodetectSelection { + id: env.id.clone(), + label: env.label.clone(), + }); + } + + // 2) Fallback to the full list + let list_url = if base_url.contains("/backend-api") { + format!("{}/wham/environments", base_url) + } else { + format!("{}/api/codex/environments", base_url) + }; + crate::append_error_log(format!("env: GET {}", list_url)); + // Fetch and log the full environments JSON for debugging + let http = reqwest::Client::builder().build()?; + let res = http.get(&list_url).headers(headers.clone()).send().await?; + let status = res.status(); + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + crate::append_error_log(format!("env: status={} content-type={}", status, ct)); + match serde_json::from_str::(&body) { + Ok(v) => { + let pretty = serde_json::to_string_pretty(&v).unwrap_or(body.clone()); + crate::append_error_log(format!("env: /environments JSON (pretty):\n{}", pretty)); + } + Err(_) => crate::append_error_log(format!("env: /environments (raw):\n{}", body)), + } + if !status.is_success() { + anyhow::bail!(format!( + "GET {} failed: {}; content-type={}; body={}", + list_url, status, ct, body + )); + } + let all_envs: Vec = serde_json::from_str(&body).map_err(|e| { + anyhow::anyhow!(format!( + "Decode error for {}: {}; content-type={}; body={}", + list_url, e, ct, body + )) + })?; + if let Some(env) = pick_environment_row(&all_envs, desired_label.as_deref()) { + return Ok(AutodetectSelection { + id: env.id.clone(), + label: env.label.clone(), + }); + } + anyhow::bail!("no environments available") +} + +fn pick_environment_row( + envs: &[CodeEnvironment], + desired_label: Option<&str>, +) -> Option { + if envs.is_empty() { + return None; + } + if let Some(label) = desired_label { + let lc = label.to_lowercase(); + if let Some(e) = envs + .iter() + .find(|e| e.label.as_deref().unwrap_or("").to_lowercase() == lc) + { + crate::append_error_log(format!("env: matched by label: {} -> {}", label, e.id)); + return Some(e.clone()); + } + } + if envs.len() == 1 { + crate::append_error_log("env: single environment available; selecting it"); + return Some(envs[0].clone()); + } + if let Some(e) = envs.iter().find(|e| e.is_pinned.unwrap_or(false)) { + crate::append_error_log(format!("env: selecting pinned environment: {}", e.id)); + return Some(e.clone()); + } + // Highest task_count as heuristic + if let Some(e) = envs + .iter() + .max_by_key(|e| e.task_count.unwrap_or(0)) + .or_else(|| envs.first()) + { + crate::append_error_log(format!("env: selecting by task_count/first: {}", e.id)); + return Some(e.clone()); + } + None +} + +async fn get_json( + url: &str, + headers: &HeaderMap, +) -> anyhow::Result { + let http = reqwest::Client::builder().build()?; + let res = http.get(url).headers(headers.clone()).send().await?; + let status = res.status(); + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + crate::append_error_log(format!("env: status={} content-type={}", status, ct)); + if !status.is_success() { + anyhow::bail!(format!( + "GET {url} failed: {status}; content-type={ct}; body={body}" + )); + } + let parsed = serde_json::from_str::(&body).map_err(|e| { + anyhow::anyhow!(format!( + "Decode error for {url}: {e}; content-type={ct}; body={body}" + )) + })?; + Ok(parsed) +} + +fn get_git_origins() -> Vec { + // Prefer: git config --get-regexp remote\..*\.url + let out = std::process::Command::new("git") + .args(["config", "--get-regexp", "remote\\..*\\.url"]) + .output(); + if let Ok(ok) = out { + if ok.status.success() { + let s = String::from_utf8_lossy(&ok.stdout); + let mut urls = Vec::new(); + for line in s.lines() { + if let Some((_, url)) = line.split_once(' ') { + urls.push(url.trim().to_string()); + } + } + if !urls.is_empty() { + return uniq(urls); + } + } + } + // Fallback: git remote -v + let out = std::process::Command::new("git") + .args(["remote", "-v"]) + .output(); + if let Ok(ok) = out { + if ok.status.success() { + let s = String::from_utf8_lossy(&ok.stdout); + let mut urls = Vec::new(); + for line in s.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + urls.push(parts[1].to_string()); + } + } + if !urls.is_empty() { + return uniq(urls); + } + } + } + Vec::new() +} + +fn uniq(mut v: Vec) -> Vec { + v.sort(); + v.dedup(); + v +} + +fn parse_owner_repo(url: &str) -> Option<(String, String)> { + // Normalize common prefixes and handle multiple SSH/HTTPS variants. + let mut s = url.trim().to_string(); + // Drop protocol scheme for ssh URLs + if let Some(rest) = s.strip_prefix("ssh://") { + s = rest.to_string(); + } + // Accept any user before @github.com (e.g., git@, org-123@) + if let Some(idx) = s.find("@github.com:") { + let rest = &s[idx + "@github.com:".len()..]; + let rest = rest.trim_start_matches('/').trim_end_matches(".git"); + let mut parts = rest.splitn(2, '/'); + let owner = parts.next()?.to_string(); + let repo = parts.next()?.to_string(); + crate::append_error_log(format!( + "env: parsed SSH GitHub origin => {}/{}", + owner, repo + )); + return Some((owner, repo)); + } + // HTTPS or git protocol + for prefix in [ + "https://github.com/", + "http://github.com/", + "git://github.com/", + "github.com/", + ] { + if let Some(rest) = s.strip_prefix(prefix) { + let rest = rest.trim_start_matches('/').trim_end_matches(".git"); + let mut parts = rest.splitn(2, '/'); + let owner = parts.next()?.to_string(); + let repo = parts.next()?.to_string(); + crate::append_error_log(format!( + "env: parsed HTTP GitHub origin => {}/{}", + owner, repo + )); + return Some((owner, repo)); + } + } + None +} + +/// List environments for the current repo(s) with a fallback to the global list. +/// Returns a de-duplicated, sorted set suitable for the TUI modal. +pub async fn list_environments( + base_url: &str, + headers: &HeaderMap, +) -> anyhow::Result> { + let mut map: HashMap = HashMap::new(); + + // 1) By-repo lookup for each parsed GitHub origin + let origins = get_git_origins(); + for origin in &origins { + if let Some((owner, repo)) = parse_owner_repo(origin) { + let url = if base_url.contains("/backend-api") { + format!( + "{}/wham/environments/by-repo/{}/{}/{}", + base_url, "github", owner, repo + ) + } else { + format!( + "{}/api/codex/environments/by-repo/{}/{}/{}", + base_url, "github", owner, repo + ) + }; + match get_json::>(&url, headers).await { + Ok(list) => { + info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len()); + for e in list { + let entry = + map.entry(e.id.clone()) + .or_insert_with(|| crate::app::EnvironmentRow { + id: e.id.clone(), + label: e.label.clone(), + is_pinned: e.is_pinned.unwrap_or(false), + repo_hints: Some(format!("{}/{}", owner, repo)), + }); + // Merge: keep label if present, or use new; accumulate pinned flag + if entry.label.is_none() { + entry.label = e.label.clone(); + } + entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false); + if entry.repo_hints.is_none() { + entry.repo_hints = Some(format!("{}/{}", owner, repo)); + } + } + } + Err(e) => { + warn!( + "env_tui: by-repo fetch failed for {}/{}: {}", + owner, repo, e + ); + } + } + } + } + + // 2) Fallback to the full list; on error return what we have if any. + let list_url = if base_url.contains("/backend-api") { + format!("{}/wham/environments", base_url) + } else { + format!("{}/api/codex/environments", base_url) + }; + match get_json::>(&list_url, headers).await { + Ok(list) => { + info!("env_tui: global list -> {} envs", list.len()); + for e in list { + let entry = map + .entry(e.id.clone()) + .or_insert_with(|| crate::app::EnvironmentRow { + id: e.id.clone(), + label: e.label.clone(), + is_pinned: e.is_pinned.unwrap_or(false), + repo_hints: None, + }); + if entry.label.is_none() { + entry.label = e.label.clone(); + } + entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false); + } + } + Err(e) => { + if map.is_empty() { + return Err(e); + } else { + warn!( + "env_tui: global list failed; using by-repo results only: {}", + e + ); + } + } + } + + let mut rows: Vec = map.into_values().collect(); + rows.sort_by(|a, b| { + // pinned first + let p = b.is_pinned.cmp(&a.is_pinned); + if p != std::cmp::Ordering::Equal { + return p; + } + // then label (ci), then id + let al = a.label.as_deref().unwrap_or("").to_lowercase(); + let bl = b.label.as_deref().unwrap_or("").to_lowercase(); + let l = al.cmp(&bl); + if l != std::cmp::Ordering::Equal { + return l; + } + a.id.cmp(&b.id) + }); + Ok(rows) +} diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs new file mode 100644 index 0000000000..70ed64215b --- /dev/null +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -0,0 +1,1190 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +mod app; +mod cli; +pub mod env_detect; +mod new_task; +pub mod scrollable_diff; +mod ui; +pub use cli::Cli; + +use base64::Engine as _; +use chrono::Utc; +use std::fs::OpenOptions; +use std::io::IsTerminal; +use std::io::Write as _; +use std::path::PathBuf; +use std::time::Duration; +use tracing::info; +use tracing_subscriber::EnvFilter; + +pub(crate) fn append_error_log(message: impl AsRef) { + let ts = Utc::now().to_rfc3339(); + if let Ok(mut f) = OpenOptions::new() + .create(true) + .append(true) + .open("error.log") + { + let _ = writeln!(f, "[{ts}] {}", message.as_ref()); + } +} + +/// Summarize a unified or codex-style patch into counts for UI display. +fn summarize_diff(patch: &str) -> codex_cloud_tasks_api::DiffSummary { + // Count +/- lines via simple prefix scan (skip headers) + let (mut adds, mut dels) = (0usize, 0usize); + for line in patch.lines() { + if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") { + continue; + } + match line.as_bytes().first().copied() { + Some(b'+') => adds += 1, + Some(b'-') => dels += 1, + _ => {} + } + } + + // Count files: prefer codex patch file ops, else git diff headers, else infer 1 if any changes. + let mut files = 0usize; + for line in patch.lines() { + if line.starts_with("*** Add File:") + || line.starts_with("*** Update File:") + || line.starts_with("*** Delete File:") + { + files += 1; + } + } + if files == 0 { + files = patch + .lines() + .filter(|l| l.starts_with("diff --git ")) + .count(); + } + if files == 0 && (adds > 0 || dels > 0) { + files = 1; + } + + codex_cloud_tasks_api::DiffSummary { + files_changed: files, + lines_added: adds, + lines_removed: dels, + } +} + +/// Entry point for the `codex cloud-tasks` subcommand. +pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { + // Very minimal logging setup; mirrors other crates' pattern. + let default_level = "error"; + let _ = tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(default_level)) + .unwrap_or_else(|_| EnvFilter::new(default_level)), + ) + .with_ansi(std::io::stderr().is_terminal()) + .with_writer(std::io::stderr) + .try_init(); + + info!("Launching Cloud Tasks list UI"); + + // Default to online unless explicitly configured to use mock. + let use_mock = matches!( + std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), + Some("mock") | Some("MOCK") + ); + + use std::sync::Arc; + let backend: Arc = if use_mock { + Arc::new(codex_cloud_tasks_client::MockClient) + } else { + // Build an HTTP client against the configured (or default) base URL. + let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); + let mut http = + codex_cloud_tasks_client::HttpClient::new(base_url.clone())?.with_user_agent(ua); + // Log which base URL and path style we're going to use. + let style = if base_url.contains("/backend-api") { + "wham" + } else { + "codex-api" + }; + append_error_log(format!("startup: base_url={base_url} path_style={style}")); + + // Require ChatGPT login (SWIC). Exit with a clear message if missing. + let token = match codex_core::config::find_codex_home() + .ok() + .map(|home| { + codex_login::AuthManager::new( + home, + codex_login::AuthMode::ChatGPT, + "codex_cloud_tasks_tui".to_string(), + ) + }) + .and_then(|am| am.auth()) + { + Some(auth) => { + // Log account context for debugging workspace selection. + if let Some(acc) = auth.get_account_id() { + append_error_log(format!( + "auth: mode=ChatGPT account_id={acc} plan={}", + auth.get_plan_type() + .unwrap_or_else(|| "".to_string()) + )); + } + match auth.get_token().await { + Ok(t) if !t.is_empty() => { + // Attach token and ChatGPT-Account-Id header if available + http = http.with_bearer_token(t.clone()); + if let Some(acc) = auth + .get_account_id() + .or_else(|| extract_chatgpt_account_id(&t)) + { + append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); + http = http.with_chatgpt_account_id(acc); + } + t + } + _ => { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud-tasks'." + ); + std::process::exit(1); + } + } + } + None => { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud-tasks'." + ); + std::process::exit(1); + } + }; + Arc::new(http) + }; + + // Terminal setup + use crossterm::ExecutableCommand; + use crossterm::event::KeyboardEnhancementFlags; + use crossterm::event::PopKeyboardEnhancementFlags; + use crossterm::event::PushKeyboardEnhancementFlags; + use crossterm::terminal::EnterAlternateScreen; + use crossterm::terminal::LeaveAlternateScreen; + use crossterm::terminal::disable_raw_mode; + use crossterm::terminal::enable_raw_mode; + use ratatui::Terminal; + use ratatui::backend::CrosstermBackend; + let mut stdout = std::io::stdout(); + enable_raw_mode()?; + stdout.execute(EnterAlternateScreen)?; + // Enable enhanced key reporting so Shift+Enter is distinguishable from Enter. + // Some terminals may not support these flags; ignore errors if enabling fails. + let _ = crossterm::execute!( + std::io::stdout(), + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS + ) + ); + let backend_ui = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend_ui)?; + terminal.clear()?; + + // App state + let mut app = app::App::new(); + // Initial load + let force_internal = matches!( + std::env::var("CODEX_CLOUD_TASKS_FORCE_INTERNAL") + .ok() + .as_deref(), + Some("1") | Some("true") | Some("TRUE") + ); + append_error_log(format!( + "startup: wham_force_internal={} ua={}", + force_internal, + codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")) + )); + // Non-blocking initial load so the in-box spinner can animate + app.status = "Loading tasks…".to_string(); + app.refresh_inflight = true; + // New list generation; reset background enrichment coordination + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + app.no_diff_yet.clear(); + + // Event stream + use crossterm::event::Event; + use crossterm::event::EventStream; + use crossterm::event::KeyCode; + use crossterm::event::KeyEventKind; + use crossterm::event::KeyModifiers; + use tokio_stream::StreamExt; + let mut events = EventStream::new(); + + // Channel for non-blocking background loads + use tokio::sync::mpsc::unbounded_channel; + let (tx, mut rx) = unbounded_channel::(); + // Kick off the initial load in background + { + let backend2 = backend.clone(); + let tx2 = tx.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend2, None).await; + let _ = tx2.send(app::AppEvent::TasksLoaded { + env: None, + result: res, + }); + }); + } + // Fetch environment list in parallel so the header can show friendly names quickly. + { + let tx2 = tx.clone(); + tokio::spawn(async move { + let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + while base_url.ends_with('/') { + base_url.pop(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{}/backend-api", base_url); + } + let ua = + codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_str(&ua) + .unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")), + ); + if let Ok(home) = codex_core::config::find_codex_home() { + let am = codex_login::AuthManager::new( + home, + codex_login::AuthMode::ChatGPT, + "codex_cloud_tasks_tui".to_string(), + ); + if let Some(auth) = am.auth() { + if let Ok(tok) = auth.get_token().await { + if !tok.is_empty() { + let v = format!("Bearer {}", tok); + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { + headers.insert(reqwest::header::AUTHORIZATION, hv); + } + if let Some(acc) = auth + .get_account_id() + .or_else(|| extract_chatgpt_account_id(&tok)) + { + if let Ok(name) = + reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") + { + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { + headers.insert(name, hv); + } + } + } + } + } + } + } + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into()))); + }); + } + + // Try to auto-detect a likely environment id on startup and refresh if found. + // Do this concurrently so the initial list shows quickly; on success we refetch with filter. + { + let tx2 = tx.clone(); + tokio::spawn(async move { + // Normalize base URL like envcheck.rs does + let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + while base_url.ends_with('/') { + base_url.pop(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{}/backend-api", base_url); + } + + // Build headers: UA + ChatGPT auth if available + let ua = + codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_str(&ua) + .unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")), + ); + if let Ok(home) = codex_core::config::find_codex_home() { + let am = codex_login::AuthManager::new( + home, + codex_login::AuthMode::ChatGPT, + "codex_cloud_tasks_tui".to_string(), + ); + if let Some(auth) = am.auth() { + if let Ok(token) = auth.get_token().await { + if !token.is_empty() { + if let Ok(hv) = + reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token)) + { + headers.insert(reqwest::header::AUTHORIZATION, hv); + } + if let Some(account_id) = auth + .get_account_id() + .or_else(|| extract_chatgpt_account_id(&token)) + { + if let Ok(name) = + reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") + { + if let Ok(hv) = + reqwest::header::HeaderValue::from_str(&account_id) + { + headers.insert(name, hv); + } + } + } + } + } + } + } + + // Run autodetect. If it fails, we keep using "All". + let res = crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await; + let _ = tx2.send(app::AppEvent::EnvironmentAutodetected( + res.map_err(|e| e.into()), + )); + }); + } + + // Simple draw loop driven by input; keep a 250ms tick for spinner animation. + let mut needs_redraw = true; + let tick = tokio::time::interval(Duration::from_millis(250)); + tokio::pin!(tick); + + // Render helper to centralize immediate redraws after handling events. + let mut render_if_needed = |terminal: &mut Terminal>, + app: &mut app::App, + needs_redraw: &mut bool| + -> anyhow::Result<()> { + if *needs_redraw { + terminal.draw(|f| ui::draw(f, app))?; + *needs_redraw = false; + } + Ok(()) + }; + + let exit_code = loop { + tokio::select! { + _ = tick.tick() => { + // When a network request is inflight, keep the spinner moving + if app.refresh_inflight || app.details_inflight || app.env_loading { app.throbber.calc_next(); needs_redraw = true; } + // Tick is for animation; still draw here if something changed. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + maybe_app_event = rx.recv() => { + if let Some(ev) = maybe_app_event { + match ev { + app::AppEvent::TasksLoaded { env, result } => { + // Only apply results for the current filter to avoid races. + if env.as_deref() != app.env_filter.as_deref() { + append_error_log(format!( + "refresh.drop: env={} current={}", + env.clone().unwrap_or_else(|| "".to_string()), + app.env_filter.clone().unwrap_or_else(|| "".to_string()) + )); + continue; + } + app.refresh_inflight = false; + match result { + Ok(tasks) => { + append_error_log(format!( + "refresh.apply: env={} count={}", + env.clone().unwrap_or_else(|| "".to_string()), + tasks.len() + )); + app.tasks = tasks; + if app.selected >= app.tasks.len() { app.selected = app.tasks.len().saturating_sub(1); } + app.status = "Loaded tasks".to_string(); + } + Err(e) => { + append_error_log(format!("refresh load_tasks failed: {e}")); + app.status = format!("Failed to load tasks: {e}"); + } + } + needs_redraw = true; + } + app::AppEvent::NewTaskSubmitted(result) => { + match result { + Ok(created) => { + append_error_log(format!("new-task: created id={}", created.id.0)); + app.status = format!("Submitted as {}", created.id.0); + app.new_task = None; + // Refresh tasks in background for current filter + app.status = format!("Submitted as {} — refreshing…", created.id.0); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + needs_redraw = true; + let backend2 = backend.clone(); + let tx2 = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend2, env_sel.as_deref()).await; + let _ = tx2.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + Err(msg) => { + append_error_log(format!("new-task: submit failed: {}", msg)); + if let Some(page) = app.new_task.as_mut() { page.submitting = false; } + app.status = format!("Submit failed: {}. See error.log for details.", msg); + needs_redraw = true; + } + } + } + app::AppEvent::TaskSummaryUpdated { generation, id, summary, no_diff_yet, environment_id: _ } => { + // Ignore stale generations + if generation != app.list_generation { continue; } + let id_str = id.0.clone(); + if let Some(t) = app.tasks.iter_mut().find(|t| t.id.0 == id_str) { + t.summary = summary.clone(); + } + app.summary_cache.insert(id_str.clone(), (summary, std::time::Instant::now())); + if no_diff_yet { app.no_diff_yet.insert(id_str); } else { app.no_diff_yet.remove(&id.0); } + needs_redraw = true; + } + app::AppEvent::EnvironmentsLoaded(result) => { + app.env_loading = false; + match result { + Ok(list) => { + app.environments = list; + app.env_error = None; + app.env_last_loaded = Some(std::time::Instant::now()); + } + Err(e) => { + app.env_error = Some(e.to_string()); + } + } + needs_redraw = true; + } + app::AppEvent::EnvironmentAutodetected(result) => { + if let Ok(sel) = result { + // Only apply if user hasn't set a filter yet or it's different. + if app.env_filter.as_deref() != Some(sel.id.as_str()) { + append_error_log(format!( + "env.select: autodetected id={} label={}", + sel.id, + sel.label.clone().unwrap_or_else(|| "".to_string()) + )); + // Preseed environments with detected label so header can show it even before list arrives + if let Some(lbl) = sel.label.clone() { + let present = app.environments.iter().any(|r| r.id == sel.id); + if !present { + app.environments.push(app::EnvironmentRow { id: sel.id.clone(), label: Some(lbl), is_pinned: false, repo_hints: None }); + } + } + app.env_filter = Some(sel.id); + app.status = "Loading tasks…".to_string(); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + app.no_diff_yet.clear(); + needs_redraw = true; + let backend2 = backend.clone(); + let tx2 = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend2, env_sel.as_deref()).await; + let _ = tx2.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + // Proactively fetch environments to resolve a friendly name for the header. + app.env_loading = true; + let tx3 = tx.clone(); + tokio::spawn(async move { + // Build headers (UA + ChatGPT token + account id) like elsewhere + let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + while base_url.ends_with('/') { base_url.pop(); } + if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { + base_url = format!("{}/backend-api", base_url); + } + let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); + if let Ok(home) = codex_core::config::find_codex_home() { + let am = codex_login::AuthManager::new( + home, + codex_login::AuthMode::ChatGPT, + "codex_cloud_tasks_tui".to_string(), + ); + if let Some(auth) = am.auth() { + if let Ok(tok) = auth.get_token().await { if !tok.is_empty() { + let v = format!("Bearer {}", tok); + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } + if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) { + if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") { + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); } + } + } + }} + } + } + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx3.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into()))); + }); + } + } + // on Err, silently continue with All + } + app::AppEvent::DetailsDiffLoaded { id, title, diff } => { + // Only update if the overlay still corresponds to this id. + if let Some(ov) = &app.diff_overlay { if ov.task_id != id { continue; } } + let mut sd = crate::scrollable_diff::ScrollableDiff::new(); + sd.set_content(diff.lines().map(|s| s.to_string()).collect()); + app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: true }); + app.details_inflight = false; + app.status.clear(); + needs_redraw = true; + } + app::AppEvent::DetailsMessagesLoaded { id, title, messages } => { + if let Some(ov) = &app.diff_overlay { if ov.task_id != id { continue; } } + let mut lines = Vec::new(); + for m in messages { lines.extend(m.lines().map(|s| s.to_string())); lines.push(String::new()); } + if lines.is_empty() { lines.push("".to_string()); } + let mut sd = crate::scrollable_diff::ScrollableDiff::new(); + sd.set_content(lines); + app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: false }); + app.details_inflight = false; + app.status.clear(); + needs_redraw = true; + } + app::AppEvent::DetailsFailed { id, title, error } => { + if let Some(ov) = &app.diff_overlay { if ov.task_id != id { continue; } } + append_error_log(format!("details failed for {}: {}", id.0, error)); + let pretty = pretty_lines_from_error(&error); + let mut sd = crate::scrollable_diff::ScrollableDiff::new(); + sd.set_content(pretty); + app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: false }); + app.details_inflight = false; + needs_redraw = true; + } + } + } + // Render immediately after processing app events. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + maybe_event = events.next() => { + match maybe_event { + Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => { + // Treat Ctrl-C like pressing 'q' in the current context. + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) + { + if app.pending_apply.is_some() { + app.pending_apply = None; + app.status = "Apply canceled".to_string(); + needs_redraw = true; + } else if app.new_task.is_some() { + app.new_task = None; + app.status = "Canceled new task".to_string(); + needs_redraw = true; + } else if app.diff_overlay.is_some() { + app.diff_overlay = None; + needs_redraw = true; + } else { + break 0; + } + // Render updated state immediately before continuing to next loop iteration. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + // Render after New Task branch to reflect input changes immediately. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + // New Task page: Ctrl+O opens environment switcher while composing. + let is_ctrl_o = key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) + || matches!(key.code, KeyCode::Char('\u{000F}')); + if is_ctrl_o && app.new_task.is_some() { + // Close task modal/pending apply if present before opening env modal + app.pending_apply = None; + app.diff_overlay = None; + app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); + // Cache environments until user explicitly refreshes with 'r' inside the modal. + let should_fetch = app.environments.is_empty(); + if should_fetch { + app.env_loading = true; + app.env_error = None; + } + needs_redraw = true; + if should_fetch { + let tx2 = tx.clone(); + tokio::spawn(async move { + // Build headers (UA + ChatGPT token + account id) + let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + while base_url.ends_with('/') { base_url.pop(); } + if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { + base_url = format!("{}/backend-api", base_url); + } + let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); + if let Ok(home) = codex_core::config::find_codex_home() { + let am = codex_login::AuthManager::new( + home, + codex_login::AuthMode::ChatGPT, + "codex_cloud_tasks_tui".to_string(), + ); + if let Some(auth) = am.auth() { + if let Ok(tok) = auth.get_token().await { if !tok.is_empty() { + let v = format!("Bearer {}", tok); + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } + if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) { + if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") { + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); } + } + } + }} + } + } + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into()))); + }); + } + // Render after opening env modal to show it instantly. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + + // New Task page has priority when active, unless an env modal is open. + if let Some(page) = app.new_task.as_mut() { + if app.env_modal.is_some() { + // Defer handling to env-modal branch below. + } else { + match key.code { + KeyCode::Esc => { + app.new_task = None; + app.status = "Canceled new task".to_string(); + needs_redraw = true; + } + _ => { + if page.submitting { + // Ignore input while submitting + } else { + match page.composer.input(key) { + codex_tui::ComposerAction::Submitted(text) => { + // Submit only if we have an env id + if let Some(env) = page.env_id.clone() { + append_error_log(format!( + "new-task: submit env={} size={}", + env, + text.chars().count() + )); + page.submitting = true; + app.status = "Submitting new task…".to_string(); + let tx2 = tx.clone(); + let backend2 = backend.clone(); + tokio::spawn(async move { + let result = codex_cloud_tasks_api::CloudBackend::create_task(&*backend2, &env, &text, "main", false).await; + let evt = match result { + Ok(ok) => app::AppEvent::NewTaskSubmitted(Ok(ok)), + Err(e) => app::AppEvent::NewTaskSubmitted(Err(format!("{}", e))), + }; + let _ = tx2.send(evt); + }); + } else { + app.status = "No environment selected (press 'e' to choose)".to_string(); + } + } + _ => {} + } + } + needs_redraw = true; + } + } + continue; + } + } + // If a diff overlay is open, handle its keys first. + if app.pending_apply.is_some() { + match key.code { + KeyCode::Char('y') => { + if let Some((task_id, title)) = app.pending_apply.take() { + app.status = format!("Applying '{title}'..."); + needs_redraw = true; + match codex_cloud_tasks_api::CloudBackend::apply_task(&*backend, task_id.clone()).await { + Ok(outcome) => { + // Always surface the message in the status bar. + app.status = outcome.message.clone(); + // If the apply failed, also write a line to error.log for post-mortem debugging. + if matches!(outcome.status, codex_cloud_tasks_api::ApplyStatus::Error) { + append_error_log(format!( + "apply_task failed for {}: {}", + task_id.0, outcome.message + )); + } + // Keep the overlay open on errors/partial success so users can keep context. + // Only close and refresh on full success. + if matches!(outcome.status, codex_cloud_tasks_api::ApplyStatus::Success) { + app.diff_overlay = None; + if let Ok(tasks) = app::load_tasks(&*backend, app.env_filter.as_deref()).await { app.tasks = tasks; } + } + } + Err(e) => { + append_error_log(format!("apply_task failed for {}: {e}", task_id.0)); + app.status = format!("Apply failed: {e}"); + } + } + needs_redraw = true; + } + } + KeyCode::Esc | KeyCode::Char('n') => { + app.pending_apply = None; + app.status = "Apply canceled".to_string(); + needs_redraw = true; + } + _ => {} + } + } else if app.diff_overlay.is_some() { + match key.code { + KeyCode::Char('a') => { + if let Some(ov) = &app.diff_overlay { + if ov.can_apply { + app.pending_apply = Some((ov.task_id.clone(), ov.title.clone())); + app.status = format!("Apply '{}' ? y/N", ov.title); + } else { + app.status = "No diff available to apply".to_string(); + } + needs_redraw = true; + } + } + // From task modal, 'o' should close it and open the env selector + KeyCode::Char('o') | KeyCode::Char('O') => { + app.pending_apply = None; + app.diff_overlay = None; + app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); + // Use cached environments unless empty + if app.environments.is_empty() { app.env_loading = true; app.env_error = None; } + needs_redraw = true; + if app.environments.is_empty() { + let tx2 = tx.clone(); + tokio::spawn(async move { + // Build headers (UA + ChatGPT token + account id) + let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + while base_url.ends_with('/') { base_url.pop(); } + if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{}/backend-api", base_url); } + let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); + if let Ok(home) = codex_core::config::find_codex_home() { + let am = codex_login::AuthManager::new( + home, + codex_login::AuthMode::ChatGPT, + "codex_cloud_tasks_tui".to_string(), + ); + if let Some(auth) = am.auth() { if let Ok(tok) = auth.get_token().await { if !tok.is_empty() { + let v = format!("Bearer {}", tok); + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } + if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) { + if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") { + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); } + } + } + }}} + } + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into()))); + }); + } + } + KeyCode::Esc | KeyCode::Char('q') => { + app.diff_overlay = None; + needs_redraw = true; + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(1); } + needs_redraw = true; + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(-1); } + needs_redraw = true; + } + KeyCode::PageDown | KeyCode::Char(' ') => { + if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(step); } + needs_redraw = true; + } + KeyCode::PageUp => { + if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(-step); } + needs_redraw = true; + } + KeyCode::Home => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_top(); } needs_redraw = true; } + KeyCode::End => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_bottom(); } needs_redraw = true; } + _ => {} + } + } else if app.env_modal.is_some() { + // Environment modal key handling + match key.code { + KeyCode::Esc => { app.env_modal = None; needs_redraw = true; } + KeyCode::Char('r') | KeyCode::Char('R') => { + // Trigger refresh of environments + app.env_loading = true; app.env_error = None; needs_redraw = true; + let tx2 = tx.clone(); + tokio::spawn(async move { + // Build headers (UA + ChatGPT token + account id) + let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + while base_url.ends_with('/') { base_url.pop(); } + if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{}/backend-api", base_url); } + let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); + if let Ok(home) = codex_core::config::find_codex_home() { + let am = codex_login::AuthManager::new( + home, + codex_login::AuthMode::ChatGPT, + "codex_cloud_tasks_tui".to_string(), + ); + if let Some(auth) = am.auth() { + if let Ok(tok) = auth.get_token().await { if !tok.is_empty() { + let v = format!("Bearer {}", tok); + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } + if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) { + if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") { + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); } + } + } + }} + } + } + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into()))); + }); + } + KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => { + if let Some(m) = app.env_modal.as_mut() { m.query.push(ch); } + needs_redraw = true; + } + KeyCode::Backspace => { if let Some(m) = app.env_modal.as_mut() { m.query.pop(); } needs_redraw = true; } + KeyCode::Down | KeyCode::Char('j') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_add(1); } needs_redraw = true; } + KeyCode::Up | KeyCode::Char('k') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_sub(1); } needs_redraw = true; } + KeyCode::Home => { if let Some(m) = app.env_modal.as_mut() { m.selected = 0; } needs_redraw = true; } + KeyCode::End => { if let Some(m) = app.env_modal.as_mut() { m.selected = app.environments.len(); } needs_redraw = true; } + KeyCode::PageDown | KeyCode::Char(' ') => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_add(step); } needs_redraw = true; } + KeyCode::PageUp => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_sub(step); } needs_redraw = true; } + KeyCode::Char('n') => { + if app.env_filter.is_none() { + app.new_task = Some(crate::new_task::NewTaskPage::new(None)); + } else { + app.new_task = Some(crate::new_task::NewTaskPage::new(app.env_filter.clone())); + } + app.status = "New Task: Enter to submit; Esc to cancel".to_string(); + needs_redraw = true; + } + KeyCode::Enter => { + // Resolve selection over filtered set + if let Some(state) = app.env_modal.take() { + let q = state.query.to_lowercase(); + let mut filtered: Vec<&app::EnvironmentRow> = app.environments.iter().filter(|r| { + if q.is_empty() { return true; } + let mut hay = String::new(); + if let Some(l) = &r.label { hay.push_str(&l.to_lowercase()); hay.push(' '); } + hay.push_str(&r.id.to_lowercase()); + if let Some(h) = &r.repo_hints { hay.push(' '); hay.push_str(&h.to_lowercase()); } + hay.contains(&q) + }).collect(); + // Keep original order (already sorted) — no need to re-sort + let idx = state.selected; + if idx == 0 { app.env_filter = None; append_error_log("env.select: All"); } + else { + let env_idx = idx.saturating_sub(1); + if let Some(row) = filtered.get(env_idx) { + append_error_log(format!( + "env.select: id={} label={}", + row.id, + row.label.clone().unwrap_or_else(|| "".to_string()) + )); + app.env_filter = Some(row.id.clone()); + } + } + // If New Task page is open, reflect the new selection in its header immediately. + if let Some(page) = app.new_task.as_mut() { + page.env_id = app.env_filter.clone(); + } + // Trigger tasks refresh with the selected filter + app.status = "Loading tasks…".to_string(); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + app.no_diff_yet.clear(); + needs_redraw = true; + let backend2 = backend.clone(); + let tx2 = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend2, env_sel.as_deref()).await; + let _ = tx2.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + } + _ => {} + } + } else { + // Base list view keys + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + break 0; + } + KeyCode::Down | KeyCode::Char('j') => { + app.next(); + needs_redraw = true; + } + KeyCode::Up | KeyCode::Char('k') => { + app.prev(); + needs_redraw = true; + } + // Ensure 'r' does not refresh tasks when the env modal is open. + KeyCode::Char('r') | KeyCode::Char('R') => { + if app.env_modal.is_some() { break 0; } + append_error_log(format!( + "refresh.request: env={}", + app.env_filter.clone().unwrap_or_else(|| "".to_string()) + )); + app.status = "Refreshing…".to_string(); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + app.no_diff_yet.clear(); + needs_redraw = true; + // Spawn background refresh + let backend2 = backend.clone(); + let tx2 = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend2, env_sel.as_deref()).await; + let _ = tx2.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + KeyCode::Char('o') | KeyCode::Char('O') => { + app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); + // Cache environments until user explicitly refreshes with 'r' inside the modal. + let should_fetch = app.environments.is_empty(); + if should_fetch { app.env_loading = true; app.env_error = None; } + needs_redraw = true; + if should_fetch { + let tx2 = tx.clone(); + tokio::spawn(async move { + // Build headers (UA + ChatGPT token + account id) + let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + while base_url.ends_with('/') { base_url.pop(); } + if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{}/backend-api", base_url); } + let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); + if let Ok(home) = codex_core::config::find_codex_home() { + let am = codex_login::AuthManager::new( + home, + codex_login::AuthMode::ChatGPT, + "codex_cloud_tasks_tui".to_string(), + ); + if let Some(auth) = am.auth() { + if let Ok(tok) = auth.get_token().await { if !tok.is_empty() { + let v = format!("Bearer {}", tok); + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } + if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) { + if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") { + if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); } + } + } + }} + } + } + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into()))); + }); + } + } + KeyCode::Char('n') => { + let env_opt = app.env_filter.clone(); + app.new_task = Some(crate::new_task::NewTaskPage::new(env_opt)); + app.status = "New Task: Enter to submit; Esc to cancel".to_string(); + needs_redraw = true; + } + KeyCode::Enter => { + if let Some(task) = app.tasks.get(app.selected).cloned() { + app.status = format!("Loading details for {}…", task.title); + app.details_inflight = true; + // Open empty overlay immediately; content arrives via events + let mut sd = crate::scrollable_diff::ScrollableDiff::new(); + sd.set_content(Vec::new()); + app.diff_overlay = Some(app::DiffOverlay{ title: task.title.clone(), task_id: task.id.clone(), sd, can_apply: false }); + needs_redraw = true; + // Spawn background details load (diff first, then messages fallback) + let backend2 = backend.clone(); + let tx2 = tx.clone(); + tokio::spawn(async move { + match codex_cloud_tasks_api::CloudBackend::get_task_diff(&*backend2, task.id.clone()).await { + Ok(diff) => { + let _ = tx2.send(app::AppEvent::DetailsDiffLoaded { id: task.id, title: task.title, diff }); + } + Err(e) => { + // Always log errors while we debug non-success states. + append_error_log(format!("get_task_diff failed for {}: {e}", task.id.0)); + match codex_cloud_tasks_api::CloudBackend::get_task_messages(&*backend2, task.id.clone()).await { + Ok(msgs) => { + let _ = tx2.send(app::AppEvent::DetailsMessagesLoaded { id: task.id, title: task.title, messages: msgs }); + } + Err(e2) => { + let _ = tx2.send(app::AppEvent::DetailsFailed { id: task.id, title: task.title, error: format!("{}", e2) }); + } + } + } + } + }); + } + } + KeyCode::Char('a') => { + if let Some(task) = app.tasks.get(app.selected) { + match codex_cloud_tasks_api::CloudBackend::get_task_diff(&*backend, task.id.clone()).await { + Ok(_) => { + app.pending_apply = Some((task.id.clone(), task.title.clone())); + app.status = format!("Apply '{}' ? y/N", task.title); + } + Err(_) => { + app.status = "No diff available to apply".to_string(); + } + } + needs_redraw = true; + } + } + _ => {} + } + } + // Render after handling a key event (when not quitting). + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + Some(Ok(Event::Resize(_, _))) => { + needs_redraw = true; + // Redraw immediately on resize for snappier UX. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + Some(Err(_)) | None => {} + _ => {} + } + // Fallback: if any other event path requested a redraw, render now. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + } + }; + + // Restore terminal + disable_raw_mode().ok(); + terminal.show_cursor().ok(); + // Best-effort restore of keyboard enhancement flags before leaving alt screen. + let _ = crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags); + let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen); + + if exit_code != 0 { + std::process::exit(exit_code); + } + Ok(()) +} + +fn extract_chatgpt_account_id(token: &str) -> Option { + // JWT: header.payload.signature + let mut parts = token.split('.'); + let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => return None, + }; + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + v.get("https://api.openai.com/auth") + .and_then(|auth| auth.get("chatgpt_account_id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) +} + +/// Convert a verbose HTTP error with embedded JSON body into concise, user-friendly lines +/// for the details overlay. Falls back to a short raw message when parsing fails. +fn pretty_lines_from_error(raw: &str) -> Vec { + let mut lines: Vec = Vec::new(); + let is_no_diff = raw.contains("No output_diff in response."); + let is_no_msgs = raw.contains("No assistant text messages in response."); + if is_no_diff { + lines.push("No diff available for this task.".to_string()); + } else if is_no_msgs { + lines.push("No assistant messages found for this task.".to_string()); + } else { + lines.push("Failed to load task details.".to_string()); + } + + // Try to parse the embedded JSON body: find the first '{' after " body=" and decode. + if let Some(body_idx) = raw.find(" body=") { + if let Some(json_start_rel) = raw[body_idx..].find('{') { + let json_start = body_idx + json_start_rel; + let json_str = raw[json_start..].trim(); + if let Ok(v) = serde_json::from_str::(json_str) { + // Prefer assistant turn context. + let turn = v + .get("current_assistant_turn") + .and_then(|x| x.as_object()) + .cloned() + .or_else(|| { + v.get("current_diff_task_turn") + .and_then(|x| x.as_object()) + .cloned() + }); + if let Some(t) = turn { + if let Some(err) = t.get("error").and_then(|e| e.as_object()) { + let code = err.get("code").and_then(|s| s.as_str()).unwrap_or(""); + let msg = err.get("message").and_then(|s| s.as_str()).unwrap_or(""); + if !code.is_empty() || !msg.is_empty() { + let summary = if code.is_empty() { + msg.to_string() + } else if msg.is_empty() { + code.to_string() + } else { + format!("{code}: {msg}") + }; + lines.push(format!("Assistant error: {}", summary)); + } + } + if let Some(status) = t.get("turn_status").and_then(|s| s.as_str()) { + lines.push(format!("Status: {}", status)); + } + if let Some(text) = t + .get("latest_event") + .and_then(|e| e.get("text")) + .and_then(|s| s.as_str()) + { + if !text.trim().is_empty() { + lines.push(format!("Latest event: {}", text.trim())); + } + } + } + } + } + } + + if lines.len() == 1 { + // Parsing yielded nothing; include a trimmed, short raw message tail for context. + let tail = if raw.len() > 320 { + format!("{}…", &raw[..320]) + } else { + raw.to_string() + }; + lines.push(tail); + } else if lines.len() >= 2 { + // Add a hint to refresh when still in progress. + if lines.iter().any(|l| l.contains("in_progress")) { + lines.push("This task may still be running. Press 'r' to refresh.".to_string()); + } + // Avoid an empty overlay + lines.push(String::new()); + } + lines +} diff --git a/codex-rs/cloud-tasks/src/new_task.rs b/codex-rs/cloud-tasks/src/new_task.rs new file mode 100644 index 0000000000..2ef30ad629 --- /dev/null +++ b/codex-rs/cloud-tasks/src/new_task.rs @@ -0,0 +1,30 @@ +use codex_tui::ComposerAction; +use codex_tui::ComposerInput; + +#[derive(Default)] +pub struct NewTaskPage { + pub composer: ComposerInput, + pub submitting: bool, + pub env_id: Option, +} + +impl NewTaskPage { + pub fn new(env_id: Option) -> Self { + let mut composer = ComposerInput::new(); + composer.set_hint_items(vec![ + ("⏎", "send"), + ("Shift+⏎", "newline"), + ("Ctrl+O", "env"), + ("Ctrl+C", "quit"), + ]); + Self { + composer, + submitting: false, + env_id, + } + } + + pub fn can_submit(&self) -> bool { + self.env_id.is_some() && !self.submitting + } +} diff --git a/codex-rs/cloud-tasks/src/scrollable_diff.rs b/codex-rs/cloud-tasks/src/scrollable_diff.rs new file mode 100644 index 0000000000..7666588cd2 --- /dev/null +++ b/codex-rs/cloud-tasks/src/scrollable_diff.rs @@ -0,0 +1,178 @@ +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +/// Scroll position and geometry for a vertical scroll view. +#[derive(Clone, Copy, Debug, Default)] +pub struct ScrollViewState { + pub scroll: u16, + pub viewport_h: u16, + pub content_h: u16, +} + +impl ScrollViewState { + pub fn clamp(&mut self) { + let max_scroll = self.content_h.saturating_sub(self.viewport_h); + if self.scroll > max_scroll { + self.scroll = max_scroll; + } + } +} + +/// A simple, local scrollable view for diffs or message text. +/// +/// Owns raw lines, caches wrapped lines for a given width, and maintains +/// a small scroll state that is clamped whenever geometry shrinks. +#[derive(Clone, Debug, Default)] +pub struct ScrollableDiff { + raw: Vec, + wrapped: Vec, + wrapped_src_idx: Vec, + wrap_cols: Option, + pub state: ScrollViewState, +} + +impl ScrollableDiff { + pub fn new() -> Self { + Self::default() + } + + /// Replace the raw content lines. Does not rewrap immediately; call `set_width` next. + pub fn set_content(&mut self, lines: Vec) { + self.raw = lines; + self.wrapped.clear(); + self.wrapped_src_idx.clear(); + self.state.content_h = 0; + } + + /// Set the wrap width. If changed, rebuild wrapped lines and clamp scroll. + pub fn set_width(&mut self, width: u16) { + if self.wrap_cols == Some(width) { + return; + } + self.wrap_cols = Some(width); + self.rewrap(width); + self.state.clamp(); + } + + /// Update viewport height and clamp scroll if needed. + pub fn set_viewport(&mut self, height: u16) { + self.state.viewport_h = height; + self.state.clamp(); + } + + /// Return the cached wrapped lines. Call `set_width` first when area changes. + pub fn wrapped_lines(&self) -> &[String] { + &self.wrapped + } + + pub fn wrapped_src_indices(&self) -> &[usize] { + &self.wrapped_src_idx + } + + pub fn raw_line_at(&self, idx: usize) -> &str { + self.raw.get(idx).map(|s| s.as_str()).unwrap_or("") + } + + /// Scroll by a signed delta; clamps to content. + pub fn scroll_by(&mut self, delta: i16) { + let s = self.state.scroll as i32 + delta as i32; + self.state.scroll = s.clamp(0, self.max_scroll() as i32) as u16; + } + + /// Page by a signed delta; typically viewport_h - 1. + pub fn page_by(&mut self, delta: i16) { + self.scroll_by(delta); + } + + pub fn to_top(&mut self) { + self.state.scroll = 0; + } + + pub fn to_bottom(&mut self) { + self.state.scroll = self.max_scroll(); + } + + /// Optional percent scrolled; None when not enough geometry is known. + pub fn percent_scrolled(&self) -> Option { + if self.state.content_h == 0 || self.state.viewport_h == 0 { + return None; + } + if self.state.content_h <= self.state.viewport_h { + return None; + } + let visible_bottom = self.state.scroll.saturating_add(self.state.viewport_h) as f32; + let pct = (visible_bottom / self.state.content_h as f32 * 100.0).round(); + Some(pct.clamp(0.0, 100.0) as u8) + } + + fn max_scroll(&self) -> u16 { + self.state.content_h.saturating_sub(self.state.viewport_h) + } + + fn rewrap(&mut self, width: u16) { + if width == 0 { + self.wrapped = self.raw.clone(); + self.state.content_h = self.wrapped.len() as u16; + return; + } + let max_cols = width as usize; + let mut out: Vec = Vec::new(); + let mut out_idx: Vec = Vec::new(); + for (raw_idx, raw) in self.raw.iter().enumerate() { + // Normalize tabs for width accounting (MVP: 4 spaces). + let raw = raw.replace('\t', " "); + if raw.is_empty() { + out.push(String::new()); + out_idx.push(raw_idx); + continue; + } + let mut line = String::new(); + let mut line_cols = 0usize; + let mut last_soft_idx: Option = None; // last whitespace or punctuation break + for (_i, ch) in raw.char_indices() { + if ch == '\n' { + out.push(std::mem::take(&mut line)); + out_idx.push(raw_idx); + line_cols = 0; + last_soft_idx = None; + continue; + } + let w = UnicodeWidthChar::width(ch).unwrap_or(0); + if line_cols.saturating_add(w) > max_cols { + if let Some(split) = last_soft_idx { + let (prefix, rest) = line.split_at(split); + out.push(prefix.trim_end().to_string()); + out_idx.push(raw_idx); + line = rest.trim_start().to_string(); + line_cols = UnicodeWidthStr::width(line.as_str()); + last_soft_idx = None; + // retry add current ch now that line may be shorter + } else { + if !line.is_empty() { + out.push(std::mem::take(&mut line)); + out_idx.push(raw_idx); + line_cols = 0; + } + } + } + if ch.is_whitespace() + || matches!( + ch, + ',' | ';' | '.' | ':' | ')' | ']' | '}' | '|' | '/' | '?' | '!' | '-' | '_' + ) + { + last_soft_idx = Some(line.len()); + } + line.push(ch); + line_cols = UnicodeWidthStr::width(line.as_str()); + } + if !line.is_empty() { + out.push(line); + out_idx.push(raw_idx); + } + } + self.wrapped = out; + self.wrapped_src_idx = out_idx; + self.state.content_h = self.wrapped.len() as u16; + } +} diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs new file mode 100644 index 0000000000..7911b5ab05 --- /dev/null +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -0,0 +1,660 @@ +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Clear; +use ratatui::widgets::List; +use ratatui::widgets::ListItem; +use ratatui::widgets::ListState; +use ratatui::widgets::Padding; +use ratatui::widgets::Paragraph; +use std::sync::OnceLock; + +use crate::app::App; +use chrono::Local; +use chrono::Utc; +use codex_cloud_tasks_api::TaskStatus; + +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // list + Constraint::Length(2), // two-line footer (help + status) + ]) + .split(area); + if app.new_task.is_some() { + draw_new_task_page(frame, chunks[0], app); + draw_footer(frame, chunks[1], app); + } else { + draw_list(frame, chunks[0], app); + draw_footer(frame, chunks[1], app); + } + + if app.diff_overlay.is_some() { + draw_diff_overlay(frame, area, app); + } + if app.env_modal.is_some() { + draw_env_modal(frame, area, app); + } +} + +// ===== Overlay helpers (geometry + styling) ===== +static ROUNDED: OnceLock = OnceLock::new(); + +fn rounded_enabled() -> bool { + *ROUNDED.get_or_init(|| { + std::env::var("CODEX_TUI_ROUNDED") + .ok() + .map(|v| v == "1") + .unwrap_or(true) + }) +} + +fn overlay_outer(area: Rect) -> Rect { + let outer_v = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(10), + Constraint::Percentage(80), + Constraint::Percentage(10), + ]) + .split(area)[1]; + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(10), + Constraint::Percentage(80), + Constraint::Percentage(10), + ]) + .split(outer_v)[1] +} + +fn overlay_block() -> Block<'static> { + let base = Block::default().borders(Borders::ALL); + let base = if rounded_enabled() { + base.border_type(BorderType::Rounded) + } else { + base + }; + base.padding(Padding::new(2, 2, 1, 1)) +} + +fn overlay_content(area: Rect) -> Rect { + overlay_block().inner(area) +} + +pub fn draw_new_task_page(frame: &mut Frame, area: Rect, app: &mut App) { + use ratatui::widgets::Wrap; + + let title_spans = { + let mut spans: Vec = vec!["New Task".magenta().bold()]; + if let Some(id) = app + .new_task + .as_ref() + .and_then(|p| p.env_id.as_ref()) + .cloned() + { + spans.push(" • ".into()); + // Try to map id to label + let label = app + .environments + .iter() + .find(|r| r.id == id) + .and_then(|r| r.label.clone()) + .unwrap_or(id); + spans.push(label.dim()); + } else { + spans.push(" • ".into()); + spans.push("Env: none (press ctrl-o to choose)".red()); + } + spans + }; + let block = Block::default() + .borders(Borders::ALL) + .title(Line::from(title_spans)); + + frame.render_widget(Clear, area); + frame.render_widget(block.clone(), area); + let content = block.inner(area); + + // Clamp composer height between 3 and 6 rows for readability. + let desired = app + .new_task + .as_ref() + .map(|p| p.composer.desired_height(content.width)) + .unwrap_or(3) + .clamp(3, 6); + + // Anchor the composer to the bottom-left by allocating a flexible spacer + // above it and a fixed `desired`-height area for the composer. + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(desired)]) + .split(content); + let composer_area = rows[1]; + + if let Some(page) = app.new_task.as_ref() { + page.composer.render_ref(composer_area, frame.buffer_mut()); + // Composer renders its own footer hints; no extra row here. + } + + // Place cursor where composer wants it + if let Some(page) = app.new_task.as_ref() { + if let Some((x, y)) = page.composer.cursor_pos(composer_area) { + frame.set_cursor(x, y); + } + } +} + +fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) { + let items: Vec = app.tasks.iter().map(|t| render_task_item(app, t)).collect(); + + // Selection reflects the actual task index (no artificial spacer item). + let mut state = ListState::default().with_selected(Some(app.selected)); + // Dim task list when a modal/overlay is active to emphasize focus. + let dim_bg = app.env_modal.is_some() || app.diff_overlay.is_some(); + // Dynamic title includes current environment filter + let suffix_span = if let Some(ref id) = app.env_filter { + let label = app + .environments + .iter() + .find(|r| &r.id == id) + .and_then(|r| r.label.clone()) + .unwrap_or_else(|| "Selected".to_string()); + format!(" • {}", label).dim() + } else { + " • All".dim() + }; + // Percent scrolled based on selection position in the list (0% at top, 100% at bottom). + let percent_span = if app.tasks.len() <= 1 { + " • 0%".dim() + } else { + let p = ((app.selected as f32) / ((app.tasks.len() - 1) as f32) * 100.0).round() as i32; + format!(" • {}%", p.clamp(0, 100)).dim() + }; + let title_line = { + let base = Line::from(vec!["Cloud Tasks".into(), suffix_span, percent_span]); + if dim_bg { + base.style(Style::default().add_modifier(Modifier::DIM)) + } else { + base + } + }; + let block = Block::default().borders(Borders::ALL).title(title_line); + // Render the outer block first + frame.render_widget(block.clone(), area); + // Draw list inside with a persistent top spacer row + let inner = block.inner(area); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(inner); + let mut list = List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().bold()); + if dim_bg { + list = list.style(Style::default().add_modifier(Modifier::DIM)); + } + frame.render_stateful_widget(list, rows[1], &mut state); + + // In-box spinner during initial/refresh loads + if app.refresh_inflight { + draw_centered_spinner(frame, inner, &mut app.throbber, "Loading tasks…"); + } +} + +fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) { + let mut help = vec![ + "↑/↓".dim(), + ": Move ".dim(), + "r".dim(), + ": Refresh ".dim(), + "Enter".dim(), + ": Open ".dim(), + ]; + // Apply hint; show disabled note when overlay is open without a diff. + if let Some(ov) = app.diff_overlay.as_ref() { + if !ov.can_apply { + help.push("a".dim()); + help.push(": Apply (disabled) ".dim()); + } else { + help.push("a".dim()); + help.push(": Apply ".dim()); + } + } else { + help.push("a".dim()); + help.push(": Apply ".dim()); + } + help.push("o : Set Env ".dim()); + if app.new_task.is_some() { + help.push("(editing new task) ".dim()); + } else { + help.push("n : New Task ".dim()); + } + help.extend(vec!["q".dim(), ": Quit ".dim()]); + // Split footer area into two rows: help+spinner (top) and status (bottom) + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)]) + .split(area); + + // Top row: help text + spinner at right + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Fill(1), Constraint::Length(18)]) + .split(rows[0]); + let para = Paragraph::new(Line::from(help)); + // Draw help text; avoid clearing the whole footer area every frame. + frame.render_widget(para, top[0]); + // Right side: spinner or clear the spinner area if idle to prevent stale glyphs. + if app.refresh_inflight || app.details_inflight || app.env_loading { + draw_inline_spinner(frame, top[1], &mut app.throbber, "Loading…"); + } else { + frame.render_widget(Clear, top[1]); + } + + // Bottom row: status/log text across full width (single-line; sanitize newlines) + let mut status_line = app.status.replace('\n', " "); + if status_line.len() > 2000 { + // hard cap to avoid TUI noise + status_line.truncate(2000); + status_line.push_str("…"); + } + // Clear the status row to avoid trailing characters when the message shrinks. + frame.render_widget(Clear, rows[1]); + let status = Paragraph::new(status_line); + frame.render_widget(status, rows[1]); +} + +fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { + // Centered overlay rect (deduped geometry) and padded content via helpers. + let inner = overlay_outer(area); + + if app.diff_overlay.is_none() { + return; + } + let is_error = if let Some(overlay) = app.diff_overlay.as_ref() { + !overlay.can_apply + && overlay + .sd + .wrapped_lines() + .first() + .map(|s| s.trim_start().starts_with("Task failed:")) + .unwrap_or(false) + } else { + false + }; + let can_apply = app + .diff_overlay + .as_ref() + .map(|o| o.can_apply) + .unwrap_or(false); + let title = app + .diff_overlay + .as_ref() + .map(|o| o.title.clone()) + .unwrap_or_default(); + // Ensure ScrollableDiff knows geometry using padded content area. + let content_area = overlay_content(inner); + if let Some(ov) = app.diff_overlay.as_mut() { + ov.sd.set_width(content_area.width); + ov.sd.set_viewport(content_area.height); + } + + // Optional percent scrolled label + let pct_opt = app + .diff_overlay + .as_ref() + .and_then(|o| o.sd.percent_scrolled()); + let mut title_spans: Vec = if is_error { + vec![ + "Details ".magenta(), + "[FAILED]".red().bold(), + " ".into(), + title.clone().magenta(), + ] + } else if can_apply { + vec!["Diff: ".magenta(), title.clone().magenta()] + } else { + vec!["Details: ".magenta(), title.clone().magenta()] + }; + if let Some(p) = pct_opt { + title_spans.push(" • ".dim()); + title_spans.push(format!("{}%", p).dim()); + } + let block = overlay_block().title(Line::from(title_spans)); + frame.render_widget(Clear, inner); + frame.render_widget(block.clone(), inner); + + let styled_lines: Vec> = if can_apply { + let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines()); + raw.unwrap_or(&[]) + .iter() + .map(|l| style_diff_line(l)) + .collect() + } else { + // Basic markdown styling for assistant messages + let mut in_code = false; + let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines()); + raw.unwrap_or(&[]) + .iter() + .map(|raw| { + if raw.trim_start().starts_with("```") { + in_code = !in_code; + return Line::from(raw.to_string().cyan()); + } + if in_code { + return Line::from(raw.to_string().cyan()); + } + let s = raw.trim_start(); + if s.starts_with("### ") || s.starts_with("## ") || s.starts_with("# ") { + return Line::from(raw.to_string().magenta().bold()); + } + if s.starts_with("- ") || s.starts_with("* ") { + let rest = &s[2..]; + return Line::from(vec!["• ".into(), rest.to_string().into()]); + } + Line::from(raw.to_string()) + }) + .collect() + }; + let raw_empty = app + .diff_overlay + .as_ref() + .map(|o| o.sd.wrapped_lines().is_empty()) + .unwrap_or(true); + if app.details_inflight && raw_empty { + // Show a centered spinner while loading details + draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…"); + } else { + // We pre-wrapped lines in ScrollableDiff; do not enable Paragraph-level wrapping here. + let scroll = app + .diff_overlay + .as_ref() + .map(|o| o.sd.state.scroll) + .unwrap_or(0); + let content = Paragraph::new(Text::from(styled_lines)).scroll((scroll, 0)); + frame.render_widget(content, content_area); + } +} + +fn style_diff_line(raw: &str) -> Line<'static> { + use ratatui::style::Color; + use ratatui::style::Modifier; + use ratatui::style::Style; + use ratatui::text::Span; + + if raw.starts_with("@@") { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )]); + } + if raw.starts_with("+++") || raw.starts_with("---") { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default().add_modifier(Modifier::DIM), + )]); + } + if raw.starts_with('+') { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default().fg(Color::Green), + )]); + } + if raw.starts_with('-') { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default().fg(Color::Red), + )]); + } + Line::from(vec![Span::raw(raw.to_string())]) +} + +fn render_task_item(app: &App, t: &codex_cloud_tasks_api::TaskSummary) -> ListItem<'static> { + let status = match t.status { + TaskStatus::Ready => "READY".green(), + TaskStatus::Pending => "PENDING".magenta(), + TaskStatus::Applied => "APPLIED".blue(), + TaskStatus::Error => "ERROR".red(), + }; + + // Title line: [STATUS] Title + let title = Line::from(vec![ + "[".into(), + status, + "] ".into(), + t.title.clone().into(), + ]); + + // Meta line: environment label and relative time (dim) + let mut meta: Vec = Vec::new(); + if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) { + meta.push(lbl.clone().dim()); + } + let when = format_relative_time(t.updated_at).dim(); + if !meta.is_empty() { + meta.push(" ".into()); + meta.push("•".dim()); + meta.push(" ".into()); + } + meta.push(when); + let meta_line = Line::from(meta); + + // Subline: summary when present; otherwise show "no diff" + let sub = if t.summary.files_changed > 0 + || t.summary.lines_added > 0 + || t.summary.lines_removed > 0 + { + let adds = t.summary.lines_added; + let dels = t.summary.lines_removed; + let files = t.summary.files_changed; + Line::from(vec![ + format!("+{}", adds).green(), + "/".into(), + format!("−{}", dels).red(), + " ".into(), + "•".dim(), + " ".into(), + format!("{}", files).into(), + " ".into(), + "files".dim(), + ]) + } else { + Line::from("no diff".to_string().dim()) + }; + + // Insert a blank spacer line after the summary to separate tasks + let spacer = Line::from(""); + ListItem::new(vec![title, meta_line, sub, spacer]) +} + +fn format_relative_time(ts: chrono::DateTime) -> String { + let now = Utc::now(); + let mut secs = (now - ts).num_seconds(); + if secs < 0 { + secs = 0; + } + if secs < 60 { + return format!("{}s ago", secs); + } + let mins = secs / 60; + if mins < 60 { + return format!("{}m ago", mins); + } + let hours = mins / 60; + if hours < 24 { + return format!("{}h ago", hours); + } + let local = ts.with_timezone(&Local); + local.format("%b %e %H:%M").to_string() +} + +fn draw_inline_spinner( + frame: &mut Frame, + area: Rect, + state: &mut throbber_widgets_tui::ThrobberState, + label: &str, +) { + use ratatui::style::Style; + use throbber_widgets_tui::BRAILLE_EIGHT; + use throbber_widgets_tui::Throbber; + use throbber_widgets_tui::WhichUse; + let w = Throbber::default() + .label(label) + .style(Style::default().cyan()) + .throbber_style(Style::default().magenta().bold()) + .throbber_set(BRAILLE_EIGHT) + .use_type(WhichUse::Spin); + frame.render_stateful_widget(w, area, state); +} + +fn draw_centered_spinner( + frame: &mut Frame, + area: Rect, + state: &mut throbber_widgets_tui::ThrobberState, + label: &str, +) { + // Center a 1xN throbber within the given rect + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(50), + Constraint::Length(1), + Constraint::Percentage(49), + ]) + .split(area); + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), + Constraint::Length(18), + Constraint::Percentage(50), + ]) + .split(rows[1]); + draw_inline_spinner(frame, cols[1], state, label); +} + +fn style_diff_fragment(src_line: &str, fragment: &str) -> Line<'static> { + if src_line.starts_with("@@") { + return Line::from(fragment.to_string().magenta().bold()); + } + if src_line.starts_with("diff --git ") || src_line.starts_with("index ") { + return Line::from(fragment.to_string().dim()); + } + if src_line.starts_with("+++") || src_line.starts_with("---") { + return Line::from(fragment.to_string().dim()); + } + match src_line.as_bytes().first().copied() { + Some(b'+') => Line::from(fragment.to_string().green()), + Some(b'-') => Line::from(fragment.to_string().red()), + _ => Line::from(fragment.to_string()), + } +} + +pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) { + use ratatui::widgets::Wrap; + + // Use shared overlay geometry and padding. + let inner = overlay_outer(area); + + // Title: primary only; move long hints to a subheader inside content. + let title = Line::from(vec!["Select Environment".magenta().bold()]); + let block = overlay_block().title(title); + + frame.render_widget(Clear, inner); + frame.render_widget(block.clone(), inner); + let content = overlay_content(inner); + + if app.env_loading { + draw_centered_spinner(frame, content, &mut app.throbber, "Loading environments…"); + return; + } + + // Layout: subheader + search + results list + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // subheader + Constraint::Length(1), // search + Constraint::Min(1), // list + ]) + .split(content); + + // Subheader with usage hints (dim cyan) + let subheader = Paragraph::new(Line::from( + "Type to search, Enter select, Esc cancel; r refresh" + .cyan() + .dim(), + )) + .wrap(Wrap { trim: true }); + frame.render_widget(subheader, rows[0]); + + let query = app + .env_modal + .as_ref() + .map(|m| m.query.clone()) + .unwrap_or_default(); + let ql = query.to_lowercase(); + let search = Paragraph::new(format!("Search: {}", query)).wrap(Wrap { trim: true }); + frame.render_widget(search, rows[1]); + + // Filter environments by query (case-insensitive substring over label/id/hints) + let envs: Vec<&crate::app::EnvironmentRow> = app + .environments + .iter() + .filter(|e| { + if ql.is_empty() { + return true; + } + let mut hay = String::new(); + if let Some(l) = &e.label { + hay.push_str(&l.to_lowercase()); + hay.push(' '); + } + hay.push_str(&e.id.to_lowercase()); + if let Some(h) = &e.repo_hints { + hay.push(' '); + hay.push_str(&h.to_lowercase()); + } + hay.contains(&ql) + }) + .collect(); + + let mut items: Vec = Vec::new(); + items.push(ListItem::new(Line::from("All Environments (Global)"))); + for env in envs.iter() { + let primary = env.label.clone().unwrap_or_else(|| "".to_string()); + let mut spans: Vec = vec![primary.into()]; + if env.is_pinned { + spans.push(" ".into()); + spans.push("PINNED".magenta().bold()); + } + spans.push(" ".into()); + spans.push(env.id.clone().dim()); + if let Some(hint) = &env.repo_hints { + spans.push(" ".into()); + spans.push(hint.clone().dim()); + } + items.push(ListItem::new(Line::from(spans))); + } + + let sel_desired = app.env_modal.as_ref().map(|m| m.selected).unwrap_or(0); + let sel = sel_desired.min(envs.len()); + let mut list_state = ListState::default().with_selected(Some(sel)); + let list = List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().bold()) + .block(Block::default().borders(Borders::NONE)); + frame.render_stateful_widget(list, rows[2], &mut list_state); +} diff --git a/codex-rs/cloud-tasks/tests/env_filter.rs b/codex-rs/cloud-tasks/tests/env_filter.rs new file mode 100644 index 0000000000..26c6d9d8e3 --- /dev/null +++ b/codex-rs/cloud-tasks/tests/env_filter.rs @@ -0,0 +1,24 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use codex_cloud_tasks_api::CloudBackend; +use codex_cloud_tasks_client::MockClient; + +#[tokio::test] +async fn mock_backend_varies_by_env() { + let client = MockClient::default(); + + let root = CloudBackend::list_tasks(&client, None).await.unwrap(); + assert!(root.iter().any(|t| t.title.contains("Update README"))); + + let a = CloudBackend::list_tasks(&client, Some("env-A")) + .await + .unwrap(); + assert_eq!(a.len(), 1); + assert_eq!(a[0].title, "A: First"); + + let b = CloudBackend::list_tasks(&client, Some("env-B")) + .await + .unwrap(); + assert_eq!(b.len(), 2); + assert!(b[0].title.starts_with("B: ")); +} diff --git a/codex-rs/codex-backend-openapi-models/Cargo.toml b/codex-rs/codex-backend-openapi-models/Cargo.toml new file mode 100644 index 0000000000..72a0a7ebae --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-backend-openapi-models" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_backend_openapi_models" +path = "src/lib.rs" + +# Important: generated code often violates our workspace lints. +# Allow unwrap/expect in this crate so the workspace builds cleanly +# after models are regenerated. +# Lint overrides are applied in src/lib.rs via crate attributes + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["serde"] } diff --git a/codex-rs/codex-backend-openapi-models/src/lib.rs b/codex-rs/codex-backend-openapi-models/src/lib.rs new file mode 100644 index 0000000000..f9e6d52fa8 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/lib.rs @@ -0,0 +1,6 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +// Re-export generated OpenAPI models. +// The regen script populates `src/models/*.rs` and writes `src/models/mod.rs`. +// This module intentionally contains no hand-written types. +pub mod models; diff --git a/codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs b/codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs new file mode 100644 index 0000000000..725b3a3737 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs @@ -0,0 +1,42 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct CodeTaskDetailsResponse { + #[serde(rename = "task")] + pub task: Box, + #[serde(rename = "current_user_turn", skip_serializing_if = "Option::is_none")] + pub current_user_turn: Option>, + #[serde( + rename = "current_assistant_turn", + skip_serializing_if = "Option::is_none" + )] + pub current_assistant_turn: Option>, + #[serde( + rename = "current_diff_task_turn", + skip_serializing_if = "Option::is_none" + )] + pub current_diff_task_turn: Option>, +} + +impl CodeTaskDetailsResponse { + pub fn new(task: models::TaskResponse) -> CodeTaskDetailsResponse { + CodeTaskDetailsResponse { + task: Box::new(task), + current_user_turn: None, + current_assistant_turn: None, + current_diff_task_turn: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs b/codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs new file mode 100644 index 0000000000..92b56db2ca --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs @@ -0,0 +1,40 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExternalPullRequestResponse { + #[serde(rename = "id")] + pub id: String, + #[serde(rename = "assistant_turn_id")] + pub assistant_turn_id: String, + #[serde(rename = "pull_request")] + pub pull_request: Box, + #[serde(rename = "codex_updated_sha", skip_serializing_if = "Option::is_none")] + pub codex_updated_sha: Option, +} + +impl ExternalPullRequestResponse { + pub fn new( + id: String, + assistant_turn_id: String, + pull_request: models::GitPullRequest, + ) -> ExternalPullRequestResponse { + ExternalPullRequestResponse { + id, + assistant_turn_id, + pull_request: Box::new(pull_request), + codex_updated_sha: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs b/codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs new file mode 100644 index 0000000000..a7e995f3ef --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs @@ -0,0 +1,77 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GitPullRequest { + #[serde(rename = "number")] + pub number: i32, + #[serde(rename = "url")] + pub url: String, + #[serde(rename = "state")] + pub state: String, + #[serde(rename = "merged")] + pub merged: bool, + #[serde(rename = "mergeable")] + pub mergeable: bool, + #[serde(rename = "draft", skip_serializing_if = "Option::is_none")] + pub draft: Option, + #[serde(rename = "title", skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(rename = "body", skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(rename = "base", skip_serializing_if = "Option::is_none")] + pub base: Option, + #[serde(rename = "head", skip_serializing_if = "Option::is_none")] + pub head: Option, + #[serde(rename = "base_sha", skip_serializing_if = "Option::is_none")] + pub base_sha: Option, + #[serde(rename = "head_sha", skip_serializing_if = "Option::is_none")] + pub head_sha: Option, + #[serde(rename = "merge_commit_sha", skip_serializing_if = "Option::is_none")] + pub merge_commit_sha: Option, + #[serde(rename = "comments", skip_serializing_if = "Option::is_none")] + pub comments: Option, + #[serde(rename = "diff", skip_serializing_if = "Option::is_none")] + pub diff: Option, + #[serde(rename = "user", skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +impl GitPullRequest { + pub fn new( + number: i32, + url: String, + state: String, + merged: bool, + mergeable: bool, + ) -> GitPullRequest { + GitPullRequest { + number, + url, + state, + merged, + mergeable, + draft: None, + title: None, + body: None, + base: None, + head: None, + base_sha: None, + head_sha: None, + merge_commit_sha: None, + comments: None, + diff: None, + user: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs new file mode 100644 index 0000000000..e2cb972f10 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -0,0 +1,22 @@ +// Curated minimal export list for current workspace usage. +// NOTE: This file was previously auto-generated by the OpenAPI generator. +// Currently export only the types referenced by the workspace +// The process for this will change + +pub mod code_task_details_response; +pub use self::code_task_details_response::CodeTaskDetailsResponse; + +pub mod task_response; +pub use self::task_response::TaskResponse; + +pub mod external_pull_request_response; +pub use self::external_pull_request_response::ExternalPullRequestResponse; + +pub mod git_pull_request; +pub use self::git_pull_request::GitPullRequest; + +pub mod task_list_item; +pub use self::task_list_item::TaskListItem; + +pub mod paginated_list_task_list_item_; +pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem; diff --git a/codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs b/codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs new file mode 100644 index 0000000000..5af75afaab --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs @@ -0,0 +1,30 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct PaginatedListTaskListItem { + #[serde(rename = "items")] + pub items: Vec, + #[serde(rename = "cursor", skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl PaginatedListTaskListItem { + pub fn new(items: Vec) -> PaginatedListTaskListItem { + PaginatedListTaskListItem { + items, + cursor: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs b/codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs new file mode 100644 index 0000000000..5f34738a43 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs @@ -0,0 +1,63 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct TaskListItem { + #[serde(rename = "id")] + pub id: String, + #[serde(rename = "title")] + pub title: String, + #[serde( + rename = "has_generated_title", + skip_serializing_if = "Option::is_none" + )] + pub has_generated_title: Option, + #[serde(rename = "updated_at", skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde( + rename = "task_status_display", + skip_serializing_if = "Option::is_none" + )] + pub task_status_display: Option>, + #[serde(rename = "archived")] + pub archived: bool, + #[serde(rename = "has_unread_turn")] + pub has_unread_turn: bool, + #[serde(rename = "pull_requests", skip_serializing_if = "Option::is_none")] + pub pull_requests: Option>, +} + +impl TaskListItem { + pub fn new( + id: String, + title: String, + has_generated_title: Option, + archived: bool, + has_unread_turn: bool, + ) -> TaskListItem { + TaskListItem { + id, + title, + has_generated_title, + updated_at: None, + created_at: None, + task_status_display: None, + archived, + has_unread_turn, + pull_requests: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/task_response.rs b/codex-rs/codex-backend-openapi-models/src/models/task_response.rs new file mode 100644 index 0000000000..6251b56b7e --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/task_response.rs @@ -0,0 +1,62 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct TaskResponse { + #[serde(rename = "id")] + pub id: String, + #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(rename = "title")] + pub title: String, + #[serde( + rename = "has_generated_title", + skip_serializing_if = "Option::is_none" + )] + pub has_generated_title: Option, + #[serde(rename = "current_turn_id", skip_serializing_if = "Option::is_none")] + pub current_turn_id: Option, + #[serde(rename = "has_unread_turn", skip_serializing_if = "Option::is_none")] + pub has_unread_turn: Option, + #[serde( + rename = "denormalized_metadata", + skip_serializing_if = "Option::is_none" + )] + pub denormalized_metadata: Option>, + #[serde(rename = "archived")] + pub archived: bool, + #[serde(rename = "external_pull_requests")] + pub external_pull_requests: Vec, +} + +impl TaskResponse { + pub fn new( + id: String, + title: String, + archived: bool, + external_pull_requests: Vec, + ) -> TaskResponse { + TaskResponse { + id, + created_at: None, + title, + has_generated_title: None, + current_turn_id: None, + has_unread_turn: None, + denormalized_metadata: None, + archived, + external_pull_requests, + } + } +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index dd152dcbb2..9f24a43b23 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -599,7 +599,10 @@ async fn includes_user_instructions_message_in_request() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn azure_overrides_assign_properties_used_for_responses_url() { - let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" }; + let (existing_env_var_with_random_value, existing_env_var_value) = std::env::vars() + .find(|(_, value)| !value.trim().is_empty()) + .expect("system environment variable should exist"); + let auth_header_value = format!("Bearer {existing_env_var_value}"); // Mock server let server = MockServer::start().await; @@ -614,14 +617,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { .and(path("/openai/responses")) .and(query_param("api-version", "2025-04-01-preview")) .and(header_regex("Custom-Header", "Value")) - .and(header_regex( - "Authorization", - format!( - "Bearer {}", - std::env::var(existing_env_var_with_random_value).unwrap() - ) - .as_str(), - )) + .and(header_regex("Authorization", auth_header_value.as_str())) .respond_with(first) .expect(1) .mount(&server) @@ -631,7 +627,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { name: "custom".to_string(), base_url: Some(format!("{}/openai", server.uri())), // Reuse the existing environment variable to avoid using unsafe code - env_key: Some(existing_env_var_with_random_value.to_string()), + env_key: Some(existing_env_var_with_random_value.clone()), query_params: Some(std::collections::HashMap::from([( "api-version".to_string(), "2025-04-01-preview".to_string(), @@ -675,7 +671,10 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn env_var_overrides_loaded_auth() { - let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" }; + let (existing_env_var_with_random_value, existing_env_var_value) = std::env::vars() + .find(|(_, value)| !value.trim().is_empty()) + .expect("system environment variable should exist"); + let auth_header_value = format!("Bearer {existing_env_var_value}"); // Mock server let server = MockServer::start().await; @@ -690,14 +689,7 @@ async fn env_var_overrides_loaded_auth() { .and(path("/openai/responses")) .and(query_param("api-version", "2025-04-01-preview")) .and(header_regex("Custom-Header", "Value")) - .and(header_regex( - "Authorization", - format!( - "Bearer {}", - std::env::var(existing_env_var_with_random_value).unwrap() - ) - .as_str(), - )) + .and(header_regex("Authorization", auth_header_value.as_str())) .respond_with(first) .expect(1) .mount(&server) @@ -707,7 +699,7 @@ async fn env_var_overrides_loaded_auth() { name: "custom".to_string(), base_url: Some(format!("{}/openai", server.uri())), // Reuse the existing environment variable to avoid using unsafe code - env_key: Some(existing_env_var_with_random_value.to_string()), + env_key: Some(existing_env_var_with_random_value.clone()), query_params: Some(std::collections::HashMap::from([( "api-version".to_string(), "2025-04-01-preview".to_string(), diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index bca3b16420..5cc08d4205 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -30,11 +30,10 @@ pub struct ServerOptions { pub port: u16, pub open_browser: bool, pub force_state: Option, - pub originator: String, } impl ServerOptions { - pub fn new(codex_home: PathBuf, client_id: String, originator: String) -> Self { + pub fn new(codex_home: PathBuf, client_id: String) -> Self { Self { codex_home, client_id: client_id.to_string(), @@ -42,7 +41,6 @@ impl ServerOptions { port: DEFAULT_PORT, open_browser: true, force_state: None, - originator, } } } @@ -98,14 +96,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let server = Arc::new(server); let redirect_uri = format!("http://localhost:{actual_port}/auth/callback"); - let auth_url = build_authorize_url( - &opts.issuer, - &opts.client_id, - &redirect_uri, - &pkce, - &state, - &opts.originator, - ); + let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state); if opts.open_browser { let _ = webbrowser::open(&auth_url); @@ -288,7 +279,6 @@ fn build_authorize_url( redirect_uri: &str, pkce: &PkceCodes, state: &str, - originator: &str, ) -> String { let query = vec![ ("response_type", "code"), @@ -300,7 +290,6 @@ fn build_authorize_url( ("id_token_add_organizations", "true"), ("codex_cli_simplified_flow", "true"), ("state", state), - ("originator", originator), ]; let qs = query .into_iter() diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index ef6b80fbe1..ceb0a94733 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -100,7 +100,6 @@ async fn end_to_end_login_flow_persists_auth_json() { port: 0, open_browser: false, force_state: Some(state), - originator: "test_originator".to_string(), }; let server = run_login_server(opts).unwrap(); let login_port = server.actual_port; @@ -159,7 +158,6 @@ async fn creates_missing_codex_home_dir() { port: 0, open_browser: false, force_state: Some(state), - originator: "test_originator".to_string(), }; let server = run_login_server(opts).unwrap(); let login_port = server.actual_port; diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index fd82496af9..72474d9866 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -160,11 +160,7 @@ impl CodexMessageProcessor { let opts = LoginServerOptions { open_browser: false, - ..LoginServerOptions::new( - config.codex_home.clone(), - CLIENT_ID.to_string(), - config.responses_originator_header.clone(), - ) + ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string()) }; enum LoginChatGptReply { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c873ac8d55..a71c4a0b04 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -99,6 +99,8 @@ pub(crate) struct ChatComposer { // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, custom_prompts: Vec, + // Optional override for footer hint items. + footer_hint_override: Option>, } /// Popup state – at most one can be visible at any time. @@ -137,6 +139,7 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), + footer_hint_override: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -242,6 +245,10 @@ impl ChatComposer { true } + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.footer_hint_override = items; + } + pub fn handle_paste_image_path(&mut self, pasted: String) -> bool { let Some(path_buf) = normalize_pasted_path(&pasted) else { return false; @@ -1266,6 +1273,17 @@ impl WidgetRef for ChatComposer { "Ctrl+C again".set_style(key_hint_style), " to quit".into(), ] + } else if let Some(items) = &self.footer_hint_override { + let mut out: Vec = Vec::new(); + for (i, (key, label)) in items.iter().enumerate() { + out.push(Span::from(" ")); + out.push(key.as_str().set_style(key_hint_style)); + out.push(Span::from(format!(" {label}"))); + if i + 1 != items.len() { + out.push(Span::from(" ")); + } + } + out } else { let newline_hint_key = if self.use_shift_enter_hint { "Shift+⏎" diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5ab32849b1..0430e4d5e5 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -26,7 +26,7 @@ mod paste_burst; mod popup_consts; mod scroll_state; mod selection_popup_common; -mod textarea; +pub(crate) mod textarea; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 50f3e3154f..f8790a0169 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1076,6 +1076,14 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); } + if let Some(env) = cfg.env.as_ref() + && !env.is_empty() + { + let mut env_pairs: Vec = env.iter().map(|(k, v)| format!("{k}={v}")).collect(); + env_pairs.sort(); + lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); + } + if names.is_empty() { lines.push(" • Tools: (none)".into()); } else { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8899b0e139..db72f0dc6a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -46,6 +46,7 @@ mod markdown; mod markdown_stream; pub mod onboarding; mod pager_overlay; +pub mod public_widgets; mod render; mod session_log; mod shimmer; @@ -65,6 +66,8 @@ mod chatwidget_stream_tests; mod updates; pub use cli::Cli; +pub use public_widgets::composer_input::ComposerAction; +pub use public_widgets::composer_input::ComposerInput; use crate::onboarding::TrustDirectorySelection; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; @@ -312,11 +315,13 @@ async fn run_ratatui_app( if should_show_onboarding { let directory_trust_decision = run_onboarding_app( OnboardingScreenArgs { + codex_home: config.codex_home.clone(), + cwd: config.cwd.clone(), show_login_screen: should_show_login_screen(login_status, &config), show_trust_screen: should_show_trust_screen, login_status, + preferred_auth_method: config.preferred_auth_method, auth_manager: auth_manager.clone(), - config: config.clone(), }, &mut tui, ) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 90b2c994f1..c7217ac1af 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -2,7 +2,6 @@ use codex_core::AuthManager; use codex_core::auth::CLIENT_ID; -use codex_core::config::Config; use codex_login::ServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; @@ -114,7 +113,6 @@ pub(crate) struct AuthModeWidget { pub login_status: LoginStatus, pub preferred_auth_method: AuthMode, pub auth_manager: Arc, - pub config: Config, } impl AuthModeWidget { @@ -316,11 +314,7 @@ impl AuthModeWidget { } self.error = None; - let opts = ServerOptions::new( - self.codex_home.clone(), - CLIENT_ID.to_string(), - self.config.responses_originator_header.clone(), - ); + let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string()); match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index beb96bce3f..5afd393c02 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,5 +1,4 @@ use codex_core::AuthManager; -use codex_core::config::Config; use codex_core::git_info::get_git_repo_root; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -22,6 +21,7 @@ use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; use color_eyre::eyre::Result; +use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; @@ -53,25 +53,26 @@ pub(crate) struct OnboardingScreen { } pub(crate) struct OnboardingScreenArgs { + pub codex_home: PathBuf, + pub cwd: PathBuf, pub show_trust_screen: bool, pub show_login_screen: bool, pub login_status: LoginStatus, + pub preferred_auth_method: AuthMode, pub auth_manager: Arc, - pub config: Config, } impl OnboardingScreen { pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { let OnboardingScreenArgs { + codex_home, + cwd, show_trust_screen, show_login_screen, login_status, + preferred_auth_method, auth_manager, - config, } = args; - let preferred_auth_method = config.preferred_auth_method; - let cwd = config.cwd.clone(); - let codex_home = config.codex_home.clone(); let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), })]; @@ -83,9 +84,8 @@ impl OnboardingScreen { sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home.clone(), login_status, - auth_manager, preferred_auth_method, - config, + auth_manager, })) } let is_git_repo = get_git_repo_root(&cwd).is_some(); diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs new file mode 100644 index 0000000000..c11012bc71 --- /dev/null +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -0,0 +1,95 @@ +//! Public wrapper around the internal ChatComposer for simple, reusable text input. +//! +//! This exposes a minimal interface suitable for other crates (e.g., +//! codex-cloud-tasks) to reuse the mature composer behavior: multi-line input, +//! paste heuristics, Enter-to-submit, and Shift+Enter for newline. + +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::InputResult; + +/// Action returned from feeding a key event into the ComposerInput. +pub enum ComposerAction { + /// The user submitted the current text (typically via Enter). Contains the submitted text. + Submitted(String), + /// No submission occurred; UI may need to redraw if `needs_redraw()` returned true. + None, +} + +/// A minimal, public wrapper for the internal `ChatComposer` that behaves as a +/// reusable text input field with submit semantics. +pub struct ComposerInput { + inner: ChatComposer, + _tx: tokio::sync::mpsc::UnboundedSender, +} + +impl ComposerInput { + /// Create a new composer input with a neutral placeholder. + pub fn new() -> Self { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let sender = AppEventSender::new(tx.clone()); + // `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior. + let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false); + Self { inner, _tx: tx } + } + + /// Returns true if the input is empty. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Clear the input text. + pub fn clear(&mut self) { + self.inner.set_text_content(String::new()); + } + + /// Feed a key event into the composer and return a high-level action. + pub fn input(&mut self, key: KeyEvent) -> ComposerAction { + match self.inner.handle_key_event(key).0 { + InputResult::Submitted(text) => ComposerAction::Submitted(text), + _ => ComposerAction::None, + } + } + + /// Override the footer hint items displayed under the composer. + /// Each tuple is rendered as "