remote tasks

This commit is contained in:
easong-openai
2025-09-03 16:57:37 -07:00
parent e83c5f429c
commit d2fcf4314e
51 changed files with 6048 additions and 68 deletions

223
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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",

View File

@@ -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" }

View File

@@ -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<String>,
user_agent: Option<HeaderValue>,
chatgpt_account_id: Option<String>,
path_style: PathStyle,
}
impl Client {
pub fn new(base_url: impl Into<String>) -> Result<Self> {
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<String>) -> Self {
self.bearer_token = Some(token.into());
self
}
pub fn with_user_agent(mut self, ua: impl Into<String>) -> 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<String>) -> 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<i32>,
task_filter: Option<&str>,
environment_id: Option<&str>,
) -> Result<PaginatedListTaskListItem> {
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::<PaginatedListTaskListItem>(&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<CodeTaskDetailsResponse> {
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::<CodeTaskDetailsResponse>(&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<String> {
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::<serde_json::Value>(&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}");
}
}
}
}

View File

@@ -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;

View File

@@ -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<String>;
/// Extract assistant text output messages (no diff) from current turns.
fn assistant_text_messages(&self) -> Vec<String>;
/// Extract an assistant error message (if the turn failed and provided one).
fn assistant_error_message(&self) -> Option<String>;
/// 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<String> {
// `current_diff_task_turn` is an object; look for `output_items`.
// Prefer explicit diff turn; fallback to assistant turn if needed.
let candidates: [&Option<std::collections::HashMap<String, Value>>; 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<String> {
let mut out = Vec::new();
let candidates: [&Option<std::collections::HashMap<String, Value>>; 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<String> {
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<serde_json::Value>) -> 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<std::collections::HashMap<String, serde_json::Value>>; 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<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();
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<std::collections::HashMap<String, Value>>; 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
}

View File

@@ -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",

View File

@@ -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);

View File

@@ -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<PathBuf>) -> 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);

View File

@@ -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"

View File

@@ -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<T> = std::result::Result<T, Error>;
#[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<Utc>,
/// Backend environment identifier (when available)
pub environment_id: Option<String>,
/// Human-friendly environment label (when available)
pub environment_label: Option<String>,
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<String>,
#[serde(default)]
pub conflict_paths: Vec<String>,
}
#[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<Vec<TaskSummary>>;
async fn get_task_diff(&self, id: TaskId) -> Result<String>;
/// Return assistant output messages (no diff) when available.
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>>;
async fn apply_task(&self, id: TaskId) -> Result<ApplyOutcome>;
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
) -> Result<CreatedTask>;
}

View File

@@ -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 }

View File

@@ -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<String>) -> anyhow::Result<Self> {
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<String>) -> Self {
self.backend = self.backend.clone().with_bearer_token(token);
self
}
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
self.backend = self.backend.clone().with_user_agent(ua);
self
}
pub fn with_chatgpt_account_id(mut self, account_id: impl Into<String>) -> 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<Vec<TaskSummary>> {
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<TaskSummary> = 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("<all>"),
tasks.len()
));
Ok(tasks)
}
async fn get_task_diff(&self, _id: TaskId) -> Result<String> {
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<Vec<String>> {
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::<serde_json::Value>(&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<ApplyOutcome> {
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<codex_cloud_tasks_api::CreatedTask> {
// Build request payload patterned after VSCode/newtask.rs
let mut input_items: Vec<serde_json::Value> = 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<String, Value>>) -> Option<String> {
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<String, Value>>) -> 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<String, Value>>) -> 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<Utc> {
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::<Utc>::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(|| "<unknown>".to_string());
// Grab the first up-to-20 non-empty lines for context.
let head: String = patch.lines().take(20).collect::<Vec<&str>>().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}");
}
}

View File

@@ -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;

View File

@@ -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<Vec<TaskSummary>> {
// 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<String> {
Ok(mock_diff_for(&id))
}
async fn get_task_messages(&self, _id: TaskId) -> Result<Vec<String>> {
Ok(vec![
"Mock assistant output: this task contains no diff.".to_string(),
])
}
async fn apply_task(&self, id: TaskId) -> Result<ApplyOutcome> {
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<codex_cloud_tasks_api::CreatedTask> {
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)
}
}

View File

