diff --git a/codex-cli/Dockerfile b/codex-cli/Dockerfile index 21a90a483..8cdb3dc90 100644 --- a/codex-cli/Dockerfile +++ b/codex-cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24-slim +FROM node:25-slim ARG TZ ENV TZ="$TZ" diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d6a13a3d4..35c07d27d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -402,7 +402,7 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "app_test_support" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -1373,7 +1373,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codex-ansi-escape" -version = "0.0.0" +version = "0.116.0" dependencies = [ "ansi-to-tui", "ratatui", @@ -1382,7 +1382,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "assert_matches", @@ -1412,7 +1412,7 @@ dependencies = [ [[package]] name = "codex-app-server" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "app_test_support", @@ -1468,7 +1468,7 @@ dependencies = [ [[package]] name = "codex-app-server-client" -version = "0.0.0" +version = "0.116.0" dependencies = [ "codex-app-server", "codex-app-server-protocol", @@ -1489,7 +1489,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "clap", @@ -1516,7 +1516,7 @@ dependencies = [ [[package]] name = "codex-app-server-test-client" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "clap", @@ -1537,7 +1537,7 @@ dependencies = [ [[package]] name = "codex-apply-patch" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "assert_cmd", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "codex-apply-patch", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "codex-artifacts" -version = "0.0.0" +version = "0.116.0" dependencies = [ "codex-package-manager", "flate2", @@ -1588,7 +1588,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.0.0" +version = "0.116.0" dependencies = [ "async-trait", "pretty_assertions", @@ -1598,7 +1598,7 @@ dependencies = [ [[package]] name = "codex-backend-client" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "codex-backend-openapi-models", @@ -1613,7 +1613,7 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.0.0" +version = "0.116.0" dependencies = [ "serde", "serde_json", @@ -1622,7 +1622,7 @@ dependencies = [ [[package]] name = "codex-chatgpt" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "clap", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "codex-cli" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "assert_cmd", @@ -1687,7 +1687,7 @@ dependencies = [ [[package]] name = "codex-client" -version = "0.0.0" +version = "0.116.0" dependencies = [ "async-trait", "bytes", @@ -1700,6 +1700,7 @@ dependencies = [ "opentelemetry_sdk", "pretty_assertions", "rand 0.9.2", + "regex-lite", "reqwest", "rustls", "rustls-native-certs", @@ -1717,7 +1718,7 @@ dependencies = [ [[package]] name = "codex-cloud-requirements" -version = "0.0.0" +version = "0.116.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -1740,7 +1741,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "async-trait", @@ -1770,7 +1771,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "async-trait", @@ -1785,7 +1786,7 @@ dependencies = [ [[package]] name = "codex-config" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -1808,7 +1809,7 @@ dependencies = [ [[package]] name = "codex-connectors" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -1820,7 +1821,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "arc-swap", @@ -1937,7 +1938,7 @@ dependencies = [ [[package]] name = "codex-debug-client" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "clap", @@ -1949,7 +1950,7 @@ dependencies = [ [[package]] name = "codex-environment" -version = "0.0.0" +version = "0.116.0" dependencies = [ "async-trait", "codex-utils-absolute-path", @@ -1960,7 +1961,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "assert_cmd", @@ -2005,7 +2006,7 @@ dependencies = [ [[package]] name = "codex-execpolicy" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "clap", @@ -2022,7 +2023,7 @@ dependencies = [ [[package]] name = "codex-execpolicy-legacy" -version = "0.0.0" +version = "0.116.0" dependencies = [ "allocative", "anyhow", @@ -2042,7 +2043,7 @@ dependencies = [ [[package]] name = "codex-experimental-api-macros" -version = "0.0.0" +version = "0.116.0" dependencies = [ "proc-macro2", "quote", @@ -2051,7 +2052,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "codex-protocol", @@ -2063,7 +2064,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "clap", @@ -2079,7 +2080,7 @@ dependencies = [ [[package]] name = "codex-git" -version = "0.0.0" +version = "0.116.0" dependencies = [ "assert_matches", "once_cell", @@ -2095,7 +2096,7 @@ dependencies = [ [[package]] name = "codex-hooks" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "chrono", @@ -2113,7 +2114,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.116.0" dependencies = [ "keyring", "tracing", @@ -2121,7 +2122,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.0.0" +version = "0.116.0" dependencies = [ "cc", "clap", @@ -2142,7 +2143,7 @@ dependencies = [ [[package]] name = "codex-lmstudio" -version = "0.0.0" +version = "0.116.0" dependencies = [ "codex-core", "reqwest", @@ -2155,7 +2156,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -2182,7 +2183,7 @@ dependencies = [ [[package]] name = "codex-mcp-server" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "codex-arg0", @@ -2209,7 +2210,7 @@ dependencies = [ [[package]] name = "codex-network-proxy" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "async-trait", @@ -2240,7 +2241,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.0.0" +version = "0.116.0" dependencies = [ "assert_matches", "async-stream", @@ -2258,7 +2259,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.0.0" +version = "0.116.0" dependencies = [ "chrono", "codex-api", @@ -2289,7 +2290,7 @@ dependencies = [ [[package]] name = "codex-package-manager" -version = "0.0.0" +version = "0.116.0" dependencies = [ "fd-lock", "flate2", @@ -2309,7 +2310,7 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.0.0" +version = "0.116.0" dependencies = [ "libc", "pretty_assertions", @@ -2317,7 +2318,7 @@ dependencies = [ [[package]] name = "codex-protocol" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "codex-execpolicy", @@ -2344,7 +2345,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "clap", @@ -2360,7 +2361,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "axum", @@ -2394,7 +2395,7 @@ dependencies = [ [[package]] name = "codex-secrets" -version = "0.0.0" +version = "0.116.0" dependencies = [ "age", "anyhow", @@ -2414,7 +2415,7 @@ dependencies = [ [[package]] name = "codex-shell-command" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -2434,7 +2435,7 @@ dependencies = [ [[package]] name = "codex-shell-escalation" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "async-trait", @@ -2455,7 +2456,7 @@ dependencies = [ [[package]] name = "codex-skills" -version = "0.0.0" +version = "0.116.0" dependencies = [ "codex-utils-absolute-path", "include_dir", @@ -2464,7 +2465,7 @@ dependencies = [ [[package]] name = "codex-state" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "chrono", @@ -2485,7 +2486,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "codex-utils-cargo-bin", @@ -2496,7 +2497,7 @@ dependencies = [ [[package]] name = "codex-test-macros" -version = "0.0.0" +version = "0.116.0" dependencies = [ "proc-macro2", "quote", @@ -2505,7 +2506,7 @@ dependencies = [ [[package]] name = "codex-tui" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "arboard", @@ -2596,7 +2597,7 @@ dependencies = [ [[package]] name = "codex-tui-app-server" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "arboard", @@ -2686,7 +2687,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.0.0" +version = "0.116.0" dependencies = [ "dirs", "path-absolutize", @@ -2700,14 +2701,14 @@ dependencies = [ [[package]] name = "codex-utils-approval-presets" -version = "0.0.0" +version = "0.116.0" dependencies = [ "codex-protocol", ] [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.116.0" dependencies = [ "lru 0.16.3", "sha1", @@ -2716,7 +2717,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.0.0" +version = "0.116.0" dependencies = [ "assert_cmd", "runfiles", @@ -2725,7 +2726,7 @@ dependencies = [ [[package]] name = "codex-utils-cli" -version = "0.0.0" +version = "0.116.0" dependencies = [ "clap", "codex-protocol", @@ -2736,15 +2737,15 @@ dependencies = [ [[package]] name = "codex-utils-elapsed" -version = "0.0.0" +version = "0.116.0" [[package]] name = "codex-utils-fuzzy-match" -version = "0.0.0" +version = "0.116.0" [[package]] name = "codex-utils-home-dir" -version = "0.0.0" +version = "0.116.0" dependencies = [ "dirs", "pretty_assertions", @@ -2753,7 +2754,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.116.0" dependencies = [ "base64 0.22.1", "codex-utils-cache", @@ -2764,7 +2765,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.0.0" +version = "0.116.0" dependencies = [ "pretty_assertions", "serde_json", @@ -2773,7 +2774,7 @@ dependencies = [ [[package]] name = "codex-utils-oss" -version = "0.0.0" +version = "0.116.0" dependencies = [ "codex-core", "codex-lmstudio", @@ -2782,7 +2783,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "filedescriptor", @@ -2798,7 +2799,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.0.0" +version = "0.116.0" dependencies = [ "assert_matches", "async-trait", @@ -2809,14 +2810,14 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.0.0" +version = "0.116.0" dependencies = [ "rustls", ] [[package]] name = "codex-utils-sandbox-summary" -version = "0.0.0" +version = "0.116.0" dependencies = [ "codex-core", "codex-protocol", @@ -2826,7 +2827,7 @@ dependencies = [ [[package]] name = "codex-utils-sleep-inhibitor" -version = "0.0.0" +version = "0.116.0" dependencies = [ "core-foundation 0.9.4", "libc", @@ -2836,14 +2837,14 @@ dependencies = [ [[package]] name = "codex-utils-stream-parser" -version = "0.0.0" +version = "0.116.0" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-utils-string" -version = "0.0.0" +version = "0.116.0" dependencies = [ "pretty_assertions", "regex-lite", @@ -2851,7 +2852,7 @@ dependencies = [ [[package]] name = "codex-windows-sandbox" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -3056,7 +3057,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core_test_support" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "assert_cmd", @@ -5791,7 +5792,7 @@ checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" [[package]] name = "mcp_test_support" -version = "0.0.0" +version = "0.116.0" dependencies = [ "anyhow", "codex-core", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 35ff64195..9936b9271 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -74,7 +74,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.116.0" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 698035fea..b417421fc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -34,6 +34,8 @@ use std::io::IsTerminal; use std::path::PathBuf; use supports_color::Stream; +const CODEX_CLI_DISPLAY_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (local.1)"); + #[cfg(target_os = "macos")] mod app_cmd; #[cfg(target_os = "macos")] @@ -58,7 +60,7 @@ use codex_core::terminal::TerminalName; #[derive(Debug, Parser)] #[clap( author, - version, + version = CODEX_CLI_DISPLAY_VERSION, // If a sub‑command is given, ignore requirements of the default args. subcommand_negates_reqs = true, // The executable is sometimes invoked via a platform‑specific name like @@ -1367,6 +1369,12 @@ mod tests { assert_eq!(args.prompt.as_deref(), Some("2+2")); } + #[test] + fn clap_version_output_includes_local_suffix() { + let version_output = MultitoolCli::command().render_version().to_string(); + assert!(version_output.contains("local.1"), "{version_output}"); + } + #[test] fn exec_resume_accepts_output_last_message_flag_after_subcommand() { let cli = MultitoolCli::try_parse_from([ diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index 2ef31ac82..982e3e718 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -12,6 +12,7 @@ futures = { workspace = true } http = { workspace = true } opentelemetry = { workspace = true } rand = { workspace = true } +regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } rustls = { workspace = true } rustls-native-certs = { workspace = true } diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 93dd81506..09ab895cb 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -27,6 +27,7 @@ pub use crate::request::Response; pub use crate::retry::RetryOn; pub use crate::retry::RetryPolicy; pub use crate::retry::backoff; +pub use crate::retry::retry_delay_for_error; pub use crate::retry::run_with_retry; pub use crate::sse::sse_stream; pub use crate::telemetry::RequestTelemetry; diff --git a/codex-rs/codex-client/src/retry.rs b/codex-rs/codex-client/src/retry.rs index c7bdd34b1..c1d4e1e8a 100644 --- a/codex-rs/codex-client/src/retry.rs +++ b/codex-rs/codex-client/src/retry.rs @@ -1,6 +1,10 @@ use crate::error::TransportError; use crate::request::Request; +use http::HeaderMap; +use http::HeaderValue; +use http::StatusCode; use rand::Rng; +use serde::Deserialize; use std::future::Future; use std::time::Duration; use tokio::time::sleep; @@ -25,8 +29,8 @@ impl RetryOn { return false; } match err { - TransportError::Http { status, .. } => { - (self.retry_429 && status.as_u16() == 429) + TransportError::Http { status, body, .. } => { + (self.retry_429 && status.as_u16() == 429 && !is_non_retryable_429(body.as_deref())) || (self.retry_5xx && status.is_server_error()) } TransportError::Timeout | TransportError::Network(_) => self.retry_transport, @@ -46,6 +50,24 @@ pub fn backoff(base: Duration, attempt: u64) -> Duration { Duration::from_millis((raw as f64 * jitter) as u64) } +pub fn retry_delay_for_error(err: &TransportError) -> Option { + let TransportError::Http { + status, + headers, + body, + .. + } = err + else { + return None; + }; + + if *status != StatusCode::TOO_MANY_REQUESTS || is_non_retryable_429(body.as_deref()) { + return None; + } + + parse_retry_after_headers(headers.as_ref()).or_else(|| parse_retry_after_body(body.as_deref())) +} + pub async fn run_with_retry( policy: RetryPolicy, mut make_req: impl FnMut() -> Request, @@ -64,10 +86,144 @@ where .retry_on .should_retry(&err, attempt, policy.max_attempts) => { - sleep(backoff(policy.base_delay, attempt + 1)).await; + let delay = retry_delay_for_error(&err) + .unwrap_or_else(|| backoff(policy.base_delay, attempt + 1)); + sleep(delay).await; } Err(err) => return Err(err), } } Err(TransportError::RetryLimit) } + +#[derive(Debug, Deserialize)] +struct RetryErrorEnvelope { + error: RetryErrorPayload, +} + +#[derive(Debug, Deserialize)] +struct RetryErrorPayload { + #[serde(default, rename = "type")] + error_type: Option, + #[serde(default)] + code: Option, + #[serde(default)] + message: Option, +} + +fn is_non_retryable_429(body: Option<&str>) -> bool { + let Some(body) = body else { + return false; + }; + + let Ok(parsed) = serde_json::from_str::(body) else { + return false; + }; + + matches!( + parsed + .error + .error_type + .as_deref() + .or(parsed.error.code.as_deref()), + Some("usage_limit_reached" | "usage_not_included") + ) +} + +fn parse_retry_after_headers(headers: Option<&HeaderMap>) -> Option { + let headers = headers?; + + if let Some(ms) = headers.get("retry-after-ms").and_then(parse_header_f64) { + return Some(Duration::from_secs_f64((ms / 1000.0).max(0.0))); + } + + headers + .get("retry-after") + .and_then(parse_header_f64) + .map(|seconds| Duration::from_secs_f64(seconds.max(0.0))) +} + +fn parse_header_f64(value: &HeaderValue) -> Option { + value.to_str().ok()?.trim().parse::().ok() +} + +fn parse_retry_after_body(body: Option<&str>) -> Option { + let message = parse_retry_message(body?)?; + let captures = retry_after_regex().captures(&message)?; + let value = captures.get(1)?.as_str().parse::().ok()?; + let unit = captures.get(2)?.as_str().to_ascii_lowercase(); + + if unit == "ms" || unit.starts_with("milli") { + Some(Duration::from_secs_f64((value / 1000.0).max(0.0))) + } else { + Some(Duration::from_secs_f64(value.max(0.0))) + } +} + +fn parse_retry_message(body: &str) -> Option { + serde_json::from_str::(body) + .ok() + .and_then(|parsed| parsed.error.message) + .or_else(|| Some(body.to_string())) +} + +fn retry_after_regex() -> &'static regex_lite::Regex { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + #[expect(clippy::unwrap_used)] + RE.get_or_init(|| { + regex_lite::Regex::new( + r"(?i)(?:try again|retry)(?:\s+after)?\s+in?\s*(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|sec|seconds?)", + ) + .unwrap() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn http_error( + status: StatusCode, + headers: Option, + body: Option<&str>, + ) -> TransportError { + TransportError::Http { + status, + url: Some("https://example.test/v1/responses".to_string()), + headers, + body: body.map(str::to_string), + } + } + + #[test] + fn retryable_429_does_not_retry_usage_limit_reached() { + let retry_on = RetryOn { + retry_429: true, + retry_5xx: true, + retry_transport: true, + }; + let err = http_error( + StatusCode::TOO_MANY_REQUESTS, + None, + Some(r#"{"error":{"type":"usage_limit_reached","message":"hard limit"}}"#), + ); + + assert!(!retry_on.should_retry(&err, 0, 4)); + } + + #[test] + fn retry_delay_prefers_retry_after_header_for_retryable_429() { + let mut headers = HeaderMap::new(); + headers.insert("retry-after", HeaderValue::from_static("3")); + let err = http_error( + StatusCode::TOO_MANY_REQUESTS, + Some(headers), + Some( + r#"{"error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded. Try again in 35 seconds."}}"#, + ), + ); + + assert_eq!(retry_delay_for_error(&err), Some(Duration::from_secs(3))); + } +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index eb5cb4c08..c336cdfaa 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -59,6 +59,9 @@ use codex_api::common::ResponsesWsRequest; use codex_api::create_text_param_for_request; use codex_api::error::ApiError; use codex_api::requests::responses::Compression; +use codex_client::RetryPolicy as HttpRetryPolicy; +use codex_client::backoff as retry_backoff; +use codex_client::retry_delay_for_error; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -162,6 +165,17 @@ impl RequestRouteTelemetry { } } +#[derive(Debug, Clone)] +pub struct RequestRetryNotice { + pub endpoint: &'static str, + pub attempt: u64, + pub max_attempts: u64, + pub delay: Duration, + pub status: Option, +} + +pub type RequestRetryNotifier = Arc; + /// A session-scoped client for model-provider API calls. /// /// This holds configuration and state that should be shared across turns within a Codex session @@ -343,12 +357,14 @@ impl ModelClient { effort: Option, summary: ReasoningSummaryConfig, session_telemetry: &SessionTelemetry, + retry_notifier: Option, ) -> Result> { if prompt.input.is_empty() { return Ok(Vec::new()); } let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); + let retry_policy = client_setup.api_provider.retry.to_policy(); let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -358,6 +374,8 @@ impl ModelClient { ), RequestRouteTelemetry::for_endpoint(RESPONSES_COMPACT_ENDPOINT), self.state.auth_env_telemetry.clone(), + retry_policy, + retry_notifier, ); let client = ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) @@ -411,6 +429,7 @@ impl ModelClient { model_info: &ModelInfo, effort: Option, session_telemetry: &SessionTelemetry, + retry_notifier: Option, ) -> Result> { if raw_memories.is_empty() { return Ok(Vec::new()); @@ -418,6 +437,7 @@ impl ModelClient { let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); + let retry_policy = client_setup.api_provider.retry.to_policy(); let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -427,6 +447,8 @@ impl ModelClient { ), RequestRouteTelemetry::for_endpoint(MEMORIES_SUMMARIZE_ENDPOINT), self.state.auth_env_telemetry.clone(), + retry_policy, + retry_notifier, ); let client = ApiMemoriesClient::new(transport, client_setup.api_provider, client_setup.api_auth) @@ -472,12 +494,16 @@ impl ModelClient { auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, auth_env_telemetry: AuthEnvTelemetry, + retry_policy: HttpRetryPolicy, + retry_notifier: Option, ) -> Arc { let telemetry = Arc::new(ApiTelemetry::new( session_telemetry.clone(), auth_context, request_route_telemetry, auth_env_telemetry, + retry_policy, + retry_notifier, )); let request_telemetry: Arc = telemetry; request_telemetry @@ -1001,6 +1027,7 @@ impl ModelClientSession { summary: ReasoningSummaryConfig, service_tier: Option, turn_metadata_header: Option<&str>, + retry_notifier: Option, ) -> Result { if let Some(path) = &*CODEX_RS_SSE_FIXTURE { warn!(path, "Streaming from fixture"); @@ -1021,6 +1048,7 @@ impl ModelClientSession { loop { let client_setup = self.client.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); + let retry_policy = client_setup.api_provider.retry.to_policy(); let request_auth_context = AuthRequestTelemetryContext::new( client_setup.auth.as_ref().map(CodexAuth::auth_mode), &client_setup.api_auth, @@ -1031,6 +1059,8 @@ impl ModelClientSession { request_auth_context, RequestRouteTelemetry::for_endpoint(RESPONSES_ENDPOINT), self.client.state.auth_env_telemetry.clone(), + retry_policy, + retry_notifier.clone(), ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); let options = self.build_responses_options(turn_metadata_header, compression); @@ -1099,6 +1129,7 @@ impl ModelClientSession { service_tier: Option, turn_metadata_header: Option<&str>, warmup: bool, + _retry_notifier: Option, ) -> Result { let auth_manager = self.client.state.auth_manager.clone(); @@ -1192,12 +1223,16 @@ impl ModelClientSession { auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, auth_env_telemetry: AuthEnvTelemetry, + retry_policy: HttpRetryPolicy, + retry_notifier: Option, ) -> (Arc, Arc) { let telemetry = Arc::new(ApiTelemetry::new( session_telemetry.clone(), auth_context, request_route_telemetry, auth_env_telemetry, + retry_policy, + retry_notifier, )); let request_telemetry: Arc = telemetry.clone(); let sse_telemetry: Arc = telemetry; @@ -1216,6 +1251,16 @@ impl ModelClientSession { auth_context, request_route_telemetry, auth_env_telemetry, + HttpRetryPolicy { + max_attempts: 0, + base_delay: Duration::from_millis(0), + retry_on: codex_client::RetryOn { + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + }, + None, )); let websocket_telemetry: Arc = telemetry; websocket_telemetry @@ -1231,6 +1276,7 @@ impl ModelClientSession { summary: ReasoningSummaryConfig, service_tier: Option, turn_metadata_header: Option<&str>, + retry_notifier: Option, ) -> Result<()> { if !self.client.responses_websocket_enabled() { return Ok(()); @@ -1249,6 +1295,7 @@ impl ModelClientSession { service_tier, turn_metadata_header, /*warmup*/ true, + retry_notifier, ) .await { @@ -1287,6 +1334,7 @@ impl ModelClientSession { summary: ReasoningSummaryConfig, service_tier: Option, turn_metadata_header: Option<&str>, + retry_notifier: Option, ) -> Result { let wire_api = self.client.state.provider.wire_api; match wire_api { @@ -1302,6 +1350,7 @@ impl ModelClientSession { service_tier, turn_metadata_header, /*warmup*/ false, + retry_notifier.clone(), ) .await? { @@ -1320,6 +1369,7 @@ impl ModelClientSession { summary, service_tier, turn_metadata_header, + retry_notifier, ) .await } @@ -1669,6 +1719,8 @@ struct ApiTelemetry { auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, auth_env_telemetry: AuthEnvTelemetry, + retry_policy: HttpRetryPolicy, + retry_notifier: Option, } impl ApiTelemetry { @@ -1677,12 +1729,16 @@ impl ApiTelemetry { auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, auth_env_telemetry: AuthEnvTelemetry, + retry_policy: HttpRetryPolicy, + retry_notifier: Option, ) -> Self { Self { session_telemetry, auth_context, request_route_telemetry, auth_env_telemetry, + retry_policy, + retry_notifier, } } } @@ -1696,13 +1752,13 @@ impl RequestTelemetry for ApiTelemetry { duration: Duration, ) { let error_message = error.map(telemetry_transport_error_message); - let status = status.map(|s| s.as_u16()); + let status_u16 = status.map(|s| s.as_u16()); let debug = error .map(extract_response_debug_context) .unwrap_or_default(); self.session_telemetry.record_api_request( attempt, - status, + status_u16, error_message.as_deref(), duration, self.auth_context.auth_header_attached, @@ -1737,11 +1793,36 @@ impl RequestTelemetry for ApiTelemetry { auth_recovery_followup_status: self .auth_context .retry_after_unauthorized - .then_some(status) + .then_some(status_u16) .flatten(), }, &self.auth_env_telemetry, ); + + let should_emit_retry_warning = error.is_some_and(|err| { + matches!( + err, + TransportError::Http { status, .. } if *status == HttpStatusCode::TOO_MANY_REQUESTS + ) && self.retry_policy.retry_on.should_retry( + err, + attempt, + self.retry_policy.max_attempts, + ) + }); + if should_emit_retry_warning + && let Some(notifier) = self.retry_notifier.as_ref() + && let Some(err) = error + { + let delay = retry_delay_for_error(err) + .unwrap_or_else(|| retry_backoff(self.retry_policy.base_delay, attempt + 1)); + notifier(RequestRetryNotice { + endpoint: self.request_route_telemetry.endpoint, + attempt: attempt + 1, + max_attempts: self.retry_policy.max_attempts, + delay, + status, + }); + } } } diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 2c07b4fd1..65e26e605 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -91,7 +91,7 @@ async fn summarize_memories_returns_empty_for_empty_input() { let session_telemetry = test_session_telemetry(); let output = client - .summarize_memories(Vec::new(), &model_info, None, &session_telemetry) + .summarize_memories(Vec::new(), &model_info, None, &session_telemetry, None) .await .expect("empty summarize request should succeed"); assert_eq!(output.len(), 0); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7b947249b..43f2371d8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6990,6 +6990,12 @@ async fn try_run_sampling_request( turn_context.reasoning_summary, turn_context.config.service_tier, turn_metadata_header, + Some( + crate::request_retry_notifier::make_request_retry_warning_notifier( + sess.clone(), + turn_context.clone(), + ), + ), ) .instrument(trace_span!("stream_request")) .or_cancel(&cancellation_token) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 7686c5b65..fab75931b 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -405,6 +405,7 @@ async fn drain_to_completed( turn_context.reasoning_summary, turn_context.config.service_tier, turn_metadata_header, + None, ) .await?; loop { diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 9439d8125..fb90cb06b 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -123,6 +123,12 @@ async fn run_remote_compact_task_inner_impl( turn_context.reasoning_effort, turn_context.reasoning_summary, &turn_context.session_telemetry, + Some( + crate::request_retry_notifier::make_request_retry_warning_notifier( + sess.clone(), + turn_context.clone(), + ), + ), ) .or_else(|err| async { let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 0a950162b..a1e3b9cee 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -68,6 +68,7 @@ mod model_provider_info; pub mod path_utils; pub mod personality_migration; pub mod plugins; +mod request_retry_notifier; mod sandbox_tags; pub mod sandboxing; mod session_prefix; diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index 921bc9953..86fdccab8 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -353,6 +353,7 @@ mod job { stage_one_context.reasoning_summary, stage_one_context.service_tier, stage_one_context.turn_metadata_header.as_deref(), + None, ) .await?; diff --git a/codex-rs/core/src/memory_trace.rs b/codex-rs/core/src/memory_trace.rs index 2e613e671..ee3af41df 100644 --- a/codex-rs/core/src/memory_trace.rs +++ b/codex-rs/core/src/memory_trace.rs @@ -51,7 +51,7 @@ pub async fn build_memories_from_trace_files( let raw_memories = prepared.iter().map(|trace| trace.payload.clone()).collect(); let output = client - .summarize_memories(raw_memories, model_info, effort, session_telemetry) + .summarize_memories(raw_memories, model_info, effort, session_telemetry, None) .await?; if output.len() != prepared.len() { return Err(CodexErr::InvalidRequest(format!( diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 737a47780..41af3b38f 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -175,7 +175,7 @@ impl ModelProviderInfo { let retry = ApiRetryConfig { max_attempts: self.request_max_retries(), base_delay: Duration::from_millis(200), - retry_429: false, + retry_429: true, retry_5xx: true, retry_transport: true, }; diff --git a/codex-rs/core/src/model_provider_info_tests.rs b/codex-rs/core/src/model_provider_info_tests.rs index a5309117a..e5016f226 100644 --- a/codex-rs/core/src/model_provider_info_tests.rs +++ b/codex-rs/core/src/model_provider_info_tests.rs @@ -1,5 +1,6 @@ use super::*; use pretty_assertions::assert_eq; +use std::time::Duration; #[test] fn test_deserialize_ollama_model_provider_toml() { @@ -121,3 +122,34 @@ supports_websockets = true let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap(); assert_eq!(provider.websocket_connect_timeout_ms, Some(15_000)); } + +#[test] +fn request_retry_policy_retries_retryable_429s_by_default() { + let provider = ModelProviderInfo { + name: "OpenAI".into(), + base_url: Some("https://api.openai.com/v1".into()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + }; + + let api_provider = provider + .to_api_provider(None) + .expect("provider should convert to api provider"); + + assert_eq!(api_provider.retry.max_attempts, 4); + assert_eq!(api_provider.retry.base_delay, Duration::from_millis(200)); + assert!(api_provider.retry.retry_429); + assert!(api_provider.retry.retry_5xx); + assert!(api_provider.retry.retry_transport); +} diff --git a/codex-rs/core/src/request_retry_notifier.rs b/codex-rs/core/src/request_retry_notifier.rs new file mode 100644 index 000000000..921ef41d6 --- /dev/null +++ b/codex-rs/core/src/request_retry_notifier.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; +use std::time::Duration; + +use crate::client::RequestRetryNotice; +use crate::client::RequestRetryNotifier; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::protocol::EventMsg; +use crate::protocol::WarningEvent; + +pub(crate) fn make_request_retry_warning_notifier( + sess: Arc, + turn_context: Arc, +) -> RequestRetryNotifier { + Arc::new(move |notice: RequestRetryNotice| { + let sess = sess.clone(); + let turn_context = turn_context.clone(); + tokio::spawn(async move { + let message = format_request_retry_warning(¬ice); + sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) + .await; + }); + }) +} + +fn format_request_retry_warning(notice: &RequestRetryNotice) -> String { + let status = notice + .status + .map(|status| status.as_u16().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let delay = format_delay(notice.delay); + format!( + "HTTP {status} from {}. Retrying in {delay} (retry {}/{})", + notice.endpoint, notice.attempt, notice.max_attempts + ) +} + +fn format_delay(delay: Duration) -> String { + let seconds = delay.as_secs_f64(); + if seconds < 1.0 { + format!("{}ms", delay.as_millis()) + } else if (seconds - seconds.round()).abs() < 0.05 { + format!("{:.0}s", seconds.round()) + } else { + format!("{seconds:.1}s") + } +} diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 326d864f1..9d47712c9 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -234,6 +234,12 @@ async fn schedule_startup_prewarm_inner( startup_turn_context.reasoning_summary, startup_turn_context.config.service_tier, startup_turn_metadata_header.as_deref(), + Some( + crate::request_retry_notifier::make_request_retry_warning_notifier( + session.clone(), + startup_turn_context.clone(), + ), + ), ) .await?; diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 823057797..a323ebe64 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -118,6 +118,7 @@ async fn responses_stream_includes_subagent_header_on_review() { summary.unwrap_or(model_info.default_reasoning_summary), None, None, + None, ) .await .expect("stream failed"); @@ -231,6 +232,7 @@ async fn responses_stream_includes_subagent_header_on_other() { summary.unwrap_or(model_info.default_reasoning_summary), None, None, + None, ) .await .expect("stream failed"); @@ -343,6 +345,7 @@ async fn responses_respects_model_info_overrides_from_config() { summary.unwrap_or(model_info.default_reasoning_summary), None, None, + None, ) .await .expect("stream failed"); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 35dee3fa7..6d4b1444e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -49,6 +49,7 @@ use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_response_sequence; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; @@ -1913,6 +1914,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { summary.unwrap_or(ReasoningSummary::Auto), None, None, + None, ) .await .expect("responses stream to start"); @@ -2188,6 +2190,72 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn retryable_429_retries_request_and_emits_warning_event() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let first = ResponseTemplate::new(429) + .insert_header("retry-after", "0") + .set_body_json(json!({ + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded. Try again in 0 seconds." + } + })); + let second = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse(vec![ + ev_response_created("resp_1"), + ev_completed("resp_1"), + ])); + + let response_mock = mount_response_sequence(&server, vec![first, second]).await; + + let mut builder = test_codex().with_config(|config| { + config.model_provider.request_max_retries = Some(1); + config.model_provider.stream_max_retries = Some(0); + }); + let codex_fixture = builder.build(&server).await?; + let codex = codex_fixture.codex.clone(); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submission should succeed after retry"); + + let warning_event = wait_for_event(&codex, |msg| matches!(msg, EventMsg::Warning(_))).await; + let EventMsg::Warning(warning) = warning_event else { + unreachable!(); + }; + assert!( + warning.message.contains("429"), + "expected 429 warning, got: {}", + warning.message + ); + assert!( + warning.message.to_ascii_lowercase().contains("retry"), + "expected retry wording, got: {}", + warning.message + ); + + wait_for_event(&codex, |msg| matches!(msg, EventMsg::TurnComplete(_))).await; + + assert_eq!( + response_mock.requests().len(), + 2, + "expected one retry after 429" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 38ef3b682..ca0c8d010 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -166,6 +166,7 @@ async fn responses_websocket_request_prewarm_reuses_connection() { harness.summary, None, None, + None, ) .await .expect("websocket prewarm failed"); @@ -247,6 +248,7 @@ async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { harness.summary, None, None, + None, ) .await .expect("websocket stream failed"); @@ -285,6 +287,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( harness.summary, None, None, + None, ) .await .expect("websocket prewarm failed"); @@ -297,6 +300,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( harness.summary, None, None, + None, ) .await .expect("websocket stream failed"); @@ -350,6 +354,7 @@ async fn responses_websocket_prewarm_uses_v2_when_provider_supports_websockets() harness.summary, None, None, + None, ) .await .expect("websocket prewarm failed"); @@ -697,6 +702,7 @@ async fn responses_websocket_emits_reasoning_included_event() { harness.summary, None, None, + None, ) .await .expect("websocket stream failed"); @@ -770,6 +776,7 @@ async fn responses_websocket_emits_rate_limit_events() { harness.summary, None, None, + None, ) .await .expect("websocket stream failed"); @@ -1350,6 +1357,7 @@ async fn responses_websocket_v2_after_error_uses_full_create_without_previous_re harness.summary, None, None, + None, ) .await .expect("websocket stream failed"); @@ -1437,6 +1445,7 @@ async fn responses_websocket_v2_surfaces_terminal_error_without_close_handshake( harness.summary, None, None, + None, ) .await .expect("websocket stream failed"); @@ -1668,6 +1677,7 @@ async fn stream_until_complete_with_turn_metadata( harness.summary, service_tier, turn_metadata_header, + None, ) .await .expect("websocket stream failed"); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 54a7a2c66..88f8c6120 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -37,7 +37,6 @@ use crate::resume_picker::SessionSelection; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; -use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; use codex_app_server_protocol::ConfigLayerSource; use codex_core::AuthManager; @@ -1173,7 +1172,7 @@ impl App { } fn clear_ui_header_lines(&self, width: u16) -> Vec> { - self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION) + self.clear_ui_header_lines_with_version(width, crate::version::CODEX_CLI_DISPLAY_VERSION) } fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) { @@ -1940,6 +1939,18 @@ impl App { ) } + fn resume_picker_action_and_target( + selection: &SessionSelection, + ) -> Option<(CwdPromptAction, &crate::resume_picker::SessionTarget)> { + match selection { + SessionSelection::Resume(target_session) => { + Some((CwdPromptAction::Resume, target_session)) + } + SessionSelection::Fork(target_session) => Some((CwdPromptAction::Fork, target_session)), + SessionSelection::StartFresh | SessionSelection::Exit => None, + } + } + fn should_handle_active_thread_events( waiting_for_initial_session_configured: bool, has_active_thread_receiver: bool, @@ -2424,22 +2435,22 @@ impl App { self.start_fresh_session_with_summary_hint(tui).await; } AppEvent::OpenResumePicker => { - match crate::resume_picker::run_resume_picker( + let selection = crate::resume_picker::run_resume_picker( tui, &self.config, /*show_all*/ false, ) - .await? - { - SessionSelection::Resume(target_session) => { + .await?; + match Self::resume_picker_action_and_target(&selection) { + Some((action, target_session)) => { let current_cwd = self.config.cwd.clone(); - let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( + let selected_cwd = match crate::resolve_cwd_for_resume_or_fork( tui, &self.config, ¤t_cwd, target_session.thread_id, &target_session.path, - CwdPromptAction::Resume, + action, /*allow_prompt*/ true, ) .await? @@ -2450,37 +2461,60 @@ impl App { return Ok(AppRunControl::Exit(ExitReason::UserRequested)); } }; - let mut resume_config = match self - .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + let mut selected_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, selected_cwd) .await { Ok(cfg) => cfg, Err(err) => { + let action_label = match action { + CwdPromptAction::Resume => "resume", + CwdPromptAction::Fork => "fork", + }; self.chat_widget.add_error_message(format!( - "Failed to rebuild configuration for resume: {err}" + "Failed to rebuild configuration for {action_label}: {err}" )); return Ok(AppRunControl::Continue); } }; - self.apply_runtime_policy_overrides(&mut resume_config); + self.apply_runtime_policy_overrides(&mut selected_config); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), self.chat_widget.thread_name(), ); - match self - .server - .resume_thread_from_rollout( - resume_config.clone(), - target_session.path.clone(), - self.auth_manager.clone(), - /*parent_trace*/ None, - ) - .await - { + let selection_result = match action { + CwdPromptAction::Resume => { + self.server + .resume_thread_from_rollout( + selected_config.clone(), + target_session.path.clone(), + self.auth_manager.clone(), + /*parent_trace*/ None, + ) + .await + } + CwdPromptAction::Fork => { + self.session_telemetry.counter( + "codex.thread.fork", + /*inc*/ 1, + &[("source", "resume_picker")], + ); + self.server + .fork_thread( + usize::MAX, + selected_config.clone(), + target_session.path.clone(), + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + } + }; + match selection_result { Ok(resumed) => { self.shutdown_current_thread().await; - self.config = resume_config; + self.config = selected_config; tui.set_notification_method(self.config.tui_notification_method); self.file_search.update_search_dir(self.config.cwd.clone()); let init = self.chatwidget_init_for_forked_or_resumed_thread( @@ -2508,15 +2542,17 @@ impl App { } Err(err) => { let path_display = target_session.path.display(); + let action_verb = match action { + CwdPromptAction::Resume => "resume", + CwdPromptAction::Fork => "fork", + }; self.chat_widget.add_error_message(format!( - "Failed to resume session from {path_display}: {err}" + "Failed to {action_verb} session from {path_display}: {err}" )); } } } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Fork(_) => {} + None => {} } // Leaving alt-screen may blank the inline viewport; force a redraw either way. @@ -4348,6 +4384,21 @@ mod tests { ); } + #[test] + fn resume_picker_action_and_target_returns_fork_selection() { + let selection = SessionSelection::Fork(crate::resume_picker::SessionTarget { + path: PathBuf::from("/tmp/fork"), + thread_id: ThreadId::new(), + }); + + match App::resume_picker_action_and_target(&selection) { + Some((CwdPromptAction::Fork, target_session)) => { + assert_eq!(target_session.path, PathBuf::from("/tmp/fork")); + } + other => panic!("unexpected action: {other:?}"), + } + } + #[tokio::test] async fn enqueue_primary_event_delivers_session_configured_before_buffered_approval() -> Result<()> { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index f2d8db0fc..8b0f73478 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -400,39 +400,54 @@ impl PickerState { self.requester.schedule_frame(); } + async fn selection_for_current_row( + &mut self, + action: SessionPickerAction, + ) -> Result> { + if let Some(row) = self.filtered_rows.get(self.selected) { + let path = row.path.clone(); + let thread_id = match row.thread_id { + Some(thread_id) => Some(thread_id), + None => { + crate::resolve_session_thread_id(path.as_path(), /*id_str_if_uuid*/ None).await + } + }; + if let Some(thread_id) = thread_id { + return Ok(Some(action.selection(path, thread_id))); + } + self.inline_error = Some(format!( + "Failed to read session metadata from {}", + path.display() + )); + self.request_frame(); + } + Ok(None) + } + async fn handle_key(&mut self, key: KeyEvent) -> Result> { self.inline_error = None; match key.code { KeyCode::Esc => return Ok(Some(SessionSelection::StartFresh)), - KeyCode::Char('c') + KeyCode::Char(c) if key .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) => + .contains(crossterm::event::KeyModifiers::CONTROL) + && c.eq_ignore_ascii_case(&'c') => { return Ok(Some(SessionSelection::Exit)); } + KeyCode::Char(c) + if key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + && c.eq_ignore_ascii_case(&'f') => + { + return self + .selection_for_current_row(SessionPickerAction::Fork) + .await; + } KeyCode::Enter => { - if let Some(row) = self.filtered_rows.get(self.selected) { - let path = row.path.clone(); - let thread_id = match row.thread_id { - Some(thread_id) => Some(thread_id), - None => { - crate::resolve_session_thread_id( - path.as_path(), - /*id_str_if_uuid*/ None, - ) - .await - } - }; - if let Some(thread_id) = thread_id { - return Ok(Some(self.action.selection(path, thread_id))); - } - self.inline_error = Some(format!( - "Failed to read session metadata from {}", - path.display() - )); - self.request_frame(); - } + return self.selection_for_current_row(self.action).await; } KeyCode::Up => { if self.selected > 0 { @@ -905,9 +920,16 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { // Hint line let action_label = state.action.action_label(); - let hint_line: Line = vec![ + let mut hint_line: Vec> = vec![ key_hint::plain(KeyCode::Enter).into(), format!(" to {action_label} ").dim(), + ]; + if matches!(state.action, SessionPickerAction::Resume) { + hint_line.push(" ".dim()); + hint_line.push(key_hint::ctrl(KeyCode::Char('f')).into()); + hint_line.push(" to fork ".dim()); + } + hint_line.extend([ " ".dim(), key_hint::plain(KeyCode::Esc).into(), " to start new ".dim(), @@ -922,9 +944,8 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { "/".dim(), key_hint::plain(KeyCode::Down).into(), " to browse".dim(), - ] - .into(); - frame.render_widget_ref(hint_line, hint); + ]); + frame.render_widget_ref(Line::from(hint_line), hint); }) } @@ -1677,6 +1698,83 @@ mod tests { assert_snapshot!("resume_picker_search_error", snapshot); } + #[tokio::test] + async fn ctrl_f_forks_selected_row_in_resume_mode() { + let loader: PageLoader = Arc::new(|_| {}); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + true, + None, + SessionPickerAction::Resume, + ); + let thread_id = ThreadId::new(); + let row = Row { + path: PathBuf::from("/tmp/fork-target.jsonl"), + preview: String::from("fork target"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }; + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)) + .await + .expect("ctrl+f should not abort the picker"); + + match selection { + Some(SessionSelection::Fork(SessionTarget { + path, + thread_id: selected_thread_id, + })) => { + assert_eq!(path, PathBuf::from("/tmp/fork-target.jsonl")); + assert_eq!(selected_thread_id, thread_id); + } + other => panic!("unexpected selection: {other:?}"), + } + } + + #[test] + fn resume_picker_hint_mentions_ctrl_f_to_fork() { + let loader: PageLoader = Arc::new(|_| {}); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + true, + None, + SessionPickerAction::Resume, + ); + state.all_rows = vec![Row { + path: PathBuf::from("/tmp/visible.jsonl"), + preview: String::from("visible row"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + state.filtered_rows = state.all_rows.clone(); + + let width: u16 = 160; + let height: u16 = 5; + let rendered = render_picker_for_test(&state, width, height); + assert!( + rendered.contains("ctrl + f"), + "rendered picker:\n{rendered}" + ); + assert!(rendered.contains("to fork"), "rendered picker:\n{rendered}"); + } + // TODO(jif) fix // #[tokio::test] // async fn resume_picker_screen_snapshot() { @@ -1947,6 +2045,74 @@ mod tests { assert_snapshot!("resume_picker_thread_names", snapshot); } + fn render_picker_for_test(state: &PickerState, width: u16, height: u16) -> String { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + let [header, search, columns, list, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(4)), + Constraint::Length(1), + ]) + .areas(area); + + let header_line: Line = vec![ + state.action.title().bold().cyan(), + " ".into(), + "Sort:".dim(), + " ".into(), + sort_key_label(state.sort_key).magenta(), + ] + .into(); + frame.render_widget_ref(header_line, header); + frame.render_widget_ref(search_line(state), search); + + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + render_column_headers(&mut frame, columns, &metrics, state.sort_key); + render_list(&mut frame, list, state, &metrics); + + let action_label = state.action.action_label(); + let mut hint_line: Vec> = vec![ + key_hint::plain(KeyCode::Enter).into(), + format!(" to {action_label} ").dim(), + ]; + if matches!(state.action, SessionPickerAction::Resume) { + hint_line.push(" ".dim()); + hint_line.push(key_hint::ctrl(KeyCode::Char('f')).into()); + hint_line.push(" to fork ".dim()); + } + hint_line.extend([ + " ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " to start new ".dim(), + " ".dim(), + key_hint::ctrl(KeyCode::Char('c')).into(), + " to quit ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Tab).into(), + " to toggle sort ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Up).into(), + "/".dim(), + key_hint::plain(KeyCode::Down).into(), + " to browse".dim(), + ]); + frame.render_widget_ref(Line::from(hint_line), hint); + } + + terminal.flush().expect("flush"); + terminal.backend().to_string() + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); diff --git a/codex-rs/tui/src/version.rs b/codex-rs/tui/src/version.rs index 8c8d108dc..20d2eff59 100644 --- a/codex-rs/tui/src/version.rs +++ b/codex-rs/tui/src/version.rs @@ -1,2 +1,16 @@ /// The current Codex CLI version as embedded at compile time. pub const CODEX_CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const CODEX_CLI_DISPLAY_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (local.1)"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_version_includes_local_suffix() { + assert_eq!( + CODEX_CLI_DISPLAY_VERSION, + concat!(env!("CARGO_PKG_VERSION"), " (local.1)") + ); + } +} diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index bc9d47abe..c3839c52a 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -45,7 +45,6 @@ use crate::resume_picker::SessionSelection; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; -use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ClientRequest; @@ -1415,7 +1414,7 @@ impl App { } fn clear_ui_header_lines(&self, width: u16) -> Vec> { - self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION) + self.clear_ui_header_lines_with_version(width, crate::version::CODEX_CLI_DISPLAY_VERSION) } fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) { @@ -2749,6 +2748,18 @@ impl App { ) } + fn resume_picker_action_and_target( + selection: &SessionSelection, + ) -> Option<(CwdPromptAction, &crate::resume_picker::SessionTarget)> { + match selection { + SessionSelection::Resume(target_session) => { + Some((CwdPromptAction::Resume, target_session)) + } + SessionSelection::Fork(target_session) => Some((CwdPromptAction::Fork, target_session)), + SessionSelection::StartFresh | SessionSelection::Exit => None, + } + } + fn should_handle_active_thread_events( waiting_for_initial_session_configured: bool, has_active_thread_receiver: bool, @@ -3244,17 +3255,17 @@ impl App { return Ok(AppRunControl::Continue); } }; - match crate::resume_picker::run_resume_picker_with_app_server( + let selection = crate::resume_picker::run_resume_picker_with_app_server( tui, &self.config, /*show_all*/ false, picker_app_server, ) - .await? - { - SessionSelection::Resume(target_session) => { + .await?; + match Self::resume_picker_action_and_target(&selection) { + Some((action, target_session)) => { let current_cwd = self.config.cwd.clone(); - let resume_cwd = if self.remote_app_server_url.is_some() { + let selected_cwd = if self.remote_app_server_url.is_some() { current_cwd.clone() } else { match crate::resolve_cwd_for_resume_or_fork( @@ -3263,7 +3274,7 @@ impl App { ¤t_cwd, target_session.thread_id, target_session.path.as_deref(), - CwdPromptAction::Resume, + action, /*allow_prompt*/ true, ) .await? @@ -3275,31 +3286,52 @@ impl App { } } }; - let mut resume_config = match self - .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + let mut selected_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, selected_cwd) .await { Ok(cfg) => cfg, Err(err) => { + let action_label = match action { + CwdPromptAction::Resume => "resume", + CwdPromptAction::Fork => "fork", + }; self.chat_widget.add_error_message(format!( - "Failed to rebuild configuration for resume: {err}" + "Failed to rebuild configuration for {action_label}: {err}" )); return Ok(AppRunControl::Continue); } }; - self.apply_runtime_policy_overrides(&mut resume_config); + self.apply_runtime_policy_overrides(&mut selected_config); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), self.chat_widget.thread_name(), ); - match app_server - .resume_thread(resume_config.clone(), target_session.thread_id) - .await - { + let selection_result = match action { + CwdPromptAction::Resume => { + app_server + .resume_thread( + selected_config.clone(), + target_session.thread_id, + ) + .await + } + CwdPromptAction::Fork => { + self.session_telemetry.counter( + "codex.thread.fork", + /*inc*/ 1, + &[("source", "resume_picker")], + ); + app_server + .fork_thread(selected_config.clone(), target_session.thread_id) + .await + } + }; + match selection_result { Ok(resumed) => { self.shutdown_current_thread(app_server).await; - self.config = resume_config; + self.config = selected_config; tui.set_notification_method(self.config.tui_notification_method); self.file_search.update_search_dir(self.config.cwd.clone()); match self @@ -3321,23 +3353,29 @@ impl App { } } Err(err) => { + let action_label = match action { + CwdPromptAction::Resume => "resumed", + CwdPromptAction::Fork => "forked", + }; self.chat_widget.add_error_message(format!( - "Failed to attach to resumed app-server thread: {err}" + "Failed to attach to {action_label} app-server thread: {err}" )); } } } Err(err) => { let path_display = target_session.display_label(); + let action_verb = match action { + CwdPromptAction::Resume => "resume", + CwdPromptAction::Fork => "fork", + }; self.chat_widget.add_error_message(format!( - "Failed to resume session from {path_display}: {err}" + "Failed to {action_verb} session from {path_display}: {err}" )); } } } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Fork(_) => {} + None => {} } // Leaving alt-screen may blank the inline viewport; force a redraw either way. @@ -5354,6 +5392,21 @@ mod tests { ); } + #[test] + fn resume_picker_action_and_target_returns_fork_selection() { + let selection = SessionSelection::Fork(crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/fork")), + thread_id: ThreadId::new(), + }); + + match App::resume_picker_action_and_target(&selection) { + Some((CwdPromptAction::Fork, target_session)) => { + assert_eq!(target_session.path, Some(PathBuf::from("/tmp/fork"))); + } + other => panic!("unexpected action: {other:?}"), + } + } + #[tokio::test] async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui_app_server/src/resume_picker.rs b/codex-rs/tui_app_server/src/resume_picker.rs index b1dab17f5..488f15573 100644 --- a/codex-rs/tui_app_server/src/resume_picker.rs +++ b/codex-rs/tui_app_server/src/resume_picker.rs @@ -585,46 +585,63 @@ impl PickerState { self.requester.schedule_frame(); } + async fn selection_for_current_row( + &mut self, + action: SessionPickerAction, + ) -> Result> { + if let Some(row) = self.filtered_rows.get(self.selected) { + let path = row.path.clone(); + let thread_id = match row.thread_id { + Some(thread_id) => Some(thread_id), + None => match path.as_ref() { + Some(path) => { + crate::resolve_session_thread_id( + path.as_path(), + /*id_str_if_uuid*/ None, + ) + .await + } + None => None, + }, + }; + if let Some(thread_id) = thread_id { + return Ok(Some(action.selection(path, thread_id))); + } + self.inline_error = Some(match path { + Some(path) => { + format!("Failed to read session metadata from {}", path.display()) + } + None => String::from("Failed to read session metadata from selected session"), + }); + self.request_frame(); + } + Ok(None) + } + async fn handle_key(&mut self, key: KeyEvent) -> Result> { self.inline_error = None; match key.code { KeyCode::Esc => return Ok(Some(SessionSelection::StartFresh)), - KeyCode::Char('c') + KeyCode::Char(c) if key .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) => + .contains(crossterm::event::KeyModifiers::CONTROL) + && c.eq_ignore_ascii_case(&'c') => { return Ok(Some(SessionSelection::Exit)); } + KeyCode::Char(c) + if key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + && c.eq_ignore_ascii_case(&'f') => + { + return self + .selection_for_current_row(SessionPickerAction::Fork) + .await; + } KeyCode::Enter => { - if let Some(row) = self.filtered_rows.get(self.selected) { - let path = row.path.clone(); - let thread_id = match row.thread_id { - Some(thread_id) => Some(thread_id), - None => match path.as_ref() { - Some(path) => { - crate::resolve_session_thread_id( - path.as_path(), - /*id_str_if_uuid*/ None, - ) - .await - } - None => None, - }, - }; - if let Some(thread_id) = thread_id { - return Ok(Some(self.action.selection(path, thread_id))); - } - self.inline_error = Some(match path { - Some(path) => { - format!("Failed to read session metadata from {}", path.display()) - } - None => { - String::from("Failed to read session metadata from selected session") - } - }); - self.request_frame(); - } + return self.selection_for_current_row(self.action).await; } KeyCode::Up => { if self.selected > 0 { @@ -1166,9 +1183,16 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { // Hint line let action_label = state.action.action_label(); - let hint_line: Line = vec![ + let mut hint_line: Vec> = vec![ key_hint::plain(KeyCode::Enter).into(), format!(" to {action_label} ").dim(), + ]; + if matches!(state.action, SessionPickerAction::Resume) { + hint_line.push(" ".dim()); + hint_line.push(key_hint::ctrl(KeyCode::Char('f')).into()); + hint_line.push(" to fork ".dim()); + } + hint_line.extend([ " ".dim(), key_hint::plain(KeyCode::Esc).into(), " to start new ".dim(), @@ -1183,9 +1207,8 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { "/".dim(), key_hint::plain(KeyCode::Down).into(), " to browse".dim(), - ] - .into(); - frame.render_widget_ref(hint_line, hint); + ]); + frame.render_widget_ref(Line::from(hint_line), hint); }) } @@ -1980,6 +2003,83 @@ mod tests { assert_snapshot!("resume_picker_search_error", snapshot); } + #[tokio::test] + async fn ctrl_f_forks_selected_row_in_resume_mode() { + let loader: PageLoader = Arc::new(|_| {}); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + true, + None, + SessionPickerAction::Resume, + ); + let thread_id = ThreadId::new(); + let row = Row { + path: Some(PathBuf::from("/tmp/fork-target.jsonl")), + preview: String::from("fork target"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }; + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)) + .await + .expect("ctrl+f should not abort the picker"); + + match selection { + Some(SessionSelection::Fork(SessionTarget { + path, + thread_id: selected_thread_id, + })) => { + assert_eq!(path, Some(PathBuf::from("/tmp/fork-target.jsonl"))); + assert_eq!(selected_thread_id, thread_id); + } + other => panic!("unexpected selection: {other:?}"), + } + } + + #[test] + fn resume_picker_hint_mentions_ctrl_f_to_fork() { + let loader: PageLoader = Arc::new(|_| {}); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + true, + None, + SessionPickerAction::Resume, + ); + state.all_rows = vec![Row { + path: Some(PathBuf::from("/tmp/visible.jsonl")), + preview: String::from("visible row"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + state.filtered_rows = state.all_rows.clone(); + + let width: u16 = 160; + let height: u16 = 5; + let rendered = render_picker_for_test(&state, width, height); + assert!( + rendered.contains("ctrl + f"), + "rendered picker:\n{rendered}" + ); + assert!(rendered.contains("to fork"), "rendered picker:\n{rendered}"); + } + // TODO(jif) fix // #[tokio::test] // async fn resume_picker_screen_snapshot() { @@ -2250,6 +2350,74 @@ mod tests { assert_snapshot!("resume_picker_thread_names", snapshot); } + fn render_picker_for_test(state: &PickerState, width: u16, height: u16) -> String { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + let [header, search, columns, list, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(4)), + Constraint::Length(1), + ]) + .areas(area); + + let header_line: Line = vec![ + state.action.title().bold().cyan(), + " ".into(), + "Sort:".dim(), + " ".into(), + sort_key_label(state.sort_key).magenta(), + ] + .into(); + frame.render_widget_ref(header_line, header); + frame.render_widget_ref(search_line(state), search); + + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + render_column_headers(&mut frame, columns, &metrics, state.sort_key); + render_list(&mut frame, list, state, &metrics); + + let action_label = state.action.action_label(); + let mut hint_line: Vec> = vec![ + key_hint::plain(KeyCode::Enter).into(), + format!(" to {action_label} ").dim(), + ]; + if matches!(state.action, SessionPickerAction::Resume) { + hint_line.push(" ".dim()); + hint_line.push(key_hint::ctrl(KeyCode::Char('f')).into()); + hint_line.push(" to fork ".dim()); + } + hint_line.extend([ + " ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " to start new ".dim(), + " ".dim(), + key_hint::ctrl(KeyCode::Char('c')).into(), + " to quit ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Tab).into(), + " to toggle sort ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Up).into(), + "/".dim(), + key_hint::plain(KeyCode::Down).into(), + " to browse".dim(), + ]); + frame.render_widget_ref(Line::from(hint_line), hint); + } + + terminal.flush().expect("flush"); + terminal.backend().to_string() + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); diff --git a/codex-rs/tui_app_server/src/version.rs b/codex-rs/tui_app_server/src/version.rs index 8c8d108dc..20d2eff59 100644 --- a/codex-rs/tui_app_server/src/version.rs +++ b/codex-rs/tui_app_server/src/version.rs @@ -1,2 +1,16 @@ /// The current Codex CLI version as embedded at compile time. pub const CODEX_CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const CODEX_CLI_DISPLAY_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (local.1)"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_version_includes_local_suffix() { + assert_eq!( + CODEX_CLI_DISPLAY_VERSION, + concat!(env!("CARGO_PKG_VERSION"), " (local.1)") + ); + } +}