mod app; mod cli; pub mod env_detect; mod new_task; pub mod scrollable_diff; mod ui; pub mod util; pub use cli::Cli; use std::io::IsTerminal; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; use tokio::sync::mpsc::UnboundedSender; use tracing::info; use tracing_subscriber::EnvFilter; use util::append_error_log; use util::set_user_agent_suffix; struct ApplyJob { task_id: codex_cloud_tasks_client::TaskId, diff_override: Option, } 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, tx: &UnboundedSender, frame_tx: &UnboundedSender, 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, tx: &UnboundedSender, frame_tx: &UnboundedSender, 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) -> anyhow::Result<()> { // 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"); set_user_agent_suffix("codex_cloud_tasks_tui"); // Default to online unless explicitly configured to use mock. let use_mock = matches!( std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), Some("mock") | Some("MOCK") ); let backend: Arc = if use_mock { Arc::new(codex_cloud_tasks_client::MockClient) } else { // Build an HTTP client against the configured (or default) base URL. let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); let ua = codex_core::default_client::get_codex_user_agent(); let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone())?.with_user_agent(ua); // Log which base URL and path style we're going to use. let style = if base_url.contains("/backend-api") { "wham" } else { "codex-api" }; append_error_log(format!("startup: base_url={base_url} path_style={style}")); // Require ChatGPT login (SWIC). Exit with a clear message if missing. let _token = match codex_core::config::find_codex_home() .ok() .map(|home| codex_login::AuthManager::new(home, false)) .and_then(|am| am.auth()) { Some(auth) => { // Log account context for debugging workspace selection. if let Some(acc) = auth.get_account_id() { append_error_log(format!("auth: mode=ChatGPT account_id={acc}")); } match auth.get_token().await { Ok(t) if !t.is_empty() => { // Attach token and ChatGPT-Account-Id header if available http = http.with_bearer_token(t.clone()); if let Some(acc) = auth .get_account_id() .or_else(|| util::extract_chatgpt_account_id(&t)) { append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); http = http.with_chatgpt_account_id(acc); } t } _ => { eprintln!( "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." ); std::process::exit(1); } } } None => { eprintln!( "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." ); std::process::exit(1); } }; Arc::new(http) }; // 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, codex_core::default_client::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::(); // 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, 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, 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::(); 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 = 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>, 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()); } } // Advance throbber only while loading. if app.refresh_inflight || app.details_inflight || app.env_loading || app.apply_preflight_inflight || app.apply_inflight { app.throbber.calc_next(); needs_redraw = true; let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); } 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(|| "".to_string()), app.env_filter.clone().unwrap_or_else(|| "".to_string()) )); continue; } app.refresh_inflight = false; match result { Ok(tasks) => { append_error_log(format!( "refresh.apply: env={} count={}", env.clone().unwrap_or_else(|| "".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(|| "".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 = diff.lines().map(str::to_string).collect(); if let Some(ov) = app.diff_overlay.as_mut() { ov.title = title; { let base = ov.base_attempt_mut(); base.diff_lines = diff_lines.clone(); base.diff_raw = Some(diff.clone()); } ov.base_can_apply = true; ov.apply_selection_to_fields(); } else { let mut overlay = app::DiffOverlay::new(id.clone(), title, None); { let base = overlay.base_attempt_mut(); base.diff_lines = diff_lines.clone(); base.diff_raw = Some(diff.clone()); } overlay.base_can_apply = true; overlay.current_view = app::DetailView::Diff; overlay.apply_selection_to_fields(); app.diff_overlay = Some(overlay); } app.details_inflight = false; app.status.clear(); needs_redraw = true; } app::AppEvent::DetailsMessagesLoaded { id, title, messages, prompt, turn_id, sibling_turn_ids, attempt_placement, attempt_status, } => { if let Some(ov) = &app.diff_overlay && ov.task_id != id { continue; } let conv = conversation_lines(prompt.clone(), &messages); if let Some(ov) = app.diff_overlay.as_mut() { ov.title = title.clone(); { let base = ov.base_attempt_mut(); base.text_lines = conv.clone(); base.prompt = prompt.clone(); base.turn_id = turn_id.clone(); base.status = attempt_status; base.attempt_placement = attempt_placement; } ov.base_turn_id = turn_id.clone(); ov.sibling_turn_ids = sibling_turn_ids.clone(); ov.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1)); if !ov.base_can_apply { ov.current_view = app::DetailView::Prompt; } ov.apply_selection_to_fields(); if let (Some(turn_id), true) = (turn_id.clone(), !sibling_turn_ids.is_empty()) && ov.attempts.len() == 1 { let backend = Arc::clone(&backend); let tx = tx.clone(); let task_id = id.clone(); tokio::spawn(async move { match codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( &*backend, task_id.clone(), turn_id, ) .await { Ok(attempts) => { let _ = tx.send(app::AppEvent::AttemptsLoaded { id: task_id, attempts }); } Err(e) => { crate::util::append_error_log(format!( "attempts.load failed for {}: {e}", task_id.0 )); } } }); } } else { let mut overlay = app::DiffOverlay::new(id.clone(), title, None); { let base = overlay.base_attempt_mut(); base.text_lines = conv.clone(); base.prompt = prompt.clone(); base.turn_id = turn_id.clone(); base.status = attempt_status; base.attempt_placement = attempt_placement; } overlay.base_turn_id = turn_id.clone(); overlay.sibling_turn_ids = sibling_turn_ids.clone(); overlay.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1)); overlay.current_view = app::DetailView::Prompt; overlay.apply_selection_to_fields(); app.diff_overlay = Some(overlay); } app.details_inflight = false; app.status.clear(); needs_redraw = true; } app::AppEvent::AttemptsLoaded { id, attempts } => { if let Some(ov) = app.diff_overlay.as_mut() { if ov.task_id != id { continue; } for attempt in attempts { if ov .attempts .iter() .any(|existing| existing.turn_id.as_deref() == Some(attempt.turn_id.as_str())) { continue; } let diff_lines = attempt .diff .as_ref() .map(|d| d.lines().map(str::to_string).collect()) .unwrap_or_default(); let text_lines = conversation_lines(None, &attempt.messages); ov.attempts.push(app::AttemptView { turn_id: Some(attempt.turn_id.clone()), status: attempt.status, attempt_placement: attempt.attempt_placement, diff_lines, text_lines, prompt: None, diff_raw: attempt.diff.clone(), }); } if ov.attempts.len() > 1 { let (_, rest) = ov.attempts.split_at_mut(1); rest.sort_by(|a, b| match (a.attempt_placement, b.attempt_placement) { (Some(lhs), Some(rhs)) => lhs.cmp(&rhs), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => a.turn_id.cmp(&b.turn_id), }); } if ov.selected_attempt >= ov.attempts.len() { ov.selected_attempt = ov.attempts.len().saturating_sub(1); } ov.attempt_total_hint = Some(ov.attempts.len()); ov.apply_selection_to_fields(); needs_redraw = true; } } app::AppEvent::DetailsFailed { id, title, error } => { if let Some(ov) = &app.diff_overlay && ov.task_id != id { continue; } append_error_log(format!("details failed for {}: {error}", id.0)); let pretty = pretty_lines_from_error(&error); if let Some(ov) = app.diff_overlay.as_mut() { ov.title = title.clone(); { let base = ov.base_attempt_mut(); base.diff_lines.clear(); base.text_lines = pretty.clone(); base.prompt = None; } ov.base_can_apply = false; ov.current_view = app::DetailView::Prompt; ov.apply_selection_to_fields(); } else { let mut overlay = app::DiffOverlay::new(id.clone(), title, None); { let base = overlay.base_attempt_mut(); base.text_lines = pretty; } overlay.base_can_apply = false; overlay.current_view = app::DetailView::Prompt; overlay.apply_selection_to_fields(); app.diff_overlay = Some(overlay); } app.details_inflight = false; needs_redraw = true; } app::AppEvent::ApplyFinished { id, result } => { // 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 until user explicitly refreshes with 'r' inside the modal. 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 result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, "main", 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 (press 'e' to choose)".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.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.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.to_top(); needs_redraw = true; } } } KeyCode::Char(']') | KeyCode::Char('}') => { cycle_attempt(1); } KeyCode::Char('[') | KeyCode::Char('{') => { cycle_attempt(-1); } KeyCode::Esc | KeyCode::Char('q') => { app.diff_overlay = None; needs_redraw = true; } KeyCode::Down | KeyCode::Char('j') => { if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(1); } needs_redraw = true; } KeyCode::Up | KeyCode::Char('k') => { if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(-1); } needs_redraw = true; } KeyCode::PageDown | KeyCode::Char(' ') => { if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(step); } needs_redraw = true; } KeyCode::PageUp => { if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(-step); } needs_redraw = true; } KeyCode::Home => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_top(); } needs_redraw = true; } KeyCode::End => { if let Some(ov) = &mut app.diff_overlay { ov.sd.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('r') | KeyCode::Char('R') => { // Trigger refresh of environments app.env_loading = true; app.env_error = None; needs_redraw = true; let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); 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(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => { if let Some(m) = app.env_modal.as_mut() { m.query.push(ch); } needs_redraw = true; } KeyCode::Backspace => { if let Some(m) = app.env_modal.as_mut() { m.query.pop(); } needs_redraw = true; } KeyCode::Down | KeyCode::Char('j') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_add(1); } needs_redraw = true; } KeyCode::Up | KeyCode::Char('k') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_sub(1); } needs_redraw = true; } KeyCode::Home => { if let Some(m) = app.env_modal.as_mut() { m.selected = 0; } needs_redraw = true; } KeyCode::End => { if let Some(m) = app.env_modal.as_mut() { m.selected = app.environments.len(); } needs_redraw = true; } KeyCode::PageDown | KeyCode::Char(' ') => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_add(step); } needs_redraw = true; } KeyCode::PageUp => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_sub(step); } needs_redraw = true; } KeyCode::Char('n') => { if app.env_filter.is_none() { app.new_task = Some(crate::new_task::NewTaskPage::new(None, app.best_of_n)); } else { app.new_task = Some(crate::new_task::NewTaskPage::new(app.env_filter.clone(), app.best_of_n)); } app.status = "New Task: Enter to submit; Esc to cancel".to_string(); needs_redraw = true; } KeyCode::Enter => { // 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(|| "".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(|| "".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 until user explicitly refreshes with 'r' inside the modal. 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, messages: &[String]) -> Vec { let mut out: Vec = 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("".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 { let mut lines: Vec = 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::(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 codex_tui::ComposerAction; use codex_tui::ComposerInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; #[test] 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::>() .join(""); assert!(footer.contains("⌃O env")); } }