@@ -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<String>,
pub skipped_paths: Vec<String>,
pub conflict_paths: Vec<String>,
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<String> = 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<String, String> {
// 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<String> = 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<GitOutput> {
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<String> {
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<String> {
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: <mode> <sha> <stage>\t<path>
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<String> {
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<String> {
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<String> {
let mut chunks: Vec<String> = 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
}

View File

@@ -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"

View File

@@ -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<String>,
pub is_pinned: bool,
pub repo_hints: Option<String>, // 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<TaskSummary>,
pub selected: usize,
pub status: String,
pub diff_overlay: Option<DiffOverlay>,
pub pending_apply: Option<(TaskId, String)>,
pub throbber: ThrobberState,
pub refresh_inflight: bool,
pub details_inflight: bool,
// Environment filter state
pub env_filter: Option<String>,
pub env_modal: Option<EnvModalState>,
pub environments: Vec<EnvironmentRow>,
pub env_last_loaded: Option<std::time::Instant>,
pub env_loading: bool,
pub env_error: Option<String>,
// New Task page
pub new_task: Option<crate::new_task::NewTaskPage>,
// Background enrichment coordination
pub list_generation: u64,
pub in_flight: std::collections::HashSet<String>,
pub summary_cache: std::collections::HashMap<String, (DiffSummary, std::time::Instant)>,
pub no_diff_yet: std::collections::HashSet<String>,
}
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<Vec<TaskSummary>> {
// 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<String>,
result: anyhow::Result<Vec<TaskSummary>>,
},
/// Background diff summary computed for a task (or determined absent)
TaskSummaryUpdated {
generation: u64,
id: TaskId,
summary: DiffSummary,
no_diff_yet: bool,
environment_id: Option<String>,
},
/// Autodetection of a likely environment id finished
EnvironmentAutodetected(anyhow::Result<crate::env_detect::AutodetectSelection>),
/// Background completion of environment list fetch
EnvironmentsLoaded(anyhow::Result<Vec<EnvironmentRow>>),
DetailsDiffLoaded {
id: TaskId,
title: String,
diff: String,
},
DetailsMessagesLoaded {
id: TaskId,
title: String,
messages: Vec<String>,
},
DetailsFailed {
id: TaskId,
title: String,
error: String,
},
/// Background completion of new task submission
NewTaskSubmitted(Result<codex_cloud_tasks_api::CreatedTask, String>),
}
pub type AppEventTx = UnboundedSender<AppEvent>;
pub type AppEventRx = UnboundedReceiver<AppEvent>;
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
struct FakeBackend {
// maps env key to titles
by_env: std::collections::HashMap<Option<String>, 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<Vec<TaskSummary>> {
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<String> {
Err(codex_cloud_tasks_api::Error::Unimplemented(
"not used in test",
))
}
async fn get_task_messages(
&self,
_id: TaskId,
) -> codex_cloud_tasks_api::Result<Vec<String>> {
Ok(vec![])
}
async fn apply_task(
&self,
_id: TaskId,
) -> codex_cloud_tasks_api::Result<codex_cloud_tasks_api::ApplyOutcome> {
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<codex_cloud_tasks_api::CreatedTask> {
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");
}
}

View File

@@ -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: <not found> ({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<String> {
// 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())
}

View File

@@ -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::<Vec<_>>().join("\n")
),
None => {
println!(
"no unified diff found; ct={ct}; body sample=\n{}",
&body.chars().take(5000).collect::<String>()
);
}
}
}
Ok(())
}

View File

@@ -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<String>,
/// Optional label to select a matching environment (case-insensitive exact match).
#[arg(long = "env-label")]
environment_label: Option<String>,
}
#[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: <not found>");
}
// 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(|| "<none>".to_string())
);
Ok(())
}
Err(e) => {
println!("env: failed: {e}");
std::process::exit(2)
}
}
}
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
// 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())
}

View File

@@ -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<String>,
/// Optional label match for environment selection (case-insensitive, exact match).
#[arg(long = "env-label")]
environment_label: Option<String>,
/// 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<String>,
}
#[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<serde_json::Value> = 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::<serde_json::Value>(&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<String> {
// 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())
}

View File

@@ -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,
}

View File

