mirror of
https://github.com/openai/codex.git
synced 2026-05-01 01:47:18 +00:00
remote tasks
This commit is contained in:
50
codex-rs/cloud-tasks/Cargo.toml
Normal file
50
codex-rs/cloud-tasks/Cargo.toml
Normal file
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "codex-cloud-tasks"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "codex_cloud_tasks"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
codex-common = { path = "../common", features = ["cli"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
codex-cloud-tasks-api = { path = "../cloud-tasks-api" }
|
||||
codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock", "online"] }
|
||||
ratatui = { version = "0.29.0" }
|
||||
crossterm = { version = "0.28.1", features = ["event-stream"] }
|
||||
tokio-stream = "0.1.17"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
codex-login = { path = "../login" }
|
||||
codex-core = { path = "../core" }
|
||||
codex-backend-client = { path = "../backend-client" }
|
||||
throbber-widgets-tui = "0.8.0"
|
||||
base64 = "0.22"
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
unicode-width = "0.1"
|
||||
codex-tui = { path = "../tui" }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
|
||||
[[bin]]
|
||||
name = "conncheck"
|
||||
path = "src/bin/conncheck.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "newtask"
|
||||
path = "src/bin/newtask.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "envcheck"
|
||||
path = "src/bin/envcheck.rs"
|
||||
247
codex-rs/cloud-tasks/src/app.rs
Normal file
247
codex-rs/cloud-tasks/src/app.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
use std::time::Duration;
|
||||
|
||||
// Environment filter data models for the TUI
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EnvironmentRow {
|
||||
pub id: String,
|
||||
pub label: Option<String>,
|
||||
pub is_pinned: bool,
|
||||
pub repo_hints: Option<String>, // e.g., "openai/codex"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EnvModalState {
|
||||
pub query: String,
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
use crate::scrollable_diff::ScrollableDiff;
|
||||
use codex_cloud_tasks_api::CloudBackend;
|
||||
use codex_cloud_tasks_api::DiffSummary;
|
||||
use codex_cloud_tasks_api::TaskId;
|
||||
use codex_cloud_tasks_api::TaskSummary;
|
||||
use throbber_widgets_tui::ThrobberState;
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct App {
|
||||
pub tasks: Vec<TaskSummary>,
|
||||
pub selected: usize,
|
||||
pub status: String,
|
||||
pub diff_overlay: Option<DiffOverlay>,
|
||||
pub pending_apply: Option<(TaskId, String)>,
|
||||
pub throbber: ThrobberState,
|
||||
pub refresh_inflight: bool,
|
||||
pub details_inflight: bool,
|
||||
// Environment filter state
|
||||
pub env_filter: Option<String>,
|
||||
pub env_modal: Option<EnvModalState>,
|
||||
pub environments: Vec<EnvironmentRow>,
|
||||
pub env_last_loaded: Option<std::time::Instant>,
|
||||
pub env_loading: bool,
|
||||
pub env_error: Option<String>,
|
||||
// New Task page
|
||||
pub new_task: Option<crate::new_task::NewTaskPage>,
|
||||
// Background enrichment coordination
|
||||
pub list_generation: u64,
|
||||
pub in_flight: std::collections::HashSet<String>,
|
||||
pub summary_cache: std::collections::HashMap<String, (DiffSummary, std::time::Instant)>,
|
||||
pub no_diff_yet: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tasks: Vec::new(),
|
||||
selected: 0,
|
||||
status: "Press r to refresh".to_string(),
|
||||
diff_overlay: None,
|
||||
pending_apply: None,
|
||||
throbber: ThrobberState::default(),
|
||||
refresh_inflight: false,
|
||||
details_inflight: false,
|
||||
env_filter: None,
|
||||
env_modal: None,
|
||||
environments: Vec::new(),
|
||||
env_last_loaded: None,
|
||||
env_loading: false,
|
||||
env_error: None,
|
||||
new_task: None,
|
||||
list_generation: 0,
|
||||
in_flight: std::collections::HashSet::new(),
|
||||
summary_cache: std::collections::HashMap::new(),
|
||||
no_diff_yet: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if self.tasks.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.selected = (self.selected + 1).min(self.tasks.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
if self.tasks.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_tasks(
|
||||
backend: &dyn CloudBackend,
|
||||
env: Option<&str>,
|
||||
) -> anyhow::Result<Vec<TaskSummary>> {
|
||||
// In later milestones, add a small debounce, spinner, and error display.
|
||||
let tasks = tokio::time::timeout(Duration::from_secs(5), backend.list_tasks(env)).await??;
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
pub struct DiffOverlay {
|
||||
pub title: String,
|
||||
pub task_id: TaskId,
|
||||
pub sd: ScrollableDiff,
|
||||
pub can_apply: bool,
|
||||
}
|
||||
|
||||
/// Internal app events delivered from background tasks.
|
||||
/// These let the UI event loop remain responsive and keep the spinner animating.
|
||||
#[derive(Debug)]
|
||||
pub enum AppEvent {
|
||||
TasksLoaded {
|
||||
env: Option<String>,
|
||||
result: anyhow::Result<Vec<TaskSummary>>,
|
||||
},
|
||||
/// Background diff summary computed for a task (or determined absent)
|
||||
TaskSummaryUpdated {
|
||||
generation: u64,
|
||||
id: TaskId,
|
||||
summary: DiffSummary,
|
||||
no_diff_yet: bool,
|
||||
environment_id: Option<String>,
|
||||
},
|
||||
/// Autodetection of a likely environment id finished
|
||||
EnvironmentAutodetected(anyhow::Result<crate::env_detect::AutodetectSelection>),
|
||||
/// Background completion of environment list fetch
|
||||
EnvironmentsLoaded(anyhow::Result<Vec<EnvironmentRow>>),
|
||||
DetailsDiffLoaded {
|
||||
id: TaskId,
|
||||
title: String,
|
||||
diff: String,
|
||||
},
|
||||
DetailsMessagesLoaded {
|
||||
id: TaskId,
|
||||
title: String,
|
||||
messages: Vec<String>,
|
||||
},
|
||||
DetailsFailed {
|
||||
id: TaskId,
|
||||
title: String,
|
||||
error: String,
|
||||
},
|
||||
/// Background completion of new task submission
|
||||
NewTaskSubmitted(Result<codex_cloud_tasks_api::CreatedTask, String>),
|
||||
}
|
||||
|
||||
pub type AppEventTx = UnboundedSender<AppEvent>;
|
||||
pub type AppEventRx = UnboundedReceiver<AppEvent>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
|
||||
struct FakeBackend {
|
||||
// maps env key to titles
|
||||
by_env: std::collections::HashMap<Option<String>, Vec<&'static str>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_cloud_tasks_api::CloudBackend for FakeBackend {
|
||||
async fn list_tasks(
|
||||
&self,
|
||||
env: Option<&str>,
|
||||
) -> codex_cloud_tasks_api::Result<Vec<TaskSummary>> {
|
||||
let key = env.map(|s| s.to_string());
|
||||
let titles = self
|
||||
.by_env
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| vec!["default-a", "default-b"]);
|
||||
let mut out = Vec::new();
|
||||
for (i, t) in titles.into_iter().enumerate() {
|
||||
out.push(TaskSummary {
|
||||
id: TaskId(format!("T-{}", i)),
|
||||
title: t.to_string(),
|
||||
status: codex_cloud_tasks_api::TaskStatus::Ready,
|
||||
updated_at: Utc::now(),
|
||||
environment_id: env.map(|s| s.to_string()),
|
||||
environment_label: None,
|
||||
summary: codex_cloud_tasks_api::DiffSummary::default(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get_task_diff(&self, _id: TaskId) -> codex_cloud_tasks_api::Result<String> {
|
||||
Err(codex_cloud_tasks_api::Error::Unimplemented(
|
||||
"not used in test",
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_task_messages(
|
||||
&self,
|
||||
_id: TaskId,
|
||||
) -> codex_cloud_tasks_api::Result<Vec<String>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn apply_task(
|
||||
&self,
|
||||
_id: TaskId,
|
||||
) -> codex_cloud_tasks_api::Result<codex_cloud_tasks_api::ApplyOutcome> {
|
||||
Err(codex_cloud_tasks_api::Error::Unimplemented(
|
||||
"not used in test",
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_task(
|
||||
&self,
|
||||
_env_id: &str,
|
||||
_prompt: &str,
|
||||
_git_ref: &str,
|
||||
_qa_mode: bool,
|
||||
) -> codex_cloud_tasks_api::Result<codex_cloud_tasks_api::CreatedTask> {
|
||||
Err(codex_cloud_tasks_api::Error::Unimplemented(
|
||||
"not used in test",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_tasks_uses_env_parameter() {
|
||||
// Arrange: env-specific task titles
|
||||
let mut by_env = std::collections::HashMap::new();
|
||||
by_env.insert(None, vec!["root-1", "root-2"]);
|
||||
by_env.insert(Some("env-A".to_string()), vec!["A-1"]);
|
||||
by_env.insert(Some("env-B".to_string()), vec!["B-1", "B-2", "B-3"]);
|
||||
let backend = FakeBackend { by_env };
|
||||
|
||||
// Act + Assert
|
||||
let root = load_tasks(&backend, None).await.unwrap();
|
||||
assert_eq!(root.len(), 2);
|
||||
assert_eq!(root[0].title, "root-1");
|
||||
|
||||
let a = load_tasks(&backend, Some("env-A")).await.unwrap();
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(a[0].title, "A-1");
|
||||
|
||||
let b = load_tasks(&backend, Some("env-B")).await.unwrap();
|
||||
assert_eq!(b.len(), 3);
|
||||
assert_eq!(b[2].title, "B-3");
|
||||
}
|
||||
}
|
||||
135
codex-rs/cloud-tasks/src/bin/conncheck.rs
Normal file
135
codex-rs/cloud-tasks/src/bin/conncheck.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::AuthMode;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Base URL (default to ChatGPT backend API)
|
||||
let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
println!("base_url: {base_url}");
|
||||
let path_style = if base_url.contains("/backend-api") {
|
||||
"wham"
|
||||
} else {
|
||||
"codex-api"
|
||||
};
|
||||
println!("path_style: {path_style}");
|
||||
|
||||
// Locate CODEX_HOME and try to load ChatGPT auth
|
||||
let codex_home = match find_codex_home() {
|
||||
Ok(p) => {
|
||||
println!("codex_home: {}", p.display());
|
||||
Some(p)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("codex_home: <not found> ({e})");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Build backend client with UA
|
||||
let ua = get_codex_user_agent(Some("codex_cloud_tasks_conncheck"));
|
||||
let mut client = BackendClient::new(base_url.clone())?.with_user_agent(ua);
|
||||
|
||||
// Attach bearer token if available from ChatGPT auth
|
||||
let mut have_auth = false;
|
||||
if let Some(home) = codex_home {
|
||||
let authm = AuthManager::new(
|
||||
home,
|
||||
AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_conncheck".to_string(),
|
||||
);
|
||||
if let Some(auth) = authm.auth() {
|
||||
match auth.get_token().await {
|
||||
Ok(token) if !token.is_empty() => {
|
||||
have_auth = true;
|
||||
println!("auth: ChatGPT token present ({} chars)", token.len());
|
||||
// Add Authorization header
|
||||
client = client.with_bearer_token(&token);
|
||||
|
||||
// Attempt to extract ChatGPT account id from the JWT and set header.
|
||||
if let Some(account_id) = extract_chatgpt_account_id(&token) {
|
||||
println!("auth: ChatGPT-Account-Id: {account_id}");
|
||||
client = client.with_chatgpt_account_id(account_id);
|
||||
} else if let Some(acc) = auth.get_account_id() {
|
||||
// Fallback: some older auth.jsons persist account_id
|
||||
println!("auth: ChatGPT-Account-Id (from auth.json): {acc}");
|
||||
client = client.with_chatgpt_account_id(acc);
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
println!("auth: ChatGPT token empty");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("auth: failed to load ChatGPT token: {e}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("auth: no ChatGPT auth.json");
|
||||
}
|
||||
}
|
||||
|
||||
if !have_auth {
|
||||
println!("note: Online endpoints typically require ChatGPT sign-in. Run: `codex login`");
|
||||
}
|
||||
|
||||
// Attempt the /list call with a short timeout to avoid hanging
|
||||
match path_style {
|
||||
"wham" => println!("request: GET /wham/tasks/list?limit=5&task_filter=current"),
|
||||
_ => println!("request: GET /api/codex/tasks/list?limit=5&task_filter=current"),
|
||||
}
|
||||
let fut = client.list_tasks(Some(5), Some("current"), None);
|
||||
let res = tokio::time::timeout(Duration::from_secs(30), fut).await;
|
||||
match res {
|
||||
Err(_) => {
|
||||
println!("error: request timed out after 30s");
|
||||
std::process::exit(2);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// backend-client includes HTTP status and body in errors.
|
||||
println!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(Ok(list)) => {
|
||||
println!("ok: received {} tasks", list.items.len());
|
||||
for item in list.items.iter().take(5) {
|
||||
println!("- {} — {}", item.id, item.title);
|
||||
}
|
||||
// Print the full response object for debugging/inspection.
|
||||
match serde_json::to_string_pretty(&list) {
|
||||
Ok(json) => {
|
||||
println!("\nfull response object (pretty JSON):\n{}", json);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("failed to serialize response to JSON: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
|
||||
// JWT: header.payload.signature
|
||||
let mut parts = token.split('.');
|
||||
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
|
||||
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
|
||||
_ => return None,
|
||||
};
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
v.get("https://api.openai.com/auth")
|
||||
.and_then(|auth| auth.get("chatgpt_account_id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
50
codex-rs/cloud-tasks/src/bin/detailcheck.rs
Normal file
50
codex-rs/cloud-tasks/src/bin/detailcheck.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::AuthMode;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
let ua = get_codex_user_agent(Some("codex_cloud_tasks_detailcheck"));
|
||||
let mut client = BackendClient::new(base_url)?.with_user_agent(ua);
|
||||
|
||||
if let Ok(home) = find_codex_home() {
|
||||
let am = AuthManager::new(
|
||||
home,
|
||||
AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_detailcheck".to_string(),
|
||||
);
|
||||
if let Some(auth) = am.auth() {
|
||||
if let Ok(tok) = auth.get_token().await {
|
||||
client = client.with_bearer_token(tok);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let list = client.list_tasks(Some(5), Some("current"), None).await?;
|
||||
println!("items: {}", list.items.len());
|
||||
for item in list.items.iter().take(5) {
|
||||
println!("item: {} {}", item.id, item.title);
|
||||
let (details, body, ct) = client.get_task_details_with_body(&item.id).await?;
|
||||
let diff = codex_backend_client::CodeTaskDetailsResponseExt::unified_diff(&details);
|
||||
match diff {
|
||||
Some(d) => println!(
|
||||
"unified diff len={} sample=\n{}",
|
||||
d.len(),
|
||||
&d.lines().take(10).collect::<Vec<_>>().join("\n")
|
||||
),
|
||||
None => {
|
||||
println!(
|
||||
"no unified diff found; ct={ct}; body sample=\n{}",
|
||||
&body.chars().take(5000).collect::<String>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
141
codex-rs/cloud-tasks/src/bin/envcheck.rs
Normal file
141
codex-rs/cloud-tasks/src/bin/envcheck.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use base64::Engine;
|
||||
use clap::Parser;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::AuthMode;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderName;
|
||||
use reqwest::header::HeaderValue;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(version, about = "Resolve Codex environment id (debug helper)")]
|
||||
struct Args {
|
||||
/// Optional override for environment id; if present we just echo it.
|
||||
#[arg(long = "env-id")]
|
||||
environment_id: Option<String>,
|
||||
/// Optional label to select a matching environment (case-insensitive exact match).
|
||||
#[arg(long = "env-label")]
|
||||
environment_label: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Base URL (default to ChatGPT backend API) with normalization
|
||||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
while base_url.ends_with('/') {
|
||||
base_url.pop();
|
||||
}
|
||||
if (base_url.starts_with("https://chatgpt.com")
|
||||
|| base_url.starts_with("https://chat.openai.com"))
|
||||
&& !base_url.contains("/backend-api")
|
||||
{
|
||||
base_url = format!("{}/backend-api", base_url);
|
||||
}
|
||||
println!("base_url: {base_url}");
|
||||
println!(
|
||||
"path_style: {}",
|
||||
if base_url.contains("/backend-api") {
|
||||
"wham"
|
||||
} else {
|
||||
"codex-api"
|
||||
}
|
||||
);
|
||||
|
||||
// Build headers: UA + ChatGPT auth if available
|
||||
let ua = get_codex_user_agent(Some("codex_cloud_tasks_envcheck"));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
|
||||
);
|
||||
|
||||
// Locate CODEX_HOME and try to load ChatGPT auth
|
||||
if let Ok(home) = find_codex_home() {
|
||||
println!("codex_home: {}", home.display());
|
||||
let authm = AuthManager::new(
|
||||
home,
|
||||
AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_envcheck".to_string(),
|
||||
);
|
||||
if let Some(auth) = authm.auth() {
|
||||
match auth.get_token().await {
|
||||
Ok(token) if !token.is_empty() => {
|
||||
println!("auth: ChatGPT token present ({} chars)", token.len());
|
||||
let value = format!("Bearer {}", token);
|
||||
if let Ok(hv) = HeaderValue::from_str(&value) {
|
||||
headers.insert(AUTHORIZATION, hv);
|
||||
}
|
||||
if let Some(account_id) = auth
|
||||
.get_account_id()
|
||||
.or_else(|| extract_chatgpt_account_id(&token))
|
||||
{
|
||||
println!("auth: ChatGPT-Account-Id: {account_id}");
|
||||
if let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") {
|
||||
if let Ok(hv) = HeaderValue::from_str(&account_id) {
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => println!("auth: ChatGPT token empty"),
|
||||
Err(e) => println!("auth: failed to load ChatGPT token: {e}"),
|
||||
}
|
||||
} else {
|
||||
println!("auth: no ChatGPT auth.json");
|
||||
}
|
||||
} else {
|
||||
println!("codex_home: <not found>");
|
||||
}
|
||||
|
||||
// If user supplied an environment id, just echo it and exit.
|
||||
if let Some(id) = args.environment_id {
|
||||
println!("env: provided env-id={id}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Auto-detect environment id using shared env_detect
|
||||
match codex_cloud_tasks::env_detect::autodetect_environment_id(
|
||||
&base_url,
|
||||
&headers,
|
||||
args.environment_label,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sel) => {
|
||||
println!(
|
||||
"env: selected environment_id={} label={}",
|
||||
sel.id,
|
||||
sel.label.unwrap_or_else(|| "<none>".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
println!("env: failed: {e}");
|
||||
std::process::exit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
|
||||
// JWT: header.payload.signature
|
||||
let mut parts = token.split('.');
|
||||
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
|
||||
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
|
||||
_ => return None,
|
||||
};
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
v.get("https://api.openai.com/auth")
|
||||
.and_then(|auth| auth.get("chatgpt_account_id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
211
codex-rs/cloud-tasks/src/bin/newtask.rs
Normal file
211
codex-rs/cloud-tasks/src/bin/newtask.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use base64::Engine;
|
||||
use clap::Parser;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::AuthMode;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderName;
|
||||
use reqwest::header::HeaderValue;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(version, about = "Create a new Codex cloud task (debug helper)")]
|
||||
struct Args {
|
||||
/// Optional override for environment id; if absent we auto-detect.
|
||||
#[arg(long = "env-id")]
|
||||
environment_id: Option<String>,
|
||||
/// Optional label match for environment selection (case-insensitive, exact match).
|
||||
#[arg(long = "env-label")]
|
||||
environment_label: Option<String>,
|
||||
/// Branch or ref to use (e.g., main)
|
||||
#[arg(long = "ref", default_value = "main")]
|
||||
git_ref: String,
|
||||
/// Run environment in QA (ask) mode
|
||||
#[arg(long = "qa-mode", default_value_t = false)]
|
||||
qa_mode: bool,
|
||||
/// Task prompt text
|
||||
#[arg(required = true)]
|
||||
prompt: Vec<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
let prompt = args.prompt.join(" ");
|
||||
|
||||
// Base URL (default to ChatGPT backend API)
|
||||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
while base_url.ends_with('/') {
|
||||
base_url.pop();
|
||||
}
|
||||
if (base_url.starts_with("https://chatgpt.com")
|
||||
|| base_url.starts_with("https://chat.openai.com"))
|
||||
&& !base_url.contains("/backend-api")
|
||||
{
|
||||
base_url = format!("{}/backend-api", base_url);
|
||||
}
|
||||
println!("base_url: {base_url}");
|
||||
let is_wham = base_url.contains("/backend-api");
|
||||
println!("path_style: {}", if is_wham { "wham" } else { "codex-api" });
|
||||
|
||||
// Build headers: UA + ChatGPT auth if available
|
||||
let ua = get_codex_user_agent(Some("codex_cloud_tasks_newtask"));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
|
||||
);
|
||||
let mut have_auth = false;
|
||||
// Locate CODEX_HOME and try to load ChatGPT auth
|
||||
if let Ok(home) = find_codex_home() {
|
||||
let authm = AuthManager::new(
|
||||
home,
|
||||
AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_newtask".to_string(),
|
||||
);
|
||||
if let Some(auth) = authm.auth() {
|
||||
match auth.get_token().await {
|
||||
Ok(token) if !token.is_empty() => {
|
||||
have_auth = true;
|
||||
println!("auth: ChatGPT token present ({} chars)", token.len());
|
||||
let value = format!("Bearer {}", token);
|
||||
if let Ok(hv) = HeaderValue::from_str(&value) {
|
||||
headers.insert(AUTHORIZATION, hv);
|
||||
}
|
||||
if let Some(account_id) = auth
|
||||
.get_account_id()
|
||||
.or_else(|| extract_chatgpt_account_id(&token))
|
||||
{
|
||||
println!("auth: ChatGPT-Account-Id: {account_id}");
|
||||
if let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") {
|
||||
if let Ok(hv) = HeaderValue::from_str(&account_id) {
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => println!("auth: ChatGPT token empty"),
|
||||
Err(e) => println!("auth: failed to load ChatGPT token: {e}"),
|
||||
}
|
||||
} else {
|
||||
println!("auth: no ChatGPT auth.json");
|
||||
}
|
||||
}
|
||||
if !have_auth {
|
||||
println!("note: Online endpoints typically require ChatGPT sign-in. Run: `codex login`");
|
||||
}
|
||||
|
||||
// Determine environment id: prefer flag, then by-repo lookup, then full list.
|
||||
let env_id = if let Some(id) = args.environment_id.clone() {
|
||||
println!("env: using provided env-id={id}");
|
||||
id
|
||||
} else {
|
||||
match codex_cloud_tasks::env_detect::autodetect_environment_id(
|
||||
&base_url,
|
||||
&headers,
|
||||
args.environment_label.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sel) => sel.id,
|
||||
Err(e) => {
|
||||
println!("env: failed to auto-detect environment: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
};
|
||||
println!("env: selected environment_id={env_id}");
|
||||
|
||||
// Build request payload patterned after VSCode: POST /wham/tasks
|
||||
let url = if is_wham {
|
||||
format!("{}/wham/tasks", base_url)
|
||||
} else {
|
||||
format!("{}/api/codex/tasks", base_url)
|
||||
};
|
||||
println!(
|
||||
"request: POST {}",
|
||||
url.strip_prefix(&base_url).unwrap_or(&url)
|
||||
);
|
||||
|
||||
// input_items
|
||||
let mut input_items: Vec<serde_json::Value> = Vec::new();
|
||||
input_items.push(serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{ "content_type": "text", "text": prompt }]
|
||||
}));
|
||||
|
||||
// Optional: starting diff via env var for quick testing
|
||||
if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF") {
|
||||
if !diff.is_empty() {
|
||||
input_items.push(serde_json::json!({
|
||||
"type": "pre_apply_patch",
|
||||
"output_diff": { "diff": diff }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"new_task": {
|
||||
"environment_id": env_id,
|
||||
"branch": args.git_ref,
|
||||
"run_environment_in_qa_mode": args.qa_mode,
|
||||
},
|
||||
"input_items": input_items,
|
||||
});
|
||||
|
||||
let http = reqwest::Client::builder().build()?;
|
||||
let res = http
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = res.status();
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
println!("status: {}", status);
|
||||
println!("content-type: {}", ct);
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(v) => println!(
|
||||
"response (pretty JSON):\n{}",
|
||||
serde_json::to_string_pretty(&v).unwrap_or(body)
|
||||
),
|
||||
Err(_) => println!("response (raw):\n{}", body),
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
// Exit non-zero on failure
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
|
||||
// JWT: header.payload.signature
|
||||
let mut parts = token.split('.');
|
||||
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
|
||||
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
|
||||
_ => return None,
|
||||
};
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
v.get("https://api.openai.com/auth")
|
||||
.and_then(|auth| auth.get("chatgpt_account_id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
9
codex-rs/cloud-tasks/src/cli.rs
Normal file
9
codex-rs/cloud-tasks/src/cli.rs
Normal 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,
|
||||
}
|
||||
380
codex-rs/cloud-tasks/src/env_detect.rs
Normal file
380
codex-rs/cloud-tasks/src/env_detect.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use reqwest::header::HeaderMap;
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
struct CodeEnvironment {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
#[serde(default)]
|
||||
is_pinned: Option<bool>,
|
||||
#[serde(default)]
|
||||
task_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutodetectSelection {
|
||||
pub id: String,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn autodetect_environment_id(
|
||||
base_url: &str,
|
||||
headers: &HeaderMap,
|
||||
desired_label: Option<String>,
|
||||
) -> anyhow::Result<AutodetectSelection> {
|
||||
// 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode)
|
||||
let origins = get_git_origins();
|
||||
crate::append_error_log(format!("env: git origins: {:?}", origins));
|
||||
let mut by_repo_envs: Vec<CodeEnvironment> = Vec::new();
|
||||
for origin in &origins {
|
||||
if let Some((owner, repo)) = parse_owner_repo(origin) {
|
||||
let url = if base_url.contains("/backend-api") {
|
||||
format!(
|
||||
"{}/wham/environments/by-repo/{}/{}/{}",
|
||||
base_url, "github", owner, repo
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}/api/codex/environments/by-repo/{}/{}/{}",
|
||||
base_url, "github", owner, repo
|
||||
)
|
||||
};
|
||||
crate::append_error_log(format!("env: GET {}", url));
|
||||
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
|
||||
Ok(mut list) => {
|
||||
crate::append_error_log(format!(
|
||||
"env: by-repo returned {} env(s) for {}/{}",
|
||||
list.len(),
|
||||
owner,
|
||||
repo
|
||||
));
|
||||
by_repo_envs.append(&mut list);
|
||||
}
|
||||
Err(e) => crate::append_error_log(format!(
|
||||
"env: by-repo fetch failed for {}/{}: {e}",
|
||||
owner, repo
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(env) = pick_environment_row(&by_repo_envs, desired_label.as_deref()) {
|
||||
return Ok(AutodetectSelection {
|
||||
id: env.id.clone(),
|
||||
label: env.label.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Fallback to the full list
|
||||
let list_url = if base_url.contains("/backend-api") {
|
||||
format!("{}/wham/environments", base_url)
|
||||
} else {
|
||||
format!("{}/api/codex/environments", base_url)
|
||||
};
|
||||
crate::append_error_log(format!("env: GET {}", list_url));
|
||||
// Fetch and log the full environments JSON for debugging
|
||||
let http = reqwest::Client::builder().build()?;
|
||||
let res = http.get(&list_url).headers(headers.clone()).send().await?;
|
||||
let status = res.status();
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
crate::append_error_log(format!("env: status={} content-type={}", status, ct));
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(v) => {
|
||||
let pretty = serde_json::to_string_pretty(&v).unwrap_or(body.clone());
|
||||
crate::append_error_log(format!("env: /environments JSON (pretty):\n{}", pretty));
|
||||
}
|
||||
Err(_) => crate::append_error_log(format!("env: /environments (raw):\n{}", body)),
|
||||
}
|
||||
if !status.is_success() {
|
||||
anyhow::bail!(format!(
|
||||
"GET {} failed: {}; content-type={}; body={}",
|
||||
list_url, status, ct, body
|
||||
));
|
||||
}
|
||||
let all_envs: Vec<CodeEnvironment> = serde_json::from_str(&body).map_err(|e| {
|
||||
anyhow::anyhow!(format!(
|
||||
"Decode error for {}: {}; content-type={}; body={}",
|
||||
list_url, e, ct, body
|
||||
))
|
||||
})?;
|
||||
if let Some(env) = pick_environment_row(&all_envs, desired_label.as_deref()) {
|
||||
return Ok(AutodetectSelection {
|
||||
id: env.id.clone(),
|
||||
label: env.label.clone(),
|
||||
});
|
||||
}
|
||||
anyhow::bail!("no environments available")
|
||||
}
|
||||
|
||||
fn pick_environment_row(
|
||||
envs: &[CodeEnvironment],
|
||||
desired_label: Option<&str>,
|
||||
) -> Option<CodeEnvironment> {
|
||||
if envs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(label) = desired_label {
|
||||
let lc = label.to_lowercase();
|
||||
if let Some(e) = envs
|
||||
.iter()
|
||||
.find(|e| e.label.as_deref().unwrap_or("").to_lowercase() == lc)
|
||||
{
|
||||
crate::append_error_log(format!("env: matched by label: {} -> {}", label, e.id));
|
||||
return Some(e.clone());
|
||||
}
|
||||
}
|
||||
if envs.len() == 1 {
|
||||
crate::append_error_log("env: single environment available; selecting it");
|
||||
return Some(envs[0].clone());
|
||||
}
|
||||
if let Some(e) = envs.iter().find(|e| e.is_pinned.unwrap_or(false)) {
|
||||
crate::append_error_log(format!("env: selecting pinned environment: {}", e.id));
|
||||
return Some(e.clone());
|
||||
}
|
||||
// Highest task_count as heuristic
|
||||
if let Some(e) = envs
|
||||
.iter()
|
||||
.max_by_key(|e| e.task_count.unwrap_or(0))
|
||||
.or_else(|| envs.first())
|
||||
{
|
||||
crate::append_error_log(format!("env: selecting by task_count/first: {}", e.id));
|
||||
return Some(e.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_json<T: serde::de::DeserializeOwned>(
|
||||
url: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> anyhow::Result<T> {
|
||||
let http = reqwest::Client::builder().build()?;
|
||||
let res = http.get(url).headers(headers.clone()).send().await?;
|
||||
let status = res.status();
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
crate::append_error_log(format!("env: status={} content-type={}", status, ct));
|
||||
if !status.is_success() {
|
||||
anyhow::bail!(format!(
|
||||
"GET {url} failed: {status}; content-type={ct}; body={body}"
|
||||
));
|
||||
}
|
||||
let parsed = serde_json::from_str::<T>(&body).map_err(|e| {
|
||||
anyhow::anyhow!(format!(
|
||||
"Decode error for {url}: {e}; content-type={ct}; body={body}"
|
||||
))
|
||||
})?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn get_git_origins() -> Vec<String> {
|
||||
// Prefer: git config --get-regexp remote\..*\.url
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["config", "--get-regexp", "remote\\..*\\.url"])
|
||||
.output();
|
||||
if let Ok(ok) = out {
|
||||
if ok.status.success() {
|
||||
let s = String::from_utf8_lossy(&ok.stdout);
|
||||
let mut urls = Vec::new();
|
||||
for line in s.lines() {
|
||||
if let Some((_, url)) = line.split_once(' ') {
|
||||
urls.push(url.trim().to_string());
|
||||
}
|
||||
}
|
||||
if !urls.is_empty() {
|
||||
return uniq(urls);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: git remote -v
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["remote", "-v"])
|
||||
.output();
|
||||
if let Ok(ok) = out {
|
||||
if ok.status.success() {
|
||||
let s = String::from_utf8_lossy(&ok.stdout);
|
||||
let mut urls = Vec::new();
|
||||
for line in s.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
urls.push(parts[1].to_string());
|
||||
}
|
||||
}
|
||||
if !urls.is_empty() {
|
||||
return uniq(urls);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn uniq(mut v: Vec<String>) -> Vec<String> {
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
fn parse_owner_repo(url: &str) -> Option<(String, String)> {
|
||||
// Normalize common prefixes and handle multiple SSH/HTTPS variants.
|
||||
let mut s = url.trim().to_string();
|
||||
// Drop protocol scheme for ssh URLs
|
||||
if let Some(rest) = s.strip_prefix("ssh://") {
|
||||
s = rest.to_string();
|
||||
}
|
||||
// Accept any user before @github.com (e.g., git@, org-123@)
|
||||
if let Some(idx) = s.find("@github.com:") {
|
||||
let rest = &s[idx + "@github.com:".len()..];
|
||||
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
|
||||
let mut parts = rest.splitn(2, '/');
|
||||
let owner = parts.next()?.to_string();
|
||||
let repo = parts.next()?.to_string();
|
||||
crate::append_error_log(format!(
|
||||
"env: parsed SSH GitHub origin => {}/{}",
|
||||
owner, repo
|
||||
));
|
||||
return Some((owner, repo));
|
||||
}
|
||||
// HTTPS or git protocol
|
||||
for prefix in [
|
||||
"https://github.com/",
|
||||
"http://github.com/",
|
||||
"git://github.com/",
|
||||
"github.com/",
|
||||
] {
|
||||
if let Some(rest) = s.strip_prefix(prefix) {
|
||||
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
|
||||
let mut parts = rest.splitn(2, '/');
|
||||
let owner = parts.next()?.to_string();
|
||||
let repo = parts.next()?.to_string();
|
||||
crate::append_error_log(format!(
|
||||
"env: parsed HTTP GitHub origin => {}/{}",
|
||||
owner, repo
|
||||
));
|
||||
return Some((owner, repo));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// List environments for the current repo(s) with a fallback to the global list.
|
||||
/// Returns a de-duplicated, sorted set suitable for the TUI modal.
|
||||
pub async fn list_environments(
|
||||
base_url: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
|
||||
let mut map: HashMap<String, crate::app::EnvironmentRow> = HashMap::new();
|
||||
|
||||
// 1) By-repo lookup for each parsed GitHub origin
|
||||
let origins = get_git_origins();
|
||||
for origin in &origins {
|
||||
if let Some((owner, repo)) = parse_owner_repo(origin) {
|
||||
let url = if base_url.contains("/backend-api") {
|
||||
format!(
|
||||
"{}/wham/environments/by-repo/{}/{}/{}",
|
||||
base_url, "github", owner, repo
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}/api/codex/environments/by-repo/{}/{}/{}",
|
||||
base_url, "github", owner, repo
|
||||
)
|
||||
};
|
||||
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
|
||||
Ok(list) => {
|
||||
info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len());
|
||||
for e in list {
|
||||
let entry =
|
||||
map.entry(e.id.clone())
|
||||
.or_insert_with(|| crate::app::EnvironmentRow {
|
||||
id: e.id.clone(),
|
||||
label: e.label.clone(),
|
||||
is_pinned: e.is_pinned.unwrap_or(false),
|
||||
repo_hints: Some(format!("{}/{}", owner, repo)),
|
||||
});
|
||||
// Merge: keep label if present, or use new; accumulate pinned flag
|
||||
if entry.label.is_none() {
|
||||
entry.label = e.label.clone();
|
||||
}
|
||||
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
|
||||
if entry.repo_hints.is_none() {
|
||||
entry.repo_hints = Some(format!("{}/{}", owner, repo));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"env_tui: by-repo fetch failed for {}/{}: {}",
|
||||
owner, repo, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback to the full list; on error return what we have if any.
|
||||
let list_url = if base_url.contains("/backend-api") {
|
||||
format!("{}/wham/environments", base_url)
|
||||
} else {
|
||||
format!("{}/api/codex/environments", base_url)
|
||||
};
|
||||
match get_json::<Vec<CodeEnvironment>>(&list_url, headers).await {
|
||||
Ok(list) => {
|
||||
info!("env_tui: global list -> {} envs", list.len());
|
||||
for e in list {
|
||||
let entry = map
|
||||
.entry(e.id.clone())
|
||||
.or_insert_with(|| crate::app::EnvironmentRow {
|
||||
id: e.id.clone(),
|
||||
label: e.label.clone(),
|
||||
is_pinned: e.is_pinned.unwrap_or(false),
|
||||
repo_hints: None,
|
||||
});
|
||||
if entry.label.is_none() {
|
||||
entry.label = e.label.clone();
|
||||
}
|
||||
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if map.is_empty() {
|
||||
return Err(e);
|
||||
} else {
|
||||
warn!(
|
||||
"env_tui: global list failed; using by-repo results only: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rows: Vec<crate::app::EnvironmentRow> = map.into_values().collect();
|
||||
rows.sort_by(|a, b| {
|
||||
// pinned first
|
||||
let p = b.is_pinned.cmp(&a.is_pinned);
|
||||
if p != std::cmp::Ordering::Equal {
|
||||
return p;
|
||||
}
|
||||
// then label (ci), then id
|
||||
let al = a.label.as_deref().unwrap_or("").to_lowercase();
|
||||
let bl = b.label.as_deref().unwrap_or("").to_lowercase();
|
||||
let l = al.cmp(&bl);
|
||||
if l != std::cmp::Ordering::Equal {
|
||||
return l;
|
||||
}
|
||||
a.id.cmp(&b.id)
|
||||
});
|
||||
Ok(rows)
|
||||
}
|
||||
1190
codex-rs/cloud-tasks/src/lib.rs
Normal file
1190
codex-rs/cloud-tasks/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
30
codex-rs/cloud-tasks/src/new_task.rs
Normal file
30
codex-rs/cloud-tasks/src/new_task.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use codex_tui::ComposerAction;
|
||||
use codex_tui::ComposerInput;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NewTaskPage {
|
||||
pub composer: ComposerInput,
|
||||
pub submitting: bool,
|
||||
pub env_id: Option<String>,
|
||||
}
|
||||
|
||||
impl NewTaskPage {
|
||||
pub fn new(env_id: Option<String>) -> Self {
|
||||
let mut composer = ComposerInput::new();
|
||||
composer.set_hint_items(vec![
|
||||
("⏎", "send"),
|
||||
("Shift+⏎", "newline"),
|
||||
("Ctrl+O", "env"),
|
||||
("Ctrl+C", "quit"),
|
||||
]);
|
||||
Self {
|
||||
composer,
|
||||
submitting: false,
|
||||
env_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_submit(&self) -> bool {
|
||||
self.env_id.is_some() && !self.submitting
|
||||
}
|
||||
}
|
||||
178
codex-rs/cloud-tasks/src/scrollable_diff.rs
Normal file
178
codex-rs/cloud-tasks/src/scrollable_diff.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Scroll position and geometry for a vertical scroll view.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ScrollViewState {
|
||||
pub scroll: u16,
|
||||
pub viewport_h: u16,
|
||||
pub content_h: u16,
|
||||
}
|
||||
|
||||
impl ScrollViewState {
|
||||
pub fn clamp(&mut self) {
|
||||
let max_scroll = self.content_h.saturating_sub(self.viewport_h);
|
||||
if self.scroll > max_scroll {
|
||||
self.scroll = max_scroll;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple, local scrollable view for diffs or message text.
|
||||
///
|
||||
/// Owns raw lines, caches wrapped lines for a given width, and maintains
|
||||
/// a small scroll state that is clamped whenever geometry shrinks.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ScrollableDiff {
|
||||
raw: Vec<String>,
|
||||
wrapped: Vec<String>,
|
||||
wrapped_src_idx: Vec<usize>,
|
||||
wrap_cols: Option<u16>,
|
||||
pub state: ScrollViewState,
|
||||
}
|
||||
|
||||
impl ScrollableDiff {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Replace the raw content lines. Does not rewrap immediately; call `set_width` next.
|
||||
pub fn set_content(&mut self, lines: Vec<String>) {
|
||||
self.raw = lines;
|
||||
self.wrapped.clear();
|
||||
self.wrapped_src_idx.clear();
|
||||
self.state.content_h = 0;
|
||||
}
|
||||
|
||||
/// Set the wrap width. If changed, rebuild wrapped lines and clamp scroll.
|
||||
pub fn set_width(&mut self, width: u16) {
|
||||
if self.wrap_cols == Some(width) {
|
||||
return;
|
||||
}
|
||||
self.wrap_cols = Some(width);
|
||||
self.rewrap(width);
|
||||
self.state.clamp();
|
||||
}
|
||||
|
||||
/// Update viewport height and clamp scroll if needed.
|
||||
pub fn set_viewport(&mut self, height: u16) {
|
||||
self.state.viewport_h = height;
|
||||
self.state.clamp();
|
||||
}
|
||||
|
||||
/// Return the cached wrapped lines. Call `set_width` first when area changes.
|
||||
pub fn wrapped_lines(&self) -> &[String] {
|
||||
&self.wrapped
|
||||
}
|
||||
|
||||
pub fn wrapped_src_indices(&self) -> &[usize] {
|
||||
&self.wrapped_src_idx
|
||||
}
|
||||
|
||||
pub fn raw_line_at(&self, idx: usize) -> &str {
|
||||
self.raw.get(idx).map(|s| s.as_str()).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Scroll by a signed delta; clamps to content.
|
||||
pub fn scroll_by(&mut self, delta: i16) {
|
||||
let s = self.state.scroll as i32 + delta as i32;
|
||||
self.state.scroll = s.clamp(0, self.max_scroll() as i32) as u16;
|
||||
}
|
||||
|
||||
/// Page by a signed delta; typically viewport_h - 1.
|
||||
pub fn page_by(&mut self, delta: i16) {
|
||||
self.scroll_by(delta);
|
||||
}
|
||||
|
||||
pub fn to_top(&mut self) {
|
||||
self.state.scroll = 0;
|
||||
}
|
||||
|
||||
pub fn to_bottom(&mut self) {
|
||||
self.state.scroll = self.max_scroll();
|
||||
}
|
||||
|
||||
/// Optional percent scrolled; None when not enough geometry is known.
|
||||
pub fn percent_scrolled(&self) -> Option<u8> {
|
||||
if self.state.content_h == 0 || self.state.viewport_h == 0 {
|
||||
return None;
|
||||
}
|
||||
if self.state.content_h <= self.state.viewport_h {
|
||||
return None;
|
||||
}
|
||||
let visible_bottom = self.state.scroll.saturating_add(self.state.viewport_h) as f32;
|
||||
let pct = (visible_bottom / self.state.content_h as f32 * 100.0).round();
|
||||
Some(pct.clamp(0.0, 100.0) as u8)
|
||||
}
|
||||
|
||||
fn max_scroll(&self) -> u16 {
|
||||
self.state.content_h.saturating_sub(self.state.viewport_h)
|
||||
}
|
||||
|
||||
fn rewrap(&mut self, width: u16) {
|
||||
if width == 0 {
|
||||
self.wrapped = self.raw.clone();
|
||||
self.state.content_h = self.wrapped.len() as u16;
|
||||
return;
|
||||
}
|
||||
let max_cols = width as usize;
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut out_idx: Vec<usize> = Vec::new();
|
||||
for (raw_idx, raw) in self.raw.iter().enumerate() {
|
||||
// Normalize tabs for width accounting (MVP: 4 spaces).
|
||||
let raw = raw.replace('\t', " ");
|
||||
if raw.is_empty() {
|
||||
out.push(String::new());
|
||||
out_idx.push(raw_idx);
|
||||
continue;
|
||||
}
|
||||
let mut line = String::new();
|
||||
let mut line_cols = 0usize;
|
||||
let mut last_soft_idx: Option<usize> = None; // last whitespace or punctuation break
|
||||
for (_i, ch) in raw.char_indices() {
|
||||
if ch == '\n' {
|
||||
out.push(std::mem::take(&mut line));
|
||||
out_idx.push(raw_idx);
|
||||
line_cols = 0;
|
||||
last_soft_idx = None;
|
||||
continue;
|
||||
}
|
||||
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if line_cols.saturating_add(w) > max_cols {
|
||||
if let Some(split) = last_soft_idx {
|
||||
let (prefix, rest) = line.split_at(split);
|
||||
out.push(prefix.trim_end().to_string());
|
||||
out_idx.push(raw_idx);
|
||||
line = rest.trim_start().to_string();
|
||||
line_cols = UnicodeWidthStr::width(line.as_str());
|
||||
last_soft_idx = None;
|
||||
// retry add current ch now that line may be shorter
|
||||
} else {
|
||||
if !line.is_empty() {
|
||||
out.push(std::mem::take(&mut line));
|
||||
out_idx.push(raw_idx);
|
||||
line_cols = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ch.is_whitespace()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | ';' | '.' | ':' | ')' | ']' | '}' | '|' | '/' | '?' | '!' | '-' | '_'
|
||||
)
|
||||
{
|
||||
last_soft_idx = Some(line.len());
|
||||
}
|
||||
line.push(ch);
|
||||
line_cols = UnicodeWidthStr::width(line.as_str());
|
||||
}
|
||||
if !line.is_empty() {
|
||||
out.push(line);
|
||||
out_idx.push(raw_idx);
|
||||
}
|
||||
}
|
||||
self.wrapped = out;
|
||||
self.wrapped_src_idx = out_idx;
|
||||
self.state.content_h = self.wrapped.len() as u16;
|
||||
}
|
||||
}
|
||||
660
codex-rs/cloud-tasks/src/ui.rs
Normal file
660
codex-rs/cloud-tasks/src/ui.rs
Normal file
@@ -0,0 +1,660 @@
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Direction;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::List;
|
||||
use ratatui::widgets::ListItem;
|
||||
use ratatui::widgets::ListState;
|
||||
use ratatui::widgets::Padding;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::app::App;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use codex_cloud_tasks_api::TaskStatus;
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||
let area = frame.area();
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(1), // list
|
||||
Constraint::Length(2), // two-line footer (help + status)
|
||||
])
|
||||
.split(area);
|
||||
if app.new_task.is_some() {
|
||||
draw_new_task_page(frame, chunks[0], app);
|
||||
draw_footer(frame, chunks[1], app);
|
||||
} else {
|
||||
draw_list(frame, chunks[0], app);
|
||||
draw_footer(frame, chunks[1], app);
|
||||
}
|
||||
|
||||
if app.diff_overlay.is_some() {
|
||||
draw_diff_overlay(frame, area, app);
|
||||
}
|
||||
if app.env_modal.is_some() {
|
||||
draw_env_modal(frame, area, app);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Overlay helpers (geometry + styling) =====
|
||||
static ROUNDED: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
fn rounded_enabled() -> bool {
|
||||
*ROUNDED.get_or_init(|| {
|
||||
std::env::var("CODEX_TUI_ROUNDED")
|
||||
.ok()
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn overlay_outer(area: Rect) -> Rect {
|
||||
let outer_v = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(10),
|
||||
Constraint::Percentage(80),
|
||||
Constraint::Percentage(10),
|
||||
])
|
||||
.split(area)[1];
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(10),
|
||||
Constraint::Percentage(80),
|
||||
Constraint::Percentage(10),
|
||||
])
|
||||
.split(outer_v)[1]
|
||||
}
|
||||
|
||||
fn overlay_block() -> Block<'static> {
|
||||
let base = Block::default().borders(Borders::ALL);
|
||||
let base = if rounded_enabled() {
|
||||
base.border_type(BorderType::Rounded)
|
||||
} else {
|
||||
base
|
||||
};
|
||||
base.padding(Padding::new(2, 2, 1, 1))
|
||||
}
|
||||
|
||||
fn overlay_content(area: Rect) -> Rect {
|
||||
overlay_block().inner(area)
|
||||
}
|
||||
|
||||
pub fn draw_new_task_page(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
let title_spans = {
|
||||
let mut spans: Vec<ratatui::text::Span> = vec!["New Task".magenta().bold()];
|
||||
if let Some(id) = app
|
||||
.new_task
|
||||
.as_ref()
|
||||
.and_then(|p| p.env_id.as_ref())
|
||||
.cloned()
|
||||
{
|
||||
spans.push(" • ".into());
|
||||
// Try to map id to label
|
||||
let label = app
|
||||
.environments
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.and_then(|r| r.label.clone())
|
||||
.unwrap_or(id);
|
||||
spans.push(label.dim());
|
||||
} else {
|
||||
spans.push(" • ".into());
|
||||
spans.push("Env: none (press ctrl-o to choose)".red());
|
||||
}
|
||||
spans
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(Line::from(title_spans));
|
||||
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block.clone(), area);
|
||||
let content = block.inner(area);
|
||||
|
||||
// Clamp composer height between 3 and 6 rows for readability.
|
||||
let desired = app
|
||||
.new_task
|
||||
.as_ref()
|
||||
.map(|p| p.composer.desired_height(content.width))
|
||||
.unwrap_or(3)
|
||||
.clamp(3, 6);
|
||||
|
||||
// Anchor the composer to the bottom-left by allocating a flexible spacer
|
||||
// above it and a fixed `desired`-height area for the composer.
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(1), Constraint::Length(desired)])
|
||||
.split(content);
|
||||
let composer_area = rows[1];
|
||||
|
||||
if let Some(page) = app.new_task.as_ref() {
|
||||
page.composer.render_ref(composer_area, frame.buffer_mut());
|
||||
// Composer renders its own footer hints; no extra row here.
|
||||
}
|
||||
|
||||
// Place cursor where composer wants it
|
||||
if let Some(page) = app.new_task.as_ref() {
|
||||
if let Some((x, y)) = page.composer.cursor_pos(composer_area) {
|
||||
frame.set_cursor(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
let items: Vec<ListItem> = app.tasks.iter().map(|t| render_task_item(app, t)).collect();
|
||||
|
||||
// Selection reflects the actual task index (no artificial spacer item).
|
||||
let mut state = ListState::default().with_selected(Some(app.selected));
|
||||
// Dim task list when a modal/overlay is active to emphasize focus.
|
||||
let dim_bg = app.env_modal.is_some() || app.diff_overlay.is_some();
|
||||
// Dynamic title includes current environment filter
|
||||
let suffix_span = if let Some(ref id) = app.env_filter {
|
||||
let label = app
|
||||
.environments
|
||||
.iter()
|
||||
.find(|r| &r.id == id)
|
||||
.and_then(|r| r.label.clone())
|
||||
.unwrap_or_else(|| "Selected".to_string());
|
||||
format!(" • {}", label).dim()
|
||||
} else {
|
||||
" • All".dim()
|
||||
};
|
||||
// Percent scrolled based on selection position in the list (0% at top, 100% at bottom).
|
||||
let percent_span = if app.tasks.len() <= 1 {
|
||||
" • 0%".dim()
|
||||
} else {
|
||||
let p = ((app.selected as f32) / ((app.tasks.len() - 1) as f32) * 100.0).round() as i32;
|
||||
format!(" • {}%", p.clamp(0, 100)).dim()
|
||||
};
|
||||
let title_line = {
|
||||
let base = Line::from(vec!["Cloud Tasks".into(), suffix_span, percent_span]);
|
||||
if dim_bg {
|
||||
base.style(Style::default().add_modifier(Modifier::DIM))
|
||||
} else {
|
||||
base
|
||||
}
|
||||
};
|
||||
let block = Block::default().borders(Borders::ALL).title(title_line);
|
||||
// Render the outer block first
|
||||
frame.render_widget(block.clone(), area);
|
||||
// Draw list inside with a persistent top spacer row
|
||||
let inner = block.inner(area);
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
||||
.split(inner);
|
||||
let mut list = List::new(items)
|
||||
.highlight_symbol("› ")
|
||||
.highlight_style(Style::default().bold());
|
||||
if dim_bg {
|
||||
list = list.style(Style::default().add_modifier(Modifier::DIM));
|
||||
}
|
||||
frame.render_stateful_widget(list, rows[1], &mut state);
|
||||
|
||||
// In-box spinner during initial/refresh loads
|
||||
if app.refresh_inflight {
|
||||
draw_centered_spinner(frame, inner, &mut app.throbber, "Loading tasks…");
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
let mut help = vec![
|
||||
"↑/↓".dim(),
|
||||
": Move ".dim(),
|
||||
"r".dim(),
|
||||
": Refresh ".dim(),
|
||||
"Enter".dim(),
|
||||
": Open ".dim(),
|
||||
];
|
||||
// Apply hint; show disabled note when overlay is open without a diff.
|
||||
if let Some(ov) = app.diff_overlay.as_ref() {
|
||||
if !ov.can_apply {
|
||||
help.push("a".dim());
|
||||
help.push(": Apply (disabled) ".dim());
|
||||
} else {
|
||||
help.push("a".dim());
|
||||
help.push(": Apply ".dim());
|
||||
}
|
||||
} else {
|
||||
help.push("a".dim());
|
||||
help.push(": Apply ".dim());
|
||||
}
|
||||
help.push("o : Set Env ".dim());
|
||||
if app.new_task.is_some() {
|
||||
help.push("(editing new task) ".dim());
|
||||
} else {
|
||||
help.push("n : New Task ".dim());
|
||||
}
|
||||
help.extend(vec!["q".dim(), ": Quit ".dim()]);
|
||||
// Split footer area into two rows: help+spinner (top) and status (bottom)
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Length(1)])
|
||||
.split(area);
|
||||
|
||||
// Top row: help text + spinner at right
|
||||
let top = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Fill(1), Constraint::Length(18)])
|
||||
.split(rows[0]);
|
||||
let para = Paragraph::new(Line::from(help));
|
||||
// Draw help text; avoid clearing the whole footer area every frame.
|
||||
frame.render_widget(para, top[0]);
|
||||
// Right side: spinner or clear the spinner area if idle to prevent stale glyphs.
|
||||
if app.refresh_inflight || app.details_inflight || app.env_loading {
|
||||
draw_inline_spinner(frame, top[1], &mut app.throbber, "Loading…");
|
||||
} else {
|
||||
frame.render_widget(Clear, top[1]);
|
||||
}
|
||||
|
||||
// Bottom row: status/log text across full width (single-line; sanitize newlines)
|
||||
let mut status_line = app.status.replace('\n', " ");
|
||||
if status_line.len() > 2000 {
|
||||
// hard cap to avoid TUI noise
|
||||
status_line.truncate(2000);
|
||||
status_line.push_str("…");
|
||||
}
|
||||
// Clear the status row to avoid trailing characters when the message shrinks.
|
||||
frame.render_widget(Clear, rows[1]);
|
||||
let status = Paragraph::new(status_line);
|
||||
frame.render_widget(status, rows[1]);
|
||||
}
|
||||
|
||||
fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
// Centered overlay rect (deduped geometry) and padded content via helpers.
|
||||
let inner = overlay_outer(area);
|
||||
|
||||
if app.diff_overlay.is_none() {
|
||||
return;
|
||||
}
|
||||
let is_error = if let Some(overlay) = app.diff_overlay.as_ref() {
|
||||
!overlay.can_apply
|
||||
&& overlay
|
||||
.sd
|
||||
.wrapped_lines()
|
||||
.first()
|
||||
.map(|s| s.trim_start().starts_with("Task failed:"))
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let can_apply = app
|
||||
.diff_overlay
|
||||
.as_ref()
|
||||
.map(|o| o.can_apply)
|
||||
.unwrap_or(false);
|
||||
let title = app
|
||||
.diff_overlay
|
||||
.as_ref()
|
||||
.map(|o| o.title.clone())
|
||||
.unwrap_or_default();
|
||||
// Ensure ScrollableDiff knows geometry using padded content area.
|
||||
let content_area = overlay_content(inner);
|
||||
if let Some(ov) = app.diff_overlay.as_mut() {
|
||||
ov.sd.set_width(content_area.width);
|
||||
ov.sd.set_viewport(content_area.height);
|
||||
}
|
||||
|
||||
// Optional percent scrolled label
|
||||
let pct_opt = app
|
||||
.diff_overlay
|
||||
.as_ref()
|
||||
.and_then(|o| o.sd.percent_scrolled());
|
||||
let mut title_spans: Vec<ratatui::text::Span> = if is_error {
|
||||
vec![
|
||||
"Details ".magenta(),
|
||||
"[FAILED]".red().bold(),
|
||||
" ".into(),
|
||||
title.clone().magenta(),
|
||||
]
|
||||
} else if can_apply {
|
||||
vec!["Diff: ".magenta(), title.clone().magenta()]
|
||||
} else {
|
||||
vec!["Details: ".magenta(), title.clone().magenta()]
|
||||
};
|
||||
if let Some(p) = pct_opt {
|
||||
title_spans.push(" • ".dim());
|
||||
title_spans.push(format!("{}%", p).dim());
|
||||
}
|
||||
let block = overlay_block().title(Line::from(title_spans));
|
||||
frame.render_widget(Clear, inner);
|
||||
frame.render_widget(block.clone(), inner);
|
||||
|
||||
let styled_lines: Vec<Line<'static>> = if can_apply {
|
||||
let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines());
|
||||
raw.unwrap_or(&[])
|
||||
.iter()
|
||||
.map(|l| style_diff_line(l))
|
||||
.collect()
|
||||
} else {
|
||||
// Basic markdown styling for assistant messages
|
||||
let mut in_code = false;
|
||||
let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines());
|
||||
raw.unwrap_or(&[])
|
||||
.iter()
|
||||
.map(|raw| {
|
||||
if raw.trim_start().starts_with("```") {
|
||||
in_code = !in_code;
|
||||
return Line::from(raw.to_string().cyan());
|
||||
}
|
||||
if in_code {
|
||||
return Line::from(raw.to_string().cyan());
|
||||
}
|
||||
let s = raw.trim_start();
|
||||
if s.starts_with("### ") || s.starts_with("## ") || s.starts_with("# ") {
|
||||
return Line::from(raw.to_string().magenta().bold());
|
||||
}
|
||||
if s.starts_with("- ") || s.starts_with("* ") {
|
||||
let rest = &s[2..];
|
||||
return Line::from(vec!["• ".into(), rest.to_string().into()]);
|
||||
}
|
||||
Line::from(raw.to_string())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let raw_empty = app
|
||||
.diff_overlay
|
||||
.as_ref()
|
||||
.map(|o| o.sd.wrapped_lines().is_empty())
|
||||
.unwrap_or(true);
|
||||
if app.details_inflight && raw_empty {
|
||||
// Show a centered spinner while loading details
|
||||
draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…");
|
||||
} else {
|
||||
// We pre-wrapped lines in ScrollableDiff; do not enable Paragraph-level wrapping here.
|
||||
let scroll = app
|
||||
.diff_overlay
|
||||
.as_ref()
|
||||
.map(|o| o.sd.state.scroll)
|
||||
.unwrap_or(0);
|
||||
let content = Paragraph::new(Text::from(styled_lines)).scroll((scroll, 0));
|
||||
frame.render_widget(content, content_area);
|
||||
}
|
||||
}
|
||||
|
||||
fn style_diff_line(raw: &str) -> Line<'static> {
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
|
||||
if raw.starts_with("@@") {
|
||||
return Line::from(vec![Span::styled(
|
||||
raw.to_string(),
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]);
|
||||
}
|
||||
if raw.starts_with("+++") || raw.starts_with("---") {
|
||||
return Line::from(vec![Span::styled(
|
||||
raw.to_string(),
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
)]);
|
||||
}
|
||||
if raw.starts_with('+') {
|
||||
return Line::from(vec![Span::styled(
|
||||
raw.to_string(),
|
||||
Style::default().fg(Color::Green),
|
||||
)]);
|
||||
}
|
||||
if raw.starts_with('-') {
|
||||
return Line::from(vec![Span::styled(
|
||||
raw.to_string(),
|
||||
Style::default().fg(Color::Red),
|
||||
)]);
|
||||
}
|
||||
Line::from(vec![Span::raw(raw.to_string())])
|
||||
}
|
||||
|
||||
fn render_task_item(app: &App, t: &codex_cloud_tasks_api::TaskSummary) -> ListItem<'static> {
|
||||
let status = match t.status {
|
||||
TaskStatus::Ready => "READY".green(),
|
||||
TaskStatus::Pending => "PENDING".magenta(),
|
||||
TaskStatus::Applied => "APPLIED".blue(),
|
||||
TaskStatus::Error => "ERROR".red(),
|
||||
};
|
||||
|
||||
// Title line: [STATUS] Title
|
||||
let title = Line::from(vec![
|
||||
"[".into(),
|
||||
status,
|
||||
"] ".into(),
|
||||
t.title.clone().into(),
|
||||
]);
|
||||
|
||||
// Meta line: environment label and relative time (dim)
|
||||
let mut meta: Vec<ratatui::text::Span> = Vec::new();
|
||||
if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) {
|
||||
meta.push(lbl.clone().dim());
|
||||
}
|
||||
let when = format_relative_time(t.updated_at).dim();
|
||||
if !meta.is_empty() {
|
||||
meta.push(" ".into());
|
||||
meta.push("•".dim());
|
||||
meta.push(" ".into());
|
||||
}
|
||||
meta.push(when);
|
||||
let meta_line = Line::from(meta);
|
||||
|
||||
// Subline: summary when present; otherwise show "no diff"
|
||||
let sub = if t.summary.files_changed > 0
|
||||
|| t.summary.lines_added > 0
|
||||
|| t.summary.lines_removed > 0
|
||||
{
|
||||
let adds = t.summary.lines_added;
|
||||
let dels = t.summary.lines_removed;
|
||||
let files = t.summary.files_changed;
|
||||
Line::from(vec![
|
||||
format!("+{}", adds).green(),
|
||||
"/".into(),
|
||||
format!("−{}", dels).red(),
|
||||
" ".into(),
|
||||
"•".dim(),
|
||||
" ".into(),
|
||||
format!("{}", files).into(),
|
||||
" ".into(),
|
||||
"files".dim(),
|
||||
])
|
||||
} else {
|
||||
Line::from("no diff".to_string().dim())
|
||||
};
|
||||
|
||||
// Insert a blank spacer line after the summary to separate tasks
|
||||
let spacer = Line::from("");
|
||||
ListItem::new(vec![title, meta_line, sub, spacer])
|
||||
}
|
||||
|
||||
fn format_relative_time(ts: chrono::DateTime<Utc>) -> String {
|
||||
let now = Utc::now();
|
||||
let mut secs = (now - ts).num_seconds();
|
||||
if secs < 0 {
|
||||
secs = 0;
|
||||
}
|
||||
if secs < 60 {
|
||||
return format!("{}s ago", secs);
|
||||
}
|
||||
let mins = secs / 60;
|
||||
if mins < 60 {
|
||||
return format!("{}m ago", mins);
|
||||
}
|
||||
let hours = mins / 60;
|
||||
if hours < 24 {
|
||||
return format!("{}h ago", hours);
|
||||
}
|
||||
let local = ts.with_timezone(&Local);
|
||||
local.format("%b %e %H:%M").to_string()
|
||||
}
|
||||
|
||||
fn draw_inline_spinner(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &mut throbber_widgets_tui::ThrobberState,
|
||||
label: &str,
|
||||
) {
|
||||
use ratatui::style::Style;
|
||||
use throbber_widgets_tui::BRAILLE_EIGHT;
|
||||
use throbber_widgets_tui::Throbber;
|
||||
use throbber_widgets_tui::WhichUse;
|
||||
let w = Throbber::default()
|
||||
.label(label)
|
||||
.style(Style::default().cyan())
|
||||
.throbber_style(Style::default().magenta().bold())
|
||||
.throbber_set(BRAILLE_EIGHT)
|
||||
.use_type(WhichUse::Spin);
|
||||
frame.render_stateful_widget(w, area, state);
|
||||
}
|
||||
|
||||
fn draw_centered_spinner(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &mut throbber_widgets_tui::ThrobberState,
|
||||
label: &str,
|
||||
) {
|
||||
// Center a 1xN throbber within the given rect
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Length(1),
|
||||
Constraint::Percentage(49),
|
||||
])
|
||||
.split(area);
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Length(18),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.split(rows[1]);
|
||||
draw_inline_spinner(frame, cols[1], state, label);
|
||||
}
|
||||
|
||||
fn style_diff_fragment(src_line: &str, fragment: &str) -> Line<'static> {
|
||||
if src_line.starts_with("@@") {
|
||||
return Line::from(fragment.to_string().magenta().bold());
|
||||
}
|
||||
if src_line.starts_with("diff --git ") || src_line.starts_with("index ") {
|
||||
return Line::from(fragment.to_string().dim());
|
||||
}
|
||||
if src_line.starts_with("+++") || src_line.starts_with("---") {
|
||||
return Line::from(fragment.to_string().dim());
|
||||
}
|
||||
match src_line.as_bytes().first().copied() {
|
||||
Some(b'+') => Line::from(fragment.to_string().green()),
|
||||
Some(b'-') => Line::from(fragment.to_string().red()),
|
||||
_ => Line::from(fragment.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
// Use shared overlay geometry and padding.
|
||||
let inner = overlay_outer(area);
|
||||
|
||||
// Title: primary only; move long hints to a subheader inside content.
|
||||
let title = Line::from(vec!["Select Environment".magenta().bold()]);
|
||||
let block = overlay_block().title(title);
|
||||
|
||||
frame.render_widget(Clear, inner);
|
||||
frame.render_widget(block.clone(), inner);
|
||||
let content = overlay_content(inner);
|
||||
|
||||
if app.env_loading {
|
||||
draw_centered_spinner(frame, content, &mut app.throbber, "Loading environments…");
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: subheader + search + results list
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // subheader
|
||||
Constraint::Length(1), // search
|
||||
Constraint::Min(1), // list
|
||||
])
|
||||
.split(content);
|
||||
|
||||
// Subheader with usage hints (dim cyan)
|
||||
let subheader = Paragraph::new(Line::from(
|
||||
"Type to search, Enter select, Esc cancel; r refresh"
|
||||
.cyan()
|
||||
.dim(),
|
||||
))
|
||||
.wrap(Wrap { trim: true });
|
||||
frame.render_widget(subheader, rows[0]);
|
||||
|
||||
let query = app
|
||||
.env_modal
|
||||
.as_ref()
|
||||
.map(|m| m.query.clone())
|
||||
.unwrap_or_default();
|
||||
let ql = query.to_lowercase();
|
||||
let search = Paragraph::new(format!("Search: {}", query)).wrap(Wrap { trim: true });
|
||||
frame.render_widget(search, rows[1]);
|
||||
|
||||
// Filter environments by query (case-insensitive substring over label/id/hints)
|
||||
let envs: Vec<&crate::app::EnvironmentRow> = app
|
||||
.environments
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
if ql.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut hay = String::new();
|
||||
if let Some(l) = &e.label {
|
||||
hay.push_str(&l.to_lowercase());
|
||||
hay.push(' ');
|
||||
}
|
||||
hay.push_str(&e.id.to_lowercase());
|
||||
if let Some(h) = &e.repo_hints {
|
||||
hay.push(' ');
|
||||
hay.push_str(&h.to_lowercase());
|
||||
}
|
||||
hay.contains(&ql)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut items: Vec<ListItem> = Vec::new();
|
||||
items.push(ListItem::new(Line::from("All Environments (Global)")));
|
||||
for env in envs.iter() {
|
||||
let primary = env.label.clone().unwrap_or_else(|| "<unnamed>".to_string());
|
||||
let mut spans: Vec<ratatui::text::Span> = vec![primary.into()];
|
||||
if env.is_pinned {
|
||||
spans.push(" ".into());
|
||||
spans.push("PINNED".magenta().bold());
|
||||
}
|
||||
spans.push(" ".into());
|
||||
spans.push(env.id.clone().dim());
|
||||
if let Some(hint) = &env.repo_hints {
|
||||
spans.push(" ".into());
|
||||
spans.push(hint.clone().dim());
|
||||
}
|
||||
items.push(ListItem::new(Line::from(spans)));
|
||||
}
|
||||
|
||||
let sel_desired = app.env_modal.as_ref().map(|m| m.selected).unwrap_or(0);
|
||||
let sel = sel_desired.min(envs.len());
|
||||
let mut list_state = ListState::default().with_selected(Some(sel));
|
||||
let list = List::new(items)
|
||||
.highlight_symbol("› ")
|
||||
.highlight_style(Style::default().bold())
|
||||
.block(Block::default().borders(Borders::NONE));
|
||||
frame.render_stateful_widget(list, rows[2], &mut list_state);
|
||||
}
|
||||
24
codex-rs/cloud-tasks/tests/env_filter.rs
Normal file
24
codex-rs/cloud-tasks/tests/env_filter.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use codex_cloud_tasks_api::CloudBackend;
|
||||
use codex_cloud_tasks_client::MockClient;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_backend_varies_by_env() {
|
||||
let client = MockClient::default();
|
||||
|
||||
let root = CloudBackend::list_tasks(&client, None).await.unwrap();
|
||||
assert!(root.iter().any(|t| t.title.contains("Update README")));
|
||||
|
||||
let a = CloudBackend::list_tasks(&client, Some("env-A"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(a[0].title, "A: First");
|
||||
|
||||
let b = CloudBackend::list_tasks(&client, Some("env-B"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(b.len(), 2);
|
||||
assert!(b[0].title.starts_with("B: "));
|
||||
}
|
||||
Reference in New Issue
Block a user