diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d809adfad..b77cf01b0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ "pretty_assertions", "regex-lite", "serde_json", - "supports-color", + "supports-color 3.0.2", "tempfile", "tokio", "toml", @@ -1088,10 +1088,13 @@ dependencies = [ "codex-login", "codex-tui", "crossterm", + "owo-colors", + "pretty_assertions", "ratatui", "reqwest", "serde", "serde_json", + "supports-color 3.0.2", "tokio", "tokio-stream", "tracing", @@ -1237,7 +1240,7 @@ dependencies = [ "serde", "serde_json", "shlex", - "supports-color", + "supports-color 3.0.2", "tempfile", "tokio", "tracing", @@ -1611,7 +1614,7 @@ dependencies = [ "shlex", "strum 0.27.2", "strum_macros 0.27.2", - "supports-color", + "supports-color 3.0.2", "tempfile", "textwrap 0.16.2", "tokio", @@ -4433,6 +4436,10 @@ name = "owo-colors" version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] [[package]] name = "parking" @@ -6168,6 +6175,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + [[package]] name = "supports-color" version = "3.0.2" diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs index 4bd12939e..cd8228bc2 100644 --- a/codex-rs/cloud-tasks-client/src/api.rs +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -127,6 +127,7 @@ impl Default for TaskText { #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn get_task_summary(&self, id: TaskId) -> Result; async fn get_task_diff(&self, id: TaskId) -> Result>; /// Return assistant output messages (no diff) when available. async fn get_task_messages(&self, id: TaskId) -> Result>; diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 57d39b7bd..f55d0fe79 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -63,6 +63,10 @@ impl CloudBackend for HttpClient { self.tasks_api().list(env).await } + async fn get_task_summary(&self, id: TaskId) -> Result { + self.tasks_api().summary(id).await + } + async fn get_task_diff(&self, id: TaskId) -> Result> { self.tasks_api().diff(id).await } @@ -149,6 +153,75 @@ mod api { Ok(tasks) } + pub(crate) async fn summary(&self, id: TaskId) -> Result { + let id_str = id.0.clone(); + let (details, body, ct) = self + .details_with_body(&id.0) + .await + .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; + let parsed: Value = serde_json::from_str(&body).map_err(|e| { + CloudTaskError::Http(format!( + "Decode error for {}: {e}; content-type={ct}; body={body}", + id.0 + )) + })?; + let task_obj = parsed + .get("task") + .and_then(Value::as_object) + .ok_or_else(|| { + CloudTaskError::Http(format!("Task metadata missing from details for {id_str}")) + })?; + let status_display = parsed + .get("task_status_display") + .or_else(|| task_obj.get("task_status_display")) + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }); + let status = map_status(status_display.as_ref()); + let mut summary = diff_summary_from_status_display(status_display.as_ref()); + if summary.files_changed == 0 + && summary.lines_added == 0 + && summary.lines_removed == 0 + && let Some(diff) = details.unified_diff() + { + summary = diff_summary_from_diff(&diff); + } + let updated_at_raw = task_obj + .get("updated_at") + .and_then(Value::as_f64) + .or_else(|| task_obj.get("created_at").and_then(Value::as_f64)) + .or_else(|| latest_turn_timestamp(status_display.as_ref())); + let environment_id = task_obj + .get("environment_id") + .and_then(Value::as_str) + .map(str::to_string); + let environment_label = env_label_from_status_display(status_display.as_ref()); + let attempt_total = attempt_total_from_status_display(status_display.as_ref()); + let title = task_obj + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let is_review = task_obj + .get("is_review") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(TaskSummary { + id, + title, + status, + updated_at: parse_updated_at(updated_at_raw.as_ref()), + environment_id, + environment_label, + summary, + is_review, + attempt_total, + }) + } + pub(crate) async fn diff(&self, id: TaskId) -> Result> { let (details, body, ct) = self .details_with_body(&id.0) @@ -679,6 +752,34 @@ mod api { .map(str::to_string) } + fn diff_summary_from_diff(diff: &str) -> DiffSummary { + let mut files_changed = 0usize; + let mut lines_added = 0usize; + let mut lines_removed = 0usize; + for line in diff.lines() { + if line.starts_with("diff --git ") { + files_changed += 1; + continue; + } + if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") { + continue; + } + match line.as_bytes().first() { + Some(b'+') => lines_added += 1, + Some(b'-') => lines_removed += 1, + _ => {} + } + } + if files_changed == 0 && !diff.trim().is_empty() { + files_changed = 1; + } + DiffSummary { + files_changed, + lines_added, + lines_removed, + } + } + fn diff_summary_from_status_display(v: Option<&HashMap>) -> DiffSummary { let mut out = DiffSummary::default(); let Some(map) = v else { return out }; @@ -700,6 +801,17 @@ mod api { out } + fn latest_turn_timestamp(v: Option<&HashMap>) -> Option { + let map = v?; + let latest = map + .get("latest_turn_status_display") + .and_then(Value::as_object)?; + latest + .get("updated_at") + .or_else(|| latest.get("created_at")) + .and_then(Value::as_f64) + } + fn attempt_total_from_status_display(v: Option<&HashMap>) -> Option { let map = v?; let latest = map diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs index 97bc5520a..2d03cea02 100644 --- a/codex-rs/cloud-tasks-client/src/mock.rs +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -1,6 +1,7 @@ use crate::ApplyOutcome; use crate::AttemptStatus; use crate::CloudBackend; +use crate::CloudTaskError; use crate::DiffSummary; use crate::Result; use crate::TaskId; @@ -60,6 +61,14 @@ impl CloudBackend for MockClient { Ok(out) } + async fn get_task_summary(&self, id: TaskId) -> Result { + let tasks = self.list_tasks(None).await?; + tasks + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found (mock)", id.0))) + } + async fn get_task_diff(&self, id: TaskId) -> Result> { Ok(Some(mock_diff_for(&id))) } diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index c9edf5b4a..188538bec 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -34,6 +34,9 @@ tokio-stream = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } unicode-width = { workspace = true } +owo-colors = { workspace = true, features = ["supports-colors"] } +supports-color = { workspace = true } [dev-dependencies] async-trait = { workspace = true } +pretty_assertions = { workspace = true } diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index 612c5f6be..ce12128a3 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -350,6 +350,7 @@ pub enum AppEvent { mod tests { use super::*; use chrono::Utc; + use codex_cloud_tasks_client::CloudTaskError; struct FakeBackend { // maps env key to titles @@ -385,6 +386,17 @@ mod tests { Ok(out) } + async fn get_task_summary( + &self, + id: TaskId, + ) -> codex_cloud_tasks_client::Result { + self.list_tasks(None) + .await? + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0))) + } + async fn get_task_diff( &self, _id: TaskId, diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs index 9c118038e..a7612153b 100644 --- a/codex-rs/cloud-tasks/src/cli.rs +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -16,6 +16,12 @@ pub struct Cli { pub enum Command { /// Submit a new Codex Cloud task without launching the TUI. Exec(ExecCommand), + /// Show the status of a Codex Cloud task. + Status(StatusCommand), + /// Apply the diff for a Codex Cloud task locally. + Apply(ApplyCommand), + /// Show the unified diff for a Codex Cloud task. + Diff(DiffCommand), } #[derive(Debug, Args)] @@ -51,3 +57,32 @@ fn parse_attempts(input: &str) -> Result { Err("attempts must be between 1 and 4".to_string()) } } + +#[derive(Debug, Args)] +pub struct StatusCommand { + /// Codex Cloud task identifier to inspect. + #[arg(value_name = "TASK_ID")] + pub task_id: String, +} + +#[derive(Debug, Args)] +pub struct ApplyCommand { + /// Codex Cloud task identifier to apply. + #[arg(value_name = "TASK_ID")] + pub task_id: String, + + /// Attempt number to apply (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, +} + +#[derive(Debug, Args)] +pub struct DiffCommand { + /// Codex Cloud task identifier to display. + #[arg(value_name = "TASK_ID")] + pub task_id: String, + + /// Attempt number to display (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, +} diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 1a3798f75..f73e07f3a 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -8,17 +8,24 @@ pub mod util; pub use cli::Cli; use anyhow::anyhow; +use chrono::Utc; +use codex_cloud_tasks_client::TaskStatus; use codex_login::AuthManager; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use std::cmp::Ordering; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; +use supports_color::Stream as SupportStream; use tokio::sync::mpsc::UnboundedSender; use tracing::info; use tracing_subscriber::EnvFilter; use util::append_error_log; +use util::format_relative_time; use util::set_user_agent_suffix; struct ApplyJob { @@ -193,6 +200,273 @@ fn resolve_query_input(query_arg: Option) -> anyhow::Result { } } +fn parse_task_id(raw: &str) -> anyhow::Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + anyhow::bail!("task id must not be empty"); + } + let without_fragment = trimmed.split('#').next().unwrap_or(trimmed); + let without_query = without_fragment + .split('?') + .next() + .unwrap_or(without_fragment); + let id = without_query + .rsplit('/') + .next() + .unwrap_or(without_query) + .trim(); + if id.is_empty() { + anyhow::bail!("task id must not be empty"); + } + Ok(codex_cloud_tasks_client::TaskId(id.to_string())) +} + +#[derive(Clone, Debug)] +struct AttemptDiffData { + placement: Option, + created_at: Option>, + diff: String, +} + +fn cmp_attempt(lhs: &AttemptDiffData, rhs: &AttemptDiffData) -> Ordering { + match (lhs.placement, rhs.placement) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => match (lhs.created_at, rhs.created_at) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + }, + } +} + +async fn collect_attempt_diffs( + backend: &dyn codex_cloud_tasks_client::CloudBackend, + task_id: &codex_cloud_tasks_client::TaskId, +) -> anyhow::Result> { + let text = + codex_cloud_tasks_client::CloudBackend::get_task_text(backend, task_id.clone()).await?; + let mut attempts = Vec::new(); + if let Some(diff) = + codex_cloud_tasks_client::CloudBackend::get_task_diff(backend, task_id.clone()).await? + { + attempts.push(AttemptDiffData { + placement: text.attempt_placement, + created_at: None, + diff, + }); + } + if let Some(turn_id) = text.turn_id { + let siblings = codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( + backend, + task_id.clone(), + turn_id, + ) + .await?; + for sibling in siblings { + if let Some(diff) = sibling.diff { + attempts.push(AttemptDiffData { + placement: sibling.attempt_placement, + created_at: sibling.created_at, + diff, + }); + } + } + } + attempts.sort_by(cmp_attempt); + if attempts.is_empty() { + anyhow::bail!( + "No diff available for task {}; it may still be running.", + task_id.0 + ); + } + Ok(attempts) +} + +fn select_attempt( + attempts: &[AttemptDiffData], + attempt: Option, +) -> anyhow::Result<&AttemptDiffData> { + if attempts.is_empty() { + anyhow::bail!("No attempts available"); + } + let desired = attempt.unwrap_or(1); + let idx = desired + .checked_sub(1) + .ok_or_else(|| anyhow!("attempt must be at least 1"))?; + if idx >= attempts.len() { + anyhow::bail!( + "Attempt {desired} not available; only {} attempt(s) found", + attempts.len() + ); + } + Ok(&attempts[idx]) +} + +fn task_status_label(status: &TaskStatus) -> &'static str { + match status { + TaskStatus::Pending => "PENDING", + TaskStatus::Ready => "READY", + TaskStatus::Applied => "APPLIED", + TaskStatus::Error => "ERROR", + } +} + +fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) -> String { + if summary.files_changed == 0 && summary.lines_added == 0 && summary.lines_removed == 0 { + let base = "no diff"; + return if colorize { + base.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + base.to_string() + }; + } + let adds = summary.lines_added; + let dels = summary.lines_removed; + let files = summary.files_changed; + if colorize { + let adds_raw = format!("+{adds}"); + let adds_str = adds_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(); + let dels_raw = format!("-{dels}"); + let dels_str = dels_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(); + let bullet = "•" + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + let file_label = "file" + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + let plural = if files == 1 { "" } else { "s" }; + format!("{adds_str}/{dels_str} {bullet} {files} {file_label}{plural}") + } else { + format!( + "+{adds}/-{dels} • {files} file{}", + if files == 1 { "" } else { "s" } + ) + } +} + +fn format_task_status_lines( + task: &codex_cloud_tasks_client::TaskSummary, + now: chrono::DateTime, + colorize: bool, +) -> Vec { + let mut lines = Vec::new(); + let status = task_status_label(&task.status); + let status = if colorize { + match task.status { + TaskStatus::Ready => status + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(), + TaskStatus::Pending => status + .if_supports_color(Stream::Stdout, |t| t.magenta()) + .to_string(), + TaskStatus::Applied => status + .if_supports_color(Stream::Stdout, |t| t.blue()) + .to_string(), + TaskStatus::Error => status + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(), + } + } else { + status.to_string() + }; + lines.push(format!("[{status}] {}", task.title)); + let mut meta_parts = Vec::new(); + if let Some(label) = task.environment_label.as_deref().filter(|s| !s.is_empty()) { + if colorize { + meta_parts.push( + label + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(label.to_string()); + } + } else if let Some(id) = task.environment_id.as_deref() { + if colorize { + meta_parts.push( + id.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(id.to_string()); + } + } + let when = format_relative_time(now, task.updated_at); + meta_parts.push(if colorize { + when.as_str() + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + when + }); + let sep = if colorize { + " • " + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + " • ".to_string() + }; + lines.push(meta_parts.join(&sep)); + lines.push(summary_line(&task.summary, colorize)); + lines +} + +async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_status").await?; + let task_id = parse_task_id(&args.task_id)?; + let summary = + codex_cloud_tasks_client::CloudBackend::get_task_summary(&*ctx.backend, task_id).await?; + let now = Utc::now(); + let colorize = supports_color::on(SupportStream::Stdout).is_some(); + for line in format_task_status_lines(&summary, now, colorize) { + println!("{line}"); + } + if !matches!(summary.status, TaskStatus::Ready) { + std::process::exit(1); + } + Ok(()) +} + +async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_diff").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + print!("{}", selected.diff); + Ok(()) +} + +async fn run_apply_command(args: crate::cli::ApplyCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_apply").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + let outcome = codex_cloud_tasks_client::CloudBackend::apply_task( + &*ctx.backend, + task_id, + Some(selected.diff.clone()), + ) + .await?; + println!("{}", outcome.message); + if !matches!( + outcome.status, + codex_cloud_tasks_client::ApplyStatus::Success + ) { + std::process::exit(1); + } + Ok(()) +} + fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { match status { codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, @@ -322,6 +596,9 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an if let Some(command) = cli.command { return match command { crate::cli::Command::Exec(args) => run_exec_command(args).await, + crate::cli::Command::Status(args) => run_status_command(args).await, + crate::cli::Command::Apply(args) => run_apply_command(args).await, + crate::cli::Command::Diff(args) => run_diff_command(args).await, }; } let Cli { .. } = cli; @@ -1713,14 +1990,111 @@ fn pretty_lines_from_error(raw: &str) -> Vec { #[cfg(test)] mod tests { + use super::*; + use codex_cloud_tasks_client::DiffSummary; + use codex_cloud_tasks_client::MockClient; + use codex_cloud_tasks_client::TaskId; + use codex_cloud_tasks_client::TaskStatus; + use codex_cloud_tasks_client::TaskSummary; use codex_tui::ComposerAction; use codex_tui::ComposerInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + #[test] + fn format_task_status_lines_with_diff_and_label() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_1".to_string()), + title: "Example task".to_string(), + status: TaskStatus::Ready, + updated_at: now, + environment_id: Some("env-1".to_string()), + environment_label: Some("Env".to_string()), + summary: DiffSummary { + files_changed: 3, + lines_added: 5, + lines_removed: 2, + }, + is_review: false, + attempt_total: None, + }; + let lines = format_task_status_lines(&task, now, false); + assert_eq!( + lines, + vec![ + "[READY] Example task".to_string(), + "Env • 0s ago".to_string(), + "+5/-2 • 3 files".to_string(), + ] + ); + } + + #[test] + fn format_task_status_lines_without_diff_falls_back() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_2".to_string()), + title: "No diff task".to_string(), + status: TaskStatus::Pending, + updated_at: now, + environment_id: Some("env-2".to_string()), + environment_label: None, + summary: DiffSummary::default(), + is_review: false, + attempt_total: Some(1), + }; + let lines = format_task_status_lines(&task, now, false); + assert_eq!( + lines, + vec![ + "[PENDING] No diff task".to_string(), + "env-2 • 0s ago".to_string(), + "no diff".to_string(), + ] + ); + } + + #[tokio::test] + async fn collect_attempt_diffs_includes_sibling_attempts() { + let backend = MockClient; + let task_id = parse_task_id("https://chatgpt.com/codex/tasks/T-1000").expect("id"); + let attempts = collect_attempt_diffs(&backend, &task_id) + .await + .expect("attempts"); + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0].placement, Some(0)); + assert_eq!(attempts[1].placement, Some(1)); + assert!(!attempts[0].diff.is_empty()); + assert!(!attempts[1].diff.is_empty()); + } + + #[test] + fn select_attempt_validates_bounds() { + let attempts = vec![AttemptDiffData { + placement: Some(0), + created_at: None, + diff: "diff --git a/file b/file\n".to_string(), + }]; + let first = select_attempt(&attempts, Some(1)).expect("attempt 1"); + assert_eq!(first.diff, "diff --git a/file b/file\n"); + assert!(select_attempt(&attempts, Some(2)).is_err()); + } + + #[test] + fn parse_task_id_from_url_and_raw() { + let raw = parse_task_id("task_i_abc123").expect("raw id"); + assert_eq!(raw.0, "task_i_abc123"); + let url = + parse_task_id("https://chatgpt.com/codex/tasks/task_i_123456?foo=bar").expect("url id"); + assert_eq!(url.0, "task_i_123456"); + assert!(parse_task_id(" ").is_err()); + } + #[test] #[ignore = "very slow"] fn composer_input_renders_typed_characters() { diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index e3a97aeb3..4c41ca576 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -20,8 +20,7 @@ use std::time::Instant; use crate::app::App; use crate::app::AttemptView; -use chrono::Local; -use chrono::Utc; +use crate::util::format_relative_time_now; use codex_cloud_tasks_client::AttemptStatus; use codex_cloud_tasks_client::TaskStatus; use codex_tui::render_markdown_text; @@ -804,7 +803,7 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) { meta.push(lbl.clone().dim()); } - let when = format_relative_time(t.updated_at).dim(); + let when = format_relative_time_now(t.updated_at).dim(); if !meta.is_empty() { meta.push(" ".into()); meta.push("•".dim()); @@ -841,27 +840,6 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li ListItem::new(vec![title, meta_line, sub, spacer]) } -fn format_relative_time(ts: chrono::DateTime) -> String { - let now = Utc::now(); - let mut secs = (now - ts).num_seconds(); - if secs < 0 { - secs = 0; - } - if secs < 60 { - return format!("{secs}s ago"); - } - let mins = secs / 60; - if mins < 60 { - return format!("{mins}m ago"); - } - let hours = mins / 60; - if hours < 24 { - return format!("{hours}h ago"); - } - let local = ts.with_timezone(&Local); - local.format("%b %e %H:%M").to_string() -} - fn draw_inline_spinner( frame: &mut Frame, area: Rect, diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 1c690b26c..79513dbcf 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -1,4 +1,6 @@ use base64::Engine as _; +use chrono::DateTime; +use chrono::Local; use chrono::Utc; use reqwest::header::HeaderMap; @@ -120,3 +122,27 @@ pub fn task_url(base_url: &str, task_id: &str) -> String { } format!("{normalized}/codex/tasks/{task_id}") } + +pub fn format_relative_time(reference: DateTime, ts: DateTime) -> String { + let mut secs = (reference - ts).num_seconds(); + if secs < 0 { + secs = 0; + } + if secs < 60 { + return format!("{secs}s ago"); + } + let mins = secs / 60; + if mins < 60 { + return format!("{mins}m ago"); + } + let hours = mins / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let local = ts.with_timezone(&Local); + local.format("%b %e %H:%M").to_string() +} + +pub fn format_relative_time_now(ts: DateTime) -> String { + format_relative_time(Utc::now(), ts) +}