@@ -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<String>,
#[serde(default)]
is_pinned: Option<bool>,
#[serde(default)]
task_count: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct AutodetectSelection {
pub id: String,
pub label: Option<String>,
}
pub async fn autodetect_environment_id(
base_url: &str,
headers: &HeaderMap,
desired_label: Option<String>,
) -> anyhow::Result<AutodetectSelection> {
// 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<CodeEnvironment> = 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::<Vec<CodeEnvironment>>(&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::<serde_json::Value>(&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<CodeEnvironment> = 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<CodeEnvironment> {
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<T: serde::de::DeserializeOwned>(
url: &str,
headers: &HeaderMap,
) -> anyhow::Result<T> {
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::<T>(&body).map_err(|e| {
anyhow::anyhow!(format!(
"Decode error for {url}: {e}; content-type={ct}; body={body}"
))
})?;
Ok(parsed)
}
fn get_git_origins() -> Vec<String> {
// 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<String>) -> Vec<String> {
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<Vec<crate::app::EnvironmentRow>> {
let mut map: HashMap<String, crate::app::EnvironmentRow> = 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::<Vec<CodeEnvironment>>(&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::<Vec<CodeEnvironment>>(&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<crate::app::EnvironmentRow> = 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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<String>,
}
impl NewTaskPage {
pub fn new(env_id: Option<String>) -> 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
}
}

View File

@@ -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<String>,
wrapped: Vec<String>,
wrapped_src_idx: Vec<usize>,
wrap_cols: Option<u16>,
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<String>) {
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<u8> {
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<String> = Vec::new();
let mut out_idx: Vec<usize> = 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<usize> = 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;
}
}

View File

@@ -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<bool> = 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<ratatui::text::Span> = 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<ListItem> = 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<ratatui::text::Span> = 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<Line<'static>> = 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<ratatui::text::Span> = 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<Utc>) -> 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<ListItem> = Vec::new();
items.push(ListItem::new(Line::from("All Environments (Global)")));
for env in envs.iter() {
let primary = env.label.clone().unwrap_or_else(|| "<unnamed>".to_string());
let mut spans: Vec<ratatui::text::Span> = 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);
}

View File

@@ -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: "));
}

View File

@@ -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"] }

View File

@@ -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;

View File

@@ -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<models::TaskResponse>,
#[serde(rename = "current_user_turn", skip_serializing_if = "Option::is_none")]
pub current_user_turn: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(
rename = "current_assistant_turn",
skip_serializing_if = "Option::is_none"
)]
pub current_assistant_turn: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(
rename = "current_diff_task_turn",
skip_serializing_if = "Option::is_none"
)]
pub current_diff_task_turn: Option<std::collections::HashMap<String, serde_json::Value>>,
}
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,
}
}
}

View File

@@ -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<models::GitPullRequest>,
#[serde(rename = "codex_updated_sha", skip_serializing_if = "Option::is_none")]
pub codex_updated_sha: Option<String>,
}
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,
}
}
}

View File

@@ -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<bool>,
#[serde(rename = "title", skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(rename = "body", skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(rename = "base", skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(rename = "head", skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(rename = "base_sha", skip_serializing_if = "Option::is_none")]
pub base_sha: Option<String>,
#[serde(rename = "head_sha", skip_serializing_if = "Option::is_none")]
pub head_sha: Option<String>,
#[serde(rename = "merge_commit_sha", skip_serializing_if = "Option::is_none")]
pub merge_commit_sha: Option<String>,
#[serde(rename = "comments", skip_serializing_if = "Option::is_none")]
pub comments: Option<serde_json::Value>,
#[serde(rename = "diff", skip_serializing_if = "Option::is_none")]
pub diff: Option<serde_json::Value>,
#[serde(rename = "user", skip_serializing_if = "Option::is_none")]
pub user: Option<serde_json::Value>,
}
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,
}
}
}

View File

@@ -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;

View File

@@ -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<models::TaskListItem>,
#[serde(rename = "cursor", skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
impl PaginatedListTaskListItem {
pub fn new(items: Vec<models::TaskListItem>) -> PaginatedListTaskListItem {
PaginatedListTaskListItem {
items,
cursor: None,
}
}
}

View File

@@ -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<bool>,
#[serde(rename = "updated_at", skip_serializing_if = "Option::is_none")]
pub updated_at: Option<f64>,
#[serde(rename = "created_at", skip_serializing_if = "Option::is_none")]
pub created_at: Option<f64>,
#[serde(
rename = "task_status_display",
skip_serializing_if = "Option::is_none"
)]
pub task_status_display: Option<std::collections::HashMap<String, serde_json::Value>>,
#[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<Vec<models::ExternalPullRequestResponse>>,
}
impl TaskListItem {
pub fn new(
id: String,
title: String,
has_generated_title: Option<bool>,
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,
}
}
}

View File

