Compare commits

...

21 Commits

Author SHA1 Message Date
easong-openai
065fa50f10 remove debug 2025-09-29 16:45:37 -07:00
easong-openai
25ab9f5e10 init, ugly 2025-09-29 16:43:18 -07:00
easong-openai
f5ab495189 handle paste 2025-09-26 13:59:37 -07:00
easong-openai
4923df37ea formatting 2025-09-26 13:12:40 -07:00
easong-openai
8858ed1090 merge 2025-09-26 12:21:18 -07:00
easong-openai
f0491f4826 rename error 2025-09-26 04:46:20 -07:00
easong-openai
e1d6531103 cleanup 2025-09-26 04:36:05 -07:00
easong-openai
5fa64b7ae1 support best of n 2025-09-26 04:29:33 -07:00
easong-openai
e20e4edbab no review tasks 2025-09-26 03:17:14 -07:00
easong-openai
16ac10f9d3 improvements 2025-09-25 03:05:30 -07:00
easong-openai
3d12b46b18 clippy 2025-09-05 19:50:10 -07:00
easong-openai
36803606a0 optionally show conversation 2025-09-05 18:11:23 -07:00
easong-openai
21ef6be571 apply patch spinner 2025-09-05 16:33:47 -07:00
easong-openai
acb706b553 merge crates 2025-09-05 15:55:06 -07:00
easong-openai
35dec89d8a codex cloud 2025-09-05 15:16:52 -07:00
easong-openai
d1cf46b09f cleanup 2025-09-05 14:48:19 -07:00
easong-openai
e17d794a4e Merge branch 'main' into easong/remote-tasks 2025-09-05 01:55:59 -07:00
easong-openai
83dfb43dbd cleanup 2025-09-04 21:09:39 -07:00
easong-openai
e5d31d5ccc better composer 2025-09-04 19:15:36 -07:00
easong-openai
9be247e41e better apply patch 2025-09-04 19:02:27 -07:00
easong-openai
d2fcf4314e remote tasks 2025-09-03 16:57:37 -07:00
53 changed files with 10535 additions and 440 deletions

878
codex-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,12 @@
[workspace]
members = [
"backend-client",
"ansi-escape",
"apply-patch",
"arg0",
"codex-backend-openapi-models",
"cloud-tasks",
"cloud-tasks-client",
"cli",
"common",
"core",
@@ -21,6 +25,7 @@ members = [
"rmcp-client",
"responses-api-proxy",
"tui",
"git-apply",
"utils/readiness",
]
resolver = "2"

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,242 @@
use crate::types::CodeTaskDetailsResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::TurnAttemptsSiblingTurnsResponse;
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;
use serde::de::DeserializeOwned;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PathStyle {
CodexApi, // /api/codex/...
ChatGptApi, // /wham/...
}
impl PathStyle {
pub fn from_base_url(base_url: &str) -> Self {
if base_url.contains("/backend-api") {
PathStyle::ChatGptApi
} else {
PathStyle::CodexApi
}
}
}
#[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!("{base_url}/backend-api");
}
let http = reqwest::Client::builder().build()?;
let path_style = PathStyle::from_base_url(&base_url);
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
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
&& let Ok(hv) = HeaderValue::from_str(acc)
{
h.insert(name, hv);
}
h
}
async fn exec_request(
&self,
req: reqwest::RequestBuilder,
method: &str,
url: &str,
) -> Result<(String, String)> {
let res = req.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!("{method} {url} failed: {status}; content-type={ct}; body={body}");
}
Ok((body, ct))
}
fn decode_json<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> {
match serde_json::from_str::<T>(body) {
Ok(v) => Ok(v),
Err(e) => {
anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
}
}
}
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::ChatGptApi => 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 (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json::<PaginatedListTaskListItem>(&url, &ct, &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::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?;
Ok((parsed, body, ct))
}
pub async fn list_sibling_turns(
&self,
task_id: &str,
turn_id: &str,
) -> Result<TurnAttemptsSiblingTurnsResponse> {
let url = match self.path_style {
PathStyle::CodexApi => format!(
"{}/api/codex/tasks/{}/turns/{}/sibling_turns",
self.base_url, task_id, turn_id
),
PathStyle::ChatGptApi => format!(
"{}/wham/tasks/{}/turns/{}/sibling_turns",
self.base_url, task_id, turn_id
),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &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::ChatGptApi => format!("{}/wham/tasks", self.base_url),
};
let req = self
.http
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&request_body);
let (body, ct) = self.exec_request(req, "POST", &url).await?;
// 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,9 @@
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;
pub use types::TurnAttemptsSiblingTurnsResponse;

View File

@@ -0,0 +1,141 @@
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::Deserialize;
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 the user's prompt text from the current user turn, when present.
fn user_text_prompt(&self) -> Option<String>;
/// Extract an assistant error message (if the turn failed and provided one).
fn assistant_error_message(&self) -> Option<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")
&& 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")
&& let Some(txt) = part.get("text").and_then(Value::as_str)
{
out.push(txt.to_string());
}
}
}
}
}
}
out
}
fn user_text_prompt(&self) -> Option<String> {
use serde_json::Value;
let map = self.current_user_turn.as_ref()?;
let items = map.get("input_items").and_then(Value::as_array)?;
let mut parts: Vec<String> = Vec::new();
for item in items {
if item.get("type").and_then(Value::as_str) == Some("message") {
// optional role filter (prefer user)
let is_user = item
.get("role")
.and_then(Value::as_str)
.map(|r| r.eq_ignore_ascii_case("user"))
.unwrap_or(true);
if !is_user {
continue;
}
if let Some(content) = item.get("content").and_then(Value::as_array) {
for c in content {
if c.get("content_type").and_then(Value::as_str) == Some("text")
&& let Some(txt) = c.get("text").and_then(Value::as_str)
{
parts.push(txt.to_string());
}
}
}
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
}
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(code.to_string())
} else if code.is_empty() {
Some(message.to_string())
} else {
Some(format!("{code}: {message}"))
}
}
}
// Removed unused helpers `single_file_paths` and `extract_file_paths_list` to reduce
// surface area; reintroduce as needed near call sites.
#[derive(Clone, Debug, Deserialize)]
pub struct TurnAttemptsSiblingTurnsResponse {
#[serde(default)]
pub sibling_turns: Vec<std::collections::HashMap<String, Value>>,
}

View File

@@ -14,6 +14,7 @@ codex-core = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
codex-git-apply = { path = "../git-apply" }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -56,46 +56,24 @@ pub async fn apply_diff_from_task(
}
async fn apply_diff(diff: &str, cwd: Option<PathBuf>) -> anyhow::Result<()> {
let mut cmd = tokio::process::Command::new("git");
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
let toplevel_output = cmd
.args(vec!["rev-parse", "--show-toplevel"])
.output()
.await?;
if !toplevel_output.status.success() {
anyhow::bail!("apply must be run from a git repository.");
}
let repo_root = String::from_utf8(toplevel_output.stdout)?
.trim()
.to_string();
let mut git_apply_cmd = tokio::process::Command::new("git")
.args(vec!["apply", "--3way"])
.current_dir(&repo_root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let Some(mut stdin) = git_apply_cmd.stdin.take() {
tokio::io::AsyncWriteExt::write_all(&mut stdin, diff.as_bytes()).await?;
drop(stdin);
}
let output = git_apply_cmd.wait_with_output().await?;
if !output.status.success() {
let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()));
let req = codex_git_apply::ApplyGitRequest {
cwd,
diff: diff.to_string(),
revert: false,
preflight: false,
};
let res = codex_git_apply::apply_git_patch(&req)?;
if res.exit_code != 0 {
anyhow::bail!(
"Git apply failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
"Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}",
res.applied_paths.len(),
res.skipped_paths.len(),
res.conflicted_paths.len(),
res.stdout,
res.stderr
);
}
println!("Successfully applied diff");
Ok(())
}

View File

@@ -44,6 +44,6 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Request failed with status {}: {}", status, body)
anyhow::bail!("Request failed with status {status}: {body}")
}
}

View File

@@ -29,6 +29,7 @@ codex-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-tui = { workspace = true }
codex-cloud-tasks = { path = "../cloud-tasks" }
ctor = { workspace = true }
owo-colors = { workspace = true }
serde_json = { workspace = true }

View File

