remote tasks

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

View File

@@ -0,0 +1,50 @@
[package]
name = "codex-cloud-tasks"
version = { workspace = true }
edition = "2024"
[lib]
name = "codex_cloud_tasks"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
codex-common = { path = "../common", features = ["cli"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
codex-cloud-tasks-api = { path = "../cloud-tasks-api" }
codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock", "online"] }
ratatui = { version = "0.29.0" }
crossterm = { version = "0.28.1", features = ["event-stream"] }
tokio-stream = "0.1.17"
chrono = { version = "0.4", features = ["serde"] }
codex-login = { path = "../login" }
codex-core = { path = "../core" }
codex-backend-client = { path = "../backend-client" }
throbber-widgets-tui = "0.8.0"
base64 = "0.22"
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
unicode-width = "0.1"
codex-tui = { path = "../tui" }
[dev-dependencies]
async-trait = "0.1"
[[bin]]
name = "conncheck"
path = "src/bin/conncheck.rs"
[[bin]]
name = "newtask"
path = "src/bin/newtask.rs"
[[bin]]
name = "envcheck"
path = "src/bin/envcheck.rs"

View File

@@ -0,0 +1,247 @@
use std::time::Duration;
// Environment filter data models for the TUI
#[derive(Clone, Debug, Default)]
pub struct EnvironmentRow {
pub id: String,
pub label: Option<String>,
pub is_pinned: bool,
pub repo_hints: Option<String>, // e.g., "openai/codex"
}
#[derive(Clone, Debug, Default)]
pub struct EnvModalState {
pub query: String,
pub selected: usize,
}
use crate::scrollable_diff::ScrollableDiff;
use codex_cloud_tasks_api::CloudBackend;
use codex_cloud_tasks_api::DiffSummary;
use codex_cloud_tasks_api::TaskId;
use codex_cloud_tasks_api::TaskSummary;
use throbber_widgets_tui::ThrobberState;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::UnboundedSender;
#[derive(Default)]
pub struct App {
pub tasks: Vec<TaskSummary>,
pub selected: usize,
pub status: String,
pub diff_overlay: Option<DiffOverlay>,
pub pending_apply: Option<(TaskId, String)>,
pub throbber: ThrobberState,
pub refresh_inflight: bool,
pub details_inflight: bool,
// Environment filter state
pub env_filter: Option<String>,
pub env_modal: Option<EnvModalState>,
pub environments: Vec<EnvironmentRow>,
pub env_last_loaded: Option<std::time::Instant>,
pub env_loading: bool,
pub env_error: Option<String>,
// New Task page
pub new_task: Option<crate::new_task::NewTaskPage>,
// Background enrichment coordination
pub list_generation: u64,
pub in_flight: std::collections::HashSet<String>,
pub summary_cache: std::collections::HashMap<String, (DiffSummary, std::time::Instant)>,
pub no_diff_yet: std::collections::HashSet<String>,
}
impl App {
pub fn new() -> Self {
Self {
tasks: Vec::new(),
selected: 0,
status: "Press r to refresh".to_string(),
diff_overlay: None,
pending_apply: None,
throbber: ThrobberState::default(),
refresh_inflight: false,
details_inflight: false,
env_filter: None,
env_modal: None,
environments: Vec::new(),
env_last_loaded: None,
env_loading: false,
env_error: None,
new_task: None,
list_generation: 0,
in_flight: std::collections::HashSet::new(),
summary_cache: std::collections::HashMap::new(),
no_diff_yet: std::collections::HashSet::new(),
}
}
pub fn next(&mut self) {
if self.tasks.is_empty() {
return;
}
self.selected = (self.selected + 1).min(self.tasks.len().saturating_sub(1));
}
pub fn prev(&mut self) {
if self.tasks.is_empty() {
return;
}
if self.selected > 0 {
self.selected -= 1;
}
}
}
pub async fn load_tasks(
backend: &dyn CloudBackend,
env: Option<&str>,
) -> anyhow::Result<Vec<TaskSummary>> {
// In later milestones, add a small debounce, spinner, and error display.
let tasks = tokio::time::timeout(Duration::from_secs(5), backend.list_tasks(env)).await??;
Ok(tasks)
}
pub struct DiffOverlay {
pub title: String,
pub task_id: TaskId,
pub sd: ScrollableDiff,
pub can_apply: bool,
}
/// Internal app events delivered from background tasks.
/// These let the UI event loop remain responsive and keep the spinner animating.
#[derive(Debug)]
pub enum AppEvent {
TasksLoaded {
env: Option<String>,
result: anyhow::Result<Vec<TaskSummary>>,
},
/// Background diff summary computed for a task (or determined absent)
TaskSummaryUpdated {
generation: u64,
id: TaskId,
summary: DiffSummary,
no_diff_yet: bool,
environment_id: Option<String>,
},
/// Autodetection of a likely environment id finished
EnvironmentAutodetected(anyhow::Result<crate::env_detect::AutodetectSelection>),
/// Background completion of environment list fetch
EnvironmentsLoaded(anyhow::Result<Vec<EnvironmentRow>>),
DetailsDiffLoaded {
id: TaskId,
title: String,
diff: String,
},
DetailsMessagesLoaded {
id: TaskId,
title: String,
messages: Vec<String>,
},
DetailsFailed {
id: TaskId,
title: String,
error: String,
},
/// Background completion of new task submission
NewTaskSubmitted(Result<codex_cloud_tasks_api::CreatedTask, String>),
}
pub type AppEventTx = UnboundedSender<AppEvent>;
pub type AppEventRx = UnboundedReceiver<AppEvent>;
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
struct FakeBackend {
// maps env key to titles
by_env: std::collections::HashMap<Option<String>, Vec<&'static str>>,
}
#[async_trait::async_trait]
impl codex_cloud_tasks_api::CloudBackend for FakeBackend {
async fn list_tasks(
&self,
env: Option<&str>,
) -> codex_cloud_tasks_api::Result<Vec<TaskSummary>> {
let key = env.map(|s| s.to_string());
let titles = self
.by_env
.get(&key)
.cloned()
.unwrap_or_else(|| vec!["default-a", "default-b"]);
let mut out = Vec::new();
for (i, t) in titles.into_iter().enumerate() {
out.push(TaskSummary {
id: TaskId(format!("T-{}", i)),
title: t.to_string(),
status: codex_cloud_tasks_api::TaskStatus::Ready,
updated_at: Utc::now(),
environment_id: env.map(|s| s.to_string()),
environment_label: None,
summary: codex_cloud_tasks_api::DiffSummary::default(),
});
}
Ok(out)
}
async fn get_task_diff(&self, _id: TaskId) -> codex_cloud_tasks_api::Result<String> {
Err(codex_cloud_tasks_api::Error::Unimplemented(
"not used in test",
))
}
async fn get_task_messages(
&self,
_id: TaskId,
) -> codex_cloud_tasks_api::Result<Vec<String>> {
Ok(vec![])
}
async fn apply_task(
&self,
_id: TaskId,
) -> codex_cloud_tasks_api::Result<codex_cloud_tasks_api::ApplyOutcome> {
Err(codex_cloud_tasks_api::Error::Unimplemented(
"not used in test",
))
}
async fn create_task(
&self,
_env_id: &str,
_prompt: &str,
_git_ref: &str,
_qa_mode: bool,
) -> codex_cloud_tasks_api::Result<codex_cloud_tasks_api::CreatedTask> {
Err(codex_cloud_tasks_api::Error::Unimplemented(
"not used in test",
))
}
}
#[tokio::test]
async fn load_tasks_uses_env_parameter() {
// Arrange: env-specific task titles
let mut by_env = std::collections::HashMap::new();
by_env.insert(None, vec!["root-1", "root-2"]);
by_env.insert(Some("env-A".to_string()), vec!["A-1"]);
by_env.insert(Some("env-B".to_string()), vec!["B-1", "B-2", "B-3"]);
let backend = FakeBackend { by_env };
// Act + Assert
let root = load_tasks(&backend, None).await.unwrap();
assert_eq!(root.len(), 2);
assert_eq!(root[0].title, "root-1");
let a = load_tasks(&backend, Some("env-A")).await.unwrap();
assert_eq!(a.len(), 1);
assert_eq!(a[0].title, "A-1");
let b = load_tasks(&backend, Some("env-B")).await.unwrap();
assert_eq!(b.len(), 3);
assert_eq!(b[2].title, "B-3");
}
}

View File

@@ -0,0 +1,135 @@
#![deny(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use base64::Engine;
use codex_backend_client::Client as BackendClient;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthManager;
use codex_login::AuthMode;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Base URL (default to ChatGPT backend API)
let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
println!("base_url: {base_url}");
let path_style = if base_url.contains("/backend-api") {
"wham"
} else {
"codex-api"
};
println!("path_style: {path_style}");
// Locate CODEX_HOME and try to load ChatGPT auth
let codex_home = match find_codex_home() {
Ok(p) => {
println!("codex_home: {}", p.display());
Some(p)
}
Err(e) => {
println!("codex_home: <not found> ({e})");
None
}
};
// Build backend client with UA
let ua = get_codex_user_agent(Some("codex_cloud_tasks_conncheck"));
let mut client = BackendClient::new(base_url.clone())?.with_user_agent(ua);
// Attach bearer token if available from ChatGPT auth
let mut have_auth = false;
if let Some(home) = codex_home {
let authm = AuthManager::new(
home,
AuthMode::ChatGPT,
"codex_cloud_tasks_conncheck".to_string(),
);
if let Some(auth) = authm.auth() {
match auth.get_token().await {
Ok(token) if !token.is_empty() => {
have_auth = true;
println!("auth: ChatGPT token present ({} chars)", token.len());
// Add Authorization header
client = client.with_bearer_token(&token);
// Attempt to extract ChatGPT account id from the JWT and set header.
if let Some(account_id) = extract_chatgpt_account_id(&token) {
println!("auth: ChatGPT-Account-Id: {account_id}");
client = client.with_chatgpt_account_id(account_id);
} else if let Some(acc) = auth.get_account_id() {
// Fallback: some older auth.jsons persist account_id
println!("auth: ChatGPT-Account-Id (from auth.json): {acc}");
client = client.with_chatgpt_account_id(acc);
}
}
Ok(_) => {
println!("auth: ChatGPT token empty");
}
Err(e) => {
println!("auth: failed to load ChatGPT token: {e}");
}
}
} else {
println!("auth: no ChatGPT auth.json");
}
}
if !have_auth {
println!("note: Online endpoints typically require ChatGPT sign-in. Run: `codex login`");
}
// Attempt the /list call with a short timeout to avoid hanging
match path_style {
"wham" => println!("request: GET /wham/tasks/list?limit=5&task_filter=current"),
_ => println!("request: GET /api/codex/tasks/list?limit=5&task_filter=current"),
}
let fut = client.list_tasks(Some(5), Some("current"), None);
let res = tokio::time::timeout(Duration::from_secs(30), fut).await;
match res {
Err(_) => {
println!("error: request timed out after 30s");
std::process::exit(2);
}
Ok(Err(e)) => {
// backend-client includes HTTP status and body in errors.
println!("error: {e}");
std::process::exit(1);
}
Ok(Ok(list)) => {
println!("ok: received {} tasks", list.items.len());
for item in list.items.iter().take(5) {
println!("- {}{}", item.id, item.title);
}
// Print the full response object for debugging/inspection.
match serde_json::to_string_pretty(&list) {
Ok(json) => {
println!("\nfull response object (pretty JSON):\n{}", json);
}
Err(e) => {
println!("failed to serialize response to JSON: {e}");
}
}
}
}
Ok(())
}
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
// JWT: header.payload.signature
let mut parts = token.split('.');
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
_ => return None,
};
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.ok()?;
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
v.get("https://api.openai.com/auth")
.and_then(|auth| auth.get("chatgpt_account_id"))
.and_then(|id| id.as_str())
.map(|s| s.to_string())
}