@@ -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<f64>,
#[serde(rename = "title")]
pub title: String,
#[serde(
rename = "has_generated_title",
skip_serializing_if = "Option::is_none"
)]
pub has_generated_title: Option<bool>,
#[serde(rename = "current_turn_id", skip_serializing_if = "Option::is_none")]
pub current_turn_id: Option<String>,
#[serde(rename = "has_unread_turn", skip_serializing_if = "Option::is_none")]
pub has_unread_turn: Option<bool>,
#[serde(
rename = "denormalized_metadata",
skip_serializing_if = "Option::is_none"
)]
pub denormalized_metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(rename = "archived")]
pub archived: bool,
#[serde(rename = "external_pull_requests")]
pub external_pull_requests: Vec<models::ExternalPullRequestResponse>,
}
impl TaskResponse {
pub fn new(
id: String,
title: String,
archived: bool,
external_pull_requests: Vec<models::ExternalPullRequestResponse>,
) -> 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,
}
}
}

View File

@@ -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(),

View File

@@ -30,11 +30,10 @@ pub struct ServerOptions {
pub port: u16,
pub open_browser: bool,
pub force_state: Option<String>,
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<LoginServer> {
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()

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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<CustomPrompt>,
// Optional override for footer hint items.
footer_hint_override: Option<Vec<(String, String)>>,
}
/// 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<Vec<(String, String)>>) {
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<Span> = 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+⏎"

View File

@@ -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 {

View File

@@ -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<String> = 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 {

View File

@@ -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,
)

View File

@@ -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<AuthManager>,
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();

View File

@@ -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<AuthManager>,
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<Step> = 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();

View File

@@ -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<AppEvent>,
}
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 "<key> <label>", with keys styled.
pub fn set_hint_items(&mut self, items: Vec<(impl Into<String>, impl Into<String>)>) {
let mapped: Vec<(String, String)> = items
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect();
self.inner.set_footer_hint_override(Some(mapped));
}
/// Clear any previously set custom hint items and restore the default hints.
pub fn clear_hint_items(&mut self) {
self.inner.set_footer_hint_override(None);
}
/// Desired height (in rows) for a given width.
pub fn desired_height(&self, width: u16) -> u16 {
self.inner.desired_height(width)
}
/// Compute the on-screen cursor position for the given area.
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.inner.cursor_pos(area)
}
/// Render the input into the provided buffer at `area`.
pub fn render_ref(&self, area: Rect, buf: &mut Buffer) {
WidgetRef::render_ref(&self.inner, area, buf);
}
}
impl Default for ComposerInput {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,2 @@
pub mod composer_input;
pub mod text_input;

View File

@@ -0,0 +1,83 @@
//! Public, minimal wrapper around the internal multiline TextArea widget.
//!
//! This exposes a stable, crate-agnostic text input for other Codex crates
//! (e.g., cloud-tasks) without making the whole TextArea API public.
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::StatefulWidgetRef;
// Use the internal text area implementation.
use crate::bottom_pane::textarea::TextArea as InnerTextArea;
use crate::bottom_pane::textarea::TextAreaState as InnerTextAreaState;
use std::cell::RefCell;
/// A reusable, multiline text input field with wrapping and cursor movement.
///
/// This wrapper intentionally exposes a very small surface area needed by
/// external consumers, while delegating to codex-tui's internal TextArea for
/// behavior and rendering.
pub struct TextInput {
ta: InnerTextArea,
state: RefCell<InnerTextAreaState>,
}
impl Default for TextInput {
fn default() -> Self {
Self::new()
}
}
impl TextInput {
/// Create a new, empty input.
pub fn new() -> Self {
Self {
ta: InnerTextArea::new(),
state: RefCell::new(InnerTextAreaState::default()),
}
}
/// Set the input contents.
pub fn set_text(&mut self, text: &str) {
self.ta.set_text(text);
}
/// Return the current text contents.
pub fn text(&self) -> &str {
self.ta.text()
}
/// Clear the input.
pub fn clear(&mut self) {
self.ta.set_text("");
}
/// Returns true if the input is empty.
pub fn is_empty(&self) -> bool {
self.ta.is_empty()
}
/// Handle a key event (inserts characters, moves cursor, etc.).
pub fn input(&mut self, key: KeyEvent) {
self.ta.input(key);
}
/// Desired height (in rows) for a given width.
pub fn desired_height(&self, width: u16) -> u16 {
self.ta.desired_height(width)
}
/// Compute the on-screen cursor position for the given area.
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let state = self.state.borrow();
self.ta.cursor_pos_with_state(area, &state)
}
/// Render the input into the provided buffer at `area`.
pub fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut state = self.state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.ta), area, buf, &mut state);
}
}