@@ -13,6 +13,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_responses_api_proxy::Args as ResponsesApiProxyArgs;
@@ -89,6 +90,10 @@ enum Subcommand {
#[clap(hide = true)]
GenerateTs(GenerateTsCommand),
/// Browse and apply tasks from the cloud.
#[clap(name = "cloud", alias = "cloud-tasks")]
Cloud(CloudTasksCli),
/// Internal: run the responses API proxy.
#[clap(hide = true)]
ResponsesApiProxy(ResponsesApiProxyArgs),
@@ -313,6 +318,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
Some(Subcommand::Completion(completion_cli)) => {
print_completion(completion_cli);
}
Some(Subcommand::Cloud(mut cloud_cli)) => {
prepend_config_flags(
&mut cloud_cli.config_overrides,
root_config_overrides.clone(),
);
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(

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"
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
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 }
codex-git-apply = { path = "../git-apply" }
dirs = { workspace = true }

View File

@@ -0,0 +1,188 @@
use chrono::DateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
pub type Result<T> = std::result::Result<T, CloudTaskError>;
#[derive(Debug, thiserror::Error)]
pub enum CloudTaskError {
#[error("unimplemented: {0}")]
Unimplemented(&'static str),
#[error("http error: {0}")]
Http(String),
#[error("io error: {0}")]
Io(String),
#[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,
/// True when the backend reports this task as a code review.
#[serde(default)]
pub is_review: bool,
/// Number of assistant attempts (best-of-N), when reported by the backend.
#[serde(default)]
pub attempt_total: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AttemptStatus {
Pending,
InProgress,
Completed,
Failed,
Cancelled,
#[default]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TurnAttempt {
pub turn_id: String,
pub attempt_placement: Option<i64>,
pub created_at: Option<DateTime<Utc>>,
pub status: AttemptStatus,
pub diff: Option<String>,
pub messages: Vec<String>,
}
#[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)]
pub enum AttachmentKind {
File,
Image,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AttachmentReference {
pub sediment_id: String,
pub asset_pointer: String,
pub path: Option<String>,
pub display_name: Option<String>,
pub kind: AttachmentKind,
pub size_bytes: Option<u64>,
pub width: Option<u32>,
pub height: Option<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FileServiceConfig {
pub base_url: String,
pub bearer_token: Option<String>,
pub chatgpt_account_id: Option<String>,
pub user_agent: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DiffSummary {
pub files_changed: usize,
pub lines_added: usize,
pub lines_removed: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaskText {
pub prompt: Option<String>,
pub messages: Vec<String>,
pub turn_id: Option<String>,
pub sibling_turn_ids: Vec<String>,
pub attempt_placement: Option<i64>,
pub attempt_status: AttemptStatus,
}
impl Default for TaskText {
fn default() -> Self {
Self {
prompt: None,
messages: Vec::new(),
turn_id: None,
sibling_turn_ids: Vec::new(),
attempt_placement: None,
attempt_status: AttemptStatus::Unknown,
}
}
}
#[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<Option<String>>;
/// Return assistant output messages (no diff) when available.
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>>;
/// Return the creating prompt and assistant messages (when available).
async fn get_task_text(&self, id: TaskId) -> Result<TaskText>;
/// Return any sibling attempts (best-of-N) for the given assistant turn.
async fn list_sibling_attempts(
&self,
task: TaskId,
turn_id: String,
) -> Result<Vec<TurnAttempt>>;
/// Dry-run apply (preflight) that validates whether the patch would apply cleanly.
/// Never modifies the working tree. When `diff_override` is supplied, the provided diff is
/// used instead of re-fetching the task details so callers can apply alternate attempts.
async fn apply_task_preflight(
&self,
id: TaskId,
diff_override: Option<String>,
) -> Result<ApplyOutcome>;
async fn apply_task(&self, id: TaskId, diff_override: Option<String>) -> Result<ApplyOutcome>;
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
attachments: &[AttachmentReference],
) -> Result<CreatedTask>;
fn file_service_config(&self) -> Option<FileServiceConfig> {
None
}
}

View File

@@ -0,0 +1,849 @@
use crate::ApplyOutcome;
use crate::ApplyStatus;
use crate::AttemptStatus;
use crate::CloudBackend;
use crate::CloudTaskError;
use crate::DiffSummary;
use crate::Result;
use crate::TaskId;
use crate::TaskStatus;
use crate::TaskSummary;
use crate::TurnAttempt;
use crate::api::TaskText;
use chrono::DateTime;
use chrono::Utc;
use serde_json::Value;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use codex_backend_client as backend;
use codex_backend_client::CodeTaskDetailsResponseExt;
#[derive(Clone)]
pub struct HttpClient {
pub base_url: String,
backend: backend::Client,
bearer_token: Option<String>,
chatgpt_account_id: Option<String>,
user_agent: Option<String>,
}
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,
bearer_token: None,
chatgpt_account_id: None,
user_agent: None,
})
}
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
let token = token.into();
self.backend = self.backend.clone().with_bearer_token(token.clone());
self.bearer_token = Some(token);
self
}
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
let ua = ua.into();
self.backend = self.backend.clone().with_user_agent(ua.clone());
self.user_agent = Some(ua);
self
}
pub fn with_chatgpt_account_id(mut self, account_id: impl Into<String>) -> Self {
let account_id = account_id.into();
self.backend = self
.backend
.clone()
.with_chatgpt_account_id(account_id.clone());
self.chatgpt_account_id = Some(account_id);
self
}
}
fn is_unified_diff(diff: &str) -> bool {
let t = diff.trim_start();
if t.starts_with("diff --git ") {
return true;
}
let has_dash_headers = diff.contains("\n--- ") && diff.contains("\n+++ ");
let has_hunk = diff.contains("\n@@ ") || diff.starts_with("@@ ");
has_dash_headers && has_hunk
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
s[s.len() - max..].to_string()
}
}
#[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| CloudTaskError::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<Option<String>> {
let id = _id.0;
let (details, body, ct) = self
.backend
.get_task_details_with_body(&id)
.await
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
if let Some(diff) = details.unified_diff() {
return Ok(Some(diff));
}
// No diff yet (pending or non-diff task).
// Keep variables bound for potential future logging.
let _ = (body, ct);
Ok(None)
}
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| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
let mut msgs = details.assistant_text_messages();
if msgs.is_empty() {
msgs.extend(extract_assistant_messages_from_body(&body));
}
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(CloudTaskError::Http(format!(
"No assistant text messages in response. GET {url}; content-type={ct}; body={body}"
)))
}
async fn get_task_text(&self, _id: TaskId) -> Result<TaskText> {
let id = _id.0;
let (details, body, _ct) = self
.backend
.get_task_details_with_body(&id)
.await
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
let prompt = details.user_text_prompt();
let mut messages = details.assistant_text_messages();
if messages.is_empty() {
messages.extend(extract_assistant_messages_from_body(&body));
}
let turn_map = details.current_assistant_turn.as_ref();
let turn_id = turn_map
.and_then(|m| m.get("id"))
.and_then(Value::as_str)
.map(str::to_string);
let sibling_turn_ids = turn_map
.and_then(|m| m.get("sibling_turn_ids"))
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let attempt_placement = turn_map
.and_then(|m| m.get("attempt_placement"))
.and_then(Value::as_i64);
let attempt_status = attempt_status_from_str(
turn_map
.and_then(|m| m.get("turn_status"))
.and_then(Value::as_str),
);
Ok(TaskText {
prompt,
messages,
turn_id,
sibling_turn_ids,
attempt_placement,
attempt_status,
})
}
async fn list_sibling_attempts(
&self,
task: TaskId,
turn_id: String,
) -> Result<Vec<TurnAttempt>> {
let resp = self
.backend
.list_sibling_turns(&task.0, &turn_id)
.await
.map_err(|e| CloudTaskError::Http(format!("list_sibling_turns failed: {e}")))?;
let mut attempts: Vec<TurnAttempt> = resp
.sibling_turns
.iter()
.filter_map(turn_attempt_from_map)
.collect();
attempts.sort_by(compare_attempts);
Ok(attempts)
}
async fn apply_task(&self, _id: TaskId, diff_override: Option<String>) -> Result<ApplyOutcome> {
let id = _id.0;
self.apply_with_diff(id, diff_override, false).await
}
async fn apply_task_preflight(
&self,
_id: TaskId,
diff_override: Option<String>,
) -> Result<ApplyOutcome> {
let id = _id.0;
self.apply_with_diff(id, diff_override, true).await
}
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
attachments: &[crate::AttachmentReference],
) -> Result<crate::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 }]
}));
for attachment in attachments {
match attachment.kind {
crate::AttachmentKind::Image => {
if let (Some(width), Some(height), Some(size_bytes)) =
(attachment.width, attachment.height, attachment.size_bytes)
{
input_items.push(serde_json::json!({
"type": "image_asset_pointer",
"asset_pointer": attachment.asset_pointer,
"width": width,
"height": height,
"size_bytes": size_bytes,
}));
continue;
}
// Fallback to container when metadata is missing
}
crate::AttachmentKind::File => {}
}
let default_path = attachment
.path
.clone()
.or_else(|| attachment.display_name.clone())
.unwrap_or_else(|| attachment.sediment_id.clone());
let file_entry = serde_json::json!({
"type": "file",
"sediment_id": attachment.sediment_id,
"path": default_path.clone(),
});
let mut container = serde_json::json!({
"type": "container_file",
"file_ids": [file_entry],
"body": "",
});
container["path"] = serde_json::Value::String(default_path);
input_items.push(container);
}
if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF")
&& !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={} attachments={}",
env_id,
prompt.chars().count(),
attachments.len()
));
Ok(crate::CreatedTask { id: TaskId(id) })
}
Err(e) => {
append_error_log(&format!(
"new_task: create failed env={} prompt_chars={} attachments={}: {}",
env_id,
prompt.chars().count(),
attachments.len(),
e
));
Err(CloudTaskError::Http(format!("create_task failed: {e}")))
}
}
}
fn file_service_config(&self) -> Option<crate::FileServiceConfig> {
Some(crate::FileServiceConfig {
base_url: self.base_url.clone(),
bearer_token: self.bearer_token.clone(),
chatgpt_account_id: self.chatgpt_account_id.clone(),
user_agent: self.user_agent.clone(),
})
}
}
/// Best-effort extraction of assistant text messages from a raw `get_task_details` body.
/// Falls back to worklog messages when structured turns are not present.
impl HttpClient {
async fn apply_with_diff(
&self,
id: String,
diff_override: Option<String>,
preflight: bool,
) -> Result<ApplyOutcome> {
let diff = match diff_override {
Some(diff) => diff,
None => {
let details =
self.backend.get_task_details(&id).await.map_err(|e| {
CloudTaskError::Http(format!("get_task_details failed: {e}"))
})?;
details.unified_diff().ok_or_else(|| {
CloudTaskError::Msg(format!("No diff available for task {id}"))
})?
}
};
if !is_unified_diff(&diff) {
let summary = summarize_patch_for_logging(&diff);
let mode = if preflight { "preflight" } else { "apply" };
append_error_log(&format!(
"apply_error: id={id} mode={mode} format=non-unified; {summary}"
));
return Ok(ApplyOutcome {
applied: false,
status: ApplyStatus::Error,
message: "Expected unified git diff; backend returned an incompatible format."
.to_string(),
skipped_paths: Vec::new(),
conflict_paths: Vec::new(),
});
}
let req = codex_git_apply::ApplyGitRequest {
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
diff: diff.clone(),
revert: false,
preflight,
};
let r = codex_git_apply::apply_git_patch(&req)
.map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?;
let status = if r.exit_code == 0 {
ApplyStatus::Success
} else if !r.applied_paths.is_empty() || !r.conflicted_paths.is_empty() {
ApplyStatus::Partial
} else {
ApplyStatus::Error
};
let applied = matches!(status, ApplyStatus::Success) && !preflight;
let message = if preflight {
match status {
ApplyStatus::Success => format!("Preflight passed for task {id} (applies cleanly)"),
ApplyStatus::Partial => format!(
"Preflight: patch does not fully apply for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
ApplyStatus::Error => format!(
"Preflight failed for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
}
} else {
match status {
ApplyStatus::Success => format!(
"Applied task {id} locally ({} files)",
r.applied_paths.len()
),
ApplyStatus::Partial => format!(
"Apply partially succeeded for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
ApplyStatus::Error => format!(
"Apply failed for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
}
};
if matches!(status, ApplyStatus::Partial | ApplyStatus::Error)
|| (preflight && !matches!(status, ApplyStatus::Success))
{
let mut log = String::new();
let summary = summarize_patch_for_logging(&diff);
let mode = if preflight { "preflight" } else { "apply" };
use std::fmt::Write as _;
let _ = writeln!(
&mut log,
"apply_result: mode={} id={} status={:?} applied={} skipped={} conflicts={} cmd={}",
mode,
id,
status,
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len(),
r.cmd_for_log
);
let _ = writeln!(
&mut log,
"stdout_tail=
{}
stderr_tail=
{}",
tail(&r.stdout, 2000),
tail(&r.stderr, 2000)
);
let _ = writeln!(&mut log, "{summary}");
let _ = writeln!(
&mut log,
"----- PATCH BEGIN -----
{diff}
----- PATCH END -----"
);
append_error_log(&log);
}
Ok(ApplyOutcome {
applied,
status,
message,
skipped_paths: r.skipped_paths,
conflict_paths: r.conflicted_paths,
})
}
}
fn extract_assistant_messages_from_body(body: &str) -> Vec<String> {
let mut msgs = Vec::new();
if let Ok(full) = serde_json::from_str::<serde_json::Value>(body)
&& 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() {
if !s.is_empty() {
msgs.push(s.to_string());
}
continue;
}
if let Some(obj) = p.as_object()
&& obj.get("content_type").and_then(|t| t.as_str()) == Some("text")
&& let Some(txt) = obj.get("text").and_then(|t| t.as_str())
{
msgs.push(txt.to_string());
}
}
}
}
}
msgs
}
fn turn_attempt_from_map(turn: &HashMap<String, Value>) -> Option<TurnAttempt> {
let turn_id = turn.get("id").and_then(Value::as_str)?.to_string();
let attempt_placement = turn.get("attempt_placement").and_then(Value::as_i64);
let created_at = parse_timestamp_value(turn.get("created_at"));
let status = attempt_status_from_str(turn.get("turn_status").and_then(Value::as_str));
let diff = extract_diff_from_turn(turn);
let messages = extract_assistant_messages_from_turn(turn);
Some(TurnAttempt {
turn_id,
attempt_placement,
created_at,
status,
diff,
messages,
})
}
fn compare_attempts(a: &TurnAttempt, b: &TurnAttempt) -> Ordering {
match (a.attempt_placement, b.attempt_placement) {
(Some(lhs), Some(rhs)) => lhs.cmp(&rhs),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => match (a.created_at, b.created_at) {
(Some(lhs), Some(rhs)) => lhs.cmp(&rhs),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => a.turn_id.cmp(&b.turn_id),
},
}
}
fn extract_diff_from_turn(turn: &HashMap<String, Value>) -> Option<String> {
let items = turn.get("output_items").and_then(Value::as_array)?;
for item in items {
match item.get("type").and_then(Value::as_str) {
Some("output_diff") => {
if let Some(diff) = item.get("diff").and_then(Value::as_str)
&& !diff.is_empty()
{
return Some(diff.to_string());
}
}
Some("pr") => {
if let Some(diff) = item
.get("output_diff")
.and_then(Value::as_object)
.and_then(|od| od.get("diff"))
.and_then(Value::as_str)
&& !diff.is_empty()
{
return Some(diff.to_string());
}
}
_ => {}
}
}
None
}
fn extract_assistant_messages_from_turn(turn: &HashMap<String, Value>) -> Vec<String> {
let mut msgs = Vec::new();
if let Some(items) = turn.get("output_items").and_then(Value::as_array) {
for item in items {
if item.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
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")
&& let Some(txt) = part.get("text").and_then(Value::as_str)
&& !txt.is_empty()
{
msgs.push(txt.to_string());
}
}
}
}
}
if msgs.is_empty()
&& let Some(err) = turn.get("error").and_then(Value::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() {
let text = if !code.is_empty() && !message.is_empty() {
format!("{code}: {message}")
} else if !code.is_empty() {
code.to_string()
} else {
message.to_string()
};
msgs.push(format!("Task failed: {text}"));
}
}
msgs
}
fn parse_timestamp_value(v: Option<&Value>) -> Option<DateTime<Utc>> {
let raw = v?.as_f64()?;
let secs = raw as i64;
let nanos = ((raw - secs as f64) * 1_000_000_000.0) as u32;
Some(DateTime::<Utc>::from(
std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos),
))
}
fn attempt_status_from_str(s: Option<&str>) -> AttemptStatus {
match s.unwrap_or("") {
"pending" => AttemptStatus::Pending,
"in_progress" => AttemptStatus::InProgress,
"completed" => AttemptStatus::Completed,
"failed" => AttemptStatus::Failed,
"cancelled" => AttemptStatus::Cancelled,
_ => AttemptStatus::Unknown,
}
}
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)
&& !s.trim().is_empty()
{
return Some(s.to_string());
}
if let Some(s) = o.get("plain_text").and_then(Value::as_str)
&& !s.trim().is_empty()
{
return Some(s.to_string());
}
// Fallback: compact JSON for debugging
if let Ok(s) = serde_json::to_string(o)
&& !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
}
fn attempt_total_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<usize> {
let map = v?;
let latest = map
.get("latest_turn_status_display")
.and_then(Value::as_object)?;
let siblings = latest.get("sibling_turn_ids").and_then(Value::as_array)?;
Some(siblings.len().saturating_add(1))
}
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()),
is_review: src
.pull_requests
.as_ref()
.is_some_and(|prs| !prs.is_empty()),
attempt_total: attempt_total_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)
&& 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 timestamp = Utc::now().to_rfc3339();
if let Some(path) = log_file_path()
&& write_log_line(&path, &timestamp, message)
{
return;
}
let fallback = Path::new("error.log");
let _ = write_log_line(fallback, &timestamp, message);
}
fn log_file_path() -> Option<PathBuf> {
let mut codex_home = codex_home_dir()?;
codex_home.push("log");
std::fs::create_dir_all(&codex_home).ok()?;
Some(codex_home.join("codex-cloud-tasks.log"))
}
fn codex_home_dir() -> Option<PathBuf> {
if let Ok(val) = std::env::var("CODEX_HOME")
&& !val.is_empty()
{
let path = PathBuf::from(val);
return path.canonicalize().ok().or(Some(path));
}
dirs::home_dir().map(|mut home| {
home.push(".codex");
home
})
}
fn write_log_line(path: &Path, timestamp: &str, message: &str) -> bool {
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
match opts.open(path) {
Ok(mut file) => {
use std::io::Write as _;
writeln!(file, "[{timestamp}] {message}").is_ok()
}
Err(_) => false,
}
}