View File

@@ -0,0 +1,50 @@
#![deny(clippy::unwrap_used, clippy::expect_used)]
use codex_backend_client::Client as BackendClient;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthManager;
use codex_login::AuthMode;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
let ua = get_codex_user_agent(Some("codex_cloud_tasks_detailcheck"));
let mut client = BackendClient::new(base_url)?.with_user_agent(ua);
if let Ok(home) = find_codex_home() {
let am = AuthManager::new(
home,
AuthMode::ChatGPT,
"codex_cloud_tasks_detailcheck".to_string(),
);
if let Some(auth) = am.auth() {
if let Ok(tok) = auth.get_token().await {
client = client.with_bearer_token(tok);
}
}
}
let list = client.list_tasks(Some(5), Some("current"), None).await?;
println!("items: {}", list.items.len());
for item in list.items.iter().take(5) {
println!("item: {} {}", item.id, item.title);
let (details, body, ct) = client.get_task_details_with_body(&item.id).await?;
let diff = codex_backend_client::CodeTaskDetailsResponseExt::unified_diff(&details);
match diff {
Some(d) => println!(
"unified diff len={} sample=\n{}",
d.len(),
&d.lines().take(10).collect::<Vec<_>>().join("\n")
),
None => {
println!(
"no unified diff found; ct={ct}; body sample=\n{}",
&body.chars().take(5000).collect::<String>()
);
}
}
}
Ok(())
}

View File

