mirror of
https://github.com/openai/codex.git
synced 2026-04-30 19:32:04 +03:00
1332 lines
82 KiB
Rust
1332 lines
82 KiB
Rust
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||
|
||
mod app;
|
||
mod cli;
|
||
pub mod env_detect;
|
||
mod new_task;
|
||
pub mod scrollable_diff;
|
||
mod ui;
|
||
pub use cli::Cli;
|
||
|
||
use base64::Engine as _;
|
||
use chrono::Utc;
|
||
use std::fs::OpenOptions;
|
||
use std::io::IsTerminal;
|
||
use std::io::Write as _;
|
||
use std::path::PathBuf;
|
||
use std::time::Duration;
|
||
use tracing::info;
|
||
use tracing_subscriber::EnvFilter;
|
||
|
||
pub(crate) fn append_error_log(message: impl AsRef<str>) {
|
||
let ts = Utc::now().to_rfc3339();
|
||
if let Ok(mut f) = OpenOptions::new()
|
||
.create(true)
|
||
.append(true)
|
||
.open("error.log")
|
||
{
|
||
let _ = writeln!(f, "[{ts}] {}", message.as_ref());
|
||
}
|
||
}
|
||
|
||
/// Summarize a unified or codex-style patch into counts for UI display.
|
||
fn summarize_diff(patch: &str) -> codex_cloud_tasks_api::DiffSummary {
|
||
// Count +/- lines via simple prefix scan (skip headers)
|
||
let (mut adds, mut dels) = (0usize, 0usize);
|
||
for line in patch.lines() {
|
||
if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") {
|
||
continue;
|
||
}
|
||
match line.as_bytes().first().copied() {
|
||
Some(b'+') => adds += 1,
|
||
Some(b'-') => dels += 1,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Count files: prefer codex patch file ops, else git diff headers, else infer 1 if any changes.
|
||
let mut files = 0usize;
|
||
for line in patch.lines() {
|
||
if line.starts_with("*** Add File:")
|
||
|| line.starts_with("*** Update File:")
|
||
|| line.starts_with("*** Delete File:")
|
||
{
|
||
files += 1;
|
||
}
|
||
}
|
||
if files == 0 {
|
||
files = patch
|
||
.lines()
|
||
.filter(|l| l.starts_with("diff --git "))
|
||
.count();
|
||
}
|
||
if files == 0 && (adds > 0 || dels > 0) {
|
||
files = 1;
|
||
}
|
||
|
||
codex_cloud_tasks_api::DiffSummary {
|
||
files_changed: files,
|
||
lines_added: adds,
|
||
lines_removed: dels,
|
||
}
|
||
}
|
||
|
||
/// Entry point for the `codex cloud-tasks` subcommand.
|
||
pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> 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");
|
||
|
||
// 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")
|
||
);
|
||
|
||
use std::sync::Arc;
|
||
let backend: Arc<dyn codex_cloud_tasks_api::CloudBackend> = 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(Some("codex_cloud_tasks_tui"));
|
||
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,
|
||
codex_login::AuthMode::ChatGPT,
|
||
"codex_cloud_tasks_tui".to_string(),
|
||
)
|
||
})
|
||
.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} plan={}",
|
||
auth.get_plan_type()
|
||
.unwrap_or_else(|| "<unknown>".to_string())
|
||
));
|
||
}
|
||
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(|| 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-tasks'."
|
||
);
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
}
|
||
None => {
|
||
eprintln!(
|
||
"Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud-tasks'."
|
||
);
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
Arc::new(http)
|
||
};
|
||
|
||
// Terminal setup
|
||
use crossterm::ExecutableCommand;
|
||
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)?;
|
||
// 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(Some("codex_cloud_tasks_tui"))
|
||
));
|
||
// 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();
|
||
app.no_diff_yet.clear();
|
||
|
||
// 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 backend2 = backend.clone();
|
||
let tx2 = tx.clone();
|
||
tokio::spawn(async move {
|
||
let res = app::load_tasks(&*backend2, None).await;
|
||
let _ = tx2.send(app::AppEvent::TasksLoaded {
|
||
env: None,
|
||
result: res,
|
||
});
|
||
});
|
||
}
|
||
// Fetch environment list in parallel so the header can show friendly names quickly.
|
||
{
|
||
let tx2 = tx.clone();
|
||
tokio::spawn(async move {
|
||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||
while base_url.ends_with('/') {
|
||
base_url.pop();
|
||
}
|
||
if (base_url.starts_with("https://chatgpt.com")
|
||
|| base_url.starts_with("https://chat.openai.com"))
|
||
&& !base_url.contains("/backend-api")
|
||
{
|
||
base_url = format!("{}/backend-api", base_url);
|
||
}
|
||
let ua =
|
||
codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(
|
||
reqwest::header::USER_AGENT,
|
||
reqwest::header::HeaderValue::from_str(&ua)
|
||
.unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")),
|
||
);
|
||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||
let am = codex_login::AuthManager::new(
|
||
home,
|
||
codex_login::AuthMode::ChatGPT,
|
||
"codex_cloud_tasks_tui".to_string(),
|
||
);
|
||
if let Some(auth) = am.auth() {
|
||
if let Ok(tok) = auth.get_token().await {
|
||
if !tok.is_empty() {
|
||
let v = format!("Bearer {}", tok);
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) {
|
||
headers.insert(reqwest::header::AUTHORIZATION, hv);
|
||
}
|
||
if let Some(acc) = auth
|
||
.get_account_id()
|
||
.or_else(|| extract_chatgpt_account_id(&tok))
|
||
{
|
||
if let Ok(name) =
|
||
reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||
{
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) {
|
||
headers.insert(name, hv);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into())));
|
||
});
|
||
}
|
||
|
||
// 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 tx2 = tx.clone();
|
||
tokio::spawn(async move {
|
||
// Normalize base URL like envcheck.rs does
|
||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||
while base_url.ends_with('/') {
|
||
base_url.pop();
|
||
}
|
||
if (base_url.starts_with("https://chatgpt.com")
|
||
|| base_url.starts_with("https://chat.openai.com"))
|
||
&& !base_url.contains("/backend-api")
|
||
{
|
||
base_url = format!("{}/backend-api", base_url);
|
||
}
|
||
|
||
// Build headers: UA + ChatGPT auth if available
|
||
let ua =
|
||
codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(
|
||
reqwest::header::USER_AGENT,
|
||
reqwest::header::HeaderValue::from_str(&ua)
|
||
.unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")),
|
||
);
|
||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||
let am = codex_login::AuthManager::new(
|
||
home,
|
||
codex_login::AuthMode::ChatGPT,
|
||
"codex_cloud_tasks_tui".to_string(),
|
||
);
|
||
if let Some(auth) = am.auth() {
|
||
if let Ok(token) = auth.get_token().await {
|
||
if !token.is_empty() {
|
||
if let Ok(hv) =
|
||
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))
|
||
{
|
||
headers.insert(reqwest::header::AUTHORIZATION, hv);
|
||
}
|
||
if let Some(account_id) = auth
|
||
.get_account_id()
|
||
.or_else(|| extract_chatgpt_account_id(&token))
|
||
{
|
||
if let Ok(name) =
|
||
reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||
{
|
||
if let Ok(hv) =
|
||
reqwest::header::HeaderValue::from_str(&account_id)
|
||
{
|
||
headers.insert(name, hv);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Run autodetect. If it fails, we keep using "All".
|
||
let res = crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await;
|
||
let _ = tx2.send(app::AppEvent::EnvironmentAutodetected(
|
||
res.map_err(|e| e.into()),
|
||
));
|
||
});
|
||
}
|
||
|
||
// 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.map_or(true, |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 mut 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());
|
||
}
|
||
}
|
||
// Advance throbber only while loading.
|
||
if app.refresh_inflight || app.details_inflight || app.env_loading || app.apply_preflight_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(|| "<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 backend2 = backend.clone();
|
||
let tx2 = tx.clone();
|
||
let env_sel = app.env_filter.clone();
|
||
tokio::spawn(async move {
|
||
let res = app::load_tasks(&*backend2, env_sel.as_deref()).await;
|
||
let _ = tx2.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: {}. See error.log for details.", msg);
|
||
needs_redraw = true;
|
||
let _ = frame_tx.send(Instant::now());
|
||
}
|
||
}
|
||
}
|
||
app::AppEvent::TaskSummaryUpdated { generation, id, summary, no_diff_yet, environment_id: _ } => {
|
||
// Ignore stale generations
|
||
if generation != app.list_generation { continue; }
|
||
let id_str = id.0.clone();
|
||
if let Some(t) = app.tasks.iter_mut().find(|t| t.id.0 == id_str) {
|
||
t.summary = summary.clone();
|
||
}
|
||
app.summary_cache.insert(id_str.clone(), (summary, std::time::Instant::now()));
|
||
if no_diff_yet { app.no_diff_yet.insert(id_str); } else { app.no_diff_yet.remove(&id.0); }
|
||
needs_redraw = true;
|
||
let _ = frame_tx.send(Instant::now());
|
||
}
|
||
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() {
|
||
if 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();
|
||
app.no_diff_yet.clear();
|
||
needs_redraw = true;
|
||
let backend2 = backend.clone();
|
||
let tx2 = tx.clone();
|
||
let env_sel = app.env_filter.clone();
|
||
tokio::spawn(async move {
|
||
let res = app::load_tasks(&*backend2, env_sel.as_deref()).await;
|
||
let _ = tx2.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 tx3 = tx.clone();
|
||
tokio::spawn(async move {
|
||
// Build headers (UA + ChatGPT token + account id) like elsewhere
|
||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||
while base_url.ends_with('/') { base_url.pop(); }
|
||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") {
|
||
base_url = format!("{}/backend-api", base_url);
|
||
}
|
||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||
let am = codex_login::AuthManager::new(
|
||
home,
|
||
codex_login::AuthMode::ChatGPT,
|
||
"codex_cloud_tasks_tui".to_string(),
|
||
);
|
||
if let Some(auth) = am.auth() {
|
||
if let Ok(tok) = auth.get_token().await { if !tok.is_empty() {
|
||
let v = format!("Bearer {}", tok);
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) {
|
||
if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") {
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); }
|
||
}
|
||
}
|
||
}}
|
||
}
|
||
}
|
||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||
let _ = tx3.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into())));
|
||
});
|
||
let _ = frame_tx.send(Instant::now());
|
||
}
|
||
}
|
||
// on Err, silently continue with All
|
||
}
|
||
app::AppEvent::DetailsDiffLoaded { id, title, diff } => {
|
||
// Only update if the overlay still corresponds to this id.
|
||
if let Some(ov) = &app.diff_overlay { if ov.task_id != id { continue; } }
|
||
let mut sd = crate::scrollable_diff::ScrollableDiff::new();
|
||
sd.set_content(diff.lines().map(|s| s.to_string()).collect());
|
||
app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: true });
|
||
app.details_inflight = false;
|
||
app.status.clear();
|
||
needs_redraw = true;
|
||
}
|
||
app::AppEvent::DetailsMessagesLoaded { id, title, messages } => {
|
||
if let Some(ov) = &app.diff_overlay { if ov.task_id != id { continue; } }
|
||
let mut lines = Vec::new();
|
||
for m in messages { lines.extend(m.lines().map(|s| s.to_string())); lines.push(String::new()); }
|
||
if lines.is_empty() { lines.push("<no output>".to_string()); }
|
||
let mut sd = crate::scrollable_diff::ScrollableDiff::new();
|
||
sd.set_content(lines);
|
||
app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: false });
|
||
app.details_inflight = false;
|
||
app.status.clear();
|
||
needs_redraw = true;
|
||
}
|
||
app::AppEvent::DetailsFailed { id, title, error } => {
|
||
if let Some(ov) = &app.diff_overlay { if ov.task_id != id { continue; } }
|
||
append_error_log(format!("details failed for {}: {}", id.0, error));
|
||
let pretty = pretty_lines_from_error(&error);
|
||
let mut sd = crate::scrollable_diff::ScrollableDiff::new();
|
||
sd.set_content(pretty);
|
||
app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: false });
|
||
app.details_inflight = false;
|
||
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::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.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;
|
||
}
|
||
// 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 tx2 = tx.clone();
|
||
tokio::spawn(async move {
|
||
// Build headers (UA + ChatGPT token + account id)
|
||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||
while base_url.ends_with('/') { base_url.pop(); }
|
||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") {
|
||
base_url = format!("{}/backend-api", base_url);
|
||
}
|
||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||
let am = codex_login::AuthManager::new(
|
||
home,
|
||
codex_login::AuthMode::ChatGPT,
|
||
"codex_cloud_tasks_tui".to_string(),
|
||
);
|
||
if let Some(auth) = am.auth() {
|
||
if let Ok(tok) = auth.get_token().await { if !tok.is_empty() {
|
||
let v = format!("Bearer {}", tok);
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) {
|
||
if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") {
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); }
|
||
}
|
||
}
|
||
}}
|
||
}
|
||
}
|
||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into())));
|
||
});
|
||
}
|
||
// 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 {
|
||
match page.composer.input(key) {
|
||
codex_tui::ComposerAction::Submitted(text) => {
|
||
// 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 tx2 = tx.clone();
|
||
let backend2 = backend.clone();
|
||
tokio::spawn(async move {
|
||
let result = codex_cloud_tasks_api::CloudBackend::create_task(&*backend2, &env, &text, "main", false).await;
|
||
let evt = match result {
|
||
Ok(ok) => app::AppEvent::NewTaskSubmitted(Ok(ok)),
|
||
Err(e) => app::AppEvent::NewTaskSubmitted(Err(format!("{}", e))),
|
||
};
|
||
let _ = tx2.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.take() {
|
||
app.status = format!("Applying '{}'...", m.title);
|
||
needs_redraw = true;
|
||
match codex_cloud_tasks_api::CloudBackend::apply_task(&*backend, m.task_id.clone()).await {
|
||
Ok(outcome) => {
|
||
app.status = outcome.message.clone();
|
||
if matches!(outcome.status, codex_cloud_tasks_api::ApplyStatus::Success) {
|
||
app.diff_overlay = None;
|
||
if let Ok(tasks) = app::load_tasks(&*backend, app.env_filter.as_deref()).await { app.tasks = tasks; }
|
||
}
|
||
}
|
||
Err(e) => {
|
||
append_error_log(format!("apply_task failed for {}: {e}", m.task_id.0));
|
||
app.status = format!("Apply failed: {e}");
|
||
}
|
||
}
|
||
needs_redraw = true;
|
||
}
|
||
}
|
||
KeyCode::Char('p') => {
|
||
if let Some(m) = app.apply_modal.take() {
|
||
// Kick off async preflight; show spinner in modal body
|
||
app.apply_preflight_inflight = true;
|
||
app.apply_modal = Some(app::ApplyModalState { task_id: m.task_id.clone(), title: m.title.clone(), result_message: None, result_level: None, skipped_paths: Vec::new(), conflict_paths: Vec::new() });
|
||
needs_redraw = true;
|
||
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
|
||
let backend2 = backend.clone();
|
||
let tx2 = tx.clone();
|
||
let id2 = m.task_id.clone();
|
||
let title2 = m.title.clone();
|
||
tokio::spawn(async move {
|
||
unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") };
|
||
let out = codex_cloud_tasks_api::CloudBackend::apply_task(&*backend2, id2.clone()).await;
|
||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||
let evt = match out {
|
||
Ok(outcome) => {
|
||
let level = match outcome.status {
|
||
codex_cloud_tasks_api::ApplyStatus::Success => app::ApplyResultLevel::Success,
|
||
codex_cloud_tasks_api::ApplyStatus::Partial => app::ApplyResultLevel::Partial,
|
||
codex_cloud_tasks_api::ApplyStatus::Error => app::ApplyResultLevel::Error,
|
||
};
|
||
app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: outcome.message, level, skipped: outcome.skipped_paths, conflicts: outcome.conflict_paths }
|
||
}
|
||
Err(e) => app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: format!("Preflight failed: {e}"), level: app::ApplyResultLevel::Error, skipped: Vec::new(), conflicts: Vec::new() },
|
||
};
|
||
let _ = tx2.send(evt);
|
||
});
|
||
}
|
||
}
|
||
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() {
|
||
match key.code {
|
||
KeyCode::Char('a') => {
|
||
if let Some(ov) = &app.diff_overlay {
|
||
if ov.can_apply {
|
||
app.apply_modal = Some(app::ApplyModalState { task_id: ov.task_id.clone(), title: ov.title.clone(), result_message: None, result_level: None, skipped_paths: Vec::new(), conflict_paths: Vec::new() });
|
||
app.apply_preflight_inflight = true;
|
||
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
|
||
let backend2 = backend.clone();
|
||
let tx2 = tx.clone();
|
||
let id2 = ov.task_id.clone();
|
||
let title2 = ov.title.clone();
|
||
tokio::spawn(async move {
|
||
unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") };
|
||
let out = codex_cloud_tasks_api::CloudBackend::apply_task(&*backend2, id2.clone()).await;
|
||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||
let evt = match out {
|
||
Ok(outcome) => {
|
||
let level = match outcome.status {
|
||
codex_cloud_tasks_api::ApplyStatus::Success => app::ApplyResultLevel::Success,
|
||
codex_cloud_tasks_api::ApplyStatus::Partial => app::ApplyResultLevel::Partial,
|
||
codex_cloud_tasks_api::ApplyStatus::Error => app::ApplyResultLevel::Error,
|
||
};
|
||
app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: outcome.message, level, skipped: outcome.skipped_paths, conflicts: outcome.conflict_paths }
|
||
}
|
||
Err(e) => app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: format!("Preflight failed: {e}"), level: app::ApplyResultLevel::Error, skipped: Vec::new(), conflicts: Vec::new() },
|
||
};
|
||
let _ = tx2.send(evt);
|
||
});
|
||
} else {
|
||
app.status = "No diff available to apply".to_string();
|
||
}
|
||
needs_redraw = true;
|
||
}
|
||
}
|
||
// 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 tx2 = tx.clone();
|
||
tokio::spawn(async move {
|
||
// Build headers (UA + ChatGPT token + account id)
|
||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||
while base_url.ends_with('/') { base_url.pop(); }
|
||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{}/backend-api", base_url); }
|
||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||
let am = codex_login::AuthManager::new(
|
||
home,
|
||
codex_login::AuthMode::ChatGPT,
|
||
"codex_cloud_tasks_tui".to_string(),
|
||
);
|
||
if let Some(auth) = am.auth() { if let Ok(tok) = auth.get_token().await { if !tok.is_empty() {
|
||
let v = format!("Bearer {}", tok);
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) {
|
||
if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") {
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); }
|
||
}
|
||
}
|
||
}}}
|
||
}
|
||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into())));
|
||
});
|
||
}
|
||
}
|
||
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 tx2 = tx.clone();
|
||
tokio::spawn(async move {
|
||
// Build headers (UA + ChatGPT token + account id)
|
||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||
while base_url.ends_with('/') { base_url.pop(); }
|
||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{}/backend-api", base_url); }
|
||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||
let am = codex_login::AuthManager::new(
|
||
home,
|
||
codex_login::AuthMode::ChatGPT,
|
||
"codex_cloud_tasks_tui".to_string(),
|
||
);
|
||
if let Some(auth) = am.auth() {
|
||
if let Ok(tok) = auth.get_token().await { if !tok.is_empty() {
|
||
let v = format!("Bearer {}", tok);
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) {
|
||
if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") {
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); }
|
||
}
|
||
}
|
||
}}
|
||
}
|
||
}
|
||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into())));
|
||
});
|
||
}
|
||
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));
|
||
} else {
|
||
app.new_task = Some(crate::new_task::NewTaskPage::new(app.env_filter.clone()));
|
||
}
|
||
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 mut 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();
|
||
app.no_diff_yet.clear();
|
||
needs_redraw = true;
|
||
let backend2 = backend.clone();
|
||
let tx2 = tx.clone();
|
||
let env_sel = app.env_filter.clone();
|
||
tokio::spawn(async move {
|
||
let res = app::load_tasks(&*backend2, env_sel.as_deref()).await;
|
||
let _ = tx2.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();
|
||
app.no_diff_yet.clear();
|
||
needs_redraw = true;
|
||
// Spawn background refresh
|
||
let backend2 = backend.clone();
|
||
let tx2 = tx.clone();
|
||
let env_sel = app.env_filter.clone();
|
||
tokio::spawn(async move {
|
||
let res = app::load_tasks(&*backend2, env_sel.as_deref()).await;
|
||
let _ = tx2.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 tx2 = tx.clone();
|
||
tokio::spawn(async move {
|
||
// Build headers (UA + ChatGPT token + account id)
|
||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||
while base_url.ends_with('/') { base_url.pop(); }
|
||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{}/backend-api", base_url); }
|
||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||
let am = codex_login::AuthManager::new(
|
||
home,
|
||
codex_login::AuthMode::ChatGPT,
|
||
"codex_cloud_tasks_tui".to_string(),
|
||
);
|
||
if let Some(auth) = am.auth() {
|
||
if let Ok(tok) = auth.get_token().await { if !tok.is_empty() {
|
||
let v = format!("Bearer {}", tok);
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) {
|
||
if let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") {
|
||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); }
|
||
}
|
||
}
|
||
}}
|
||
}
|
||
}
|
||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res.map_err(|e| e.into())));
|
||
});
|
||
}
|
||
}
|
||
KeyCode::Char('n') => {
|
||
let env_opt = app.env_filter.clone();
|
||
app.new_task = Some(crate::new_task::NewTaskPage::new(env_opt));
|
||
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 {}…", task.title);
|
||
app.details_inflight = true;
|
||
// Open empty overlay immediately; content arrives via events
|
||
let mut sd = crate::scrollable_diff::ScrollableDiff::new();
|
||
sd.set_content(Vec::new());
|
||
app.diff_overlay = Some(app::DiffOverlay{ title: task.title.clone(), task_id: task.id.clone(), sd, can_apply: false });
|
||
needs_redraw = true;
|
||
// Spawn background details load (diff first, then messages fallback)
|
||
let backend2 = backend.clone();
|
||
let tx2 = tx.clone();
|
||
tokio::spawn(async move {
|
||
match codex_cloud_tasks_api::CloudBackend::get_task_diff(&*backend2, task.id.clone()).await {
|
||
Ok(diff) => {
|
||
let _ = tx2.send(app::AppEvent::DetailsDiffLoaded { id: task.id, title: task.title, diff });
|
||
}
|
||
Err(e) => {
|
||
// Always log errors while we debug non-success states.
|
||
append_error_log(format!("get_task_diff failed for {}: {e}", task.id.0));
|
||
match codex_cloud_tasks_api::CloudBackend::get_task_messages(&*backend2, task.id.clone()).await {
|
||
Ok(msgs) => {
|
||
let _ = tx2.send(app::AppEvent::DetailsMessagesLoaded { id: task.id, title: task.title, messages: msgs });
|
||
}
|
||
Err(e2) => {
|
||
let _ = tx2.send(app::AppEvent::DetailsFailed { id: task.id, title: task.title, error: format!("{}", e2) });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
// Animate spinner while details load.
|
||
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
|
||
}
|
||
}
|
||
KeyCode::Char('a') => {
|
||
if let Some(task) = app.tasks.get(app.selected) {
|
||
match codex_cloud_tasks_api::CloudBackend::get_task_diff(&*backend, task.id.clone()).await {
|
||
Ok(_) => {
|
||
app.apply_modal = Some(app::ApplyModalState { task_id: task.id.clone(), title: task.title.clone(), result_message: None, result_level: None, skipped_paths: Vec::new(), conflict_paths: Vec::new() });
|
||
app.apply_preflight_inflight = true;
|
||
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
|
||
let backend2 = backend.clone();
|
||
let tx2 = tx.clone();
|
||
let id2 = task.id.clone();
|
||
let title2 = task.title.clone();
|
||
tokio::spawn(async move {
|
||
unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") };
|
||
let out = codex_cloud_tasks_api::CloudBackend::apply_task(&*backend2, id2.clone()).await;
|
||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||
let evt = match out {
|
||
Ok(outcome) => {
|
||
let level = match outcome.status {
|
||
codex_cloud_tasks_api::ApplyStatus::Success => app::ApplyResultLevel::Success,
|
||
codex_cloud_tasks_api::ApplyStatus::Partial => app::ApplyResultLevel::Partial,
|
||
codex_cloud_tasks_api::ApplyStatus::Error => app::ApplyResultLevel::Error,
|
||
};
|
||
app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: outcome.message, level, skipped: outcome.skipped_paths, conflicts: outcome.conflict_paths }
|
||
}
|
||
Err(e) => app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: format!("Preflight failed: {e}"), level: app::ApplyResultLevel::Error, skipped: Vec::new(), conflicts: Vec::new() },
|
||
};
|
||
let _ = tx2.send(evt);
|
||
});
|
||
}
|
||
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();
|
||
// Best-effort restore of keyboard enhancement flags before leaving alt screen.
|
||
let _ = crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags);
|
||
let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen);
|
||
|
||
if exit_code != 0 {
|
||
std::process::exit(exit_code);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
|
||
// JWT: header.payload.signature
|
||
let mut parts = token.split('.');
|
||
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
|
||
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
|
||
_ => return None,
|
||
};
|
||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||
.decode(payload_b64)
|
||
.ok()?;
|
||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||
v.get("https://api.openai.com/auth")
|
||
.and_then(|auth| auth.get("chatgpt_account_id"))
|
||
.and_then(|id| id.as_str())
|
||
.map(|s| s.to_string())
|
||
}
|
||
|
||
/// 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=") {
|
||
if 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())
|
||
{
|
||
if !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
|
||
}
|