Download codex-rs/cloud-tasks/src/lib.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 115 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/cloud-tasks/src/lib.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/cloud-tasks/src/lib.rs
-
curl -L -o lib.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/cloud-tasks/src/lib.rs
115 kB
| 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> { | |
| 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); | |
| 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, | |
| /*qa_mode*/ 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())) | |
| } | |
| 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 | |
| } | |
| // logging helper lives in util module | |
| // (no standalone patch summarizer needed – UI displays raw diffs) | |
| /// Entry point for the `codex cloud` subcommand. | |
| 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; | |
| // Very minimal logging setup; mirrors other crates' pattern. | |
| 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?; | |
| // Terminal setup | |
| 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)?; | |
| // Enable enhanced key reporting so Shift+Enter is distinguishable from Enter. | |
| // Some terminals may not support these flags; ignore errors if enabling fails. | |
| 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()?; | |
| // App state | |
| let mut app = app::App::new(); | |
| // Initial load | |
| 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() | |
| )); | |
| // Non-blocking initial load so the in-box spinner can animate | |
| app.status = "Loading tasks…".to_string(); | |
| app.refresh_inflight = true; | |
| // New list generation; reset background enrichment coordination | |
| app.list_generation = app.list_generation.saturating_add(1); | |
| app.in_flight.clear(); | |
| // reset any in-flight enrichment state | |
| // Event stream | |
| 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(); | |
| // Channel for non-blocking background loads | |
| use tokio::sync::mpsc::unbounded_channel; | |
| let (tx, mut rx) = unbounded_channel::<app::AppEvent>(); | |
| // Kick off the initial load in background | |
| { | |
| let backend = Arc::clone(&backend); | |
| let tx = tx.clone(); | |
| tokio::spawn(async move { | |
| let res = app::load_tasks(&*backend, /*env*/ None).await; | |
| let _ = tx.send(app::AppEvent::TasksLoaded { | |
| env: None, | |
| result: res, | |
| }); | |
| }); | |
| } | |
| // Fetch environment list in parallel so the header can show friendly names quickly. | |
| spawn_environment_load(tx.clone(), base_url.clone(), environment_http.clone()); | |
| // Try to auto-detect a likely environment id on startup and refresh if found. | |
| // Do this concurrently so the initial list shows quickly; on success we refetch with filter. | |
| { | |
| 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); | |
| // Build headers: UA + ChatGPT auth if available | |
| let headers = util::build_chatgpt_headers().await; | |
| // Run autodetect. If it fails, we keep using "All". | |
| let res = crate::env_detect::autodetect_environment_id( | |
| &environment_http, | |
| &base_url, | |
| &headers, | |
| /*desired_label*/ None, | |
| ) | |
| .await; | |
| let _ = tx.send(app::AppEvent::EnvironmentAutodetected(res)); | |
| }); | |
| } | |
| // Event-driven redraws with a tiny coalescing scheduler (snappy UI, no fixed 250ms tick). | |
| 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::<()>(); | |
| // Coalesce frame requests to the earliest deadline; emit a single redraw signal. | |
| 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; // recompute sleep target | |
| } | |
| None => break, | |
| } | |
| } | |
| _ = &mut sleeper => { | |
| if next_deadline.take().is_some() { | |
| let _ = redraw_tx.send(()); | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // Kick an initial draw so the UI appears immediately. | |
| let _ = frame_tx.send(Instant::now()); | |
| // Render helper to centralize immediate redraws after handling events. | |
| 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! { | |
| // Coalesced redraw requests: spinner animation and paste-burst micro‑flush. | |
| Some(()) = redraw_rx.recv() => { | |
| // Micro‑flush pending first key held by paste‑burst. | |
| 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()); | |
| } | |
| } | |
| // Keep spinner pulsing only while loading. | |
| 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 } => { | |
| // Only apply results for the current filter to avoid races. | |
| 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; | |
| // Refresh tasks in background for current filter | |
| 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()); | |
| } | |
| } | |
| } | |
| // (removed TaskSummaryUpdated; unused in this prototype) | |
| app::AppEvent::ApplyPreflightFinished { id, title, message, level, skipped, conflicts } => { | |
| // Only update if modal is still open and ids match | |
| 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 { | |
| // Only apply if user hasn't set a filter yet or it's different. | |
| 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()) | |
| )); | |
| // Preseed environments with detected label so header can show it even before list arrives | |
| 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(); | |
| // reset spinner state | |
| 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 }); | |
| }); | |
| } | |
| // Proactively fetch environments to resolve a friendly name for the header. | |
| app.env_loading = true; | |
| spawn_environment_load( | |
| tx.clone(), | |
| base_url.clone(), | |
| environment_http.clone(), | |
| ); | |
| let _ = frame_tx.send(Instant::now()); | |
| } | |
| } | |
| // on Err, silently continue with All | |
| } | |
| 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, /*attempt_total_hint*/ 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, /*attempt_total_hint*/ 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(/*prompt*/ 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, /*attempt_total_hint*/ 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 } => { | |
| // Only update if the modal still corresponds to this id. | |
| 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; | |
| // Refresh tasks after successful apply | |
| 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 immediately after processing app events. | |
| 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) => { | |
| // Treat Ctrl-C like pressing 'q' in the current context. | |
| if key.modifiers.contains(KeyModifiers::CONTROL) | |
| && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) | |
| { | |
| if app.env_modal.is_some() { | |
| // Close environment selector if open (don’t quit composer). | |
| 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 updated state immediately before continuing to next loop iteration. | |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; | |
| // Render after New Task branch to reflect input changes immediately. | |
| 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; | |
| } | |
| // New Task page: Ctrl+O opens environment switcher while composing. | |
| 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() { | |
| // Close task modal/pending apply if present before opening env modal | |
| app.diff_overlay = None; | |
| app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); | |
| // Cache environments while the modal is open to avoid repeated fetches. | |
| let should_fetch = app.environments.is_empty(); | |
| if should_fetch { | |
| app.env_loading = true; | |
| app.env_error = None; | |
| // Ensure spinner animates while loading environments. | |
| 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 after opening env modal to show it instantly. | |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; | |
| continue; | |
| } | |
| // New Task page has priority when active, unless an env modal is open. | |
| if let Some(page) = app.new_task.as_mut() { | |
| if app.env_modal.is_some() { | |
| // Defer handling to env-modal branch below. | |
| } else { | |
| match key.code { | |
| KeyCode::Esc => { | |
| app.new_task = None; | |
| app.status = "Canceled new task".to_string(); | |
| needs_redraw = true; | |
| } | |
| _ => { | |
| if page.submitting { | |
| // Ignore input while submitting | |
| } else if let codex_tui::ComposerAction::Submitted(text) = | |
| page.composer.input(key) | |
| { | |
| // Submit only if we have an env id | |
| 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(/*branch_override*/ None).await; | |
| let result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, &git_ref, /*qa_mode*/ 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 paste‑burst is active, schedule a micro‑flush frame. | |
| if page.composer.is_in_paste_burst() { | |
| let _ = frame_tx.send( | |
| Instant::now() | |
| + codex_tui::ComposerInput::recommended_flush_delay(), | |
| ); | |
| } | |
| // Always schedule an immediate redraw for key edits in the composer. | |
| let _ = frame_tx.send(Instant::now()); | |
| // Draw now so non-char edits (e.g., Option+Delete) reflect instantly. | |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; | |
| } | |
| } | |
| continue; | |
| } | |
| } | |
| // If a diff overlay is open, handle its keys first. | |
| if app.apply_modal.is_some() { | |
| // Simple apply confirmation modal: y apply, p preflight, n/Esc cancel | |
| 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); | |
| } | |
| // From task modal, 'o' should close it and open the env selector | |
| KeyCode::Char('o') | KeyCode::Char('O') => { | |
| app.diff_overlay = None; | |
| app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); | |
| // Use cached environments unless empty | |
| 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(/*delta*/ 1); } | |
| needs_redraw = true; | |
| } | |
| KeyCode::Up | KeyCode::Char('k') => { | |
| if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(/*delta*/ -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() { | |
| // Environment modal key handling | |
| 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(/*env_id*/ 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 => { | |
| // Resolve selection over filtered set | |
| 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(); | |
| // Keep original order (already sorted) — no need to re-sort | |
| 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 New Task page is open, reflect the new selection in its header immediately. | |
| if let Some(page) = app.new_task.as_mut() { | |
| page.env_id = app.env_filter.clone(); | |
| } | |
| // Trigger tasks refresh with the selected filter | |
| app.status = "Loading tasks…".to_string(); | |
| app.refresh_inflight = true; | |
| app.list_generation = app.list_generation.saturating_add(1); | |
| app.in_flight.clear(); | |
| // reset spinner state | |
| 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 { | |
| // Base list view keys | |
| 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; | |
| } | |
| // Ensure 'r' does not refresh tasks when the env modal is open. | |
| 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(); | |
| // reset spinner state | |
| needs_redraw = true; | |
| // Spawn background refresh | |
| 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 }); | |
| // Cache environments while the modal is open to avoid repeated fetches. | |
| 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; | |
| // Open empty overlay immediately; content arrives via events | |
| let overlay = app::DiffOverlay::new( | |
| task.id.clone(), | |
| task.title.clone(), | |
| task.attempt_total, | |
| ); | |
| app.diff_overlay = Some(overlay); | |
| needs_redraw = true; | |
| // Spawn background details load (diff first, then messages fallback) | |
| 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}") }); | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| // Also fetch conversation text even when diff exists | |
| { | |
| 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); | |
| } | |
| }); | |
| } | |
| // Animate spinner while details load. | |
| 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 after handling a key event (when not quitting). | |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; | |
| } | |
| Some(Ok(Event::Resize(_, _))) => { | |
| needs_redraw = true; | |
| // Redraw immediately on resize for snappier UX. | |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; | |
| } | |
| Some(Err(_)) | None => {} | |
| _ => {} | |
| } | |
| // Fallback: if any other event path requested a redraw, render now. | |
| render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; | |
| } | |
| } | |
| }; | |
| // Restore terminal | |
| disable_raw_mode().ok(); | |
| terminal.show_cursor().ok(); | |
| let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste); | |
| // Best-effort restore of keyboard enhancement flags before leaving alt screen. | |
| 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)); | |
| }); | |
| } | |
| // extract_chatgpt_account_id moved to util.rs | |
| /// Build plain-text conversation lines: a labeled user prompt followed by assistant messages. | |
| 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 | |
| } | |
| /// Convert a verbose HTTP error with embedded JSON body into concise, user-friendly lines | |
| /// for the details overlay. Falls back to a short raw message when parsing fails. | |
| 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()); | |
| } | |
| // Try to parse the embedded JSON body: find the first '{' after " body=" and decode. | |
| 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) { | |
| // Prefer assistant turn context. | |
| 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 { | |
| // Parsing yielded nothing; include a trimmed, short raw message tail for context. | |
| let tail = if raw.len() > 320 { | |
| format!("{}…", &raw[..320]) | |
| } else { | |
| raw.to_string() | |
| }; | |
| lines.push(tail); | |
| } else if lines.len() >= 2 { | |
| // Add a hint to refresh when still in progress. | |
| if lines.iter().any(|l| l.contains("in_progress")) { | |
| lines.push("This task may still be running. Press 'r' to refresh.".to_string()); | |
| } | |
| // Avoid an empty overlay | |
| lines.push(String::new()); | |
| } | |
| lines | |
| } | |
| 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() | |
| } | |
| } | |
| async fn branch_override_is_used_when_provided() { | |
| let git_ref = resolve_git_ref_with_git_info( | |
| Some(&"feature/override".to_string()), | |
| &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ None), | |
| ) | |
| .await; | |
| assert_eq!(git_ref, "feature/override"); | |
| } | |
| async fn trims_override_whitespace() { | |
| let git_ref = resolve_git_ref_with_git_info( | |
| Some(&" feature/spaces ".to_string()), | |
| &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ None), | |
| ) | |
| .await; | |
| assert_eq!(git_ref, "feature/spaces"); | |
| } | |
| async fn prefers_current_branch_when_available() { | |
| let git_ref = resolve_git_ref_with_git_info( | |
| /*branch_override*/ None, | |
| &StubGitInfo::new( | |
| Some("default-main".to_string()), | |
| Some("feature/current".to_string()), | |
| ), | |
| ) | |
| .await; | |
| assert_eq!(git_ref, "feature/current"); | |
| } | |
| async fn falls_back_to_current_branch_when_default_is_missing() { | |
| let git_ref = resolve_git_ref_with_git_info( | |
| /*branch_override*/ None, | |
| &StubGitInfo::new(/*default_branch*/ None, Some("develop".to_string())), | |
| ) | |
| .await; | |
| assert_eq!(git_ref, "develop"); | |
| } | |
| async fn falls_back_to_main_when_no_git_info_is_available() { | |
| let git_ref = resolve_git_ref_with_git_info( | |
| /*branch_override*/ None, | |
| &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ None), | |
| ) | |
| .await; | |
| assert_eq!(git_ref, "main"); | |
| } | |
| 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, /*colorize*/ false); | |
| assert_eq!( | |
| lines, | |
| vec![ | |
| "[READY] Example task".to_string(), | |
| "Env • 0s ago".to_string(), | |
| "+5/-2 • 3 files".to_string(), | |
| ] | |
| ); | |
| } | |
| 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, /*colorize*/ false); | |
| assert_eq!( | |
| lines, | |
| vec![ | |
| "[PENDING] No diff task".to_string(), | |
| "env-2 • 0s ago".to_string(), | |
| "no diff".to_string(), | |
| ] | |
| ); | |
| } | |
| 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, | |
| /*colorize*/ 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(), | |
| ] | |
| ); | |
| } | |
| 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()); | |
| } | |
| 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()); | |
| } | |
| 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()); | |
| } | |
| } | |