@@ -0,0 +1,141 @@
#![deny(clippy::unwrap_used, clippy::expect_used)]
use base64::Engine;
use clap::Parser;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthManager;
use codex_login::AuthMode;
use reqwest::header::AUTHORIZATION;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
#[derive(Debug, Parser)]
#[command(version, about = "Resolve Codex environment id (debug helper)")]
struct Args {
/// Optional override for environment id; if present we just echo it.
#[arg(long = "env-id")]
environment_id: Option<String>,
/// Optional label to select a matching environment (case-insensitive exact match).
#[arg(long = "env-label")]
environment_label: Option<String>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
// Base URL (default to ChatGPT backend API) with normalization
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
while base_url.ends_with('/') {
base_url.pop();
}
if (base_url.starts_with("https://chatgpt.com")
|| base_url.starts_with("https://chat.openai.com"))
&& !base_url.contains("/backend-api")
{
base_url = format!("{}/backend-api", base_url);
}
println!("base_url: {base_url}");
println!(
"path_style: {}",
if base_url.contains("/backend-api") {
"wham"
} else {
"codex-api"
}
);
// Build headers: UA + ChatGPT auth if available
let ua = get_codex_user_agent(Some("codex_cloud_tasks_envcheck"));
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
// Locate CODEX_HOME and try to load ChatGPT auth
if let Ok(home) = find_codex_home() {
println!("codex_home: {}", home.display());
let authm = AuthManager::new(
home,
AuthMode::ChatGPT,
"codex_cloud_tasks_envcheck".to_string(),
);
if let Some(auth) = authm.auth() {
match auth.get_token().await {
Ok(token) if !token.is_empty() => {
println!("auth: ChatGPT token present ({} chars)", token.len());
let value = format!("Bearer {}", token);
if let Ok(hv) = HeaderValue::from_str(&value) {
headers.insert(AUTHORIZATION, hv);
}
if let Some(account_id) = auth
.get_account_id()
.or_else(|| extract_chatgpt_account_id(&token))
{
println!("auth: ChatGPT-Account-Id: {account_id}");
if let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") {
if let Ok(hv) = HeaderValue::from_str(&account_id) {
headers.insert(name, hv);
}
}
}
}
Ok(_) => println!("auth: ChatGPT token empty"),
Err(e) => println!("auth: failed to load ChatGPT token: {e}"),
}
} else {
println!("auth: no ChatGPT auth.json");
}
} else {
println!("codex_home: <not found>");
}
// If user supplied an environment id, just echo it and exit.
if let Some(id) = args.environment_id {
println!("env: provided env-id={id}");
return Ok(());
}
// Auto-detect environment id using shared env_detect
match codex_cloud_tasks::env_detect::autodetect_environment_id(
&base_url,
&headers,
args.environment_label,
)
.await
{
Ok(sel) => {
println!(
"env: selected environment_id={} label={}",
sel.id,
sel.label.unwrap_or_else(|| "<none>".to_string())
);
Ok(())
}
Err(e) => {
println!("env: failed: {e}");
std::process::exit(2)
}
}
}
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
// JWT: header.payload.signature
let mut parts = token.split('.');
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
_ => return None,
};
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.ok()?;
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
v.get("https://api.openai.com/auth")
.and_then(|auth| auth.get("chatgpt_account_id"))
.and_then(|id| id.as_str())
.map(|s| s.to_string())
}

View File

@@ -0,0 +1,211 @@
#![deny(clippy::unwrap_used, clippy::expect_used)]
use base64::Engine;
use clap::Parser;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthManager;
use codex_login::AuthMode;
use reqwest::header::AUTHORIZATION;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
#[derive(Debug, Parser)]
#[command(version, about = "Create a new Codex cloud task (debug helper)")]
struct Args {
/// Optional override for environment id; if absent we auto-detect.
#[arg(long = "env-id")]
environment_id: Option<String>,
/// Optional label match for environment selection (case-insensitive, exact match).
#[arg(long = "env-label")]
environment_label: Option<String>,
/// Branch or ref to use (e.g., main)
#[arg(long = "ref", default_value = "main")]
git_ref: String,
/// Run environment in QA (ask) mode
#[arg(long = "qa-mode", default_value_t = false)]
qa_mode: bool,
/// Task prompt text
#[arg(required = true)]
prompt: Vec<String>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let prompt = args.prompt.join(" ");
// Base URL (default to ChatGPT backend API)
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
while base_url.ends_with('/') {
base_url.pop();
}
if (base_url.starts_with("https://chatgpt.com")
|| base_url.starts_with("https://chat.openai.com"))
&& !base_url.contains("/backend-api")
{
base_url = format!("{}/backend-api", base_url);
}
println!("base_url: {base_url}");
let is_wham = base_url.contains("/backend-api");
println!("path_style: {}", if is_wham { "wham" } else { "codex-api" });
// Build headers: UA + ChatGPT auth if available
let ua = get_codex_user_agent(Some("codex_cloud_tasks_newtask"));
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
let mut have_auth = false;
// Locate CODEX_HOME and try to load ChatGPT auth
if let Ok(home) = find_codex_home() {
let authm = AuthManager::new(
home,
AuthMode::ChatGPT,
"codex_cloud_tasks_newtask".to_string(),
);
if let Some(auth) = authm.auth() {
match auth.get_token().await {
Ok(token) if !token.is_empty() => {
have_auth = true;
println!("auth: ChatGPT token present ({} chars)", token.len());
let value = format!("Bearer {}", token);
if let Ok(hv) = HeaderValue::from_str(&value) {
headers.insert(AUTHORIZATION, hv);
}
if let Some(account_id) = auth
.get_account_id()
.or_else(|| extract_chatgpt_account_id(&token))
{
println!("auth: ChatGPT-Account-Id: {account_id}");
if let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") {
if let Ok(hv) = HeaderValue::from_str(&account_id) {
headers.insert(name, hv);
}
}
}
}
Ok(_) => println!("auth: ChatGPT token empty"),
Err(e) => println!("auth: failed to load ChatGPT token: {e}"),
}
} else {
println!("auth: no ChatGPT auth.json");
}
}
if !have_auth {
println!("note: Online endpoints typically require ChatGPT sign-in. Run: `codex login`");
}
// Determine environment id: prefer flag, then by-repo lookup, then full list.
let env_id = if let Some(id) = args.environment_id.clone() {
println!("env: using provided env-id={id}");
id
} else {
match codex_cloud_tasks::env_detect::autodetect_environment_id(
&base_url,
&headers,
args.environment_label.clone(),
)
.await
{
Ok(sel) => sel.id,
Err(e) => {
println!("env: failed to auto-detect environment: {e}");
std::process::exit(2);
}
}
};
println!("env: selected environment_id={env_id}");
// Build request payload patterned after VSCode: POST /wham/tasks
let url = if is_wham {
format!("{}/wham/tasks", base_url)
} else {
format!("{}/api/codex/tasks", base_url)
};
println!(
"request: POST {}",
url.strip_prefix(&base_url).unwrap_or(&url)
);
// input_items
let mut input_items: Vec<serde_json::Value> = Vec::new();
input_items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": [{ "content_type": "text", "text": prompt }]
}));
// Optional: starting diff via env var for quick testing
if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF") {
if !diff.is_empty() {
input_items.push(serde_json::json!({
"type": "pre_apply_patch",
"output_diff": { "diff": diff }
}));
}
}
let request_body = serde_json::json!({
"new_task": {
"environment_id": env_id,
"branch": args.git_ref,
"run_environment_in_qa_mode": args.qa_mode,
},
"input_items": input_items,
});
let http = reqwest::Client::builder().build()?;
let res = http
.post(&url)
.headers(headers)
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&request_body)
.send()
.await?;
let status = res.status();
let ct = res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = res.text().await.unwrap_or_default();
println!("status: {}", status);
println!("content-type: {}", ct);
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => println!(
"response (pretty JSON):\n{}",
serde_json::to_string_pretty(&v).unwrap_or(body)
),
Err(_) => println!("response (raw):\n{}", body),
}
if !status.is_success() {
// Exit non-zero on failure
std::process::exit(1);
}
Ok(())
}
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
// JWT: header.payload.signature
let mut parts = token.split('.');
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
_ => return None,
};
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.ok()?;
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
v.get("https://api.openai.com/auth")
.and_then(|auth| auth.get("chatgpt_account_id"))
.and_then(|id| id.as_str())
.map(|s| s.to_string())
}