View File

@@ -0,0 +1,32 @@
mod api;
pub use api::ApplyOutcome;
pub use api::ApplyStatus;
pub use api::AttachmentKind;
pub use api::AttachmentReference;
pub use api::AttemptStatus;
pub use api::CloudBackend;
pub use api::CloudTaskError;
pub use api::CreatedTask;
pub use api::DiffSummary;
pub use api::FileServiceConfig;
pub use api::Result;
pub use api::TaskId;
pub use api::TaskStatus;
pub use api::TaskSummary;
pub use api::TaskText;
pub use api::TurnAttempt;
#[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 now lives in the shared crate `codex-git-apply`.

View File

@@ -0,0 +1,180 @@
use crate::ApplyOutcome;
use crate::AttemptStatus;
use crate::CloudBackend;
use crate::DiffSummary;
use crate::Result;
use crate::TaskId;
use crate::TaskStatus;
use crate::TaskSummary;
use crate::TurnAttempt;
use crate::api::TaskText;
use chrono::Utc;
#[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(str::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(other.to_string()),
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,
},
is_review: false,
attempt_total: Some(if id_str == "T-1000" { 2 } else { 1 }),
});
}
Ok(out)
}
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
Ok(Some(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 get_task_text(&self, _id: TaskId) -> Result<TaskText> {
Ok(TaskText {
prompt: Some("Why is there no diff?".to_string()),
messages: vec!["Mock assistant output: this task contains no diff.".to_string()],
turn_id: Some("mock-turn".to_string()),
sibling_turn_ids: Vec::new(),
attempt_placement: Some(0),
attempt_status: AttemptStatus::Completed,
})
}
async fn apply_task(&self, id: TaskId, _diff_override: Option<String>) -> 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 apply_task_preflight(
&self,
id: TaskId,
_diff_override: Option<String>,
) -> Result<ApplyOutcome> {
Ok(ApplyOutcome {
applied: false,
status: crate::ApplyStatus::Success,
message: format!("Preflight passed for task {} (mock)", id.0),
skipped_paths: Vec::new(),
conflict_paths: Vec::new(),
})
}
async fn list_sibling_attempts(
&self,
task: TaskId,
_turn_id: String,
) -> Result<Vec<TurnAttempt>> {
if task.0 == "T-1000" {
return Ok(vec![TurnAttempt {
turn_id: "T-1000-attempt-2".to_string(),
attempt_placement: Some(1),
created_at: Some(Utc::now()),
status: AttemptStatus::Completed,
diff: Some(mock_diff_for(&task)),
messages: vec!["Mock alternate attempt".to_string()],
}]);
}
Ok(Vec::new())
}
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
attachments: &[crate::AttachmentReference],
) -> Result<crate::CreatedTask> {
let _ = (env_id, prompt, git_ref, qa_mode, attachments);
let id = format!("task_local_{}", chrono::Utc::now().timestamp_millis());
Ok(crate::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(diffy::Hunk::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,54 @@
[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 = ["fs", "macros", "rt-multi-thread"] }
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
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" }
codex-file-search = { path = "../file-search" }
mime_guess = "2"
url = "2"
image = { workspace = true }
[dev-dependencies]
async-trait = "0.1"
tempfile = "3"
[[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,474 @@
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"
pub default_branch: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct EnvModalState {
pub query: String,
pub selected: usize,
}
#[derive(Clone, Debug, Copy, PartialEq, Eq)]
pub enum ApplyResultLevel {
Success,
Partial,
Error,
}
#[derive(Clone, Debug)]
pub struct ApplyModalState {
pub task_id: TaskId,
pub title: String,
pub result_message: Option<String>,
pub result_level: Option<ApplyResultLevel>,
pub skipped_paths: Vec<String>,
pub conflict_paths: Vec<String>,
pub diff_override: Option<String>,
}
use crate::scrollable_diff::ScrollableDiff;
use codex_cloud_tasks_client::CloudBackend;
use codex_cloud_tasks_client::TaskId;
use codex_cloud_tasks_client::TaskSummary;
use throbber_widgets_tui::ThrobberState;
#[derive(Default)]
pub struct App {
pub tasks: Vec<TaskSummary>,
pub selected: usize,
pub status: String,
pub diff_overlay: Option<DiffOverlay>,
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 apply_modal: Option<ApplyModalState>,
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>,
// Apply preflight spinner state
pub apply_preflight_inflight: bool,
// Apply action spinner state
pub apply_inflight: bool,
// Background enrichment coordination
pub list_generation: u64,
pub in_flight: std::collections::HashSet<String>,
// Background enrichment caches were planned; currently unused.
}
impl App {
pub fn new() -> Self {
Self {
tasks: Vec::new(),
selected: 0,
status: "Press r to refresh".to_string(),
diff_overlay: None,
throbber: ThrobberState::default(),
refresh_inflight: false,
details_inflight: false,
env_filter: None,
env_modal: None,
apply_modal: None,
environments: Vec::new(),
env_last_loaded: None,
env_loading: false,
env_error: None,
new_task: None,
apply_preflight_inflight: false,
apply_inflight: false,
list_generation: 0,
in_flight: 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??;
// Hide review-only tasks from the main list.
let filtered: Vec<TaskSummary> = tasks.into_iter().filter(|t| !t.is_review).collect();
Ok(filtered)
}
pub struct DiffOverlay {
pub title: String,
pub task_id: TaskId,
pub sd: ScrollableDiff,
pub base_can_apply: bool,
pub diff_lines: Vec<String>,
pub text_lines: Vec<String>,
pub prompt: Option<String>,
pub attempts: Vec<AttemptView>,
pub selected_attempt: usize,
pub current_view: DetailView,
pub base_turn_id: Option<String>,
pub sibling_turn_ids: Vec<String>,
pub attempt_total_hint: Option<usize>,
}
#[derive(Clone, Debug, Default)]
pub struct AttemptView {
pub turn_id: Option<String>,
pub status: codex_cloud_tasks_client::AttemptStatus,
pub attempt_placement: Option<i64>,
pub diff_lines: Vec<String>,
pub text_lines: Vec<String>,
pub prompt: Option<String>,
pub diff_raw: Option<String>,
}
impl AttemptView {
pub fn has_diff(&self) -> bool {
!self.diff_lines.is_empty()
}
pub fn has_text(&self) -> bool {
!self.text_lines.is_empty() || self.prompt.is_some()
}
}
impl DiffOverlay {
pub fn new(task_id: TaskId, title: String, attempt_total_hint: Option<usize>) -> Self {
let mut sd = ScrollableDiff::new();
sd.set_content(Vec::new());
Self {
title,
task_id,
sd,
base_can_apply: false,
diff_lines: Vec::new(),
text_lines: Vec::new(),
prompt: None,
attempts: vec![AttemptView::default()],
selected_attempt: 0,
current_view: DetailView::Prompt,
base_turn_id: None,
sibling_turn_ids: Vec::new(),
attempt_total_hint,
}
}
pub fn current_attempt(&self) -> Option<&AttemptView> {
self.attempts.get(self.selected_attempt)
}
pub fn base_attempt_mut(&mut self) -> &mut AttemptView {
if self.attempts.is_empty() {
self.attempts.push(AttemptView::default());
}
&mut self.attempts[0]
}
pub fn set_view(&mut self, view: DetailView) {
self.current_view = view;
self.apply_selection_to_fields();
}
pub fn expected_attempts(&self) -> Option<usize> {
self.attempt_total_hint.or({
if self.attempts.is_empty() {
None
} else {
Some(self.attempts.len())
}
})
}
pub fn attempt_count(&self) -> usize {
self.attempts.len()
}
pub fn attempt_display_total(&self) -> usize {
self.expected_attempts()
.unwrap_or_else(|| self.attempts.len().max(1))
}
pub fn step_attempt(&mut self, delta: isize) -> bool {
let total = self.attempts.len();
if total <= 1 {
return false;
}
let total_isize = total as isize;
let current = self.selected_attempt as isize;
let mut next = current + delta;
next = ((next % total_isize) + total_isize) % total_isize;
let next = next as usize;
self.selected_attempt = next;
self.apply_selection_to_fields();
true
}
pub fn current_can_apply(&self) -> bool {
matches!(self.current_view, DetailView::Diff)
&& self
.current_attempt()
.and_then(|attempt| attempt.diff_raw.as_ref())
.map(|diff| !diff.is_empty())
.unwrap_or(false)
}
pub fn apply_selection_to_fields(&mut self) {
let (diff_lines, text_lines, prompt) = if let Some(attempt) = self.current_attempt() {
(
attempt.diff_lines.clone(),
attempt.text_lines.clone(),
attempt.prompt.clone(),
)
} else {
self.diff_lines.clear();
self.text_lines.clear();
self.prompt = None;
self.sd.set_content(vec!["<loading attempt>".to_string()]);
return;
};
self.diff_lines = diff_lines.clone();
self.text_lines = text_lines.clone();
self.prompt = prompt;
match self.current_view {
DetailView::Diff => {
if diff_lines.is_empty() {
self.sd.set_content(vec!["<no diff available>".to_string()]);
} else {
self.sd.set_content(diff_lines);
}
}
DetailView::Prompt => {
if text_lines.is_empty() {
self.sd.set_content(vec!["<no output>".to_string()]);
} else {
self.sd.set_content(text_lines);
}
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DetailView {
Diff,
Prompt,
}
/// 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 events were planned; removed for now to keep code minimal.
/// 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>,
prompt: Option<String>,
turn_id: Option<String>,
sibling_turn_ids: Vec<String>,
attempt_placement: Option<i64>,
attempt_status: codex_cloud_tasks_client::AttemptStatus,
},
DetailsFailed {
id: TaskId,
title: String,
error: String,
},
AttemptsLoaded {
id: TaskId,
attempts: Vec<codex_cloud_tasks_client::TurnAttempt>,
},
/// Background completion of new task submission
NewTaskSubmitted(Result<codex_cloud_tasks_client::CreatedTask, String>),
/// Background completion of apply preflight when opening modal or on demand
ApplyPreflightFinished {
id: TaskId,
title: String,
message: String,
level: ApplyResultLevel,
skipped: Vec<String>,
conflicts: Vec<String>,
},
/// Background completion of apply action (actual patch application)
ApplyFinished {
id: TaskId,
result: std::result::Result<codex_cloud_tasks_client::ApplyOutcome, String>,
},
}
// Convenience aliases; currently unused.
#[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_client::CloudBackend for FakeBackend {
async fn list_tasks(
&self,
env: Option<&str>,
) -> codex_cloud_tasks_client::Result<Vec<TaskSummary>> {
let key = env.map(str::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_client::TaskStatus::Ready,
updated_at: Utc::now(),
environment_id: env.map(str::to_string),
environment_label: None,
summary: codex_cloud_tasks_client::DiffSummary::default(),
is_review: false,
attempt_total: Some(1),
});
}
Ok(out)
}
async fn get_task_diff(
&self,
_id: TaskId,
) -> codex_cloud_tasks_client::Result<Option<String>> {
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
"not used in test",
))
}
async fn get_task_messages(
&self,
_id: TaskId,
) -> codex_cloud_tasks_client::Result<Vec<String>> {
Ok(vec![])
}
async fn get_task_text(
&self,
_id: TaskId,
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::TaskText> {
Ok(codex_cloud_tasks_client::TaskText {
prompt: Some("Example prompt".to_string()),
messages: Vec::new(),
turn_id: Some("fake-turn".to_string()),
sibling_turn_ids: Vec::new(),
attempt_placement: Some(0),
attempt_status: codex_cloud_tasks_client::AttemptStatus::Completed,
})
}
async fn list_sibling_attempts(
&self,
_task: TaskId,
_turn_id: String,
) -> codex_cloud_tasks_client::Result<Vec<codex_cloud_tasks_client::TurnAttempt>> {
Ok(Vec::new())
}
async fn apply_task(
&self,
_id: TaskId,
_diff_override: Option<String>,
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::ApplyOutcome> {
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
"not used in test",
))
}
async fn apply_task_preflight(
&self,
_id: TaskId,
_diff_override: Option<String>,
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::ApplyOutcome> {
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
"not used in test",
))
}
async fn create_task(
&self,
_env_id: &str,
_prompt: &str,
_git_ref: &str,
_qa_mode: bool,
_attachments: &[codex_cloud_tasks_client::AttachmentReference],
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::CreatedTask> {
Err(codex_cloud_tasks_client::CloudTaskError::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,226 @@
pub mod upload;
pub use upload::AttachmentAssetPointer;
pub use upload::AttachmentId;
pub use upload::AttachmentUploadError;
pub use upload::AttachmentUploadMode;
pub use upload::AttachmentUploadProgress;
pub use upload::AttachmentUploadState;
pub use upload::AttachmentUploadUpdate;
pub use upload::AttachmentUploader;
pub use upload::HttpConfig as AttachmentUploadHttpConfig;
pub use upload::pointer_id_from_value;
use serde::Deserialize;
use serde::Serialize;
const MAX_SUGGESTIONS: usize = 5;
/// The type of attachment included alongside a composer submission.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AttachmentKind {
File,
Image,
}
/// Metadata describing a file or asset attached via an `@` mention.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComposerAttachment {
pub kind: AttachmentKind,
pub label: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fs_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
#[serde(skip, default)]
pub id: AttachmentId,
#[serde(skip_serializing, skip_deserializing)]
pub upload: AttachmentUploadState,
}
impl ComposerAttachment {
pub fn from_suggestion(id: AttachmentId, suggestion: &MentionSuggestion) -> Self {
Self {
kind: AttachmentKind::File,
label: suggestion.label.clone(),
path: suggestion.path.clone(),
fs_path: suggestion.fs_path.clone(),
start_line: suggestion.start_line,
end_line: suggestion.end_line,
id,
upload: AttachmentUploadState::default(),
}
}
}
/// UI state for the active `@` mention query inside the composer.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MentionQueryState {
pub current: Option<MentionToken>,
}
impl MentionQueryState {
/// Returns true when the stored token changed.
pub fn update_from(&mut self, token: Option<String>) -> bool {
let next = token.map(MentionToken::from_query);
if next != self.current {
self.current = next;
return true;
}
false
}
}
/// Represents an `@` mention currently under the user's cursor.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MentionToken {
/// Query string without the leading `@`.
pub query: String,
/// Raw token including the `@` prefix.
pub raw: String,
}
impl MentionToken {
pub(crate) fn from_query(query: String) -> Self {
let raw = format!("@{query}");
Self { query, raw }
}
}
/// A suggested file (or range within a file) that matches the active `@` token.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MentionSuggestion {
pub label: String,
pub path: String,
pub fs_path: Option<String>,
pub start_line: Option<u32>,
pub end_line: Option<u32>,
}
impl MentionSuggestion {
pub fn new(label: impl Into<String>, path: impl Into<String>) -> Self {
Self {
label: label.into(),
path: path.into(),
fs_path: None,
start_line: None,
end_line: None,
}
}
}
/// Tracks suggestion list + selection for the mention picker overlay.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MentionPickerState {
suggestions: Vec<MentionSuggestion>,
selected: usize,
}
impl MentionPickerState {
pub fn clear(&mut self) -> bool {
if self.suggestions.is_empty() {
return false;
}
self.suggestions.clear();
self.selected = 0;
true
}
pub fn move_selection(&mut self, delta: isize) {
if self.suggestions.is_empty() {
return;
}
let len = self.suggestions.len() as isize;
let mut idx = self.selected as isize + delta;
if idx < 0 {
idx = len - 1;
}
if idx >= len {
idx = 0;
}
self.selected = idx as usize;
}
pub fn selected_index(&self) -> usize {
self.selected.min(self.suggestions.len().saturating_sub(1))
}
pub fn current(&self) -> Option<&MentionSuggestion> {
self.suggestions.get(self.selected_index())
}
pub fn render_height(&self) -> u16 {
let rows = self.suggestions.len().clamp(1, MAX_SUGGESTIONS) as u16;
// Add borders + padding space.
rows.saturating_add(2)
}
pub fn items(&self) -> &[MentionSuggestion] {
&self.suggestions
}
pub fn set_suggestions(&mut self, suggestions: Vec<MentionSuggestion>) -> bool {
let mut trimmed = suggestions;
if trimmed.len() > MAX_SUGGESTIONS {
trimmed.truncate(MAX_SUGGESTIONS);
}
if trimmed == self.suggestions {
return false;
}
self.suggestions = trimmed;
self.selected = 0;
true
}
}
#[cfg(test)]
mod tests {
use super::AttachmentUploadState;
use super::*;
#[test]
fn compose_attachment_from_suggestion_copies_fields() {
let mut suggestion = MentionSuggestion::new("src/main.rs", "src/main.rs");
suggestion.fs_path = Some("/repo/src/main.rs".to_string());
suggestion.start_line = Some(10);
suggestion.end_line = Some(20);
let att = ComposerAttachment::from_suggestion(AttachmentId::new(42), &suggestion);
assert_eq!(att.label, "src/main.rs");
assert_eq!(att.path, "src/main.rs");
assert_eq!(att.fs_path.as_deref(), Some("/repo/src/main.rs"));
assert_eq!(att.start_line, Some(10));
assert_eq!(att.end_line, Some(20));
assert!(matches!(att.upload, AttachmentUploadState::NotStarted));
assert_eq!(att.id.raw(), 42);
}
#[test]
fn move_selection_wraps() {
let _token = MentionToken::from_query("foo".to_string());
let mut picker = MentionPickerState::default();
assert!(picker.set_suggestions(vec![
MentionSuggestion::new("src/foo.rs", "src/foo.rs"),
MentionSuggestion::new("src/main.rs", "src/main.rs"),
]));
picker.move_selection(1);
assert_eq!(
picker.selected_index(),
1.min(picker.items().len().saturating_sub(1))
);
picker.move_selection(-1);
assert_eq!(picker.selected_index(), 0);
}
#[test]
fn refresh_none_clears_suggestions() {
let _token = MentionToken::from_query("bar".to_string());
let mut picker = MentionPickerState::default();
assert!(
picker.set_suggestions(vec![MentionSuggestion::new("docs/bar.md", "docs/bar.md",)])
);
assert!(picker.clear());
assert!(picker.items().is_empty());
}
}

View File

@@ -0,0 +1,605 @@
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use crate::util::append_error_log;
use chrono::Local;
use mime_guess::MimeGuess;
use reqwest::Client;
use serde::Deserialize;
use serde::Serialize;
use tokio::sync::mpsc;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::UnboundedSender;
use tracing::debug;
use tracing::warn;
use url::Url;
const UPLOAD_USE_CASE: &str = "codex";
/// Stable identifier assigned to each staged attachment.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AttachmentId(pub u64);
impl AttachmentId {
pub const fn new(raw: u64) -> Self {
Self(raw)
}
pub const fn raw(self) -> u64 {
self.0
}
}
/// Represents the lifecycle of an attachment upload initiated after an `@` mention.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AttachmentUploadState {
NotStarted,
Uploading(AttachmentUploadProgress),
Uploaded(AttachmentUploadSuccess),
Failed(AttachmentUploadError),
}
impl Default for AttachmentUploadState {
fn default() -> Self {
Self::NotStarted
}
}
impl AttachmentUploadState {
pub fn is_pending(&self) -> bool {
matches!(self, Self::NotStarted | Self::Uploading(_))
}
pub fn is_uploaded(&self) -> bool {
matches!(self, Self::Uploaded(_))
}
pub fn is_failed(&self) -> bool {
matches!(self, Self::Failed(_))
}
}
/// Progress for uploads where the total size is known.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AttachmentUploadProgress {
pub uploaded_bytes: u64,
pub total_bytes: Option<u64>,
}
impl AttachmentUploadProgress {
pub fn new(uploaded_bytes: u64, total_bytes: Option<u64>) -> Self {
Self {
uploaded_bytes,
total_bytes,
}
}
}
/// Successful upload metadata containing the remote pointer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AttachmentUploadSuccess {
pub asset_pointer: AttachmentAssetPointer,
pub display_name: String,
}
impl AttachmentUploadSuccess {
pub fn new(asset_pointer: AttachmentAssetPointer, display_name: impl Into<String>) -> Self {
Self {
asset_pointer,
display_name: display_name.into(),
}
}
}
/// Describes the remote asset pointer returned by the file service.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AttachmentAssetPointer {
pub kind: AttachmentPointerKind,
pub value: String,
}
impl AttachmentAssetPointer {
pub fn new(kind: AttachmentPointerKind, value: impl Into<String>) -> Self {
Self {
kind,
value: value.into(),
}
}
}
/// High-level pointer type so we can support both single file and container uploads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttachmentPointerKind {
File,
Image,
#[allow(dead_code)]
Container,
}
impl fmt::Display for AttachmentPointerKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::File => write!(f, "file"),
Self::Image => write!(f, "image"),
Self::Container => write!(f, "container"),
}
}
}
/// Captures a user-visible error when uploading an attachment fails.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AttachmentUploadError {
pub message: String,
}
impl AttachmentUploadError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for AttachmentUploadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
/// Internal update emitted by the background uploader task.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AttachmentUploadUpdate {
Started {
id: AttachmentId,
total_bytes: Option<u64>,
},
Finished {
id: AttachmentId,
result: Result<AttachmentUploadSuccess, AttachmentUploadError>,
},
}
/// Configuration for attachment uploads.
#[derive(Clone, Debug)]
pub enum AttachmentUploadMode {
Disabled,
#[cfg_attr(not(test), allow(dead_code))]
ImmediateSuccess,
Http(HttpConfig),
}
#[derive(Clone, Debug)]
pub struct HttpConfig {
pub base_url: String,
pub bearer_token: Option<String>,
pub chatgpt_account_id: Option<String>,
pub user_agent: Option<String>,
}
impl HttpConfig {
fn trimmed_base(&self) -> String {
self.base_url.trim_end_matches('/').to_string()
}
}
#[derive(Clone)]
enum AttachmentUploadBackend {
Disabled,
ImmediateSuccess,
Http(Arc<AttachmentUploadHttp>),
}
#[derive(Clone)]
struct AttachmentUploadHttp {
client: Client,
base_url: String,
bearer_token: Option<String>,
chatgpt_account_id: Option<String>,
user_agent: Option<String>,
}
impl AttachmentUploadHttp {
fn apply_default_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let mut b = builder;
if let Some(token) = &self.bearer_token {
b = b.bearer_auth(token);
}
if let Some(acc) = &self.chatgpt_account_id {
b = b.header("ChatGPT-Account-Id", acc);
}
if let Some(ua) = &self.user_agent {
b = b.header(reqwest::header::USER_AGENT, ua.clone());
}
b
}
}
/// Bookkeeping for in-flight attachment uploads, providing polling APIs for the UI thread.
pub struct AttachmentUploader {
update_tx: UnboundedSender<AttachmentUploadUpdate>,
update_rx: UnboundedReceiver<AttachmentUploadUpdate>,
inflight: HashMap<AttachmentId, Arc<AtomicBool>>,
backend: AttachmentUploadBackend,
}
impl AttachmentUploader {
pub fn new(mode: AttachmentUploadMode) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let backend = match mode {
AttachmentUploadMode::Disabled => AttachmentUploadBackend::Disabled,
AttachmentUploadMode::ImmediateSuccess => AttachmentUploadBackend::ImmediateSuccess,
AttachmentUploadMode::Http(cfg) => match Client::builder().build() {
Ok(client) => AttachmentUploadBackend::Http(Arc::new(AttachmentUploadHttp {
client,
base_url: cfg.trimmed_base(),
bearer_token: cfg.bearer_token,
chatgpt_account_id: cfg.chatgpt_account_id,
user_agent: cfg.user_agent,
})),
Err(err) => {
warn!("attachment_upload.http_client_init_failed: {err}");
AttachmentUploadBackend::Disabled
}
},
};
Self {
update_tx: tx,
update_rx: rx,
inflight: HashMap::new(),
backend,
}
}
pub fn start_upload(
&mut self,
id: AttachmentId,
display_name: impl Into<String>,
fs_path: PathBuf,
) -> Result<(), AttachmentUploadError> {
if self.inflight.contains_key(&id) {
return Err(AttachmentUploadError::new("upload already queued"));
}
if let AttachmentUploadBackend::Disabled = &self.backend {
return Err(AttachmentUploadError::new(
"file uploads are not available in this environment",
));
}
if !is_supported_image(&fs_path) {
return Err(AttachmentUploadError::new(
"only image files can be uploaded",
));
}
let cancel_token = Arc::new(AtomicBool::new(false));
self.inflight.insert(id, cancel_token.clone());
let tx = self.update_tx.clone();
let backend = self.backend.clone();
let path_clone = fs_path.clone();
let label = display_name.into();
tokio::spawn(async move {
let metadata = tokio::fs::metadata(&fs_path).await.ok();
let total_bytes = metadata.as_ref().map(std::fs::Metadata::len);
let _ = tx.send(AttachmentUploadUpdate::Started { id, total_bytes });
if cancel_token.load(Ordering::Relaxed) {
let _ = tx.send(AttachmentUploadUpdate::Finished {
id,
result: Err(AttachmentUploadError::new("upload canceled")),
});
return;
}
let result = match backend {
AttachmentUploadBackend::Disabled => Err(AttachmentUploadError::new(
"file uploads are not available in this environment",
)),
AttachmentUploadBackend::ImmediateSuccess => {
let pointer = AttachmentAssetPointer::new(
AttachmentPointerKind::File,
format!("file-service://mock-{}", id.raw()),
);
Ok(AttachmentUploadSuccess::new(pointer, label.clone()))
}
AttachmentUploadBackend::Http(http) => {
perform_http_upload(
http,
&path_clone,
&label,
total_bytes,
cancel_token.clone(),
)
.await
}
};
let _ = tx.send(AttachmentUploadUpdate::Finished { id, result });
});
Ok(())
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn cancel_all(&mut self) {
for cancel in self.inflight.values() {
cancel.store(true, Ordering::Relaxed);
}
}
pub fn poll(&mut self) -> Vec<AttachmentUploadUpdate> {
let mut out = Vec::new();
while let Ok(update) = self.update_rx.try_recv() {
if let AttachmentUploadUpdate::Finished { id, .. } = &update {
self.inflight.remove(id);
}
out.push(update);
}
out
}
}
impl Default for AttachmentUploader {
fn default() -> Self {
Self::new(AttachmentUploadMode::Disabled)
}
}
async fn perform_http_upload(
http: Arc<AttachmentUploadHttp>,
fs_path: &Path,
display_label: &str,
total_bytes: Option<u64>,
cancel_token: Arc<AtomicBool>,
) -> Result<AttachmentUploadSuccess, AttachmentUploadError> {
let file_bytes = tokio::fs::read(fs_path)
.await
.map_err(|e| AttachmentUploadError::new(format!("failed to read file: {e}")))?;
if cancel_token.load(Ordering::Relaxed) {
return Err(AttachmentUploadError::new("upload canceled"));
}
let file_name = fs_path
.file_name()
.and_then(|s| s.to_str())
.map(std::string::ToString::to_string)
.unwrap_or_else(|| display_label.to_string());
let create_url = format!("{}/files", http.base_url);
let body = CreateFileRequest {
file_name: &file_name,
file_size: total_bytes.unwrap_or(file_bytes.len() as u64),
use_case: UPLOAD_USE_CASE,
timezone_offset_min: (Local::now().offset().utc_minus_local() / 60),
reset_rate_limits: false,
};
let create_resp = http
.apply_default_headers(http.client.post(&create_url))
.json(&body)
.send()
.await
.map_err(|e| AttachmentUploadError::new(format!("file create failed: {e}")))?;
if !create_resp.status().is_success() {
let status = create_resp.status();
let text = create_resp.text().await.unwrap_or_default();
return Err(AttachmentUploadError::new(format!(
"file create request failed status={status} body={text}"
)));
}
let created: CreateFileResponse = create_resp
.json()
.await
.map_err(|e| AttachmentUploadError::new(format!("decode file create response: {e}")))?;
if cancel_token.load(Ordering::Relaxed) {
return Err(AttachmentUploadError::new("upload canceled"));
}
let upload_url = resolve_upload_url(&created.upload_url)
.ok_or_else(|| AttachmentUploadError::new("invalid upload url"))?;
let mime = infer_image_mime(fs_path)
.ok_or_else(|| AttachmentUploadError::new("only image files can be uploaded"))?;
let mut azure_req = http.client.put(&upload_url);
azure_req = azure_req
.header("x-ms-blob-type", "BlockBlob")
.header("x-ms-version", "2020-04-08");
azure_req = azure_req
.header(reqwest::header::CONTENT_TYPE, mime.as_str())
.header("x-ms-blob-content-type", mime.as_str());
let azure_resp = azure_req
.body(file_bytes)
.send()
.await
.map_err(|e| AttachmentUploadError::new(format!("blob upload failed: {e}")))?;
if !(200..300).contains(&azure_resp.status().as_u16()) {
let status = azure_resp.status();
let text = azure_resp.text().await.unwrap_or_default();
return Err(AttachmentUploadError::new(format!(
"blob upload failed status={status} body={text}"
)));
}
if cancel_token.load(Ordering::Relaxed) {
return Err(AttachmentUploadError::new("upload canceled"));
}
// Finalization must succeed so the pointer can be used; surface any failure
// to the caller after logging for easier debugging.
if let Err(err) = finalize_upload(http.clone(), &created.file_id, &file_name).await {
let reason = err.message.clone();
warn!(
"mention.attachment.upload.finalize_failed file_id={} reason={reason}",
created.file_id
);
append_error_log(format!(
"mention.attachment.upload.finalize_failed file_id={} reason={reason}",
created.file_id
));
return Err(err);
}
let pointer = asset_pointer_from_id(&created.file_id);
debug!(
"mention.attachment.upload.success file_id={} pointer={}",
created.file_id, pointer
);
let pointer_kind = AttachmentPointerKind::Image;
Ok(AttachmentUploadSuccess::new(
AttachmentAssetPointer::new(pointer_kind, pointer),
display_label,
))
}
fn asset_pointer_from_id(file_id: &str) -> String {
if file_id.starts_with("file_") {
format!("sediment://{file_id}")
} else {
format!("file-service://{file_id}")
}
}
pub fn pointer_id_from_value(pointer: &str) -> Option<String> {
pointer
.strip_prefix("file-service://")
.or_else(|| pointer.strip_prefix("sediment://"))
.map(str::to_string)
.or_else(|| (!pointer.is_empty()).then(|| pointer.to_string()))
}
async fn finalize_upload(
http: Arc<AttachmentUploadHttp>,
file_id: &str,
file_name: &str,
) -> Result<(), AttachmentUploadError> {
let finalize_url = format!("{}/files/process_upload_stream", http.base_url);
let body = FinalizeUploadRequest {
file_id,
use_case: UPLOAD_USE_CASE,
index_for_retrieval: false,
file_name,
};
let finalize_resp = http
.apply_default_headers(http.client.post(&finalize_url))
.json(&body)
.send()
.await
.map_err(|e| AttachmentUploadError::new(format!("finalize upload failed: {e}")))?;
if !finalize_resp.status().is_success() {
let status = finalize_resp.status();
let text = finalize_resp.text().await.unwrap_or_default();
return Err(AttachmentUploadError::new(format!(
"finalize upload failed status={status} body={text}"
)));
}
Ok(())
}
fn resolve_upload_url(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
if !parsed.as_str().to_lowercase().contains("estuary") {
return Some(parsed.into());
}
parsed
.query_pairs()
.find(|(k, _)| k == "upload_url")
.map(|(_, v)| v.into_owned())
}
#[derive(Serialize)]
struct CreateFileRequest<'a> {
file_name: &'a str,
file_size: u64,
use_case: &'a str,
timezone_offset_min: i32,
reset_rate_limits: bool,
}
#[derive(Serialize)]
struct FinalizeUploadRequest<'a> {
file_id: &'a str,
use_case: &'a str,
index_for_retrieval: bool,
file_name: &'a str,
}
#[derive(Deserialize)]
struct CreateFileResponse {
file_id: String,
upload_url: String,
}
fn is_supported_image(path: &Path) -> bool {
infer_image_mime(path).is_some()
}
fn infer_image_mime(path: &Path) -> Option<String> {
let guess = MimeGuess::from_path(path)
.first_raw()
.map(std::string::ToString::to_string);
if let Some(m) = guess {
if m.starts_with("image/") {
return Some(m);
}
}
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.trim().to_ascii_lowercase())?;
let mime = match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"svg" => "image/svg+xml",
"heic" => "image/heic",
"heif" => "image/heif",
_ => return None,
};
Some(mime.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn infer_image_mime_accepts_common_extensions() {
let cases = [
("foo.png", Some("image/png")),
("bar.JPG", Some("image/jpeg")),
("baz.jpeg", Some("image/jpeg")),
("img.gif", Some("image/gif")),
("slide.WEBP", Some("image/webp")),
("art.bmp", Some("image/bmp")),
("vector.svg", Some("image/svg+xml")),
("photo.heic", Some("image/heic")),
("photo.heif", Some("image/heif")),
];
for (path, expected) in cases {
let actual = infer_image_mime(Path::new(path));
assert_eq!(actual.as_deref(), expected, "case {path}");
}
}
#[test]
fn infer_image_mime_rejects_unknown_extension() {
assert!(infer_image_mime(Path::new("doc.txt")).is_none());
}
}

