| mod app; |
| mod cli; |
| pub(crate) mod env_detect; |
| mod new_task; |
| pub(crate) mod scrollable_diff; |
| mod ui; |
| pub(crate) mod util; |
| pub use cli::Cli; |
|
|
| use anyhow::anyhow; |
| use chrono::Utc; |
| use codex_cloud_tasks_client::TaskStatus; |
| use codex_git_utils::current_branch_name; |
| use codex_git_utils::default_branch_name; |
| use codex_http_client::ClientRouteClass; |
| use codex_http_client::HttpClientFactory; |
| use codex_http_client::OutboundProxyPolicy; |
| use codex_http_client::RouteAwareClientPool; |
| use codex_login::default_client::get_codex_user_agent; |
| use owo_colors::OwoColorize; |
| use owo_colors::Stream; |
| use std::cmp::Ordering; |
| use std::io::IsTerminal; |
| use std::io::Read; |
| use std::path::PathBuf; |
| use std::sync::Arc; |
| use std::time::Duration; |
| use std::time::Instant; |
| use supports_color::Stream as SupportStream; |
| use tokio::sync::mpsc::UnboundedSender; |
| use tracing::info; |
| use tracing_subscriber::EnvFilter; |
| use util::append_error_log; |
| use util::format_relative_time; |
| use util::set_user_agent_suffix; |
|
|
| struct ApplyJob { |
| task_id: codex_cloud_tasks_client::TaskId, |
| diff_override: Option<String>, |
| } |
|
|
| struct BackendContext { |
| backend: Arc<dyn codex_cloud_tasks_client::CloudBackend>, |
| base_url: String, |
| environment_http: RouteAwareClientPool, |
| } |
|
|
| async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext> { |
| #[cfg(debug_assertions)] |
| let use_mock = matches!( |
| std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), |
| Some("mock") | Some("MOCK") |
| ); |
| let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") |
| .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); |
| let base_url = util::validate_chatgpt_base_url(&base_url)?; |
|
|
| set_user_agent_suffix(user_agent_suffix); |
|
|
| #[cfg(debug_assertions)] |
| if use_mock { |
| let http_client_factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault); |
| return Ok(BackendContext { |
| backend: Arc::new(codex_cloud_tasks_mock_client::MockClient), |
| base_url, |
| environment_http: RouteAwareClientPool::new_without_redirects_or_request_logging( |
| http_client_factory, |
| ClientRouteClass::Api, |
| ), |
| }); |
| } |
|
|
| let ua = get_codex_user_agent(); |
| let (auth_manager, http_client_factory) = util::load_auth_manager(Some(base_url.clone())).await; |
| let environment_http = RouteAwareClientPool::new_without_redirects_or_request_logging( |
| http_client_factory.clone(), |
| ClientRouteClass::Api, |
| ); |
| let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone(), http_client_factory) |
| .with_user_agent(ua); |
| let style = if base_url.contains("/backend-api") { |
| "wham" |
| } else { |
| "codex-api" |
| }; |
| append_error_log(format!("startup: base_url={base_url} path_style={style}")); |
|
|
| let auth = match auth_manager.as_ref() { |
| Some(manager) => manager.auth().await, |
| None => None, |
| }; |
| let auth = match auth { |
| Some(auth) => auth, |
| None => { |
| eprintln!( |
| "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." |
| ); |
| std::process::exit(1); |
| } |
| }; |
|
|
| if let Some(acc) = auth.get_account_id() { |
| append_error_log(format!("auth: mode=ChatGPT account_id={acc}")); |
| } |
|
|
| if !auth.uses_codex_backend() { |
| eprintln!( |
| "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." |
| ); |
| std::process::exit(1); |
| } |
|
|
| let auth_provider = codex_model_provider::auth_provider_from_auth(&auth); |
| http = http.with_auth_provider(auth_provider); |
| if let Some(acc) = auth.get_account_id() { |
| append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); |
| } |
|
|
| Ok(BackendContext { |
| backend: Arc::new(http), |
| base_url, |
| environment_http, |
| }) |
| } |
|
|
| trait GitInfoProvider { |
| fn default_branch_name( |
| &self, |
| path: &std::path::Path, |
| ) -> impl std::future::Future<Output = Option<String>> + Send; |
|
|
| fn current_branch_name( |
| &self, |
| path: &std::path::Path, |
| ) -> impl std::future::Future<Output = Option<String>> + Send; |
| } |
|
|
| struct RealGitInfo; |
|
|
| impl GitInfoProvider for RealGitInfo { |
| async fn default_branch_name(&self, path: &std::path::Path) -> Option<String> { |
| default_branch_name(path).await |
| } |
|
|
| async fn current_branch_name(&self, path: &std::path::Path) -> Option<String> { |
| current_branch_name(path).await |
| } |
| } |
|
|
| async fn resolve_git_ref(branch_override: Option<&String>) -> String { |
| resolve_git_ref_with_git_info(branch_override, &RealGitInfo).await |
| } |
|
|
| async fn resolve_git_ref_with_git_info( |
| branch_override: Option<&String>, |
| git_info: &impl GitInfoProvider, |
| ) -> String { |
| if let Some(branch) = branch_override { |
| let branch = branch.trim(); |
| if !branch.is_empty() { |
| return branch.to_string(); |
| } |
| } |
|
|
| if let Ok(cwd) = std::env::current_dir() { |
| if let Some(branch) = git_info.current_branch_name(&cwd).await { |
| branch |
| } else if let Some(branch) = git_info.default_branch_name(&cwd).await { |
| branch |
| } else { |
| "main".to_string() |
| } |
| } else { |
| "main".to_string() |
| } |
| } |
|
|
| async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> { |
| let crate::cli::ExecCommand { |
| query, |
| environment, |
| branch, |
| attempts, |
| } = args; |
| let ctx = init_backend("codex_cloud_tasks_exec").await?; |
| let prompt = resolve_query_input(query)?; |
| let env_id = resolve_environment_id(&ctx, &environment).await?; |
| let git_ref = resolve_git_ref(branch.as_ref()).await; |
| let created = codex_cloud_tasks_client::CloudBackend::create_task( |
| &*ctx.backend, |
| &env_id, |
| &prompt, |
| &git_ref, |
| false, |
| attempts, |
| ) |
| .await?; |
| let url = util::task_url(&ctx.base_url, &created.id.0); |
| println!("{url}"); |
| Ok(()) |
| } |
|
|
| async fn resolve_environment_id(ctx: &BackendContext, requested: &str) -> anyhow::Result<String> { |
| let trimmed = requested.trim(); |
| if trimmed.is_empty() { |
| return Err(anyhow!("environment id must not be empty")); |
| } |
| let normalized = util::normalize_base_url(&ctx.base_url); |
| let headers = util::build_chatgpt_headers().await; |
| let environments = |
| crate::env_detect::list_environments(&ctx.environment_http, &normalized, &headers).await?; |
| if environments.is_empty() { |
| return Err(anyhow!( |
| "no cloud environments are available for this workspace" |
| )); |
| } |
|
|
| if let Some(row) = environments.iter().find(|row| row.id == trimmed) { |
| return Ok(row.id.clone()); |
| } |
|
|
| let label_matches = environments |
| .iter() |
| .filter(|row| { |
| row.label |
| .as_deref() |
| .map(|label| label.eq_ignore_ascii_case(trimmed)) |
| .unwrap_or(false) |
| }) |
| .collect::<Vec<_>>(); |
| match label_matches.as_slice() { |
| [] => Err(anyhow!( |
| "environment '{trimmed}' not found; run `codex cloud` to list available environments" |
| )), |
| [single] => Ok(single.id.clone()), |
| [first, rest @ ..] => { |
| let first_id = &first.id; |
| if rest.iter().all(|row| row.id == *first_id) { |
| Ok(first_id.clone()) |
| } else { |
| Err(anyhow!( |
| "environment label '{trimmed}' is ambiguous; run `codex cloud` to pick the desired environment id" |
| )) |
| } |
| } |
| } |
| } |
|
|
| fn resolve_query_input(query_arg: Option<String>) -> anyhow::Result<String> { |
| match query_arg { |
| Some(q) if q != "-" => Ok(q), |
| maybe_dash => { |
| let force_stdin = matches!(maybe_dash.as_deref(), Some("-")); |
| if std::io::stdin().is_terminal() && !force_stdin { |
| return Err(anyhow!( |
| "no query provided. Pass one as an argument or pipe it via stdin." |
| )); |
| } |
| if !force_stdin { |
| eprintln!("Reading query from stdin..."); |
| } |
| let mut buffer = String::new(); |
| std::io::stdin() |
| .read_to_string(&mut buffer) |
| .map_err(|e| anyhow!("failed to read query from stdin: {e}"))?; |
| if buffer.trim().is_empty() { |
| return Err(anyhow!( |
| "no query provided via stdin (received empty input)." |
| )); |
| } |
| Ok(buffer) |
| } |
| } |
| } |
|
|
| fn parse_task_id(raw: &str) -> anyhow::Result<codex_cloud_tasks_client::TaskId> { |
| let trimmed = raw.trim(); |
| if trimmed.is_empty() { |
| anyhow::bail!("task id must not be empty"); |
| } |
| let without_fragment = trimmed.split('#').next().unwrap_or(trimmed); |
| let without_query = without_fragment |
| .split('?') |
| .next() |
| .unwrap_or(without_fragment); |
| let id = without_query |
| .rsplit('/') |
| .next() |
| .unwrap_or(without_query) |
| .trim(); |
| if id.is_empty() { |
| anyhow::bail!("task id must not be empty"); |
| } |
| Ok(codex_cloud_tasks_client::TaskId(id.to_string())) |
| } |
|
|
| #[derive(Clone, Debug)] |
| struct AttemptDiffData { |
| placement: Option<i64>, |
| created_at: Option<chrono::DateTime<Utc>>, |
| diff: String, |
| } |
|
|
| fn cmp_attempt(lhs: &AttemptDiffData, rhs: &AttemptDiffData) -> Ordering { |
| match (lhs.placement, rhs.placement) { |
| (Some(a), Some(b)) => a.cmp(&b), |
| (Some(_), None) => Ordering::Less, |
| (None, Some(_)) => Ordering::Greater, |
| (None, None) => match (lhs.created_at, rhs.created_at) { |
| (Some(a), Some(b)) => a.cmp(&b), |
| (Some(_), None) => Ordering::Less, |
| (None, Some(_)) => Ordering::Greater, |
| (None, None) => Ordering::Equal, |
| }, |
| } |
| } |
|
|
| async fn collect_attempt_diffs( |
| backend: &dyn codex_cloud_tasks_client::CloudBackend, |
| task_id: &codex_cloud_tasks_client::TaskId, |
| ) -> anyhow::Result<Vec<AttemptDiffData>> { |
| let text = |
| codex_cloud_tasks_client::CloudBackend::get_task_text(backend, task_id.clone()).await?; |
| let mut attempts = Vec::new(); |
| if let Some(diff) = |
| codex_cloud_tasks_client::CloudBackend::get_task_diff(backend, task_id.clone()).await? |
| { |
| attempts.push(AttemptDiffData { |
| placement: text.attempt_placement, |
| created_at: None, |
| diff, |
| }); |
| } |
| if let Some(turn_id) = text.turn_id { |
| let siblings = codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( |
| backend, |
| task_id.clone(), |
| turn_id, |
| ) |
| .await?; |
| for sibling in siblings { |
| if let Some(diff) = sibling.diff { |
| attempts.push(AttemptDiffData { |
| placement: sibling.attempt_placement, |
| created_at: sibling.created_at, |
| diff, |
| }); |
| } |
| } |
| } |
| attempts.sort_by(cmp_attempt); |
| if attempts.is_empty() { |
| anyhow::bail!( |
| "No diff available for task {}; it may still be running.", |
| task_id.0 |
| ); |
| } |
| Ok(attempts) |
| } |
|
|
| fn select_attempt( |
| attempts: &[AttemptDiffData], |
| attempt: Option<usize>, |
| ) -> anyhow::Result<&AttemptDiffData> { |
| if attempts.is_empty() { |
| anyhow::bail!("No attempts available"); |
| } |
| let desired = attempt.unwrap_or(1); |
| let idx = desired |
| .checked_sub(1) |
| .ok_or_else(|| anyhow!("attempt must be at least 1"))?; |
| if idx >= attempts.len() { |
| anyhow::bail!( |
| "Attempt {desired} not available; only {} attempt(s) found", |
| attempts.len() |
| ); |
| } |
| Ok(&attempts[idx]) |
| } |
|
|
| fn task_status_label(status: &TaskStatus) -> &'static str { |
| match status { |
| TaskStatus::Pending => "PENDING", |
| TaskStatus::Ready => "READY", |
| TaskStatus::Applied => "APPLIED", |
| TaskStatus::Error => "ERROR", |
| } |
| } |
|
|
| fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) -> String { |
| if summary.files_changed == 0 && summary.lines_added == 0 && summary.lines_removed == 0 { |
| let base = "no diff"; |
| return if colorize { |
| base.if_supports_color(Stream::Stdout, |t| t.dimmed()) |
| .to_string() |
| } else { |
| base.to_string() |
| }; |
| } |
| let adds = summary.lines_added; |
| let dels = summary.lines_removed; |
| let files = summary.files_changed; |
| if colorize { |
| let adds_raw = format!("+{adds}"); |
| let adds_str = adds_raw |
| .as_str() |
| .if_supports_color(Stream::Stdout, |t| t.green()) |
| .to_string(); |
| let dels_raw = format!("-{dels}"); |
| let dels_str = dels_raw |
| .as_str() |
| .if_supports_color(Stream::Stdout, |t| t.red()) |
| .to_string(); |
| let bullet = "•" |
| .if_supports_color(Stream::Stdout, |t| t.dimmed()) |
| .to_string(); |
| let file_label = format!("file{}", if files == 1 { "" } else { "s" }) |
| .if_supports_color(Stream::Stdout, |t| t.dimmed()) |
| .to_string(); |
| format!("{adds_str}/{dels_str} {bullet} {files} {file_label}") |
| } else { |
| format!( |
| "+{adds}/-{dels} • {files} file{}", |
| if files == 1 { "" } else { "s" } |
| ) |
| } |
| } |
|
|
| fn format_task_status_lines( |
| task: &codex_cloud_tasks_client::TaskSummary, |
| now: chrono::DateTime<Utc>, |
| colorize: bool, |
| ) -> Vec<String> { |
| let mut lines = Vec::new(); |
| let status = task_status_label(&task.status); |
| let status = if colorize { |
| match task.status { |
| TaskStatus::Ready => status |
| .if_supports_color(Stream::Stdout, |t| t.green()) |
| .to_string(), |
| TaskStatus::Pending => status |
| .if_supports_color(Stream::Stdout, |t| t.magenta()) |
| .to_string(), |
| TaskStatus::Applied => status |
| .if_supports_color(Stream::Stdout, |t| t.blue()) |
| .to_string(), |
| TaskStatus::Error => status |
| .if_supports_color(Stream::Stdout, |t| t.red()) |
| .to_string(), |
| } |
| } else { |
| status.to_string() |
| }; |
| lines.push(format!("[{status}] {}", task.title)); |
| let mut meta_parts = Vec::new(); |
| if let Some(label) = task.environment_label.as_deref().filter(|s| !s.is_empty()) { |
| if colorize { |
| meta_parts.push( |
| label |
| .if_supports_color(Stream::Stdout, |t| t.dimmed()) |
| .to_string(), |
| ); |
| } else { |
| meta_parts.push(label.to_string()); |
| } |
| } else if let Some(id) = task.environment_id.as_deref() { |
| if colorize { |
| meta_parts.push( |
| id.if_supports_color(Stream::Stdout, |t| t.dimmed()) |
| .to_string(), |
| ); |
| } else { |
| meta_parts.push(id.to_string()); |
| } |
| } |
| let when = format_relative_time(now, task.updated_at); |
| meta_parts.push(if colorize { |
| when.as_str() |
| .if_supports_color(Stream::Stdout, |t| t.dimmed()) |
| .to_string() |
| } else { |
| when |
| }); |
| let sep = if colorize { |
| " • " |
| .if_supports_color(Stream::Stdout, |t| t.dimmed()) |
| .to_string() |
| } else { |
| " • ".to_string() |
| }; |
| lines.push(meta_parts.join(&sep)); |
| lines.push(summary_line(&task.summary, colorize)); |
| lines |
| } |
|
|
| fn format_task_list_lines( |
| tasks: &[codex_cloud_tasks_client::TaskSummary], |
| base_url: &str, |
| now: chrono::DateTime<Utc>, |
| colorize: bool, |
| ) -> Vec<String> { |
| let mut lines = Vec::new(); |
| for (idx, task) in tasks.iter().enumerate() { |
| lines.push(util::task_url(base_url, &task.id.0)); |
| for line in format_task_status_lines(task, now, colorize) { |
| lines.push(format!(" {line}")); |
| } |
| if idx + 1 < tasks.len() { |
| lines.push(String::new()); |
| } |
| } |
| lines |
| } |
|
|
| async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> { |
| let ctx = init_backend("codex_cloud_tasks_status").await?; |
| let task_id = parse_task_id(&args.task_id)?; |
| let summary = |
| codex_cloud_tasks_client::CloudBackend::get_task_summary(&*ctx.backend, task_id).await?; |
| let now = Utc::now(); |
| let colorize = supports_color::on(SupportStream::Stdout).is_some(); |
| for line in format_task_status_lines(&summary, now, colorize) { |
| println!("{line}"); |
| } |
| if !matches!(summary.status, TaskStatus::Ready) { |
| std::process::exit(1); |
| } |
| Ok(()) |
| } |
|
|
| async fn run_list_command(args: crate::cli::ListCommand) -> anyhow::Result<()> { |
| let ctx = init_backend("codex_cloud_tasks_list").await?; |
| let env_filter = if let Some(env) = args.environment { |
| Some(resolve_environment_id(&ctx, &env).await?) |
| } else { |
| None |
| }; |
| let page = codex_cloud_tasks_client::CloudBackend::list_tasks( |
| &*ctx.backend, |
| env_filter.as_deref(), |
| Some(args.limit), |
| args.cursor.as_deref(), |
| ) |
| .await?; |
| if args.json { |
| let tasks: Vec<_> = page |
| .tasks |
| .iter() |
| .map(|task| { |
| serde_json::json!({ |
| "id": task.id.0, |
| "url": util::task_url(&ctx.base_url, &task.id.0), |
| "title": task.title, |
| "status": task.status, |
| "updated_at": task.updated_at, |
| "environment_id": task.environment_id, |
| "environment_label": task.environment_label, |
| "summary": { |
| "files_changed": task.summary.files_changed, |
| "lines_added": task.summary.lines_added, |
| "lines_removed": task.summary.lines_removed, |
| }, |
| "is_review": task.is_review, |
| "attempt_total": task.attempt_total, |
| }) |
| }) |
| .collect(); |
| let payload = serde_json::json!({ |
| "tasks": tasks, |
| "cursor": page.cursor, |
| }); |
| println!("{}", serde_json::to_string_pretty(&payload)?); |
| return Ok(()); |
| } |
| if page.tasks.is_empty() { |
| println!("No tasks found."); |
| return Ok(()); |
| } |
| let now = Utc::now(); |
| let colorize = supports_color::on(SupportStream::Stdout).is_some(); |
| for line in format_task_list_lines(&page.tasks, &ctx.base_url, now, colorize) { |
| println!("{line}"); |
| } |
| if let Some(cursor) = page.cursor { |
| let command = format!("codex cloud list --cursor='{cursor}'"); |
| if colorize { |
| println!( |
| "\nTo fetch the next page, run {}", |
| command.if_supports_color(Stream::Stdout, |text| text.cyan()) |
| ); |
| } else { |
| println!("\nTo fetch the next page, run {command}"); |
| } |
| } |
| Ok(()) |
| } |
|
|
| async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> { |
| let ctx = init_backend("codex_cloud_tasks_diff").await?; |
| let task_id = parse_task_id(&args.task_id)?; |
| let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; |
| let selected = select_attempt(&attempts, args.attempt)?; |
| print!("{}", selected.diff); |
| Ok(()) |
| } |
|
|
| async fn run_apply_command(args: crate::cli::ApplyCommand) -> anyhow::Result<()> { |
| let ctx = init_backend("codex_cloud_tasks_apply").await?; |
| let task_id = parse_task_id(&args.task_id)?; |
| let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; |
| let selected = select_attempt(&attempts, args.attempt)?; |
| let outcome = codex_cloud_tasks_client::CloudBackend::apply_task( |
| &*ctx.backend, |
| task_id, |
| Some(selected.diff.clone()), |
| ) |
| .await?; |
| println!("{}", outcome.message); |
| if !matches!( |
| outcome.status, |
| codex_cloud_tasks_client::ApplyStatus::Success |
| ) { |
| std::process::exit(1); |
| } |
| Ok(()) |
| } |
|
|
| fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { |
| match status { |
| codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, |
| codex_cloud_tasks_client::ApplyStatus::Partial => app::ApplyResultLevel::Partial, |
| codex_cloud_tasks_client::ApplyStatus::Error => app::ApplyResultLevel::Error, |
| } |
| } |
|
|
| fn spawn_preflight( |
| app: &mut app::App, |
| backend: &Arc<dyn codex_cloud_tasks_client::CloudBackend>, |
| tx: &UnboundedSender<app::AppEvent>, |
| frame_tx: &UnboundedSender<Instant>, |
| title: String, |
| job: ApplyJob, |
| ) -> bool { |
| if app.apply_inflight { |
| app.status = "An apply is already running; wait for it to finish first.".to_string(); |
| return false; |
| } |
| if app.apply_preflight_inflight { |
| app.status = "A preflight is already running; wait for it to finish first.".to_string(); |
| return false; |
| } |
|
|
| app.apply_preflight_inflight = true; |
| let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); |
|
|
| let backend = backend.clone(); |
| let tx = tx.clone(); |
| tokio::spawn(async move { |
| let ApplyJob { |
| task_id, |
| diff_override, |
| } = job; |
| let result = codex_cloud_tasks_client::CloudBackend::apply_task_preflight( |
| &*backend, |
| task_id.clone(), |
| diff_override, |
| ) |
| .await; |
|
|
| let event = match result { |
| Ok(outcome) => { |
| let level = level_from_status(outcome.status); |
| app::AppEvent::ApplyPreflightFinished { |
| id: task_id, |
| title, |
| message: outcome.message, |
| level, |
| skipped: outcome.skipped_paths, |
| conflicts: outcome.conflict_paths, |
| } |
| } |
| Err(e) => app::AppEvent::ApplyPreflightFinished { |
| id: task_id, |
| title, |
| message: format!("Preflight failed: {e}"), |
| level: app::ApplyResultLevel::Error, |
| skipped: Vec::new(), |
| conflicts: Vec::new(), |
| }, |
| }; |
|
|
| let _ = tx.send(event); |
| }); |
|
|
| true |
| } |
|
|
| fn spawn_apply( |
| app: &mut app::App, |
| backend: &Arc<dyn codex_cloud_tasks_client::CloudBackend>, |
| tx: &UnboundedSender<app::AppEvent>, |
| frame_tx: &UnboundedSender<Instant>, |
| job: ApplyJob, |
| ) -> bool { |
| if app.apply_inflight { |
| app.status = "An apply is already running; wait for it to finish first.".to_string(); |
| return false; |
| } |
| if app.apply_preflight_inflight { |
| app.status = "Finish the current preflight before starting another apply.".to_string(); |
| return false; |
| } |
|
|
| app.apply_inflight = true; |
| let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); |
|
|
| let backend = backend.clone(); |
| let tx = tx.clone(); |
| tokio::spawn(async move { |
| let ApplyJob { |
| task_id, |
| diff_override, |
| } = job; |
| let result = codex_cloud_tasks_client::CloudBackend::apply_task( |
| &*backend, |
| task_id.clone(), |
| diff_override, |
| ) |
| .await; |
|
|
| let event = match result { |
| Ok(outcome) => app::AppEvent::ApplyFinished { |
| id: task_id, |
| result: Ok(outcome), |
| }, |
| Err(e) => app::AppEvent::ApplyFinished { |
| id: task_id, |
| result: Err(format!("{e}")), |
| }, |
| }; |
|
|
| let _ = tx.send(event); |
| }); |
|
|
| true |
| } |
|
|
| |
|
|
| |
|
|
| |
| pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> { |
| if let Some(command) = cli.command { |
| return match command { |
| crate::cli::Command::Exec(args) => run_exec_command(args).await, |
| crate::cli::Command::Status(args) => run_status_command(args).await, |
| crate::cli::Command::List(args) => run_list_command(args).await, |
| crate::cli::Command::Apply(args) => run_apply_command(args).await, |
| crate::cli::Command::Diff(args) => run_diff_command(args).await, |
| }; |
| } |
| let Cli { .. } = cli; |
|
|
| |
| let default_level = "error"; |
| let _ = tracing_subscriber::fmt() |
| .with_env_filter( |
| EnvFilter::try_from_default_env() |
| .or_else(|_| EnvFilter::try_new(default_level)) |
| .unwrap_or_else(|_| EnvFilter::new(default_level)), |
| ) |
| .with_ansi(std::io::stderr().is_terminal()) |
| .with_writer(std::io::stderr) |
| .try_init(); |
|
|
| info!("Launching Cloud Tasks list UI"); |
| let BackendContext { |
| backend, |
| base_url, |
| environment_http, |
| } = init_backend("codex_cloud_tasks_tui").await?; |
|
|
| |
| use crossterm::ExecutableCommand; |
| use crossterm::event::DisableBracketedPaste; |
| use crossterm::event::EnableBracketedPaste; |
| use crossterm::event::KeyboardEnhancementFlags; |
| use crossterm::event::PopKeyboardEnhancementFlags; |
| use crossterm::event::PushKeyboardEnhancementFlags; |
| use crossterm::terminal::EnterAlternateScreen; |
| use crossterm::terminal::LeaveAlternateScreen; |
| use crossterm::terminal::disable_raw_mode; |
| use crossterm::terminal::enable_raw_mode; |
| use ratatui::Terminal; |
| use ratatui::backend::CrosstermBackend; |
| let mut stdout = std::io::stdout(); |
| enable_raw_mode()?; |
| stdout.execute(EnterAlternateScreen)?; |
| stdout.execute(EnableBracketedPaste)?; |
| |
| |
| let _ = crossterm::execute!( |
| std::io::stdout(), |
| PushKeyboardEnhancementFlags( |
| KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES |
| | KeyboardEnhancementFlags::REPORT_EVENT_TYPES |
| | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS |
| ) |
| ); |
| let backend_ui = CrosstermBackend::new(stdout); |
| let mut terminal = Terminal::new(backend_ui)?; |
| terminal.clear()?; |
|
|
| |
| let mut app = app::App::new(); |
| |
| let force_internal = matches!( |
| std::env::var("CODEX_CLOUD_TASKS_FORCE_INTERNAL") |
| .ok() |
| .as_deref(), |
| Some("1") | Some("true") | Some("TRUE") |
| ); |
| append_error_log(format!( |
| "startup: wham_force_internal={} ua={}", |
| force_internal, |
| get_codex_user_agent() |
| )); |
| |
| app.status = "Loading tasks…".to_string(); |
| app.refresh_inflight = true; |
| |
| app.list_generation = app.list_generation.saturating_add(1); |
| app.in_flight.clear(); |
| |
|
|
| |
| use crossterm::event::Event; |
| use crossterm::event::EventStream; |
| use crossterm::event::KeyCode; |
| use crossterm::event::KeyEventKind; |
| use crossterm::event::KeyModifiers; |
| use tokio_stream::StreamExt; |
| let mut events = EventStream::new(); |
|
|
| |
| use tokio::sync::mpsc::unbounded_channel; |
| let (tx, mut rx) = unbounded_channel::<app::AppEvent>(); |
| |
| { |
| let backend = Arc::clone(&backend); |
| let tx = tx.clone(); |
| tokio::spawn(async move { |
| let res = app::load_tasks(&*backend, None).await; |
| let _ = tx.send(app::AppEvent::TasksLoaded { |
| env: None, |
| result: res, |
| }); |
| }); |
| } |
| |
| spawn_environment_load(tx.clone(), base_url.clone(), environment_http.clone()); |
|
|
| |
| |
| { |
| let tx = tx.clone(); |
| let base_url = base_url.clone(); |
| let environment_http = environment_http.clone(); |
| tokio::spawn(async move { |
| let base_url = util::normalize_base_url(&base_url); |
| |
| let headers = util::build_chatgpt_headers().await; |
|
|
| |
| let res = crate::env_detect::autodetect_environment_id( |
| &environment_http, |
| &base_url, |
| &headers, |
| None, |
| ) |
| .await; |
| let _ = tx.send(app::AppEvent::EnvironmentAutodetected(res)); |
| }); |
| } |
|
|
| |
| let mut needs_redraw = true; |
| use std::time::Instant; |
| use tokio::time::Instant as TokioInstant; |
| use tokio::time::sleep_until; |
| let (frame_tx, mut frame_rx) = tokio::sync::mpsc::unbounded_channel::<Instant>(); |
| let (redraw_tx, mut redraw_rx) = tokio::sync::mpsc::unbounded_channel::<()>(); |
|
|
| |
| tokio::spawn(async move { |
| let mut next_deadline: Option<Instant> = None; |
| loop { |
| let target = |
| next_deadline.unwrap_or_else(|| Instant::now() + Duration::from_secs(24 * 60 * 60)); |
| let sleeper = sleep_until(TokioInstant::from_std(target)); |
| tokio::pin!(sleeper); |
| tokio::select! { |
| recv = frame_rx.recv() => { |
| match recv { |
| Some(at) => { |
| if next_deadline.is_none_or(|cur| at < cur) { |
| next_deadline = Some(at); |
| } |
| continue; |
| } |
| None => break, |
| } |
| } |
| _ = &mut sleeper => { |
| if next_deadline.take().is_some() { |
| let _ = redraw_tx.send(()); |
| } |
| } |
| } |
| } |
| }); |
| |
| let _ = frame_tx.send(Instant::now()); |
|
|
| |
| let render_if_needed = |terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>, |
| app: &mut app::App, |
| needs_redraw: &mut bool| |
| -> anyhow::Result<()> { |
| if *needs_redraw { |
| if let Some(delay) = app |
| .new_task |
| .as_ref() |
| .and_then(|page| page.composer.footer_flash_delay()) |
| { |
| let _ = frame_tx.send(Instant::now() + delay); |
| } |
| terminal.draw(|f| ui::draw(f, app))?; |
| *needs_redraw = false; |
| } |
| Ok(()) |
| }; |
|
|
| let exit_code = loop { |
| tokio::select! { |
| |
| Some(()) = redraw_rx.recv() => { |
| |
| if let Some(page) = app.new_task.as_mut() { |
| needs_redraw = true; |
| page.composer.flush_paste_burst_if_due(); |
| if page.composer.is_in_paste_burst() { |
| let _ = frame_tx |
| .send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); |
| } |
| } |
| |
| if app.refresh_inflight |
| || app.details_inflight |
| || app.env_loading |
| || app.apply_preflight_inflight |
| || app.apply_inflight |
| { |
| if app.spinner_start.is_none() { |
| app.spinner_start = Some(Instant::now()); |
| } |
| needs_redraw = true; |
| let _ = frame_tx.send(Instant::now() + Duration::from_millis(600)); |
| } else { |
| app.spinner_start = None; |
| } |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| } |
| maybe_app_event = rx.recv() => { |
| if let Some(ev) = maybe_app_event { |
| match ev { |
| app::AppEvent::TasksLoaded { env, result } => { |
| |
| if env.as_deref() != app.env_filter.as_deref() { |
| append_error_log(format!( |
| "refresh.drop: env={} current={}", |
| env.clone().unwrap_or_else(|| "<all>".to_string()), |
| app.env_filter.clone().unwrap_or_else(|| "<all>".to_string()) |
| )); |
| continue; |
| } |
| app.refresh_inflight = false; |
| match result { |
| Ok(tasks) => { |
| append_error_log(format!( |
| "refresh.apply: env={} count={}", |
| env.clone().unwrap_or_else(|| "<all>".to_string()), |
| tasks.len() |
| )); |
| app.tasks = tasks; |
| if app.selected >= app.tasks.len() { app.selected = app.tasks.len().saturating_sub(1); } |
| app.status = "Loaded tasks".to_string(); |
| } |
| Err(e) => { |
| append_error_log(format!("refresh load_tasks failed: {e}")); |
| app.status = format!("Failed to load tasks: {e}"); |
| } |
| } |
| needs_redraw = true; |
| let _ = frame_tx.send(Instant::now()); |
| } |
| app::AppEvent::NewTaskSubmitted(result) => { |
| match result { |
| Ok(created) => { |
| append_error_log(format!("new-task: created id={}", created.id.0)); |
| app.status = format!("Submitted as {}", created.id.0); |
| app.new_task = None; |
| |
| app.status = format!("Submitted as {} — refreshing…", created.id.0); |
| app.refresh_inflight = true; |
| app.list_generation = app.list_generation.saturating_add(1); |
| needs_redraw = true; |
| let backend = Arc::clone(&backend); |
| let tx = tx.clone(); |
| let env_sel = app.env_filter.clone(); |
| tokio::spawn(async move { |
| let res = app::load_tasks(&*backend, env_sel.as_deref()).await; |
| let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); |
| }); |
| let _ = frame_tx.send(Instant::now()); |
| } |
| Err(msg) => { |
| append_error_log(format!("new-task: submit failed: {msg}")); |
| if let Some(page) = app.new_task.as_mut() { page.submitting = false; } |
| app.status = format!("Submit failed: {msg}. See error.log for details."); |
| needs_redraw = true; |
| let _ = frame_tx.send(Instant::now()); |
| } |
| } |
| } |
| |
| app::AppEvent::ApplyPreflightFinished { id, title, message, level, skipped, conflicts } => { |
| |
| if let Some(m) = app.apply_modal.as_mut() |
| && m.task_id == id |
| { |
| m.title = title; |
| m.result_message = Some(message); |
| m.result_level = Some(level); |
| m.skipped_paths = skipped; |
| m.conflict_paths = conflicts; |
| app.apply_preflight_inflight = false; |
| needs_redraw = true; |
| let _ = frame_tx.send(Instant::now()); |
| } |
| } |
| app::AppEvent::EnvironmentsLoaded(result) => { |
| app.env_loading = false; |
| match result { |
| Ok(list) => { |
| app.environments = list; |
| app.env_error = None; |
| app.env_last_loaded = Some(std::time::Instant::now()); |
| } |
| Err(e) => { |
| app.env_error = Some(e.to_string()); |
| } |
| } |
| needs_redraw = true; |
| let _ = frame_tx.send(Instant::now()); |
| } |
| app::AppEvent::EnvironmentAutodetected(result) => { |
| if let Ok(sel) = result { |
| |
| if app.env_filter.as_deref() != Some(sel.id.as_str()) { |
| append_error_log(format!( |
| "env.select: autodetected id={} label={}", |
| sel.id, |
| sel.label.clone().unwrap_or_else(|| "<none>".to_string()) |
| )); |
| |
| if let Some(lbl) = sel.label.clone() { |
| let present = app.environments.iter().any(|r| r.id == sel.id); |
| if !present { |
| app.environments.push(app::EnvironmentRow { id: sel.id.clone(), label: Some(lbl), is_pinned: false, repo_hints: None }); |
| } |
| } |
| app.env_filter = Some(sel.id); |
| app.status = "Loading tasks…".to_string(); |
| app.refresh_inflight = true; |
| app.list_generation = app.list_generation.saturating_add(1); |
| app.in_flight.clear(); |
| |
| needs_redraw = true; |
| { |
| let backend = Arc::clone(&backend); |
| let tx = tx.clone(); |
| let env_sel = app.env_filter.clone(); |
| tokio::spawn(async move { |
| let res = app::load_tasks(&*backend, env_sel.as_deref()).await; |
| let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); |
| }); |
| } |
| |
| app.env_loading = true; |
| spawn_environment_load( |
| tx.clone(), |
| base_url.clone(), |
| environment_http.clone(), |
| ); |
| let _ = frame_tx.send(Instant::now()); |
| } |
| } |
| |
| } |
| app::AppEvent::DetailsDiffLoaded { id, title, diff } => { |
| if let Some(ov) = &app.diff_overlay |
| && ov.task_id != id { |
| continue; |
| } |
| let diff_lines: Vec<String> = diff.lines().map(str::to_string).collect(); |
| if let Some(ov) = app.diff_overlay.as_mut() { |
| ov.title = title; |
| { |
| let base = ov.base_attempt_mut(); |
| base.diff_lines = diff_lines.clone(); |
| base.diff_raw = Some(diff.clone()); |
| } |
| ov.base_can_apply = true; |
| ov.apply_selection_to_fields(); |
| } else { |
| let mut overlay = app::DiffOverlay::new(id.clone(), title, None); |
| { |
| let base = overlay.base_attempt_mut(); |
| base.diff_lines = diff_lines.clone(); |
| base.diff_raw = Some(diff.clone()); |
| } |
| overlay.base_can_apply = true; |
| overlay.current_view = app::DetailView::Diff; |
| overlay.apply_selection_to_fields(); |
| app.diff_overlay = Some(overlay); |
| } |
| app.details_inflight = false; |
| app.status.clear(); |
| needs_redraw = true; |
| } |
| app::AppEvent::DetailsMessagesLoaded { |
| id, |
| title, |
| messages, |
| prompt, |
| turn_id, |
| sibling_turn_ids, |
| attempt_placement, |
| attempt_status, |
| } => { |
| if let Some(ov) = &app.diff_overlay |
| && ov.task_id != id { |
| continue; |
| } |
| let conv = conversation_lines(prompt.clone(), &messages); |
| if let Some(ov) = app.diff_overlay.as_mut() { |
| ov.title = title.clone(); |
| { |
| let base = ov.base_attempt_mut(); |
| base.text_lines = conv.clone(); |
| base.prompt = prompt.clone(); |
| base.turn_id = turn_id.clone(); |
| base.status = attempt_status; |
| base.attempt_placement = attempt_placement; |
| } |
| ov.base_turn_id = turn_id.clone(); |
| ov.sibling_turn_ids = sibling_turn_ids.clone(); |
| ov.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1)); |
| if !ov.base_can_apply { |
| ov.current_view = app::DetailView::Prompt; |
| } |
| ov.apply_selection_to_fields(); |
| if let (Some(turn_id), true) = (turn_id.clone(), !sibling_turn_ids.is_empty()) |
| && ov.attempts.len() == 1 { |
| let backend = Arc::clone(&backend); |
| let tx = tx.clone(); |
| let task_id = id.clone(); |
| tokio::spawn(async move { |
| match codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( |
| &*backend, |
| task_id.clone(), |
| turn_id, |
| ) |
| .await |
| { |
| Ok(attempts) => { |
| let _ = tx.send(app::AppEvent::AttemptsLoaded { id: task_id, attempts }); |
| } |
| Err(e) => { |
| crate::util::append_error_log(format!( |
| "attempts.load failed for {}: {e}", |
| task_id.0 |
| )); |
| } |
| } |
| }); |
| } |
| } else { |
| let mut overlay = app::DiffOverlay::new(id.clone(), title, None); |
| { |
| let base = overlay.base_attempt_mut(); |
| base.text_lines = conv.clone(); |
| base.prompt = prompt.clone(); |
| base.turn_id = turn_id.clone(); |
| base.status = attempt_status; |
| base.attempt_placement = attempt_placement; |
| } |
| overlay.base_turn_id = turn_id.clone(); |
| overlay.sibling_turn_ids = sibling_turn_ids.clone(); |
| overlay.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1)); |
| overlay.current_view = app::DetailView::Prompt; |
| overlay.apply_selection_to_fields(); |
| app.diff_overlay = Some(overlay); |
| } |
| app.details_inflight = false; |
| app.status.clear(); |
| needs_redraw = true; |
| } |
| app::AppEvent::AttemptsLoaded { id, attempts } => { |
| if let Some(ov) = app.diff_overlay.as_mut() { |
| if ov.task_id != id { |
| continue; |
| } |
| for attempt in attempts { |
| if ov |
| .attempts |
| .iter() |
| .any(|existing| existing.turn_id.as_deref() == Some(attempt.turn_id.as_str())) |
| { |
| continue; |
| } |
| let diff_lines = attempt |
| .diff |
| .as_ref() |
| .map(|d| d.lines().map(str::to_string).collect()) |
| .unwrap_or_default(); |
| let text_lines = conversation_lines( None, &attempt.messages); |
| ov.attempts.push(app::AttemptView { |
| turn_id: Some(attempt.turn_id.clone()), |
| status: attempt.status, |
| attempt_placement: attempt.attempt_placement, |
| diff_lines, |
| text_lines, |
| prompt: None, |
| diff_raw: attempt.diff.clone(), |
| }); |
| } |
| if ov.attempts.len() > 1 { |
| let (_, rest) = ov.attempts.split_at_mut(1); |
| rest.sort_by(|a, b| match (a.attempt_placement, b.attempt_placement) { |
| (Some(lhs), Some(rhs)) => lhs.cmp(&rhs), |
| (Some(_), None) => std::cmp::Ordering::Less, |
| (None, Some(_)) => std::cmp::Ordering::Greater, |
| (None, None) => a.turn_id.cmp(&b.turn_id), |
| }); |
| } |
| if ov.selected_attempt >= ov.attempts.len() { |
| ov.selected_attempt = ov.attempts.len().saturating_sub(1); |
| } |
| ov.attempt_total_hint = Some(ov.attempts.len()); |
| ov.apply_selection_to_fields(); |
| needs_redraw = true; |
| } |
| } |
| app::AppEvent::DetailsFailed { id, title, error } => { |
| if let Some(ov) = &app.diff_overlay |
| && ov.task_id != id { |
| continue; |
| } |
| append_error_log(format!("details failed for {}: {error}", id.0)); |
| let pretty = pretty_lines_from_error(&error); |
| if let Some(ov) = app.diff_overlay.as_mut() { |
| ov.title = title.clone(); |
| { |
| let base = ov.base_attempt_mut(); |
| base.diff_lines.clear(); |
| base.text_lines = pretty.clone(); |
| base.prompt = None; |
| } |
| ov.base_can_apply = false; |
| ov.current_view = app::DetailView::Prompt; |
| ov.apply_selection_to_fields(); |
| } else { |
| let mut overlay = app::DiffOverlay::new(id.clone(), title, None); |
| { |
| let base = overlay.base_attempt_mut(); |
| base.text_lines = pretty; |
| } |
| overlay.base_can_apply = false; |
| overlay.current_view = app::DetailView::Prompt; |
| overlay.apply_selection_to_fields(); |
| app.diff_overlay = Some(overlay); |
| } |
| app.details_inflight = false; |
| needs_redraw = true; |
| } |
| app::AppEvent::ApplyFinished { id, result } => { |
| |
| if let Some(m) = &app.apply_modal { |
| if m.task_id != id { continue; } |
| } else { |
| continue; |
| } |
| app.apply_inflight = false; |
| match result { |
| Ok(outcome) => { |
| app.status = outcome.message.clone(); |
| if matches!(outcome.status, codex_cloud_tasks_client::ApplyStatus::Success) { |
| app.apply_modal = None; |
| app.diff_overlay = None; |
| |
| let backend = Arc::clone(&backend); |
| let tx = tx.clone(); |
| let env_sel = app.env_filter.clone(); |
| tokio::spawn(async move { |
| let res = app::load_tasks(&*backend, env_sel.as_deref()).await; |
| let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); |
| }); |
| } |
| } |
| Err(e) => { |
| append_error_log(format!("apply_task failed for {}: {e}", id.0)); |
| app.status = format!("Apply failed: {e}"); |
| } |
| } |
| needs_redraw = true; |
| } |
| } |
| } |
| |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| } |
| maybe_event = events.next() => { |
| match maybe_event { |
| Some(Ok(Event::Paste(pasted))) => { |
| if app.env_modal.is_some() { |
| if let Some(m) = app.env_modal.as_mut() { |
| for ch in pasted.chars() { |
| match ch { |
| '\r' | '\n' => continue, |
| '\t' => m.query.push(' '), |
| _ => m.query.push(ch), |
| } |
| } |
| } |
| needs_redraw = true; |
| } else if let Some(page) = app.new_task.as_mut() |
| && !page.submitting |
| { |
| if page.composer.handle_paste(pasted) { |
| needs_redraw = true; |
| } |
| let _ = frame_tx.send(Instant::now()); |
| } |
| } |
| Some(Ok(Event::Key(key))) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { |
| |
| if key.modifiers.contains(KeyModifiers::CONTROL) |
| && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) |
| { |
| if app.env_modal.is_some() { |
| |
| app.env_modal = None; |
| needs_redraw = true; |
| } else if app.best_of_modal.is_some() { |
| app.best_of_modal = None; |
| needs_redraw = true; |
| } else if app.apply_modal.is_some() { |
| app.apply_modal = None; |
| app.status = "Apply canceled".to_string(); |
| needs_redraw = true; |
| } else if app.new_task.is_some() { |
| app.new_task = None; |
| app.status = "Canceled new task".to_string(); |
| needs_redraw = true; |
| } else if app.diff_overlay.is_some() { |
| app.diff_overlay = None; |
| needs_redraw = true; |
| } else { |
| break 0; |
| } |
| |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| continue; |
| } |
| let is_ctrl_n = key.modifiers.contains(KeyModifiers::CONTROL) |
| && matches!(key.code, KeyCode::Char('n') | KeyCode::Char('N')) |
| || matches!(key.code, KeyCode::Char('\u{000E}')); |
| if is_ctrl_n { |
| if app.new_task.is_none() { |
| continue; |
| } |
| if app.best_of_modal.is_some() { |
| app.best_of_modal = None; |
| needs_redraw = true; |
| } else { |
| let selected = app.best_of_n.saturating_sub(1).min(3); |
| app.best_of_modal = Some(app::BestOfModalState { selected }); |
| app.status = format!( |
| "Select best-of attempts (current: {} attempt{})", |
| app.best_of_n, |
| if app.best_of_n == 1 { "" } else { "s" } |
| ); |
| needs_redraw = true; |
| } |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| continue; |
| } |
| if app.best_of_modal.is_some() { |
| match key.code { |
| KeyCode::Esc => { |
| app.best_of_modal = None; |
| needs_redraw = true; |
| } |
| KeyCode::Down | KeyCode::Char('j') => { |
| if let Some(m) = app.best_of_modal.as_mut() { |
| m.selected = (m.selected + 1).min(3); |
| } |
| needs_redraw = true; |
| } |
| KeyCode::Up | KeyCode::Char('k') => { |
| if let Some(m) = app.best_of_modal.as_mut() { |
| m.selected = m.selected.saturating_sub(1); |
| } |
| needs_redraw = true; |
| } |
| KeyCode::Char('1') | KeyCode::Char('2') | KeyCode::Char('3') | KeyCode::Char('4') => { |
| if let Some(m) = app.best_of_modal.as_mut() { |
| let val = match key.code { |
| KeyCode::Char('1') => 0, |
| KeyCode::Char('2') => 1, |
| KeyCode::Char('3') => 2, |
| KeyCode::Char('4') => 3, |
| _ => m.selected, |
| }; |
| m.selected = val; |
| } |
| needs_redraw = true; |
| } |
| KeyCode::Enter => { |
| if let Some(state) = app.best_of_modal.take() { |
| let new_value = state.selected + 1; |
| app.best_of_n = new_value; |
| if let Some(page) = app.new_task.as_mut() { |
| page.best_of_n = new_value; |
| } |
| append_error_log(format!("best-of.select: attempts={new_value}")); |
| app.status = format!( |
| "Best-of updated to {new_value} attempt{}", |
| if new_value == 1 { "" } else { "s" } |
| ); |
| needs_redraw = true; |
| } |
| } |
| _ => {} |
| } |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| continue; |
| } |
| |
| let is_ctrl_o = key.modifiers.contains(KeyModifiers::CONTROL) |
| && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) |
| || matches!(key.code, KeyCode::Char('\u{000F}')); |
| if is_ctrl_o && app.new_task.is_some() { |
| |
| app.diff_overlay = None; |
| app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); |
| |
| let should_fetch = app.environments.is_empty(); |
| if should_fetch { |
| app.env_loading = true; |
| app.env_error = None; |
| |
| let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); |
| } |
| needs_redraw = true; |
| if should_fetch { |
| spawn_environment_load( |
| tx.clone(), |
| base_url.clone(), |
| environment_http.clone(), |
| ); |
| } |
| |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| continue; |
| } |
|
|
| |
| if let Some(page) = app.new_task.as_mut() { |
| if app.env_modal.is_some() { |
| |
| } else { |
| match key.code { |
| KeyCode::Esc => { |
| app.new_task = None; |
| app.status = "Canceled new task".to_string(); |
| needs_redraw = true; |
| } |
| _ => { |
| if page.submitting { |
| |
| } else if let codex_tui::ComposerAction::Submitted(text) = |
| page.composer.input(key) |
| { |
| |
| if let Some(env) = page.env_id.clone() { |
| append_error_log(format!( |
| "new-task: submit env={} size={}", |
| env, |
| text.chars().count() |
| )); |
| page.submitting = true; |
| app.status = "Submitting new task…".to_string(); |
| let tx = tx.clone(); |
| let backend = Arc::clone(&backend); |
| let best_of_n = page.best_of_n; |
| tokio::spawn(async move { |
| let git_ref = resolve_git_ref( None).await; |
|
|
| let result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, &git_ref, false, best_of_n).await; |
| let evt = match result { |
| Ok(ok) => app::AppEvent::NewTaskSubmitted(Ok(ok)), |
| Err(e) => app::AppEvent::NewTaskSubmitted(Err(format!("{e}"))), |
| }; |
| let _ = tx.send(evt); |
| }); |
| } else { |
| app.status = "No environment selected".to_string(); |
| } |
| } |
| needs_redraw = true; |
| |
| if page.composer.is_in_paste_burst() { |
| let _ = frame_tx.send( |
| Instant::now() |
| + codex_tui::ComposerInput::recommended_flush_delay(), |
| ); |
| } |
| |
| let _ = frame_tx.send(Instant::now()); |
| |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| } |
| } |
| continue; |
| } |
| } |
| |
| if app.apply_modal.is_some() { |
| |
| match key.code { |
| KeyCode::Char('y') => { |
| if let Some(m) = app.apply_modal.as_ref() { |
| let title = m.title.clone(); |
| let job = ApplyJob { |
| task_id: m.task_id.clone(), |
| diff_override: m.diff_override.clone(), |
| }; |
| if spawn_apply(&mut app, &backend, &tx, &frame_tx, job) { |
| app.status = format!("Applying '{title}'..."); |
| } |
| needs_redraw = true; |
| } |
| } |
| KeyCode::Char('p') => { |
| if let Some(m) = app.apply_modal.take() { |
| let title = m.title.clone(); |
| let job = ApplyJob { |
| task_id: m.task_id.clone(), |
| diff_override: m.diff_override.clone(), |
| }; |
| if spawn_preflight(&mut app, &backend, &tx, &frame_tx, title.clone(), job) { |
| app.apply_modal = Some(app::ApplyModalState { |
| task_id: m.task_id, |
| title: title.clone(), |
| result_message: None, |
| result_level: None, |
| skipped_paths: Vec::new(), |
| conflict_paths: Vec::new(), |
| diff_override: m.diff_override, |
| }); |
| app.status = format!("Preflighting '{title}'..."); |
| } else { |
| app.apply_modal = Some(m); |
| } |
| needs_redraw = true; |
| } |
| } |
| KeyCode::Esc |
| | KeyCode::Char('n') |
| | KeyCode::Char('q') |
| | KeyCode::Char('Q') => { app.apply_modal = None; app.status = "Apply canceled".to_string(); needs_redraw = true; } |
| _ => {} |
| } |
| } else if app.diff_overlay.is_some() { |
| let mut cycle_attempt = |delta: isize| { |
| if let Some(ov) = app.diff_overlay.as_mut() |
| && ov.attempt_count() > 1 { |
| ov.step_attempt(delta); |
| let total = ov.attempt_display_total(); |
| let current = ov.selected_attempt + 1; |
| app.status = format!("Viewing attempt {current} of {total}"); |
| ov.sd.scroll_to_top(); |
| needs_redraw = true; |
| } |
| }; |
|
|
| match key.code { |
| KeyCode::Char('a') => { |
| if app.apply_inflight || app.apply_preflight_inflight { |
| app.status = "Finish the current apply/preflight before starting another.".to_string(); |
| needs_redraw = true; |
| continue; |
| } |
| let snapshot = app.diff_overlay.as_ref().map(|ov| { |
| ( |
| ov.task_id.clone(), |
| ov.title.clone(), |
| ov.current_can_apply(), |
| ov.current_attempt().and_then(|attempt| attempt.diff_raw.clone()), |
| ) |
| }); |
| if let Some((task_id, title, can_apply, diff_override)) = snapshot { |
| if can_apply { |
| let job = ApplyJob { |
| task_id: task_id.clone(), |
| diff_override: diff_override.clone(), |
| }; |
| if spawn_preflight(&mut app, &backend, &tx, &frame_tx, title.clone(), job) { |
| app.apply_modal = Some(app::ApplyModalState { |
| task_id, |
| title: title.clone(), |
| result_message: None, |
| result_level: None, |
| skipped_paths: Vec::new(), |
| conflict_paths: Vec::new(), |
| diff_override, |
| }); |
| app.status = format!("Preflighting '{title}'..."); |
| } |
| } else { |
| app.status = "No diff available to apply.".to_string(); |
| } |
| needs_redraw = true; |
| } |
| } |
| KeyCode::Tab => { |
| cycle_attempt(1); |
| } |
| KeyCode::BackTab => { |
| cycle_attempt(-1); |
| } |
| |
| KeyCode::Char('o') | KeyCode::Char('O') => { |
| app.diff_overlay = None; |
| app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); |
| |
| if app.environments.is_empty() { app.env_loading = true; app.env_error = None; } |
| needs_redraw = true; |
| if app.environments.is_empty() { |
| spawn_environment_load( |
| tx.clone(), |
| base_url.clone(), |
| environment_http.clone(), |
| ); |
| } |
| } |
| KeyCode::Left => { |
| if let Some(ov) = &mut app.diff_overlay { |
| let has_text = ov.current_attempt().is_some_and(app::AttemptView::has_text); |
| let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; |
| if has_text && has_diff { |
| ov.set_view(app::DetailView::Prompt); |
| ov.sd.scroll_to_top(); |
| needs_redraw = true; |
| } |
| } |
| } |
| KeyCode::Right => { |
| if let Some(ov) = &mut app.diff_overlay { |
| let has_text = ov.current_attempt().is_some_and(app::AttemptView::has_text); |
| let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; |
| if has_text && has_diff { |
| ov.set_view(app::DetailView::Diff); |
| ov.sd.scroll_to_top(); |
| needs_redraw = true; |
| } |
| } |
| } |
| KeyCode::Char(']') | KeyCode::Char('}') => { |
| cycle_attempt(1); |
| } |
| KeyCode::Char('[') | KeyCode::Char('{') => { |
| cycle_attempt(-1); |
| } |
| KeyCode::Esc | KeyCode::Char('q') => { |
| app.diff_overlay = None; |
| needs_redraw = true; |
| } |
| KeyCode::Down | KeyCode::Char('j') => { |
| if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by( 1); } |
| needs_redraw = true; |
| } |
| KeyCode::Up | KeyCode::Char('k') => { |
| if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by( -1); } |
| needs_redraw = true; |
| } |
| KeyCode::PageDown | KeyCode::Char(' ') => { |
| if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(step); } |
| needs_redraw = true; |
| } |
| KeyCode::PageUp => { |
| if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(-step); } |
| needs_redraw = true; |
| } |
| KeyCode::Home => { if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_to_top(); } needs_redraw = true; } |
| KeyCode::End => { if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_to_bottom(); } needs_redraw = true; } |
| _ => {} |
| } |
| } else if app.env_modal.is_some() { |
| |
| match key.code { |
| KeyCode::Esc => { app.env_modal = None; needs_redraw = true; } |
| KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => { |
| if let Some(m) = app.env_modal.as_mut() { m.query.push(ch); } |
| needs_redraw = true; |
| } |
| KeyCode::Backspace => { if let Some(m) = app.env_modal.as_mut() { m.query.pop(); } needs_redraw = true; } |
| KeyCode::Down | KeyCode::Char('j') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_add(1); } needs_redraw = true; } |
| KeyCode::Up | KeyCode::Char('k') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_sub(1); } needs_redraw = true; } |
| KeyCode::Home => { if let Some(m) = app.env_modal.as_mut() { m.selected = 0; } needs_redraw = true; } |
| KeyCode::End => { if let Some(m) = app.env_modal.as_mut() { m.selected = app.environments.len(); } needs_redraw = true; } |
| KeyCode::PageDown | KeyCode::Char(' ') => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_add(step); } needs_redraw = true; } |
| KeyCode::PageUp => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_sub(step); } needs_redraw = true; } |
| KeyCode::Char('n') => { |
| if app.env_filter.is_none() { |
| app.new_task = Some(crate::new_task::NewTaskPage::new( None, app.best_of_n)); |
| } else { |
| app.new_task = Some(crate::new_task::NewTaskPage::new(app.env_filter.clone(), app.best_of_n)); |
| } |
| app.status = "New Task: Enter to submit; Esc to cancel".to_string(); |
| needs_redraw = true; |
| } |
| KeyCode::Enter => { |
| |
| if let Some(state) = app.env_modal.take() { |
| let q = state.query.to_lowercase(); |
| let filtered: Vec<&app::EnvironmentRow> = app.environments.iter().filter(|r| { |
| if q.is_empty() { return true; } |
| let mut hay = String::new(); |
| if let Some(l) = &r.label { hay.push_str(&l.to_lowercase()); hay.push(' '); } |
| hay.push_str(&r.id.to_lowercase()); |
| if let Some(h) = &r.repo_hints { hay.push(' '); hay.push_str(&h.to_lowercase()); } |
| hay.contains(&q) |
| }).collect(); |
| |
| let idx = state.selected; |
| if idx == 0 { app.env_filter = None; append_error_log("env.select: All"); } |
| else { |
| let env_idx = idx.saturating_sub(1); |
| if let Some(row) = filtered.get(env_idx) { |
| append_error_log(format!( |
| "env.select: id={} label={}", |
| row.id, |
| row.label.clone().unwrap_or_else(|| "<none>".to_string()) |
| )); |
| app.env_filter = Some(row.id.clone()); |
| } |
| } |
| |
| if let Some(page) = app.new_task.as_mut() { |
| page.env_id = app.env_filter.clone(); |
| } |
| |
| app.status = "Loading tasks…".to_string(); |
| app.refresh_inflight = true; |
| app.list_generation = app.list_generation.saturating_add(1); |
| app.in_flight.clear(); |
| |
| needs_redraw = true; |
| let backend = Arc::clone(&backend); |
| let tx = tx.clone(); |
| let env_sel = app.env_filter.clone(); |
| tokio::spawn(async move { |
| let res = app::load_tasks(&*backend, env_sel.as_deref()).await; |
| let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); |
| }); |
| } |
| } |
| _ => {} |
| } |
| } else { |
| |
| match key.code { |
| KeyCode::Char('q') | KeyCode::Esc => { |
| break 0; |
| } |
| KeyCode::Down | KeyCode::Char('j') => { |
| app.next(); |
| needs_redraw = true; |
| } |
| KeyCode::Up | KeyCode::Char('k') => { |
| app.prev(); |
| needs_redraw = true; |
| } |
| |
| KeyCode::Char('r') | KeyCode::Char('R') => { |
| if app.env_modal.is_some() { break 0; } |
| append_error_log(format!( |
| "refresh.request: env={}", |
| app.env_filter.clone().unwrap_or_else(|| "<all>".to_string()) |
| )); |
| app.status = "Refreshing…".to_string(); |
| app.refresh_inflight = true; |
| app.list_generation = app.list_generation.saturating_add(1); |
| app.in_flight.clear(); |
| |
| needs_redraw = true; |
| |
| let backend = Arc::clone(&backend); |
| let tx = tx.clone(); |
| let env_sel = app.env_filter.clone(); |
| tokio::spawn(async move { |
| let res = app::load_tasks(&*backend, env_sel.as_deref()).await; |
| let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); |
| }); |
| } |
| KeyCode::Char('o') | KeyCode::Char('O') => { |
| app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); |
| |
| let should_fetch = app.environments.is_empty(); |
| if should_fetch { app.env_loading = true; app.env_error = None; } |
| needs_redraw = true; |
| if should_fetch { |
| spawn_environment_load( |
| tx.clone(), |
| base_url.clone(), |
| environment_http.clone(), |
| ); |
| } |
| } |
| KeyCode::Char('n') => { |
| let env_opt = app.env_filter.clone(); |
| app.new_task = Some(crate::new_task::NewTaskPage::new(env_opt, app.best_of_n)); |
| app.status = "New Task: Enter to submit; Esc to cancel".to_string(); |
| needs_redraw = true; |
| } |
| KeyCode::Enter => { |
| if let Some(task) = app.tasks.get(app.selected).cloned() { |
| app.status = format!("Loading details for {title}…", title = task.title); |
| app.details_inflight = true; |
| |
| let overlay = app::DiffOverlay::new( |
| task.id.clone(), |
| task.title.clone(), |
| task.attempt_total, |
| ); |
| app.diff_overlay = Some(overlay); |
| needs_redraw = true; |
| |
| let id = task.id.clone(); |
| let title = task.title.clone(); |
| { |
| let backend = Arc::clone(&backend); |
| let tx = tx.clone(); |
| let diff_id = id.clone(); |
| let diff_title = title.clone(); |
| tokio::spawn(async move { |
| match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, diff_id.clone()).await { |
| Ok(Some(diff)) => { |
| let _ = tx.send(app::AppEvent::DetailsDiffLoaded { id: diff_id, title: diff_title, diff }); |
| } |
| Ok(None) => { |
| match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { |
| Ok(text) => { |
| let evt = app::AppEvent::DetailsMessagesLoaded { |
| id: diff_id, |
| title: diff_title, |
| messages: text.messages, |
| prompt: text.prompt, |
| turn_id: text.turn_id, |
| sibling_turn_ids: text.sibling_turn_ids, |
| attempt_placement: text.attempt_placement, |
| attempt_status: text.attempt_status, |
| }; |
| let _ = tx.send(evt); |
| } |
| Err(e2) => { |
| let _ = tx.send(app::AppEvent::DetailsFailed { id: diff_id, title: diff_title, error: format!("{e2}") }); |
| } |
| } |
| } |
| Err(e) => { |
| append_error_log(format!("get_task_diff failed for {}: {e}", diff_id.0)); |
| match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { |
| Ok(text) => { |
| let evt = app::AppEvent::DetailsMessagesLoaded { |
| id: diff_id, |
| title: diff_title, |
| messages: text.messages, |
| prompt: text.prompt, |
| turn_id: text.turn_id, |
| sibling_turn_ids: text.sibling_turn_ids, |
| attempt_placement: text.attempt_placement, |
| attempt_status: text.attempt_status, |
| }; |
| let _ = tx.send(evt); |
| } |
| Err(e2) => { |
| let _ = tx.send(app::AppEvent::DetailsFailed { id: diff_id, title: diff_title, error: format!("{e2}") }); |
| } |
| } |
| } |
| } |
| }); |
| } |
| |
| { |
| let backend = Arc::clone(&backend); |
| let tx = tx.clone(); |
| let msg_id = id; |
| let msg_title = title; |
| tokio::spawn(async move { |
| if let Ok(text) = codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, msg_id.clone()).await { |
| let evt = app::AppEvent::DetailsMessagesLoaded { |
| id: msg_id, |
| title: msg_title, |
| messages: text.messages, |
| prompt: text.prompt, |
| turn_id: text.turn_id, |
| sibling_turn_ids: text.sibling_turn_ids, |
| attempt_placement: text.attempt_placement, |
| attempt_status: text.attempt_status, |
| }; |
| let _ = tx.send(evt); |
| } |
| }); |
| } |
| |
| let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); |
| } |
| } |
| KeyCode::Char('a') => { |
| if app.apply_inflight || app.apply_preflight_inflight { |
| app.status = "Finish the current apply/preflight before starting another.".to_string(); |
| needs_redraw = true; |
| continue; |
| } |
|
|
| if let Some(task) = app.tasks.get(app.selected).cloned() { |
| match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task.id.clone()).await { |
| Ok(Some(diff)) => { |
| let diff_override = Some(diff.clone()); |
| let task_id = task.id.clone(); |
| let title = task.title.clone(); |
| let job = ApplyJob { |
| task_id: task_id.clone(), |
| diff_override: diff_override.clone(), |
| }; |
| if spawn_preflight( |
| &mut app, |
| &backend, |
| &tx, |
| &frame_tx, |
| title.clone(), |
| job, |
| ) { |
| app.apply_modal = Some(app::ApplyModalState { |
| task_id, |
| title: title.clone(), |
| result_message: None, |
| result_level: None, |
| skipped_paths: Vec::new(), |
| conflict_paths: Vec::new(), |
| diff_override, |
| }); |
| app.status = format!("Preflighting '{title}'..."); |
| } |
| } |
| Ok(None) | Err(_) => { |
| app.status = "No diff available to apply".to_string(); |
| } |
| } |
| needs_redraw = true; |
| } |
| } |
| _ => {} |
| } |
| } |
| |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| } |
| Some(Ok(Event::Resize(_, _))) => { |
| needs_redraw = true; |
| |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| } |
| Some(Err(_)) | None => {} |
| _ => {} |
| } |
| |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; |
| } |
| } |
| }; |
|
|
| |
| disable_raw_mode().ok(); |
| terminal.show_cursor().ok(); |
| let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste); |
| |
| let _ = crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags); |
| let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen); |
|
|
| if exit_code != 0 { |
| std::process::exit(exit_code); |
| } |
| Ok(()) |
| } |
|
|
| fn spawn_environment_load( |
| tx: UnboundedSender<app::AppEvent>, |
| base_url: String, |
| http: RouteAwareClientPool, |
| ) { |
| tokio::spawn(async move { |
| let base_url = util::normalize_base_url(&base_url); |
| let headers = util::build_chatgpt_headers().await; |
| let result = crate::env_detect::list_environments(&http, &base_url, &headers).await; |
| let _ = tx.send(app::AppEvent::EnvironmentsLoaded(result)); |
| }); |
| } |
|
|
| |
|
|
| |
| fn conversation_lines(prompt: Option<String>, messages: &[String]) -> Vec<String> { |
| let mut out: Vec<String> = Vec::new(); |
| if let Some(p) = prompt { |
| out.push("user:".to_string()); |
| for l in p.lines() { |
| out.push(l.to_string()); |
| } |
| out.push(String::new()); |
| } |
| if !messages.is_empty() { |
| out.push("assistant:".to_string()); |
| for (i, m) in messages.iter().enumerate() { |
| for l in m.lines() { |
| out.push(l.to_string()); |
| } |
| if i + 1 < messages.len() { |
| out.push(String::new()); |
| } |
| } |
| } |
| if out.is_empty() { |
| out.push("<no output>".to_string()); |
| } |
| out |
| } |
|
|
| |
| |
| fn pretty_lines_from_error(raw: &str) -> Vec<String> { |
| let mut lines: Vec<String> = Vec::new(); |
| let is_no_diff = raw.contains("No output_diff in response."); |
| let is_no_msgs = raw.contains("No assistant text messages in response."); |
| if is_no_diff { |
| lines.push("No diff available for this task.".to_string()); |
| } else if is_no_msgs { |
| lines.push("No assistant messages found for this task.".to_string()); |
| } else { |
| lines.push("Failed to load task details.".to_string()); |
| } |
|
|
| |
| if let Some(body_idx) = raw.find(" body=") |
| && let Some(json_start_rel) = raw[body_idx..].find('{') |
| { |
| let json_start = body_idx + json_start_rel; |
| let json_str = raw[json_start..].trim(); |
| if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) { |
| |
| let turn = v |
| .get("current_assistant_turn") |
| .and_then(|x| x.as_object()) |
| .cloned() |
| .or_else(|| { |
| v.get("current_diff_task_turn") |
| .and_then(|x| x.as_object()) |
| .cloned() |
| }); |
| if let Some(t) = turn { |
| if let Some(err) = t.get("error").and_then(|e| e.as_object()) { |
| let code = err.get("code").and_then(|s| s.as_str()).unwrap_or(""); |
| let msg = err.get("message").and_then(|s| s.as_str()).unwrap_or(""); |
| if !code.is_empty() || !msg.is_empty() { |
| let summary = if code.is_empty() { |
| msg.to_string() |
| } else if msg.is_empty() { |
| code.to_string() |
| } else { |
| format!("{code}: {msg}") |
| }; |
| lines.push(format!("Assistant error: {summary}")); |
| } |
| } |
| if let Some(status) = t.get("turn_status").and_then(|s| s.as_str()) { |
| lines.push(format!("Status: {status}")); |
| } |
| if let Some(text) = t |
| .get("latest_event") |
| .and_then(|e| e.get("text")) |
| .and_then(|s| s.as_str()) |
| && !text.trim().is_empty() |
| { |
| lines.push(format!("Latest event: {}", text.trim())); |
| } |
| } |
| } |
| } |
|
|
| if lines.len() == 1 { |
| |
| let tail = if raw.len() > 320 { |
| format!("{}…", &raw[..320]) |
| } else { |
| raw.to_string() |
| }; |
| lines.push(tail); |
| } else if lines.len() >= 2 { |
| |
| if lines.iter().any(|l| l.contains("in_progress")) { |
| lines.push("This task may still be running. Press 'r' to refresh.".to_string()); |
| } |
| |
| lines.push(String::new()); |
| } |
| lines |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use crate::resolve_git_ref_with_git_info; |
| use codex_cloud_tasks_client::DiffSummary; |
| use codex_cloud_tasks_client::TaskId; |
| use codex_cloud_tasks_client::TaskStatus; |
| use codex_cloud_tasks_client::TaskSummary; |
| use codex_cloud_tasks_mock_client::MockClient; |
| use pretty_assertions::assert_eq; |
|
|
| struct StubGitInfo { |
| default_branch: Option<String>, |
| current_branch: Option<String>, |
| } |
|
|
| impl StubGitInfo { |
| fn new(default_branch: Option<String>, current_branch: Option<String>) -> Self { |
| Self { |
| default_branch, |
| current_branch, |
| } |
| } |
| } |
|
|
| impl super::GitInfoProvider for StubGitInfo { |
| async fn default_branch_name(&self, _path: &std::path::Path) -> Option<String> { |
| self.default_branch.clone() |
| } |
|
|
| async fn current_branch_name(&self, _path: &std::path::Path) -> Option<String> { |
| self.current_branch.clone() |
| } |
| } |
|
|
| #[tokio::test] |
| async fn branch_override_is_used_when_provided() { |
| let git_ref = resolve_git_ref_with_git_info( |
| Some(&"feature/override".to_string()), |
| &StubGitInfo::new( None, None), |
| ) |
| .await; |
|
|
| assert_eq!(git_ref, "feature/override"); |
| } |
|
|
| #[tokio::test] |
| async fn trims_override_whitespace() { |
| let git_ref = resolve_git_ref_with_git_info( |
| Some(&" feature/spaces ".to_string()), |
| &StubGitInfo::new( None, None), |
| ) |
| .await; |
|
|
| assert_eq!(git_ref, "feature/spaces"); |
| } |
|
|
| #[tokio::test] |
| async fn prefers_current_branch_when_available() { |
| let git_ref = resolve_git_ref_with_git_info( |
| None, |
| &StubGitInfo::new( |
| Some("default-main".to_string()), |
| Some("feature/current".to_string()), |
| ), |
| ) |
| .await; |
|
|
| assert_eq!(git_ref, "feature/current"); |
| } |
|
|
| #[tokio::test] |
| async fn falls_back_to_current_branch_when_default_is_missing() { |
| let git_ref = resolve_git_ref_with_git_info( |
| None, |
| &StubGitInfo::new( None, Some("develop".to_string())), |
| ) |
| .await; |
|
|
| assert_eq!(git_ref, "develop"); |
| } |
|
|
| #[tokio::test] |
| async fn falls_back_to_main_when_no_git_info_is_available() { |
| let git_ref = resolve_git_ref_with_git_info( |
| None, |
| &StubGitInfo::new( None, None), |
| ) |
| .await; |
|
|
| assert_eq!(git_ref, "main"); |
| } |
|
|
| #[test] |
| fn format_task_status_lines_with_diff_and_label() { |
| let now = Utc::now(); |
| let task = TaskSummary { |
| id: TaskId("task_1".to_string()), |
| title: "Example task".to_string(), |
| status: TaskStatus::Ready, |
| updated_at: now, |
| environment_id: Some("env-1".to_string()), |
| environment_label: Some("Env".to_string()), |
| summary: DiffSummary { |
| files_changed: 3, |
| lines_added: 5, |
| lines_removed: 2, |
| }, |
| is_review: false, |
| attempt_total: None, |
| }; |
| let lines = format_task_status_lines(&task, now, false); |
| assert_eq!( |
| lines, |
| vec![ |
| "[READY] Example task".to_string(), |
| "Env • 0s ago".to_string(), |
| "+5/-2 • 3 files".to_string(), |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn format_task_status_lines_without_diff_falls_back() { |
| let now = Utc::now(); |
| let task = TaskSummary { |
| id: TaskId("task_2".to_string()), |
| title: "No diff task".to_string(), |
| status: TaskStatus::Pending, |
| updated_at: now, |
| environment_id: Some("env-2".to_string()), |
| environment_label: None, |
| summary: DiffSummary::default(), |
| is_review: false, |
| attempt_total: Some(1), |
| }; |
| let lines = format_task_status_lines(&task, now, false); |
| assert_eq!( |
| lines, |
| vec![ |
| "[PENDING] No diff task".to_string(), |
| "env-2 • 0s ago".to_string(), |
| "no diff".to_string(), |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn format_task_list_lines_formats_urls() { |
| let now = Utc::now(); |
| let tasks = vec![ |
| TaskSummary { |
| id: TaskId("task_1".to_string()), |
| title: "Example task".to_string(), |
| status: TaskStatus::Ready, |
| updated_at: now, |
| environment_id: Some("env-1".to_string()), |
| environment_label: Some("Env".to_string()), |
| summary: DiffSummary { |
| files_changed: 3, |
| lines_added: 5, |
| lines_removed: 2, |
| }, |
| is_review: false, |
| attempt_total: None, |
| }, |
| TaskSummary { |
| id: TaskId("task_2".to_string()), |
| title: "No diff task".to_string(), |
| status: TaskStatus::Pending, |
| updated_at: now, |
| environment_id: Some("env-2".to_string()), |
| environment_label: None, |
| summary: DiffSummary::default(), |
| is_review: false, |
| attempt_total: Some(1), |
| }, |
| ]; |
| let lines = format_task_list_lines( |
| &tasks, |
| "https://chatgpt.com/backend-api", |
| now, |
| false, |
| ); |
| assert_eq!( |
| lines, |
| vec![ |
| "https://chatgpt.com/codex/tasks/task_1".to_string(), |
| " [READY] Example task".to_string(), |
| " Env • 0s ago".to_string(), |
| " +5/-2 • 3 files".to_string(), |
| String::new(), |
| "https://chatgpt.com/codex/tasks/task_2".to_string(), |
| " [PENDING] No diff task".to_string(), |
| " env-2 • 0s ago".to_string(), |
| " no diff".to_string(), |
| ] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn collect_attempt_diffs_includes_sibling_attempts() { |
| let backend = MockClient; |
| let task_id = parse_task_id("https://chatgpt.com/codex/tasks/T-1000").expect("id"); |
| let attempts = collect_attempt_diffs(&backend, &task_id) |
| .await |
| .expect("attempts"); |
| assert_eq!(attempts.len(), 2); |
| assert_eq!(attempts[0].placement, Some(0)); |
| assert_eq!(attempts[1].placement, Some(1)); |
| assert!(!attempts[0].diff.is_empty()); |
| assert!(!attempts[1].diff.is_empty()); |
| } |
|
|
| #[test] |
| fn select_attempt_validates_bounds() { |
| let attempts = vec![AttemptDiffData { |
| placement: Some(0), |
| created_at: None, |
| diff: "diff --git a/file b/file\n".to_string(), |
| }]; |
| let first = select_attempt(&attempts, Some(1)).expect("attempt 1"); |
| assert_eq!(first.diff, "diff --git a/file b/file\n"); |
| assert!(select_attempt(&attempts, Some(2)).is_err()); |
| } |
|
|
| #[test] |
| fn parse_task_id_from_url_and_raw() { |
| let raw = parse_task_id("task_i_abc123").expect("raw id"); |
| assert_eq!(raw.0, "task_i_abc123"); |
| let url = |
| parse_task_id("https://chatgpt.com/codex/tasks/task_i_123456?foo=bar").expect("url id"); |
| assert_eq!(url.0, "task_i_123456"); |
| assert!(parse_task_id(" ").is_err()); |
| } |
| } |
|
|