Codex Handbook
cloud-tasks/src/lib.rs 2402 lines
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_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,}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());    set_user_agent_suffix(user_agent_suffix);    #[cfg(debug_assertions)]    if use_mock {        return Ok(BackendContext {            backend: Arc::new(codex_cloud_tasks_mock_client::MockClient),            base_url,        });    }    let ua = get_codex_user_agent();    let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone())?.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_manager = util::load_auth_manager(Some(base_url.clone())).await;    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,    })}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(&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}// 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, .. } = init_backend("codex_cloud_tasks_tui").await?;    let backend = backend;    // 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.    {        let tx = tx.clone();        tokio::spawn(async move {            let base_url = util::normalize_base_url(                &std::env::var("CODEX_CLOUD_TASKS_BASE_URL")                    .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),            );            let headers = util::build_chatgpt_headers().await;            let res = crate::env_detect::list_environments(&base_url, &headers).await;            let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));        });    }    // 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();        tokio::spawn(async move {            let base_url = util::normalize_base_url(                &std::env::var("CODEX_CLOUD_TASKS_BASE_URL")                    .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),            );            // 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(                &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 {            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() {                    if page.composer.flush_paste_burst_if_due() { needs_redraw = true; }                    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;                                    {                                        let tx = tx.clone();                                        tokio::spawn(async move {                                            let base_url = crate::util::normalize_base_url(                                                &std::env::var("CODEX_CLOUD_TASKS_BASE_URL")                                                    .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),                                            );                                            let headers = crate::util::build_chatgpt_headers().await;                                            let res = crate::env_detect::list_environments(&base_url, &headers).await;                                            let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));                                        });                                    }                                    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 {                                    let tx = tx.clone();                                    tokio::spawn(async move {            let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()));            let headers = crate::util::build_chatgpt_headers().await;                                        let res = crate::env_detect::list_environments(&base_url, &headers).await;                                        let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));                                    });                            }                            // 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() {                                        let tx = tx.clone();                                        tokio::spawn(async move {                                            let base_url = crate::util::normalize_base_url(                                                &std::env::var("CODEX_CLOUD_TASKS_BASE_URL")                                                    .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),                                            );                                            let headers = crate::util::build_chatgpt_headers().await;                                            let res = crate::env_detect::list_environments(&base_url, &headers).await;                                            let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));                                        });                                    }                                }                                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 {                                    let tx = tx.clone();                                    tokio::spawn(async move {                                        let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()));                                        let headers = crate::util::build_chatgpt_headers().await;                                        let res = crate::env_detect::list_environments(&base_url, &headers).await;                                        let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));                                    });                                    }                                }                                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(())}// 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}#[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 codex_tui::ComposerAction;    use codex_tui::ComposerInput;    use crossterm::event::KeyCode;    use crossterm::event::KeyEvent;    use crossterm::event::KeyModifiers;    use pretty_assertions::assert_eq;    use ratatui::buffer::Buffer;    use ratatui::layout::Rect;    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(/*default_branch*/ None, /*current_branch*/ 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(/*default_branch*/ None, /*current_branch*/ 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(            /*branch_override*/ 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(            /*branch_override*/ None,            &StubGitInfo::new(/*default_branch*/ 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(            /*branch_override*/ None,            &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ 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, /*colorize*/ 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, /*colorize*/ 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,            /*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(),            ]        );    }    #[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());    }    #[test]    #[ignore = "very slow"]    fn composer_input_renders_typed_characters() {        let mut composer = ComposerInput::new();        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);        match composer.input(key) {            ComposerAction::Submitted(_) => panic!("unexpected submission"),            ComposerAction::None => {}        }        let area = Rect::new(0, 0, 20, 5);        let mut buf = Buffer::empty(area);        composer.render_ref(area, &mut buf);        let found = buf.content().iter().any(|cell| cell.symbol() == "a");        assert!(found, "typed character was not rendered: {buf:?}");        composer.set_hint_items(vec![("⌃O", "env"), ("⌃C", "quit")]);        composer.render_ref(area, &mut buf);        let footer = buf            .content()            .iter()            .skip((area.width as usize) * (area.height as usize - 1))            .map(ratatui::buffer::Cell::symbol)            .collect::<Vec<_>>()            .join("");        assert!(footer.contains("⌃O env"));    }}