View File

@@ -0,0 +1,106 @@
use codex_backend_client::Client as BackendClient;
use codex_cloud_tasks::util::extract_chatgpt_account_id;
use codex_cloud_tasks::util::normalize_base_url;
use codex_cloud_tasks::util::set_user_agent_suffix;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthManager;
use std::time::Duration;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Base URL (default to ChatGPT backend API) and normalize to canonical form
let raw_base = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
let base_url = normalize_base_url(&raw_base);
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
set_user_agent_suffix("codex_cloud_tasks_conncheck");
let ua = get_codex_user_agent();
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);
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);
}
// Keep output concise; omit full JSON payload to stay readable.
}
}
Ok(())
}

View File

@@ -0,0 +1,45 @@
use codex_backend_client::Client as BackendClient;
use codex_cloud_tasks::util::set_user_agent_suffix;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthManager;
#[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());
set_user_agent_suffix("codex_cloud_tasks_detailcheck");
let ua = get_codex_user_agent();
let mut client = BackendClient::new(base_url)?.with_user_agent(ua);
if let Ok(home) = find_codex_home() {
let am = AuthManager::new(home);
if let Some(auth) = am.auth()
&& 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,136 @@
use base64::Engine;
use clap::Parser;
use codex_cloud_tasks::util::set_user_agent_suffix;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthManager;
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!("{base_url}/backend-api");
}
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
set_user_agent_suffix("codex_cloud_tasks_envcheck");
let ua = get_codex_user_agent();
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);
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")
&& 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(str::to_string)
}