View File

@@ -0,0 +1,9 @@
use clap::Parser;
use codex_common::CliConfigOverrides;
#[derive(Parser, Debug, Default)]
#[command(version)]
pub struct Cli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}

View File

@@ -0,0 +1,380 @@
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
use std::collections::HashMap;
use tracing::info;
use tracing::warn;
#[derive(Debug, Clone, serde::Deserialize)]
struct CodeEnvironment {
id: String,
#[serde(default)]
label: Option<String>,
#[serde(default)]
is_pinned: Option<bool>,
#[serde(default)]
task_count: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct AutodetectSelection {
pub id: String,
pub label: Option<String>,
}
pub async fn autodetect_environment_id(
base_url: &str,
headers: &HeaderMap,
desired_label: Option<String>,
) -> anyhow::Result<AutodetectSelection> {
// 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode)
let origins = get_git_origins();
crate::append_error_log(format!("env: git origins: {:?}", origins));
let mut by_repo_envs: Vec<CodeEnvironment> = Vec::new();
for origin in &origins {
if let Some((owner, repo)) = parse_owner_repo(origin) {
let url = if base_url.contains("/backend-api") {
format!(
"{}/wham/environments/by-repo/{}/{}/{}",
base_url, "github", owner, repo
)
} else {
format!(
"{}/api/codex/environments/by-repo/{}/{}/{}",
base_url, "github", owner, repo
)
};
crate::append_error_log(format!("env: GET {}", url));
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
Ok(mut list) => {
crate::append_error_log(format!(
"env: by-repo returned {} env(s) for {}/{}",
list.len(),
owner,
repo
));
by_repo_envs.append(&mut list);
}
Err(e) => crate::append_error_log(format!(
"env: by-repo fetch failed for {}/{}: {e}",
owner, repo
)),
}
}
}
if let Some(env) = pick_environment_row(&by_repo_envs, desired_label.as_deref()) {
return Ok(AutodetectSelection {
id: env.id.clone(),
label: env.label.clone(),
});
}
// 2) Fallback to the full list
let list_url = if base_url.contains("/backend-api") {
format!("{}/wham/environments", base_url)
} else {
format!("{}/api/codex/environments", base_url)
};
crate::append_error_log(format!("env: GET {}", list_url));
// Fetch and log the full environments JSON for debugging
let http = reqwest::Client::builder().build()?;
let res = http.get(&list_url).headers(headers.clone()).send().await?;
let status = res.status();
let ct = res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = res.text().await.unwrap_or_default();
crate::append_error_log(format!("env: status={} content-type={}", status, ct));
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => {
let pretty = serde_json::to_string_pretty(&v).unwrap_or(body.clone());
crate::append_error_log(format!("env: /environments JSON (pretty):\n{}", pretty));
}
Err(_) => crate::append_error_log(format!("env: /environments (raw):\n{}", body)),
}
if !status.is_success() {
anyhow::bail!(format!(
"GET {} failed: {}; content-type={}; body={}",
list_url, status, ct, body
));
}
let all_envs: Vec<CodeEnvironment> = serde_json::from_str(&body).map_err(|e| {
anyhow::anyhow!(format!(
"Decode error for {}: {}; content-type={}; body={}",
list_url, e, ct, body
))
})?;
if let Some(env) = pick_environment_row(&all_envs, desired_label.as_deref()) {
return Ok(AutodetectSelection {
id: env.id.clone(),
label: env.label.clone(),
});
}
anyhow::bail!("no environments available")
}
fn pick_environment_row(
envs: &[CodeEnvironment],
desired_label: Option<&str>,
) -> Option<CodeEnvironment> {
if envs.is_empty() {
return None;
}
if let Some(label) = desired_label {
let lc = label.to_lowercase();
if let Some(e) = envs
.iter()
.find(|e| e.label.as_deref().unwrap_or("").to_lowercase() == lc)
{
crate::append_error_log(format!("env: matched by label: {} -> {}", label, e.id));
return Some(e.clone());
}
}
if envs.len() == 1 {
crate::append_error_log("env: single environment available; selecting it");
return Some(envs[0].clone());
}
if let Some(e) = envs.iter().find(|e| e.is_pinned.unwrap_or(false)) {
crate::append_error_log(format!("env: selecting pinned environment: {}", e.id));
return Some(e.clone());
}
// Highest task_count as heuristic
if let Some(e) = envs
.iter()
.max_by_key(|e| e.task_count.unwrap_or(0))
.or_else(|| envs.first())
{
crate::append_error_log(format!("env: selecting by task_count/first: {}", e.id));
return Some(e.clone());
}
None
}
async fn get_json<T: serde::de::DeserializeOwned>(
url: &str,
headers: &HeaderMap,
) -> anyhow::Result<T> {
let http = reqwest::Client::builder().build()?;
let res = http.get(url).headers(headers.clone()).send().await?;
let status = res.status();
let ct = res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = res.text().await.unwrap_or_default();
crate::append_error_log(format!("env: status={} content-type={}", status, ct));
if !status.is_success() {
anyhow::bail!(format!(
"GET {url} failed: {status}; content-type={ct}; body={body}"
));
}
let parsed = serde_json::from_str::<T>(&body).map_err(|e| {
anyhow::anyhow!(format!(
"Decode error for {url}: {e}; content-type={ct}; body={body}"
))
})?;
Ok(parsed)
}
fn get_git_origins() -> Vec<String> {
// Prefer: git config --get-regexp remote\..*\.url
let out = std::process::Command::new("git")
.args(["config", "--get-regexp", "remote\\..*\\.url"])
.output();
if let Ok(ok) = out {
if ok.status.success() {
let s = String::from_utf8_lossy(&ok.stdout);
let mut urls = Vec::new();
for line in s.lines() {
if let Some((_, url)) = line.split_once(' ') {
urls.push(url.trim().to_string());
}
}
if !urls.is_empty() {
return uniq(urls);
}
}
}
// Fallback: git remote -v
let out = std::process::Command::new("git")
.args(["remote", "-v"])
.output();
if let Ok(ok) = out {
if ok.status.success() {
let s = String::from_utf8_lossy(&ok.stdout);
let mut urls = Vec::new();
for line in s.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
urls.push(parts[1].to_string());
}
}
if !urls.is_empty() {
return uniq(urls);
}
}
}
Vec::new()
}
fn uniq(mut v: Vec<String>) -> Vec<String> {
v.sort();
v.dedup();
v
}
fn parse_owner_repo(url: &str) -> Option<(String, String)> {
// Normalize common prefixes and handle multiple SSH/HTTPS variants.
let mut s = url.trim().to_string();
// Drop protocol scheme for ssh URLs
if let Some(rest) = s.strip_prefix("ssh://") {
s = rest.to_string();
}
// Accept any user before @github.com (e.g., git@, org-123@)
if let Some(idx) = s.find("@github.com:") {
let rest = &s[idx + "@github.com:".len()..];
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
let mut parts = rest.splitn(2, '/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
crate::append_error_log(format!(
"env: parsed SSH GitHub origin => {}/{}",
owner, repo
));
return Some((owner, repo));
}
// HTTPS or git protocol
for prefix in [
"https://github.com/",
"http://github.com/",
"git://github.com/",
"github.com/",
] {
if let Some(rest) = s.strip_prefix(prefix) {
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
let mut parts = rest.splitn(2, '/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
crate::append_error_log(format!(
"env: parsed HTTP GitHub origin => {}/{}",
owner, repo
));
return Some((owner, repo));
}
}
None
}
/// List environments for the current repo(s) with a fallback to the global list.
/// Returns a de-duplicated, sorted set suitable for the TUI modal.
pub async fn list_environments(
base_url: &str,
headers: &HeaderMap,
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
let mut map: HashMap<String, crate::app::EnvironmentRow> = HashMap::new();
// 1) By-repo lookup for each parsed GitHub origin
let origins = get_git_origins();
for origin in &origins {
if let Some((owner, repo)) = parse_owner_repo(origin) {
let url = if base_url.contains("/backend-api") {
format!(
"{}/wham/environments/by-repo/{}/{}/{}",
base_url, "github", owner, repo
)
} else {
format!(
"{}/api/codex/environments/by-repo/{}/{}/{}",
base_url, "github", owner, repo
)
};
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
Ok(list) => {
info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len());
for e in list {
let entry =
map.entry(e.id.clone())
.or_insert_with(|| crate::app::EnvironmentRow {
id: e.id.clone(),
label: e.label.clone(),
is_pinned: e.is_pinned.unwrap_or(false),
repo_hints: Some(format!("{}/{}", owner, repo)),
});
// Merge: keep label if present, or use new; accumulate pinned flag
if entry.label.is_none() {
entry.label = e.label.clone();
}
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
if entry.repo_hints.is_none() {
entry.repo_hints = Some(format!("{}/{}", owner, repo));
}
}
}
Err(e) => {
warn!(
"env_tui: by-repo fetch failed for {}/{}: {}",
owner, repo, e
);
}
}
}
}
// 2) Fallback to the full list; on error return what we have if any.
let list_url = if base_url.contains("/backend-api") {
format!("{}/wham/environments", base_url)
} else {
format!("{}/api/codex/environments", base_url)
};
match get_json::<Vec<CodeEnvironment>>(&list_url, headers).await {
Ok(list) => {
info!("env_tui: global list -> {} envs", list.len());
for e in list {
let entry = map
.entry(e.id.clone())
.or_insert_with(|| crate::app::EnvironmentRow {
id: e.id.clone(),
label: e.label.clone(),
is_pinned: e.is_pinned.unwrap_or(false),
repo_hints: None,
});
if entry.label.is_none() {
entry.label = e.label.clone();
}
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
}
}
Err(e) => {
if map.is_empty() {
return Err(e);
} else {
warn!(
"env_tui: global list failed; using by-repo results only: {}",
e
);
}
}
}
let mut rows: Vec<crate::app::EnvironmentRow> = map.into_values().collect();
rows.sort_by(|a, b| {
// pinned first
let p = b.is_pinned.cmp(&a.is_pinned);
if p != std::cmp::Ordering::Equal {
return p;
}
// then label (ci), then id
let al = a.label.as_deref().unwrap_or("").to_lowercase();
let bl = b.label.as_deref().unwrap_or("").to_lowercase();
let l = al.cmp(&bl);
if l != std::cmp::Ordering::Equal {
return l;
}
a.id.cmp(&b.id)
});
Ok(rows)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
use codex_tui::ComposerAction;
use codex_tui::ComposerInput;
#[derive(Default)]
pub struct NewTaskPage {
pub composer: ComposerInput,
pub submitting: bool,
pub env_id: Option<String>,
}
impl NewTaskPage {
pub fn new(env_id: Option<String>) -> Self {
let mut composer = ComposerInput::new();
composer.set_hint_items(vec![
("", "send"),
("Shift+⏎", "newline"),
("Ctrl+O", "env"),
("Ctrl+C", "quit"),
]);
Self {
composer,
submitting: false,
env_id,
}
}
pub fn can_submit(&self) -> bool {
self.env_id.is_some() && !self.submitting
}
}

View File

@@ -0,0 +1,178 @@
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
/// Scroll position and geometry for a vertical scroll view.
#[derive(Clone, Copy, Debug, Default)]
pub struct ScrollViewState {
pub scroll: u16,
pub viewport_h: u16,
pub content_h: u16,
}
impl ScrollViewState {
pub fn clamp(&mut self) {
let max_scroll = self.content_h.saturating_sub(self.viewport_h);
if self.scroll > max_scroll {
self.scroll = max_scroll;
}
}
}
/// A simple, local scrollable view for diffs or message text.
///
/// Owns raw lines, caches wrapped lines for a given width, and maintains
/// a small scroll state that is clamped whenever geometry shrinks.
#[derive(Clone, Debug, Default)]
pub struct ScrollableDiff {
raw: Vec<String>,
wrapped: Vec<String>,
wrapped_src_idx: Vec<usize>,
wrap_cols: Option<u16>,
pub state: ScrollViewState,
}
impl ScrollableDiff {
pub fn new() -> Self {
Self::default()
}
/// Replace the raw content lines. Does not rewrap immediately; call `set_width` next.
pub fn set_content(&mut self, lines: Vec<String>) {
self.raw = lines;
self.wrapped.clear();
self.wrapped_src_idx.clear();
self.state.content_h = 0;
}
/// Set the wrap width. If changed, rebuild wrapped lines and clamp scroll.
pub fn set_width(&mut self, width: u16) {
if self.wrap_cols == Some(width) {
return;
}
self.wrap_cols = Some(width);
self.rewrap(width);
self.state.clamp();
}
/// Update viewport height and clamp scroll if needed.
pub fn set_viewport(&mut self, height: u16) {
self.state.viewport_h = height;
self.state.clamp();
}
/// Return the cached wrapped lines. Call `set_width` first when area changes.
pub fn wrapped_lines(&self) -> &[String] {
&self.wrapped
}
pub fn wrapped_src_indices(&self) -> &[usize] {
&self.wrapped_src_idx
}
pub fn raw_line_at(&self, idx: usize) -> &str {
self.raw.get(idx).map(|s| s.as_str()).unwrap_or("")
}
/// Scroll by a signed delta; clamps to content.
pub fn scroll_by(&mut self, delta: i16) {
let s = self.state.scroll as i32 + delta as i32;
self.state.scroll = s.clamp(0, self.max_scroll() as i32) as u16;
}
/// Page by a signed delta; typically viewport_h - 1.
pub fn page_by(&mut self, delta: i16) {
self.scroll_by(delta);
}
pub fn to_top(&mut self) {
self.state.scroll = 0;
}
pub fn to_bottom(&mut self) {
self.state.scroll = self.max_scroll();
}
/// Optional percent scrolled; None when not enough geometry is known.
pub fn percent_scrolled(&self) -> Option<u8> {
if self.state.content_h == 0 || self.state.viewport_h == 0 {
return None;
}
if self.state.content_h <= self.state.viewport_h {
return None;
}
let visible_bottom = self.state.scroll.saturating_add(self.state.viewport_h) as f32;
let pct = (visible_bottom / self.state.content_h as f32 * 100.0).round();
Some(pct.clamp(0.0, 100.0) as u8)
}
fn max_scroll(&self) -> u16 {
self.state.content_h.saturating_sub(self.state.viewport_h)
}
fn rewrap(&mut self, width: u16) {
if width == 0 {
self.wrapped = self.raw.clone();
self.state.content_h = self.wrapped.len() as u16;
return;
}
let max_cols = width as usize;
let mut out: Vec<String> = Vec::new();
let mut out_idx: Vec<usize> = Vec::new();
for (raw_idx, raw) in self.raw.iter().enumerate() {
// Normalize tabs for width accounting (MVP: 4 spaces).
let raw = raw.replace('\t', " ");
if raw.is_empty() {
out.push(String::new());
out_idx.push(raw_idx);
continue;
}
let mut line = String::new();
let mut line_cols = 0usize;
let mut last_soft_idx: Option<usize> = None; // last whitespace or punctuation break
for (_i, ch) in raw.char_indices() {
if ch == '\n' {
out.push(std::mem::take(&mut line));
out_idx.push(raw_idx);
line_cols = 0;
last_soft_idx = None;
continue;
}
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if line_cols.saturating_add(w) > max_cols {
if let Some(split) = last_soft_idx {
let (prefix, rest) = line.split_at(split);
out.push(prefix.trim_end().to_string());
out_idx.push(raw_idx);
line = rest.trim_start().to_string();
line_cols = UnicodeWidthStr::width(line.as_str());
last_soft_idx = None;
// retry add current ch now that line may be shorter
} else {
if !line.is_empty() {
out.push(std::mem::take(&mut line));
out_idx.push(raw_idx);
line_cols = 0;
}
}
}
if ch.is_whitespace()
|| matches!(
ch,
',' | ';' | '.' | ':' | ')' | ']' | '}' | '|' | '/' | '?' | '!' | '-' | '_'
)
{
last_soft_idx = Some(line.len());
}
line.push(ch);
line_cols = UnicodeWidthStr::width(line.as_str());
}
if !line.is_empty() {
out.push(line);
out_idx.push(raw_idx);
}
}
self.wrapped = out;
self.wrapped_src_idx = out_idx;
self.state.content_h = self.wrapped.len() as u16;
}
}

View File

@@ -0,0 +1,660 @@
use ratatui::layout::Constraint;
use ratatui::layout::Direction;
use ratatui::layout::Layout;
use ratatui::prelude::*;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Clear;
use ratatui::widgets::List;
use ratatui::widgets::ListItem;
use ratatui::widgets::ListState;
use ratatui::widgets::Padding;
use ratatui::widgets::Paragraph;
use std::sync::OnceLock;
use crate::app::App;
use chrono::Local;
use chrono::Utc;
use codex_cloud_tasks_api::TaskStatus;
pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), // list
Constraint::Length(2), // two-line footer (help + status)
])
.split(area);
if app.new_task.is_some() {
draw_new_task_page(frame, chunks[0], app);
draw_footer(frame, chunks[1], app);
} else {
draw_list(frame, chunks[0], app);
draw_footer(frame, chunks[1], app);
}
if app.diff_overlay.is_some() {
draw_diff_overlay(frame, area, app);
}
if app.env_modal.is_some() {
draw_env_modal(frame, area, app);
}
}
// ===== Overlay helpers (geometry + styling) =====
static ROUNDED: OnceLock<bool> = OnceLock::new();
fn rounded_enabled() -> bool {
*ROUNDED.get_or_init(|| {
std::env::var("CODEX_TUI_ROUNDED")
.ok()
.map(|v| v == "1")
.unwrap_or(true)
})
}
fn overlay_outer(area: Rect) -> Rect {
let outer_v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(10),
Constraint::Percentage(80),
Constraint::Percentage(10),
])
.split(area)[1];
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(10),
Constraint::Percentage(80),
Constraint::Percentage(10),
])
.split(outer_v)[1]
}
fn overlay_block() -> Block<'static> {
let base = Block::default().borders(Borders::ALL);
let base = if rounded_enabled() {
base.border_type(BorderType::Rounded)
} else {
base
};
base.padding(Padding::new(2, 2, 1, 1))
}
fn overlay_content(area: Rect) -> Rect {
overlay_block().inner(area)
}
pub fn draw_new_task_page(frame: &mut Frame, area: Rect, app: &mut App) {
use ratatui::widgets::Wrap;
let title_spans = {
let mut spans: Vec<ratatui::text::Span> = vec!["New Task".magenta().bold()];
if let Some(id) = app
.new_task
.as_ref()
.and_then(|p| p.env_id.as_ref())
.cloned()
{
spans.push("".into());
// Try to map id to label
let label = app
.environments
.iter()
.find(|r| r.id == id)
.and_then(|r| r.label.clone())
.unwrap_or(id);
spans.push(label.dim());
} else {
spans.push("".into());
spans.push("Env: none (press ctrl-o to choose)".red());
}
spans
};
let block = Block::default()
.borders(Borders::ALL)
.title(Line::from(title_spans));
frame.render_widget(Clear, area);
frame.render_widget(block.clone(), area);
let content = block.inner(area);
// Clamp composer height between 3 and 6 rows for readability.
let desired = app
.new_task
.as_ref()
.map(|p| p.composer.desired_height(content.width))
.unwrap_or(3)
.clamp(3, 6);
// Anchor the composer to the bottom-left by allocating a flexible spacer
// above it and a fixed `desired`-height area for the composer.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(desired)])
.split(content);
let composer_area = rows[1];
if let Some(page) = app.new_task.as_ref() {
page.composer.render_ref(composer_area, frame.buffer_mut());
// Composer renders its own footer hints; no extra row here.
}
// Place cursor where composer wants it
if let Some(page) = app.new_task.as_ref() {
if let Some((x, y)) = page.composer.cursor_pos(composer_area) {
frame.set_cursor(x, y);
}
}
}
fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) {
let items: Vec<ListItem> = app.tasks.iter().map(|t| render_task_item(app, t)).collect();
// Selection reflects the actual task index (no artificial spacer item).
let mut state = ListState::default().with_selected(Some(app.selected));
// Dim task list when a modal/overlay is active to emphasize focus.
let dim_bg = app.env_modal.is_some() || app.diff_overlay.is_some();
// Dynamic title includes current environment filter
let suffix_span = if let Some(ref id) = app.env_filter {
let label = app
.environments
.iter()
.find(|r| &r.id == id)
.and_then(|r| r.label.clone())
.unwrap_or_else(|| "Selected".to_string());
format!("{}", label).dim()
} else {
" • All".dim()
};
// Percent scrolled based on selection position in the list (0% at top, 100% at bottom).
let percent_span = if app.tasks.len() <= 1 {
" • 0%".dim()
} else {
let p = ((app.selected as f32) / ((app.tasks.len() - 1) as f32) * 100.0).round() as i32;
format!("{}%", p.clamp(0, 100)).dim()
};
let title_line = {
let base = Line::from(vec!["Cloud Tasks".into(), suffix_span, percent_span]);
if dim_bg {
base.style(Style::default().add_modifier(Modifier::DIM))
} else {
base
}
};
let block = Block::default().borders(Borders::ALL).title(title_line);
// Render the outer block first
frame.render_widget(block.clone(), area);
// Draw list inside with a persistent top spacer row
let inner = block.inner(area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner);
let mut list = List::new(items)
.highlight_symbol(" ")
.highlight_style(Style::default().bold());
if dim_bg {
list = list.style(Style::default().add_modifier(Modifier::DIM));
}
frame.render_stateful_widget(list, rows[1], &mut state);
// In-box spinner during initial/refresh loads
if app.refresh_inflight {
draw_centered_spinner(frame, inner, &mut app.throbber, "Loading tasks…");
}
}
fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
let mut help = vec![
"↑/↓".dim(),
": Move ".dim(),
"r".dim(),
": Refresh ".dim(),
"Enter".dim(),
": Open ".dim(),
];
// Apply hint; show disabled note when overlay is open without a diff.
if let Some(ov) = app.diff_overlay.as_ref() {
if !ov.can_apply {
help.push("a".dim());
help.push(": Apply (disabled) ".dim());
} else {
help.push("a".dim());
help.push(": Apply ".dim());
}
} else {
help.push("a".dim());
help.push(": Apply ".dim());
}
help.push("o : Set Env ".dim());
if app.new_task.is_some() {
help.push("(editing new task) ".dim());
} else {
help.push("n : New Task ".dim());
}
help.extend(vec!["q".dim(), ": Quit ".dim()]);
// Split footer area into two rows: help+spinner (top) and status (bottom)
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
// Top row: help text + spinner at right
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Length(18)])
.split(rows[0]);
let para = Paragraph::new(Line::from(help));
// Draw help text; avoid clearing the whole footer area every frame.
frame.render_widget(para, top[0]);
// Right side: spinner or clear the spinner area if idle to prevent stale glyphs.
if app.refresh_inflight || app.details_inflight || app.env_loading {
draw_inline_spinner(frame, top[1], &mut app.throbber, "Loading…");
} else {
frame.render_widget(Clear, top[1]);
}
// Bottom row: status/log text across full width (single-line; sanitize newlines)
let mut status_line = app.status.replace('\n', " ");
if status_line.len() > 2000 {
// hard cap to avoid TUI noise
status_line.truncate(2000);
status_line.push_str("");
}
// Clear the status row to avoid trailing characters when the message shrinks.
frame.render_widget(Clear, rows[1]);
let status = Paragraph::new(status_line);
frame.render_widget(status, rows[1]);
}
fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
// Centered overlay rect (deduped geometry) and padded content via helpers.
let inner = overlay_outer(area);
if app.diff_overlay.is_none() {
return;
}
let is_error = if let Some(overlay) = app.diff_overlay.as_ref() {
!overlay.can_apply
&& overlay
.sd
.wrapped_lines()
.first()
.map(|s| s.trim_start().starts_with("Task failed:"))
.unwrap_or(false)
} else {
false
};
let can_apply = app
.diff_overlay
.as_ref()
.map(|o| o.can_apply)
.unwrap_or(false);
let title = app
.diff_overlay
.as_ref()
.map(|o| o.title.clone())
.unwrap_or_default();
// Ensure ScrollableDiff knows geometry using padded content area.
let content_area = overlay_content(inner);
if let Some(ov) = app.diff_overlay.as_mut() {
ov.sd.set_width(content_area.width);
ov.sd.set_viewport(content_area.height);
}
// Optional percent scrolled label
let pct_opt = app
.diff_overlay
.as_ref()
.and_then(|o| o.sd.percent_scrolled());
let mut title_spans: Vec<ratatui::text::Span> = if is_error {
vec![
"Details ".magenta(),
"[FAILED]".red().bold(),
" ".into(),
title.clone().magenta(),
]
} else if can_apply {
vec!["Diff: ".magenta(), title.clone().magenta()]
} else {
vec!["Details: ".magenta(), title.clone().magenta()]
};
if let Some(p) = pct_opt {
title_spans.push("".dim());
title_spans.push(format!("{}%", p).dim());
}
let block = overlay_block().title(Line::from(title_spans));
frame.render_widget(Clear, inner);
frame.render_widget(block.clone(), inner);
let styled_lines: Vec<Line<'static>> = if can_apply {
let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines());
raw.unwrap_or(&[])
.iter()
.map(|l| style_diff_line(l))
.collect()
} else {
// Basic markdown styling for assistant messages
let mut in_code = false;
let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines());
raw.unwrap_or(&[])
.iter()
.map(|raw| {
if raw.trim_start().starts_with("```") {
in_code = !in_code;
return Line::from(raw.to_string().cyan());
}
if in_code {
return Line::from(raw.to_string().cyan());
}
let s = raw.trim_start();
if s.starts_with("### ") || s.starts_with("## ") || s.starts_with("# ") {
return Line::from(raw.to_string().magenta().bold());
}
if s.starts_with("- ") || s.starts_with("* ") {
let rest = &s[2..];
return Line::from(vec!["".into(), rest.to_string().into()]);
}
Line::from(raw.to_string())
})
.collect()
};
let raw_empty = app
.diff_overlay
.as_ref()
.map(|o| o.sd.wrapped_lines().is_empty())
.unwrap_or(true);
if app.details_inflight && raw_empty {
// Show a centered spinner while loading details
draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…");
} else {
// We pre-wrapped lines in ScrollableDiff; do not enable Paragraph-level wrapping here.
let scroll = app
.diff_overlay
.as_ref()
.map(|o| o.sd.state.scroll)
.unwrap_or(0);
let content = Paragraph::new(Text::from(styled_lines)).scroll((scroll, 0));
frame.render_widget(content, content_area);
}
}
fn style_diff_line(raw: &str) -> Line<'static> {
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
if raw.starts_with("@@") {
return Line::from(vec![Span::styled(
raw.to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)]);
}
if raw.starts_with("+++") || raw.starts_with("---") {
return Line::from(vec![Span::styled(
raw.to_string(),
Style::default().add_modifier(Modifier::DIM),
)]);
}
if raw.starts_with('+') {
return Line::from(vec![Span::styled(
raw.to_string(),
Style::default().fg(Color::Green),
)]);
}
if raw.starts_with('-') {
return Line::from(vec![Span::styled(
raw.to_string(),
Style::default().fg(Color::Red),
)]);
}
Line::from(vec![Span::raw(raw.to_string())])
}
fn render_task_item(app: &App, t: &codex_cloud_tasks_api::TaskSummary) -> ListItem<'static> {
let status = match t.status {
TaskStatus::Ready => "READY".green(),
TaskStatus::Pending => "PENDING".magenta(),
TaskStatus::Applied => "APPLIED".blue(),
TaskStatus::Error => "ERROR".red(),
};
// Title line: [STATUS] Title
let title = Line::from(vec![
"[".into(),
status,
"] ".into(),
t.title.clone().into(),
]);
// Meta line: environment label and relative time (dim)
let mut meta: Vec<ratatui::text::Span> = Vec::new();
if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) {
meta.push(lbl.clone().dim());
}
let when = format_relative_time(t.updated_at).dim();
if !meta.is_empty() {
meta.push(" ".into());
meta.push("".dim());
meta.push(" ".into());
}
meta.push(when);
let meta_line = Line::from(meta);
// Subline: summary when present; otherwise show "no diff"
let sub = if t.summary.files_changed > 0
|| t.summary.lines_added > 0
|| t.summary.lines_removed > 0
{
let adds = t.summary.lines_added;
let dels = t.summary.lines_removed;
let files = t.summary.files_changed;
Line::from(vec![
format!("+{}", adds).green(),
"/".into(),
format!("{}", dels).red(),
" ".into(),
"".dim(),
" ".into(),
format!("{}", files).into(),
" ".into(),
"files".dim(),
])
} else {
Line::from("no diff".to_string().dim())
};
// Insert a blank spacer line after the summary to separate tasks
let spacer = Line::from("");
ListItem::new(vec![title, meta_line, sub, spacer])
}
fn format_relative_time(ts: chrono::DateTime<Utc>) -> String {
let now = Utc::now();
let mut secs = (now - ts).num_seconds();
if secs < 0 {
secs = 0;
}
if secs < 60 {
return format!("{}s ago", secs);
}
let mins = secs / 60;
if mins < 60 {
return format!("{}m ago", mins);
}
let hours = mins / 60;
if hours < 24 {
return format!("{}h ago", hours);
}
let local = ts.with_timezone(&Local);
local.format("%b %e %H:%M").to_string()
}
fn draw_inline_spinner(
frame: &mut Frame,
area: Rect,
state: &mut throbber_widgets_tui::ThrobberState,
label: &str,
) {
use ratatui::style::Style;
use throbber_widgets_tui::BRAILLE_EIGHT;
use throbber_widgets_tui::Throbber;
use throbber_widgets_tui::WhichUse;
let w = Throbber::default()
.label(label)
.style(Style::default().cyan())
.throbber_style(Style::default().magenta().bold())
.throbber_set(BRAILLE_EIGHT)
.use_type(WhichUse::Spin);
frame.render_stateful_widget(w, area, state);
}
fn draw_centered_spinner(
frame: &mut Frame,
area: Rect,
state: &mut throbber_widgets_tui::ThrobberState,
label: &str,
) {
// Center a 1xN throbber within the given rect
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50),
Constraint::Length(1),
Constraint::Percentage(49),
])
.split(area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Length(18),
Constraint::Percentage(50),
])
.split(rows[1]);
draw_inline_spinner(frame, cols[1], state, label);
}
fn style_diff_fragment(src_line: &str, fragment: &str) -> Line<'static> {
if src_line.starts_with("@@") {
return Line::from(fragment.to_string().magenta().bold());
}
if src_line.starts_with("diff --git ") || src_line.starts_with("index ") {
return Line::from(fragment.to_string().dim());
}
if src_line.starts_with("+++") || src_line.starts_with("---") {
return Line::from(fragment.to_string().dim());
}
match src_line.as_bytes().first().copied() {
Some(b'+') => Line::from(fragment.to_string().green()),
Some(b'-') => Line::from(fragment.to_string().red()),
_ => Line::from(fragment.to_string()),
}
}
pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) {
use ratatui::widgets::Wrap;
// Use shared overlay geometry and padding.
let inner = overlay_outer(area);
// Title: primary only; move long hints to a subheader inside content.
let title = Line::from(vec!["Select Environment".magenta().bold()]);
let block = overlay_block().title(title);
frame.render_widget(Clear, inner);
frame.render_widget(block.clone(), inner);
let content = overlay_content(inner);
if app.env_loading {
draw_centered_spinner(frame, content, &mut app.throbber, "Loading environments…");
return;
}
// Layout: subheader + search + results list
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // subheader
Constraint::Length(1), // search
Constraint::Min(1), // list
])
.split(content);
// Subheader with usage hints (dim cyan)
let subheader = Paragraph::new(Line::from(
"Type to search, Enter select, Esc cancel; r refresh"
.cyan()
.dim(),
))
.wrap(Wrap { trim: true });
frame.render_widget(subheader, rows[0]);
let query = app
.env_modal
.as_ref()
.map(|m| m.query.clone())
.unwrap_or_default();
let ql = query.to_lowercase();
let search = Paragraph::new(format!("Search: {}", query)).wrap(Wrap { trim: true });
frame.render_widget(search, rows[1]);
// Filter environments by query (case-insensitive substring over label/id/hints)
let envs: Vec<&crate::app::EnvironmentRow> = app
.environments
.iter()
.filter(|e| {
if ql.is_empty() {
return true;
}
let mut hay = String::new();
if let Some(l) = &e.label {
hay.push_str(&l.to_lowercase());
hay.push(' ');
}
hay.push_str(&e.id.to_lowercase());
if let Some(h) = &e.repo_hints {
hay.push(' ');
hay.push_str(&h.to_lowercase());
}
hay.contains(&ql)
})
.collect();
let mut items: Vec<ListItem> = Vec::new();
items.push(ListItem::new(Line::from("All Environments (Global)")));
for env in envs.iter() {
let primary = env.label.clone().unwrap_or_else(|| "<unnamed>".to_string());
let mut spans: Vec<ratatui::text::Span> = vec![primary.into()];
if env.is_pinned {
spans.push(" ".into());
spans.push("PINNED".magenta().bold());
}
spans.push(" ".into());
spans.push(env.id.clone().dim());
if let Some(hint) = &env.repo_hints {
spans.push(" ".into());
spans.push(hint.clone().dim());
}
items.push(ListItem::new(Line::from(spans)));
}
let sel_desired = app.env_modal.as_ref().map(|m| m.selected).unwrap_or(0);
let sel = sel_desired.min(envs.len());
let mut list_state = ListState::default().with_selected(Some(sel));
let list = List::new(items)
.highlight_symbol(" ")
.highlight_style(Style::default().bold())
.block(Block::default().borders(Borders::NONE));
frame.render_stateful_widget(list, rows[2], &mut list_state);
}

View File

@@ -0,0 +1,24 @@
#![deny(clippy::unwrap_used, clippy::expect_used)]
use codex_cloud_tasks_api::CloudBackend;
use codex_cloud_tasks_client::MockClient;
#[tokio::test]
async fn mock_backend_varies_by_env() {
let client = MockClient::default();
let root = CloudBackend::list_tasks(&client, None).await.unwrap();
assert!(root.iter().any(|t| t.title.contains("Update README")));
let a = CloudBackend::list_tasks(&client, Some("env-A"))
.await
.unwrap();
assert_eq!(a.len(), 1);
assert_eq!(a[0].title, "A: First");
let b = CloudBackend::list_tasks(&client, Some("env-B"))
.await
.unwrap();
assert_eq!(b.len(), 2);
assert!(b[0].title.starts_with("B: "));
}