View File

@@ -0,0 +1,206 @@
use base64::Engine;
use clap::Parser;
use codex_cloud_tasks::util::set_user_agent_suffix;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthManager;
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!("{base_url}/backend-api");
}
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
set_user_agent_suffix("codex_cloud_tasks_newtask");
let ua = get_codex_user_agent();
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);
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")
&& 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!("{base_url}/wham/tasks")
} else {
format!("{base_url}/api/codex/tasks")
};
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")
&& !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(str::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,405 @@
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>,
#[serde(default)]
repo_map: Option<HashMap<String, GitRepository>>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct GitRepository {
#[serde(default)]
repository_full_name: Option<String>,
#[serde(default)]
default_branch: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AutodetectSelection {
pub id: String,
pub label: Option<String>,
pub default_branch: Option<String>,
}
fn clean_branch(branch: Option<&str>) -> Option<String> {
branch
.map(str::trim)
.filter(|s| !s.is_empty())
.map(std::string::ToString::to_string)
}
fn default_branch_from_env(env: &CodeEnvironment, repo_hint: Option<&str>) -> Option<String> {
let repo_map = env.repo_map.as_ref()?;
if let Some(hint) = repo_hint {
if let Some(repo) = repo_map
.values()
.find(|repo| repo.repository_full_name.as_deref() == Some(hint))
&& let Some(branch) = clean_branch(repo.default_branch.as_deref())
{
return Some(branch);
}
if let Some(repo) = repo_map.get(hint)
&& let Some(branch) = clean_branch(repo.default_branch.as_deref())
{
return Some(branch);
}
}
repo_map
.values()
.find_map(|repo| clean_branch(repo.default_branch.as_deref()))
}
fn merge_environment_row(
map: &mut HashMap<String, crate::app::EnvironmentRow>,
env: &CodeEnvironment,
repo_hint: Option<&str>,
) {
let default_branch = default_branch_from_env(env, repo_hint);
let repo_hint_owned = repo_hint.map(str::to_string);
let entry = map
.entry(env.id.clone())
.or_insert_with(|| crate::app::EnvironmentRow {
id: env.id.clone(),
label: env.label.clone(),
is_pinned: env.is_pinned.unwrap_or(false),
repo_hints: repo_hint_owned.clone(),
default_branch: default_branch.clone(),
});
if entry.label.is_none() {
entry.label = env.label.clone();
}
entry.is_pinned = entry.is_pinned || env.is_pinned.unwrap_or(false);
if entry.repo_hints.is_none() {
entry.repo_hints = repo_hint_owned;
}
if let Some(branch) = default_branch {
entry.default_branch = Some(branch);
}
}
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 {owner}/{repo}",
list.len(),
));
by_repo_envs.append(&mut list);
}
Err(e) => crate::append_error_log(format!(
"env: by-repo fetch failed for {owner}/{repo}: {e}"
)),
}
}
}
if let Some(env) = pick_environment_row(&by_repo_envs, desired_label.as_deref()) {
return Ok(AutodetectSelection {
id: env.id.clone(),
label: env.label.as_deref().map(str::to_owned),
default_branch: default_branch_from_env(&env, None),
});
}
// 2) Fallback to the full list
let list_url = if base_url.contains("/backend-api") {
format!("{base_url}/wham/environments")
} else {
format!("{base_url}/api/codex/environments")
};
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={status} content-type={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!("GET {list_url} failed: {status}; content-type={ct}; body={body}");
}
let all_envs: Vec<CodeEnvironment> = serde_json::from_str(&body).map_err(|e| {
anyhow::anyhow!("Decode error for {list_url}: {e}; content-type={ct}; body={body}")
})?;
if let Some(env) = pick_environment_row(&all_envs, desired_label.as_deref()) {
return Ok(AutodetectSelection {
id: env.id.clone(),
label: env.label.as_deref().map(str::to_owned),
default_branch: default_branch_from_env(&env, None),
});
}
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={status} content-type={ct}"));
if !status.is_success() {
anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}");
}
let parsed = serde_json::from_str::<T>(&body).map_err(|e| {
anyhow::anyhow!("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
&& 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
&& 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 env in list {
let repo_hint = format!("{owner}/{repo}");
merge_environment_row(&mut map, &env, Some(repo_hint.as_str()));
}
}
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!("{base_url}/wham/environments")
} else {
format!("{base_url}/api/codex/environments")
};
match get_json::<Vec<CodeEnvironment>>(&list_url, headers).await {
Ok(list) => {
info!("env_tui: global list -> {} envs", list.len());
for env in list {
merge_environment_row(&mut map, &env, None);
}
}
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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
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;
// Force rewrap on next set_width even if width is unchanged
self.wrap_cols = None;
}
/// 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(String::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();
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);
}
}
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
use base64::Engine as _;
use chrono::Utc;
use reqwest::header::HeaderMap;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
pub fn set_user_agent_suffix(suffix: &str) {
if let Ok(mut guard) = codex_core::default_client::USER_AGENT_SUFFIX.lock() {
guard.replace(suffix.to_string());
}
}
pub fn append_error_log(message: impl AsRef<str>) {
let message = message.as_ref();
let timestamp = Utc::now().to_rfc3339();
if let Some(path) = log_file_path()
&& write_log_line(&path, &timestamp, message)
{
return;
}
let fallback = Path::new("error.log");
let _ = write_log_line(fallback, &timestamp, message);
}
/// Normalize the configured base URL to a canonical form used by the backend client.
/// - trims trailing '/'
/// - appends '/backend-api' for ChatGPT hosts when missing
pub fn normalize_base_url(input: &str) -> String {
let mut base_url = input.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!("{base_url}/backend-api");
}
base_url
}
fn log_file_path() -> Option<PathBuf> {
let mut log_dir = codex_core::config::find_codex_home().ok()?;
log_dir.push("log");
std::fs::create_dir_all(&log_dir).ok()?;
Some(log_dir.join("codex-cloud-tasks.log"))
}
fn write_log_line(path: &Path, timestamp: &str, message: &str) -> bool {
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
match opts.open(path) {
Ok(mut file) => {
use std::io::Write as _;
writeln!(file, "[{timestamp}] {message}").is_ok()
}
Err(_) => false,
}
}
/// Extract the ChatGPT account id from a JWT token, when present.
pub fn extract_chatgpt_account_id(token: &str) -> Option<String> {
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(str::to_string)
}
pub fn switch_to_branch(branch: &str) -> Result<(), String> {
let branch = branch.trim();
if branch.is_empty() {
return Err("default branch name is empty".to_string());
}
if let Ok(current) = current_branch()
&& current == branch
{
append_error_log(format!("git.switch: already on branch {branch}"));
return Ok(());
}
append_error_log(format!("git.switch: switching to branch {branch}"));
match ensure_success(&["checkout", branch]) {
Ok(()) => Ok(()),
Err(err) => {
append_error_log(format!("git.switch: checkout {branch} failed: {err}"));
if ensure_success(&["rev-parse", "--verify", branch]).is_ok() {
return Err(err);
}
if let Err(fetch_err) = ensure_success(&["fetch", "origin", branch]) {
append_error_log(format!(
"git.switch: fetch origin/{branch} failed: {fetch_err}"
));
return Err(err);
}
let tracking = format!("origin/{branch}");
ensure_success(&["checkout", "-b", branch, &tracking]).map_err(|create_err| {
append_error_log(format!(
"git.switch: checkout -b {branch} {tracking} failed: {create_err}"
));
create_err
})
}
}
}
fn current_branch() -> Result<String, String> {
let output = run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
if !output.status.success() {
return Err(format!(
"git rev-parse --abbrev-ref failed: {}",
format_command_failure(output, &["rev-parse", "--abbrev-ref", "HEAD"])
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn ensure_success(args: &[&str]) -> Result<(), String> {
let output = run_git(args)?;
if output.status.success() {
return Ok(());
}
Err(format_command_failure(output, args))
}
fn run_git(args: &[&str]) -> Result<std::process::Output, String> {
Command::new("git")
.args(args)
.output()
.map_err(|e| format!("failed to launch git {}: {e}", join_args(args)))
}
fn format_command_failure(output: std::process::Output, args: &[&str]) -> String {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
format!(
"git {} exited with status {}. stdout: {} stderr: {}",
join_args(args),
output
.status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "<signal>".to_string()),
stdout.trim(),
stderr.trim()
)
}
fn join_args(args: &[&str]) -> String {
args.join(" ")
}
/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`,
/// and optional `ChatGPT-Account-Id`.
pub async fn build_chatgpt_headers() -> HeaderMap {
use reqwest::header::AUTHORIZATION;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use reqwest::header::USER_AGENT;
set_user_agent_suffix("codex_cloud_tasks_tui");
let ua = codex_core::default_client::get_codex_user_agent();
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
if let Ok(home) = codex_core::config::find_codex_home() {
let am = codex_login::AuthManager::new(home);
if let Some(auth) = am.auth()
&& let Ok(tok) = auth.get_token().await
&& !tok.is_empty()
{
let v = format!("Bearer {tok}");
if let Ok(hv) = HeaderValue::from_str(&v) {
headers.insert(AUTHORIZATION, hv);
}
if let Some(acc) = auth
.get_account_id()
.or_else(|| extract_chatgpt_account_id(&tok))
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
&& let Ok(hv) = HeaderValue::from_str(&acc)
{
headers.insert(name, hv);
}
}
}
headers
}

View File

@@ -0,0 +1,22 @@
use codex_cloud_tasks_client::CloudBackend;
use codex_cloud_tasks_client::MockClient;
#[tokio::test]
async fn mock_backend_varies_by_env() {
let client = MockClient;
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

@@ -415,7 +415,7 @@ fn set_project_trusted_inner(doc: &mut DocumentMut, project_path: &Path) -> anyh
.get_mut(project_key.as_str())
.and_then(|i| i.as_table_mut())
else {
return Err(anyhow::anyhow!("project table missing for {}", project_key));
return Err(anyhow::anyhow!("project table missing for {project_key}"));
};
proj_tbl.set_implicit(false);
proj_tbl["trust_level"] = toml_edit::value("trusted");

View File

@@ -186,8 +186,7 @@ impl McpConnectionManager {
// Validate server name before spawning
if !is_valid_mcp_server_name(&server_name) {
let error = anyhow::anyhow!(
"invalid server name '{}': must match pattern ^[a-zA-Z0-9_-]+$",
server_name
"invalid server name '{server_name}': must match pattern ^[a-zA-Z0-9_-]+$"
);
errors.insert(server_name, error);
continue;

View File

@@ -0,0 +1,17 @@
[package]
name = "codex-git-apply"
version = { workspace = true }
edition = "2024"
[lib]
name = "codex_git_apply"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
once_cell = "1"
regex = "1"
tempfile = "3"

View File

@@ -0,0 +1,647 @@
use once_cell::sync::Lazy;
use regex::Regex;
use std::ffi::OsStr;
use std::io;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct ApplyGitRequest {
pub cwd: PathBuf,
pub diff: String,
pub revert: bool,
pub preflight: bool,
}
#[derive(Debug, Clone)]
pub struct ApplyGitResult {
pub exit_code: i32,
pub applied_paths: Vec<String>,
pub skipped_paths: Vec<String>,
pub conflicted_paths: Vec<String>,
pub stdout: String,
pub stderr: String,
pub cmd_for_log: String,
}
pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
let git_root = resolve_git_root(&req.cwd)?;
// Write unified diff into a temporary file
let (tmpdir, patch_path) = write_temp_patch(&req.diff)?;
// Keep tmpdir alive until function end to ensure the file exists
let _guard = tmpdir;
if req.revert {
// Stage WT paths first to avoid index mismatch on revert.
stage_paths(&git_root, &req.diff)?;
}
// Build git args
let mut args: Vec<String> = vec!["apply".into(), "--3way".into()];
if req.revert {
args.push("-R".into());
}
// Optional: additional git config via env knob (defaults OFF)
let mut cfg_parts: Vec<String> = Vec::new();
if let Ok(cfg) = std::env::var("CODEX_APPLY_GIT_CFG") {
for pair in cfg.split(',') {
let p = pair.trim();
if p.is_empty() || !p.contains('=') {
continue;
}
cfg_parts.push("-c".into());
cfg_parts.push(p.to_string());
}
}
args.push(patch_path.to_string_lossy().to_string());
// Optional preflight: dry-run only; do not modify working tree
if req.preflight {
let mut check_args = vec!["apply".to_string(), "--check".to_string()];
if req.revert {
check_args.push("-R".to_string());
}
check_args.push(patch_path.to_string_lossy().to_string());
let rendered = render_command_for_log(&git_root, &cfg_parts, &check_args);
let (c_code, c_out, c_err) = run_git(&git_root, &cfg_parts, &check_args)?;
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
parse_git_apply_output(&c_out, &c_err);
applied_paths.sort();
applied_paths.dedup();
skipped_paths.sort();
skipped_paths.dedup();
conflicted_paths.sort();
conflicted_paths.dedup();
return Ok(ApplyGitResult {
exit_code: c_code,
applied_paths,
skipped_paths,
conflicted_paths,
stdout: c_out,
stderr: c_err,
cmd_for_log: rendered,
});
}
let cmd_for_log = render_command_for_log(&git_root, &cfg_parts, &args);
let (code, stdout, stderr) = run_git(&git_root, &cfg_parts, &args)?;
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
parse_git_apply_output(&stdout, &stderr);
applied_paths.sort();
applied_paths.dedup();
skipped_paths.sort();
skipped_paths.dedup();
conflicted_paths.sort();
conflicted_paths.dedup();
Ok(ApplyGitResult {
exit_code: code,
applied_paths,
skipped_paths,
conflicted_paths,
stdout,
stderr,
cmd_for_log,
})
}
fn resolve_git_root(cwd: &Path) -> io::Result<PathBuf> {
let out = std::process::Command::new("git")
.arg("rev-parse")
.arg("--show-toplevel")
.current_dir(cwd)
.output()?;
let code = out.status.code().unwrap_or(-1);
if code != 0 {
return Err(io::Error::other(format!(
"not a git repository (exit {}): {}",
code,
String::from_utf8_lossy(&out.stderr)
)));
}
let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(PathBuf::from(root))
}
fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, PathBuf)> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("patch.diff");
std::fs::write(&path, diff)?;
Ok((dir, path))
}
fn run_git(cwd: &Path, git_cfg: &[String], args: &[String]) -> io::Result<(i32, String, String)> {
let mut cmd = std::process::Command::new("git");
for p in git_cfg {
cmd.arg(p);
}
for a in args {
cmd.arg(a);
}
let out = cmd.current_dir(cwd).output()?;
let code = out.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
Ok((code, stdout, stderr))
}
fn quote_shell(s: &str) -> String {
let simple = s
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c));
if simple {
s.to_string()
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push("git".to_string());
for a in git_cfg {
parts.push(quote_shell(a));
}
for a in args {
parts.push(quote_shell(a));
}
format!(
"(cd {} && {})",
quote_shell(&cwd.display().to_string()),
parts.join(" ")
)
}
pub fn extract_paths_from_patch(diff_text: &str) -> Vec<String> {
static RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^diff --git a/(.*?) b/(.*)$")
.unwrap_or_else(|e| panic!("invalid regex: {e}"))
});
let mut set = std::collections::BTreeSet::new();
for caps in RE.captures_iter(diff_text) {
if let Some(a) = caps.get(1).map(|m| m.as_str())
&& a != "/dev/null"
&& !a.trim().is_empty()
{
set.insert(a.to_string());
}
if let Some(b) = caps.get(2).map(|m| m.as_str())
&& b != "/dev/null"
&& !b.trim().is_empty()
{
set.insert(b.to_string());
}
}
set.into_iter().collect()
}
pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
let paths = extract_paths_from_patch(diff);
let mut existing: Vec<String> = Vec::new();
for p in paths {
let joined = git_root.join(&p);
if std::fs::symlink_metadata(&joined).is_ok() {
existing.push(p);
}
}
if existing.is_empty() {
return Ok(());
}
let mut cmd = std::process::Command::new("git");
cmd.arg("add");
cmd.arg("--");
for p in &existing {
cmd.arg(OsStr::new(p));
}
let out = cmd.current_dir(git_root).output()?;
let _code = out.status.code().unwrap_or(-1);
// We do not hard fail staging; best-effort is OK. Return Ok even on non-zero.
Ok(())
}
// ============ Parser ported from VS Code (TS) ============
pub fn parse_git_apply_output(
stdout: &str,
stderr: &str,
) -> (Vec<String>, Vec<String>, Vec<String>) {
let combined = [stdout, stderr]
.iter()
.filter(|s| !s.is_empty())
.cloned()
.collect::<Vec<&str>>()
.join("\n");
let mut applied = std::collections::BTreeSet::new();
let mut skipped = std::collections::BTreeSet::new();
let mut conflicted = std::collections::BTreeSet::new();
let mut last_seen_path: Option<String> = None;
fn add(set: &mut std::collections::BTreeSet<String>, raw: &str) {
let trimmed = raw.trim();
if trimmed.is_empty() {
return;
}
let first = trimmed.chars().next().unwrap_or('\0');
let last = trimmed.chars().last().unwrap_or('\0');
let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 {
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
};
if !unquoted.is_empty() {
set.insert(unquoted.to_string());
}
}
static APPLIED_CLEAN: Lazy<Regex> =
Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+cleanly\\.?$"));
static APPLIED_CONFLICTS: Lazy<Regex> =
Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+with conflicts\\.?$"));
static APPLYING_WITH_REJECTS: Lazy<Regex> = Lazy::new(|| {
regex_ci("^Applying patch\\s+(?P<path>.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$")
});
static CHECKING_PATCH: Lazy<Regex> =
Lazy::new(|| regex_ci("^Checking patch\\s+(?P<path>.+?)\\.\\.\\.$"));
static UNMERGED_LINE: Lazy<Regex> = Lazy::new(|| regex_ci("^U\\s+(?P<path>.+)$"));
static PATCH_FAILED: Lazy<Regex> =
Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)(?::\\d+)?(?:\\s|$)"));
static DOES_NOT_APPLY: Lazy<Regex> =
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+patch does not apply$"));
static THREE_WAY_START: Lazy<Regex> = Lazy::new(|| {
regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$")
});
static THREE_WAY_FAILED: Lazy<Regex> =
Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$"));
static FALLBACK_DIRECT: Lazy<Regex> =
Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$"));
static LACKS_BLOB: Lazy<Regex> = Lazy::new(|| {
regex_ci(
"^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$",
)
});
static INDEX_MISMATCH: Lazy<Regex> =
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not match index\\b"));
static NOT_IN_INDEX: Lazy<Regex> =
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not exist in index\\b"));
static ALREADY_EXISTS_WT: Lazy<Regex> = Lazy::new(|| {
regex_ci("^error:\\s+(?P<path>.+?)\\s+already exists in (?:the )?working directory\\b")
});
static FILE_EXISTS: Lazy<Regex> =
Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)\\s+File exists"));
static RENAMED_DELETED: Lazy<Regex> =
Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P<path>.+?)\\s+has been renamed\\/deleted"));
static CANNOT_APPLY_BINARY: Lazy<Regex> = Lazy::new(|| {
regex_ci(
"^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+without full index line$",
)
});
static BINARY_DOES_NOT_APPLY: Lazy<Regex> = Lazy::new(|| {
regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
});
static BINARY_INCORRECT_RESULT: Lazy<Regex> = Lazy::new(|| {
regex_ci(
"^error:\\s+binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+creates incorrect result\\b",
)
});
static CANNOT_READ_CURRENT: Lazy<Regex> = Lazy::new(|| {
regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
});
static SKIPPED_PATCH: Lazy<Regex> =
Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P<path>.+?)['\\\"]\\.$"));
static CANNOT_MERGE_BINARY_WARN: Lazy<Regex> = Lazy::new(|| {
regex_ci(
"^warning:\\s*Cannot merge binary files:\\s+(?P<path>.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)",
)
});
for raw_line in combined.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
// === "Checking patch <path>..." tracking ===
if let Some(c) = CHECKING_PATCH.captures(line) {
if let Some(m) = c.name("path") {
last_seen_path = Some(m.as_str().to_string());
}
continue;
}
// === Status lines ===
if let Some(c) = APPLIED_CLEAN.captures(line) {
if let Some(m) = c.name("path") {
add(&mut applied, m.as_str());
let p = applied.iter().next_back().cloned();
if let Some(p) = p {
conflicted.remove(&p);
skipped.remove(&p);
last_seen_path = Some(p);
}
}
continue;
}
if let Some(c) = APPLIED_CONFLICTS.captures(line) {
if let Some(m) = c.name("path") {
add(&mut conflicted, m.as_str());
let p = conflicted.iter().next_back().cloned();
if let Some(p) = p {
applied.remove(&p);
skipped.remove(&p);
last_seen_path = Some(p);
}
}
continue;
}
if let Some(c) = APPLYING_WITH_REJECTS.captures(line) {
if let Some(m) = c.name("path") {
add(&mut conflicted, m.as_str());
let p = conflicted.iter().next_back().cloned();
if let Some(p) = p {
applied.remove(&p);
skipped.remove(&p);
last_seen_path = Some(p);
}
}
continue;
}
// === “U <path>” after conflicts ===
if let Some(c) = UNMERGED_LINE.captures(line) {
if let Some(m) = c.name("path") {
add(&mut conflicted, m.as_str());
let p = conflicted.iter().next_back().cloned();
if let Some(p) = p {
applied.remove(&p);
skipped.remove(&p);
last_seen_path = Some(p);
}
}
continue;
}
// === Early hints ===
if PATCH_FAILED.is_match(line) || DOES_NOT_APPLY.is_match(line) {
if let Some(c) = PATCH_FAILED
.captures(line)
.or_else(|| DOES_NOT_APPLY.captures(line))
&& let Some(m) = c.name("path")
{
add(&mut skipped, m.as_str());
last_seen_path = Some(m.as_str().to_string());
}
continue;
}
// === Ignore narration ===
if THREE_WAY_START.is_match(line) || FALLBACK_DIRECT.is_match(line) {
continue;
}
// === 3-way failed entirely; attribute to last_seen_path ===
if THREE_WAY_FAILED.is_match(line) || LACKS_BLOB.is_match(line) {
if let Some(p) = last_seen_path.clone() {
add(&mut skipped, &p);
applied.remove(&p);
conflicted.remove(&p);
}
continue;
}
// === Skips / I/O problems ===
if let Some(c) = INDEX_MISMATCH
.captures(line)
.or_else(|| NOT_IN_INDEX.captures(line))
.or_else(|| ALREADY_EXISTS_WT.captures(line))
.or_else(|| FILE_EXISTS.captures(line))
.or_else(|| RENAMED_DELETED.captures(line))
.or_else(|| CANNOT_APPLY_BINARY.captures(line))
.or_else(|| BINARY_DOES_NOT_APPLY.captures(line))
.or_else(|| BINARY_INCORRECT_RESULT.captures(line))
.or_else(|| CANNOT_READ_CURRENT.captures(line))
.or_else(|| SKIPPED_PATCH.captures(line))
{
if let Some(m) = c.name("path") {
add(&mut skipped, m.as_str());
let p_now = skipped.iter().next_back().cloned();
if let Some(p) = p_now {
applied.remove(&p);
conflicted.remove(&p);
last_seen_path = Some(p);
}
}
continue;
}
// === Warnings that imply conflicts ===
if let Some(c) = CANNOT_MERGE_BINARY_WARN.captures(line) {
if let Some(m) = c.name("path") {
add(&mut conflicted, m.as_str());
let p = conflicted.iter().next_back().cloned();
if let Some(p) = p {
applied.remove(&p);
skipped.remove(&p);
last_seen_path = Some(p);
}
}
continue;
}
}
// Final precedence: conflicts > applied > skipped
for p in conflicted.iter() {
applied.remove(p);
skipped.remove(p);
}
for p in applied.iter() {
skipped.remove(p);
}
(
applied.into_iter().collect(),
skipped.into_iter().collect(),
conflicted.into_iter().collect(),
)
}
fn regex_ci(pat: &str) -> Regex {
Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::sync::Mutex;
use std::sync::OnceLock;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
let out = std::process::Command::new(args[0])
.args(&args[1..])
.current_dir(cwd)
.output()
.expect("spawn ok");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn init_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
// git init and minimal identity
let _ = run(root, &["git", "init"]);
let _ = run(root, &["git", "config", "user.email", "codex@example.com"]);
let _ = run(root, &["git", "config", "user.name", "Codex"]);
dir
}
#[test]
fn apply_add_success() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let root = repo.path();
let diff = "diff --git a/hello.txt b/hello.txt\nnew file mode 100644\n--- /dev/null\n+++ b/hello.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n";
let req = ApplyGitRequest {
cwd: root.to_path_buf(),
diff: diff.to_string(),
revert: false,
preflight: false,
};
let r = apply_git_patch(&req).expect("run apply");
assert_eq!(r.exit_code, 0, "exit code 0");
// File exists now
assert!(root.join("hello.txt").exists());
}
#[test]
fn apply_modify_conflict() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let root = repo.path();
// seed file and commit
std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap();
let _ = run(root, &["git", "add", "file.txt"]);
let _ = run(root, &["git", "commit", "-m", "seed"]);
// local edit (unstaged)
std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap();
// patch wants to change the same line differently
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+remote2\n line3\n";
let req = ApplyGitRequest {
cwd: root.to_path_buf(),
diff: diff.to_string(),
revert: false,
preflight: false,
};
let r = apply_git_patch(&req).expect("run apply");
assert_ne!(r.exit_code, 0, "non-zero exit on conflict");
}
#[test]
fn apply_modify_skipped_missing_index() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let root = repo.path();
// Try to modify a file that is not in the index
let diff = "diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
let req = ApplyGitRequest {
cwd: root.to_path_buf(),
diff: diff.to_string(),
revert: false,
preflight: false,
};
let r = apply_git_patch(&req).expect("run apply");
assert_ne!(r.exit_code, 0, "non-zero exit on missing index");
}
#[test]
fn apply_then_revert_success() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let root = repo.path();
// Seed file and commit original content
std::fs::write(root.join("file.txt"), "orig\n").unwrap();
let _ = run(root, &["git", "add", "file.txt"]);
let _ = run(root, &["git", "commit", "-m", "seed"]);
// Forward patch: orig -> ORIG
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n";
let apply_req = ApplyGitRequest {
cwd: root.to_path_buf(),
diff: diff.to_string(),
revert: false,
preflight: false,
};
let res_apply = apply_git_patch(&apply_req).expect("apply ok");
assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
let after_apply = std::fs::read_to_string(root.join("file.txt")).unwrap();
assert_eq!(after_apply, "ORIG\n");
// Revert patch: ORIG -> orig (stage paths first; engine handles it)
let revert_req = ApplyGitRequest {
cwd: root.to_path_buf(),
diff: diff.to_string(),
revert: true,
preflight: false,
};
let res_revert = apply_git_patch(&revert_req).expect("revert ok");
assert_eq!(res_revert.exit_code, 0, "revert apply succeeded");
let after_revert = std::fs::read_to_string(root.join("file.txt")).unwrap();
assert_eq!(after_revert, "orig\n");
}
#[test]
fn preflight_blocks_partial_changes() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let root = repo.path();
// Build a multi-file diff: one valid add (ok.txt) and one invalid modify (ghost.txt)
let diff = "diff --git a/ok.txt b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1,2 @@\n+alpha\n+beta\n\n\
diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
// 1) With preflight enabled, nothing should be changed (even though ok.txt could be added)
let req1 = ApplyGitRequest {
cwd: root.to_path_buf(),
diff: diff.to_string(),
revert: false,
preflight: true,
};
let r1 = apply_git_patch(&req1).expect("preflight apply");
assert_ne!(r1.exit_code, 0, "preflight reports failure");
assert!(
!root.join("ok.txt").exists(),
"preflight must prevent adding ok.txt"
);
assert!(
r1.cmd_for_log.contains("--check"),
"preflight path recorded --check"
);
// 2) Without preflight, we should see no --check in the executed command
let req2 = ApplyGitRequest {
cwd: root.to_path_buf(),
diff: diff.to_string(),
revert: false,
preflight: false,
};
let r2 = apply_git_patch(&req2).expect("direct apply");
assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");
assert!(
!r2.cmd_for_log.contains("--check"),
"non-preflight path should not use --check"
);
}
}

View File

@@ -274,8 +274,7 @@ impl McpClient {
err.error.code, err.error.message
))),
other => Err(anyhow!(format!(
"unexpected message variant received in reply path: {:?}",
other
"unexpected message variant received in reply path: {other:?}"
))),
}
}

View File

@@ -66,7 +66,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
.status()
.with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?;
if !status.success() {
return Err(anyhow!("Prettier failed with status {}", status));
return Err(anyhow!("Prettier failed with status {status}"));
}
}

View File

@@ -5,23 +5,30 @@ use crate::session_log;
#[derive(Clone, Debug)]
pub(crate) struct AppEventSender {
pub app_event_tx: UnboundedSender<AppEvent>,
app_event_tx: Option<UnboundedSender<AppEvent>>,
}
impl AppEventSender {
pub(crate) fn new(app_event_tx: UnboundedSender<AppEvent>) -> Self {
Self { app_event_tx }
Self {
app_event_tx: Some(app_event_tx),
}
}
pub(crate) fn noop() -> Self {
Self { app_event_tx: None }
}
/// Send an event to the app event channel. If it fails, we swallow the
/// error and log it.
pub(crate) fn send(&self, event: AppEvent) {
let Some(tx) = &self.app_event_tx else { return };
// Record inbound events for high-fidelity session replay.
// Avoid double-logging Ops; those are logged at the point of submission.
if !matches!(event, AppEvent::CodexOp(_)) {
session_log::log_inbound_app_event(&event);
}
if let Err(e) = self.app_event_tx.send(event) {
if let Err(e) = tx.send(event) {
tracing::error!("failed to send event: {e}");
}
}

View File

@@ -38,6 +38,7 @@ use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::key_hint;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_file_search::FileMatch;
use std::cell::RefCell;
@@ -87,6 +88,9 @@ 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)>>,
inline_file_search_enabled: bool,
}
/// Popup state at most one can be visible at any time.
@@ -130,12 +134,25 @@ impl ChatComposer {
paste_burst: PasteBurst::default(),
disable_paste_burst: false,
custom_prompts: Vec::new(),
footer_hint_override: None,
inline_file_search_enabled: true,
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
this
}
pub(crate) fn set_inline_file_search_enabled(&mut self, enabled: bool) {
self.inline_file_search_enabled = enabled;
if !enabled {
if matches!(self.active_popup, ActivePopup::File(_)) {
self.active_popup = ActivePopup::None;
}
self.current_file_query = None;
self.dismissed_file_popup_token = None;
}
}
pub fn desired_height(&self, width: u16) -> u16 {
// Leave 1 column for the left border and 1 column for left padding
self.textarea
@@ -224,6 +241,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;
@@ -268,6 +289,10 @@ impl ChatComposer {
self.textarea.text().to_string()
}
pub(crate) fn current_mention_token(&self) -> Option<String> {
Self::current_at_token(&self.textarea)
}
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) {
let placeholder = format!("[image {width}x{height} {format_label}]");
@@ -521,7 +546,7 @@ impl ChatComposer {
// Determine dimensions; if that fails fall back to normal path insertion.
let path_buf = PathBuf::from(&sel_path);
if let Ok((w, h)) = image::image_dimensions(&path_buf) {
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
// Remove the current @token (mirror logic from replace_current_token without inserting text)
// using the flat text and byte-offset cursor API.
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
@@ -560,11 +585,11 @@ impl ChatComposer {
self.textarea.insert_str(" ");
} else {
// Fallback to plain path insertion if metadata read fails.
self.insert_selected_path(&sel_path);
self.replace_current_token(&sel_path);
}
} else {
// Non-image: inserting file path.
self.insert_selected_path(&sel_path);
self.replace_current_token(&sel_path);
}
// No selection: treat Enter as closing the popup/session.
self.active_popup = ActivePopup::None;
@@ -590,7 +615,7 @@ impl ChatComposer {
/// - If the token under the cursor starts with `@`, that token is
/// returned without the leading `@`. This includes the case where the
/// token is just "@" (empty query), which is used to trigger a UI hint
fn current_at_token(textarea: &TextArea) -> Option<String> {
pub(crate) fn current_at_token(textarea: &TextArea) -> Option<String> {
let cursor_offset = textarea.cursor();
let text = textarea.text();
@@ -686,7 +711,7 @@ impl ChatComposer {
/// The algorithm mirrors `current_at_token` so replacement works no matter
/// where the cursor is within the token and regardless of how many
/// `@tokens` exist in the line.
fn insert_selected_path(&mut self, path: &str) {
pub(crate) fn replace_current_token(&mut self, path: &str) {
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
// Clamp to a valid char boundary to avoid panics when slicing.
@@ -938,9 +963,13 @@ impl ChatComposer {
code: KeyCode::Backspace,
..
} = input
&& self.try_remove_any_placeholder_at_cursor()
{
return (InputResult::None, true);
if self.try_remove_any_placeholder_at_cursor() {
return (InputResult::None, true);
}
if self.try_remove_bracket_reference_before_cursor() {
return (InputResult::None, true);
}
}
// Normal input handling
@@ -1140,6 +1169,56 @@ impl ChatComposer {
false
}
fn try_remove_bracket_reference_before_cursor(&mut self) -> bool {
let cursor = self.textarea.cursor();
if cursor == 0 {
return false;
}
let text = self.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor);
if safe_cursor == 0 {
return false;
}
let before_cursor = &text[..safe_cursor];
if !before_cursor.ends_with(']') {
return false;
}
let Some(start_idx) = before_cursor.rfind('[') else {
return false;
};
if before_cursor[start_idx..before_cursor.len().saturating_sub(1)].contains(']') {
return false;
}
if start_idx > 0 {
if let Some(prev) = before_cursor[..start_idx].chars().rev().next() {
if !prev.is_whitespace()
&& !matches!(prev, '(' | '{' | '[' | '<' | ',' | ';' | ':' | '!')
{
return false;
}
}
}
if let Some(next_char) = text[safe_cursor..].chars().next() {
if !next_char.is_whitespace()
&& !matches!(next_char, ')' | ']' | '}' | '>' | ',' | '.' | ';' | ':')
{
return false;
}
}
let mut remove_end = safe_cursor;
if let Some(next_char) = text[safe_cursor..].chars().next() {
if next_char.is_whitespace() {
remove_end += next_char.len_utf8();
}
}
self.textarea.replace_range(start_idx..remove_end, "");
self.textarea.set_cursor(start_idx);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
true
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
@@ -1174,6 +1253,13 @@ impl ChatComposer {
/// Synchronize `self.file_search_popup` with the current text in the textarea.
/// Note this is only called when self.active_popup is NOT Command.
fn sync_file_search_popup(&mut self) {
if !self.inline_file_search_enabled {
self.active_popup = ActivePopup::None;
self.current_file_query = None;
self.dismissed_file_popup_token = None;
return;
}
// Determine if there is an @token underneath the cursor.
let query = match Self::current_at_token(&self.textarea) {
Some(token) => token,
@@ -1263,17 +1349,30 @@ impl WidgetRef for ChatComposer {
} else {
popup_rect
};
render_footer(
hint_rect,
buf,
FooterProps {
ctrl_c_quit_hint: self.ctrl_c_quit_hint,
is_task_running: self.is_task_running,
esc_backtrack_hint: self.esc_backtrack_hint,
use_shift_enter_hint: self.use_shift_enter_hint,
token_usage_info: self.token_usage_info.as_ref(),
},
);
if self.ctrl_c_quit_hint || self.footer_hint_override.is_none() {
render_footer(
hint_rect,
buf,
FooterProps {
ctrl_c_quit_hint: self.ctrl_c_quit_hint,
is_task_running: self.is_task_running,
esc_backtrack_hint: self.esc_backtrack_hint,
use_shift_enter_hint: self.use_shift_enter_hint,
token_usage_info: self.token_usage_info.as_ref(),
},
);
} else if let Some(items) = &self.footer_hint_override {
let mut spans: Vec<Span<'static>> = Vec::new();
for (idx, (key, label)) in items.iter().enumerate() {
spans.push(" ".into());
spans.push(key_hint::plain(key));
spans.push(Span::from(format!(" {label}")));
if idx + 1 != items.len() {
spans.push(" ".into());
}
}
Line::from(spans).render_ref(hint_rect, buf);
}
}
}
let border_style = if self.has_focus {

View File

@@ -854,6 +854,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

@@ -56,6 +56,7 @@ mod markdown_stream;
mod new_model_popup;
pub mod onboarding;
mod pager_overlay;
pub mod public_widgets;
mod render;
mod resume_picker;
mod session_log;
@@ -81,6 +82,8 @@ use crate::onboarding::onboarding_screen::run_onboarding_app;
use crate::tui::Tui;
pub use cli::Cli;
use codex_core::internal_storage::InternalStorage;
pub use public_widgets::composer_input::ComposerAction;
pub use public_widgets::composer_input::ComposerInput;
// (tests access modules directly within the crate)

View File

@@ -0,0 +1,134 @@
//! 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 std::time::Duration;
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,
}
impl ComposerInput {
/// Create a new composer input with a neutral placeholder.
pub fn new() -> Self {
let sender = AppEventSender::noop();
// `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior.
let mut inner =
ChatComposer::new(true, sender, true, "Compose new task".to_string(), false);
inner.set_inline_file_search_enabled(false);
Self { inner }
}
/// 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,
}
}
/// Current `@` token under the cursor, without the leading `@`.
pub fn mention_token(&self) -> Option<String> {
self.inner.current_mention_token()
}
pub fn replace_current_token(&mut self, replacement: &str) {
self.inner.replace_current_token(replacement);
}
pub fn handle_paste(&mut self, pasted: String) -> bool {
self.inner.handle_paste(pasted)
}
pub fn set_text_content(&mut self, text: String) {
self.inner.set_text_content(text);
}
/// Read the current composer text.
pub fn text_content(&self) -> String {
self.inner.current_text()
}
/// 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);
}
/// Return true if a paste-burst detection is currently active.
pub fn is_in_paste_burst(&self) -> bool {
self.inner.is_in_paste_burst()
}
/// Flush a pending paste-burst if the inter-key timeout has elapsed.
/// Returns true if text changed and a redraw is warranted.
pub fn flush_paste_burst_if_due(&mut self) -> bool {
self.inner.flush_paste_burst_if_due()
}
/// Recommended delay to schedule the next micro-flush frame while a
/// paste-burst is active.
pub fn recommended_flush_delay() -> Duration {
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()
}
}
impl Default for ComposerInput {
fn default() -> Self {
Self::new()
}
}

View File

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